diff --git a/.claude/commands/apply.md b/.claude/commands/apply.md index 49af6f6..b56cd6b 100644 --- a/.claude/commands/apply.md +++ b/.claude/commands/apply.md @@ -248,11 +248,15 @@ If either compile fails, fix the error and re-compile until clean. **Measure first, then look.** A visual read catches gross breakage but cannot tell you that a page is 40% empty, and the failure below survives both a clean compile and a correct page count: ```bash +python tools/verify_pdf.py cv/main__.pdf --pages 2 +python tools/verify_pdf.py cover_letters/cover__.pdf --pages 1 python tools/verify_layout.py cv/main__.pdf python tools/verify_layout.py cover_letters/cover__.pdf ``` -The script reports, per page, where the text starts and stops, bottom whitespace as a share of page height, and the largest vertical gap between lines. It exits 1 on: a hole over 100pt (~7 blank lines), a non-final page ending more than 25% early, body text colliding with the page-number footer, a final page more than 35% empty, and an entry header or section heading stranded at a page break. Page count is **not** checked here — that is `verify_pdf.py --pages`'s job, and Step 5d already runs it. +The two `--pages` lines are the page-count check: exactly 2 pages for the CV and exactly 1 for the cover letter (the hard limits in `05-cv-templates.md` and `06-cover-letter-templates.md`), exit 1 otherwise. With a custom template active, substitute its declared **Page limit** from the `ACTIVE-TEMPLATE` block. Nothing else runs this check - `verify_layout.py` deliberately leaves page count to it, and Step 5d's extraction call passes no `--pages` - so if these lines are skipped, the page budget is enforced by nothing but the visual read below. + +The layout script reports, per page, where the text starts and stops, bottom whitespace as a share of page height, and the largest vertical gap between lines. It exits 1 on: a hole over 100pt (~7 blank lines), a non-final page ending more than 25% early, body text colliding with the page-number footer, a final page more than 35% empty, and an entry header or section heading stranded at a page break. Page count is **not** checked here — that is `verify_pdf.py --pages`'s job, and the two `--pages` lines above run it. The hole check is the one a visual read misses. A moderncv `\cventry` renders as a `tabular`, so it is an **unbreakable block**: when it does not fit in the space left, the whole entry jumps to the next page and leaves a hole behind, while the document still compiles and still reports the right page count. Fix it by shortening the entry that follows the hole, not by stretching the page. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5562ed8..3e2a8ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,20 @@ per-file diff commands. ### Fixed +- **`/apply` Step 5b now actually runs the page-count check it claimed Step 5d ran** + (`.claude/commands/apply.md`, `tests/test_apply_page_count.py`) - the 5b prose said + "Page count is not checked here - that is `verify_pdf.py --pages`'s job, and Step 5d already + runs it", and `verify_layout.py`'s docstring declines to measure page count for the same + reason. Step 5d's only `verify_pdf.py` call is `--dump-text`, and no step in the workflow + passed `--pages` at all (only the upstream-only CI assertion on the stock examples does), + so the hard 2-page CV and 1-page cover letter limits were enforced by nothing but the + visual PDF read - the "measure first, then look" failure 5b was written to stop. 5b now + runs `verify_pdf.py --pages 2` on the CV and `--pages 1` on the cover letter ahead of + `verify_layout.py`, names the `ACTIVE-TEMPLATE` page limit as the substitute for a custom + template, and the deferral sentence points at those lines. Four spec tests pin the + invocations, their counts, their order relative to the layout measurement, and that no + prose defers the check to a step that does not run it; all four fail on master. + - **`salary_lookup.py` prints the privacy footnote only when a row actually carries `N/A*`** - the `* N/A = Too few employees to publish (privacy)` line was appended under every category table, including one where every row has an index, so the output asserted a diff --git a/tests/test_apply_page_count.py b/tests/test_apply_page_count.py new file mode 100644 index 0000000..105f2e6 --- /dev/null +++ b/tests/test_apply_page_count.py @@ -0,0 +1,85 @@ +"""Guard for /apply Step 5b's page-count check. + +The 2-page CV and 1-page cover letter limits are the hard rules of +05-cv-templates.md and 06-cover-letter-templates.md, and two places defer +their enforcement to `tools/verify_pdf.py --pages`: `verify_layout.py`'s +docstring ("page count is verify_pdf.py's job, and CI runs it") and Step 5b's +own prose, which used to say "Step 5d already runs it". Step 5d's only +invocation is `--dump-text`, and no other step passed `--pages` at all, so +the one rule with a mechanical check had zero runnable implementations in +the workflow. These tests pin that the invocations exist where the prose says +they do, with the counts the guides require, and that no step defers the +check to another step that does not run it. +""" +import re +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +APPLY = REPO / ".claude" / "commands" / "apply.md" +VERIFY_LAYOUT = REPO / "tools" / "verify_layout.py" + + +def section(path, heading): + """The body of one markdown section, up to the next heading of any depth.""" + text = path.read_text(encoding="utf-8") + start = text.index(heading) + len(heading) + rest = text[start:] + end = re.search(r"^#{1,4} ", rest, re.MULTILINE) + return rest[: end.start()] if end else rest + + +def page_count_invocations(text): + """(document path, page count) for every runnable verify_pdf --pages line.""" + return re.findall( + r"^python tools/verify_pdf\.py (\S+) --pages (\d+)\s*$", text, re.MULTILINE + ) + + +class ApplyRunsThePageCountCheck(unittest.TestCase): + def setUp(self): + self.step_5b = section(APPLY, "### 5b. Inspect layout") + + def test_step_5b_checks_both_documents_with_the_guides_page_limits(self): + invocations = dict(page_count_invocations(self.step_5b)) + self.assertEqual( + invocations.get("cv/main__.pdf"), + "2", + "Step 5b must run verify_pdf.py --pages 2 on the CV - the hard " + "2-page limit has no other mechanical check", + ) + self.assertEqual( + invocations.get("cover_letters/cover__.pdf"), + "1", + "Step 5b must run verify_pdf.py --pages 1 on the cover letter", + ) + + def test_page_count_runs_before_the_layout_measurement(self): + # verify_layout.py's own docstring declines to check page count because + # verify_pdf.py --pages does; the deferral only holds if that runs first. + first_pages = self.step_5b.index("--pages") + first_layout = self.step_5b.index("verify_layout.py") + self.assertLess(first_pages, first_layout) + + def test_no_step_defers_the_check_to_a_step_that_does_not_run_it(self): + text = APPLY.read_text(encoding="utf-8") + self.assertNotIn( + "Step 5d already runs it", + text, + "Step 5d's only verify_pdf call is --dump-text; the page-count " + "invocation lives in 5b and the prose must point there", + ) + + def test_layout_tools_deferral_is_backed_by_a_runnable_invocation(self): + docstring = VERIFY_LAYOUT.read_text(encoding="utf-8") + self.assertIn("verify_pdf.py --pages", docstring) + self.assertGreaterEqual( + len(page_count_invocations(APPLY.read_text(encoding="utf-8"))), + 2, + "verify_layout.py defers page count to verify_pdf.py --pages, so " + "/apply must actually invoke it", + ) + + +if __name__ == "__main__": + unittest.main()