From 8fb34c6cb70ab301e325c08724c457a0eaeaa82b Mon Sep 17 00:00:00 2001 From: yi111 <153097222+Yi-111-a@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:27:21 +0800 Subject: [PATCH] fix(specs): settle every workflow spec on python3, one Windows note at first use (#678) * fix(specs): settle every workflow spec on python3 with one Windows note at first use * fix(specs): finish python3 in the specs /apply reads; Windows note says run as python - 04-job-evaluation (salary lookup), 05-cv-templates (verify_pdf x2) and CLAUDE.md now spell python3 like apply.md; framework_version bumped on 04 (1.2.7) and 05 (1.4.8), each with its own Windows note at first use. - The note now covers Windows generally: python3 is either the exit-49 Store stub or a separate Store Python without pip's packages, so run as python. - tests/test_python_command_specs.py globs commands, skills and agents instead of naming four files. - CHANGELOG entry matches the final scope. * changelog: place the #678 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Mads Lorentzen Co-authored-by: Claude Opus 5.5 --- .claude/commands/apply.md | 16 +-- .claude/commands/rank.md | 2 + .../04-job-evaluation.md | 7 +- .../05-cv-templates.md | 8 +- .../09-web-research.md | 6 +- .claude/skills/job-scraper/SKILL.md | 2 + CHANGELOG.md | 22 ++++ CLAUDE.md | 2 +- tests/test_apply_page_count.py | 2 +- tests/test_posting_gate_wiring.py | 2 +- tests/test_python_command_specs.py | 116 ++++++++++++++++++ 11 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 tests/test_python_command_specs.py diff --git a/.claude/commands/apply.md b/.claude/commands/apply.md index 61ab9830..36266e34 100644 --- a/.claude/commands/apply.md +++ b/.claude/commands/apply.md @@ -44,8 +44,10 @@ Read the evaluation framework: Using the framework from `04-job-evaluation.md`, evaluate the job posting against the candidate's profile. If the salary lookup tool is configured, run: +> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings. + ```bash -python salary_lookup.py "" --json +python3 salary_lookup.py "" --json ``` If the posting specifies a city, add `--city ""` to narrow results. Parse the JSON output and include the salary benchmark in the evaluation. If the tool is not configured or returns an error, skip the salary benchmark. @@ -256,10 +258,10 @@ 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 +python3 tools/verify_pdf.py cv/main__.pdf --pages 2 +python3 tools/verify_pdf.py cover_letters/cover__.pdf --pages 1 +python3 tools/verify_layout.py cv/main__.pdf +python3 tools/verify_layout.py cover_letters/cover__.pdf ``` 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. @@ -301,12 +303,12 @@ Do not proceed to Step 6 until both PDFs pass inspection. An ATS parser reads the PDF's embedded **text layer**, not the rendered page — a CV that passed visual inspection can still extract as garbage (icon glyphs where the contact details should be, scrambled reading order in multi-column layouts). This step verifies what a parser actually sees. It applies to the **CV only**; cover letters rarely go through keyword screening. -**Availability check:** extract with `python tools/verify_pdf.py` (tries **pypdf** first — BSD, `pip install pypdf` — then Poppler `pdftotext`). If both are missing, print a one-line warning that the mechanical parse check is skipped, do the keyword-coverage check (item 3 below) against your visual Read of the PDF instead, and note the degraded mode in the Step 6 report. Same graceful-skip pattern as the salary lookup. If a documented fallback still shells out to `pdftotext -layout`, keep the `-enc UTF-8` flag: Xpdf-based builds default to Latin-1 output, and without it a correct non-ASCII CV fails the replacement-character check below. +**Availability check:** extract with `python3 tools/verify_pdf.py` (tries **pypdf** first — BSD, `pip install pypdf` — then Poppler `pdftotext`). If both are missing, print a one-line warning that the mechanical parse check is skipped, do the keyword-coverage check (item 3 below) against your visual Read of the PDF instead, and note the degraded mode in the Step 6 report. Same graceful-skip pattern as the salary lookup. If a documented fallback still shells out to `pdftotext -layout`, keep the `-enc UTF-8` flag: Xpdf-based builds default to Latin-1 output, and without it a correct non-ASCII CV fails the replacement-character check below. **1. Extract the text layer:** ```bash -python tools/verify_pdf.py cv/main__.pdf --ascii-dates --dump-text cv/main__.txt +python3 tools/verify_pdf.py cv/main__.pdf --ascii-dates --dump-text cv/main__.txt ``` `--ascii-dates` is the date-range check from `05-cv-templates.md` ("Date fields must be ASCII ranges"): it exits 1 if the raw text layer has a year joined to a Unicode dash - the en-dash LaTeX makes from `--`, which a Workday import dropped together with the date - naming each hit and its code point. It reads the raw layer, not the folded comparison `--contains` uses, which is why `--contains "2016-2024"` cannot catch this. The dump is written before the check runs, so a failure still leaves the `.txt`. Fix a hit in the `` date argument (a single ASCII hyphen), then re-run 5a-5c and re-extract. diff --git a/.claude/commands/rank.md b/.claude/commands/rank.md index 06d6cb13..72e5577e 100644 --- a/.claude/commands/rank.md +++ b/.claude/commands/rank.md @@ -26,6 +26,8 @@ Follow these steps **in order**. Never read `job_scraper/seen_jobs.json` into the conversation. It holds every job the workspace has ever seen - most of it `skipped` - while a run only ever touches the handful of entries being scored, so a manual read costs the whole backlog on every run and grows for the life of the workspace. Selecting candidates is a query, so run the query: +> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings. + ```bash python3 tools/rank_state.py candidates --limit 10 # add --all / --focus "" per Step 0 ``` diff --git a/.claude/skills/job-application-assistant/04-job-evaluation.md b/.claude/skills/job-application-assistant/04-job-evaluation.md index fafe603d..677536d1 100644 --- a/.claude/skills/job-application-assistant/04-job-evaluation.md +++ b/.claude/skills/job-application-assistant/04-job-evaluation.md @@ -1,5 +1,5 @@ --- -framework_version: 1.2.6 +framework_version: 1.2.7 --- # Job Evaluation Framework @@ -124,8 +124,11 @@ Does this role advance career goals and contain tasks that energize? ### 6. Salary Benchmark (Optional) If the salary lookup tool is configured (`salary_data.json` exists), look up the company: + +> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings. + ``` -python salary_lookup.py "" --json +python3 salary_lookup.py "" --json ``` If a city is known from the posting, add `--city ""` to narrow results. diff --git a/.claude/skills/job-application-assistant/05-cv-templates.md b/.claude/skills/job-application-assistant/05-cv-templates.md index bead8fdf..0498f5f7 100644 --- a/.claude/skills/job-application-assistant/05-cv-templates.md +++ b/.claude/skills/job-application-assistant/05-cv-templates.md @@ -1,5 +1,5 @@ --- -framework_version: 1.4.7 +framework_version: 1.4.8 --- # CV Templates and Tailoring Guide @@ -273,8 +273,10 @@ Restore the highest-relevance item that was previously cut — a CV that ends mi Most employers run CVs through an ATS before a human sees them, and the ATS reads the PDF's embedded **text layer**, not the rendered page. A CV can pass visual inspection and still extract as garbage. After the layout passes the compile-and-inspect loop, verify the text layer: +> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings. + ```bash -python tools/verify_pdf.py cv/main__.pdf --dump-text cv/main__.txt +python3 tools/verify_pdf.py cv/main__.pdf --dump-text cv/main__.txt ``` Extraction tries **pypdf** first (`pip install pypdf`, BSD license), then Poppler `pdftotext`. If a fallback still uses `pdftotext -layout`, it must also pass `-enc UTF-8`: Xpdf-based builds default to Latin-1, which makes every non-ASCII character in a perfectly good CV read back as a replacement character. If neither extractor is available, skip the mechanical check with a warning and rely on the visual PDF read for keyword coverage. @@ -310,7 +312,7 @@ Two independent causes, both easy to avoid: Where a genuine range exists, use it even when a single year would be factually accurate - a degree written `1995` is true but imports worse than `1992-1995`. Do not invent a start date you do not have; a lone graduation year is fine, just expect it to be typed in by hand. -**Cause 1 is mechanical in step 5d**: `python tools/verify_pdf.py cv/main__.pdf --ascii-dates` scans the raw text layer for a year joined to a Unicode dash and exits 1 naming each hit with its code point (`U+2013 in 'Role Title 2016–2024'`). It deliberately reads the raw layer: the fold that lets `--contains "2016-2024"` match what the template renders maps that same en-dash back to `-`, so `--contains` can never see this. A year on either side of the dash is enough, so `Mar 2016 – Jul 2016` and `2016 – Present` are caught too; a numeric range with no year (`EUR 600k–1M`) is left alone. **Cause 2 stays a read-through check**: after extracting the text layer, confirm every experience entry shows a start *and* an end. Because both failures are silent and invisible in the PDF, the candidate otherwise discovers them only while filling in the application form. +**Cause 1 is mechanical in step 5d**: `python3 tools/verify_pdf.py cv/main__.pdf --ascii-dates` scans the raw text layer for a year joined to a Unicode dash and exits 1 naming each hit with its code point (`U+2013 in 'Role Title 2016–2024'`). It deliberately reads the raw layer: the fold that lets `--contains "2016-2024"` match what the template renders maps that same en-dash back to `-`, so `--contains` can never see this. A year on either side of the dash is enough, so `Mar 2016 – Jul 2016` and `2016 – Present` are caught too; a numeric range with no year (`EUR 600k–1M`) is left alone. **Cause 2 stays a read-through check**: after extracting the text layer, confirm every experience entry shows a start *and* an end. Because both failures are silent and invisible in the PDF, the candidate otherwise discovers them only while filling in the application form. ## Page Budget - Hard 2-Page Limit diff --git a/.claude/skills/job-application-assistant/09-web-research.md b/.claude/skills/job-application-assistant/09-web-research.md index a33c8320..d9aae98a 100644 --- a/.claude/skills/job-application-assistant/09-web-research.md +++ b/.claude/skills/job-application-assistant/09-web-research.md @@ -1,5 +1,5 @@ --- -framework_version: 1.1.5 +framework_version: 1.1.6 --- # Web Research and Fetching @@ -35,6 +35,8 @@ Do **not** respond to a 403 by softening the cover letter to vague generalities, The retry command below runs this check itself and refuses to fetch when it fails - there is no flag to skip it. The standalone checker is still there for diagnosing a refusal: +> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings. + ```bash python3 tools/robots_check.py '' ``` @@ -52,7 +54,7 @@ Two details worth knowing, both covered by `tests/test_robots_check.py`: python3 tools/fetch_page.py '' --out "${SCRATCHPAD:?set this to the session scratchpad directory from your system prompt}/page.html" --text ``` -One command does the whole retry: it runs the `robots.txt` gate above (exit `1` and no fetch when the policy says no), GETs the page with browser headers, saves the body to `--out`, prints `HTTP size= saved=`, and with `--text` prints the tag-stripped text (script, style, noscript, svg and comments removed, entities unescaped) capped at `--max-chars` (default 6000). It is GET-only with a fixed argv, http(s) only, bounded to 45 s, 5 redirects and 10 MB, and it refuses an `--out` inside the repo - fetched content is untrusted and belongs in the scratchpad. That fixed shape is why the workflow pre-approves `python tools/fetch_page.py` and not `curl`. +One command does the whole retry: it runs the `robots.txt` gate above (exit `1` and no fetch when the policy says no), GETs the page with browser headers, saves the body to `--out`, prints `HTTP size= saved=`, and with `--text` prints the tag-stripped text (script, style, noscript, svg and comments removed, entities unescaped) capped at `--max-chars` (default 6000). It is GET-only with a fixed argv, http(s) only, bounded to 45 s, 5 redirects and 10 MB, and it refuses an `--out` inside the repo - fetched content is untrusted and belongs in the scratchpad. That fixed shape is why the workflow pre-approves `python3 tools/fetch_page.py` and not `curl`. The HTTP status is information, not a verdict: a `403` body still tells you what the site answered. Only exit `2` (unsupported scheme, transport failure, oversized response) means nothing was retrieved. diff --git a/.claude/skills/job-scraper/SKILL.md b/.claude/skills/job-scraper/SKILL.md index bf92162f..937c8b88 100644 --- a/.claude/skills/job-scraper/SKILL.md +++ b/.claude/skills/job-scraper/SKILL.md @@ -143,6 +143,8 @@ For each new job, do a rapid fit check (NOT the full evaluation from `04-job-eva 1. Derive each entry's key with the helper, never by slugifying in the moment: +> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings. + ```bash python3 tools/job_key.py --company "" --title "" --url "<url>" ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f708ebf..9ce1064f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -220,6 +220,28 @@ per-file diff commands. that code page (e.g. Polish, Cyrillic, or CJK) raised `UnicodeEncodeError` when writing stdout. Similarly, `robots_check.py` now guarantees UTF-8 standard streams across platforms. +- **Every workflow spec invokes Python as `python3`, with a single Windows note at first use** + (#678, fixes #669, `.claude/commands/apply.md`, `.claude/commands/rank.md`, + `.claude/skills/job-scraper/SKILL.md`, + `.claude/skills/job-application-assistant/04-job-evaluation.md`, + `.claude/skills/job-application-assistant/05-cv-templates.md`, + `.claude/skills/job-application-assistant/09-web-research.md`, `CLAUDE.md`, + `tests/test_python_command_specs.py`) - the specs split between spellings: + `/apply` and the skill files it reads (`04-job-evaluation`, `05-cv-templates`) + plus `CLAUDE.md` ran bare `python salary_lookup.py` / `python tools/verify_*.py` + while `/rank`, `/scrape` and `09-web-research` ran `python3 ...`, so one + `/apply` run saw both spellings for the same commands. Every command now reads + `python3`, and each spec that runs a tool carries one Windows note at its + first use: run the tools as `python` there, because a python.org install + ships no `python3` and that name reaches the Microsoft Store alias, which + either exits 49 with no output or starts a separate Store Python without the + packages `pip` installed (such as pypdf). `.claude/settings.json` already + allows both spellings, so no settings or guard change. + `tests/test_python_command_specs.py` globs `.claude/commands/*.md`, + `.claude/skills/**/*.md` and `.claude/agents/*.md` and fails if any spec + runs a bare `python`, or a spec that runs a tool carries no note, a second + note, or one after its first command. + ### Security - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a diff --git a/CLAUDE.md b/CLAUDE.md index d904d0ef..b7b76323 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,7 @@ Both documents MUST be compiled and visually inspected via the Read tool on the - [ ] **Cover letter bullet font matches body font** - `\lettercontent{}` must not wrap `\begin{itemize}...\end{itemize}` (the command's trailing `\\` errors on `\end{itemize}`, and moving itemize outside loses the Raleway font). Standard pattern: close `\lettercontent{}`, then wrap the list in `{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont \begin{itemize}...\end{itemize}\par}` ### ATS & keyword verification (CV) -ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `python tools/verify_pdf.py cv/main_<company>_<role>.pdf --dump-text cv/main_<company>_<role>.txt` (pypdf, then `pdftotext -layout -enc UTF-8`) and verify what a parser sees. If both extractors are missing, skip the parseability items with a warning and check keyword coverage from the visual PDF read instead. +ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `python3 tools/verify_pdf.py cv/main_<company>_<role>.pdf --dump-text cv/main_<company>_<role>.txt` (pypdf, then `pdftotext -layout -enc UTF-8`) and verify what a parser sees. If both extractors are missing, skip the parseability items with a warning and check keyword coverage from the visual PDF read instead. - [ ] CV text layer extracts cleanly - no `(cid:*)` markers, `�` replacement characters, or text visible in the PDF but absent from the extraction - [ ] Email and phone appear as **literal text** in the extraction (icon-glyph noise like `MOBILE-ALT`/`Envelope` is harmless, but a contact detail carried only by an icon or hyperlink is invisible to ATS) - [ ] Reading order of the extracted text matches the visual order (single-column stock template is safe; multi-column custom templates are where this breaks) diff --git a/tests/test_apply_page_count.py b/tests/test_apply_page_count.py index 105f2e69..9da845b7 100644 --- a/tests/test_apply_page_count.py +++ b/tests/test_apply_page_count.py @@ -32,7 +32,7 @@ def section(path, heading): 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 + r"^python3 tools/verify_pdf\.py (\S+) --pages (\d+)\s*$", text, re.MULTILINE ) diff --git a/tests/test_posting_gate_wiring.py b/tests/test_posting_gate_wiring.py index 8263b43a..e64aa9c8 100644 --- a/tests/test_posting_gate_wiring.py +++ b/tests/test_posting_gate_wiring.py @@ -78,7 +78,7 @@ class TrustBoundaryWiring(unittest.TestCase): text = RESEARCH.read_text(encoding="utf-8") boundary = re.search(r"## Trust boundary(.*?)(?=\n## )", text, re.DOTALL).group(1) self.assertIn("posting_guard", boundary) - self.assertIn("framework_version: 1.1.5", text) + self.assertIn("framework_version: 1.1.6", text) if __name__ == "__main__": diff --git a/tests/test_python_command_specs.py b/tests/test_python_command_specs.py new file mode 100644 index 00000000..b582c178 --- /dev/null +++ b/tests/test_python_command_specs.py @@ -0,0 +1,116 @@ +"""Guard: the workflow specs settle on `python3`, with one Windows note each. + +The specs used to split between spellings: `/apply` and the skill files it +reads (`04-job-evaluation`, `05-cv-templates`) ran bare `python ...` while +`/rank`, `/scrape` and `09-web-research` ran `python3 ...` (#669). The specs +now all use `python3`, and every spec that runs a tool carries a single +Windows note at its first use: a python.org install on Windows ships no +`python3`, so that name reaches the Microsoft Store alias, which either exits +49 with no output or starts a separate Store Python without the packages +`pip` installed. The note points at `python`, and `.claude/settings.json` +keeps granting both spellings, so no settings or guard change is needed. + +Nothing enforced either half. A later edit could reintroduce a bare `python +tools/...` line, or a second Windows note, without any test noticing; +`lint_skills.py` reads frontmatter, not command bodies, and the changelog +guard does not read the specs either. This does - over every command, skill +and agent spec rather than a fixed list, so a bare `python` line added to a +spec this file never named still fails. + +Only line-leading interpreter tokens are checked - lines that actually run +Python from a code block. Prose that mentions a spelling mid-sentence (the +setup hint, the permission list) is left alone, as are the +`Bash(python ...)`/`Bash(python3 ...)` permission entries, which must keep +naming every spelling a user's machine might actually expose. +""" +import re +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +SPEC_GLOBS = ( + ".claude/commands/*.md", + ".claude/skills/**/*.md", + ".claude/agents/*.md", +) + +SPECS = sorted( + {path.relative_to(REPO).as_posix() for pattern in SPEC_GLOBS for path in REPO.glob(pattern)} +) + +# Specs known to run a tool today; the glob must keep finding them. +KNOWN_TOOL_SPECS = { + ".claude/commands/rank.md", + ".claude/commands/apply.md", + ".claude/skills/job-scraper/SKILL.md", + ".claude/skills/job-application-assistant/04-job-evaluation.md", + ".claude/skills/job-application-assistant/05-cv-templates.md", + ".claude/skills/job-application-assistant/09-web-research.md", +} + +WINDOWS_NOTE = "> **Windows:** run these tools as `python`, not `python3`." + +# A line that starts by running an interpreter: the token at line start, +# optionally behind list indentation. `python3` is the settled spelling and +# never flagged; `python` (bare) and any `"$PY"`/probe reintroduction are. +LINE_INTERPRETER = re.compile(r"""^\s*(python3|python|"\$PY"|PY=)""") + + +def command_lines(text): + """Yield (lineno, line) for every line-leading interpreter invocation.""" + for lineno, line in enumerate(text.splitlines(), 1): + m = LINE_INTERPRETER.match(line) + if m and m.group(1) != "python3": + yield lineno, line, m.group(1) + + +class SpecPythonSpellingTest(unittest.TestCase): + def test_glob_covers_every_known_tool_spec(self): + missing = KNOWN_TOOL_SPECS - set(SPECS) + self.assertEqual(missing, set(), f"spec glob no longer finds {sorted(missing)}") + + def test_every_command_invokes_python3(self): + problems = [] + for spec in SPECS: + text = (REPO / spec).read_text(encoding="utf-8") + for lineno, line, token in command_lines(text): + problems.append(f"{spec}:{lineno}: {line.strip()}") + self.assertEqual( + problems, + [], + "workflow specs must invoke Python as `python3 ...` on every line - " + "the settled spelling (#669); a bare `python` breaks where distros " + "ship no python alias, and a `\"$PY\"` probe cannot work across " + "Claude Code's per-call shells:\n" + "\n".join(problems), + ) + + def test_each_tool_spec_carries_exactly_one_windows_note_before_first_use(self): + for spec in SPECS: + text = (REPO / spec).read_text(encoding="utf-8") + lines = text.splitlines() + first_use = next( + (i for i, l in enumerate(lines) if LINE_INTERPRETER.match(l)), None + ) + notes = text.count(WINDOWS_NOTE) + if first_use is None: + self.assertEqual( + notes, 0, f"{spec} runs no tool but carries a Windows note" + ) + continue + self.assertEqual( + notes, + 1, + f"{spec} must carry exactly one Windows note (found {notes}) - " + "the note explains why Windows runs the tools as `python` (#669) " + "and must not be repeated after every command", + ) + note_at = next(i for i, l in enumerate(lines) if l.startswith(WINDOWS_NOTE)) + self.assertLess( + note_at, + first_use, + f"{spec} puts its Windows note after the first python3 command", + ) + +if __name__ == "__main__": + unittest.main()