Conversation
Single-column résumés that put the section name in a narrow left rail —
sharing the row with the section's first entry, or vertically stacked as the
left cell of a skills grid — left the header unrecognized. matchSectionHeader
is exact-match / head-noun-last, so an inline leading-token header
("Experience Staff Engineer, Platform Aug 2024 - Present") and a stacked
"Technical" / "Skills" grid label never opened their sections: skills leaked
into the experience band and the date anchor grabbed skill rows as role-1
title/company (bullets=0, education swallowed into a spurious role).
- add a single-column-gated leading-token recognizer in classifyLine: a line
whose leading item exactly matches a section alias opens the section and
retains the row remainder as its first content line. Guarded against false
opens on a styled "Experience:" label or a prose remainder — trailing-colon
reject, remainder must lead uppercase with no terminal sentence punctuation
and carry a strong date token (month/season+year, slash date, Present) or a
degree/institution tell in a leading window, not a bare year span.
- add a stacked rail-label join in splitIntoSections: two consecutive rows
sharing a rail x whose leading tokens join to a canonical alias open that
section, gated on each row being a value grid (>=2 short cells).
- add scripts/fixtures/gen-label-rail.mjs (pdf-lib) + a synthetic label-rail
fixture under tests/fixtures/pdfs/unknown/ and its snapshot.
- does not touch the two-column detector or experience headerLookback.
Resolves #355
Deploying resumelint with
|
| Latest commit: |
1098d23
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f633151c.resumelint.pages.dev |
| Branch Preview URL: | https://gh-355.resumelint.pages.dev |
rohithgollapalli
left a comment
There was a problem hiding this comment.
Reviewed the full diff plus the surrounding sections.ts machinery it depends on. Well-guarded and heavily tested — no blocking findings.
Verified clean:
- Regex statefulness —
remainderLooksLikeEntrycalls.test()on importedDATE_RANGE_RE(/i),DEGREE_RE(no flags),INSTITUTION_HINTS(/i); none global, so nolastIndexflakiness. - Field consistency —
isGridValueRowreadsit.str, matchingPdfTextItem/mergeItemText; the test helper mapstext→strand models item widths the same way the recognizer reads them. - Loop-var scoping — the new stacked-rail block reads
openedRealSection/seenContactInProfile, both set before it fires (contact line → flag → next row un-gated), matching the fixture ordering. - Item-boundary FP guard holds for the common case — a normal
Education Coordinatortitle is one text run, soitem[0]never equals a bare alias. The FP-rejection tests (colon lead, lowercase-connective lead, bare-year span, prose-that-joins-to-Technical Skills) are non-vacuous. - Fixture PII — extracted the committed binary with
pdftotext:JANE SMITH / jane.smith@example.com / (312) 555-0123, fully synthetic and phone-policy-compliant.
One low-severity, non-blocking residual left inline on matchLeadingTokenHeader (a real Experience Designer-type title with a split first item could false-open experience) — small blast radius, inherent to the item-boundary approach; a one-line acknowledgement comment would suffice. Nice work with the adversarial-review FP guards. LGTM 🎉
The 923ad0a label-rail recognizers assumed the section keyword shares a row with its content (inline) or forms a clean stacked pair over a grid. A real single-column, left-label-rail resume breaks both: the rail holds the section names (x~26) while the body (x>=99) carries a MULTI-COLUMN skills grid whose cells sit at irregular per-cell baselines, so groupIntoLines fragments the grid into one line per cell. The stacked Technical/Skills label rows are no longer adjacent clean grid rows, so tryStackedRailLabel bails, skills scatter into the experience band, the date anchor grabs skill rows as role title/company, and every role loses its bullets (score collapses). - add splitByLabelRail: a single-column-only pre-pass in splitIntoSections that detects a narrow section-label rail (min left-edge cluster separated from the body by >=40pt), recovers each label (alone / stacked-join / inline leading-token), then assigns every body line to the label whose y-band it falls in. Assignment is by absolute (page, y), so grid fragmentation and pdfjs emission order don't matter. - key rail detection, banding, and assignment on (page, y) throughout so a multi-page rail resume can't scramble page-2 content into page-1 sections (a label owns only same-page body below it; a pre-label page-N line continues the prior page's section). - RAIL_BAND_OVERLAP_TOL is sub-line jitter (5pt), not a line-height, so a label sitting ~one line below the prior section's last bullet can't steal that bullet. - mergeRowsByBaseline (rail path, date-anchored sections only) merges lines the 50pt column-gap split had stranded, restoring glyphless-bullet detection. Skills excluded (its splitter treats a single space as intra-token). - fold in the 923ad0a inline/same-row-stacked recognizers with all their FP guards (styled `Experience:`, prose remainder, bare-year-span, degree / institution window) preserved. - inline path: add a standalone-alias gap guard (STANDALONE_ALIAS_MIN_GAP) so a real title whose first word is its own item ("Experience Designer") can't false-open a section — a genuine rail header has a rail->body x-jump, a compound title has ordinary word spacing (review feedback, PR #359). - add scripts/fixtures/gen-label-rail-grid.mjs (synthetic persona) + a fragmented-grid label-rail fixture and its snapshot; regression tests for the multi-page and tight-spacing hazards. - does not touch detectColumnBoundaries / the two-column detector / experience headerLookback. Refs #355
The label-rail recognizers tripped two fallow complexity findings on PR #359 (report-only, but worth clearing): splitByLabelRail (cyclomatic 27 / cognitive 40) and matchLeadingTokenHeader (cognitive 16). Both are pure extract-function refactors — no behavior change. - matchLeadingTokenHeader: lift the inner per-section prefix match into matchAliasPrefix (the aliases are disjoint, so the first keyword hit is the only hit — .find replaces the nested loop + continues). - splitByLabelRail: split into recoverRailLabels (rail-label recovery, with the stacked-join branch further extracted to tryStackedLabel) and buildRailSections (band assignment + row merge); bandFor and mergeRailEntryRows lifted to module scope so buildRailSections clears the cognitive threshold too. Verified identical output: sections suite 52 passed, real #355 résumé probe unchanged (score 64, skills 10, bullets 2/10/7/3). Refs #355
|
Thanks for the review, @rohithgollapalli 🙏 — all outstanding items are resolved:
Functionality verified end-to-end on the real résumé that motivated #355 — both via the Merging with admin bypass. |
Summary
Single-column résumés that put the section name in a narrow left rail — sharing the row with the section's first entry (
Experience Staff Engineer, Platform Aug 2024 - Present), or vertically stacked as the left cell of a skills grid (Technical/Skills) — left the header unrecognized.matchSectionHeaderis exact-match / head-noun-last, so neither an inline leading-token header nor a stacked grid label opened its section: skills leaked into the experience band and the date anchor grabbed skill rows as role-1 title/company (bullets=0, education swallowed into a spurious role).Fix is in section-header recognition (
sections.ts), single-column only. The two-column detector and experienceheaderLookbackare untouched (this résumé is genuinely single-column —triggers: []).Closes #355
What changed
classifyLine, single-column-gated): a line whose leading item exactly matches a section alias opens the section and retains the row remainder as its first content line — so the inline title/date reach the entry parser.splitIntoSections): two consecutive rows sharing a rail x whose leading tokens join to a canonical alias open that section, gated on each row being a value grid (≥2 short cells).YYYY-YYYYspan) or a degree/institution tell in a leading window.sections.test.ts.Update (
f77cf7f) — real-résumé repro was still failing; extended the fixDogfooding the canonical #355 résumé against the branch showed
923ad0aalone did not resolve it: score 21,skills(0), all rolesbullets=0, role-1 title/company came out as skill tokens. Root cause was a compounding geometry the original synthetic fixture under-modeled: a genuine left-label-rail layout (section names at x≈26, body at x≥99) whose multi-column skills grid has irregular per-cell baselines, sogroupIntoLinesfragments it one-line-per-cell — the stackedTechnical/Skillslabel rows are no longer adjacent clean grid rows,tryStackedRailLabelbails, and skills scatter into experience.splitByLabelRail— a single-column-only pre-pass: detect the narrow rail (min left-edge cluster ≥40pt from the body), recover each label (alone / stacked-join / inline leading-token), then assign every body line to the label whose y-band it falls in. Assignment is by absolute (page, y), so grid fragmentation and pdfjs emission order don't matter.(page, y)keying throughout so a multi-page rail résumé can't scramble page-2 content into page-1 sections.RAIL_BAND_OVERLAP_TOL= 5pt (sub-line jitter, not a line-height) so a label ~one line below the prior section's last bullet can't steal that bullet.mergeRowsByBaseline(rail path, date-anchored sections only) rejoins lines the 50pt column-gap split had stranded, restoring glyphless-bullet detection.STANDALONE_ALIAS_MIN_GAP = 12pt, review feedback): a real title whose first word is its own item (Experience Designer) can't false-open a section — a genuine rail header has a rail→body x-jump, a compound title has ordinary word spacing.gen-label-rail-grid.mjs+ fragmented-grid fixture + snapshot; regression tests for the multi-page, tight-spacing, and standalone-alias hazards.Result on the real repro: score 21 → 64,
skills(0)→skills(10), roles bullets 2 / 10 / 7 / 3.Adversarial review
Experience:/ prose-year label lines false-opening) → fixed via the FP guards. R2 converged.(page,y)keying) and a 15pt tolerance stealing the prior section's last bullet (dropped to 5pt); both regression-tested (fails@15/passes@5). R2 = reviewer (Rohith) approved; his one non-blocking residual (single-word alias vs. a real compound title) closed by the standalone-alias gap guard.Test plan
npm run testgreen (1641 passed; corpus 41/41, no snapshot regressions)npm run typecheck/npm run lintcleannpm run verifygreen (passed on push)JANE SMITHandJORDAN BLAKE,@example.com,(312) 555-0123)