diff --git a/.github/workflows/loop-tools-gate.yml b/.github/workflows/loop-tools-gate.yml index 1fef9cff8d..d9d1baf61a 100644 --- a/.github/workflows/loop-tools-gate.yml +++ b/.github/workflows/loop-tools-gate.yml @@ -64,6 +64,13 @@ jobs: # workflow produced (#2327). Pure Python, no compiler, no corpus. run: python3 scripts/ci/test_damage_repair_snapshot_required.py + - name: An empty match is not a number + # #3067. `tri wave` printed "lesson last -> next 1" against a 242 KB + # document whose own last wave loop is 898, because the matcher found + # nothing and empty-plus-exit-0 is not a failure. Runs here: no compiler + # is needed, and the test builds its own two-document fixture. + run: python3 scripts/ci/test_an_empty_match_is_not_a_number.py + - name: An absent compiler is "could not run", not "the check failed" # #3045. This job has no compiler, which is what makes it the right # place: the assertion is about the no-build path and nowhere else can diff --git a/docs/now/2026-09-04-an-empty-match-is-not-a-number.md b/docs/now/2026-09-04-an-empty-match-is-not-a-number.md new file mode 100644 index 0000000000..34cb765d4b --- /dev/null +++ b/docs/now/2026-09-04-an-empty-match-is-not-a-number.md @@ -0,0 +1,26 @@ +# NOW -- An empty match is not a number (2026-09-04) + +## `tri wave` said the next lesson is 1; the document's last one is 898 + +- `.claude/skills/t27-wave-loop.md` is 242 KB and present, its worked examples + are headed `## Worked example -- Wave Loop 898`, and + `grep -cE '^\*\*[0-9]+\.'` on it is **0**. The format moved past the matcher. +- `last_lesson_no` tested that the file EXISTS and then returned whatever the + matcher produced. Empty output with exit 0 is not a failure, so + `$(last_lesson_no || echo '-')` never substituted the `-`, and + `$(( "" + 1 ))` is 1. +- The write path took the same route. `tri lesson` carries the guard written for + this -- `no="$(last_lesson_no)" || die "no numbered lessons found"` -- and + `bash -x` shows `no=` then `next=1` with the die SKIPPED. Nothing was written + only because an unrelated downstream step failed on the same empty anchor. +- Both readers now return 1 when the matcher produced no number, so the `-` and + the `die` work again, and `tri wave` says `UNREADABLE -- no line matched in + ` rather than inventing a number. +- `theorem` is fixed the same way although it reads correctly today: the two + halves of one display should not differ in whether they can lie. +- Not done, deliberately: widening the matcher to the `## … Wave Loop N` form. + Which heading is canonical belongs to whoever owns the document; saying "I + cannot read this" has to land first either way. +- Two mutants: the old reader kills the three defect assertions, a reader that + always fails kills all three CONTROLS -- a document that does carry lessons + must still be read and written, or the test cannot fail. diff --git a/scripts/ci/test_an_empty_match_is_not_a_number.py b/scripts/ci/test_an_empty_match_is_not_a_number.py new file mode 100644 index 0000000000..6a97aaa6d1 --- /dev/null +++ b/scripts/ci/test_an_empty_match_is_not_a_number.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""#3067: a matcher that matched nothing is not a reading. + +`last_lesson_no` tested that the file EXISTS and then returned whatever the +matcher produced -- empty, with exit 0, when the file was present and the format +had moved past the pattern. So `$(last_lesson_no || echo '-')` never substituted +the '-', and `no="$(last_lesson_no)" || die` never died. + +Measured on the real document: `.claude/skills/t27-wave-loop.md` is 242 KB and +present, its worked examples are headed `## Worked example -- Wave Loop 898`, +and `grep -cE '^\\*\\*[0-9]+\\.'` on it is 0. `tri wave` printed + + lesson last -> next 1 + +where the document's own last wave loop is 898. `tri lesson` reached `next=1` +the same way -- traced with `bash -x` -- and wrote nothing only because an +unrelated downstream step failed on the same empty anchor. + +Each assertion has a control: a document that DOES carry lessons must still be +read and written correctly, or this file cannot fail. +""" + +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +FAILURES = [] + +NO_LESSONS = """# Wave loop + +## Worked example -- Wave Loop 898 + +Body text, with no `**N.` lesson line anywhere. +""" + +HAS_LESSONS = """# Wave loop + +**41. An existing lesson.** body + +## Next heading + +more +""" + + +def check(name, ok, detail=""): + print(f" {'ok ' if ok else 'FAILED '}{name}") + if not ok: + FAILURES.append(f"{name}: {detail}") + + +def tree(doc): + d = tempfile.mkdtemp(prefix=f"tri-wave-{os.getpid()}-") + shutil.copytree(REPO / "scripts", os.path.join(d, "scripts")) + os.makedirs(os.path.join(d, ".claude/skills")) + with open(os.path.join(d, ".claude/skills/t27-wave-loop.md"), "w") as f: + f.write(doc) + subprocess.run(["git", "init", "-q", "."], cwd=d, capture_output=True) + return d + + +def tri(d, *args): + return subprocess.run(["bash", os.path.join(d, "scripts", "tri"), *args], + capture_output=True, text=True, cwd=d) + + +def main(): + d = tree(NO_LESSONS) + try: + w = tri(d, "wave") + check("a matcher that matched nothing does not become a number", + "next 1" not in w.stdout, f"stdout={w.stdout!r}") + check("and says the line is unreadable, naming the file", + "UNREADABLE" in w.stdout and "t27-wave-loop.md" in w.stdout, f"stdout={w.stdout!r}") + + l = tri(d, "lesson", "A brand new lesson", "with a body") + check("tri lesson refuses instead of numbering from an empty match", + l.returncode != 0, f"rc={l.returncode} out={(l.stdout+l.stderr)!r}") + check("and the refusal is the one written for it", + "no numbered lessons found" in (l.stdout + l.stderr), f"out={(l.stdout+l.stderr)!r}") + doc = Path(d, ".claude/skills/t27-wave-loop.md").read_text() + check("and nothing was written", "**1." not in doc, "a lesson numbered 1 was inserted") + finally: + shutil.rmtree(d, ignore_errors=True) + + # CONTROLS. Without these, a `last_lesson_no` that always fails passes above. + d = tree(HAS_LESSONS) + try: + w = tri(d, "wave") + check("control: a document with lessons is still read", + "last 41 -> next 42" in w.stdout, f"stdout={w.stdout!r}") + l = tri(d, "lesson", "A brand new lesson", "with a body") + check("control: and a lesson is still written, with the next number", + l.returncode == 0, f"rc={l.returncode} out={(l.stdout+l.stderr)!r}") + doc = Path(d, ".claude/skills/t27-wave-loop.md").read_text() + check("control: numbered 42, not 1", "**42. A brand new lesson.**" in doc, f"doc={doc!r}") + finally: + shutil.rmtree(d, ignore_errors=True) + + print() + if FAILURES: + print("FAILED:") + for f in FAILURES: + print(f" - {f}") + return 1 + print("ok: an empty match refuses; a real one still reads and writes.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tri b/scripts/tri index 01b9daa26f..fd6ef85b93 100755 --- a/scripts/tri +++ b/scripts/tri @@ -80,14 +80,38 @@ lesson_numbers() { grep -oE '^\*\*[0-9]+\.[[:space:]]' "$WAVE_SKILL" 2>/dev/null # theorems: T709, T709a, T709b are three distinct entries, not a triple collision. theorem_tokens() { grep -oE '^#{2,4} T[0-9]+[a-z]*' "$THEORY_DOC" | awk '{ print $2 }'; } +# AN EMPTY MATCH IS NOT A NUMBER. +# +# These returned empty-and-exit-0 when the file existed and the matcher found +# nothing, so `$(last_lesson_no || echo '-')` never substituted the '-' and +# `no="$(last_lesson_no)" || die` never died. Measured on the real document: +# `.claude/skills/t27-wave-loop.md` is 242 KB and present, its worked examples +# are headed `## Worked example -- Wave Loop 898`, and `grep -cE '^\*\*[0-9]+\.'` +# on it is **0**. `tri wave` therefore printed +# +# lesson last -> next 1 +# +# where the document's own last wave loop is 898. `tri lesson` took the same +# path to `next=1` -- traced with `bash -x`: `no=` then `next=1`, with the die +# skipped -- and wrote nothing only because an unrelated downstream step failed +# on the same empty anchor. The guard that exists for this could not fire. +# +# The `-f` test only catches an ABSENT file. A present file whose format has +# moved past the matcher is the case that actually happened. last_lesson_no() { [ -f "$WAVE_SKILL" ] || return 1 - lesson_numbers | sort -n | tail -1 + local n + n="$(lesson_numbers | sort -n | tail -1)" + [ -n "$n" ] || return 1 + printf '%s\n' "$n" } last_theorem_no() { [ -f "$THEORY_DOC" ] || return 1 - theorem_tokens | tr -dc '0-9\n' | sort -n | tail -1 + local n + n="$(theorem_tokens | tr -dc '0-9\n' | sort -n | tail -1)" + [ -n "$n" ] || return 1 + printf '%s\n' "$n" } origin_slug() { @@ -138,10 +162,16 @@ case "$cmd" in printf 'branch %s @ %s (%s dirty)\n' "$branch" "$head" "$dirty" printf 'disk %s GiB free (floor %s GiB)%s\n' "$free" "$DISK_FLOOR_GIB" \ "$(awk -v f="$free" -v fl="$DISK_FLOOR_GIB" 'BEGIN { print (f + 0 < fl + 0) ? " <<< BELOW FLOOR" : "" }')" - printf 'lesson last %s -> next %s\n' "$lesson" \ - "$([ "$lesson" = '-' ] && echo '-' || echo $((lesson + 1)))" - printf 'theorem last T%s -> next T%s\n' "$theorem" \ - "$([ "$theorem" = '-' ] && echo '-' || echo $((theorem + 1)))" + if [ "$lesson" = '-' ]; then + printf 'lesson UNREADABLE -- no line matched in %s\n' "${WAVE_SKILL#"$REPO_ROOT"/}" + else + printf 'lesson last %s -> next %s\n' "$lesson" "$((lesson + 1))" + fi + if [ "$theorem" = '-' ]; then + printf 'theorem UNREADABLE -- no heading matched in %s\n' "${THEORY_DOC#"$REPO_ROOT"/}" + else + printf 'theorem last T%s -> next T%s\n' "$theorem" "$((theorem + 1))" + fi printf 't27c %s\n' "${t27c_path:-not built (compiler commands unavailable)}" exit 0 ;;