Skip to content

fix(heuristics): reject resume-doc-title boilerplate as candidate name - #14

Merged
Vaishnavi1709 merged 1 commit into
mainfrom
feat/name-detection-issue-10
Jun 8, 2026
Merged

fix(heuristics): reject resume-doc-title boilerplate as candidate name#14
Vaishnavi1709 merged 1 commit into
mainfrom
feat/name-detection-issue-10

Conversation

@Vaishnavi1709

Copy link
Copy Markdown
Collaborator

Summary

Closes #10 (mode 1). Partial coverage for mode 2.

The name selector at extract-fields.ts:75 scored "first profile line + largest font + title-case" up to 1.0, which lets a Microsoft-style Functional Resume Sample doc-title header win over the real candidate name on the next line. Reported by @sriyau64 during dogfooding.

The data needed for a proper fix was already on PdfLine (y-coords of each profile line and the contact tokens) — the selector just never consumed it. Fix is fully local to extractName().

What changed

Layer Behaviour
Hard rejection Drops candidates where ≥60 % of tokens are doc-title boilerplate (resume / cv / curriculum vitae / sample / template / chronological / functional / example / draft / combination / profile / biography). "Jane Smith Resume" (1/3 boilerplate) still passes. "Functional Resume Sample" / "Curriculum Vitae" / "Resume Sample" all reject.
First-eligible reindex When the literal first line is rejected, the next surviving candidate inherits the +0.4 first-line bonus — it's effectively in the header position. Without this, the runner-up's confidence would drop to 0.45 (under ANON_CONTACT_CONFIDENCE_FLOOR = 0.5 in score.ts:470), and the completeness scorer would mark the (correctly-detected) name as missing — mode 2 of #10 manifesting inside the mode-1 fix.
Soft contact-cluster proximity +0.15 when candidate y is within ~80 pt of the email / phone / LinkedIn line, via findContactClusterY(). Backup signal for ambiguous header layouts; intentionally too small to override +0.4 first-line.

Verification

Programmatic

  • npm run typecheck — clean
  • npm run test174 / 174 (167 baseline + 6 new extractName unit tests + 1 new corpus fixture)
  • 6 existing corpus snapshots: byte-identical, zero regression
  • pdftotext + pdfinfo on the new fixture: zero real-author tokens, no Author / Title / XMP metadata leakage

Real-world PDF (the exact failure pattern in the issue)

Verified locally against a real "Functional Resume Sample" PDF (John W. Smith / Colorado State / public template). Before / after on the same file:

Pre-fix (main) Post-fix
full_name "Functional Resume Sample" "John W. Smith"
given_name "Functional" "John"
family_name "Resume Sample" "W. Smith"
fieldConfidence.full_name 1.00 0.95
completeness.missing omits name (wrong name still passes the 0.5 floor) omits name (right name passes)

The score numerics don't move here because the bug is "wrong name picked confidently" rather than "no name picked" — both candidates clear the confidence floor, so completeness scores name as present either way. The user-visible win is the displayed name flipping from doc title to the real candidate. (The extractName confidence bump from 0.45 → 0.85 from the first-eligible reindex is what keeps the score from regressing on the fixed branch.)

Fixture

tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf — synthetic single-page XeLaTeX export structured to reproduce mode 1:

Functional Resume Sample         (Huge, line 0 — the boilerplate)
Jane Smith                       (Large, line 1 — the real name)
123 Example Way, ...             (line 2 — contact starts)
jane.smith@example.com · ...     (line 3)
SUMMARY / EXPERIENCE / EDUCATION (3 sections, 5 bullets total)

LaTeX source captured in the commit body so it's regenerable. PII-clean: synthetic persona only.

Acceptance criteria from #10

  • ✅ Reproduce mode 1 (header-as-name) on a fixture
  • ✅ Bias toward proximity to contact cluster (soft +0.15 via findContactClusterY)
  • ✅ Snapshot test pinning correct behaviour on the fixture
  • ✅ Unit tests covering header-rejection + no-regression on standard top-line names (6 new tests in extract-fields.test.ts)
  • ✅ No regression on standard single-column resumes (6/6 existing corpus snapshots byte-identical)
  • ⚠️ Mode 2 ("name set apart from contact, low confidence") — the in-miniature case (boilerplate rejection dropping a candidate below the floor) is fixed via the reindex. The broader case the reporter described (Tier 1 fails entirely → Tier 1.5 falls back at 0.5) needs its own repro fixture to investigate properly. Documented in commit body as needing follow-up.

Reviewer note — UI verification gap

The Result UI doesn't currently render parsed.full_name as a visible field — only the completeness "Missing" list, which is a negative signal. That made the fix invisible end-to-end during local verification. I worked around it with a temporary dev banner under AtsScoreReadout (reverted before commit). Worth a separate UX discussion about whether to surface detected fields for parser-audit verification — not filing yet, deferring to @s-annam's call on whether resumelint's "parser-audit lane" framing wants that surface.

Refs #10

🤖 Generated with Claude Code

Closes #10 (mode 1). Adds partial defence against mode 2.

The name selector in `extractName()` was scoring "first line in profile +
largest font + title-case" up to 1.0, which lets a Microsoft-style
"Functional Resume Sample" doc-title header win over the real candidate
name on the line below it (reported by @sriyau64 during dogfooding).

The data needed for a proper fix was already on `PdfLine` — y-coordinates
of each profile line and the contact tokens — the selector just never
consumed it. Fix is fully local to `extractName()`:

- Hard rejection for resume-doc-title boilerplate. A line where ≥60% of
  tokens are in {resume, cv, curriculum vitae, sample, template, example,
  chronological, functional, combination, profile, biography, ...} is
  skipped before scoring. "Jane Smith Resume" still passes (1/3
  boilerplate); "Functional Resume Sample", "Curriculum Vitae", "Resume
  Sample" all reject.

- First-eligible-index reindex. When the literal first line is rejected
  as boilerplate, the next surviving candidate inherits the first-line
  +0.4 bonus — it is effectively in the header position. Without this,
  fixing the wrong-name pick would dial the runner-up's confidence to
  0.45, below `ANON_CONTACT_CONFIDENCE_FLOOR = 0.5` in score.ts. The
  completeness scorer would then mark the (correctly-detected) name as
  "missing" — mode 2 of issue #10 manifesting inside the fix for mode 1.
  Reindex puts the surviving candidate over 0.5, so completeness scores
  the name as present.

- Soft contact-cluster proximity bonus (+0.15) when the candidate's y is
  within ~80pt of the email/phone/linkedin line. Backup signal for
  ambiguous header layouts; doesn't override the first-line bonus.

Note on mode 2 in general: the reporter described it as "separately, on
another resume… real name detected only with low confidence — likely
because it was set apart from the contact block." Without a specific
upstream PDF, the broader mode 2 (Tier 1 fails entirely, Tier 1.5 falls
back at 0.5) needs its own repro fixture to investigate properly.
Filing as follow-up.

Fixture: tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf,
a synthetic single-page XeLaTeX export structured as:

  Functional Resume Sample          (Huge, line 0 — the boilerplate)
  Jane Smith                        (Large, line 1 — the real name)
  123 Example Way, ...              (line 2 — contact starts)
  jane.smith@example.com · ...      (line 3)
  SUMMARY / EXPERIENCE / EDUCATION  (3 sections, 5 bullets total)

PII-clean: synthetic persona only, no Author / Title / XMP metadata
beyond XeTeX producer string.

Pre-fix snapshot pinned `full_name = "Functional Resume Sample"` and
flagged `name` as missing in completeness; post-fix `full_name = "Jane
Smith"` and `name` clears the floor. Overall score moves 75 → 78.

Unit tests added to extract-fields.test.ts for the selector:
- header-above-name picks the real name (mode 1)
- "Curriculum Vitae" rejected
- "Resume Sample" rejected
- "Jane Smith Resume" still picks (only 1/3 boilerplate)
- no regression: top-line name still picked when no boilerplate
- runner-up confidence ≥ 0.5 after boilerplate rejection (mode 2
  in miniature — guards the threshold-crossing)

Verification:
- npm run typecheck: clean
- npm run test: 174/174 (167 prior baseline + 6 extractName tests
  + 1 new corpus fixture)
- 6 existing corpus snapshots: byte-identical, zero regression
- pdftotext + pdfinfo on new fixture: no real-author tokens, no
  Author/Title metadata

Regenerating the fixture: see the .tex source captured in commit body
section "fixture source" below.

Refs #10

----- fixture source: header_as_name.tex (xelatex) -----
\documentclass[11pt]{article}
\usepackage[margin=1in]{geometry}
\usepackage{enumitem}
\pagestyle{empty}
\setlength{\parindent}{0pt}
\begin{document}
\begin{center}
  {\Huge\bfseries Functional Resume Sample}\\[1.5em]
  {\Large Jane Smith}\\[0.4em]
  123 Example Way, Springfield, IL 62701\\
  jane.smith@example.com $\cdot$ (555) 010-0123 $\cdot$ linkedin.com/in/janesmith
\end{center}
\vspace{1em}
\textbf{SUMMARY}\\
Experienced software engineer with five years of full-stack development experience building scalable web services.

\vspace{0.8em}
\textbf{EXPERIENCE}\\
\textbf{Acme Corp} \hfill Jan 2022 -- Present\\
\emph{Senior Software Engineer}
\begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt]
  \item Built scalable web services with Node.js and Go, handling 50M requests/day.
  \item Led team of three engineers on the payments platform.
  \item Reduced p99 latency by 40\% through caching and query optimisation.
\end{itemize}

\textbf{Initech} \hfill 2020 -- 2022\\
\emph{Software Engineer}
\begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt]
  \item Migrated monolith to microservices using Kubernetes.
  \item Owned authentication and authorisation across all internal tools.
\end{itemize}

\vspace{0.5em}
\textbf{EDUCATION}\\
B.S. Computer Science, Springfield State University, 2020
\end{document}
@Vaishnavi1709
Vaishnavi1709 requested a review from s-annam June 4, 2026 22:09

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: reject resume-doc-title boilerplate as candidate name

Summary

Clean, well-scoped, well-tested fix for mode 1 of #10. The hard-rejection approach is sound and the author is admirably transparent about what's not covered (mode 2) and about the verification gaps. 174/174 green, typecheck clean, fixture PII-verified locally (Jane Smith / @example.com / 555 phone, no Author/Title/XMP leakage). No blocking issues.

Spec Alignment (issue #10)

Requirement Status Notes
Reproduce mode 1 (header-as-name) on a fixture header-as-name-functional-resume.pdf
Reproduce mode 2 (separated-name, low confidence) ⚠️ Partial In-miniature case fixed via reindex; broader case deferred with rationale
Bias toward contact-cluster proximity ✅ (soft) +0.15 via findContactClusterY; intentionally too weak to overturn +0.4 first-line — the actual mode-1 fix is the hard rejection, not proximity
Snapshot test on fixture mode 1
No regression on standard single-column resumes 6 corpus snapshots byte-identical + new unit test

Highlights

  • Fix is fully local to extractName() and reuses data already on PdfLine — no new plumbing.
  • The firstEligibleIdx reindex is a genuinely sharp catch: it stops the mode-1 fix from regressing into mode-2 (dropping the real name below the 0.5 completeness floor). The unit test guarding that threshold boundary is excellent.
  • Conservative ≥60%-boilerplate rule, with a test pinning that "Jane Smith Resume" still passes.

Key Findings (all non-blocking)

  1. [Suggestion] Closes #10 will auto-close the issue on merge, but mode 2's acceptance criteria aren't met. Consider Refs #10 + a follow-up issue for mode 2, so the remaining work isn't silently lost.
  2. [Suggestion] Merge ordering — this PR is stacked on #13 (it carries 974761f … (#13), the corpus harness). The new fixture's snapshot only runs via corpus.test.ts from #13. Merge #13 first, or #14 drags the whole harness in with it.
  3. [Suggestion] firstEligibleIdx blast radius is wider than "boilerplate" — see inline.
  4. [Nit] Dead set entry "résumé" — see inline.
  5. [Nit] Comment inaccuracy in findContactClusterY — see inline.

Verdict

APPROVE — 0 blocking items; findings are suggestions/nits that needn't gate merge. Recommend landing #13 first.

Verified locally: full suite (incl. corpus snapshot) 174/174, tsc -b --noEmit clean, fixture binary inspected with pdftotext/pdfinfo.


let score = 0;
if (i === 0) score += 0.4;
if (i === firstEligibleIdx) score += 0.4;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion]: The reindex is a great catch for the boilerplate case, but its blast radius is broader than the commit title implies. firstEligibleIdx is the first line surviving all the continue filters above (digits, @, length>60, word-count, letterRatio < 0.7), not just boilerplate rejection. So if line 0 is filtered for any of those reasons, the next line now inherits the +0.4 first-line bonus where previously it got nothing. Likely an improvement, but worth either narrowing the comment to reflect that or confirming the intent — the 6 byte-identical snapshots are reassuring but only cover the existing corpus.

*/
const NAME_BOILERPLATE_WORDS = new Set([
"resume",
"résumé",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit]: This "résumé" entry is dead — looksLikeDocTitleBoilerplate lowers each token with .replace(/[^a-z]/g, ""), so "Résumé" normalizes to "rsum" and never matches this set member. The unaccented "resume" still catches the common case, so impact is small, but consider dropping it or adding the normalized form so the intent isn't misleading.

PHONE_RE.test(line.text) ||
LINKEDIN_RE.test(line.text)
) {
// Reset lastIndex defensively; the constants are recompiled per call

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit]: Minor comment inaccuracy — EMAIL_RE/PHONE_RE/LINKEDIN_RE are module-level constants, not "recompiled per call." The defensive lastIndex = 0 reset is correct and harmless (global regexes auto-reset lastIndex on a failed .test(), and the success path resets before returning), so no behavior change needed — just the comment.

@s-annam

s-annam commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Correction to my review above — finding #2 ("merge ordering / this PR is stacked on #13") is obsolete: #13 was already merged into main (commit 974761f, 2026-06-04). My local checkout was a commit behind, so I mis-read the merge-base. GitHub's 4-file diff is correct, and this PR applies directly on current main with the corpus harness already present.

The verification still stands — the full suite I ran (174/174, incl. the new corpus snapshot) reflects the real post-merge state. The remaining findings (1, 3, 4, 5) are unchanged.

@Vaishnavi1709
Vaishnavi1709 merged commit 5a83c26 into main Jun 8, 2026
1 check passed
@s-annam
s-annam deleted the feat/name-detection-issue-10 branch June 11, 2026 16:59
s-annam pushed a commit that referenced this pull request Jun 15, 2026
#14)

Closes #10 (mode 1). Adds partial defence against mode 2.

The name selector in `extractName()` was scoring "first line in profile +
largest font + title-case" up to 1.0, which lets a Microsoft-style
"Functional Resume Sample" doc-title header win over the real candidate
name on the line below it (reported by @sriyau64 during dogfooding).

The data needed for a proper fix was already on `PdfLine` — y-coordinates
of each profile line and the contact tokens — the selector just never
consumed it. Fix is fully local to `extractName()`:

- Hard rejection for resume-doc-title boilerplate. A line where ≥60% of
  tokens are in {resume, cv, curriculum vitae, sample, template, example,
  chronological, functional, combination, profile, biography, ...} is
  skipped before scoring. "Jane Smith Resume" still passes (1/3
  boilerplate); "Functional Resume Sample", "Curriculum Vitae", "Resume
  Sample" all reject.

- First-eligible-index reindex. When the literal first line is rejected
  as boilerplate, the next surviving candidate inherits the first-line
  +0.4 bonus — it is effectively in the header position. Without this,
  fixing the wrong-name pick would dial the runner-up's confidence to
  0.45, below `ANON_CONTACT_CONFIDENCE_FLOOR = 0.5` in score.ts. The
  completeness scorer would then mark the (correctly-detected) name as
  "missing" — mode 2 of issue #10 manifesting inside the fix for mode 1.
  Reindex puts the surviving candidate over 0.5, so completeness scores
  the name as present.

- Soft contact-cluster proximity bonus (+0.15) when the candidate's y is
  within ~80pt of the email/phone/linkedin line. Backup signal for
  ambiguous header layouts; doesn't override the first-line bonus.

Note on mode 2 in general: the reporter described it as "separately, on
another resume… real name detected only with low confidence — likely
because it was set apart from the contact block." Without a specific
upstream PDF, the broader mode 2 (Tier 1 fails entirely, Tier 1.5 falls
back at 0.5) needs its own repro fixture to investigate properly.
Filing as follow-up.

Fixture: tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf,
a synthetic single-page XeLaTeX export structured as:

  Functional Resume Sample          (Huge, line 0 — the boilerplate)
  Jane Smith                        (Large, line 1 — the real name)
  123 Example Way, ...              (line 2 — contact starts)
  jane.smith@example.com · ...      (line 3)
  SUMMARY / EXPERIENCE / EDUCATION  (3 sections, 5 bullets total)

PII-clean: synthetic persona only, no Author / Title / XMP metadata
beyond XeTeX producer string.

Pre-fix snapshot pinned `full_name = "Functional Resume Sample"` and
flagged `name` as missing in completeness; post-fix `full_name = "Jane
Smith"` and `name` clears the floor. Overall score moves 75 → 78.

Unit tests added to extract-fields.test.ts for the selector:
- header-above-name picks the real name (mode 1)
- "Curriculum Vitae" rejected
- "Resume Sample" rejected
- "Jane Smith Resume" still picks (only 1/3 boilerplate)
- no regression: top-line name still picked when no boilerplate
- runner-up confidence ≥ 0.5 after boilerplate rejection (mode 2
  in miniature — guards the threshold-crossing)

Verification:
- npm run typecheck: clean
- npm run test: 174/174 (167 prior baseline + 6 extractName tests
  + 1 new corpus fixture)
- 6 existing corpus snapshots: byte-identical, zero regression
- pdftotext + pdfinfo on new fixture: no real-author tokens, no
  Author/Title metadata

Regenerating the fixture: see the .tex source captured in commit body
section "fixture source" below.

Refs #10

----- fixture source: header_as_name.tex (xelatex) -----
\documentclass[11pt]{article}
\usepackage[margin=1in]{geometry}
\usepackage{enumitem}
\pagestyle{empty}
\setlength{\parindent}{0pt}
\begin{document}
\begin{center}
  {\Huge\bfseries Functional Resume Sample}\\[1.5em]
  {\Large Jane Smith}\\[0.4em]
  123 Example Way, Springfield, IL 62701\\
  jane.smith@example.com $\cdot$ (555) 010-0123 $\cdot$ linkedin.com/in/janesmith
\end{center}
\vspace{1em}
\textbf{SUMMARY}\\
Experienced software engineer with five years of full-stack development experience building scalable web services.

\vspace{0.8em}
\textbf{EXPERIENCE}\\
\textbf{Acme Corp} \hfill Jan 2022 -- Present\\
\emph{Senior Software Engineer}
\begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt]
  \item Built scalable web services with Node.js and Go, handling 50M requests/day.
  \item Led team of three engineers on the payments platform.
  \item Reduced p99 latency by 40\% through caching and query optimisation.
\end{itemize}

\textbf{Initech} \hfill 2020 -- 2022\\
\emph{Software Engineer}
\begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt]
  \item Migrated monolith to microservices using Kubernetes.
  \item Owned authentication and authorisation across all internal tools.
\end{itemize}

\vspace{0.5em}
\textbf{EDUCATION}\\
B.S. Computer Science, Springfield State University, 2020
\end{document}
s-annam added a commit that referenced this pull request Jun 15, 2026
)

Mode 2 of #10: when the real candidate name is set apart below a larger
job-title tagline (e.g. "Product Designer" over "Jane Smith"), extractName
picked the tagline — position + size alone won the name slot, and the
+0.15 contact-cluster proximity bonus from #14 was too small to overturn it.

Re-weight extractName so contact proximity can *change the winner*, not just
nudge confidence:
- A *later* eligible line within ~80pt of the contact cluster now gets +0.4
  (vs +0.15 for the first eligible line). The split gates the strong bonus on
  `i !== firstEligibleIdx`, which keeps the #14 mode-1 fixture (first-eligible
  name) byte-identical.
- A line that looksLikeTitle() gets -0.6 — a job-title tagline must not win
  the name slot. Real names never match the title-keyword set, so this only
  ever penalizes non-name lines.

Verified pre/post-fix on the new fixture: pre-fix picks "Product Designer"
(conf 1.0), post-fix picks "Jane Smith" (conf 0.70). All 6 existing corpus
snapshots + the #14 mode-1 fixture stay byte-identical; standard top-line
names unaffected.

Scope note: #10/#16 framed mode 2 as "right name detected at low confidence
(<=0.5), marked missing." In the current code the actual reproducible failure
is "wrong name (tagline) picked confidently" — the +0.4 first-eligible floor
structurally prevents a sole correct name from landing <=0.5. Fixture + tests
reflect the real defect.

Fixture tests/fixtures/pdfs/unknown/name-set-apart-tagline.pdf is synthetic
(PII-free), generated with reportlab. Regeneration script:

  from reportlab.pdfgen import canvas
  from reportlab.lib.pagesizes import letter
  W, H = letter
  c = canvas.Canvas(PATH, pagesize=letter)
  def line(y, text, size, font="Helvetica", x=72):
      c.setFont(font, size); c.drawString(x, H - y, text)
  line(78,  "Product Designer", 20, "Helvetica-Bold")   # tagline (largest, top)
  line(100, "Jane Smith", 13, "Helvetica-Bold")         # real name
  line(118, "jane.smith@example.com  |  (555) 010-0147  |  San Francisco, CA", 10)
  line(150, "SUMMARY", 12, "Helvetica-Bold")
  line(168, "Product designer with eight years shipping consumer mobile and web", 10)
  line(182, "experiences, from research through high-fidelity delivery and handoff.", 10)
  line(214, "EXPERIENCE", 12, "Helvetica-Bold")
  line(232, "Northwind Labs  —  Senior Product Designer", 11, "Helvetica-Bold")
  line(246, "Jan 2021 - Present", 10)
  line(262, "• Led redesign of the onboarding flow, lifting activation 18% in two quarters.", 10)
  line(276, "• Built and maintained the cross-platform design system used by 30 engineers.", 10)
  line(300, "Brightside Studio  —  Product Designer", 11, "Helvetica-Bold")
  line(314, "Jun 2017 - Dec 2020", 10)
  line(330, "• Designed three mobile apps from concept to launch on iOS and Android.", 10)
  line(344, "• Ran weekly usability sessions and turned findings into shipped changes.", 10)
  line(376, "EDUCATION", 12, "Helvetica-Bold")
  line(394, "State University  —  B.F.A. in Graphic Design", 11, "Helvetica-Bold")
  line(408, "2013 - 2017", 10)
  c.showPage(); c.save()

Resolves #16

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
s-annam pushed a commit that referenced this pull request Jun 25, 2026
#14)

Closes #10 (mode 1). Adds partial defence against mode 2.

The name selector in `extractName()` was scoring "first line in profile +
largest font + title-case" up to 1.0, which lets a Microsoft-style
"Functional Resume Sample" doc-title header win over the real candidate
name on the line below it (reported by @sriyau64 during dogfooding).

The data needed for a proper fix was already on `PdfLine` — y-coordinates
of each profile line and the contact tokens — the selector just never
consumed it. Fix is fully local to `extractName()`:

- Hard rejection for resume-doc-title boilerplate. A line where ≥60% of
  tokens are in {resume, cv, curriculum vitae, sample, template, example,
  chronological, functional, combination, profile, biography, ...} is
  skipped before scoring. "Jane Smith Resume" still passes (1/3
  boilerplate); "Functional Resume Sample", "Curriculum Vitae", "Resume
  Sample" all reject.

- First-eligible-index reindex. When the literal first line is rejected
  as boilerplate, the next surviving candidate inherits the first-line
  +0.4 bonus — it is effectively in the header position. Without this,
  fixing the wrong-name pick would dial the runner-up's confidence to
  0.45, below `ANON_CONTACT_CONFIDENCE_FLOOR = 0.5` in score.ts. The
  completeness scorer would then mark the (correctly-detected) name as
  "missing" — mode 2 of issue #10 manifesting inside the fix for mode 1.
  Reindex puts the surviving candidate over 0.5, so completeness scores
  the name as present.

- Soft contact-cluster proximity bonus (+0.15) when the candidate's y is
  within ~80pt of the email/phone/linkedin line. Backup signal for
  ambiguous header layouts; doesn't override the first-line bonus.

Note on mode 2 in general: the reporter described it as "separately, on
another resume… real name detected only with low confidence — likely
because it was set apart from the contact block." Without a specific
upstream PDF, the broader mode 2 (Tier 1 fails entirely, Tier 1.5 falls
back at 0.5) needs its own repro fixture to investigate properly.
Filing as follow-up.

Fixture: tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf,
a synthetic single-page XeLaTeX export structured as:

  Functional Resume Sample          (Huge, line 0 — the boilerplate)
  Jane Smith                        (Large, line 1 — the real name)
  123 Example Way, ...              (line 2 — contact starts)
  jane.smith@example.com · ...      (line 3)
  SUMMARY / EXPERIENCE / EDUCATION  (3 sections, 5 bullets total)

PII-clean: synthetic persona only, no Author / Title / XMP metadata
beyond XeTeX producer string.

Pre-fix snapshot pinned `full_name = "Functional Resume Sample"` and
flagged `name` as missing in completeness; post-fix `full_name = "Jane
Smith"` and `name` clears the floor. Overall score moves 75 → 78.

Unit tests added to extract-fields.test.ts for the selector:
- header-above-name picks the real name (mode 1)
- "Curriculum Vitae" rejected
- "Resume Sample" rejected
- "Jane Smith Resume" still picks (only 1/3 boilerplate)
- no regression: top-line name still picked when no boilerplate
- runner-up confidence ≥ 0.5 after boilerplate rejection (mode 2
  in miniature — guards the threshold-crossing)

Verification:
- npm run typecheck: clean
- npm run test: 174/174 (167 prior baseline + 6 extractName tests
  + 1 new corpus fixture)
- 6 existing corpus snapshots: byte-identical, zero regression
- pdftotext + pdfinfo on new fixture: no real-author tokens, no
  Author/Title metadata

Regenerating the fixture: see the .tex source captured in commit body
section "fixture source" below.

Refs #10

----- fixture source: header_as_name.tex (xelatex) -----
\documentclass[11pt]{article}
\usepackage[margin=1in]{geometry}
\usepackage{enumitem}
\pagestyle{empty}
\setlength{\parindent}{0pt}
\begin{document}
\begin{center}
  {\Huge\bfseries Functional Resume Sample}\\[1.5em]
  {\Large Jane Smith}\\[0.4em]
  123 Example Way, Springfield, IL 62701\\
  jane.smith@example.com $\cdot$ (555) 010-0123 $\cdot$ linkedin.com/in/janesmith
\end{center}
\vspace{1em}
\textbf{SUMMARY}\\
Experienced software engineer with five years of full-stack development experience building scalable web services.

\vspace{0.8em}
\textbf{EXPERIENCE}\\
\textbf{Acme Corp} \hfill Jan 2022 -- Present\\
\emph{Senior Software Engineer}
\begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt]
  \item Built scalable web services with Node.js and Go, handling 50M requests/day.
  \item Led team of three engineers on the payments platform.
  \item Reduced p99 latency by 40\% through caching and query optimisation.
\end{itemize}

\textbf{Initech} \hfill 2020 -- 2022\\
\emph{Software Engineer}
\begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt]
  \item Migrated monolith to microservices using Kubernetes.
  \item Owned authentication and authorisation across all internal tools.
\end{itemize}

\vspace{0.5em}
\textbf{EDUCATION}\\
B.S. Computer Science, Springfield State University, 2020
\end{document}
s-annam added a commit that referenced this pull request Jun 25, 2026
)

Mode 2 of #10: when the real candidate name is set apart below a larger
job-title tagline (e.g. "Product Designer" over "Jane Smith"), extractName
picked the tagline — position + size alone won the name slot, and the
+0.15 contact-cluster proximity bonus from #14 was too small to overturn it.

Re-weight extractName so contact proximity can *change the winner*, not just
nudge confidence:
- A *later* eligible line within ~80pt of the contact cluster now gets +0.4
  (vs +0.15 for the first eligible line). The split gates the strong bonus on
  `i !== firstEligibleIdx`, which keeps the #14 mode-1 fixture (first-eligible
  name) byte-identical.
- A line that looksLikeTitle() gets -0.6 — a job-title tagline must not win
  the name slot. Real names never match the title-keyword set, so this only
  ever penalizes non-name lines.

Verified pre/post-fix on the new fixture: pre-fix picks "Product Designer"
(conf 1.0), post-fix picks "Jane Smith" (conf 0.70). All 6 existing corpus
snapshots + the #14 mode-1 fixture stay byte-identical; standard top-line
names unaffected.

Scope note: #10/#16 framed mode 2 as "right name detected at low confidence
(<=0.5), marked missing." In the current code the actual reproducible failure
is "wrong name (tagline) picked confidently" — the +0.4 first-eligible floor
structurally prevents a sole correct name from landing <=0.5. Fixture + tests
reflect the real defect.

Fixture tests/fixtures/pdfs/unknown/name-set-apart-tagline.pdf is synthetic
(PII-free), generated with reportlab. Regeneration script:

  from reportlab.pdfgen import canvas
  from reportlab.lib.pagesizes import letter
  W, H = letter
  c = canvas.Canvas(PATH, pagesize=letter)
  def line(y, text, size, font="Helvetica", x=72):
      c.setFont(font, size); c.drawString(x, H - y, text)
  line(78,  "Product Designer", 20, "Helvetica-Bold")   # tagline (largest, top)
  line(100, "Jane Smith", 13, "Helvetica-Bold")         # real name
  line(118, "jane.smith@example.com  |  (555) 010-0147  |  San Francisco, CA", 10)
  line(150, "SUMMARY", 12, "Helvetica-Bold")
  line(168, "Product designer with eight years shipping consumer mobile and web", 10)
  line(182, "experiences, from research through high-fidelity delivery and handoff.", 10)
  line(214, "EXPERIENCE", 12, "Helvetica-Bold")
  line(232, "Northwind Labs  —  Senior Product Designer", 11, "Helvetica-Bold")
  line(246, "Jan 2021 - Present", 10)
  line(262, "• Led redesign of the onboarding flow, lifting activation 18% in two quarters.", 10)
  line(276, "• Built and maintained the cross-platform design system used by 30 engineers.", 10)
  line(300, "Brightside Studio  —  Product Designer", 11, "Helvetica-Bold")
  line(314, "Jun 2017 - Dec 2020", 10)
  line(330, "• Designed three mobile apps from concept to launch on iOS and Android.", 10)
  line(344, "• Ran weekly usability sessions and turned findings into shipped changes.", 10)
  line(376, "EDUCATION", 12, "Helvetica-Bold")
  line(394, "State University  —  B.F.A. in Graphic Design", 11, "Helvetica-Bold")
  line(408, "2013 - 2017", 10)
  c.showPage(); c.save()

Resolves #16

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
s-annam pushed a commit that referenced this pull request Jun 28, 2026
#14)

Closes #10 (mode 1). Adds partial defence against mode 2.

The name selector in `extractName()` was scoring "first line in profile +
largest font + title-case" up to 1.0, which lets a Microsoft-style
"Functional Resume Sample" doc-title header win over the real candidate
name on the line below it (reported by @sriyau64 during dogfooding).

The data needed for a proper fix was already on `PdfLine` — y-coordinates
of each profile line and the contact tokens — the selector just never
consumed it. Fix is fully local to `extractName()`:

- Hard rejection for resume-doc-title boilerplate. A line where ≥60% of
  tokens are in {resume, cv, curriculum vitae, sample, template, example,
  chronological, functional, combination, profile, biography, ...} is
  skipped before scoring. "Jane Smith Resume" still passes (1/3
  boilerplate); "Functional Resume Sample", "Curriculum Vitae", "Resume
  Sample" all reject.

- First-eligible-index reindex. When the literal first line is rejected
  as boilerplate, the next surviving candidate inherits the first-line
  +0.4 bonus — it is effectively in the header position. Without this,
  fixing the wrong-name pick would dial the runner-up's confidence to
  0.45, below `ANON_CONTACT_CONFIDENCE_FLOOR = 0.5` in score.ts. The
  completeness scorer would then mark the (correctly-detected) name as
  "missing" — mode 2 of issue #10 manifesting inside the fix for mode 1.
  Reindex puts the surviving candidate over 0.5, so completeness scores
  the name as present.

- Soft contact-cluster proximity bonus (+0.15) when the candidate's y is
  within ~80pt of the email/phone/linkedin line. Backup signal for
  ambiguous header layouts; doesn't override the first-line bonus.

Note on mode 2 in general: the reporter described it as "separately, on
another resume… real name detected only with low confidence — likely
because it was set apart from the contact block." Without a specific
upstream PDF, the broader mode 2 (Tier 1 fails entirely, Tier 1.5 falls
back at 0.5) needs its own repro fixture to investigate properly.
Filing as follow-up.

Fixture: tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf,
a synthetic single-page XeLaTeX export structured as:

  Functional Resume Sample          (Huge, line 0 — the boilerplate)
  Jane Smith                        (Large, line 1 — the real name)
  123 Example Way, ...              (line 2 — contact starts)
  jane.smith@example.com · ...      (line 3)
  SUMMARY / EXPERIENCE / EDUCATION  (3 sections, 5 bullets total)

PII-clean: synthetic persona only, no Author / Title / XMP metadata
beyond XeTeX producer string.

Pre-fix snapshot pinned `full_name = "Functional Resume Sample"` and
flagged `name` as missing in completeness; post-fix `full_name = "Jane
Smith"` and `name` clears the floor. Overall score moves 75 → 78.

Unit tests added to extract-fields.test.ts for the selector:
- header-above-name picks the real name (mode 1)
- "Curriculum Vitae" rejected
- "Resume Sample" rejected
- "Jane Smith Resume" still picks (only 1/3 boilerplate)
- no regression: top-line name still picked when no boilerplate
- runner-up confidence ≥ 0.5 after boilerplate rejection (mode 2
  in miniature — guards the threshold-crossing)

Verification:
- npm run typecheck: clean
- npm run test: 174/174 (167 prior baseline + 6 extractName tests
  + 1 new corpus fixture)
- 6 existing corpus snapshots: byte-identical, zero regression
- pdftotext + pdfinfo on new fixture: no real-author tokens, no
  Author/Title metadata

Regenerating the fixture: see the .tex source captured in commit body
section "fixture source" below.

Refs #10

----- fixture source: header_as_name.tex (xelatex) -----
\documentclass[11pt]{article}
\usepackage[margin=1in]{geometry}
\usepackage{enumitem}
\pagestyle{empty}
\setlength{\parindent}{0pt}
\begin{document}
\begin{center}
  {\Huge\bfseries Functional Resume Sample}\\[1.5em]
  {\Large Jane Smith}\\[0.4em]
  123 Example Way, Springfield, IL 62701\\
  jane.smith@example.com $\cdot$ (555) 010-0123 $\cdot$ linkedin.com/in/janesmith
\end{center}
\vspace{1em}
\textbf{SUMMARY}\\
Experienced software engineer with five years of full-stack development experience building scalable web services.

\vspace{0.8em}
\textbf{EXPERIENCE}\\
\textbf{Acme Corp} \hfill Jan 2022 -- Present\\
\emph{Senior Software Engineer}
\begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt]
  \item Built scalable web services with Node.js and Go, handling 50M requests/day.
  \item Led team of three engineers on the payments platform.
  \item Reduced p99 latency by 40\% through caching and query optimisation.
\end{itemize}

\textbf{Initech} \hfill 2020 -- 2022\\
\emph{Software Engineer}
\begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt]
  \item Migrated monolith to microservices using Kubernetes.
  \item Owned authentication and authorisation across all internal tools.
\end{itemize}

\vspace{0.5em}
\textbf{EDUCATION}\\
B.S. Computer Science, Springfield State University, 2020
\end{document}
s-annam added a commit that referenced this pull request Jun 28, 2026
)

Mode 2 of #10: when the real candidate name is set apart below a larger
job-title tagline (e.g. "Product Designer" over "Jane Smith"), extractName
picked the tagline — position + size alone won the name slot, and the
+0.15 contact-cluster proximity bonus from #14 was too small to overturn it.

Re-weight extractName so contact proximity can *change the winner*, not just
nudge confidence:
- A *later* eligible line within ~80pt of the contact cluster now gets +0.4
  (vs +0.15 for the first eligible line). The split gates the strong bonus on
  `i !== firstEligibleIdx`, which keeps the #14 mode-1 fixture (first-eligible
  name) byte-identical.
- A line that looksLikeTitle() gets -0.6 — a job-title tagline must not win
  the name slot. Real names never match the title-keyword set, so this only
  ever penalizes non-name lines.

Verified pre/post-fix on the new fixture: pre-fix picks "Product Designer"
(conf 1.0), post-fix picks "Jane Smith" (conf 0.70). All 6 existing corpus
snapshots + the #14 mode-1 fixture stay byte-identical; standard top-line
names unaffected.

Scope note: #10/#16 framed mode 2 as "right name detected at low confidence
(<=0.5), marked missing." In the current code the actual reproducible failure
is "wrong name (tagline) picked confidently" — the +0.4 first-eligible floor
structurally prevents a sole correct name from landing <=0.5. Fixture + tests
reflect the real defect.

Fixture tests/fixtures/pdfs/unknown/name-set-apart-tagline.pdf is synthetic
(PII-free), generated with reportlab. Regeneration script:

  from reportlab.pdfgen import canvas
  from reportlab.lib.pagesizes import letter
  W, H = letter
  c = canvas.Canvas(PATH, pagesize=letter)
  def line(y, text, size, font="Helvetica", x=72):
      c.setFont(font, size); c.drawString(x, H - y, text)
  line(78,  "Product Designer", 20, "Helvetica-Bold")   # tagline (largest, top)
  line(100, "Jane Smith", 13, "Helvetica-Bold")         # real name
  line(118, "jane.smith@example.com  |  (555) 010-0147  |  San Francisco, CA", 10)
  line(150, "SUMMARY", 12, "Helvetica-Bold")
  line(168, "Product designer with eight years shipping consumer mobile and web", 10)
  line(182, "experiences, from research through high-fidelity delivery and handoff.", 10)
  line(214, "EXPERIENCE", 12, "Helvetica-Bold")
  line(232, "Northwind Labs  —  Senior Product Designer", 11, "Helvetica-Bold")
  line(246, "Jan 2021 - Present", 10)
  line(262, "• Led redesign of the onboarding flow, lifting activation 18% in two quarters.", 10)
  line(276, "• Built and maintained the cross-platform design system used by 30 engineers.", 10)
  line(300, "Brightside Studio  —  Product Designer", 11, "Helvetica-Bold")
  line(314, "Jun 2017 - Dec 2020", 10)
  line(330, "• Designed three mobile apps from concept to launch on iOS and Android.", 10)
  line(344, "• Ran weekly usability sessions and turned findings into shipped changes.", 10)
  line(376, "EDUCATION", 12, "Helvetica-Bold")
  line(394, "State University  —  B.F.A. in Graphic Design", 11, "Helvetica-Bold")
  line(408, "2013 - 2017", 10)
  c.showPage(); c.save()

Resolves #16

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
s-annam added a commit that referenced this pull request Jul 9, 2026
…rap + reuse

Reviewer feedback from @Vaishnavi1709 (CHANGES_REQUESTED) and @Samhit21.

Blocking:
- #1 Added LinkedIn/GitHub link (guided picker → addedProfiles) now back-fills
  the empty legacy `_url` slot the scorer + contact gap read, so the add moves
  the score. applyProfileOverrides returns the back-filled keys.
- #2 Anonymous scorer now treats a GitHub link as satisfying the
  "Professional profile" completeness check (parity with the ContactCard rule),
  so a GitHub-but-no-LinkedIn résumé isn't docked / listed as missing LinkedIn.
- #3 applyOverrides returns an edited `fieldConfidence` (user-affirmed contact
  edits bumped to present, clears dropped to 0), threaded onto both the score
  input and displayResult so a typed-in / added link stops reading as absent
  against the frozen base parse.
- #4 useDownloadReport.download returns a boolean; the dialog closes only on
  success, so a generation failure no longer unmounts the error UI.
- #5 New shared src/lib/pdf/text-wrap.ts breaks a single overlong word at char
  boundaries (opt-in); the audit-report identity header uses it so a long URL
  no longer overflows the page.

Secondary:
- #6 Audit-report identity sourced via buildContact + basicsFromContact (no full
  buildAtsResumeModel + toJsonResume walk just to read .basics).
- #7 render-audit-report + serialize are now dynamic-imported in
  useDownloadReport, keeping the ~470 LOC report path out of the entry chunk.
- #8 Extracted src/lib/download/blob-download.ts (slugifyName +
  triggerBlobDownload); useDownloadPdf/useDownloadReport/useReportGap consume it.
- #9 wrapWordsToLines shared by both PDF renderers (render-ats-pdf keeps its
  no-mid-word-break contract; the report opts into breaking).
- #12 Achievements now map to JSON Resume `awards[]` (title + optional date;
  omitted entirely when absent, so achievement-free exports are byte-identical).

Nits:
- #14 usernameFromUrl drops its unreachable try/catch.
- #16 render-audit-report embeds Helvetica + Helvetica-Bold via Promise.all.
- #17 LinkedIn non-profile-path carve-out is now a `HostRule.nonProfilePath`
  field instead of an inline special case in the classify loop.

Deferred (with rationale in the PR reply): #10 override-channel consolidation,
#11 profiles-mirror memo split, #13 ISO-3166 location country codes, #15
unconditional profiles assign (breaks the empty-override no-op invariant), and
@Samhit21's non-profile-LinkedIn label nit (by-design).
s-annam added a commit that referenced this pull request Jul 10, 2026
…me export, shareable audit report (#400) (#421)

* feat(export): download-export standards — profiles[] model, JSON Resume export, shareable audit report (#400)

Batch #400. Three sub-issues accumulated onto one branch:

- #335 profiles[] model: add ProfileLink {url,network,kind} + profiles[] to
  ResumeData; contributor-extensible host registry (classifyProfile). Extraction
  mirrors the 4 legacy link keys into profiles[] — legacy keys stay the scoring/
  snapshot source of truth, so every corpus snapshot is byte-unchanged (no
  re-bake). Variable-length contact-link UI: "+ Add link", per-profile
  EditableField edit, delete, unknown-host hostname labels, brand-neutral
  "Professional profile" row.

- #334 JSON Resume export: pure toJsonResume(AtsResumeModel) -> JsonResume;
  basics.profiles from profiles[]; free-form dates degrade to raw strings, never
  fabricated; layout-contract test asserts every emitted heading re-recognizes via
  matchSectionHeader(); embed resume.json as a pdf-lib attachment (text layer
  untouched -> parse->export->re-parse roundtrip byte-identical); pdf-lib stays
  lazy.

- #343 shareable audit report: secondary "Download report" control (Download PDF
  stays the primary CTA) with PDF/JSON format + include-identity checkbox
  (default OFF); render-audit-report.ts (lazy pdf-lib) + report/serialize.ts.
  Privacy gate: identity-off artifacts carry zero PII.

Adversarial review: 1 round, 1 blocking finding fixed + covered — the JSON audit
report embedded score.bullets verbatim (résumé accomplishment text: employer/
project names) even with identity off; now stripped unconditionally at the
builder, with a bullets-bearing fixture added so the gate actually covers it.

Deferred to follow-up issues (non-blocking, reviewer-verified safe): the
profile-registry <-> extract/contact circular dep (function-scoped, no init
hazard) and the render-ats-pdf <-> render-audit-report clone family (renderers
have genuinely diverged needs).

Closes #335
Closes #334
Closes #343
Refs #400

* refactor(export): clear Fallow findings on PR #421

Address the three Fallow code-scanning comments on PR #421, all
logic-preserving and snapshot-safe (44 corpus snapshots byte-unchanged):

- Circular dep (profile-registry ↔ extract/contact): extract the shared
  normalizeUrl / urlSlug / LINKEDIN_NONPROFILE_RE helpers into a new leaf
  src/lib/contact/url-utils.ts. Both modules now import the leaf; neither
  imports the other. Closes the cycle #423 was filed to track.

- Cognitive complexity in toJsonResume (23 → under threshold): move the
  per-entry section-kind dispatch out of the double loop into appendEntry().

- Cognitive complexity in renderAuditReportPdf (17 → under threshold):
  extract the identity-header and layout-flags clusters into
  drawIdentityHeader() / drawLayoutFlags() helpers.

The render-ats-pdf ↔ render-audit-report clone family stays deferred to #424
(the renderers have genuinely diverged font/layout needs).

Refs #400, #421

* feat(contact): guided network picker for professional-profile add

The empty "Professional profile" links row and the extra-links "+ Add"
both handed a naive user a bare `https://…` field with no cue for what a
professional profile is or which networks we accept. Replace both with a
guided ProfileLinkAdd affordance: an inviting pill that expands to
tappable network chips (LinkedIn / GitHub / GitLab / Portfolio) which
pre-fill the host prefix and drop the caret at the handle, plus a helper
line naming the rest of the recognized hosts.

- profile-registry.ts: add a UI-only `quickPick` hint to the LinkedIn /
  GitHub / GitLab host rules and derive `PROFILE_QUICK_PICKS` (+ a
  Portfolio catch-all) and `otherRecognizedNetworks()` from PROFILE_HOSTS
  — one source of truth, so a new host auto-surfaces as a chip / helper
  example.
- ProfileLinkAdd.tsx: new shared picker built from AddPill + the
  @design-system Button; reused by both add points (a required single
  slot that collapses after one, and the multi-add extra-links row).
- ContactDetails.renderLink: an absent required link (only the
  brand-neutral "Professional profile" row reaches here) now opens the
  guided picker instead of a bare warning-pill field; a low-confidence
  value still edits in place.
- ContactExtraLinks: "+ Add link" → guided "+ Add a profile".

Reframes the empty state from a warning into an invitation with choices
(the gap is still counted by the AttentionStrip). No new parse path — the
committed URL is classified by the same classifyProfile sink.

* fix(export): address PR #421 review — score-moving edits, error UI, wrap + reuse

Reviewer feedback from @Vaishnavi1709 (CHANGES_REQUESTED) and @Samhit21.

Blocking:
- #1 Added LinkedIn/GitHub link (guided picker → addedProfiles) now back-fills
  the empty legacy `_url` slot the scorer + contact gap read, so the add moves
  the score. applyProfileOverrides returns the back-filled keys.
- #2 Anonymous scorer now treats a GitHub link as satisfying the
  "Professional profile" completeness check (parity with the ContactCard rule),
  so a GitHub-but-no-LinkedIn résumé isn't docked / listed as missing LinkedIn.
- #3 applyOverrides returns an edited `fieldConfidence` (user-affirmed contact
  edits bumped to present, clears dropped to 0), threaded onto both the score
  input and displayResult so a typed-in / added link stops reading as absent
  against the frozen base parse.
- #4 useDownloadReport.download returns a boolean; the dialog closes only on
  success, so a generation failure no longer unmounts the error UI.
- #5 New shared src/lib/pdf/text-wrap.ts breaks a single overlong word at char
  boundaries (opt-in); the audit-report identity header uses it so a long URL
  no longer overflows the page.

Secondary:
- #6 Audit-report identity sourced via buildContact + basicsFromContact (no full
  buildAtsResumeModel + toJsonResume walk just to read .basics).
- #7 render-audit-report + serialize are now dynamic-imported in
  useDownloadReport, keeping the ~470 LOC report path out of the entry chunk.
- #8 Extracted src/lib/download/blob-download.ts (slugifyName +
  triggerBlobDownload); useDownloadPdf/useDownloadReport/useReportGap consume it.
- #9 wrapWordsToLines shared by both PDF renderers (render-ats-pdf keeps its
  no-mid-word-break contract; the report opts into breaking).
- #12 Achievements now map to JSON Resume `awards[]` (title + optional date;
  omitted entirely when absent, so achievement-free exports are byte-identical).

Nits:
- #14 usernameFromUrl drops its unreachable try/catch.
- #16 render-audit-report embeds Helvetica + Helvetica-Bold via Promise.all.
- #17 LinkedIn non-profile-path carve-out is now a `HostRule.nonProfilePath`
  field instead of an inline special case in the classify loop.

Deferred (with rationale in the PR reply): #10 override-channel consolidation,
#11 profiles-mirror memo split, #13 ISO-3166 location country codes, #15
unconditional profiles assign (breaks the empty-override no-op invariant), and
@Samhit21's non-profile-LinkedIn label nit (by-design).

* refactor(pdf): drop wrapWordsToLines cognitive complexity below fallow threshold

Collapse the duplicated break-or-seat branches into a single flush-then-seat
loop. The fits / doesn't-fit paths both ended in the same "break the overlong
word, else emit whole" logic; hoisting the flush and running that seating once
drops cognitive complexity from 18 to under the 15 threshold (PR #421 fallow /
Samhit review), with identical output — the 5 text-wrap tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFMiiujnuNq6FsXYUXMehM

---------

Co-authored-by: Claude Opus 4.8 <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.

Name detection misreads a document header as the candidate name (and misses the real name nearby)

2 participants