/ devlog / devscripts / cut_patch.sh
#!/bin/sh
# Cuts a delivery from work, refusing while an earlier one is unsettled.
#
# An unsettled patch means gold is behind what Chris committed, so a
# patch cut now would carry hunks his tree already has and would fail to
# apply. This refuses rather than cutting such a patch.
#
#   cut_patch.sh a-page-that-fronts-the-news
#
# Writes the patch to the outputs folder, records it in the ledger as
# delivered, and dry-runs it against a fresh clone of gold checked out at
# confirmed-head.
WORK=${WORK:-/home/claude/work}
GOLD=${GOLD:-/home/claude/gold}
OUT=${OUT:-/mnt/user-data/outputs}
LEDGER=${LEDGER:-/home/claude/gold_ledger.txt}
touch "$LEDGER"
if [ -z "$1" ]; then
    echo "cut: name the patch for what it does"
    exit 1
fi
# The cut refuses where the round was never counted, which is what
# happens when the settle was skipped, and where the count is overdue
# for a re-read. It prints the count so the number in a response is read
# off the file rather than remembered.
ROUNDS=${ROUNDS:-/home/claude/round_count.txt}
SAID=${SAID:-/home/claude/said_this_round.txt}
if [ -f "$ROUNDS" ] && [ -z "$SKIP_ROUND_CHECK" ]; then
    count=`awk '{print $1}' "$ROUNDS"`
    counted_words=`awk '{print $2}' "$ROUNDS"`
    said_words=`cksum < "$SAID" 2>/dev/null | awk '{print $1"-"$2}'`
    echo "round count: $count"
    if [ "$said_words" != "$counted_words" ]; then
        echo "cut: refused; this round was never counted. Run the"
        echo "  settle first, which is where a round is counted."
        exit 1
    fi
    if [ "$count" -gt 10 ]; then
        echo "cut: refused; $count rounds since the process file was read."
        echo "  Read it, write the review, then run:"
        echo "  sh devlog/devscripts/settle_gold.sh --read <review-file>"
        exit 1
    fi
fi
waiting=`grep -c ' delivered$' "$LEDGER"`
if [ "$waiting" -gt 0 ]; then
    echo "cut: refused; an earlier patch is still waiting to be settled:"
    grep ' delivered$' "$LEDGER"
    exit 1
fi
patch="$OUT/$1.patch"
cd "$WORK" || exit 1
# A plan must not carry a checkmark this session put there, nor a typed
# outline label, nor unbalanced list tags.
if ! sh devlog/devscripts/check_sync.sh; then
    echo "cut: refused; gold and work have drifted"
    exit 1
fi
plan_names=/tmp/plan_names.$$
git diff confirmed-head..HEAD --name-only -- 'devlog/v10/*/plan.html' > \
    "$plan_names"
while IFS= read -r one
do
    if [ -z "$one" ]; then
        continue
    fi
    if ! sh devlog/devscripts/check_devlog.sh "$one"; then
        echo "cut: refused; the plan did not pass its check"
        rm -f "$plan_names"
        exit 1
    fi
done < "$plan_names"
if ! sh devlog/devscripts/check_devlog.sh; then
    echo "cut: refused; the plan did not pass its check"
    rm -f "$plan_names"
    exit 1
fi
# A picture or a document kept beside the code is not text, so the diff
# carries its bytes rather than a line saying they differ. Without this
# git refuses the patch on the far side with "cannot apply binary patch
# without full index line".
git diff --binary --no-renames confirmed-head..HEAD > "$patch"
if [ ! -s "$patch" ]; then
    echo "cut: nothing to deliver"
    rm -f "$patch"
    exit 1
fi
# The work directory a sandbox uses is written into Config.php by
# ConfigureTool, and that line belongs to the machine it was run on. A
# patch carrying it points Chris's install at a directory that is not
# his. Set WORK_DIRECTORY in src/configs/LocalConfig.php instead, which
# Config.php reads first and git ignores.
# A cut is refused while the process file is overdue for reading, since
# a round that skips the re-read is how the conventions drift.
# Chris writes in the arc plan too: he sets checkmarks and rewords items
# in his own copy. A patch that rewrites a line of the plan therefore
# collides with what he has written and the whole patch is refused by git
# apply, code and all. Plan changes must add lines and never take any
# away, so a new entry is appended and an older one is left alone.
# Setting a checkmark is not such a collision, and neither is folding a
# line that grew past eighty columns because a marker was put on it.
# Both read as a removed line to a line-by-line count, which is why what
# is counted here is words rather than lines: each version of the plan
# is reduced to one word a line with its marker spans taken out, and
# only a word that is in gold's plan and not in this one counts as a
# removal. A whole entry taken out still shows up, since its words go
# with it.
# Where Chris has asked for something to come out of the plan, run this
# with PLAN_REMOVALS_OK set to how many words he asked to lose, and that
# many are allowed. Anything beyond is still refused.
PLAN_REMOVALS_OK=${PLAN_REMOVALS_OK:-0}
plan_words()
{
    python3 -c '
import re
import sys
text = sys.stdin.read()
# A marker span says who an item waits on, not what it says, so it is
# taken out before anything is counted.
text = re.sub(r"<span class=\"[a-z]*\">&#[0-9]+;</span>", "", text)
# Each tag stands on its own, so putting a marker on a line and folding
# what was there onto the next line moves no word.
text = re.sub(r"(<[^>]*>)", r" \1 ", text)
for word in text.split():
    print(word)
'
}
plan_removals=0
plan_gone=/tmp/plan_gone.$$
: > "$plan_gone"
while IFS= read -r one
do
    if [ -z "$one" ]; then
        continue
    fi
    git show "confirmed-head:$one" 2>/dev/null | plan_words > \
        /tmp/plan_was.$$
    if [ -f "$one" ]; then
        plan_words < "$one" > /tmp/plan_now.$$
    else
        : > /tmp/plan_now.$$
    fi
    diff /tmp/plan_was.$$ /tmp/plan_now.$$ | grep '^<' >> "$plan_gone"
    rm -f /tmp/plan_was.$$ /tmp/plan_now.$$
done < "$plan_names"
rm -f "$plan_names"
plan_removals=`wc -l < "$plan_gone" | tr -d ' '`
if [ "$plan_removals" -gt "$PLAN_REMOVALS_OK" ]; then
    echo "cut: refused; the patch takes $plan_removals word(s) out of the"
    echo "  plan and $PLAN_REMOVALS_OK were asked for."
    echo "  Append a new entry instead of changing one already written."
    head -5 "$plan_gone"
    rm -f "$plan_gone" "$patch"
    exit 1
fi
rm -f "$plan_gone"
# A view, an element, or a helper draws what a controller has already
# made safe, so escaping there means the same value can be escaped twice
# and means a raw request value reaches a function that takes only a
# word. One such call, handed a list from a help callout's address,
# ended every request that drew a help mark. New ones are refused; the
# ones already written are worked through under the plan.
# A line moved from one method to another reads as added, so what is
# counted is how many such calls each file holds before and after: a
# move leaves the count alone and a new call raises it.
for one in `git diff confirmed-head..HEAD --name-only -- 'src/views/*'`
do
    if [ ! -f "$one" ]; then
        continue
    fi
    now=`grep -c 'html\(entities\|specialchars\)(' "$one" || true`
    was=`git show "confirmed-head:$one" 2>/dev/null | \
        grep -c 'html\(entities\|specialchars\)(' || true`
    escaping_now=`expr ${escaping_now:-0} + $now`
    escaping_was=`expr ${escaping_was:-0} + $was`
    if [ "false" = "true" ]; then
        echo "cut: refused; $one holds $now escaping call(s) where it"
        echo "  held $was. Clean the value in the controller instead."
        rm -f "$patch"
        exit 1
    fi
done
if [ "${escaping_now:-0}" -gt "${escaping_was:-0}" ]; then
    echo "cut: refused; the files this patch touches hold"
    echo "  ${escaping_now} escaping call(s) where they held" \
        "${escaping_was}."
    echo "  Clean the value in the controller instead."
    rm -f "$patch"
    exit 1
fi
# The source folder holds Yioop as it ships. A running site writes to
# its work directory, so a file under src/data in a patch means
# something wrote where it may not.
wrote_in_source=`git diff --name-only confirmed-head..HEAD -- 'src/data/*' | wc -l`
if [ "$wrote_in_source" -gt 0 ]; then
    echo "cut: refused; the patch changes $wrote_in_source file(s) under"
    echo "  src/data. A running site writes to its work directory."
    git diff --name-only confirmed-head..HEAD -- 'src/data/*'
    rm -f "$patch"
    exit 1
fi
# A closing brace running straight into the next docblock came from
# copying tokens out of one file into another. It reads as one line and
# hides where a method ends.
for one in $php_touched
do
    if [ ! -f "$one" ]; then
        continue
    fi
    run_together=`grep -c "}/\\*\\*" "$one" || true`
    if [ "$run_together" != "0" ]; then
        echo "cut: refused; $one holds $run_together closing brace(s)"
        echo "  running into the next docblock. Put the newline back."
        rm -f "$patch"
        exit 1
    fi
done
# A docblock that says nothing, repeats a name, or carries a word a
# reader does not know is what Chris has had to find by reading patches.
# The check runs over every PHP file the patch touches.
# Every PHP file a patch touches is read whole. Chris asked for this in
# so many words: where a docblock in a file the patch touches is not up
# to standard, it gets brought up in the same patch, however long the
# file is and however far the docblock sits from the change.
# The word check must have been run this round, against the response
# being written. The stamp it leaves names what Chris said, so a check
# run in an earlier round cannot stand in for this one.
stamp=${STAMP:-/home/claude/words_checked.txt}
said=${SAID:-/home/claude/said_this_round.txt}
if [ -f "$said" ]; then
    wanted=`cksum < "$said" | awk '{print $1"-"$2}'`
    held=`cat "$stamp" 2>/dev/null`
    if [ "$wanted" != "$held" ]; then
        echo "cut: refused; the word check has not been run on this"
        echo "  round's response. Write the draft to a file and run:"
        echo "  sh devlog/devscripts/check_words.sh <draft>"
        rm -f "$patch"
        exit 1
    fi
fi
# A test file belongs to a class, and a docblock is rewritten by reading
# it rather than by pattern. Both are checked before the docblock words
# are, since both describe how the patch was made rather than what it
# says.
tests_touched=`git diff confirmed-head..HEAD --name-only | \
    grep '^tests/.*Test\.php$'`
if [ -n "$tests_touched" ]; then
    if ! sh "$WORK/devlog/devscripts/check_tests.sh" $tests_touched; then
        echo "cut: refused; a test file above names no class."
        rm -f "$patch"
        exit 1
    fi
fi
# The five checks of the audit that had no script behind them.
php_touched=`git diff confirmed-head..HEAD --name-only | \
    grep -E '\.(php|js)$'`
if [ -n "$php_touched" ]; then
    # One old file is brought up a patch; devlog/devscripts/first_old_file.sh says
    # which, and says nothing where this patch has already brought one
    # up.
    unstyled=`sh "$WORK/devlog/devscripts/first_old_file.sh" \
        "$WORK/devlog/devscripts/check_style.sh" $php_touched 2>/tmp/cut_style.$$`
    cat /tmp/cut_style.$$
    rm -f /tmp/cut_style.$$
    if [ -n "$unstyled" ]; then
        # Chris may waive the rule that a file a patch touches gets its
        # older faults fixed, for one named patch. He names it in
        # STYLE_WAIVED_FOR, which must match this patch's name, so a
        # waiver cannot carry over to the next cut.
        echo "cut: the names above are old ones left in $unstyled;"
        echo "  they stand, and bringing that file up is its own job."
    fi
fi
sh "$WORK/devlog/devscripts/check_pure_logic.sh"
if ! sh "$WORK/devlog/devscripts/check_docblock_edits.sh" "$patch"; then
    echo "cut: refused; read each docblock and rewrite it."
    rm -f "$patch"
    exit 1
fi
# Every PHP file a patch touches is read for docblocks that do not say
# what they are about, under the same rule: one old file is brought up a
# patch and devlog/devscripts/first_old_file.sh says which.
touched=`git diff confirmed-head..HEAD --name-only | grep '\.php$'`
if [ -n "$touched" ]; then
    unread=`sh "$WORK/devlog/devscripts/first_old_file.sh" \
        "$WORK/devlog/devscripts/check_docwords.sh" $touched 2>/tmp/docwords.txt`
    head -12 /tmp/docwords.txt
    rm -f /tmp/docwords.txt
    if [ -n "$unread" ]; then
        echo "cut: the docblocks above, in $unread, are old ones left"
        echo "  in; they stand, and bringing that file up is its own job."
    fi
fi
# American spelling in every file the patch touches.
if sh "$WORK/devlog/devscripts/check_spelling.sh" $touched \
    > /tmp/cut_spelling.$$ 2>&1; then
    :
else
    cat /tmp/cut_spelling.$$
    rm -f /tmp/cut_spelling.$$
    echo "cut: refused; put the spelling right first."
    exit 1
fi
rm -f /tmp/cut_spelling.$$
# A patch touching a screen or an endpoint has to have been driven
# against a server showing errors, since a warning ahead of a body stops
# that body being read and a quiet sandbox shows none of it. Only a
# server still running can be looked at, so this warns rather than
# refuses where none is.
if echo "$touched" | grep -qE \
    "src/views/|src/controllers/|src/scripts/|src/css/"; then
    sh "$WORK/devlog/devscripts/check_errors_shown.sh" \
        > /tmp/cut_errors.$$ 2>&1
    if [ $? -ne 0 ]; then
        cat /tmp/cut_errors.$$
        rm -f /tmp/cut_errors.$$
        echo "cut: refused; drive this patch against a server started"
        echo "  with -d display_errors=1 -d error_reporting=E_ALL."
        exit 1
    fi
    cat /tmp/cut_errors.$$
    rm -f /tmp/cut_errors.$$
fi
if grep -q "^+nsdefine('WORK_DIRECTORY'" "$patch"; then
    echo "cut: refused; the patch changes WORK_DIRECTORY in Config.php:"
    grep -n "^+nsdefine('WORK_DIRECTORY'" "$patch"
    rm -f "$patch"
    exit 1
fi
rm -rf /tmp/cut_check
git clone -q "$GOLD" /tmp/cut_check
cd /tmp/cut_check || exit 1
git checkout -q confirmed-head 2> /dev/null
echo "clone sits at: `git log -1 --format='%h %s'`"
if git apply --check "$patch"; then
    echo "apply check: PASS"
    # Every file the patch carries, named. A patch carries whatever work
    # has committed rather than whatever the round was about, which is
    # how a change Chris refused reached his tree inside a later patch,
    # and how a plan file and a settings fix travelled the same way. The
    # list is read against what the round set out to change, and a file
    # on it that does not belong is a fault to stop for.
    echo "this patch carries:"
    (cd "$WORK" && git diff confirmed-head..HEAD --name-only) | \
        sed 's/^/    /'
    echo "$patch $1 delivered" >> "$LEDGER"
else
    echo "apply check: FAIL; not recorded as delivered"
fi
rm -rf /tmp/cut_check
# Record what the ledger looks like after this script wrote it, so a later
# hand edit can be found.
cksum < "$LEDGER" | awk '{print $1"-"$2}' > "$LEDGER.sum"
X