app/lib/cv/validate.mjs
378 lines and 20 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| <anonymous:13> | 13 to 13 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:14> | 14 to 14 | 1 | 100% | OkCRAP 1 | Ok100% |
| isRecord | 81 to 81 | 3 | 100% | OkCRAP 3 | Critical50% |
| isText | 84 to 84 | 3 | 100% | OkCRAP 3 | Ok86.7% |
| isCount | 87 to 87 | 3 | 100% | OkCRAP 3 | Critical41.7% |
| misfit | 94 to 130 | 26 | 97% | WatchCRAP 26 | Watch62.5% |
| <anonymous:117> | 117 to 123 | 6 | 100% | WatchCRAP 6 | Critical18.8% |
| <anonymous:119> | 119 to 119 | 2 | 100% | OkCRAP 2 | Ok81.8% |
| checkShape | 138 to 143 | 3 | 100% | OkCRAP 3 | Ok100% |
| checkShared | 152 to 179 | 25 | 69% | CriticalCRAP 43.3 | Critical58.7% |
| <anonymous:164> | 164 to 164 | 2 | 100% | OkCRAP 2 | Ok92.9% |
| <anonymous:176> | 176 to 176 | 5 | 0% | WatchCRAP 30 | Critical0% |
| checkEntry | 189 to 220 | 16 | 78% | WatchCRAP 18.7 | Watch76.9% |
| checkProfile | 228 to 272 | 25 | 91% | WatchCRAP 25.4 | Watch63.9% |
| parseCvFile | 281 to 337 | 16 | 100% | WatchCRAP 16 | Ok97.1% |
| <anonymous:307> | 307 to 307 | 1 | 100% | OkCRAP 1 | Critical50% |
| <anonymous:333> | 333 to 333 | 1 | 100% | OkCRAP 1 | Ok100% |
| paperRefErrors | 347 to 364 | 7 | 100% | WatchCRAP 7 | Ok95.5% |
| <anonymous:359> | 359 to 359 | 1 | 100% | OkCRAP 1 | Ok100% |
| orderErrors | 388 to 407 | 10 | 100% | WatchCRAP 10 | Ok83.3% |
Imports
It imports app/lib/content/wide-dash.mjs, app/lib/cv/view.mjs, app/lib/publications/authors.mjs. Imported by app/lib/cv/compile.mjs.
Source
// The one validation module for the CV (docs/CV.md). CI (build:content, test/cv.test.mjs), sync:content and the
// CV save all call it, so a file CI passes is the file a save accepts. It holds a file to its shape, to the
// privacy rules, to the house style (no wide dash) and, once the files are joined to the publications, to the
// rules that need the whole CV: a cited paper exists and lists Dustin, and each section reads newest first.
// Pure: no Env, no filesystem, no clock beyond the year bound below.
import matter from "gray-matter";
import { findWideDashes } from "../content/wide-dash.mjs";
import { canonicalAuthor } from "../publications/authors.mjs";
import { AREAS, ROLES } from "./view.mjs";
const AREA_IDS = new Set(AREAS.map(([id]) => /** @type {string} */ (id)));
const ROLE_IDS = new Set(ROLES.map(([id]) => /** @type {string} */ (id)));
/** The owner, whom every paper the CV claims must list (as the CV's role for it is read from the author list). */
const OWNER = "Dustin Edwards";
/** The edition stamp the CV prints: a season and a year, as "Fall 2026". */
const EDITION_PATTERN = /^(Spring|Summer|Fall|Winter) (\d{4})$/;
/** Entries are dated between these years, so a typo such as 2205 is refused rather than charted. */
const YEAR_MIN = 1950;
const YEAR_MAX = 2100;
const DOI_PATTERN = /^10\.\d{4,9}\/\S+$/;
const HTTPS = /^https:\/\/\S+$/;
/** The sections an appointment or a service line sits under, in the order the CV prints them. */
const APPOINTMENT_SECTIONS = ["Positions", "Administrative and leadership"];
const SERVICE_SECTIONS = [
"Department",
"College",
"University",
"Professional",
"Community",
"Compensated professional consulting",
"Professional memberships",
];
/**
* What no CV line may carry (the privacy rules): an email, a phone number, a room, a private community group.
* Read from the values, so a comment explaining the rule cannot trip it.
*/
const PRIVACY = /** @type {const} */ ([
[/@[a-z0-9-]+\.[a-z]{2,}/i, "an email address"],
[/\(\d{3}\)\s*\d{3}-\d{4}|\b\d{3}-\d{3}-\d{4}\b/, "a phone number"],
[/\bRoom\b|\bSuite\b|\bOffice \d/i, "a room"],
[/Mountains Lake/i, "a private community group"],
]);
/**
* A field's kind, for one entry type: `s` a non-empty string, `n` a non-negative integer, `money` US dollars to the cent, `b` a boolean, and
* a trailing `?` makes it optional. The shared fields (year, endYear, areas, role, links) are checked apart.
*/
const SHAPES = /** @type {const} */ ({
appointment: { section: "s", title: "s", org: "s", detail: "s?", duties: "duties?", headline: "b?" },
education: { title: "s", org: "s", detail: "s?" },
grant: { amount: "money", title: "s", funder: "s?", note: "s?" },
award: { title: "s", org: "s?", place: "s?" },
talk: { title: "s", presenters: "strings", venue: "s" },
course: { code: "s", title: "s", org: "s", term: "s" },
mentoring: { section: "s", count: "n", unit: "unit", level: "s", program: "s", org: "s", detail: "s?", cohort: "b" },
service: { section: "s", title: "s", org: "s?", place: "s?" },
development: { title: "s", org: "s?", place: "s?" },
});
/** The inline paper (one the site has no record for) states its own citation. */
const INLINE_PAPER = /** @type {const} */ ({
title: "s",
authors: "strings",
journal: "s",
volume: "s?",
issue: "s?",
pages: "s?",
});
const SHARED_KEYS = ["year", "endYear", "areas", "role", "links"];
/** @param {unknown} value @returns {value is Record<string, unknown>} */
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
/** @param {unknown} value */
const isText = (value) => typeof value === "string" && value.length > 0 && value === value.trim();
/** @param {unknown} value @returns {value is number} */
const isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
/**
* @param {unknown} value
* @param {string} kind
* @returns {string | null} a reason, or null when the value fits
*/
function misfit(value, kind) {
const optional = kind.endsWith("?");
const base = optional ? kind.slice(0, -1) : kind;
if (value === undefined) return optional ? null : "is missing";
switch (base) {
case "s":
return isText(value) ? null : "must be non-empty text with no space at either end";
case "n":
return isCount(value) ? null : "must be a whole number, zero or more";
case "money":
return typeof value === "number" && Number.isFinite(value) && value >= 0 && Math.round(value * 100) === value * 100
? null
: "must be dollars, zero or more, to the cent";
case "b":
return typeof value === "boolean" ? null : "must be true or false";
case "strings":
return Array.isArray(value) && value.length > 0 && value.every(isText) ? null : "must be a non-empty list of text";
case "unit":
return value === "students" || value === "awards" ? null : 'must be "students" or "awards"';
case "duties":
return Array.isArray(value) &&
value.length > 0 &&
value.every(
(duty) =>
isRecord(duty) &&
Object.keys(duty).every((key) => key === "heading" || key === "items") &&
isText(duty.heading) &&
Array.isArray(duty.items) &&
duty.items.length > 0 &&
duty.items.every(isText),
)
? null
: "must be a non-empty list of { heading, items } with items a non-empty list of text";
default:
throw new Error(`cv validate: unknown field kind ${kind}`);
}
}
/**
* @param {Record<string, unknown>} entry
* @param {Record<string, string>} shape
* @param {string} where
* @param {string[]} errors
*/
function checkShape(entry, shape, where, errors) {
for (const [key, kind] of Object.entries(shape)) {
const reason = misfit(entry[key], kind);
if (reason) errors.push(`${where}: ${key} ${reason}`);
}
}
/**
* The fields every dated entry carries, and the keys it may not.
*
* @param {Record<string, unknown>} entry
* @param {string} where
* @param {string[]} errors
*/
function checkShared(entry, where, errors) {
const { year, endYear, areas, role, links } = entry;
const yearOk = year === null || (typeof year === "number" && Number.isInteger(year) && year >= YEAR_MIN && year <= YEAR_MAX);
if (!yearOk) errors.push(`${where}: year must be a whole year from ${YEAR_MIN} to ${YEAR_MAX}, or null for an undated line`);
if (endYear !== undefined) {
if (year === null) errors.push(`${where}: an undated line (year: null) has no endYear`);
else if (endYear !== "present" && !(typeof endYear === "number" && Number.isInteger(endYear) && endYear <= YEAR_MAX)) {
errors.push(`${where}: endYear must be a whole year or "present"`);
} else if (typeof endYear === "number" && typeof year === "number" && endYear < year) {
errors.push(`${where}: endYear ${endYear} is before year ${year}`);
}
}
if (!Array.isArray(areas) || !areas.every((a) => typeof a === "string" && AREA_IDS.has(a))) {
errors.push(`${where}: areas must be a list (empty is allowed) of ${[...AREA_IDS].join(", ")}`);
} else if (new Set(areas).size !== areas.length) {
errors.push(`${where}: areas names one area twice`);
}
if (role !== null && !(typeof role === "string" && ROLE_IDS.has(role))) {
errors.push(`${where}: role must be null or one of ${[...ROLE_IDS].join(", ")}`);
}
if (links !== undefined) {
const linksOk =
Array.isArray(links) &&
links.length > 0 &&
links.every((l) => isRecord(l) && Object.keys(l).length === 2 && isText(l.label) && typeof l.href === "string" && HTTPS.test(l.href));
if (!linksOk) errors.push(`${where}: links must be a non-empty list of { label, href } with an https href`);
}
}
/**
* One entry of a section file, held to its type's shape.
*
* @param {import("./types.ts").CvType} type
* @param {unknown} entry
* @param {string} where
* @param {string[]} errors
*/
function checkEntry(type, entry, where, errors) {
if (!isRecord(entry)) {
errors.push(`${where}: an entry is a mapping of fields`);
return;
}
if ("type" in entry) errors.push(`${where}: an entry does not state its type; the file's front matter does`);
if (type === "publication" && "doi" in entry) {
for (const key of Object.keys(entry)) {
if (key !== "doi") {
errors.push(`${where}: a paper named by DOI states nothing else (${key}); its facts come from content/publications/`);
}
}
if (typeof entry.doi !== "string" || !DOI_PATTERN.test(entry.doi)) errors.push(`${where}: doi is not a DOI`);
return;
}
/** @type {Record<string, string>} */
const shape = type === "publication" ? INLINE_PAPER : SHAPES[/** @type {Exclude<typeof type, "publication">} */ (type)];
checkShape(entry, shape, where, errors);
checkShared(entry, where, errors);
const known = new Set([...Object.keys(shape), ...SHARED_KEYS]);
for (const key of Object.keys(entry)) {
if (!known.has(key)) errors.push(`${where}: "${key}" is not a field of a ${type} entry`);
}
if (type === "appointment" && !APPOINTMENT_SECTIONS.includes(/** @type {string} */ (entry.section))) {
errors.push(`${where}: section must be one of ${APPOINTMENT_SECTIONS.join(", ")}`);
}
if (type === "service" && !SERVICE_SECTIONS.includes(/** @type {string} */ (entry.section))) {
errors.push(`${where}: section must be one of ${SERVICE_SECTIONS.join(", ")}`);
}
}
/**
* The profile: the edition stamp, the person and the presentation totals.
*
* @param {Record<string, unknown>} data
* @param {string[]} errors
*/
function checkProfile(data, errors) {
const { edition, person, presentations } = data;
for (const key of Object.keys(data)) {
if (!["edition", "person", "presentations"].includes(key)) errors.push(`profile: "${key}" is not a field of the profile`);
}
if (typeof edition !== "string" || !EDITION_PATTERN.test(edition)) {
errors.push('edition must be a season and a year, such as "Fall 2026"');
}
if (!isRecord(person)) {
errors.push("person must be a mapping of name, degree, discipline, department and org");
} else {
for (const key of ["name", "degree", "discipline", "department", "org"]) {
if (!isText(person[key])) errors.push(`person.${key} must be non-empty text with no space at either end`);
}
for (const key of Object.keys(person)) {
if (!["name", "degree", "discipline", "department", "org"].includes(key)) {
errors.push(
key === "title"
? 'person.title is not a field of the person: the title is derived from the current appointment and the discipline (app/lib/identity.mjs)'
: `person.${key} is not a field of the person`,
);
}
}
if (person.name !== OWNER) errors.push(`person.name is "${String(person.name)}", the site's one name for him is "${OWNER}"`);
}
if (!isRecord(presentations)) {
errors.push("presentations must be a mapping of international, national, from and to");
} else {
for (const key of ["international", "national"]) {
if (!isCount(presentations[key])) errors.push(`presentations.${key} must be a whole number, zero or more`);
}
for (const key of ["from", "to"]) {
const value = presentations[key];
if (!(typeof value === "number" && Number.isInteger(value) && value >= YEAR_MIN && value <= YEAR_MAX)) {
errors.push(`presentations.${key} must be a whole year from ${YEAR_MIN} to ${YEAR_MAX}`);
}
}
for (const key of Object.keys(presentations)) {
if (!["international", "national", "from", "to"].includes(key)) errors.push(`presentations.${key} is not a field of the presentations`);
}
if (typeof presentations.from === "number" && typeof presentations.to === "number" && presentations.from > presentations.to) {
errors.push("presentations.from is after presentations.to");
}
}
}
/**
* Parses and judges one CV file.
*
* @param {{ slug: string, type: import("./types.ts").CvType | "profile" }} file the registry's entry for it
* @param {string} raw the whole file, front matter included
* @returns {{ ok: false, errors: string[] } | { ok: true, errors: [], record: import("./types.ts").CvFileRecord }}
*/
export function parseCvFile(file, raw) {
/** @type {string[]} */
const errors = [];
for (const hit of findWideDashes(raw)) {
errors.push(`line ${hit.line}, column ${hit.column}: wide dash ${hit.char}; house style uses commas, periods, parentheses or colons ("${hit.excerpt}")`);
}
let parsed;
try {
parsed = matter(raw);
} catch (error) {
return { ok: false, errors: [...errors, `the front matter is not valid YAML: ${error instanceof Error ? error.message : String(error)}`] };
}
const data = /** @type {Record<string, unknown>} */ (parsed.data);
if (parsed.content.trim() !== "") errors.push("the file has text after its front matter, which no page shows; the CV is the front matter alone");
const { slug, type } = file;
if (type === "profile") {
checkProfile(data, errors);
} else {
for (const key of Object.keys(data)) {
if (key !== "type" && key !== "entries") errors.push(`"${key}" is not a field of a section file (type, entries)`);
}
if (data.type !== type) errors.push(`type is ${JSON.stringify(data.type)}, but ${slug} holds ${type} entries`);
if (!Array.isArray(data.entries) || data.entries.length === 0) {
errors.push("entries must be a non-empty list");
} else {
data.entries.forEach((entry, i) => checkEntry(type, entry, `entries[${i}]`, errors));
}
}
// The privacy rules read the values, not the comments: JSON of what the file states.
const values = JSON.stringify(data);
for (const [pattern, what] of PRIVACY) {
if (pattern.test(values)) errors.push(`the file carries ${what}; the CV never does`);
}
if (errors.length > 0) return { ok: false, errors };
/** @type {import("./types.ts").CvFileRecord} */
const record =
type === "profile"
? {
slug: "profile",
type: "profile",
edition: /** @type {string} */ (data.edition),
person: /** @type {import("./types.ts").CvPerson} */ (data.person),
presentations: /** @type {import("./types.ts").CvPresentations} */ (data.presentations),
}
: {
slug,
type,
// The file states the type once; each entry carries it from here on.
entries: /** @type {Array<Record<string, unknown>>} */ (data.entries).map(
(e) => /** @type {import("./types.ts").CvSourceEntry} */ ({ type, ...e }),
),
};
return { ok: true, errors: [], record };
}
/**
* The paper references against the publication records: each DOI exists as a published paper, appears once,
* and the paper lists the owner among its authors (the CV cannot claim a paper he did not write).
*
* @param {import("./types.ts").CvSourceEntry[]} entries
* @param {Map<string, import("../publications/types.ts").Publication>} byDoi the published records by lower-cased DOI
* @returns {string[]}
*/
export function paperRefErrors(entries, byDoi) {
/** @type {string[]} */
const errors = [];
const seen = new Set();
for (const entry of entries) {
if (entry.type !== "publication" || !("doi" in entry)) continue;
const key = entry.doi.toLowerCase();
if (seen.has(key)) errors.push(`DOI ${entry.doi} is listed twice`);
seen.add(key);
const paper = byDoi.get(key);
if (!paper) {
errors.push(`no published publication has DOI ${entry.doi}; add its file to content/publications/ first, and publish it`);
} else if (!paper.authors.some((name) => canonicalAuthor(name) === OWNER)) {
errors.push(`DOI ${entry.doi} does not list ${OWNER} among its authors, so the CV cannot claim it`);
}
}
return errors;
}
/**
* The types whose sections read newest first, undated lines last, so the page, the twin and the PDF agree on
* an order the twin states for grants. Service and mentoring lines keep the CV's own order within a section (a
* committee is listed with its chair first, a cohort by program), so the rule does not reach them.
*/
const NEWEST_FIRST_TYPES = [
"appointment",
"education",
"publication",
"grant",
"award",
"talk",
"course",
"development",
];
/**
* Judged on the resolved entries, so a paper's year is the record's.
*
* @param {Array<{ id: string, type: string, section: string | null, year: number | null }>} entries
* @returns {string[]}
*/
export function orderErrors(entries) {
/** @type {string[]} */
const errors = [];
/** @type {Map<string, { year: number | null, id: string }>} */
const last = new Map();
for (const e of entries) {
if (!NEWEST_FIRST_TYPES.includes(e.type)) continue;
const key = `${e.type}/${e.section ?? ""}`;
const before = last.get(key);
if (before) {
if (before.year === null && e.year !== null) {
errors.push(`${e.id} is dated ${e.year} but follows the undated ${before.id}; undated lines come last in their section`);
} else if (before.year !== null && e.year !== null && e.year > before.year) {
errors.push(`${e.id} (${e.year}) follows ${before.id} (${before.year}); each section reads newest first`);
}
}
last.set(key, { year: e.year, id: e.id });
}
return errors;
}