feat(resume): add a deterministic ATS parseability check (#3314)

* feat(resume): add a deterministic ATS parseability check

Adds an offline ATS linter that reports whether a parser can read a
resume, surfaced as an always-on panel in the builder.

The existing Resume Analysis panel needs a configured AI provider, so
users who never set one up get no feedback at all. These 22 rules run
as a pure function over ResumeData with no provider, no network and no
rendered PDF, so they work for everyone on every edit.

Rules cover contact details, date parseability, sections that hold
content but never render, column and sidebar placement, and typography
thresholds. The catalog mirrors the Semantic CSS diagnostic catalog:
stable codes carrying a severity, meaning and action, with no i18n
dependency so the web layer translates by code. Each finding carries a
JSON Pointer, which is what makes jump-to-field work.

Deliberately no second score. Resume Analysis owns overallScore, so
this reports "N of M checks passed" and counts by severity instead.

* fix(resume): accept localized ongoing periods and reject bare ones

Two period-parsing bugs found in review.

The ongoing-token set was English-only, so a German resume reading
"2020 - heute" was reported as unparseable and the panel told the user
to rewrite a perfectly valid range. Rather than guess translations for
55 locales, a range ending that carries no digits and is not a month
name in the resume's locale is now read as ongoing. That keeps a
genuinely incomplete ending such as "Jan 2020 - Feb" reported, since
"Feb" resolves as a month.

A bare "Present" also parsed as a valid period, so an experience entry
with no start date passed the check. A standalone ongoing token is now
rejected; ongoing tokens remain valid as the end of a range.

* feat(web): scroll ATS findings to the item they belong to

Findings for different items in one section all landed on the section
header, so a date problem on the third role gave no more help than
naming the section.

getAtsFindingTarget now resolves the offending item from the finding's
JSON Pointer against the resume, and SectionItem carries a matching DOM
id. The panel scrolls to that item and falls back to the section header
when the item is not mounted, which is what happens while its section
is collapsed.

* fix(resume): recognize ongoing periods by token, not by shape

The previous heuristic read any short, digit-free range ending as an
ongoing marker, so "2020 - unknown", "2020 - later" and "2020 - tbd"
parsed cleanly and suppressed the finding they should have raised.

Replaced with an explicit table of ongoing words keyed by language,
covering the locales the app ships. Matching is exact, so unrecognized
endings are reported again. A locale missing from the table falls back
to the earlier behaviour of reporting its ongoing periods, which is a
visible gap someone can close by adding a word rather than a silent
hole in detection.

Tests assert every listed token parses and that the table stays
lowercase, since lookups normalize that way.

* fix(ats): parse punctuated ongoing tokens

* fix(ats): parse Unicode punctuated ongoing tokens

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Syed Ali Abbas Zaidi
2026-08-13 23:07:55 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent f64d02df7f
commit 45303fb465
17 changed files with 2175 additions and 0 deletions
@@ -39,6 +39,7 @@ import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { atsFindingItemElementId } from "@/libs/resume/ats";
import {
addItemToSection,
createCustomSectionWithItem,
@@ -244,6 +245,7 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
return (
<Reorder.Item
key={item.id}
id={atsFindingItemElementId(item.id)}
value={item}
dragListener={false}
dragControls={controls}
@@ -9,6 +9,7 @@ import { Copyright } from "@/components/ui/copyright";
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
import { BuilderSidebarEdge } from "../../-components/edge";
import { useBuilderSidebar } from "../../-store/sidebar";
import { AtsCheckSectionBuilder } from "./sections/ats-check";
import { CustomStylesSectionBuilder } from "./sections/custom-styles";
import { DesignSectionBuilder } from "./sections/design";
import { ExportSectionBuilder } from "./sections/export";
@@ -33,6 +34,7 @@ function getSectionComponent(type: RightSidebarSection) {
.with("notes", () => <NotesSectionBuilder />)
.with("sharing", () => <SharingSectionBuilder />)
.with("statistics", () => <StatisticsSectionBuilder />)
.with("ats", () => <AtsCheckSectionBuilder />)
.with("analysis", () => <ResumeAnalysisSectionBuilder />)
.with("export", () => <ExportSectionBuilder />)
.with("information", () => <InformationSectionBuilder />)
@@ -0,0 +1,194 @@
// @vitest-environment happy-dom
import type { ExperienceItem, ResumeData } from "@reactive-resume/schema/resume/data";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const resumeState = vi.hoisted(() => ({ data: undefined as ResumeData | undefined }));
const sidebarState = vi.hoisted(() => ({ toggleSidebar: vi.fn() }));
const sectionState = vi.hoisted(() => ({ setCollapsed: vi.fn() }));
type SectionBaseProps = { children: React.ReactNode };
type SectionStoreSelector = (state: { setCollapsed: typeof sectionState.setCollapsed }) => unknown;
vi.mock("@/features/resume/builder/draft", () => ({
useResumeData: () => resumeState.data,
}));
vi.mock("../../../-store/sidebar", () => ({
useBuilderSidebar: () => sidebarState,
}));
vi.mock("../../../-store/section", () => ({
useSectionStore: (selector: SectionStoreSelector) => selector(sectionState),
}));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
}));
const { AtsCheckSectionBuilder } = await import("./ats-check");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
Element.prototype.scrollIntoView = vi.fn();
});
beforeEach(() => {
vi.clearAllMocks();
resumeState.data = undefined;
document.body.innerHTML = "";
});
const experienceItem = (overrides: Partial<ExperienceItem> = {}): ExperienceItem => ({
id: "exp-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "Jan 2020 - Present",
website: { url: "", label: "", inlineLink: false },
description: "<p>Designed and shipped the difference engine.</p>",
roles: [],
...overrides,
});
function makeResume(mutate: (data: ResumeData) => void = () => undefined): ResumeData {
const data = structuredClone(defaultResumeData);
data.basics.name = "Ada Lovelace";
data.basics.email = "ada@example.com";
data.basics.phone = "+44 20 7946 0100";
data.basics.location = "London, UK";
data.sections.experience.items = [experienceItem()];
data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: [] }];
mutate(data);
return data;
}
const renderPanel = () =>
render(
<I18nProvider i18n={i18n}>
<AtsCheckSectionBuilder />
</I18nProvider>,
);
describe("AtsCheckSectionBuilder", () => {
it("renders nothing before the resume is ready", () => {
const { container } = renderPanel();
expect(container).toBeEmptyDOMElement();
});
it("reports a clean resume as fully passing", () => {
resumeState.data = makeResume();
renderPanel();
expect(screen.getByText("22 of 22 checks passed")).toBeTruthy();
expect(screen.getByText(/Every check passed/)).toBeTruthy();
});
it("lists a finding with its title and remedy", () => {
resumeState.data = makeResume((data) => {
data.basics.email = "ada at example dot com";
});
renderPanel();
expect(screen.getByText("This email address will not be recognized.")).toBeTruthy();
expect(screen.getByText(/Use a plain address/)).toBeTruthy();
expect(screen.getByText("21 of 22 checks passed")).toBeTruthy();
});
it("counts findings by severity", () => {
resumeState.data = makeResume((data) => {
data.basics.email = "";
data.picture.url = "/uploads/ada.png";
});
renderPanel();
expect(screen.getByText("1 error")).toBeTruthy();
expect(screen.getByText("1 note")).toBeTruthy();
});
it("opens the owning sidebar section when a finding's location is clicked", () => {
resumeState.data = makeResume((data) => {
data.basics.email = "";
});
renderPanel();
fireEvent.click(screen.getByRole("button", { name: /Basics/ }));
expect(sidebarState.toggleSidebar).toHaveBeenCalledWith("left", true);
expect(sectionState.setCollapsed).toHaveBeenCalledWith("basics", false);
});
it("points typography findings at the right sidebar", () => {
resumeState.data = makeResume((data) => {
data.metadata.typography.body.fontSize = 8;
});
renderPanel();
fireEvent.click(screen.getByRole("button", { name: /Typography/ }));
expect(sidebarState.toggleSidebar).toHaveBeenCalledWith("right", true);
expect(sectionState.setCollapsed).toHaveBeenCalledWith("typography", false);
});
});
describe("navigating to a finding", () => {
const stubElement = (id: string) => {
const element = document.createElement("div");
element.id = id;
element.scrollIntoView = vi.fn();
document.body.append(element);
return element;
};
it("scrolls to the item a finding belongs to, not the section header", () => {
const section = stubElement("sidebar-experience");
const item = stubElement("resume-item-exp-1");
resumeState.data = makeResume((data) => {
data.sections.experience.items = [experienceItem({ period: "nonsense" })];
});
renderPanel();
fireEvent.click(screen.getByRole("button", { name: /Experience/ }));
expect(item.scrollIntoView).toHaveBeenCalled();
expect(section.scrollIntoView).not.toHaveBeenCalled();
});
it("sends two findings in the same section to different items", () => {
const first = stubElement("resume-item-exp-1");
const second = stubElement("resume-item-exp-2");
resumeState.data = makeResume((data) => {
data.sections.experience.items = [
experienceItem({ id: "exp-1", period: "nonsense" }),
experienceItem({ id: "exp-2", period: "gibberish" }),
];
});
renderPanel();
const buttons = screen.getAllByRole("button", { name: /Experience · Item/ });
fireEvent.click(buttons[0] as HTMLElement);
fireEvent.click(buttons[1] as HTMLElement);
expect(first.scrollIntoView).toHaveBeenCalledOnce();
expect(second.scrollIntoView).toHaveBeenCalledOnce();
});
it("falls back to the section header when the item is not mounted", () => {
const section = stubElement("sidebar-experience");
resumeState.data = makeResume((data) => {
data.sections.experience.items = [experienceItem({ period: "nonsense" })];
});
renderPanel();
fireEvent.click(screen.getByRole("button", { name: /Experience/ }));
expect(section.scrollIntoView).toHaveBeenCalled();
});
});
@@ -0,0 +1,170 @@
import type { AtsFinding, AtsSeverity } from "@reactive-resume/resume/ats";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { ArrowRightIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { useCallback, useMemo } from "react";
import { match } from "ts-pattern";
import { lintResumeForAts } from "@reactive-resume/resume/ats";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { cn } from "@reactive-resume/utils/style";
import { useResumeData } from "@/features/resume/builder/draft";
import {
atsFindingItemElementId,
getAtsFindingLocation,
getAtsFindingMessage,
getAtsFindingTarget,
} from "@/libs/resume/ats";
import { useSectionStore } from "../../../-store/section";
import { useBuilderSidebar } from "../../../-store/sidebar";
import { SectionBase } from "../shared/section-base";
const SEVERITIES = ["error", "warning", "info"] as const;
function severityDotClass(severity: AtsSeverity) {
return match(severity)
.with("error", () => "bg-rose-600")
.with("warning", () => "bg-amber-600")
.with("info", () => "bg-sky-600")
.exhaustive();
}
function severityLabel(severity: AtsSeverity) {
return match(severity)
.with("error", () => t`Error`)
.with("warning", () => t`Warning`)
.with("info", () => t`Note`)
.exhaustive();
}
type SeverityCountProps = {
severity: AtsSeverity;
count: number;
};
function SeverityCount({ severity, count }: SeverityCountProps) {
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
<span className={cn("size-2 shrink-0 rounded-full", severityDotClass(severity))} aria-hidden />
{match(severity)
.with("error", () => <Plural value={count} one="# error" other="# errors" />)
.with("warning", () => <Plural value={count} one="# warning" other="# warnings" />)
.with("info", () => <Plural value={count} one="# note" other="# notes" />)
.exhaustive()}
</span>
);
}
type AtsFindingRowProps = {
finding: AtsFinding;
onJump: (pointer: string) => void;
};
function AtsFindingRow({ finding, onJump }: AtsFindingRowProps) {
const message = getAtsFindingMessage(finding.code);
const location = getAtsFindingLocation(finding.pointer);
return (
<li className="space-y-2 rounded-md border bg-card p-3">
<div className="flex items-start gap-2">
<span className={cn("mt-1.5 size-2 shrink-0 rounded-full", severityDotClass(finding.severity))} aria-hidden />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-sm leading-snug">{message.title}</p>
<p className="text-muted-foreground text-xs leading-normal">{message.action}</p>
</div>
<Badge variant="secondary" className="shrink-0">
{severityLabel(finding.severity)}
</Badge>
</div>
{location && (
<Button size="sm" variant="ghost" className="h-7 gap-1.5 px-2 text-xs" onClick={() => onJump(finding.pointer)}>
{location}
<ArrowRightIcon />
</Button>
)}
</li>
);
}
export function AtsCheckSectionBuilder() {
const data = useResumeData();
const { toggleSidebar } = useBuilderSidebar();
const setCollapsed = useSectionStore((state) => state.setCollapsed);
const report = useMemo(() => (data ? lintResumeForAts(data) : null), [data]);
const onJump = useCallback(
(pointer: string) => {
const target = getAtsFindingTarget(pointer, data);
if (!target) return;
toggleSidebar(target.side, true);
setCollapsed(target.section, false);
const item = target.itemId ? document.getElementById(atsFindingItemElementId(target.itemId)) : null;
const destination = item ?? document.getElementById(`sidebar-${target.section}`);
destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
},
[data, setCollapsed, toggleSidebar],
);
if (!report) return null;
const { counts, findings, passedRules, totalRules } = report;
return (
<SectionBase type="ats" className="space-y-4">
<div className="space-y-3">
<div className="space-y-3 rounded-md border bg-card p-3">
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
These checks run as you type and never leave your browser. They ask whether a machine can read your
resume, not whether it reads well.
</Trans>
</p>
<div className="space-y-2">
<p className="font-medium text-sm leading-none">
<Trans>
{passedRules} of {totalRules} checks passed
</Trans>
</p>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width: `${Math.round((passedRules / totalRules) * 100)}%` }}
/>
</div>
</div>
{findings.length > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{SEVERITIES.filter((severity) => counts[severity] > 0).map((severity) => (
<SeverityCount key={severity} severity={severity} count={counts[severity]} />
))}
</div>
)}
</div>
{findings.length === 0 ? (
<div className="flex items-center gap-3 rounded-md border border-dashed p-3">
<CheckCircleIcon className="size-5 shrink-0 text-emerald-600" />
<p className="text-muted-foreground text-sm leading-normal">
<Trans>Every check passed. Nothing here should stop a parser from reading your resume.</Trans>
</p>
</div>
) : (
<ul className="space-y-2">
{findings.map((finding) => (
<AtsFindingRow key={`${finding.code}:${finding.pointer}`} finding={finding} onJump={onJump} />
))}
</ul>
)}
</div>
</SectionBase>
);
}