Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/lib/heuristics/extract-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,55 @@ describe("extractContact — location no longer falls back to document-wide scan
});
});

describe("extractContact — locale-aware phone parsing (issue #69)", () => {
it("parses a UK national-format number when location is 'London, United Kingdom'", () => {
// 020 7946 0958 is an Ofcom-reserved London documentation number.
// Without the region hint, libphonenumber would fail to parse national-format
// UK numbers (no +44 prefix), so the phone field would be undefined.
const { lines, profile } = buildContext([
{ text: "Emma Clarke", fontSize: 18 },
{ text: "emma.clarke@example.com · 020 7946 0958 · London, United Kingdom", fontSize: 10 },
{ text: "" },
{ text: "EXPERIENCE", fontSize: 13 },
{ text: "Some London company, 2020 - Present", fontSize: 11 },
]);
const contact = extractContact(profile, lines);
expect(contact.location).toContain("London");
expect(contact.phone).toBeDefined();
// Should reformat as international since it's not a US/CA number.
expect(contact.phone).toMatch(/^\+44/);
expect(contact.confidence.phone).toBeGreaterThan(0);
});

it("still parses a US number correctly when location is 'Chicago, IL'", () => {
// (312) 555-0123 — real area code, 555 exchange, 0123 subscriber (synthetic).
const { lines, profile } = buildContext([
{ text: "Alex Johnson", fontSize: 18 },
{ text: "alex@example.com · (312) 555-0123 · Chicago, IL", fontSize: 10 },
{ text: "" },
{ text: "EXPERIENCE", fontSize: 13 },
{ text: "Acme Corp, 2021 - Present", fontSize: 11 },
]);
const contact = extractContact(profile, lines);
expect(contact.location).toContain("Chicago");
expect(contact.phone).toBe("(312) 555-0123");
});

it("falls back gracefully when no location is present in the profile", () => {
// US phone should still parse under the default US fallback.
const { lines, profile } = buildContext([
{ text: "Sam Rivera", fontSize: 18 },
{ text: "sam@example.com · (408) 555-0142", fontSize: 10 },
{ text: "" },
{ text: "EXPERIENCE", fontSize: 13 },
{ text: "Some Corp, 2022 - Present", fontSize: 11 },
]);
const contact = extractContact(profile, lines);
expect(contact.location).toBeUndefined();
expect(contact.phone).toBe("(408) 555-0142");
});
});

describe("extractName — document-title boilerplate rejection (issue #10)", () => {
it("picks the real name when a 'Functional Resume Sample' header is above it", () => {
// Mode 1 of issue #10: a public Microsoft-style sample template renders
Expand Down
39 changes: 23 additions & 16 deletions src/lib/heuristics/extract-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
INSTITUTION_HINTS,
COMPANY_SUFFIX_RE,
} from "./regex.ts";
import { findFirstPhone } from "./phone.ts";
import { findFirstPhone, regionFromLocation } from "./phone.ts";
import { parseEntryBlocks } from "./entry-blocks.ts";
import type { EntryBlock } from "./entry-blocks.ts";
import {
Expand Down Expand Up @@ -251,22 +251,10 @@ export function extractContact(
): ContactExtractionResult {
const scan = (lines: PdfLine[], joined: string): ContactExtractionResult => {
const email = firstMatch(EMAIL_RE, joined);
const phoneResult = findFirstPhone(joined);
const phone = phoneResult?.formatted;
const linkedin = firstMatch(LINKEDIN_RE, joined);
const github = firstMatch(GITHUB_RE, joined);

// Other URLs that aren't linkedin/github → portfolio/website bucket.
const others = allMatches(URL_RE, joined).filter((u) => {
const lower = u.toLowerCase();
return !lower.includes("linkedin.com") && !lower.includes("github.com");
});
const portfolio = others.find((u) =>
/(portfolio|\.me\b|\.io\b|\.dev\b|behance|dribbble|medium)/i.test(u),
);
const websiteCandidates = others.filter((u) => u !== portfolio);
const website = websiteCandidates[0];

// Extract location BEFORE phone so we can derive the parse region.
// `location` is intentionally not subject to the document-wide fallback —
// see the doc-comment on `extractContact` for the reasoning.
let location: string | undefined;
for (const line of lines) {
const us = US_LOCATION_RE.exec(line.text);
Expand All @@ -285,6 +273,25 @@ export function extractContact(
}
}

// Derive the phone parse region from the extracted location; fall back to
// "US" when the location is absent or the country is not in our mapping.
const phoneRegion = regionFromLocation(location) ?? "US";
const phoneResult = findFirstPhone(joined, phoneRegion);
const phone = phoneResult?.formatted;
const linkedin = firstMatch(LINKEDIN_RE, joined);
const github = firstMatch(GITHUB_RE, joined);

// Other URLs that aren't linkedin/github → portfolio/website bucket.
const others = allMatches(URL_RE, joined).filter((u) => {
const lower = u.toLowerCase();
return !lower.includes("linkedin.com") && !lower.includes("github.com");
});
const portfolio = others.find((u) =>
/(portfolio|\.me\b|\.io\b|\.dev\b|behance|dribbble|medium)/i.test(u),
);
const websiteCandidates = others.filter((u) => u !== portfolio);
const website = websiteCandidates[0];

return {
email,
phone,
Expand Down
102 changes: 101 additions & 1 deletion src/lib/heuristics/phone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Copyright 2026 The resumelint Authors

import { describe, it, expect } from "vitest";
import { normalizePhone, findFirstPhone } from "./phone.ts";
import { normalizePhone, findFirstPhone, regionFromLocation } from "./phone.ts";

// ── normalizePhone ───────────────────────────────────────────────────────────

Expand Down Expand Up @@ -100,3 +100,103 @@ describe("findFirstPhone — extraction from text", () => {
expect(r1?.formatted).toBe(r2?.formatted);
});
});

// ── regionFromLocation ───────────────────────────────────────────────────────

describe("regionFromLocation — US locations", () => {
it("returns US for a standard City, ST pattern", () => {
expect(regionFromLocation("San Francisco, CA")).toBe("US");
});

it("returns US for a two-word city with state abbr", () => {
expect(regionFromLocation("New York, NY")).toBe("US");
});

it("returns US regardless of whether the state abbr is a known state", () => {
// US_LOCATION_RE matches any 2-letter uppercase token after the comma.
expect(regionFromLocation("Springfield, IL")).toBe("US");
});
});

describe("regionFromLocation — international locations", () => {
it("returns GB for 'United Kingdom'", () => {
expect(regionFromLocation("London, United Kingdom")).toBe("GB");
});

it("returns GB for 'UK' abbreviation", () => {
expect(regionFromLocation("Manchester, UK")).toBe("GB");
});

it("returns IN for India", () => {
expect(regionFromLocation("Bengaluru, India")).toBe("IN");
});

it("returns CA for Canada", () => {
expect(regionFromLocation("Toronto, Canada")).toBe("CA");
});

it("returns AU for Australia", () => {
expect(regionFromLocation("Sydney, Australia")).toBe("AU");
});

it("returns DE for Germany", () => {
expect(regionFromLocation("Berlin, Germany")).toBe("DE");
});

it("returns SG for Singapore", () => {
expect(regionFromLocation("Singapore, Singapore")).toBe("SG");
});
});

describe("regionFromLocation — unmapped / edge cases", () => {
it("returns undefined for undefined input", () => {
expect(regionFromLocation(undefined)).toBeUndefined();
});

it("returns undefined for an empty string", () => {
expect(regionFromLocation("")).toBeUndefined();
});

it("returns undefined for a country not in the mapping", () => {
// "Uzbekistan" is real but not in the explicit table.
expect(regionFromLocation("Tashkent, Uzbekistan")).toBeUndefined();
});

it("returns undefined for plain text with no location pattern", () => {
expect(regionFromLocation("Remote")).toBeUndefined();
});
});

describe("regionFromLocation → findFirstPhone — intl locale path", () => {
it("parses a UK national-format number when region is GB", () => {
// 020 7946 0958 is an Ofcom-reserved London documentation number.
const region = regionFromLocation("London, United Kingdom");
expect(region).toBe("GB");
// national format: no country prefix — libphonenumber needs the region hint.
const result = findFirstPhone("020 7946 0958", region ?? "US");
expect(result).toBeDefined();
expect(result!.isValid).toBe(true);
// Non-US numbers format as international.
expect(result!.formatted).toBe("+44 20 7946 0958");
});

it("parses an Indian national-format number when region is IN", () => {
// 098765 43210 is a common synthetic Indian mobile used in docs.
const region = regionFromLocation("Bengaluru, India");
expect(region).toBe("IN");
const result = findFirstPhone("098765 43210", region ?? "US");
expect(result).toBeDefined();
expect(result!.isValid).toBe(true);
// Indian numbers format as +91 …
expect(result!.formatted).toMatch(/^\+91/);
});

it("falls back to US for an unmapped location", () => {
const region = regionFromLocation("Tashkent, Uzbekistan") ?? "US";
expect(region).toBe("US");
// A standard US number still parses correctly under US default.
const result = findFirstPhone("(312) 555-0123", region);
expect(result).toBeDefined();
expect(result!.formatted).toBe("(312) 555-0123");
});
});
Loading
Loading