From e6f6f4e322148cc3558726c4b060486f5388316f Mon Sep 17 00:00:00 2001 From: Mads Lorentzen <50207393+MadsLorentzen@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:51:51 +0200 Subject: [PATCH] fix(setup): fill the CV and cover-letter template contact blocks; guard CHANGELOG structure (#433) /setup Step 3 personalised cv/main_example.tex but never the LaTeX contact blocks embedded in 05-cv-templates.md and 06-cover-letter-templates.md, the two files /apply actually compiles from; 06 was not a Step 3 target at all. Step 3.5 now names the 05 contact tokens, a new Step 3.6 covers the 06 contact line and signature, the completion summary lists 06, and /reset restores both blocks instead of listing 06 as framework-only (the existing /reset coverage test forced that half). tests/test_changelog_structure.py checks [Unreleased] on every PR for duplicate headings, unknown headings, orphan entries and conflict markers - the #425 duplicate-heading shape that was fixed by hand at merge time. Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi Co-authored-by: Claude Fable 5.1 --- .claude/commands/reset.md | 17 ++-- .claude/commands/setup.md | 14 ++-- CHANGELOG.md | 19 +++++ tests/test_changelog_structure.py | 124 ++++++++++++++++++++++++++++ tests/test_placeholder_integrity.py | 2 +- tests/test_setup_command.py | 87 +++++++++++++++++++ 6 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 tests/test_changelog_structure.py create mode 100644 tests/test_setup_command.py diff --git a/.claude/commands/reset.md b/.claude/commands/reset.md index a56f526..234d4b8 100644 --- a/.claude/commands/reset.md +++ b/.claude/commands/reset.md @@ -41,7 +41,8 @@ Read the current state of these files and report whether each has content or is - `.claude/skills/job-application-assistant/01-candidate-profile.md` - `.claude/skills/job-application-assistant/02-behavioral-profile.md` - `.claude/skills/job-application-assistant/04-job-evaluation.md` *(personalized match areas, career goals, and life-situation constraints only — the scoring framework is preserved)* -- `.claude/skills/job-application-assistant/05-cv-templates.md` *(profile statements section only — framework structure is preserved)* +- `.claude/skills/job-application-assistant/05-cv-templates.md` *(profile statements section and the contact block inside the LaTeX template only — framework structure is preserved)* +- `.claude/skills/job-application-assistant/06-cover-letter-templates.md` *(contact line and signature inside the LaTeX template only — framework structure is preserved)* - `.claude/skills/job-application-assistant/07-interview-prep.md` *(STAR examples and STAR candidates sections only — framework structure is preserved)* - `.claude/skills/job-scraper/search-queries.md` *(role titles, domain keywords, and location terms only — query structure is preserved)* @@ -63,8 +64,11 @@ Present as: constraints will be restored to placeholders. The scoring framework (dimensions, score bands, weights, Language Gate, Company Research Checklist) is preserved. -- 05-cv-templates.md — [has profile statements / already blank] - Profile statement templates will be cleared. LaTeX structure and tailoring guidelines are preserved. +- 05-cv-templates.md — [has profile statements or contact details / already blank] + Profile statement templates will be cleared and the contact block in the LaTeX template restored to placeholders. LaTeX structure and tailoring guidelines are preserved. + +- 06-cover-letter-templates.md — [has contact details / already blank] + The contact line and signature in the LaTeX template will be restored to placeholders. Letter structure, opening patterns, and closing formulations are preserved. - 07-interview-prep.md — [has STAR examples / already blank] STAR examples and any STAR candidate stubs will be cleared. Framework, tough questions, and roleplay guidelines are preserved. @@ -75,7 +79,6 @@ Present as: The following files are NOT touched (they contain framework rules, not candidate data): - 03-writing-style.md - - 06-cover-letter-templates.md Outside the profile scope, still holding your personal data: CLAUDE.md and cv/main_example.tex. This scope covers skill files only. @@ -207,7 +210,9 @@ Leave the rest of `04-job-evaluation.md` intact: the five scoring dimensions and ``` -Leave all other content in `05-cv-templates.md` intact. +Then restore the contact block inside the file's LaTeX template to its placeholder tokens: `\name{[FIRST_NAME]}{[LAST_NAME]}`, `\address{[YOUR_ADDRESS]}{}{}`, `\phone[mobile]{[YOUR_PHONE]}`, `\email{[YOUR_EMAIL]}`, the `\extrainfo{...}` line's `[YOUR_LINKEDIN_URL]` and `[YOUR_GITHUB_URL]`, and `[YOUR_NAME]` in the `pdftitle`. Leave all other content in `05-cv-templates.md` intact. + +**For `06-cover-letter-templates.md`**, restore the contact line and the signature inside the file's LaTeX template to their placeholder tokens: the `\namesection{}` line becomes `\namesection{}{\Huge{[YOUR_NAME]}}{ \href{mailto:[YOUR_EMAIL]}{[YOUR_EMAIL]} | [YOUR_PHONE] | \urlstyle{same}\href{[YOUR_LINKEDIN_URL]}{LinkedIn}` and `\signature{...}` becomes `\signature{[YOUR_NAME]}`. Leave all other content in `06-cover-letter-templates.md` intact - the letter structure, opening patterns, and closing formulations are framework, not candidate data. If `/setup` Step 3.6 ever personalizes anything beyond these two lines, add it here too. **For `07-interview-prep.md`**, locate and remove: - The entire `## Ready-Made STAR Examples` section and all numbered STAR examples under it @@ -223,7 +228,7 @@ Replace with: Leave all other content in `07-interview-prep.md` intact (STAR format explanation, tough questions, questions to ask interviewers, phone/video tips, follow-up etiquette, roleplay guidelines). -**For `.claude/skills/job-scraper/search-queries.md`**, restore the values `/setup` Step 3.8 personalized back to their placeholder tokens: +**For `.claude/skills/job-scraper/search-queries.md`**, restore the values `/setup` Step 3.9 personalized back to their placeholder tokens: - **Search Sites**: the board names back to `[YOUR_JOB_BOARD]`, `[YOUR_INDUSTRY_JOB_BOARD]`, `[YOUR_ADDITIONAL_JOB_BOARD]`, and the LinkedIn filter back to `[YOUR_COUNTRY]` / `[YOUR_CITY]`. - **Query Categories**: the four priority headings back to `[YOUR_PRIMARY_ROLE_TYPE]`, `[YOUR_DOMAIN_EXPERTISE]`, `[YOUR_ADJACENT_ROLE_TYPE]`, and `Broader Technical / Consulting`; inside the query blocks, the titles, skills, and domain terms back to `[YOUR_PRIMARY_JOB_TITLE_1]`, `[YOUR_PRIMARY_JOB_TITLE_2]`, `[YOUR_ADJACENT_TITLE_1]`, `[YOUR_ADJACENT_TITLE_2]`, `[YOUR_KEY_SKILL]`, `[YOUR_DOMAIN_KEYWORD_1]`, `[YOUR_DOMAIN_KEYWORD_2]`, `[YOUR_DOMAIN]`, and the location terms back to `[YOUR_CITY]`, `[YOUR_COUNTRY]`, `[YOUR_REGION]`. diff --git a/.claude/commands/setup.md b/.claude/commands/setup.md index 1194ae5..82930df 100644 --- a/.claude/commands/setup.md +++ b/.claude/commands/setup.md @@ -367,15 +367,18 @@ Replace skill match areas with the user's actual skills: Update career goals and motivation filters with their actual preferences. ### 5. Update `05-cv-templates.md` *(Path B and C; skip if Path A populated it)* -Add role-specific profile statement templates based on their background. +Add role-specific profile statement templates based on their background, and personalise the contact block inside the file's LaTeX template: replace `[FIRST_NAME]`, `[LAST_NAME]`, `[YOUR_ADDRESS]`, `[YOUR_PHONE]`, `[YOUR_EMAIL]`, `[YOUR_LINKEDIN_URL]` and `[YOUR_GITHUB_URL]` (and `[YOUR_NAME]` in the PDF title) with their actual details. Check this block whichever path ran - Path A extracts profile statements from documents, not the contact block. `/apply` builds every tailored CV from this template, so a placeholder left here reaches a compiled document. -### 6. Update `07-interview-prep.md` *(Path B and C; skip if Path A populated it)* +### 6. Update `06-cover-letter-templates.md` *(all paths - Path A does not fill this block)* +Personalise the contact line and the signature inside the file's LaTeX template: replace `[YOUR_NAME]`, `[YOUR_EMAIL]`, `[YOUR_PHONE]` and `[YOUR_LINKEDIN_URL]` in the `\namesection{}` line, and `[YOUR_NAME]` in `\signature{}`. Path A merges only structural patterns (openings, bullets, closings) into this file, never the contact block. `/apply` compiles every cover letter from this template. + +### 7. Update `07-interview-prep.md` *(Path B and C; skip if Path A populated it)* Create STAR examples from their actual experience (at least 3-4 examples). Path A leaves STAR stubs under "## STAR Candidates (Complete Manually)" rather than full examples; if any stubs are present, mention them in Step 4 so the user knows to flesh them out. -### 7. Update `cv/main_example.tex` +### 8. Update `cv/main_example.tex` Replace placeholder personal data with their actual name, contact info, and add their education and most recent experience entries. -### 8. Generate `.claude/skills/job-scraper/search-queries.md` +### 9. Generate `.claude/skills/job-scraper/search-queries.md` Replace all placeholder tokens in the search queries file with the user's actual information from Section 9 (or the equivalent follow-up questions in Path A's Step A7): - Replace `[YOUR_PRIMARY_ROLE_TYPE]`, `[YOUR_PRIMARY_JOB_TITLE]`, etc. with actual role titles - Replace `[YOUR_KEY_SKILL]`, `[YOUR_DOMAIN_KEYWORD_1]`, etc. with actual skills and domain terms @@ -399,7 +402,8 @@ Present a summary: > - `.claude/skills/job-application-assistant/01-candidate-profile.md` - Structured profile > - `.claude/skills/job-application-assistant/02-behavioral-profile.md` - Behavioral assessment > - `.claude/skills/job-application-assistant/04-job-evaluation.md` - Personalized evaluation framework -> - `.claude/skills/job-application-assistant/05-cv-templates.md` - CV templates with your profile statements +> - `.claude/skills/job-application-assistant/05-cv-templates.md` - CV templates with your profile statements and contact block +> - `.claude/skills/job-application-assistant/06-cover-letter-templates.md` - Cover letter templates with your contact line and signature > - `.claude/skills/job-application-assistant/07-interview-prep.md` - STAR examples from your experience > - `cv/main_example.tex` - Your LaTeX CV template > - `.claude/skills/job-scraper/search-queries.md` - Job search queries for `/scrape` diff --git a/CHANGELOG.md b/CHANGELOG.md index c75d0c7..66db8da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ per-file diff commands. ### Added +- **CHANGELOG structure guard** (`tests/test_changelog_structure.py`) - every PR edits this one + shared file by hand near the same line, and nothing checked the result: a second `### Fixed` + heading landed directly under `[Unreleased]`, above `### Added`, on #425 and was fixed by hand + at merge time. The `[Unreleased]` section is now checked on every PR for duplicate headings, + headings outside the Keep a Changelog set, entries above any heading, and leftover conflict + markers. Released sections are history and are not inspected. + - **`/rank` now consumes the `posted_date` #391 persists** (#390, the deferred second half) - Step 3 gains a staleness flag: a posting whose stored `posted_date` is more than 30 days old at rank time carries a visible ⚠ marker with its age spelled out @@ -41,6 +48,18 @@ per-file diff commands. ### Fixed +- **`/setup` now fills the contact blocks inside `05-cv-templates.md` and + `06-cover-letter-templates.md`, and `/reset` restores them** - Step 3 personalised + `cv/main_example.tex` but never the LaTeX contact blocks embedded in the two template files + `/apply` actually compiles from, so a full Path B or C run left `[YOUR_NAME]`, `[YOUR_EMAIL]` + and `[YOUR_PHONE]` in both, and whether they reached a document depended on the drafter + noticing (a real user ran `/setup` and then hand-edited both files, #420). + `06-cover-letter-templates.md` was not a Step 3 target at all. Step 3.5 now names the `05` + contact tokens, a new Step 3.6 covers the `06` contact line and signature (Path A never fills + it, so it runs for every path), the completion summary lists `06`, and `/reset` clears both + blocks instead of listing `06` as framework-only. Pinned by `tests/test_setup_command.py`; the + existing `/reset` coverage test is what forced the `reset.md` half. + - **`/rank` no longer reads or rewrites the whole of `seen_jobs.json` on every run** (#395) - Step 1 used to read the entire state file into the conversation to select candidates by eye, and Step 4 emitted it back to record scores: a cost paid on every run regardless of diff --git a/tests/test_changelog_structure.py b/tests/test_changelog_structure.py new file mode 100644 index 0000000..27d15c2 --- /dev/null +++ b/tests/test_changelog_structure.py @@ -0,0 +1,124 @@ +"""Structural guard for CHANGELOG.md's [Unreleased] section. + +Contributors edit one shared file by hand, and every PR inserts its entry near +the same line. Two failure shapes have reached master or a merge queue: + +- a second `### Fixed` heading added directly under `## [Unreleased]` because + the author did not see the existing one further down (#425, fixed by hand at + merge time), and +- entries placed above any `###` heading, or under a heading Keep a Changelog + does not define. + +`lint_skills.py` does not read the changelog, so nothing caught either. This +test does, on every PR. It only inspects [Unreleased]; released sections are +history and stay as they are. +""" +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +CHANGELOG = REPO / "CHANGELOG.md" + +KNOWN_HEADINGS = {"Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"} +CONFLICT_MARKERS = ("<<<<<<< ", "=======", ">>>>>>> ") + + +def unreleased_block(text: str) -> str: + """The lines between `## [Unreleased]` and the next `## [` heading.""" + start = text.index("## [Unreleased]") + end = text.find("\n## [", start + 1) + return text[start:] if end == -1 else text[start:end] + + +def unreleased_problems(text: str) -> list[str]: + """Return a human-readable problem per structural defect in [Unreleased].""" + problems: list[str] = [] + seen: list[str] = [] + current: str | None = None + for lineno, line in enumerate(unreleased_block(text).splitlines(), 1): + if any(line.startswith(marker) for marker in CONFLICT_MARKERS): + problems.append(f"conflict marker on [Unreleased] line {lineno}: {line.strip()}") + continue + if line.startswith("### "): + name = line[4:].strip() + if name not in KNOWN_HEADINGS: + problems.append( + f"unknown heading '### {name}' in [Unreleased]; use one of {sorted(KNOWN_HEADINGS)}" + ) + if name in seen: + problems.append( + f"'### {name}' appears twice in [Unreleased] - fold the entry into the existing section" + ) + seen.append(name) + current = name + elif line.startswith("- ") and current is None: + problems.append(f"entry above any '###' heading in [Unreleased]: {line.strip()[:70]}") + return problems + + +CLEAN = """# Changelog + +## [Unreleased] + +### Added + +- **A new thing** - described. + +### Fixed + +- **A fixed thing** - described. + +## [1.0.0] - 2026-01-01 + +### Fixed + +- old entry +""" + + +class UnreleasedProblemsTests(unittest.TestCase): + def test_clean_section_reports_nothing(self): + self.assertEqual(unreleased_problems(CLEAN), []) + + def test_duplicate_heading_is_reported(self): + # The exact #425 shape: a second "### Fixed" inserted directly under + # [Unreleased], above "### Added", while "### Fixed" already exists below. + text = CLEAN.replace( + "## [Unreleased]\n\n### Added", + "## [Unreleased]\n\n### Fixed\n\n- **Entry in the wrong place** - described.\n\n### Added", + ) + problems = unreleased_problems(text) + self.assertTrue(any("Fixed" in p and "twice" in p for p in problems), problems) + + def test_unknown_heading_is_reported(self): + text = CLEAN.replace("### Fixed", "### Fixes") + problems = unreleased_problems(text) + self.assertTrue(any("Fixes" in p for p in problems), problems) + + def test_entry_above_any_heading_is_reported(self): + text = CLEAN.replace( + "## [Unreleased]\n\n### Added", + "## [Unreleased]\n\n- **Orphan entry** - no heading above it.\n\n### Added", + ) + problems = unreleased_problems(text) + self.assertTrue(any("Orphan entry" in p for p in problems), problems) + + def test_conflict_markers_are_reported(self): + text = CLEAN.replace("### Fixed", "<<<<<<< HEAD\n### Fixed") + problems = unreleased_problems(text) + self.assertTrue(any("conflict marker" in p for p in problems), problems) + + def test_released_sections_are_not_inspected(self): + # A duplicate heading in an old release is history, not a defect here. + text = CLEAN + "\n### Fixed\n\n- another old entry\n" + self.assertEqual(unreleased_problems(text), []) + + +class RealChangelogTests(unittest.TestCase): + def test_unreleased_section_is_well_formed(self): + text = CHANGELOG.read_text(encoding="utf-8") + self.assertEqual(unreleased_problems(text), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_placeholder_integrity.py b/tests/test_placeholder_integrity.py index 367002e..2a2b91c 100644 --- a/tests/test_placeholder_integrity.py +++ b/tests/test_placeholder_integrity.py @@ -32,7 +32,7 @@ PROFILE_SENTINEL = "[YOUR_EMAIL]" def personalize_cv(text: str) -> str: - """Apply /setup Step 3.7's documented edit: replace placeholder personal + """Apply /setup Step 3.8's documented edit: replace placeholder personal data with a real name and contact info. Header comments and hyperref metadata are not personal data, so they are deliberately left alone - that is exactly why a comment-located sentinel guards nothing.""" diff --git a/tests/test_setup_command.py b/tests/test_setup_command.py new file mode 100644 index 0000000..315669e --- /dev/null +++ b/tests/test_setup_command.py @@ -0,0 +1,87 @@ +"""Guards for the /setup command spec. + +The command is a markdown spec (the spec IS the implementation). These tests pin +one invariant that broke silently: Step 3 must personalise every contact block +that `/apply` later compiles into a document. `cv/main_example.tex` was covered; +the LaTeX blocks embedded in `05-cv-templates.md` and `06-cover-letter-templates.md` +were not, so a full Path B/C run left `[YOUR_NAME]`, `[YOUR_EMAIL]` and +`[YOUR_PHONE]` in both, and whether they reached a compiled cover letter depended +on the drafter noticing. A real user (#420) ran `/setup` and then hand-edited both +files to close the gap. +""" +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +COMMAND = REPO / ".claude" / "commands" / "setup.md" +SKILL_DIR = REPO / ".claude" / "skills" / "job-application-assistant" +CV_TEMPLATES = SKILL_DIR / "05-cv-templates.md" +COVER_TEMPLATES = SKILL_DIR / "06-cover-letter-templates.md" + + +def _sections(text: str) -> dict[str, str]: + """Split a command spec into {heading: body} by '## ' headers.""" + parts = text.split("\n## ") + result = {} + for part in parts[1:]: + heading, _, body = part.partition("\n") + result[heading.strip()] = body + return result + + +def _substeps(step_body: str) -> dict[str, str]: + """Split a step body into {'### N. ...' heading: body}.""" + parts = step_body.split("\n### ") + result = {} + for part in parts[1:]: + heading, _, body = part.partition("\n") + result[heading.strip()] = body + return result + + +class SetupStep3ContactBlocks(unittest.TestCase): + def setUp(self): + self.step3 = _sections(COMMAND.read_text(encoding="utf-8"))["Step 3: Generate Profile Files"] + self.substeps = _substeps(self.step3) + + def _substep_for(self, filename: str) -> str: + matches = [body for heading, body in self.substeps.items() if filename in heading] + self.assertEqual(len(matches), 1, f"expected exactly one Step 3 substep for {filename}, got {len(matches)}") + return matches[0] + + def test_cv_templates_substep_fills_the_contact_block(self): + body = self._substep_for("05-cv-templates.md") + self.assertIn("contact", body.lower()) + for token in ("[FIRST_NAME]", "[YOUR_EMAIL]", "[YOUR_PHONE]"): + self.assertIn(token, body, f"the 05 substep must name {token} as something to replace") + + def test_cover_letter_templates_get_their_own_substep(self): + body = self._substep_for("06-cover-letter-templates.md") + self.assertIn("signature", body.lower()) + for token in ("[YOUR_NAME]", "[YOUR_EMAIL]", "[YOUR_PHONE]", "[YOUR_LINKEDIN_URL]"): + self.assertIn(token, body, f"the 06 substep must name {token} as something to replace") + + def test_completion_summary_lists_the_cover_letter_templates(self): + step4 = _sections(COMMAND.read_text(encoding="utf-8"))["Step 4: Confirm & Next Steps"] + summary = step4.split("**Privacy note:**")[0] + self.assertIn("06-cover-letter-templates.md", summary) + + +class TemplatesStillCarryThePlaceholders(unittest.TestCase): + """The instructions above target real tokens; if a template renames them, + the instruction and this test must move together.""" + + def test_cv_templates_contact_block_tokens(self): + text = CV_TEMPLATES.read_text(encoding="utf-8") + for token in ("[FIRST_NAME]", "[LAST_NAME]", "[YOUR_EMAIL]", "[YOUR_PHONE]"): + self.assertIn(token, text) + + def test_cover_letter_templates_contact_and_signature_tokens(self): + text = COVER_TEMPLATES.read_text(encoding="utf-8") + for token in ("[YOUR_NAME]", "[YOUR_EMAIL]", "[YOUR_PHONE]", "[YOUR_LINKEDIN_URL]"): + self.assertIn(token, text) + self.assertIn("\\signature{[YOUR_NAME]}", text) + + +if __name__ == "__main__": + unittest.main()