mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-07-17 16:34:48 -05:00
The patch-application harness at book/tools/scripts/testing/apply_fix.sh
previously snapshotted pre/post violation counts using the now-removed
\`check rendering\` group and \`rendering --scope mitpress-*\` style-
rule scopes. Update to the semantic-group equivalents:
check rendering --path X → check all --path X
(broader coverage; the old rendering
group was a subset of what all covers)
rendering --scope mitpress-* → <new-group> --scope <scope>
per the commit 2 migration map
The 8 style-specific scope checks at the bottom of the script now use
a `group:scope` pair array for readability:
punctuation:vs-period (was mitpress-vs-period)
numbers:percent-in-captions (was mitpress-percent-in-captions)
punctuation:emdash (was mitpress-spaced-emdash)
punctuation:eg-ie-comma (was mitpress-eg-ie-comma)
refs:capitalized (was mitpress-capitalized-refs)
prose:above-below (was mitpress-above-below)
punctuation:hyphen-range (was mitpress-hyphen-range)
prose:acknowledgements (was mitpress-acknowledgements)
No other stale references to old scope paths remain anywhere (grepped
across .claude, book/, *.sh, *.py, *.md, *.yaml). The hook IDs in
.pre-commit-config.yaml (e.g. \`mitpress-vs-period\`) are intentionally
unchanged — hook IDs are a contract with CI and contributor git hooks;
ID renames are a separate follow-up.
Concludes the check-taxonomy refactor. Summary:
commit 1 — introduce 9 semantic check groups (additive)
commit 2 — migrate 26 pre-commit entries to semantic groups
commit 3 — remove `rendering` group; delete add_check.py
commit 4 — update apply_fix.sh test harness (this commit)
Final state: ./book/binder check --list shows 9 purpose-built groups in
place of the 28-scope rendering bucket. All pre-commit hooks pass
against baseline (bib-lint chain-run transient unrelated to refactor).
281 lines
9.8 KiB
Bash
Executable File
281 lines
9.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# apply_fix.sh
|
|
# Single-fix wrapper for the MLSysBook camera-ready overnight sweep.
|
|
#
|
|
# Usage:
|
|
# apply_fix.sh --chapter <chapter-name> --vol <vol1|vol2> --format <html|pdf> \
|
|
# --issue-code <code> --summary <one-line> \
|
|
# --diagnosis <multi-line-string-or-@file> \
|
|
# --patch-file <abs-path-to-patch>
|
|
#
|
|
# Required environment variables (must be set by orchestrator):
|
|
# RUN_TS, WORKTREE, RUN_DIR
|
|
#
|
|
# Exit codes:
|
|
# 0 success — patch applied and committed
|
|
# 2 rejected — patch moved to ${RUN_DIR}/fixes/suggested/<id>.patch
|
|
# 3 invalid arguments
|
|
# 4 prerequisite failure (e.g. binder check missing)
|
|
# 99 forbidden remote-op pattern detected in this script
|
|
|
|
set -uo pipefail
|
|
|
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
# shellcheck disable=SC1091
|
|
source "${SELF_DIR}/camera_ready_sweep.sh"
|
|
assert_no_remote_ops "$0"
|
|
|
|
# ---------------------- argument parsing -------------------------------------
|
|
CHAPTER=""
|
|
VOL=""
|
|
FMT=""
|
|
ISSUE_CODE=""
|
|
SUMMARY=""
|
|
DIAGNOSIS=""
|
|
PATCH_FILE=""
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--chapter) CHAPTER="$2"; shift 2 ;;
|
|
--vol) VOL="$2"; shift 2 ;;
|
|
--format) FMT="$2"; shift 2 ;;
|
|
--issue-code) ISSUE_CODE="$2"; shift 2 ;;
|
|
--summary) SUMMARY="$2"; shift 2 ;;
|
|
--diagnosis) DIAGNOSIS="$2"; shift 2 ;;
|
|
--patch-file) PATCH_FILE="$2"; shift 2 ;;
|
|
*) echo "apply_fix.sh: unknown arg $1" >&2; exit 3 ;;
|
|
esac
|
|
done
|
|
|
|
for v in CHAPTER VOL FMT ISSUE_CODE SUMMARY PATCH_FILE; do
|
|
if [ -z "${!v}" ]; then
|
|
echo "apply_fix.sh: missing required --${v,,}" >&2
|
|
exit 3
|
|
fi
|
|
done
|
|
case "${VOL}" in vol1|vol2) ;; *) echo "bad --vol ${VOL}" >&2; exit 3 ;; esac
|
|
case "${FMT}" in html|pdf) ;; *) echo "bad --format ${FMT}" >&2; exit 3 ;; esac
|
|
[ -f "${PATCH_FILE}" ] || { echo "missing patch file ${PATCH_FILE}" >&2; exit 3; }
|
|
for v in RUN_TS WORKTREE RUN_DIR; do
|
|
if [ -z "${!v:-}" ]; then echo "env var ${v} required" >&2; exit 3; fi
|
|
done
|
|
|
|
# Resolve diagnosis: support @file shorthand
|
|
if [[ "${DIAGNOSIS}" == @* ]]; then
|
|
DIAGNOSIS_FILE="${DIAGNOSIS#@}"
|
|
[ -f "${DIAGNOSIS_FILE}" ] || { echo "missing diagnosis file ${DIAGNOSIS_FILE}" >&2; exit 3; }
|
|
DIAGNOSIS="$(cat "${DIAGNOSIS_FILE}")"
|
|
fi
|
|
|
|
cd "${WORKTREE}"
|
|
|
|
PATCH_ID="$(basename "${PATCH_FILE}" .patch)-$(date +%s)"
|
|
SUGGESTED_DEST="${RUN_DIR}/fixes/suggested/${PATCH_ID}.patch"
|
|
APPLIED_DIR="${RUN_DIR}/fixes/applied"
|
|
PER_FIX_LOG="${RUN_DIR}/logs/per-fix/${PATCH_ID}.log"
|
|
|
|
reject_to_suggested() {
|
|
local reason="$1"
|
|
mkdir -p "$(dirname "${SUGGESTED_DEST}")"
|
|
cp "${PATCH_FILE}" "${SUGGESTED_DEST}"
|
|
log_health "{\"event\":\"fix_reverted\",\"reason\":\"${reason//\"/\\\"}\",\"chapter\":\"${CHAPTER}\",\"issue_code\":\"${ISSUE_CODE}\",\"patch_id\":\"${PATCH_ID}\"}"
|
|
echo "REJECTED: ${reason}" | tee -a "${PER_FIX_LOG}"
|
|
exit 2
|
|
}
|
|
|
|
mkdir -p "${RUN_DIR}/logs/per-fix" "${APPLIED_DIR}" "$(dirname "${SUGGESTED_DEST}")"
|
|
echo "=== apply_fix.sh ${PATCH_ID} ===" > "${PER_FIX_LOG}"
|
|
echo "chapter=${CHAPTER} vol=${VOL} fmt=${FMT} issue=${ISSUE_CODE}" >> "${PER_FIX_LOG}"
|
|
|
|
# 2) git apply --check (dry run)
|
|
if ! git apply --check "${PATCH_FILE}" >>"${PER_FIX_LOG}" 2>&1; then
|
|
reject_to_suggested "git_apply_check_failed"
|
|
fi
|
|
|
|
# 3) Forbidden-paths gate
|
|
FORBIDDEN_REGEXES=(
|
|
'^_quarto.*\.yml$'
|
|
'^_metadata\.yml$'
|
|
'^book/quarto/config/'
|
|
'\.bib$'
|
|
'^book/cli/'
|
|
'^book/tools/'
|
|
'^book/vscode-ext/'
|
|
'^\.cursor/'
|
|
'^\.github/'
|
|
'^Makefile$'
|
|
'\.png$'
|
|
'\.jpg$'
|
|
'\.jpeg$'
|
|
'\.svg$'
|
|
'\.pdf$'
|
|
'\.gif$'
|
|
'\.webp$'
|
|
'^\.claude/rules/'
|
|
)
|
|
|
|
CHANGED_FILES="$(git apply --numstat "${PATCH_FILE}" 2>>"${PER_FIX_LOG}" | awk '{print $3}')"
|
|
for cf in ${CHANGED_FILES}; do
|
|
for rx in "${FORBIDDEN_REGEXES[@]}"; do
|
|
if printf '%s' "${cf}" | grep -E -q "${rx}"; then
|
|
reject_to_suggested "forbidden_path:${cf}:matches:${rx}"
|
|
fi
|
|
done
|
|
done
|
|
|
|
# 4) Size cap: total touched lines <= 40, file count <= 3
|
|
SIZE_TOTAL="$(git apply --numstat "${PATCH_FILE}" | awk '{adds+=$1; dels+=$2}END{print adds+dels}')"
|
|
FILE_COUNT="$(printf '%s\n' "${CHANGED_FILES}" | sed '/^$/d' | wc -l | tr -d ' ')"
|
|
if [ "${SIZE_TOTAL}" -gt 40 ]; then
|
|
reject_to_suggested "patch_too_large:${SIZE_TOTAL}_lines"
|
|
fi
|
|
if [ "${FILE_COUNT}" -gt 3 ]; then
|
|
reject_to_suggested "too_many_files:${FILE_COUNT}"
|
|
fi
|
|
|
|
# 5) Chapter cap: at most 5 fix(<chapter>) commits on this branch
|
|
EXISTING_COMMITS="$(git log --oneline "camera-ready/${RUN_TS}" 2>/dev/null \
|
|
| awk -v c="fix(${CHAPTER})" 'index($2,c)==1' | wc -l | tr -d ' ' || echo 0)"
|
|
if [ "${EXISTING_COMMITS}" -ge 5 ]; then
|
|
reject_to_suggested "chapter_cap_reached:${EXISTING_COMMITS}"
|
|
fi
|
|
|
|
# Resolve chapter qmd path (best effort)
|
|
CHAPTER_QMD="$(find "${WORKTREE}/book/quarto/contents/${VOL}" -type f -name "${CHAPTER}.qmd" | head -1)"
|
|
if [ -z "${CHAPTER_QMD}" ]; then
|
|
CHAPTER_QMD="$(find "${WORKTREE}/book/quarto/contents/${VOL}" -type f -name '*.qmd' -path "*${CHAPTER}*" | head -1)"
|
|
fi
|
|
|
|
# Snapshot pre-apply violation counts (for delta detection)
|
|
snapshot_violations() {
|
|
local out="$1"
|
|
shift
|
|
( cd "${WORKTREE}/book" && ./binder check "$@" --json 2>/dev/null | \
|
|
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('total_issues',0))" ) > "${out}" 2>/dev/null \
|
|
|| echo 0 > "${out}"
|
|
}
|
|
|
|
PRE_REND="${PER_FIX_LOG}.pre-rend"
|
|
PRE_REFS="${PER_FIX_LOG}.pre-refs"
|
|
PRE_MITPRESS="${PER_FIX_LOG}.pre-mitpress"
|
|
# Post-refactor: the old `rendering` umbrella group was dissolved into semantic
|
|
# groups (markup, prose, punctuation, numbers, math, structure, code, tables,
|
|
# index). `check all` is the broadest equivalent and catches everything the old
|
|
# `rendering` call did plus more.
|
|
[ -n "${CHAPTER_QMD}" ] && snapshot_violations "${PRE_REND}" all --path "${CHAPTER_QMD}"
|
|
if grep -E -q '^\+.*@[a-zA-Z]' "${PATCH_FILE}" || grep -E -q '^\+.*\\cite' "${PATCH_FILE}"; then
|
|
TOUCHED_REFS=1
|
|
[ -n "${CHAPTER_QMD}" ] && snapshot_violations "${PRE_REFS}" refs --path "${CHAPTER_QMD}"
|
|
else
|
|
TOUCHED_REFS=0
|
|
fi
|
|
[ -n "${CHAPTER_QMD}" ] && snapshot_violations "${PRE_MITPRESS}" punctuation --scope vs-period --path "${CHAPTER_QMD}"
|
|
|
|
# 6) Apply patch
|
|
if ! git apply "${PATCH_FILE}" >>"${PER_FIX_LOG}" 2>&1; then
|
|
reject_to_suggested "git_apply_failed_after_check"
|
|
fi
|
|
|
|
revert_patch() {
|
|
git apply -R "${PATCH_FILE}" >>"${PER_FIX_LOG}" 2>&1 || true
|
|
}
|
|
|
|
# 7) Post-apply rendering & refs checks vs snapshots
|
|
post_check_or_revert() {
|
|
local label="$1" pre="$2"; shift 2
|
|
if [ -z "${CHAPTER_QMD}" ] || [ ! -f "${pre}" ]; then return 0; fi
|
|
local POST="${PER_FIX_LOG}.post-${label}"
|
|
snapshot_violations "${POST}" "$@"
|
|
local pre_n post_n
|
|
pre_n="$(cat "${pre}")"
|
|
post_n="$(cat "${POST}")"
|
|
if [ "${post_n}" -gt "${pre_n}" ]; then
|
|
revert_patch
|
|
reject_to_suggested "new_${label}_violations:${pre_n}->${post_n}"
|
|
fi
|
|
}
|
|
post_check_or_revert all "${PRE_REND}" all --path "${CHAPTER_QMD}"
|
|
[ "${TOUCHED_REFS}" -eq 1 ] && post_check_or_revert refs "${PRE_REFS}" refs --path "${CHAPTER_QMD}"
|
|
|
|
# 8) Style-specific scope checks (post-refactor: scopes live in semantic groups,
|
|
# no longer under `rendering --scope mitpress-*`).
|
|
# group:scope pairs — one per style rule that was previously mitpress-*
|
|
for pair in \
|
|
"punctuation:vs-period" \
|
|
"numbers:percent-in-captions" \
|
|
"punctuation:emdash" \
|
|
"punctuation:eg-ie-comma" \
|
|
"refs:capitalized" \
|
|
"prose:above-below" \
|
|
"punctuation:hyphen-range" \
|
|
"prose:acknowledgements"; do
|
|
group="${pair%%:*}"
|
|
scope="${pair#*:}"
|
|
PRE="${PER_FIX_LOG}.pre-${group}-${scope}"
|
|
POST="${PER_FIX_LOG}.post-${group}-${scope}"
|
|
[ -n "${CHAPTER_QMD}" ] || continue
|
|
snapshot_violations "${PRE}" "${group}" --scope "${scope}" --path "${CHAPTER_QMD}" 2>/dev/null
|
|
snapshot_violations "${POST}" "${group}" --scope "${scope}" --path "${CHAPTER_QMD}" 2>/dev/null
|
|
pre_n="$(cat "${PRE}" 2>/dev/null || echo 0)"
|
|
post_n="$(cat "${POST}" 2>/dev/null || echo 0)"
|
|
if [ "${post_n}" -gt "${pre_n}" ]; then
|
|
revert_patch
|
|
reject_to_suggested "new_${group}_${scope}_violations:${pre_n}->${post_n}"
|
|
fi
|
|
done
|
|
|
|
# 9) Rebuild the chapter in target format
|
|
case "${VOL}" in
|
|
vol1) VOL_FLAG="--vol1" ;;
|
|
vol2) VOL_FLAG="--vol2" ;;
|
|
esac
|
|
( cd "${WORKTREE}/book" && ./binder build "${FMT}" "${CHAPTER}" "${VOL_FLAG}" >>"${PER_FIX_LOG}" 2>&1 )
|
|
BUILD_RC=$?
|
|
if [ "${BUILD_RC}" -ne 0 ]; then
|
|
revert_patch
|
|
reject_to_suggested "build_failed:${FMT}:rc=${BUILD_RC}"
|
|
fi
|
|
|
|
# 10) Post-edit Gemini math re-validation
|
|
if grep -E -q '^\+.*(\$\$|\$[^$]+\$)' "${PATCH_FILE}"; then
|
|
AFFECTED_LINES="$(grep -nE '\$\$|\$[^$]+\$' "${CHAPTER_QMD}" | awk -F: '{print $1}' | tr '\n' ',' | sed 's/,$//')"
|
|
if [ -n "${AFFECTED_LINES}" ]; then
|
|
if ! python3 "${SELF_DIR}/gemini_math_check.py" revalidate \
|
|
--chapter-qmd "${CHAPTER_QMD}" \
|
|
--equation-line-numbers "${AFFECTED_LINES}" >>"${PER_FIX_LOG}" 2>&1; then
|
|
revert_patch
|
|
reject_to_suggested "gemini_math_revalidation_failed"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# 11) Commit
|
|
COMMIT_MSG_FILE="$(mktemp)"
|
|
{
|
|
printf "fix(%s): %s — %s\n\n" "${CHAPTER}" "${ISSUE_CODE}" "${SUMMARY}"
|
|
printf "Volume: %s\nFormat: %s\nIssue-Code: %s\nRun: %s\nPatch-Id: %s\n\n" \
|
|
"${VOL}" "${FMT}" "${ISSUE_CODE}" "${RUN_TS}" "${PATCH_ID}"
|
|
printf "Diagnosis:\n%s\n\n" "${DIAGNOSIS}"
|
|
printf "Authored by: apply_fix.sh (camera-ready overnight sweep)\nLocal-only commit; never pushed.\n"
|
|
} > "${COMMIT_MSG_FILE}"
|
|
|
|
git add -A >>"${PER_FIX_LOG}" 2>&1
|
|
if ! git commit -F "${COMMIT_MSG_FILE}" >>"${PER_FIX_LOG}" 2>&1; then
|
|
revert_patch
|
|
rm -f "${COMMIT_MSG_FILE}"
|
|
reject_to_suggested "git_commit_failed"
|
|
fi
|
|
rm -f "${COMMIT_MSG_FILE}"
|
|
|
|
NEW_SHA="$(git rev-parse HEAD)"
|
|
|
|
# 12) Save the committed patch
|
|
git format-patch -1 HEAD --stdout > "${APPLIED_DIR}/${NEW_SHA}.patch" 2>>"${PER_FIX_LOG}"
|
|
|
|
# 13) Append health entry
|
|
log_health "{\"event\":\"fix_applied\",\"sha\":\"${NEW_SHA}\",\"chapter\":\"${CHAPTER}\",\"vol\":\"${VOL}\",\"format\":\"${FMT}\",\"issue_code\":\"${ISSUE_CODE}\",\"patch_id\":\"${PATCH_ID}\"}"
|
|
|
|
# 14) Print the SHA on stdout
|
|
echo "${NEW_SHA}"
|
|
exit 0
|