From 6a308c5cc8122b83f5b394bdeee37c09fea0b277 Mon Sep 17 00:00:00 2001 From: Ayobami Adegoke Date: Mon, 21 Sep 2026 07:21:04 +0100 Subject: [PATCH] fix(rank): handle UTF-8 BOM in tracker CSV headers (#485) --- CHANGELOG.md | 8 ++++++++ tests/test_rank_state.py | 16 ++++++++++++++++ tools/rank_state.py | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32639980..3c2d9492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,14 @@ per-file diff commands. ### Fixed +- **`/rank` tracker exclusion handles UTF-8 BOMs on reordered CSV headers** + (`tools/rank_state.py`, `tests/test_rank_state.py`) - when `company` or `role` + is the first column, a leading BOM becomes part of the header name and an + already-tracked application is selected for ranking again. Read with + `utf-8-sig` so both BOM-prefixed and plain UTF-8 trackers match correctly. + The standard `date`-first header already worked; regression coverage checks + all three column orders with and without a BOM. + - **`/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 diff --git a/tests/test_rank_state.py b/tests/test_rank_state.py index d0add9a6..b5de98c2 100644 --- a/tests/test_rank_state.py +++ b/tests/test_rank_state.py @@ -105,6 +105,22 @@ class Candidates(RankStateCase): self.assertEqual([row["key"] for row in out["selected"]], ["b"]) self.assertEqual(out["excluded_by_tracker"], 1) + def test_tracker_exclusion_handles_utf8_bom_and_reordered_columns(self): + self.write_state({"a": entry(company="Acme", title="SOC Analyst"), "b": entry(company="Other")}) + tracker = self.tmp / "tracker.csv" + # A spreadsheet export can prepend a BOM to either matching column. + for encoding in ("utf-8", "utf-8-sig"): + for csv_text in ( + "date,company,role\r\n2026-08-01,ACME,soc analyst\r\n", + "company,role,date\r\nACME,soc analyst,2026-08-01\r\n", + "role,company,date\r\nsoc analyst,ACME,2026-08-01\r\n", + ): + with self.subTest(encoding=encoding, header=csv_text.splitlines()[0]): + tracker.write_bytes(csv_text.encode(encoding)) + out = self.run_tool("candidates", "--tracker", str(tracker)) + self.assertEqual([row["key"] for row in out["selected"]], ["b"]) + self.assertEqual(out["excluded_by_tracker"], 1) + def test_focus_filters_on_title_company_and_stored_fit_notes(self): self.write_state( { diff --git a/tools/rank_state.py b/tools/rank_state.py index 0d80f70a..86633232 100644 --- a/tools/rank_state.py +++ b/tools/rank_state.py @@ -107,7 +107,7 @@ def tracker_pairs(path: Path) -> set[tuple[str, str]]: import csv pairs = set() - with path.open(encoding="utf-8", newline="") as fh: + with path.open(encoding="utf-8-sig", newline="") as fh: for row in csv.DictReader(fh): company, role = norm(row.get("company")), norm(row.get("role")) if company: