Problem
The relevant-coursework continuation loop in extractEducation over-consumes lines. When a bullet course item is followed by non-bullet lines, the loop greedily joins every following line into the current course until it hits a bullet, a degree, an institution hint, or a date-only line — and marks each joined line consumed, removing it from entry detection.
That stop condition is too loose. Two real-résumé inputs slip through and get wrongly swallowed into the previous course item (and lost from the parsed entry):
- Acronym-only / hint-less schools.
INSTITUTION_HINTS only matches University|College|Institute|School|Academy|Polytechnic (src/lib/heuristics/regex.ts:289). Schools like MIT, UC Berkeley, or Stanford don't match, so in a School / Degree ordering the school line gets joined into the trailing course of the prior entry and consumed — that entry then loses its institution.
- Trailing prose. Lines like
GPA: 3.8 or Minor in Economics match none of the stop tests, so they get appended onto the last course title and consumed.
Reported by Samhit in the #resumelint Slack as a follow-up to the coursework parse work (PR #165, and #169 which shipped via #173).
Affected code
src/lib/heuristics/extract/education.ts:216-238 — the coursework recovery loop:
for (let i = 0; i < ls.length; i++) {
if (!isBulletLine(ls[i])) continue;
let item = stripBullet(ls[i].text);
const span = [i];
let j = i + 1;
while (
j < ls.length &&
!isBulletLine(ls[j]) &&
!DEGREE_RE.test(ls[j].text) &&
!INSTITUTION_HINTS.test(ls[j].text) &&
!isDateOnlyLine(ls[j].text)
) {
item += ` ${ls[j].text.trim()}`; // ← joins ANY non-matching line
span.push(j);
j++;
}
item = item.trim();
if (/^[A-Z0-9]/.test(item)) {
coursework.push(item);
for (const k of span) consumed.add(k);
}
i = j - 1;
}
Relevant helpers: DEGREE_RE / INSTITUTION_HINTS (src/lib/heuristics/regex.ts:286-290), isDateOnlyLine (education.ts:136), isBulletLine / stripBullet (imported from ../shared-side helpers).
Root cause
The continuation logic is deny-list driven (join unless it looks like a degree/institution/date/bullet). The deny-list is incomplete: it can't see hint-less schools or prose notes. The fix is to make the join opt-in — only absorb a line that actually looks like a wrapped continuation of the bullet — and/or cap the run length.
Proposed fix
Tighten the continuation stop condition. Either approach (or both) is acceptable:
- Cap at one continuation line. A wrapped grid cell almost never spills past one line. Limit the inner loop to a single absorbed line (
span.length <= 2). This alone prevents runaway swallowing of a whole following entry.
- Only join lines that look like a wrap. Add a positive
looksLikeWrap(line) guard before absorbing: the continuation should read as a sentence fragment (leading lowercase word, or no strong "new field" signal). Reject lines that look like a standalone field:
- all-caps / Title-Case short tokens that could be an acronym school (e.g.
MIT, UC Berkeley),
- lines matching a
GPA[:\s] / ^Minor\b / ^Major\b style label,
- lines that are themselves Title-Case headers.
Recommended: implement (1) as the hard cap and layer (2)'s reject-patterns so a single trailing prose line (GPA: 3.8) is still not absorbed. Keep the existing /^[A-Z0-9]/ course-title acceptance guard unchanged.
Step-by-step
- In
education.ts, extract a small helper isCourseworkContinuation(line: string): boolean that returns false for: acronym/Title-Case standalone tokens, GPA/Minor/Major prose labels, and any line already caught by DEGREE_RE/INSTITUTION_HINTS/isDateOnlyLine/isBulletLine.
- Replace the inner
while condition to require isCourseworkContinuation(ls[j].text) and cap absorption at one line (j - i <= 1 before the increment, i.e. at most one continuation appended).
- Leave the
consumed bookkeeping and entry-grouping below (education.ts:240+) untouched — once the loop stops over-consuming, the school/prose lines flow back into entry detection automatically.
Acceptance criteria
Notes
- The corpus fixture
tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.pdf exercises the wrap path — verify its snapshot does not regress, and consider a new fixture only if a unit test can't reproduce the School/Degree-acronym case cleanly.
- Scope is the coursework recovery loop only; do not touch the entry-grouping or scoring logic below it.
Problem
The relevant-coursework continuation loop in
extractEducationover-consumes lines. When a bullet course item is followed by non-bullet lines, the loop greedily joins every following line into the current course until it hits a bullet, a degree, an institution hint, or a date-only line — and marks each joined lineconsumed, removing it from entry detection.That stop condition is too loose. Two real-résumé inputs slip through and get wrongly swallowed into the previous course item (and lost from the parsed entry):
INSTITUTION_HINTSonly matchesUniversity|College|Institute|School|Academy|Polytechnic(src/lib/heuristics/regex.ts:289). Schools likeMIT,UC Berkeley, orStanforddon't match, so in a School / Degree ordering the school line gets joined into the trailing course of the prior entry and consumed — that entry then loses its institution.GPA: 3.8orMinor in Economicsmatch none of the stop tests, so they get appended onto the last course title and consumed.Reported by Samhit in the
#resumelintSlack as a follow-up to the coursework parse work (PR #165, and #169 which shipped via #173).Affected code
src/lib/heuristics/extract/education.ts:216-238— the coursework recovery loop:Relevant helpers:
DEGREE_RE/INSTITUTION_HINTS(src/lib/heuristics/regex.ts:286-290),isDateOnlyLine(education.ts:136),isBulletLine/stripBullet(imported from../shared-side helpers).Root cause
The continuation logic is deny-list driven (join unless it looks like a degree/institution/date/bullet). The deny-list is incomplete: it can't see hint-less schools or prose notes. The fix is to make the join opt-in — only absorb a line that actually looks like a wrapped continuation of the bullet — and/or cap the run length.
Proposed fix
Tighten the continuation stop condition. Either approach (or both) is acceptable:
span.length <= 2). This alone prevents runaway swallowing of a whole following entry.looksLikeWrap(line)guard before absorbing: the continuation should read as a sentence fragment (leading lowercase word, or no strong "new field" signal). Reject lines that look like a standalone field:MIT,UC Berkeley),GPA[:\s]/^Minor\b/^Major\bstyle label,Recommended: implement (1) as the hard cap and layer (2)'s reject-patterns so a single trailing prose line (
GPA: 3.8) is still not absorbed. Keep the existing/^[A-Z0-9]/course-title acceptance guard unchanged.Step-by-step
education.ts, extract a small helperisCourseworkContinuation(line: string): booleanthat returnsfalsefor: acronym/Title-Case standalone tokens,GPA/Minor/Majorprose labels, and any line already caught byDEGREE_RE/INSTITUTION_HINTS/isDateOnlyLine/isBulletLine.whilecondition to requireisCourseworkContinuation(ls[j].text)and cap absorption at one line (j - i <= 1before the increment, i.e. at most one continuation appended).consumedbookkeeping and entry-grouping below (education.ts:240+) untouched — once the loop stops over-consuming, the school/prose lines flow back into entry detection automatically.Acceptance criteria
MIT,UC Berkeley) and the prior entry ends in a coursework bullet parses the acronym school as the institution of its own entry — it is not appended to the prior course nor dropped.GPA: 3.8(orMinor in Economics) does not absorb that prose into the last course title; the course list ends at the real last course.● Global Dimensions of+Business) still merge into one coursework item — the existing multi-column wrap behavior is preserved.src/lib/heuristics/extract/education.test.ts, mirroringexperience.role-comma.test.ts).npm run testgreen (full corpus snapshot diff reviewed — any*.expected.jsonchange is an intended improvement, not a regression).npm run typecheckandnpm run lintclean.Notes
tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.pdfexercises the wrap path — verify its snapshot does not regress, and consider a new fixture only if a unit test can't reproduce the School/Degree-acronym case cleanly.