From e681457190202753b89b49a4e8bc22087b50df2e Mon Sep 17 00:00:00 2001 From: Sonike <1700162+Sonike@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:07:05 +0800 Subject: [PATCH] fix: reject impossible calendar dates across catalog checks --- site/checks.mjs | 14 ++++++++++---- site/checks.test.mjs | 16 ++++++++++++++++ site/lib/lib.test.mjs | 14 ++++++++++++++ site/lib/shared.mjs | 12 +++++++++++- site/lib/stacks.mjs | 5 ++--- site/validate.mjs | 4 ++-- 6 files changed, 55 insertions(+), 10 deletions(-) diff --git a/site/checks.mjs b/site/checks.mjs index 14f1968..7faa51a 100644 --- a/site/checks.mjs +++ b/site/checks.mjs @@ -8,8 +8,8 @@ import path from "node:path"; import { parseStack, pickPath, StackError } from "./lib/stacks.mjs"; +import { isRealDate } from "./lib/shared.mjs"; -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const DATE_ANY_RE = /\d{4}-\d{2}-\d{2}/g; /** Generic hosts and distributors that are never a rights holder. */ @@ -144,7 +144,7 @@ export function checkAttributionConsistency(rel, meta, body, vocab) { * The check that defends the repo's central promise: a `verified` date may * never be newer than the evidence it claims to rest on. Bumping `verified` * without adding a dated Evidence line is exactly the failure this catches. - * Also rejects Evidence dates in the future, and (V10) an Evidence section + * Also rejects impossible or future Evidence dates, and (V10) an Evidence section * that carries no date at all, whatever the entry's status. */ export function checkEvidenceDates(rel, meta, body, today) { @@ -156,12 +156,18 @@ export function checkEvidenceDates(rel, meta, body, today) { errors.push(`${rel} ## Evidence section carries no YYYY-MM-DD date`); return errors; } - const newest = dates[dates.length - 1]; + const validDates = dates.filter((date) => { + if (isRealDate(date)) return true; + errors.push(`${rel} Evidence date ${date} is not a real YYYY-MM-DD date`); + return false; + }); + if (!validDates.length) return errors; + const newest = validDates[validDates.length - 1]; if (newest > today) { errors.push(`${rel} Evidence date ${newest} is in the future`); } const verified = empty(meta.verified) ? null : String(meta.verified); - if (verified && DATE_RE.test(verified) && verified > newest) { + if (isRealDate(verified) && verified > newest) { errors.push( `${rel} verified ${verified} is newer than its newest Evidence date ${newest}. ` + `A new verified date needs a dated Evidence line from the same check` diff --git a/site/checks.test.mjs b/site/checks.test.mjs index b841d03..30246b0 100644 --- a/site/checks.test.mjs +++ b/site/checks.test.mjs @@ -157,6 +157,22 @@ accepts( ); /* V8 / V10 -------------------------------------------------------------- */ +for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2026-04-31"]) { + rejects( + `V8 rejects impossible Evidence date ${date}`, + checkEvidenceDates("bad.md", { verified: "2026-01-01" }, `# X${EV(date)}`, TODAY), + `Evidence date ${date} is not a real YYYY-MM-DD date` + ); +} +rejects( + "V8 rejects an impossible date even alongside valid Evidence", + checkEvidenceDates("bad.md", { verified: "2026-09-20" }, `# X${EV("2026-02-30")}\n- Rechecked 2026-09-20.\n`, TODAY), + "Evidence date 2026-02-30 is not a real YYYY-MM-DD date" +); +accepts( + "V8 accepts real leap-day Evidence", + checkEvidenceDates("ok.md", { verified: "2000-02-29" }, `# X${EV("2000-02-29")}`, TODAY) +); rejects( "V8 rejects a verified date newer than its newest Evidence date", checkEvidenceDates( diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs index e272919..2c18fb8 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -7,6 +7,7 @@ import { commercialLabel, entryPageUrl, esc, + isRealDate, STATUS_NOTES, verifiedAge, } from "./shared.mjs"; @@ -37,12 +38,21 @@ function throws(label, fn, needle) { } /* shared ----------------------------------------------------------------- */ +for (const date of ["2026-01-01", "2026-12-31", "2026-04-30", "2024-02-29", "2000-02-29", "0000-02-29", "0099-12-31"]) { + eq(`isRealDate accepts ${date}`, isRealDate(date), true); +} +for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2100-02-29", "2026-04-31", "2026-00-01", "2026-01-00", "2026-01-32", "0099-02-29", "2026-1-01", "2026-01-1", "2026-01-01\n", " 2026-01-01", "2026-01-01T00:00:00Z", "", null, undefined, 20260101]) { + eq(`isRealDate rejects ${JSON.stringify(date)}`, isRealDate(date), false); +} eq("esc escapes all five characters", esc(`'&'`), "<a href="x">'&'</a>"); eq("commercialLabel true", commercialLabel(true), "commercial OK"); eq("commercialLabel varies", commercialLabel("varies"), "per-file review"); eq("commercialLabel unknown", commercialLabel("unknown"), "commercial ?"); eq("verifiedAge fresh", verifiedAge("2026-09-01", Date.parse("2026-09-24T00:00:00Z")).bucket, "fresh"); eq("verifiedAge missing", verifiedAge(null, Date.now()).bucket, "unknown"); +for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2026-04-31"]) { + eq(`verifiedAge rejects ${date}`, verifiedAge(date, Date.parse("2026-09-24T00:00:00Z")).bucket, "unknown"); +} eq("entryPageUrl trims the site slash", entryPageUrl({ siteUrl: "https://x.test/site/" }, "a-b"), "https://x.test/site/entry/a-b/"); has("STATUS_NOTES covers needs-review", STATUS_NOTES["needs-review"], "open"); @@ -241,6 +251,10 @@ throws("licence in a why sentence", badStack((m) => m.replace("Five loops.", "Fi throws("licence phrase in a why sentence", badStack((m) => m.replace("Five loops.", "Five public domain loops.")), '("public domain")'); throws("missing walked", badStack((m) => m.replace("walked: 2026-09-20\n", "")), "frontmatter is missing walked"); throws("bad walked date", badStack((m) => m.replace("2026-09-20", "20 Sept")), "walked is not YYYY-MM-DD"); +for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2026-04-31"]) { + throws(`impossible walked date ${date}`, badStack((m) => m.replace("2026-09-20", date)), "walked is not YYYY-MM-DD"); +} +eq("leap-day walked date", parseStack(stackMd.replace("2026-09-20", "2000-02-29"), { file: "stacks/s1.md" }).meta.walked, "2000-02-29"); throws("empty pick section", badStack((m) => m.replace("- **Music, no credit:** [Chips](../catalog/audio/subspaceaudio-5-chiptunes.md). Five loops.", "")), 'section "Audio" has no picks'); throws("gaps line not a bullet", badStack((m) => m.replace("- No parallax layers yet.", "No parallax layers yet.")), "stacks/s1.md:24: a Gaps line is a bullet"); eq("licence terms skip plain words", licenceTerms({ licenses: { custom: {}, MIT: { spdx: "MIT" }, CC0: { spdx: "CC0-1.0" } } }, ["OFL-1.1"]).sort().join(","), "CC0,CC0-1.0,MIT,OFL-1.1"); diff --git a/site/lib/shared.mjs b/site/lib/shared.mjs index 8046aa8..1fb1793 100644 --- a/site/lib/shared.mjs +++ b/site/lib/shared.mjs @@ -19,8 +19,18 @@ export const STATUS_NOTES = { const VERIFIED_FRESH_DAYS = 180; const VERIFIED_AGING_DAYS = 365; +/** A real calendar date in strict YYYY-MM-DD form, independent of local time. */ +export function isRealDate(value) { + if (typeof value !== "string" || value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const [year, month, day] = value.split("-").map(Number); + const date = new Date(0); + // Unlike Date.UTC, this preserves years 0000-0099. + date.setUTCFullYear(year, month - 1, day); + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +} + export function verifiedAge(verified, now) { - if (!verified) return { days: null, bucket: "unknown" }; + if (!isRealDate(verified)) return { days: null, bucket: "unknown" }; const t = Date.parse(`${verified}T00:00:00Z`); if (Number.isNaN(t)) return { days: null, bucket: "unknown" }; const days = Math.max(0, Math.floor((now - t) / 86400000)); diff --git a/site/lib/stacks.mjs b/site/lib/stacks.mjs index b26e0b0..1c1f63b 100644 --- a/site/lib/stacks.mjs +++ b/site/lib/stacks.mjs @@ -5,7 +5,7 @@ */ import fs from "node:fs"; import path from "node:path"; -import { unquoteScalar } from "./shared.mjs"; +import { isRealDate, unquoteScalar } from "./shared.mjs"; export class StackError extends Error {} @@ -13,7 +13,6 @@ export class StackError extends Error {} export const STACK_SECTIONS = ["Art", "Audio", "Fonts", "Tools", "Gaps"]; const REQUIRED = ["id", "title", "task", "walked"]; -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; // - **Need:** [Entry name](path/to/entry.md). Why this pick. const PICK_RE = /^- \*\*([^*]+?):\*\* \[([^\]]+)\]\(([^)\s]+)\)\.\s+(\S.*)$/; // Names and shorthands matched in any case, as whole words. The exact ids @@ -99,7 +98,7 @@ export function parseStack(text, { file, terms = [] }) { metaLine[key] = i; } for (const key of REQUIRED) if (!meta[key]) fail(0, `frontmatter is missing ${key}`); - if (!DATE_RE.test(meta.walked)) fail(0, "walked is not YYYY-MM-DD"); + if (!isRealDate(meta.walked)) fail(0, "walked is not YYYY-MM-DD (a real calendar date is required)"); noLicence(metaLine.title, meta.title, "title"); noLicence(metaLine.task, meta.task, "task"); diff --git a/site/validate.mjs b/site/validate.mjs index 914301a..2c0616f 100644 --- a/site/validate.mjs +++ b/site/validate.mjs @@ -20,6 +20,7 @@ import { checkValueSpellings, } from "./checks.mjs"; import { licenceTerms, listStackFiles } from "./lib/stacks.mjs"; +import { isRealDate } from "./lib/shared.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, ".."); @@ -81,7 +82,6 @@ const SKIP_WALK = new Set([".git", "node_modules", "dist", "RESEARCH"]); const ALLOWED_BINARY_PREFIXES = ["site/dist/", "docs/images/readme/"]; const EMOJI_RE = /\p{Extended_Pictographic}/u; const MD_LINK_RE = /!\[[^\]]*\]\(([^)]+)\)|\[[^\]]*\]\(([^)]+)\)/g; -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const EVIDENCE_DATE_RE = /\d{4}-\d{2}-\d{2}/; const COMMERCIAL_VALUES = new Set(["true", "false", "unknown", "varies"]); const STATUS_VALUES = new Set(["active", "needs-review", "deprecated"]); @@ -315,7 +315,7 @@ function main() { if (meta.verified) { const v = String(meta.verified); - if (!DATE_RE.test(v)) errors.push(`${rel} verified is not YYYY-MM-DD`); + if (!isRealDate(v)) errors.push(`${rel} verified is not a real YYYY-MM-DD date`); else if (v > today) errors.push(`${rel} verified ${v} is in the future`); }