From e8e92cc79c86cdbc967995186d759d7c6e871d8c Mon Sep 17 00:00:00 2001 From: Rahul Ranjan Date: Thu, 22 Oct 2026 14:37:15 +0530 Subject: [PATCH 1/2] fix(links): validate --stale-days up front and never count API failures as problems --stale-days given as the last argument or as a non-number became NaN and the run died in new Date(NaN).toISOString() only after every fetch had already run. A GitHub API 5xx/timeout/rate limit was written as a row under 'repository state differs from maintenance' and counted in problems, so a transient outage opened the correction issue; after a 403/429 the loop broke and the remaining repositories were silently unchecked. The flag is now validated before anything runs, API failances land in a 'Could not check (N skipped)' section excluded from problems, and a rate-limited loop says which repositories were not checked and why. The helpers moved to lib/link-check.mjs so the argument parsing and the failure classification are tested. Fixes #82 Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- site/check-links.mjs | 65 ++++++++++-------------------- site/lib/lib.test.mjs | 62 ++++++++++++++++++++++++++++ site/lib/link-check.mjs | 89 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 170 insertions(+), 46 deletions(-) diff --git a/site/check-links.mjs b/site/check-links.mjs index e4d52e3..4eb8a89 100644 --- a/site/check-links.mjs +++ b/site/check-links.mjs @@ -21,10 +21,9 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { maintenanceFromRepo } from "./checks.mjs"; import { parseFrontmatter } from "./lib/frontmatter.mjs"; import { listEntryFiles } from "./lib/entry-files.mjs"; -import { classify, repoOf, reportUrl } from "./lib/link-check.mjs"; +import { checkMaintenance, classify, parseStaleDays, reportUrl } from "./lib/link-check.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, ".."); @@ -82,48 +81,22 @@ async function run(entries) { } /** - * GitHub sources whose repository state and `maintenance` field disagree. - * One API call per github.com repository, one at a time: a few dozen calls, - * well inside the limit with a token. An API failure is listed, not guessed. + * The GitHub maintenance check now lives in ./lib/link-check.mjs + * (`checkMaintenance`) so its failure classification is tested: it returns + * `{ differs, skipped }`, and API failures are never counted as problems. */ -async function checkMaintenance(entries) { - const headers = { "user-agent": UA, accept: "application/vnd.github+json" }; - if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`; - const out = []; - for (const e of entries) { - let host; - try { - host = new URL(e.url).hostname; - } catch { - continue; - } - if (host !== "github.com") continue; - const repo = repoOf(e.url); - if (!repo.includes("/")) continue; - try { - const res = await fetch(`https://api.github.com/repos/${repo}`, { headers, signal: AbortSignal.timeout(TIMEOUT_MS) }); - if (!res.ok) { - out.push({ ...e, detail: `GitHub API HTTP ${res.status} for ${repo}` }); - if (res.status === 403 || res.status === 429) break; // rate limited: the rest would fail too - continue; - } - const data = await res.json(); - const want = maintenanceFromRepo(data); - if (want !== e.maintenance) { - const state = data.archived ? "archived" : `last push ${String(data.pushed_at).slice(0, 10)}`; - out.push({ ...e, detail: `${repo} is ${state}; maintenance is ${e.maintenance || "unset"}, expected ${want || "unset"}` }); - } - } catch (err) { - out.push({ ...e, detail: `GitHub API ${err.name || "error"} for ${repo}` }); - } - } - return out; -} -const staleDays = Number(arg("--stale-days", "365")); +let staleDays; +try { + staleDays = parseStaleDays(arg("--stale-days", "365")); +} catch (err) { + console.error(err.message); + console.error("usage: node site/check-links.mjs [--report ] [--stale-days 365]"); + process.exit(2); +} const entries = loadEntries().filter((e) => e.status !== "deprecated"); const results = await run(entries); -const maintenance = await checkMaintenance(entries); +const { differs: maintenanceDiffers, skipped: maintenanceSkipped } = await checkMaintenance(entries, { userAgent: UA, timeoutMs: TIMEOUT_MS }); const cutoff = new Date(Date.now() - staleDays * 86400000).toISOString().slice(0, 10); const rows = entries.map((e, i) => ({ ...e, ...results[i] })); const pick = (kind) => rows.filter((r) => r.kind === kind); @@ -133,7 +106,7 @@ const line = (r) => `- [ ] [\`${r.id}\`](${REPO}/blob/main/${r.rel}): ${reportUr const dead = pick("dead"); const moved = pick("moved"); const blocked = pick("blocked"); -const problems = dead.length + moved.length + stale.length + maintenance.length; +const problems = dead.length + moved.length + stale.length + maintenanceDiffers.length; const date = new Date().toISOString().slice(0, 10); const report = [ `Link check of ${entries.length} active and needs-review entries, run ${date}.`, @@ -152,9 +125,13 @@ const report = [ "", ...(stale.length ? stale.map((e) => `- [ ] [\`${e.id}\`](${REPO}/blob/main/${e.rel}): verified ${e.verified}`) : ["None."]), "", - `### GitHub repository state differs from \`maintenance\` (${maintenance.length})`, + `### GitHub repository state differs from \`maintenance\` (${maintenanceDiffers.length})`, + "", + ...(maintenanceDiffers.length ? maintenanceDiffers.map(line) : ["None."]), + "", + `### Could not check (GitHub API failures, ${maintenanceSkipped.length})`, "", - ...(maintenance.length ? maintenance.map(line) : ["None."]), + ...(maintenanceSkipped.length ? maintenanceSkipped.map(line) : ["None."]), "", `
Refused the checker (${blocked.length}): usually bot blocking, check by hand`, "", @@ -168,4 +145,4 @@ const reportPath = arg("--report", null); if (reportPath) fs.writeFileSync(reportPath, report); else process.stdout.write(report); if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `problems=${problems}\n`); -console.error(`${entries.length} checked: ${dead.length} dead, ${moved.length} moved, ${stale.length} stale, ${maintenance.length} maintenance, ${blocked.length} blocked`); +console.error(`${entries.length} checked: ${dead.length} dead, ${moved.length} moved, ${stale.length} stale, ${maintenanceDiffers.length} maintenance, ${maintenanceSkipped.length} API-unchecked, ${blocked.length} blocked`); diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs index f6b3cbc..b1a5233 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -683,6 +683,68 @@ const neText = newEntryText("id: example\ncategory: 3d\nverified: 2026-07-19 # eq("new-entry text", neText, "id: my-pack\ncategory: 2d\nverified: 2026-10-04\nstatus: needs-review\n"); eq("template copy starts needs-review", newEntryText(nodeFs.readFileSync(new URL("../../catalog/TEMPLATE.md", import.meta.url), "utf8"), { id: "a", category: "2d", today: "2026-10-04" }).split(/\r?\n/).includes("status: needs-review"), true); +/* check-links helpers (#82) ------------------------------------------------ */ +import { checkMaintenance, parseStaleDays } from "./link-check.mjs"; + +eq("stale-days: whole number", parseStaleDays("365"), 365); +eq("stale-days: zero allowed", parseStaleDays("0"), 0); +let staleThrew = ""; +try { parseStaleDays("abc"); } catch (e) { staleThrew = e.message; } +has("stale-days: non-number rejected", staleThrew, "whole number"); +staleThrew = ""; +try { parseStaleDays(undefined); } catch (e) { staleThrew = e.message; } +has("stale-days: missing value rejected", staleThrew, "undefined"); +staleThrew = ""; +try { parseStaleDays("-5"); } catch (e) { staleThrew = e.message; } +has("stale-days: negative rejected", staleThrew, "whole number"); +staleThrew = ""; +try { parseStaleDays("3.5"); } catch (e) { staleThrew = e.message; } +has("stale-days: fraction rejected", staleThrew, "whole number"); + +const ghEntry = (id, maintenance) => ({ id, url: `https://github.com/example/${id}`, maintenance, rel: `catalog/2d/${id}.md` }); +const ghRes = (status, data) => ({ ok: status >= 200 && status < 300, status, json: async () => data }); + +{ + const { differs, skipped } = await checkMaintenance([ghEntry("a", null)], { fetchImpl: async () => ghRes(500, {}) }); + eq("maintenance: 5xx not a problem", differs.length, 0); + eq("maintenance: 5xx skipped", skipped.length, 1); + has("maintenance: 5xx detail", skipped[0].detail, "HTTP 500"); +} +{ + const { differs, skipped } = await checkMaintenance([ghEntry("a", null)], { + fetchImpl: async () => ghRes(200, { archived: true, pushed_at: "2026-01-01T00:00:00Z" }), + }); + eq("maintenance: mismatch found", differs.length, 1); + eq("maintenance: mismatch only", skipped.length, 0); + has("maintenance: mismatch detail", differs[0].detail, "archived"); +} +{ + let calls = 0; + const { differs, skipped } = await checkMaintenance([ghEntry("a", null), ghEntry("b", null), ghEntry("c", null)], { + fetchImpl: async () => { calls += 1; return ghRes(403, {}); }, + }); + eq("maintenance: rate limit stops the loop", calls, 1); + eq("maintenance: rest reported skipped", skipped.length, 3); + has("maintenance: skipped reason", skipped[2].detail, "not checked"); + eq("maintenance: rate limit not a problem", differs.length, 0); +} +{ + const { differs, skipped } = await checkMaintenance([ghEntry("a", null)], { + fetchImpl: async () => { throw new Error("timeout"); }, + }); + eq("maintenance: fetch error skipped", skipped.length, 1); + has("maintenance: fetch error detail", skipped[0].detail, "GitHub API"); + eq("maintenance: fetch error not a problem", differs.length, 0); +} +{ + let calls = 0; + const { differs, skipped } = await checkMaintenance([{ id: "x", url: "https://example.com/a", maintenance: null, rel: "x" }], { + fetchImpl: async () => { calls += 1; return ghRes(200, {}); }, + }); + eq("maintenance: non-github ignored", calls, 0); + eq("maintenance: non-github no rows", differs.length + skipped.length, 0); +} + /* report (keep last) ------------------------------------------------------ */ if (failures.length) { console.error(`lib.test failed (${failures.length}):`); diff --git a/site/lib/link-check.mjs b/site/lib/link-check.mjs index 419e37d..d2f64c8 100644 --- a/site/lib/link-check.mjs +++ b/site/lib/link-check.mjs @@ -1,8 +1,12 @@ /** - * The pure parts of site/check-links.mjs: how a response is classified and - * how a URL is written into the report issue. Kept here so they are tested. + * The pure parts of site/check-links.mjs: how a response is classified, + * how a URL is written into the report issue, how the run is configured and + * how the GitHub maintenance check separates findings from outages. + * Kept here so they are tested. */ +import { maintenanceFromRepo } from "../checks.mjs"; + /** * Two-label public suffixes the catalog's sources sit under. A plain "last two * labels" rule would read every *.co.uk host as the same site. @@ -44,3 +48,84 @@ export function classify(entryUrl, status, finalUrl) { export function reportUrl(url) { return `\`${String(url).replaceAll("`", "%60")}\``; } + +/** + * `--stale-days` for site/check-links.mjs: a whole number of days, >= 0. + * A flag given as the last argument arrives as `undefined` and a non-number + * as NaN; either used to slip through and the run died later, in + * `new Date(NaN).toISOString()` — after every fetch had already run (#82). + */ +export function parseStaleDays(raw) { + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + throw new Error(`--stale-days wants a whole number of days >= 0, got ${JSON.stringify(String(raw))}`); + } + return n; +} + +/** + * GitHub sources whose repository state and `maintenance` field disagree, + * plus the ones that could not be checked at all. + * + * An API failure — a 5xx, a timeout or a rate limit — is a *skipped* row, + * never a catalog problem: guessing from a transient error used to open or + * update the correction issue over an outage. After a 403/429 the remaining + * repositories are marked skipped with the reason instead of silently + * disappearing from the report (#82). + * + * `fetchImpl` is injectable so the failure classification is testable + * without the network. + */ +export async function checkMaintenance(entries, opts = {}) { + const fetchImpl = opts.fetchImpl ?? globalThis.fetch; + const timeoutMs = opts.timeoutMs ?? 20000; + const headers = { + accept: "application/vnd.github+json", + "user-agent": opts.userAgent ?? "Free-Game-Dev-Assets link check", + }; + const token = opts.token ?? process.env.GITHUB_TOKEN; + if (token) headers.authorization = `Bearer ${token}`; + + const pending = []; + for (const e of entries) { + let host; + try { + host = new URL(e.url).hostname; + } catch { + continue; + } + if (host !== "github.com") continue; + if (!repoOf(e.url).includes("/")) continue; + pending.push(e); + } + + const differs = []; + const skipped = []; + for (let i = 0; i < pending.length; i++) { + const e = pending[i]; + const repo = repoOf(e.url); + try { + const res = await fetchImpl(`https://api.github.com/repos/${repo}`, { headers, signal: AbortSignal.timeout(timeoutMs) }); + if (!res.ok) { + skipped.push({ ...e, detail: `GitHub API HTTP ${res.status} for ${repo}` }); + if (res.status === 403 || res.status === 429) { + // Rate limited: the rest would fail the same way — say so instead of dropping them. + for (const rest of pending.slice(i + 1)) { + skipped.push({ ...rest, detail: `not checked: stopped after GitHub API HTTP ${res.status}` }); + } + break; + } + continue; + } + const data = await res.json(); + const want = maintenanceFromRepo(data); + if (want !== e.maintenance) { + const state = data.archived ? "archived" : `last push ${String(data.pushed_at).slice(0, 10)}`; + differs.push({ ...e, detail: `${repo} is ${state}; maintenance is ${e.maintenance || "unset"}, expected ${want || "unset"}` }); + } + } catch (err) { + skipped.push({ ...e, detail: `GitHub API ${err.name || "error"} for ${repo}` }); + } + } + return { differs, skipped }; +} From 18cf9f51e157beb2f0135bbcdb6c87ac8cf4b5ff Mon Sep 17 00:00:00 2001 From: Rahul Ranjan Date: Thu, 22 Oct 2026 14:51:13 +0530 Subject: [PATCH 2/2] fix(validate): type-check list and boolean fields; strip trailing comments in scalars The validator checked list fields for emptiness but never that they are lists, and never that commercial/attribution_required are booleans. The frontmatter parser had no trailing-comment handling, so the template's own 'tags: [low-poly, modular] # ...' line parsed as a string and every new entry from npm run new-entry that kept the comment started with string tags; a quoted commercial: "true" passed the enum check and rendered as 'commercial ?'. parseScalar now strips unquoted trailing comments (a # preceded by whitespace, outside quotes; URL fragments untouched), and validate rejects non-list lists and non-boolean boolean fields via two new checks in checks.mjs. Fixes #79 Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- site/checks.mjs | 39 +++++++++++++++++++++++++++++++++++++++ site/checks.test.mjs | 23 +++++++++++++++++++++++ site/lib/frontmatter.mjs | 22 +++++++++++++++++++++- site/lib/lib.test.mjs | 20 +++++++++++++++++++- site/validate.mjs | 5 +++++ 5 files changed, 107 insertions(+), 2 deletions(-) diff --git a/site/checks.mjs b/site/checks.mjs index e5dc20f..2f50da9 100644 --- a/site/checks.mjs +++ b/site/checks.mjs @@ -30,6 +30,45 @@ export function evidenceDates(ev) { } /** Generic hosts and distributors that are never a rights holder. */ +export const LIST_FIELDS = ["subcategories", "formats", "tags", "hardware_tags"]; + +/** + * The frontmatter fields that are lists must actually be lists. The parser + * used to turn `tags: [a, b] # note` into the string "[a, b] # note" + * and every downstream check silently fell back to `[]`, so the entry was + * built with no tags at all; a quoted `commercial: "true"` likewise passed + * the enum check as a string and rendered as "commercial ?" (#79). + */ +export function checkListTypes(rel, meta, fields = LIST_FIELDS) { + const errors = []; + for (const field of fields) { + const value = meta[field]; + if (value === undefined || value === null || empty(value)) continue; + if (!Array.isArray(value)) { + const shown = typeof value === "string" ? `"${value}"` : String(value); + errors.push(`${rel} ${field} must be a list ([a, b]), got the ${typeof value} ${shown}`); + } + } + return errors; +} + +/** + * `commercial` and `attribution_required` are booleans; the documented + * string answers are listed per key. A quoted "true" is a string and is + * rejected rather than accepted as an answer (#79). + */ +export function checkBooleanField(rel, meta, key, stringAnswers) { + const value = meta[key]; + if (value === undefined || value === null || empty(value)) return []; + if (typeof value === "boolean") return []; + const answer = String(value).toLowerCase(); + if (stringAnswers.has(answer)) return []; + const allowed = `true or false${stringAnswers.size ? ` or ${[...stringAnswers].join("|")}` : ""}`; + return [`${rel} ${key} must be ${allowed}, got the string "${value}"`]; +} + +/** Generic hosts that host many rights holders, so a name like "GitHub" is + * not a publisher. See checkPublisherConsistency for how it is used. */ export const GENERIC_HOSTS = new Set([ "github", "github.com", diff --git a/site/checks.test.mjs b/site/checks.test.mjs index 6a56df0..a9dd93b 100644 --- a/site/checks.test.mjs +++ b/site/checks.test.mjs @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import { checkActiveIsSettled, checkAttributionConsistency, + checkBooleanField, checkCategoryReadmeRows, checkCategorySets, checkCountTables, @@ -23,6 +24,7 @@ import { evidenceDates, markdownAnchors, checkLicenseVocabulary, + checkListTypes, checkMaintenance, maintenanceFromRepo, checkPublisherConsistency, @@ -748,6 +750,27 @@ accepts( formatVocab.formats.filter((f) => Object.prototype.hasOwnProperty.call(aliases.formats, f)) ); +/* field types (#79) ------------------------------------------------------- */ + +rejects("#79 string tags are a type error", checkListTypes("bad.md", { tags: "[a, b] # note" }), "must be a list"); +rejects("#79 string formats are a type error", checkListTypes("bad.md", { formats: "PNG" }), "must be a list"); +accepts("#79 list tags pass", checkListTypes("good.md", { tags: ["low-poly"] })); +accepts("#79 absent optional list passes", checkListTypes("good.md", {})); +accepts("#79 empty list passes (emptiness is a separate rule)", checkListTypes("good.md", { tags: [] })); +rejects( + "#79 quoted commercial string is rejected", + checkBooleanField("bad.md", { commercial: "true" }, "commercial", new Set(["unknown", "varies"])), + 'must be true or false or unknown|varies, got the string "true"' +); +accepts("#79 boolean commercial passes", checkBooleanField("good.md", { commercial: true }, "commercial", new Set(["unknown", "varies"]))); +accepts("#79 string varies passes", checkBooleanField("good.md", { commercial: "varies" }, "commercial", new Set(["unknown", "varies"]))); +accepts("#79 string unknown passes for attribution", checkBooleanField("good.md", { attribution_required: "unknown" }, "attribution_required", new Set(["unknown"]))); +rejects( + "#79 quoted attribution string is rejected", + checkBooleanField("bad.md", { attribution_required: "false" }, "attribution_required", new Set(["unknown"])), + 'must be true or false or unknown' +); + /* ----------------------------------------------------------------------- */ if (failures.length) { console.error(`checks.test failed (${failures.length}):`); diff --git a/site/lib/frontmatter.mjs b/site/lib/frontmatter.mjs index 37af725..b0da648 100644 --- a/site/lib/frontmatter.mjs +++ b/site/lib/frontmatter.mjs @@ -35,8 +35,28 @@ function splitFlowList(inner) { return parts.map((p) => unquoteScalar(p.trim())); } +/** + * Removes an unquoted trailing `# comment` — YAML needs a space before the + * `#` — so a template line like `tags: [a, b] # note` parses as the list + * and not as the string "[a, b] # note" (#79). A `#` inside quotes, or + * with no space before it (a URL fragment), is kept. + */ +function stripTrailingComment(raw) { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (ch === "#" && !inSingle && !inDouble && i > 0 && (raw[i - 1] === " " || raw[i - 1] === "\t")) { + return raw.slice(0, i).trimEnd(); + } + } + return raw; +} + export function parseScalar(raw) { - const v = raw.trim(); + const v = stripTrailingComment(raw).trim(); if (v === "true") return true; if (v === "false") return false; if (v === "null" || v === "~" || v === "") return null; diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs index b1a5233..ed1ae09 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -469,7 +469,7 @@ has("recent window wording counts the build day", fp, "Checked on 2026-09-25 or has("empty recent window wording counts the build day", freshnessPageHtml({ stats: freshnessStats([fe("a", "Alpha", 90)], fNow), site, stamp: "2026-09-25", total: 1, hasCard: false }), "None checked on 2026-09-25 or in the 30 days before it."); /* frontmatter -------------------------------------------------------------- */ -import { evidenceSection, parseFrontmatter, plainText, summaryFromBody } from "./frontmatter.mjs"; +import { evidenceSection, parseFrontmatter, parseScalar, plainText, summaryFromBody } from "./frontmatter.mjs"; import { latestAllowedDate } from "./shared.mjs"; const fm = (lines, body = "Body.\n") => parseFrontmatter(`---\n${lines.join("\n")}\n---\n${body}`); const f1 = fm([ @@ -492,6 +492,24 @@ eq("frontmatter with CRLF line endings", parseFrontmatter("---\r\nid: a\r\nname: has("frontmatter rejects a duplicate key", fm(["id: a", "id: b"]).errors.join(), 'line 3: key "id" is given twice'); has("frontmatter rejects a line that is not key: value", fm(["id: a", "just words"]).errors.join(), 'line 3: "just words" is not a key: value line'); eq("no frontmatter is null", parseFrontmatter("# Title\n"), null); + +/* frontmatter: trailing comments (#79) ------------------------------------ */ + +eq("scalar keeps a url fragment", parseScalar("https://example.com/#anchor"), "https://example.com/#anchor"); +eq("scalar strips a trailing comment", JSON.stringify(parseScalar("[low-poly, modular] # describe the content")), JSON.stringify(["low-poly", "modular"])); +eq("scalar strips a comment on a boolean", parseScalar("true # set active once"), true); +eq("scalar keeps a quoted hash", parseScalar('"a # not a comment"'), "a # not a comment"); +eq("scalar strips only outside quotes", parseScalar('"http://x" # note'), "http://x"); +eq("scalar keeps a hash with no space before it", parseScalar("[a,b]#note"), "[a,b]#note"); + +const fComment = fm([ + "id: a", + "tags: [low-poly, modular] # describe the content; never the licence or the publisher", + "commercial: true # yes", +]); +eq("template tags line parses as a list", JSON.stringify(fComment.meta.tags), JSON.stringify(["low-poly", "modular"])); +eq("commented boolean still parses", fComment.meta.commercial, true); +eq("template tags line has no errors", fComment.errors.length, 0); eq("evidence stops at the next heading, whatever its letter", evidenceSection("## Evidence\n\n- e (2026-01-01)\n\n## Extras\n\n- x"), "## Evidence\n\n- e (2026-01-01)\n"); eq("summary keeps underscores", summaryFromBody("Split by Rig_Medium and **KHR_draco**.\n\n## Notes"), "Split by Rig_Medium and KHR_draco."); eq("summary keeps C# and link text", summaryFromBody("# T\n\nA [C#](x.md) library, `code`, *em*."), "A C# library, code, em."); diff --git a/site/validate.mjs b/site/validate.mjs index fe0d597..2d6dfba 100644 --- a/site/validate.mjs +++ b/site/validate.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { checkActiveIsSettled, checkAttributionConsistency, + checkBooleanField, checkCategoryReadmeRows, checkCategorySets, checkCountTables, @@ -17,6 +18,7 @@ import { checkEvidenceDates, checkFormatVocabulary, checkLicenseVocabulary, + checkListTypes, checkMaintenance, checkPublisherConsistency, checkSpdxConsistency, @@ -303,6 +305,9 @@ function main() { if (attrKey === "true" && isEmptyField(meta.attribution_string)) { errors.push(`${rel} attribution_required true needs attribution_string`); } + errors.push(...checkListTypes(rel, meta)); + errors.push(...checkBooleanField(rel, meta, "commercial", new Set(["unknown", "varies"]))); + errors.push(...checkBooleanField(rel, meta, "attribution_required", new Set(["unknown"]))); errors.push(...checkEntryUrl(rel, meta)); errors.push(...checkLicenseVocabulary(rel, meta, vocab)); errors.push(...checkSpdxConsistency(rel, meta, vocab, spdxAllowed));