app/kb/procedures/validate.mjs
668 lines and 38 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| <anonymous:20> | 20 to 20 | 1 | 100% | OkCRAP 1 | Ok100% |
| sentenceCount | 32 to 34 | 2 | 100% | OkCRAP 2 | Watch62.5% |
| isGap | 61 to 63 | 2 | 100% | OkCRAP 2 | Ok85.7% |
| label | 69 to 74 | 4 | 100% | OkCRAP 4 | Critical0% |
| itemName | 77 to 82 | 4 | 100% | OkCRAP 4 | Critical0% |
| collectGaps | 96 to 115 | 14 | 100% | WatchCRAP 14 | Critical50% |
| <anonymous:102> | 102 to 107 | 7 | 100% | WatchCRAP 7 | Critical0% |
| quantityPattern | 122 to 126 | 1 | 100% | OkCRAP 1 | Watch66.7% |
| <anonymous:124> | 124 to 124 | 1 | 100% | OkCRAP 1 | Ok100% |
| nonEmptyString | 133 to 135 | 2 | 100% | OkCRAP 2 | Ok90.9% |
| validateHistory | 145 to 171 | 9 | 79% | WatchCRAP 9.8 | Watch66.7% |
| <anonymous:153> | 153 to 165 | 15 | 100% | WatchCRAP 15 | Ok82% |
| validateProcedure | 181 to 496 | 172 | 94% | CriticalCRAP 177.2 | Watch63.4% |
| required | 195 to 202 | 6 | 100% | WatchCRAP 6 | Watch78.3% |
| <anonymous:259> | 259 to 259 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:282> | 282 to 285 | 5 | 100% | OkCRAP 5 | Critical43.8% |
| <anonymous:289> | 289 to 289 | 1 | 100% | OkCRAP 1 | Critical37.5% |
| <anonymous:308> | 308 to 316 | 5 | 67% | WatchCRAP 5.9 | Critical50% |
| <anonymous:335> | 335 to 336 | 3 | 100% | OkCRAP 3 | Watch70% |
| <anonymous:345> | 345 to 357 | 11 | 100% | WatchCRAP 11 | Critical55.3% |
| <anonymous:372> | 372 to 376 | 4 | 100% | OkCRAP 4 | Ok88.2% |
| <anonymous:382> | 382 to 382 | 1 | 100% | OkCRAP 1 | Watch66.7% |
| <anonymous:382> | 382 to 382 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:382> | 382 to 382 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:395> | 395 to 395 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:403> | 403 to 403 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:403> | 403 to 403 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:429> | 429 to 429 | 2 | 100% | OkCRAP 2 | Critical57.1% |
| <anonymous:431> | 431 to 431 | 1 | 100% | OkCRAP 1 | Critical33.3% |
| protocolRules | 507 to 645 | 92 | 98% | CriticalCRAP 92 | No data: changed since the last mutation run |
| walk | 632 to 637 | 5 | 60% | WatchCRAP 6.6 | Critical0% |
| <anonymous:633> | 633 to 633 | 1 | 100% | OkCRAP 1 | Critical0% |
| <anonymous:638> | 638 to 642 | 3 | 100% | OkCRAP 3 | Critical0% |
| <anonymous:641> | 641 to 641 | 2 | 100% | OkCRAP 2 | Critical0% |
| recipeRules | 652 to 668 | 19 | 77% | WatchCRAP 23.7 | Critical34.9% |
| computationalRules | 676 to 707 | 23 | 93% | WatchCRAP 23.2 | Watch78.5% |
| <anonymous:684> | 684 to 684 | 1 | 100% | OkCRAP 1 | Watch75% |
| <anonymous:693> | 693 to 696 | 6 | 100% | WatchCRAP 6 | Watch62.5% |
Imports
It imports app/kb/bases.mjs, app/kb/procedures/cite.mjs, app/kb/procedures/parse.mjs, app/kb/procedures/proof.mjs, app/kb/procedures/taxonomy.mjs, app/lib/phage-tools.mjs. Imported by app/components/procedure-materials.tsx, app/kb/form-fields.mjs, app/kb/libraries.mjs, app/kb/procedures/compile.mjs, app/kb/procedures/render.mjs, app/kb/registry/strain.mjs.
Source
// Judges a parsed procedure against its profile (docs/PROCEDURES.md). check:protocols and the operator
// API's save_procedure both call validateProcedure, so a file CI refuses is a file the save tool refuses,
// for the same reason in the same words.
//
// A value nobody has yet is written "MISSING: <why>" in place. It passes and is returned as a gap, so
// the check can print it and the read tool can show it. A bare MISSING, or a required field left out,
// is an error: a forgotten field cannot pass as a recorded gap.
import { DOI_PATTERN, VERSION_PATTERN } from "./cite.mjs";
import { allSteps, PLATFORMS, readCalcs, stepConditions } from "./parse.mjs";
import { proofErrors } from "./proof.mjs";
import { COURSES, METHODS, NON_STRAIN_ORGANISMS, PROTOCOL_STATUSES } from "./taxonomy.mjs";
import { TOOLS } from "../../lib/phage-tools.mjs";
import { BASES } from "../bases.mjs";
const PROFILES = /** @type {const} */ (["protocol", "recipe", "computational"]);
/** Where each profile's pages live (its knowledge base's root, app/kb/bases.mjs); the file's name is the last segment. */
const PROFILE_ROOTS = /** @type {Record<(typeof PROFILES)[number], string>} */ (
Object.fromEntries(BASES.map((base) => [base.profile, base.entryRoot]))
);
const NOT_APPLICABLE = "not applicable";
/** A software how-to's form, per Diátaxis (docs/KNOWLEDGE-BASE.md, S1): a lesson that teaches, or a recipe for a task. */
export const HOWTO_TYPES = /** @type {const} */ ({ tutorial: "Tutorial", "how-to": "How-to" });
/** The sentences a how-to's WHY may run to before its reasoning belongs on an explanation page it links (S1). */
const HOWTO_WHY_SENTENCES = 3;
/** The sentences in some markdown: each run of words ended by ., ! or ? before a capital or the end, so "e.g. this" is one. @param {string} text */
export function sentenceCount(text) {
return (text.replace(/`[^`]*`/g, "x").match(/[^.!?]*[A-Za-z0-9][^.!?]*[.!?]+(?=[*_]*(?:\s+[*_]*[A-Z0-9]|\s*$))/g) ?? []).length;
}
/** Units are a closed list. A unit outside it fails until it is added here on purpose. */
const PROTOCOL_UNITS = [
"µl", "µL", "ml", "mL", "l", "L", "M", "mM", "µM", "nM", "mg/ml", "µg/ml", "ng/µl", "%", "U", "g", "mg", "µg", "ng",
"volume", "volumes", "tube", "tubes", "reaction", "reactions", "plate", "plates", "x",
];
const TIME_UNITS = ["second", "seconds", "sec", "s", "minute", "minutes", "min", "hour", "hours", "h", "day", "days"];
/** A recorded gap: the marker and the reason, never the marker alone. */
const GAP = /^MISSING:\s*(\S.*)$/s;
/** The levels a protocol's `biosafety_level` may name; each is Dustin's to set. */
export const BIOSAFETY_LEVELS = ["BSL-1", "BSL-2"];
/** Words kept off a protocol's `biosafety` note: the agent only. The separate `biosafety_level` field holds the level. */
const BIOSAFETY_BANNED = /\b(BSL|biosafety level|IBC|NIH)\b/i;
/** A primer's id in the lab registry (app/kb/registry/compile.mjs ID_PATTERN): a protocol names primers by it and stores no sequence. */
const PRIMER_ID = /^[a-z0-9][a-z0-9-]{0,62}$/;
const SEO_TITLE_MAX = 60;
const DESCRIPTION_MAX = 155;
/**
* @param {unknown} value
*/
function isGap(value) {
return typeof value === "string" && GAP.test(value);
}
/**
* @param {Record<string, unknown>} item
* @param {number} index
*/
function label(item, index) {
for (const key of ["id", "name", "set", "citation", "for"]) {
if (typeof item[key] === "string" && item[key]) return /** @type {string} */ (item[key]);
}
return String(index);
}
/** What an item in a list is called in words: its name, stage or set, else its id. @param {Record<string, unknown>} item */
function itemName(item) {
for (const key of ["name", "stage", "set", "id", "citation", "for"]) {
if (typeof item[key] === "string" && item[key]) return /** @type {string} */ (item[key]);
}
return null;
}
/**
* Every recorded gap under `value`, by path, with `where`: the same place in words, below the top-level field, so the
* admin can say "TES buffer: storage" for solutions[tes-buffer].storage (app/kb/gap-labels.mjs). An unnamed item is its
* place in its list ("step 2"), left out when it is the list's only item.
*
* @param {unknown} value
* @param {string} path
* @param {Array<{ field: string, reason: string, where?: string[] }>} gaps
* @param {string[]} errors
* @param {string[]} [where]
* @param {string} [key] the key the value sits under
*/
function collectGaps(value, path, gaps, errors, where = [], key = "") {
if (typeof value === "string") {
const m = GAP.exec(value);
if (m) gaps.push({ field: path, reason: (m[1] ?? "").trim(), ...(where.length > 0 ? { where } : {}) });
else if (/^MISSING\b/.test(value)) errors.push(`${path} is "${value}": write MISSING: and the reason it is missing`);
} else if (Array.isArray(value)) {
value.forEach((item, i) => {
const object = item && typeof item === "object" ? /** @type {Record<string, unknown>} */ (item) : null;
const name = object ? itemName(object) : null;
const words = name ? [...where, name] : value.length > 1 ? [...where, `${key.replace(/s$/, "")} ${i + 1}`] : where;
collectGaps(item, `${path}[${object ? label(object, i) : i}]`, gaps, errors, words, key);
});
} else if (value && typeof value === "object") {
for (const [k, v] of Object.entries(value)) {
// A list or a group under a key is named by its items; a value is named by its key.
const leaf = path && !(v && typeof v === "object");
collectGaps(v, path ? `${path}.${k}` : k, gaps, errors, leaf ? [...where, k.replace(/_c$/, " (°C)").replaceAll("_", " ")] : where, k);
}
}
}
/**
* "250 µl", "0.5 ml", "100% or 80%", "1.25 to 5 µl", "2.5 volumes".
*
* @param {string[]} units
*/
function quantityPattern(units) {
const number = String.raw`\d[\d,]*(?:\.\d+)?`;
const unit = units.map((u) => u.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join("|");
return new RegExp(`^${number}(?: ?(?:${unit}))?(?: (?:to|or) ${number})? ?(?:${unit})$`);
}
const PROTOCOL_QUANTITY = quantityPattern(PROTOCOL_UNITS);
/**
* @param {unknown} value
*/
function nonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0;
}
/**
* The version history, newest first: each version with its date, what changed and, once Zenodo has minted
* one, its DOI. The first entry is the version the page is at, so the version is stored once in `version`
* and the history cannot disagree with it.
*
* @param {Record<string, any>} d
* @param {string[]} errors
*/
function validateHistory(d, errors) {
if (d.history === undefined || isGap(d.history)) return;
if (!Array.isArray(d.history) || d.history.length === 0) {
errors.push("history must be a list of versions, newest first");
return;
}
const seen = new Set();
let newer = "9999-12-31";
d.history.forEach((/** @type {any} */ h, /** @type {number} */ i) => {
const at = `history[${h?.version ?? i}]`;
if (!h || typeof h !== "object") return errors.push(`${at} must be a version`);
for (const k of Object.keys(h)) if (!["version", "date", "summary", "doi"].includes(k)) errors.push(`${at}.${k} is not a history field (version, date, summary, doi)`);
if (!VERSION_PATTERN.test(String(h.version ?? ""))) errors.push(`${at} needs a version of letters, digits, dots, hyphens and underscores`);
if (seen.has(String(h.version))) errors.push(`${at}: the version is listed twice`);
seen.add(String(h.version));
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(h.date ?? ""))) errors.push(`${at}.date is ${JSON.stringify(h.date)}; write the date as YYYY-MM-DD`);
else if (String(h.date) > newer) errors.push(`${at}.date ${h.date} is later than the entry above it; the history is newest first`);
else newer = String(h.date);
if (!nonEmptyString(h.summary)) errors.push(`${at} needs a summary of what changed`);
if (h.doi !== undefined && !DOI_PATTERN.test(String(h.doi))) errors.push(`${at}.doi is ${JSON.stringify(h.doi)}; write the DOI as 10.xxxx/suffix, without the https://doi.org/ address`);
});
if (d.version === undefined || d.version === null || isGap(d.version)) {
errors.push("history needs a version: the first entry is the version the page is at, so set version to it");
} else if (String(d.history[0]?.version) !== String(d.version)) {
errors.push(`version is ${JSON.stringify(d.version)} but the first history entry is ${JSON.stringify(String(d.history[0]?.version))}; the newest entry is the current version`);
}
}
/**
* Checks one parsed procedure.
*
* @param {import("./parse.mjs").ParsedProcedure} parsed
* @param {{ slug: string, primers?: ReadonlySet<string> | null, strains?: ReadonlySet<string> | null, reagents?: ReadonlySet<string> | null, equipment?: ReadonlySet<string> | null }} expect the slug its file name gives, and the ids of the primers and
* host strains the lab registry holds (null or absent when the registry is not at hand, which `compileProcedure` refuses for a protocol that lists primers)
* @returns {{ errors: string[], gaps: Array<{ field: string, reason: string, where?: string[], section?: string }> }}
*/
export function validateProcedure(parsed, expect) {
/** @type {string[]} */
const errors = [...parsed.problems];
/** @type {Array<{ field: string, reason: string, where?: string[], section?: string }>} */
const gaps = [];
const d = parsed.data;
const profile = /** @type {typeof PROFILES[number]} */ (d.profile);
if (!PROFILES.includes(profile)) {
errors.push(`profile is ${JSON.stringify(d.profile)}; it must be one of ${PROFILES.join(", ")}`);
return { errors, gaps };
}
/** @param {string} field */
const required = (field) => {
const value = d[field];
if (value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0)) {
errors.push(`${field} is required for a ${profile}; write the value, or "MISSING: <why>" if nobody has it yet`);
return false;
}
return true;
};
collectGaps(d, "", gaps, errors);
// Identity.
const root = PROFILE_ROOTS[profile];
if (required("path")) {
if (typeof d.path !== "string" || d.path !== `${root}${expect.slug}`) {
errors.push(`path is ${JSON.stringify(d.path)}; a ${profile} named ${expect.slug}.md lives at ${root}${expect.slug}`);
}
}
for (const field of ["title", "seo_title", "description"]) {
if (required(field) && !nonEmptyString(d[field])) errors.push(`${field} must be text`);
}
if (typeof d.seo_title === "string" && d.seo_title.length > SEO_TITLE_MAX) {
errors.push(`seo_title is ${d.seo_title.length} characters; Google clips near ${SEO_TITLE_MAX}`);
}
if (typeof d.description === "string" && d.description.length > DESCRIPTION_MAX) {
errors.push(`description is ${d.description.length} characters; Google clips near ${DESCRIPTION_MAX}`);
}
if (d.draft !== undefined && typeof d.draft !== "boolean") errors.push("draft must be true or false");
// A variant made with the editor's Duplicate names the procedure it was copied from, and the version it was copied at.
if (d.forked_from !== undefined) {
const f = d.forked_from;
if (!f || typeof f !== "object" || Array.isArray(f) || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(f.slug ?? ""))) {
errors.push("forked_from is { slug, version }: the file name of the procedure this one was copied from, and its version if it had one");
} else {
for (const key of Object.keys(f)) if (!["slug", "version"].includes(key)) errors.push(`forked_from.${key} is not a forked_from field (slug, version)`);
if (f.slug === expect.slug) errors.push("forked_from names this procedure itself");
}
}
// What the library filters by. method, organism and course are ids from the closed lists in taxonomy.mjs; target
// is the gene, region or sample, in words. Every procedure is some method, so method is required.
// An organism is a non-strain organism or a strain of the lab registry (docs/REGISTRY.md), read from it as the host strains are.
const organisms = expect.strains ? [...Object.keys(NON_STRAIN_ORGANISMS), ...expect.strains] : null;
for (const [field, vocabulary] of /** @type {const} */ ([["method", METHODS], ["organism", null], ["course", COURSES]])) {
if (field === "method") required(field);
const value = d[field];
if (value === undefined || isGap(value)) continue;
const allowed = vocabulary ? Object.keys(vocabulary) : organisms;
// With no registry at hand an organism cannot be judged, and compileProcedure refuses a file that names one.
if (!allowed) continue;
if (!Array.isArray(value)) errors.push(`${field} must be a list of ids from: ${allowed.join(", ")}`);
else {
for (const id of value) {
if (allowed.includes(id)) continue;
errors.push(
vocabulary
? `${field} "${id}" is not one of: ${allowed.join(", ")} (taxonomy.mjs)`
: `organism "${id}" is neither one of: ${Object.keys(NON_STRAIN_ORGANISMS).join(", ")} (taxonomy.mjs) nor a strain in the lab registry (there is no content/registry/strain/${id}.md)`,
);
}
if (new Set(value).size !== value.length) errors.push(`${field} names one id twice`);
}
}
if (d.target !== undefined && !isGap(d.target)) {
if (!Array.isArray(d.target) || d.target.some((t) => !nonEmptyString(t))) errors.push("target must be a list of the genes, regions or samples it works on, each in words");
}
// Where it stands in the library's "Start here" list: a position (1, 2, ...), stored, never inferred. A position
// two procedures share would make the order a guess, so the corpus check refuses it (checkStartHere).
if (d.start_here !== undefined && !isGap(d.start_here)) {
if (!Number.isInteger(d.start_here) || d.start_here < 1) errors.push("start_here must be a position in the Start here list: 1, 2, 3 ...");
}
// Proof of use: the papers that used the method and the phages it produced, as slugs. Stated, never inferred.
if (!isGap(d.proof_of_use)) errors.push(...proofErrors(d.proof_of_use));
// The shared core.
if (required("version") && !isGap(d.version) && !VERSION_PATTERN.test(String(d.version))) {
errors.push(`version is ${JSON.stringify(d.version)}; it sits in a URL, so use letters, digits, dots, hyphens and underscores, such as "2" or "1.1"`);
}
validateHistory(d, errors);
if (required("updated") && !isGap(d.updated) && !/^\d{4}-\d{2}-\d{2}$/.test(String(d.updated))) {
errors.push(`updated is ${JSON.stringify(d.updated)}; write the date as YYYY-MM-DD`);
}
if (required("based_on") && !isGap(d.based_on)) {
if (!Array.isArray(d.based_on)) errors.push("based_on must be a list of sources");
else {
d.based_on.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
if (!s || typeof s !== "object" || !nonEmptyString(s.citation)) errors.push(`based_on[${i}] needs a citation`);
else if (!nonEmptyString(s.for)) errors.push(`based_on[${s.citation}] needs a "for": what it is the source of`);
});
}
}
if (required("references") && !isGap(d.references)) {
if (!Array.isArray(d.references) || d.references.some((r) => !nonEmptyString(r))) {
errors.push("references must be a list of markdown lines");
}
}
for (const field of ["expected_results", "limitations"]) {
if (required(field) && !nonEmptyString(d[field])) errors.push(`${field} must be markdown text`);
}
if (d.time !== undefined) {
if (!d.time || typeof d.time !== "object") errors.push("time must hold total and hands_on");
else for (const k of Object.keys(d.time)) if (!["total", "hands_on"].includes(k)) errors.push(`time.${k} is not a time field`);
}
// Materials and equipment.
/** @type {Map<string, any>} */
const materials = new Map();
if (profile !== "recipe" || d.materials !== undefined) {
if (required("materials")) {
if (!Array.isArray(d.materials)) errors.push("materials must be a list");
else {
d.materials.forEach((/** @type {any} */ m, /** @type {number} */ i) => {
if (!m || typeof m !== "object" || !nonEmptyString(m.name)) {
errors.push(`materials[${i}] needs a name`);
return;
}
const key = m.name.toLowerCase();
if (materials.has(key)) errors.push(`materials lists "${m.name}" twice`);
materials.set(key, m);
});
}
}
}
if (d.equipment !== undefined && !Array.isArray(d.equipment)) errors.push("equipment must be a list");
// An item that is lab-registry equipment names it with `equipment: <id>` (docs/REGISTRY.md): the protocol keeps its own phrase for
// it (the setting is the protocol's), and who makes it is stored there once.
if (Array.isArray(d.equipment)) {
for (const e of d.equipment) {
if (typeof e !== "object" || e === null || e.equipment === undefined) continue;
const at = `equipment[${String(e.name)}]`;
if (typeof e.equipment !== "string" || !PRIMER_ID.test(e.equipment)) {
errors.push(`${at}.equipment is the id of an equipment item in the lab registry (content/registry/equipment/), such as water-bath`);
} else if (expect.equipment && !expect.equipment.has(e.equipment)) {
errors.push(`${at}.equipment names no equipment in the lab registry (there is no content/registry/equipment/${e.equipment}.md)`);
}
}
}
const equipmentNames = new Set(
(Array.isArray(d.equipment) ? d.equipment : []).map((/** @type {any} */ e) =>
String(typeof e === "string" ? e : e?.name ?? "").toLowerCase(),
),
);
// Troubleshooting.
const troubleIds = new Set();
if (d.troubleshooting !== undefined) {
if (!Array.isArray(d.troubleshooting)) errors.push("troubleshooting must be a list of rows");
else {
d.troubleshooting.forEach((/** @type {any} */ row, /** @type {number} */ i) => {
const at = `troubleshooting[${row?.id ?? i}]`;
if (!row || typeof row !== "object") return errors.push(`${at} must be a row`);
if (!/^[a-z0-9-]+$/.test(String(row.id ?? ""))) errors.push(`${at} needs an id in lowercase-with-hyphens`);
if (troubleIds.has(row.id)) errors.push(`${at}: the id is used twice`);
troubleIds.add(row.id);
for (const col of ["step", "problem", "reason", "solution"]) {
if (!nonEmptyString(row[col])) errors.push(`${at} needs ${col}: the table's columns are step, problem, possible reason, solution`);
}
for (const k of Object.keys(row)) {
if (!["id", "step", "problem", "reason", "solution"].includes(k)) errors.push(`${at}.${k} is not a column`);
}
});
}
}
// The method.
const steps = allSteps(parsed);
if (steps.length === 0) errors.push("the procedure has no steps: a method section holds a numbered list");
let previous = 0;
for (const section of parsed.sections) {
for (const block of section.blocks) {
if (block.type !== "steps") continue;
const first = block.steps[0];
if (first && first.number !== 1 && first.number !== previous + 1) {
errors.push(`line ${first.line}: step ${first.number} neither restarts at 1 nor follows step ${previous}`);
}
block.steps.forEach((step, i) => {
if (i > 0 && step.number !== (block.steps[i - 1]?.number ?? 0) + 1) {
errors.push(`line ${step.line}: step ${step.number} follows step ${block.steps[i - 1]?.number}`);
}
});
previous = block.steps[block.steps.length - 1]?.number ?? previous;
}
}
// A step's section, so a gap in it can say where it is (app/kb/gap-labels.mjs).
const sectionOf = new Map(parsed.sections.flatMap((section) => section.blocks.flatMap((block) => (block.type === "steps" ? block.steps.map((s) => [s, section.title]) : []))));
for (const step of steps) {
const at = `line ${step.line}, step ${step.number}`;
if (!step.source.trim()) errors.push(`${at} has no words`);
for (const reason of step.flags.critical) if (!reason) errors.push(`${at}: CRITICAL needs its reason`);
for (const kind of /** @type {const} */ (["pause", "why", "expect", "challenge", "solution"])) {
for (const text of step.flags[kind]) if (!text) errors.push(`${at}: an empty ${kind.toUpperCase()} flag`);
}
for (const id of step.flags.troubleshooting) {
if (!troubleIds.has(id)) errors.push(`${at}: TROUBLESHOOTING names "${id}", which is not a row id in troubleshooting`);
}
if (step.flags.calc.length > 0 && profile !== "protocol") errors.push(`${at}: CALC belongs to the protocol profile`);
const calcs = readCalcs(step.flags.calc);
if (new Set(calcs.map((c) => c.id)).size !== calcs.length) errors.push(`${at}: CALC names a calculator twice`);
for (const { id, values } of calcs) {
const tool = Object.hasOwn(TOOLS, id) ? TOOLS[id] : undefined;
if (!tool) {
errors.push(id ? `${at}: CALC names "${id}", which is not a calculator (${Object.keys(TOOLS).join(", ")})` : `${at}: CALC gives a value before it names a calculator`);
continue;
}
for (const [name, value] of Object.entries(values)) {
if (!tool.fields.some((f) => f.name === name)) errors.push(`${at}: CALC ${id} has no field "${name}" (${tool.fields.map((f) => f.name).join(", ")})`);
// Every value is the step's own: a number that is not in the step's words is not from the record.
else if (!step.source.includes(value)) errors.push(`${at}: CALC ${id} ${name}=${value}, but the step does not state ${value}; a value is filled in only from the step's own words`);
}
}
for (const seg of step.segments) {
if (seg.type === "material") {
const declared = materials.get(seg.name.toLowerCase());
if (!declared && profile !== "recipe") {
errors.push(`${at}: @${seg.name} is not in materials; declare it there with its stock and amount`);
}
if (seg.quantity && profile === "protocol" && !PROTOCOL_QUANTITY.test(`${seg.quantity.amount} ${seg.quantity.unit}`.trim())) {
errors.push(`${at}: @${seg.name}'s amount "${seg.quantity.raw}" is not a number and a unit from the closed list (${PROTOCOL_UNITS.join(", ")})`);
}
}
if (seg.type === "equipment" && profile !== "recipe" && !equipmentNames.has(seg.name.toLowerCase())) {
errors.push(`${at}: #${seg.name} is not in equipment`);
}
if (seg.type === "timer") {
if (!seg.quantity || seg.quantity.min === null || !TIME_UNITS.includes(seg.quantity.unit)) {
errors.push(`${at}: the timer ~{${seg.quantity?.raw ?? ""}} needs a number and a unit of time (${TIME_UNITS.join(", ")})`);
}
}
}
if (profile === "protocol") {
const { spins } = stepConditions(step);
const words = step.segments.map((s) => (s.type === "text" ? s.value : "")).join("");
const spinsHere = /\bspin\b|\bspun\b|\bcentrifuge\b/i.test(words.replace(/pulse[- ]spin/gi, ""));
const hasG = spins.some((s) => s.unit === "x g");
if (spinsHere && !hasG && step.flags.spin.length === 0) {
errors.push(`${at} spins with no g-force: add "> SPIN: <n> x g", or "> SPIN: MISSING: <why>" (a speed in rpm needs the rotor)`);
}
for (const spin of step.flags.spin) {
if (isGap(spin)) gaps.push({ field: `step ${step.number} (line ${step.line}).spin`, reason: spin.replace(/^MISSING:\s*/, ""), section: sectionOf.get(step) ?? "" });
else if (!/x g\b/.test(spin)) errors.push(`${at}: SPIN "${spin}" is not in x g`);
}
} else if (step.flags.spin.length > 0) {
errors.push(`${at}: SPIN belongs to the protocol profile`);
}
// A tutorial teaches with challenges; a how-to links its reasoning out (S1, S5).
if (step.flags.challenge.length > 0 && !(profile === "computational" && d.type === "tutorial")) {
errors.push(`${at}: CHALLENGE belongs to a tutorial (a computational procedure with type: tutorial)`);
}
if (step.flags.solution.length < step.flags.challenge.length) errors.push(`${at}: each CHALLENGE needs its SOLUTION after it`);
if (profile === "computational" && d.type === "how-to") {
for (const why of step.flags.why) {
const n = sentenceCount(why);
if (n > HOWTO_WHY_SENTENCES) {
errors.push(`${at}: WHY runs to ${n} sentences; a how-to keeps a reason to ${HOWTO_WHY_SENTENCES} at most, so put the rest on a page and link it under explanation`);
}
}
}
/** @type {Set<string>} */
const platforms = new Set();
for (const cmd of step.commands) {
if (cmd.platform === null) continue;
if (profile !== "computational") errors.push(`${at}: platform= belongs to the computational profile`);
else if (!Object.hasOwn(PLATFORMS, cmd.platform)) errors.push(`${at}: platform=${cmd.platform} is not a platform (${Object.keys(PLATFORMS).join(", ")})`);
else if (platforms.has(cmd.platform)) errors.push(`${at}: two blocks for platform=${cmd.platform}; a step gives each platform one block`);
platforms.add(cmd.platform);
}
if (platforms.size === 1) errors.push(`${at}: a block names one platform alone; give each platform its block, or name none`);
if (profile === "computational") {
for (const cmd of step.commands) {
if (cmd.output === null && step.flags.expect.length === 0) {
errors.push(`${at}: a command needs its expected output: a \`\`\`output block after it, or an EXPECT flag`);
}
}
} else if (step.commands.length > 0 && profile === "recipe") {
errors.push(`${at}: a recipe step carries no commands`);
}
if (step.photos.length > 0 && profile !== "recipe") errors.push(`${at}: step photos belong to the recipe profile`);
for (const photo of step.photos) if (!photo.alt.trim()) errors.push(`${at}: a step photo needs its alt text`);
}
// The profiles.
// A recipe's density, so its volume can be shown as weight: a number the recipe states, never a table's (DECIDE 7).
for (const m of Array.isArray(d.materials) ? d.materials : []) {
if (m?.grams_per_cup === undefined) continue;
const at = `materials[${String(m.name)}].grams_per_cup`;
if (profile !== "recipe") errors.push(`${at} belongs to the recipe profile`);
else if (typeof m.grams_per_cup !== "number" || !(m.grams_per_cup > 0)) errors.push(`${at} is the grams in one US cup of it, a number above 0`);
}
if (profile === "protocol") protocolRules(d, errors, required, materials, expect.primers ?? null, expect.strains ?? null, expect.reagents ?? null);
if (profile === "recipe") recipeRules(d, errors, required);
if (profile === "computational") computationalRules(d, errors, required, materials);
else {
for (const field of ["type", "questions", "objectives", "key_points", "explanation"]) {
if (d[field] !== undefined) errors.push(`${field} belongs to the computational profile`);
}
}
return { errors, gaps };
}
/**
* @param {Record<string, any>} d
* @param {string[]} errors
* @param {(field: string) => boolean} required
* @param {Map<string, any>} materials
* @param {ReadonlySet<string> | null} primerIds the primers the lab registry holds
* @param {ReadonlySet<string> | null} strainIds the strains the lab registry holds
* @param {ReadonlySet<string> | null} reagentIds the reagents the lab registry holds
*/
function protocolRules(d, errors, required, materials, primerIds, strainIds, reagentIds) {
for (const field of ["host_strain", "status", "last_run", "biosafety", "biosafety_level", "scale"]) required(field);
// A host strain is the lab registry's, named by id and never typed (docs/REGISTRY.md): its organism, designation and
// collection number are stored once, on its record.
if (d.host_strain !== undefined && d.host_strain !== NOT_APPLICABLE && !isGap(d.host_strain)) {
if (!Array.isArray(d.host_strain)) errors.push(`host_strain must be a list of { strain: <id> }, "${NOT_APPLICABLE}" or "MISSING: <why>"`);
else {
/** @type {Set<string>} */
const seenStrains = new Set();
for (const s of d.host_strain) {
const id = s?.strain;
const at = `host_strain[${typeof id === "string" ? id : "?"}]`;
if (typeof id !== "string" || !PRIMER_ID.test(id)) {
errors.push(`${at} is { strain: <id> }: the id of a strain in the lab registry (content/registry/strain/), such as foliorum. A protocol names its host strains and types none`);
} else if (seenStrains.has(id)) {
errors.push(`${at} is listed twice`);
} else {
seenStrains.add(id);
if (strainIds && !strainIds.has(id)) errors.push(`${at} names no strain in the lab registry (there is no content/registry/strain/${id}.md)`);
}
for (const key of Object.keys(s ?? {})) {
if (key !== "strain") errors.push(`${at}.${key} is not a protocol field: a strain's organism and collection number are stored once, in the lab registry`);
}
}
}
}
if (d.status !== undefined && !isGap(d.status) && !Object.keys(PROTOCOL_STATUSES).includes(d.status)) {
errors.push(`status is ${JSON.stringify(d.status)}; it is one of ${Object.keys(PROTOCOL_STATUSES).join(", ")} (taxonomy.mjs), or "MISSING: <why>"`);
}
if (d.biosafety_level !== undefined && !isGap(d.biosafety_level) && !BIOSAFETY_LEVELS.includes(d.biosafety_level)) {
errors.push(`biosafety_level is ${JSON.stringify(d.biosafety_level)}; it is one of ${BIOSAFETY_LEVELS.join(", ")}, or "MISSING: <why>" until Dustin sets it`);
}
if (d.biosafety && !isGap(d.biosafety) && d.biosafety !== NOT_APPLICABLE) {
if (typeof d.biosafety !== "object" || !nonEmptyString(d.biosafety.organism)) {
errors.push('biosafety lists the agent only: { organism, strain, atcc }, or "not applicable"');
} else {
for (const k of Object.keys(d.biosafety)) {
if (!["organism", "strain", "atcc"].includes(k)) errors.push(`biosafety.${k}: the agent only (organism, strain, ATCC number)`);
}
}
}
if (BIOSAFETY_BANNED.test(JSON.stringify(d.biosafety ?? ""))) {
errors.push("biosafety names a biosafety level, an IBC or the NIH Guidelines; it lists the agent only");
}
if (d.scale && !isGap(d.scale)) {
if (typeof d.scale !== "object" || !(Number(d.scale.count) > 0) || !nonEmptyString(d.scale.unit)) {
errors.push("scale needs a count and a unit, such as { count: 5, unit: tube }");
}
}
const solutionIds = new Set();
if (d.solutions !== undefined) {
if (!Array.isArray(d.solutions)) errors.push("solutions must be a list");
else {
for (const s of d.solutions) {
const at = `solutions[${s?.id ?? "?"}]`;
if (!s || !/^[a-z0-9-]+$/.test(String(s.id ?? ""))) errors.push(`${at} needs an id in lowercase-with-hyphens`);
solutionIds.add(s?.id);
if (!nonEmptyString(s?.name)) errors.push(`${at} needs a name`);
if (!Array.isArray(s?.components) || s.components.length === 0) errors.push(`${at} needs its components`);
for (const field of ["storage", "shelf_life"]) {
if (!nonEmptyString(s?.[field])) errors.push(`${at} needs ${field}, or "MISSING: <why>"`);
}
for (const c of Array.isArray(s?.components) ? s.components : []) {
if (!nonEmptyString(c?.name)) errors.push(`${at} has a component with no name`);
for (const q of ["final", "amount"]) {
if (c?.[q] !== undefined && !isGap(c[q]) && !PROTOCOL_QUANTITY.test(String(c[q]))) {
errors.push(`${at}.${c.name}.${q} is "${c[q]}", not a number and a unit from the closed list`);
}
}
}
}
}
}
for (const m of materials.values()) {
const at = `materials[${m.name}]`;
// A material that is a reagent of the lab registry names it by id (docs/REGISTRY.md): who supplies it and its catalog number
// are stored there once, and the protocol keeps its own amount, stock and final.
if (m.reagent !== undefined) {
if (typeof m.reagent !== "string" || !PRIMER_ID.test(m.reagent)) {
errors.push(`${at}.reagent is the id of a reagent in the lab registry (content/registry/reagent/), such as zinc-chloride`);
} else if (reagentIds && !reagentIds.has(m.reagent)) {
errors.push(`${at}.reagent names no reagent in the lab registry (there is no content/registry/reagent/${m.reagent}.md)`);
}
}
for (const key of ["amount", "final", "per"]) {
const q = m[key];
if (q === undefined || isGap(q)) continue;
if (key === "per" && typeof q === "string" && !/^\d/.test(q)) continue;
if (typeof q !== "string" || !PROTOCOL_QUANTITY.test(q)) {
errors.push(`${at}.${key} is "${String(q)}", not a number and a unit from the closed list (${PROTOCOL_UNITS.join(", ")})`);
}
}
if (m.stock !== undefined) {
const stocks = Array.isArray(m.stock) ? m.stock : [m.stock];
for (const s of stocks) {
if (!isGap(s) && !PROTOCOL_QUANTITY.test(String(s))) errors.push(`${at}.stock "${s}" is not a number and a unit from the closed list`);
}
}
if (m.solution !== undefined && !solutionIds.has(m.solution)) errors.push(`${at}.solution names "${m.solution}", which is not in solutions`);
}
if (d.primers !== undefined && d.primers !== NOT_APPLICABLE && !isGap(d.primers)) {
if (!Array.isArray(d.primers)) errors.push(`primers must be a list, or "${NOT_APPLICABLE}"`);
else {
/** @type {Set<string>} */
const seen = new Set();
for (const p of d.primers) {
const id = p?.primer;
const at = `primers[${typeof id === "string" ? id : "?"}]`;
if (typeof id !== "string" || !PRIMER_ID.test(id)) {
errors.push(`${at} is { primer: <id> }: the id of a primer in the lab registry (content/registry/primer/), such as lco1490. A protocol names its primers and stores no sequence`);
} else if (seen.has(id)) {
errors.push(`${at} is listed twice`);
} else {
seen.add(id);
if (primerIds && !primerIds.has(id)) errors.push(`${at} names no primer in the lab registry (there is no content/registry/primer/${id}.md)`);
}
for (const key of Object.keys(p ?? {})) {
if (key !== "primer") errors.push(`${at}.${key} is not a protocol field: a primer's sequence, direction and set are stored once, in the lab registry`);
}
}
}
}
if (d.cycling !== undefined) {
if (!Array.isArray(d.cycling)) errors.push("cycling must be a list of programs");
else {
const walk = (/** @type {any} */ stage, /** @type {string} */ at) => {
if (Array.isArray(stage?.steps)) stage.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => walk(s, `${at}.steps[${i}]`));
if (typeof stage?.temperature_c === "string" && /^\d+ to \d+$/.test(stage.temperature_c) && stage.touchdown_step_c === undefined) {
errors.push(`${at} is a touchdown (${stage.temperature_c} °C) with no touchdown_step_c; write it, or "MISSING: <why>"`);
}
};
d.cycling.forEach((/** @type {any} */ program, /** @type {number} */ i) => {
const at = `cycling[${program?.set ?? i}]`;
if (!Array.isArray(program?.program)) errors.push(`${at} needs its program: a list of stages`);
else program.program.forEach((/** @type {any} */ stage, /** @type {number} */ j) => walk(stage, `${at}.program[${stage?.stage ?? j}]`));
});
}
}
}
/**
* @param {Record<string, any>} d
* @param {string[]} errors
* @param {(field: string) => boolean} required
*/
function recipeRules(d, errors, required) {
for (const field of ["servings", "cuisine", "category", "prep_time", "cook_time", "image"]) required(field);
if (d.servings !== undefined && !isGap(d.servings) && !(Number(d.servings) > 0)) errors.push("servings must be a number");
for (const field of ["prep_time", "cook_time"]) {
if (d[field] !== undefined && !isGap(d[field]) && !/^\d+(?:\.\d+)? (minutes?|hours?)$/.test(String(d[field]))) {
errors.push(`${field} is "${d[field]}"; write it as "20 minutes" or "1.5 hours"`);
}
}
if (d.image !== undefined && !isGap(d.image) && (!nonEmptyString(d.image?.src) || !nonEmptyString(d.image?.alt))) {
errors.push("image needs src and alt");
}
if (d.substitutions !== undefined) {
if (!Array.isArray(d.substitutions)) errors.push("substitutions must be a list");
else for (const s of d.substitutions) if (!nonEmptyString(s?.for) || !nonEmptyString(s?.use)) errors.push("each substitution needs for and use");
}
if (d.scale !== undefined) errors.push("a recipe scales by servings, not scale");
}
/**
* @param {Record<string, any>} d
* @param {string[]} errors
* @param {(field: string) => boolean} required
* @param {Map<string, any>} materials
*/
function computationalRules(d, errors, required, materials) {
required("environment");
if (required("type") && !Object.hasOwn(HOWTO_TYPES, d.type)) {
errors.push(`type is ${JSON.stringify(d.type)}; it is one of ${Object.keys(HOWTO_TYPES).join(", ")}`);
}
// A tutorial opens as a Carpentries episode does: what it answers, what the reader will be able to do, and how long (S2).
for (const field of ["questions", "objectives", "key_points"]) {
if (d[field] === undefined || isGap(d[field])) continue;
if (!Array.isArray(d[field]) || d[field].some((/** @type {unknown} */ x) => !nonEmptyString(x))) errors.push(`${field} must be a list of lines`);
}
if (d.type === "tutorial") {
required("objectives");
if (!d.time || !nonEmptyString(d.time.total)) errors.push("a tutorial states its time: time.total, such as 30 minutes");
}
if (d.explanation !== undefined && !isGap(d.explanation)) {
if (!Array.isArray(d.explanation)) errors.push("explanation must be a list of pages, each with a title and a url");
else {
d.explanation.forEach((/** @type {any} */ e, /** @type {number} */ i) => {
if (!e || typeof e !== "object" || !nonEmptyString(e.title)) errors.push(`explanation[${i}] needs a title`);
else if (typeof e.url !== "string" || !/^(\/[^/\s]|https:\/\/\S)/.test(e.url)) errors.push(`explanation[${e.title}].url is a page on this site (/...) or an https address`);
});
}
}
if (required("prerequisites") && !isGap(d.prerequisites) && !Array.isArray(d.prerequisites)) {
errors.push("prerequisites must be a list");
}
if (d.scale !== undefined || d.servings !== undefined) errors.push("a computational procedure does not scale");
for (const m of materials.values()) {
if (!["software", "data", "file"].includes(m.kind)) errors.push(`materials[${m.name}].kind must be software, data or file`);
if (m.kind === "software" && !nonEmptyString(m.version)) errors.push(`materials[${m.name}] is software and needs its version`);
}
}