Skip to content

fix: second audit pass — 16 more bugs found using applyr on real data - #50

Merged
DeibyGS merged 11 commits into
mainfrom
fix/cc-audit-round-2
Aug 12, 2026
Merged

fix: second audit pass — 16 more bugs found using applyr on real data#50
DeibyGS merged 11 commits into
mainfrom
fix/cc-audit-round-2

Conversation

@DeibyGS

@DeibyGS DeibyGS commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

Continuation of the audit that produced #47/#48/#49: this round came from running applyr against a real 200+ offer database and a real CV/cover-letter pipeline, end to end, repeatedly. 16 more bugs, 11 commits, each independently tested and passing before the next was started.

⚠️ PR budget exception

This PR is 992 lines (852 insertions, 140 deletions), over the 500-line budget. Per explicit user approval: kept as one PR rather than split into a chain, because each commit is already an atomic, independently-tested fix — splitting would distribute the same 11 commits across more reviews without reducing the risk of any individual change. Documented here per the budget rule's exception clause.

Changes

File / Area What Changed
applyr/commands/analytics.py plan shares gaps' priority ranking; followups filters by status instead of a dead flag; followups --json respects --json on the empty-result path
applyr/commands/core.py Biggest weakness picks the true lowest score; --sort/--limit validated via SORT_FIELDS; --all uses 0 instead of -1; setup-agent writes to the detected config path, not always the default
applyr/cv.py cv keywords finds the file cv generate actually wrote (via cv_used), and matches keywords against the document body only (_strip_frontmatter), not the YAML frontmatter carrying a copy of the offer's own tech_stack; cover letters claim only skills present in the profile
applyr/db.py Schema warning goes through warn() (stderr) instead of a bare print()
applyr/commands/workflow.py export defaults into APPLYR_DIR, not the current working directory
applyr/cli.py Unknown commands and missing required arguments now exit non-zero via die(), on stderr, instead of printing usage and exiting 0
CHANGELOG.md All 16 fixes documented under Unreleased
tests/* ~85 new tests covering every fix above

The two worth reading in full

cv keywords always reported 100%. It matched the offer's required keywords against the whole generated CV file, which opens with YAML frontmatter carrying a verbatim copy of the offer's own tech_stack. The offer's keywords were being matched against a copy of themselves — every generated CV, for every user, scored 100% STRONG regardless of what it actually said.

The cover letter could invent skills. generate_cover_letter took the offer's first three technologies verbatim and wrote "With my background in <them>," without ever consulting the candidate's profile. On a real vacancy it produced "my skills in React.js, Redux, Hooks" for a profile with no Redux anywhere — a false claim, sent to an employer, on the candidate's behalf, against the tool's own first rule ("never invent skills, projects, or experience").

Recurring pattern

Four of these bugs were functions or design decisions already written correctly, just not connected: warn() existed and said "warnings are not data" in its own docstring; _parse_markdown_for_review already stripped frontmatter and had no callers; compare already used die() correctly for missing arguments while sixteen sibling call sites didn't; cmd_update's own comment already stated "a rejected or closed offer needs no chasing" but nothing cleared the stale follow-up date it implied.

Test Plan

  • Tests pass — 544 passed (460 at the start of this round)
  • Every fix verified against real data: a live 211-offer database, real-generated CVs, real cover letters
  • git fsck run after unrelated worktree cleanup earlier in the session — no corruption, only expected dangling objects from rebase/worktree-removal
  • Budget: 992 lines — exceeds 500, exception documented above with explicit user approval

Checklist

  • Branch follows naming convention
  • Commits are work-unit based (tests+code together per fix)
  • No secrets or .env committed
  • Docs updated — CHANGELOG backfilled for the whole round before this PR

🤖 Generated with Claude Code

DeibyGS and others added 11 commits August 11, 2026 19:20
`plan` classified priority against absolute thresholds (200/100/40) while
`gaps` classified it relative to the worst gap. On a real 207-offer database
the weakest topic already scored 415, so `plan` called all six topics CRITICAL
while `gaps` spread the same six across HIGH/MEDIUM/LOW. Two commands, one
dataset, contradictory answers — and a plan that says "prioritize everything"
says nothing.

The earlier fix to gap priority changed `cmd_gaps` and left its sibling.

- `cmd_plan` reuses `_gap_priority`, so both commands share one vocabulary and
  one scale
- Rank by `total_gap` directly instead of recomputing frequency x avg_gap,
  which was the same number with a rounding step in the middle
- Drop the priority ladder duplicated across the JSON and human branches
- Remove PRIORITY_CRITICAL_SCORE / PRIORITY_HIGH_SCORE / PRIORITY_MEDIUM_SCORE,
  now unused

3 tests, one of which asserts the two commands return identical priorities.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule was "lowest partial, or else highest missing", which got it wrong
twice over.

Preferring the partial bucket meant a topic scored 30 and printed under
"Missing" two lines above was passed over in favour of one scored 50 — the
summary contradicted the breakdown it sits under. On a real offer it reported
English (50) as the biggest weakness while Experience (30) sat in the Missing
section directly above.

And the missing branch sorted descending, so a profile weak across the board
was told its *least* bad shortfall was the problem.

It is now simply the lowest-scoring topic that is not already strong.

A test pinned the old behaviour — asserting Experience (60) over Projects (30,
"No relevant projects") — so it was asserting the bug. Corrected, plus two new
cases: the worst of several missing topics wins, and a partial can still be the
weakest when nothing is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cv keywords` matched the offer's required keywords against the whole
generated file. That file opens with YAML frontmatter carrying the offer's own
`tech_stack` and `summary` — so the offer's keywords were matched against a
verbatim copy of themselves. Every keyword hit. Every generated CV scored
100% and reported KEYWORD STATUS: STRONG, whatever it actually said.

Verified on a real CV that mentions neither AWS nor Redux nor Webpack: before,
11/11 matched and STRONG; after, 8/11 and ADEQUATE, with those three correctly
listed as missing. The metric did not just drift — it was structurally
incapable of returning anything but a perfect score, and it told the user their
CV covered skills it never mentioned.

A second fault kept it from ever running at all: the lookup globbed for
`*offer_<id>*`, a naming convention nothing produces. `cv generate` names files
`cv-<company-slug>.md` and records that stem in `cv_used`, which is the
authoritative link. The command now reads `cv_used` first and keeps the glob as
a fallback for hand-named files.

- Extract `_strip_frontmatter`, and route `_parse_markdown_for_review` through
  it (that helper documented the frontmatter-stripping behaviour but had no
  callers at all)
- 4 tests, including an unterminated frontmatter block that must not swallow
  the document

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two ways applyr put things where they did not belong.

`export` wrote the whole database — every company, score, note and private
assessment — into the current working directory. Run it from a checkout, as
anyone working on applyr will, and it drops an untracked 200KB file of personal
job-search data one `git add .` away from being published. Everything else
applyr owns lives in APPLYR_DIR; this was the only command that wrote outside
it. An explicit path still wins.

The "database schema is newer than applyr" notice used a bare print(), putting
a prose line above the payload of every `--json` command and breaking the parse
at character one — for exactly the agent callers applyr exists to serve.
`warn()` was already there, its docstring already said "warnings are not data",
and nothing called it. Now it does.

Also isolates `errors._json_mode` in the test suite. `cli.py` sets it on every
`--json` run and nothing resets it, so one JSON test silenced stderr for every
test collected after it and the suite's result depended on ordering. That is
what surfaced this: the new stderr assertion passed alone and failed in the
full run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The letter took the offer's first three technologies verbatim and wrote "With
my background in <them>". The candidate's profile was never consulted, so the
sentence asserted whatever the employer happened to ask for first.

On a real vacancy it produced "my skills in React.js, Redux, Hooks" for a
profile that contains no Redux anywhere. This is not a metric being wrong: a
cover letter is sent to an employer, so the tool was writing a false claim on
the candidate's behalf — against the first rule in its own agent contract,
"never invent skills, projects, or experience".

Key skills are now the intersection of what the offer asks for and what the
profile actually contains, falling back to "relevant technologies" when there
is no overlap rather than naming a stack the candidate cannot back up.

- `_profile_haystack` flattens the profile for the check, preferring the raw
  cv-master text over the handful of parsed fields
- `cmd_cv_cover_letter` now passes that raw text through
- 4 tests, including an offer with zero overlap and one with no stack at all

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fallback branch printed a message and fell through, so the process exited
0. `applyr <typo> && next-step` therefore ran the next step after a command
that did nothing — the exact failure an agent-driven CLI cannot afford. It also
wrote the message to stdout, in `--json` mode too, where every other error path
emits a structured object on stderr and leaves the payload stream parseable.

The `cv` subcommand branch in the same file already used `die()` correctly;
only the top level was left behind.

Two tests pinned the old behaviour by asserting the message appeared in stdout,
and a third read `assert code != 0 or "Unknown command" in out` — an `or` that
passed either way, which is why nothing ever caught a branch that announced
failure and exited 0. All three now assert the real contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seventeen call sites printed a usage line and returned, so the process exited
0 having done nothing. `applyr show && next-step` ran the next step; an agent
gating on the exit code was told a command it never completed had succeeded.
The usage text went to stdout as well, contaminating the payload stream that
`--json` callers parse.

`compare` proved it was an oversight rather than a decision: with no arguments
it exited 0, with one argument it exited 1 through die(). That one site was
already right; the other sixteen now match it, sharing a `_usage` helper that
emits a stable `missing_arguments` code.

Asking for help is not a failure. Three branches conflated the two cases in a
single condition (`len(args) < 2 or args[1] in ("--help", "-h")`), which is
exactly how missing arguments inherited help's successful exit — they are now
separate, and `--help` still prints to stdout and exits 0.

Nine tests asserted the old contract (`assert code == 0`, usage in stdout) and
have been corrected; 32 new cases cover every affected command in both modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--sort` accepted only raw database column names and silently fell back to the
default for anything else. `--sort score` and `--sort date` — the two a user
would reach for first — returned an unsorted list with no indication, while
`--sort compatibility_pct` worked. None of the working names appeared in the
help text.

The source already knew better: the status check three lines above exists
because an unknown value "reads as 'no offers' rather than 'you mistyped it'".
The sort field got the opposite treatment in the same function.

- `SORT_FIELDS` maps what a user types to the column it means, so `score` and
  `date` work alongside the column names, and anything else is an error
- The mapping is still an allowlist, so no user string reaches the ORDER BY
- Help now names the accepted values

`--limit -5` returned every offer in the database, because SQLite reads a
negative LIMIT as unbounded. `--all` was built on that same accident,
passing -1 deliberately; it now passes 0, which the existing code already
treats as "emit no LIMIT clause", and a negative `--limit` is rejected with a
pointer to `--all`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`follow_up_done` is checked by the followups query but written by nothing in
applyr — no command exposes it, so it stays 0 on every row forever and never
excluded anything. The query filtered on it anyway, which filtered out
nothing.

Verified on a real database: offer #142, rejected three weeks earlier, still
listed as an OVERDUE follow-up next to offers genuinely awaiting a first
reply. A follow-up only means something while an answer is still owed, and
that state already exists on the row — `cmd_update` encodes exactly this
reasoning when deciding whether to set a *new* follow-up date ("only while an
answer is still owed"), but nothing cleared the date already stored once a
reply arrived.

- Filter on `status IN ('applied', 'waiting')` instead of the dead flag —
  the two statuses that mean a reply is still pending
- On the real database this dropped offer #142 out of the list and reduced
  the overdue count from 16 to 15

Writing the test surfaced a second bug in the same function: the "nothing
pending" early return ignored `as_json` and always printed the human sentence,
so `followups --json` with an empty result handed the caller a JSONDecodeError
on exactly the case it most needs to handle cleanly — the same class of fault
already fixed in `response_rate`.

6 tests: one per reply status confirming exclusion, one confirming a
genuinely pending offer still shows, and two pinning the empty --json shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.claude/CLAUDE.md` and `.cursor/rules` are both listed in
`_AGENT_DETECT_ORDER` as valid signals — a project using either is correctly
recognised as "claude" or "cursor". But the write target ignored which file
was matched and always fell back to the first entry in `_AGENT_TARGETS`
(top-level `CLAUDE.md`, or `.cursorrules`).

A project already using the nested location therefore got a second, empty
file created at the root, appended with instructions, while its real config
file — the one the agent actually reads — was left untouched. The user's
context ends up split across two files, one of which the tool created without
being asked and the other of which it silently ignored.

Verified end to end in a scratch project: before the fix, a `.claude/CLAUDE.md`
already in place was left alone and a fresh top-level `CLAUDE.md` appeared;
after, the existing `.claude/CLAUDE.md` is the one appended to, and no second
file is created.

- Track which path the auto-detect loop matched and prefer it over the
  default when writing
- Explicit `--agent` still uses the default path, since no detection ran to
  prefer an alternative — unchanged behaviour, covered by a test
- `detected_path` only ever holds a value from the curated detection table,
  never user input

3 tests: the nested claude path, the nested cursor path, and the explicit-agent
case confirming detection bypass is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
16 fixes from this session's second audit pass (bugs #10 through #25) had no
CHANGELOG entry — round one's commits documented themselves as they went;
this round didn't. Backfilling before the PR rather than after merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DeibyGS
DeibyGS merged commit 04af81a into main Aug 12, 2026
12 checks passed
@DeibyGS
DeibyGS deleted the fix/cc-audit-round-2 branch August 12, 2026 15:51
@DeibyGS DeibyGS mentioned this pull request Aug 12, 2026
6 tasks
DeibyGS added a commit that referenced this pull request Aug 12, 2026
Patch release: every commit since 1.5.0 is a `fix:` — no new public API
surface, no new flags. 25 bugs across two audit passes (PRs #47, #48, #49,
#50), found by running applyr against a real 200+ offer database and a real
CV/cover-letter pipeline end to end, repeatedly.

Highlights: response tracking was structurally incapable of reporting a
non-zero rate; the scoring rubric rewarded job postings for omitting
requirements; `cv keywords` matched every generated CV against a copy of the
offer's own tech stack, so it always reported 100%; cover letters could claim
skills the candidate's profile never mentioned; seventeen call sites reported
success on missing required arguments.

Full list in CHANGELOG.md under 1.5.1.

Co-authored-by: Deibyg <40776476+deiybg@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant