mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 17:54:22 +10:00
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.
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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<T extends { name: string; data: ResumeData
|
||||
metadata: {
|
||||
...resume.data.metadata,
|
||||
notes: "",
|
||||
check: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -131,6 +131,8 @@ export function analyzePdfResume(raw: RawExtraction, options: AnalyzePdfOptions
|
||||
|
||||
export type { HarvestOptions, HarvestProgress, PdfDocumentLike, PdfPageLike } from "./harvest";
|
||||
export type {
|
||||
ContactEntities,
|
||||
DetectedHeading,
|
||||
ExtractedDocument,
|
||||
JdMatchReport,
|
||||
JdTermMatch,
|
||||
@@ -147,9 +149,13 @@ export type {
|
||||
RawPage,
|
||||
RawTextItem,
|
||||
ResumeSemantics,
|
||||
TextLine,
|
||||
} from "./types";
|
||||
export type { PdfRuleCode };
|
||||
export { buildResumeSemantics } from "./analyze/semantics";
|
||||
export { PDF_ATS_RULE_CODES } from "./catalog";
|
||||
export { buildExtractedDocument } from "./extract";
|
||||
export { HARVEST_DEFAULTS, harvestPdfDocument } from "./harvest";
|
||||
export { surfaceFormsOf } from "./jd/aliases";
|
||||
export { matchJobDescription } from "./jd/match";
|
||||
export { PDF_OPS } from "./pdf-ops";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ATS_RULE_CATALOG_V1, ATS_RULE_CODES, atsRuleSeverity } from "./catalog";
|
||||
import { ATS_CATEGORIES, ATS_RULE_CATALOG_V1, ATS_RULE_CODES, atsRuleSeverity } from "./catalog";
|
||||
|
||||
describe("ATS_RULE_CATALOG_V1", () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Record<string, AtsRuleReference>>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AtsFinding, "code" | "pointer">, 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<string, unknown> | 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<AtsSeverity, number> = { error: 0, warning: 0, info: 0 };
|
||||
const fired = new Set<AtsRuleCode>();
|
||||
const failed = new Set<AtsRuleCode>();
|
||||
|
||||
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<AtsCategory, AtsCategoryScore>;
|
||||
|
||||
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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<AtsFinding, "key">;
|
||||
|
||||
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,
|
||||
];
|
||||
|
||||
@@ -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<Record<string, string | number>>;
|
||||
|
||||
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<Record<AtsSeverity, number>>;
|
||||
/** 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<Record<AtsCategory, AtsCategoryScore>>;
|
||||
};
|
||||
|
||||
@@ -30,9 +30,10 @@ function buildPlacement(data: ResumeData): ReadonlyMap<string, SectionPlacement>
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user