mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
* fix(gmail-sync): strip CSV-breaking characters from the email subject Step 7a interpolated the raw subject line of a received email into the `notes` column of job_search_tracker.csv. No writer here emits a quoted tracker field, so an unescaped comma splits the row - for csv.DictReader just as much as for a naive split, which matters because tools/rank_state.py is the repo's only machine reader and uses exactly that. `notes` is column 10 of 14, so a subject as ordinary as "Re: Your application, Data Scientist" shifted cv_file, cover_letter_file and source a column left. A line break is worse: it ends the row and starts a second one. The rule now sits on the append instruction itself rather than in a general note a writer can miss. The subject survives verbatim in the archive's outcome.md, which is Markdown and carries no such constraint. /outcome Step 4 (outcome.md:195) is also free-form and has the same exposure, but its text is model-authored in a turn the user is watching rather than copied from third-party mail unattended. Left out deliberately, to be filed separately. * fix(outcome): keep the Step 4 tracker note free of CSV-breaking characters /outcome Step 4 appended "a short dated note" to `notes` with no constraint on its content, the same exposure /gmail-sync Step 7a had: nothing quotes a tracker field, so `rejected, no feedback given` shifts cv_file, cover_letter_file and source a column left under csv.DictReader, and a line break ends the row. The append instruction now requires a note with no commas, double quotes or line breaks. Folded in at the maintainer's request on #455 so one entry and one rule cover both free-form writers. The CSV-safety tests move out of test_gmail_sync_command.py into test_tracker_notes_csv_safe.py, where a CASES table pins the rule on each writer's append line.
136 lines
5.2 KiB
Python
136 lines
5.2 KiB
Python
"""Guards for the rule that keeps free-form `notes` from breaking a tracker row.
|
|
|
|
No writer in this framework emits a quoted tracker field, so a comma inside
|
|
`notes` splits the row - for the `csv.DictReader` in `tools/rank_state.py` as
|
|
much as for a naive split - and shifts `cv_file`, `cover_letter_file` and
|
|
`source` a column left. A line break is worse: it ends the row. Two writers put
|
|
free-form text into `notes`: `/gmail-sync` Step 7a copies an email subject, and
|
|
`/outcome` Step 4 writes a short note of its own. The fixed-format writers
|
|
(`followed up YYYY-MM-DD`, `stale resolved no_response (YYYY-MM-DD)`,
|
|
`redrafted`) cannot contain the characters and are not listed.
|
|
|
|
The spec IS the implementation, so the guard is `CASES`: each rule must sit on
|
|
the line that instructs the append, not merely somewhere in the section. The
|
|
shape tests below it parse with `csv.DictReader` and document why the rule
|
|
exists; they pass on master too.
|
|
"""
|
|
import csv
|
|
import io
|
|
import re
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
COMMANDS = REPO / ".claude" / "commands"
|
|
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
|
|
OUTCOME = COMMANDS / "outcome.md"
|
|
|
|
TRACKER_HEADER = (
|
|
"date,company,sector,role,role_type,channel,status,contact_person,"
|
|
"fit_rating,notes,cv_file,cover_letter_file,source,deadline"
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
class FreeFormNotesWritersStateTheRule(unittest.TestCase):
|
|
"""Format: (path, heading, line_anchor, rule, why)"""
|
|
|
|
CASES = [
|
|
(
|
|
GMAIL_SYNC,
|
|
"### Step 7a: Write Approved Updates",
|
|
"append to `notes`",
|
|
"with every comma, double quote and line break deleted from the subject first",
|
|
"Step 7a item 2 deliberately keeps the subject verbatim in `outcome.md`, "
|
|
"so the rule must sit on the tracker append, not anywhere in the step",
|
|
),
|
|
(
|
|
OUTCOME,
|
|
"## Step 4: Update the Tracker",
|
|
"append a short dated note",
|
|
"containing no commas, double quotes or line breaks",
|
|
"Step 4 is the primary status-update path and its note is written "
|
|
"free-form - `rejected, no feedback given` is the natural sentence",
|
|
),
|
|
]
|
|
|
|
def test_rule_is_stated_where_the_append_happens(self):
|
|
for path, heading, anchor, rule, why in self.CASES:
|
|
with self.subTest(path=path.name, heading=heading):
|
|
lines = [l for l in section(path, heading).splitlines() if anchor in l]
|
|
self.assertEqual(len(lines), 1, f"expected one append instruction: {why}")
|
|
self.assertIn(rule, lines[0], why)
|
|
|
|
|
|
class NotesShapeUnderTheShippedReader(unittest.TestCase):
|
|
"""Why the rule exists, demonstrated with the reader the repo ships."""
|
|
|
|
SUBJECT = 'Re: Your application, Data Scientist - "next steps"'
|
|
|
|
def test_sanitised_note_keeps_the_row_parseable(self):
|
|
safe = self.SUBJECT.replace(",", "").replace('"', "")
|
|
rows = self._parse(f'2026-09-12 gmail-sync: acknowledged ("{safe}")')
|
|
|
|
self.assertEqual(len(rows), 1, "the note must not end the row early")
|
|
row = rows[0]
|
|
self.assertIsNone(row.get(None), "the row must be no wider than the header")
|
|
self.assertEqual(row["cv_file"], "cv/main_acme_data_scientist.tex")
|
|
self.assertEqual(
|
|
row["cover_letter_file"], "cover_letters/cover_acme_data_scientist.tex"
|
|
)
|
|
self.assertEqual(row["source"], "linkedin")
|
|
|
|
def test_a_comma_in_the_note_shifts_the_columns(self):
|
|
for note in (
|
|
f'2026-09-12 gmail-sync: acknowledged ("{self.SUBJECT}")',
|
|
"2026-09-12 rejected, no feedback given",
|
|
):
|
|
with self.subTest(note=note):
|
|
row = self._parse(note)[0]
|
|
self.assertIsNotNone(row.get(None), "the comma must widen the row")
|
|
self.assertNotEqual(row["cv_file"], "cv/main_acme_data_scientist.tex")
|
|
self.assertNotEqual(row["source"], "linkedin")
|
|
|
|
def test_a_line_break_in_the_note_splits_the_row_in_two(self):
|
|
rows = self._parse('2026-09-12 gmail-sync: acknowledged ("Re: update\nlater")')
|
|
self.assertEqual(len(rows), 2)
|
|
self.assertIsNone(rows[0]["cv_file"], "the first row ends mid-note")
|
|
|
|
@classmethod
|
|
def _parse(cls, notes):
|
|
stream = io.StringIO(TRACKER_HEADER + "\n" + cls._row(notes) + "\n")
|
|
return list(csv.DictReader(stream))
|
|
|
|
@staticmethod
|
|
def _row(notes):
|
|
return ",".join(
|
|
[
|
|
"2026-09-01",
|
|
"Acme",
|
|
"tech",
|
|
"Data Scientist",
|
|
"full_time",
|
|
"portal",
|
|
"applied",
|
|
"",
|
|
"8",
|
|
notes,
|
|
"cv/main_acme_data_scientist.tex",
|
|
"cover_letters/cover_acme_data_scientist.tex",
|
|
"linkedin",
|
|
"2026-09-30",
|
|
]
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|