From e26ce08fc54af691e20a99e645b53defe54e07fd Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 28 Sep 2026 21:33:11 +0200 Subject: [PATCH] feat(resume): check categories, stable issue keys, ignores and a two-column rule - Every live check has a category (contact details, dates, layout, section headings, writing). Reports score the applicable rules, per category too. - Findings carry a key that uses entry ids instead of array indexes, so it survives reordering. Keys in the new metadata.check.ignored set findings aside without counting them against the score. - TWO_COLUMN_LAYOUT flags a two-column template that prints a sidebar. - Full-width pages print no sidebar, so sections placed only there are now reported as never printing instead of passing as main-column content. - metadata.check also holds job-posting terms hidden as not true; public viewers don't receive it. - ats-pdf exports the job-description matcher, spelling variants and the semantics reader for the editor's job match and parser view. --- .../src/features/resume/access-policy.test.ts | 12 ++- .../api/src/features/resume/access-policy.ts | 3 + packages/resume/src/ats-pdf/index.ts | 6 ++ packages/resume/src/ats/catalog.test.ts | 6 +- packages/resume/src/ats/catalog.ts | 39 ++++++++- packages/resume/src/ats/index.ts | 75 ++++++++++++++--- packages/resume/src/ats/rules.test.ts | 81 ++++++++++++++++++- packages/resume/src/ats/rules.ts | 57 +++++++++---- packages/resume/src/ats/types.ts | 26 ++++++ packages/resume/src/ats/walk.ts | 5 +- packages/schema/src/resume/data.ts | 14 ++++ 11 files changed, 291 insertions(+), 33 deletions(-) diff --git a/packages/api/src/features/resume/access-policy.test.ts b/packages/api/src/features/resume/access-policy.test.ts index e46e40af5..0b8970707 100644 --- a/packages/api/src/features/resume/access-policy.test.ts +++ b/packages/api/src/features/resume/access-policy.test.ts @@ -62,13 +62,21 @@ describe("redactResumeForViewer", () => { expect(result.name).toBe("Resume"); }); - it("strips metadata.notes for non-owner", () => { + it("strips metadata.notes and the author's Check choices for non-owner", () => { const resume = { name: "Title", - data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Private notes" } }, + data: { + ...defaultResumeData, + metadata: { + ...defaultResumeData.metadata, + notes: "Private notes", + check: { ignored: ["MISSING_PHONE:/basics/phone"], hiddenTerms: ["HIPAA"] }, + }, + }, }; const result = redactResumeForViewer(resume, false); expect(result.data.metadata.notes).toBe(""); + expect(result.data.metadata.check).toBeUndefined(); }); it("preserves stylesheet source for an authorized non-owner", () => { diff --git a/packages/api/src/features/resume/access-policy.ts b/packages/api/src/features/resume/access-policy.ts index dba1e6ccf..a209e79e6 100644 --- a/packages/api/src/features/resume/access-policy.ts +++ b/packages/api/src/features/resume/access-policy.ts @@ -42,6 +42,8 @@ export function assertCanView(resume: Resume, viewer: Viewer): void { * contains personal context like "Senior Eng @ Foo — final draft"). * - `resume.data.metadata.notes` — explicitly documented as "only visible * to the author when editing" in the resume schema. + * - `resume.data.metadata.check` — the author's Check choices (ignored + * issues, job-posting terms hidden as "not true for me"). * * Everything else (including `data.basics.name`, the person's name on the * resume itself) is part of the public payload and is returned unchanged. @@ -61,6 +63,7 @@ export function redactResumeForViewer { it("has a stable set of codes", () => { @@ -23,6 +23,7 @@ describe("ATS_RULE_CATALOG_V1", () => { "NON_STANDARD_SECTION_TITLE", "MULTI_COLUMN_PROSE_SECTION", "PROSE_SECTION_IN_SIDEBAR", + "TWO_COLUMN_LAYOUT", "SMALL_BODY_FONT", "TIGHT_LINE_HEIGHT", "TIGHT_PAGE_MARGINS", @@ -30,10 +31,11 @@ describe("ATS_RULE_CATALOG_V1", () => { `); }); - it("gives every rule a severity", () => { + it("gives every rule a severity and a category", () => { for (const code of ATS_RULE_CODES) { const rule = ATS_RULE_CATALOG_V1[code]; expect(["error", "warning", "info"]).toContain(rule.severity); + expect(ATS_CATEGORIES).toContain(rule.category); } }); diff --git a/packages/resume/src/ats/catalog.ts b/packages/resume/src/ats/catalog.ts index eb40a93de..b96898acb 100644 --- a/packages/resume/src/ats/catalog.ts +++ b/packages/resume/src/ats/catalog.ts @@ -1,76 +1,102 @@ -import type { AtsSeverity } from "./types"; +import type { AtsCategory, AtsSeverity } from "./types"; type AtsRuleReference = { severity: AtsSeverity; + category: AtsCategory; }; export const ATS_RULE_CATALOG_V1 = { MISSING_NAME: { severity: "error", + category: "contact", }, MISSING_EMAIL: { severity: "error", + category: "contact", }, MALFORMED_EMAIL: { severity: "error", + category: "contact", }, MISSING_PHONE: { severity: "warning", + category: "contact", }, MISSING_LOCATION: { severity: "info", + category: "contact", }, MALFORMED_URL: { severity: "warning", + category: "contact", }, PICTURE_PRESENT: { severity: "info", + category: "contact", }, EMPTY_PERIOD: { severity: "warning", + category: "dates", }, UNPARSEABLE_PERIOD: { severity: "error", + category: "dates", }, UNPARSEABLE_DATE: { severity: "warning", + category: "dates", }, REVERSED_PERIOD: { severity: "error", + category: "dates", }, FUTURE_DATED_PERIOD: { severity: "warning", + category: "dates", }, SECTION_MISSING_FROM_LAYOUT: { severity: "error", + category: "layout", }, NO_VISIBLE_EXPERIENCE: { severity: "warning", + category: "headings", }, MISSING_EXPERIENCE_DESCRIPTION: { severity: "warning", + category: "writing", }, NON_STANDARD_SECTION_TITLE: { severity: "info", + category: "headings", }, MULTI_COLUMN_PROSE_SECTION: { severity: "warning", + category: "layout", }, PROSE_SECTION_IN_SIDEBAR: { severity: "warning", + category: "layout", + }, + TWO_COLUMN_LAYOUT: { + severity: "warning", + category: "layout", }, SMALL_BODY_FONT: { severity: "warning", + category: "layout", }, TIGHT_LINE_HEIGHT: { severity: "warning", + category: "layout", }, TIGHT_PAGE_MARGINS: { severity: "warning", + category: "layout", }, } as const satisfies Readonly>; @@ -78,4 +104,15 @@ export type AtsRuleCode = keyof typeof ATS_RULE_CATALOG_V1; export const ATS_RULE_CODES = Object.keys(ATS_RULE_CATALOG_V1) as readonly AtsRuleCode[]; +/** The order Check lists its category rows in. */ +export const ATS_CATEGORIES = [ + "contact", + "dates", + "layout", + "headings", + "writing", +] as const satisfies readonly AtsCategory[]; + export const atsRuleSeverity = (code: AtsRuleCode): AtsSeverity => ATS_RULE_CATALOG_V1[code].severity; + +export const atsRuleCategory = (code: AtsRuleCode): AtsCategory => ATS_RULE_CATALOG_V1[code].category; diff --git a/packages/resume/src/ats/index.ts b/packages/resume/src/ats/index.ts index 6daa295a8..938f528c0 100644 --- a/packages/resume/src/ats/index.ts +++ b/packages/resume/src/ats/index.ts @@ -1,9 +1,9 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { AtsRuleCode } from "./catalog"; -import type { RuleContext } from "./rules"; -import type { AtsFinding, AtsReport, AtsSeverity } from "./types"; -import { ATS_RULE_CODES } from "./catalog"; -import { ATS_RULES } from "./rules"; +import type { AtsRuleFinding, RuleContext } from "./rules"; +import type { AtsCategory, AtsCategoryScore, AtsFinding, AtsReport, AtsSeverity } from "./types"; +import { ATS_CATEGORIES, ATS_RULE_CODES, atsRuleCategory } from "./catalog"; +import { ATS_RULES, checksSectionTitles } from "./rules"; import { walkSections } from "./walk"; export type AtsLintOptions = { @@ -20,6 +20,30 @@ function compareFindings(a: AtsFinding, b: AtsFinding): number { return a.code < b.code ? -1 : 1; } +const decodePointerToken = (token: string) => token.replace(/~1/g, "/").replace(/~0/g, "~"); + +/** The finding's key: its code and pointer, with array indexes swapped for entry ids (see {@link AtsFinding.key}). */ +export function atsFindingKey(finding: Pick, data: ResumeData): string { + let node: unknown = data; + + const tokens = finding.pointer + .split("/") + .slice(1) + .map((token) => { + if (Array.isArray(node)) { + const entry: unknown = node[Number(token)]; + node = entry; + const id = (entry as { id?: unknown } | undefined)?.id; + return typeof id === "string" ? `#${id}` : token; + } + + node = (node as Record | undefined)?.[decodePointerToken(token)]; + return token; + }); + + return `${finding.code}:/${tokens.join("/")}`; +} + export function lintResumeForAts(data: ResumeData, options: AtsLintOptions = {}): AtsReport { const context: RuleContext = { data, @@ -28,25 +52,54 @@ export function lintResumeForAts(data: ResumeData, options: AtsLintOptions = {}) now: options.now ?? new Date(), }; - const findings = ATS_RULES.flatMap((rule) => rule(context)).sort(compareFindings); + const ignoredKeys = new Set(data.metadata.check?.ignored ?? []); + const all = ATS_RULES.flatMap((rule) => rule(context)) + .map((item: AtsRuleFinding): AtsFinding => ({ ...item, key: atsFindingKey(item, data) })) + .sort(compareFindings); + const findings = all.filter((item) => !ignoredKeys.has(item.key)); const counts: Record = { error: 0, warning: 0, info: 0 }; - const fired = new Set(); + const failed = new Set(); for (const item of findings) { counts[item.severity] += 1; - fired.add(item.code); + failed.add(item.code); } + const applicable = ATS_RULE_CODES.filter( + (code) => code !== "NON_STANDARD_SECTION_TITLE" || checksSectionTitles(context.locale), + ); + const passed = applicable.filter((code) => !failed.has(code)); + + const categories = Object.fromEntries( + ATS_CATEGORIES.map((category): [AtsCategory, AtsCategoryScore] => [ + category, + { + total: applicable.filter((code) => atsRuleCategory(code) === category).length, + passed: passed.filter((code) => atsRuleCategory(code) === category).length, + }, + ]), + ) as Record; + return { findings, + ignored: all.filter((item) => ignoredKeys.has(item.key)), counts, - totalRules: ATS_RULE_CODES.length, - passedRules: ATS_RULE_CODES.length - fired.size, + totalRules: applicable.length, + passedRules: passed.length, + score: Math.round((passed.length / applicable.length) * 100), + categories, }; } export type { AtsRuleCode } from "./catalog"; -export type { AtsFinding, AtsFindingParams, AtsReport, AtsSeverity } from "./types"; +export type { + AtsCategory, + AtsCategoryScore, + AtsFinding, + AtsFindingParams, + AtsReport, + AtsSeverity, +} from "./types"; export type { SectionPlacement, WalkedItem, WalkedSection } from "./walk"; -export { ATS_RULE_CODES } from "./catalog"; +export { ATS_CATEGORIES, ATS_RULE_CODES, atsRuleCategory } from "./catalog"; diff --git a/packages/resume/src/ats/rules.test.ts b/packages/resume/src/ats/rules.test.ts index 4977bb7aa..67587a361 100644 --- a/packages/resume/src/ats/rules.test.ts +++ b/packages/resume/src/ats/rules.test.ts @@ -33,6 +33,17 @@ function makeResume(mutate: (data: ResumeData) => void = () => undefined): Resum return data; } +const skillItem = () => ({ + id: "s1", + hidden: false, + icon: "", + iconColor: "", + name: "Mathematics", + proficiency: "", + level: 0, + keywords: [], +}); + const lint = (data: ResumeData) => lintResumeForAts(data, { now: NOW }); const codesOf = (data: ResumeData) => lint(data).findings.map((item) => item.code); @@ -44,7 +55,46 @@ describe("lintResumeForAts", () => { it("counts every rule as passed when nothing fires", () => { const report = lint(makeResume()); expect(report.passedRules).toBe(report.totalRules); + expect(report.score).toBe(100); expect(report.counts).toEqual({ error: 0, warning: 0, info: 0 }); + expect(report.categories.contact).toEqual({ total: 7, passed: 7 }); + }); + + it("scores the share of rules with no open finding, and groups them by category", () => { + const report = lint(makeResume((data) => (data.basics.phone = ""))); + expect(report.passedRules).toBe(report.totalRules - 1); + expect(report.score).toBe(Math.round(((report.totalRules - 1) / report.totalRules) * 100)); + expect(report.categories.contact).toEqual({ total: 7, passed: 6 }); + }); + + it("leaves the English heading rule out of the score for other languages", () => { + const english = lint(makeResume()); + const german = lint(makeResume((data) => (data.metadata.page.locale = "de-DE"))); + expect(german.totalRules).toBe(english.totalRules - 1); + expect(german.categories.headings.total).toBe(english.categories.headings.total - 1); + }); + + it("keys findings by entry id, so reordering entries keeps the key", () => { + const second = experienceItem({ id: "exp-2", period: "a while back" }); + const before = lint(makeResume((data) => (data.sections.experience.items = [experienceItem(), second]))); + const after = lint(makeResume((data) => (data.sections.experience.items = [second, experienceItem()]))); + const keyOf = (report: typeof before) => report.findings.find((item) => item.code === "UNPARSEABLE_PERIOD")?.key; + + expect(keyOf(before)).toBe("UNPARSEABLE_PERIOD:/sections/experience/items/#exp-2/period"); + expect(keyOf(after)).toBe(keyOf(before)); + }); + + it("sets ignored findings aside without counting them against the score", () => { + const data = makeResume((resume) => { + resume.basics.phone = ""; + resume.metadata.check = { ignored: ["MISSING_PHONE:/basics/phone"], hiddenTerms: [] }; + }); + const report = lint(data); + + expect(report.findings).toEqual([]); + expect(report.ignored.map((item) => item.code)).toEqual(["MISSING_PHONE"]); + expect(report.counts.warning).toBe(0); + expect(report.score).toBe(100); }); it("flags the gaps in a blank resume", () => { @@ -75,6 +125,7 @@ describe("contact rules", () => { code: "MALFORMED_URL", severity: "warning", pointer: "/basics/website/url", + key: "MALFORMED_URL:/basics/website/url", params: { value: "example.com/ada" }, }); }); @@ -112,6 +163,7 @@ describe("date rules", () => { code: "UNPARSEABLE_PERIOD", severity: "error", pointer: "/sections/experience/items/0/period", + key: "UNPARSEABLE_PERIOD:/sections/experience/items/#exp-1/period", params: { value: "a while back" }, }); }); @@ -245,6 +297,7 @@ describe("structure rules", () => { code: "SECTION_MISSING_FROM_LAYOUT", severity: "error", pointer: "/sections/education", + key: "SECTION_MISSING_FROM_LAYOUT:/sections/education", params: { section: "education" }, }); }); @@ -335,11 +388,37 @@ describe("layout rules", () => { expect(codesOf(data)).toContain("PROSE_SECTION_IN_SIDEBAR"); }); - it("treats a full-width page's sidebar as the main column", () => { + it("knows a full-width page prints no sidebar", () => { const data = makeResume((resume) => { resume.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: ["experience"] }]; }); expect(codesOf(data)).not.toContain("PROSE_SECTION_IN_SIDEBAR"); + expect(codesOf(data)).toContain("SECTION_MISSING_FROM_LAYOUT"); + }); + + it("flags a two-column template printing a sidebar, naming its sections", () => { + const data = makeResume((resume) => { + resume.sections.skills.items = [skillItem()]; + resume.metadata.template = "azurill"; + resume.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }]; + }); + + expect(lint(data).findings).toContainEqual( + expect.objectContaining({ code: "TWO_COLUMN_LAYOUT", params: { sections: "skills" } }), + ); + }); + + it("leaves one-column templates, full-width pages and empty sidebars out of the two-column rule", () => { + const withSidebar = (template: ResumeData["metadata"]["template"], fullWidth: boolean, withSkills = true) => + makeResume((resume) => { + resume.sections.skills.items = withSkills ? [skillItem()] : []; + resume.metadata.template = template; + resume.metadata.layout.pages = [{ fullWidth, main: ["experience"], sidebar: ["skills"] }]; + }); + + expect(codesOf(withSidebar("onyx", false))).not.toContain("TWO_COLUMN_LAYOUT"); + expect(codesOf(withSidebar("azurill", true))).not.toContain("TWO_COLUMN_LAYOUT"); + expect(codesOf(withSidebar("azurill", false, false))).not.toContain("TWO_COLUMN_LAYOUT"); }); it("leaves short-list sections in the sidebar alone", () => { diff --git a/packages/resume/src/ats/rules.ts b/packages/resume/src/ats/rules.ts index 4d80b84de..f5d3e0dea 100644 --- a/packages/resume/src/ats/rules.ts +++ b/packages/resume/src/ats/rules.ts @@ -9,6 +9,7 @@ import { parsePeriod, parseSingleDate, } from "@reactive-resume/schema/resume/period"; +import { templateLayouts } from "@reactive-resume/schema/templates"; import { atsRuleSeverity } from "./catalog"; import { SECTION_TITLE_ALIASES } from "./section-aliases"; import { isRenderedSection } from "./walk"; @@ -20,7 +21,10 @@ export type RuleContext = { now: Date; }; -export type AtsRule = (context: RuleContext) => AtsFinding[]; +/** A finding as a rule reports it; the linter adds its key. */ +export type AtsRuleFinding = Omit; + +export type AtsRule = (context: RuleContext) => AtsRuleFinding[]; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; @@ -34,7 +38,7 @@ const MIN_BODY_FONT_SIZE = 9; const MIN_LINE_HEIGHT = 1.15; const MIN_PAGE_MARGIN = 8; -function finding(code: AtsRuleCode, pointer: string, params?: AtsFindingParams): AtsFinding { +function finding(code: AtsRuleCode, pointer: string, params?: AtsFindingParams): AtsRuleFinding { return { code, severity: atsRuleSeverity(code), pointer, ...(params ? { params } : {}) }; } @@ -57,7 +61,7 @@ function isParseableUrl(value: string): boolean { const contactRules: AtsRule = (context) => { const { basics, picture } = context.data; - const findings: AtsFinding[] = []; + const findings: AtsRuleFinding[] = []; if (!basics.name.trim()) findings.push(finding("MISSING_NAME", "/basics/name")); @@ -76,7 +80,7 @@ const contactRules: AtsRule = (context) => { }; const urlRules: AtsRule = (context) => { - const findings: AtsFinding[] = []; + const findings: AtsRuleFinding[] = []; const website = context.data.basics.website.url.trim(); if (website && !isParseableUrl(website)) { @@ -117,7 +121,7 @@ function periodFindings( pointer: string, type: CustomSectionType, context: RuleContext, -): AtsFinding[] { +): AtsRuleFinding[] { if (typeof raw !== "string") return []; const value = raw.trim(); @@ -127,7 +131,7 @@ function periodFindings( const parsed = structured ? resumeDatesToPeriod(structured) : parsePeriod(value, context.locale); if (!parsed) return [finding("UNPARSEABLE_PERIOD", pointer, { value })]; - const findings: AtsFinding[] = []; + const findings: AtsRuleFinding[] = []; if (parsed.start && parsed.end && isReversedPeriod(parsed.start, parsed.end)) { findings.push(finding("REVERSED_PERIOD", pointer, { value })); } @@ -138,7 +142,7 @@ function periodFindings( return findings; } -function singleDateFindings(raw: unknown, dates: unknown, pointer: string, context: RuleContext): AtsFinding[] { +function singleDateFindings(raw: unknown, dates: unknown, pointer: string, context: RuleContext): AtsRuleFinding[] { if (typeof raw !== "string") return []; const value = raw.trim(); @@ -150,7 +154,7 @@ function singleDateFindings(raw: unknown, dates: unknown, pointer: string, conte } const dateRules: AtsRule = (context) => { - const findings: AtsFinding[] = []; + const findings: AtsRuleFinding[] = []; for (const section of context.sections) { if (isCoverLetter(section) || !isRenderedSection(section)) continue; @@ -175,7 +179,7 @@ const dateRules: AtsRule = (context) => { }; const structureRules: AtsRule = (context) => { - const findings: AtsFinding[] = []; + const findings: AtsRuleFinding[] = []; // A section with no items is not reported: every template's renderer returns null before it // emits a heading, so an empty section produces nothing on the page rather than a bare title. @@ -211,10 +215,13 @@ const structureRules: AtsRule = (context) => { return findings; }; -const titleRules: AtsRule = (context) => { - if (!context.locale.toLowerCase().startsWith("en")) return []; +/** The heading rule knows English headings only, so it applies to English resumes alone. */ +export const checksSectionTitles = (locale: string) => locale.toLowerCase().startsWith("en"); - const findings: AtsFinding[] = []; +const titleRules: AtsRule = (context) => { + if (!checksSectionTitles(context.locale)) return []; + + const findings: AtsRuleFinding[] = []; for (const section of context.sections) { if (isCoverLetter(section) || !isRenderedSection(section)) continue; @@ -232,7 +239,7 @@ const titleRules: AtsRule = (context) => { }; const layoutRules: AtsRule = (context) => { - const findings: AtsFinding[] = []; + const findings: AtsRuleFinding[] = []; for (const section of context.sections) { if (isCoverLetter(section) || !isRenderedSection(section)) continue; @@ -255,8 +262,29 @@ const layoutRules: AtsRule = (context) => { return findings; }; +/** + * A two-column template prints its sidebar as a column of its own, which most systems read after the main + * column (or interleave with it). Full-width pages print their sidebar sections in the main column instead. + */ +const twoColumnRules: AtsRule = (context) => { + const { layout, template } = context.data.metadata; + if (templateLayouts[template].columns !== 2) return []; + + const printed = new Set( + context.sections + .filter((section) => !isCoverLetter(section) && isRenderedSection(section) && section.items.length > 0) + .map((section) => section.id), + ); + const sidebar = new Set( + layout.pages.flatMap((page) => (page.fullWidth ? [] : page.sidebar.filter((id) => printed.has(id)))), + ); + if (sidebar.size === 0) return []; + + return [finding("TWO_COLUMN_LAYOUT", "/metadata/layout/pages", { sections: [...sidebar].join(",") })]; +}; + const typographyRules: AtsRule = (context) => { - const findings: AtsFinding[] = []; + const findings: AtsRuleFinding[] = []; const { page, typography } = context.data.metadata; if (typography.body.fontSize < MIN_BODY_FONT_SIZE) { @@ -295,5 +323,6 @@ export const ATS_RULES: readonly AtsRule[] = [ structureRules, titleRules, layoutRules, + twoColumnRules, typographyRules, ]; diff --git a/packages/resume/src/ats/types.ts b/packages/resume/src/ats/types.ts index 9f417d3c4..5afa270d0 100644 --- a/packages/resume/src/ats/types.ts +++ b/packages/resume/src/ats/types.ts @@ -2,18 +2,44 @@ import type { AtsRuleCode } from "./catalog"; export type AtsSeverity = "error" | "warning" | "info"; +/** The groups Check shows its rules in: contact details, dates, layout, section headings and writing. */ +export type AtsCategory = "contact" | "dates" | "layout" | "headings" | "writing"; + export type AtsFindingParams = Readonly>; export type AtsFinding = { code: AtsRuleCode; severity: AtsSeverity; + /** JSON pointer to the value, with array indexes (`/sections/experience/items/0/period`). */ pointer: string; + /** + * Identifies the finding across edits: the code and the pointer with each array index replaced by the + * entry's id where it has one (`MALFORMED_URL:/sections/experience/items/#a1b2/website/url`), so moving + * entries doesn't change it. Ignored findings are stored by this key. + */ + key: string; params?: AtsFindingParams; }; +export type AtsCategoryScore = { + /** Rules in the category that apply to this resume. */ + total: number; + /** Those with no open finding. */ + passed: number; +}; + export type AtsReport = { + /** Open findings, most severe first. */ findings: readonly AtsFinding[]; + /** Findings the author chose to ignore (`metadata.check.ignored`). They don't count against the score. */ + ignored: readonly AtsFinding[]; + /** Open findings by severity. */ counts: Readonly>; + /** Rules that apply to this resume: all of them, less the English heading rule for other languages. */ totalRules: number; + /** Applicable rules with no open finding. */ passedRules: number; + /** `passedRules / totalRules`, as a whole percentage. */ + score: number; + categories: Readonly>; }; diff --git a/packages/resume/src/ats/walk.ts b/packages/resume/src/ats/walk.ts index ec959a528..f99e194aa 100644 --- a/packages/resume/src/ats/walk.ts +++ b/packages/resume/src/ats/walk.ts @@ -30,9 +30,10 @@ function buildPlacement(data: ResumeData): ReadonlyMap if (!placement.has(id)) placement.set(id, "main"); } - const sidebarPlacement: SectionPlacement = page.fullWidth ? "main" : "sidebar"; + // A full-width page prints no sidebar at all, so its sidebar sections only print if another page places them. + if (page.fullWidth) continue; for (const id of page.sidebar) { - if (!placement.has(id)) placement.set(id, sidebarPlacement); + if (!placement.has(id)) placement.set(id, "sidebar"); } } diff --git a/packages/schema/src/resume/data.ts b/packages/schema/src/resume/data.ts index 1c7a40c9f..a8a281c6a 100644 --- a/packages/schema/src/resume/data.ts +++ b/packages/schema/src/resume/data.ts @@ -738,6 +738,20 @@ export const metadataSchema = z.object({ "Structured style rules that target semantic resume sections and slots for React PDF rendering.", ), stylesheet: semanticStylesheetSchema.optional(), + check: z + .object({ + ignored: z + .array(z.string()) + .catch([]) + .describe("Check issues the author chose to ignore, by issue key (the rule code and where it applies)."), + hiddenTerms: z + .array(z.string()) + .catch([]) + .describe("Job-posting terms the author hid from Job match as not true for them."), + }) + .optional() + .catch(undefined) + .describe("The author's Check choices for this resume. Not printed; missing until a choice is made."), }); export const resumeDataSchema = z.looseObject({