Skip to content

Commit ed697b2

Browse files
SonikeTMHSDigital
authored andcommitted
fix: reject impossible calendar dates across catalog checks
1 parent 0528672 commit ed697b2

6 files changed

Lines changed: 55 additions & 10 deletions

File tree

‎site/checks.mjs‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
import path from "node:path";
1010
import { parseStack, pickPath, StackError } from "./lib/stacks.mjs";
11+
import { isRealDate } from "./lib/shared.mjs";
1112

12-
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1313
const DATE_ANY_RE = /\d{4}-\d{2}-\d{2}/g;
1414

1515
/** Generic hosts and distributors that are never a rights holder. */
@@ -144,7 +144,7 @@ export function checkAttributionConsistency(rel, meta, body, vocab) {
144144
* The check that defends the repo's central promise: a `verified` date may
145145
* never be newer than the evidence it claims to rest on. Bumping `verified`
146146
* without adding a dated Evidence line is exactly the failure this catches.
147-
* Also rejects Evidence dates in the future, and (V10) an Evidence section
147+
* Also rejects impossible or future Evidence dates, and (V10) an Evidence section
148148
* that carries no date at all, whatever the entry's status.
149149
*/
150150
export function checkEvidenceDates(rel, meta, body, today) {
@@ -156,12 +156,18 @@ export function checkEvidenceDates(rel, meta, body, today) {
156156
errors.push(`${rel} ## Evidence section carries no YYYY-MM-DD date`);
157157
return errors;
158158
}
159-
const newest = dates[dates.length - 1];
159+
const validDates = dates.filter((date) => {
160+
if (isRealDate(date)) return true;
161+
errors.push(`${rel} Evidence date ${date} is not a real YYYY-MM-DD date`);
162+
return false;
163+
});
164+
if (!validDates.length) return errors;
165+
const newest = validDates[validDates.length - 1];
160166
if (newest > today) {
161167
errors.push(`${rel} Evidence date ${newest} is in the future`);
162168
}
163169
const verified = empty(meta.verified) ? null : String(meta.verified);
164-
if (verified && DATE_RE.test(verified) && verified > newest) {
170+
if (isRealDate(verified) && verified > newest) {
165171
errors.push(
166172
`${rel} verified ${verified} is newer than its newest Evidence date ${newest}. ` +
167173
`A new verified date needs a dated Evidence line from the same check`

‎site/checks.test.mjs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,22 @@ accepts(
157157
);
158158

159159
/* V8 / V10 -------------------------------------------------------------- */
160+
for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2026-04-31"]) {
161+
rejects(
162+
`V8 rejects impossible Evidence date ${date}`,
163+
checkEvidenceDates("bad.md", { verified: "2026-01-01" }, `# X${EV(date)}`, TODAY),
164+
`Evidence date ${date} is not a real YYYY-MM-DD date`
165+
);
166+
}
167+
rejects(
168+
"V8 rejects an impossible date even alongside valid Evidence",
169+
checkEvidenceDates("bad.md", { verified: "2026-09-20" }, `# X${EV("2026-02-30")}\n- Rechecked 2026-09-20.\n`, TODAY),
170+
"Evidence date 2026-02-30 is not a real YYYY-MM-DD date"
171+
);
172+
accepts(
173+
"V8 accepts real leap-day Evidence",
174+
checkEvidenceDates("ok.md", { verified: "2000-02-29" }, `# X${EV("2000-02-29")}`, TODAY)
175+
);
160176
rejects(
161177
"V8 rejects a verified date newer than its newest Evidence date",
162178
checkEvidenceDates(

‎site/lib/lib.test.mjs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
commercialLabel,
88
entryPageUrl,
99
esc,
10+
isRealDate,
1011
STATUS_NOTES,
1112
verifiedAge,
1213
} from "./shared.mjs";
@@ -37,12 +38,21 @@ function throws(label, fn, needle) {
3738
}
3839

3940
/* shared ----------------------------------------------------------------- */
41+
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"]) {
42+
eq(`isRealDate accepts ${date}`, isRealDate(date), true);
43+
}
44+
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]) {
45+
eq(`isRealDate rejects ${JSON.stringify(date)}`, isRealDate(date), false);
46+
}
4047
eq("esc escapes all five characters", esc(`<a href="x">'&'</a>`), "&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;");
4148
eq("commercialLabel true", commercialLabel(true), "commercial OK");
4249
eq("commercialLabel varies", commercialLabel("varies"), "per-file review");
4350
eq("commercialLabel unknown", commercialLabel("unknown"), "commercial ?");
4451
eq("verifiedAge fresh", verifiedAge("2026-09-01", Date.parse("2026-09-24T00:00:00Z")).bucket, "fresh");
4552
eq("verifiedAge missing", verifiedAge(null, Date.now()).bucket, "unknown");
53+
for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2026-04-31"]) {
54+
eq(`verifiedAge rejects ${date}`, verifiedAge(date, Date.parse("2026-09-24T00:00:00Z")).bucket, "unknown");
55+
}
4656
eq("entryPageUrl trims the site slash", entryPageUrl({ siteUrl: "https://x.test/site/" }, "a-b"), "https://x.test/site/entry/a-b/");
4757
has("STATUS_NOTES covers needs-review", STATUS_NOTES["needs-review"], "open");
4858

@@ -241,6 +251,10 @@ throws("licence in a why sentence", badStack((m) => m.replace("Five loops.", "Fi
241251
throws("licence phrase in a why sentence", badStack((m) => m.replace("Five loops.", "Five public domain loops.")), '("public domain")');
242252
throws("missing walked", badStack((m) => m.replace("walked: 2026-09-20\n", "")), "frontmatter is missing walked");
243253
throws("bad walked date", badStack((m) => m.replace("2026-09-20", "20 Sept")), "walked is not YYYY-MM-DD");
254+
for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2026-04-31"]) {
255+
throws(`impossible walked date ${date}`, badStack((m) => m.replace("2026-09-20", date)), "walked is not YYYY-MM-DD");
256+
}
257+
eq("leap-day walked date", parseStack(stackMd.replace("2026-09-20", "2000-02-29"), { file: "stacks/s1.md" }).meta.walked, "2000-02-29");
244258
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');
245259
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");
246260
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");

‎site/lib/shared.mjs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,18 @@ export const STATUS_NOTES = {
1919
const VERIFIED_FRESH_DAYS = 180;
2020
const VERIFIED_AGING_DAYS = 365;
2121

22+
/** A real calendar date in strict YYYY-MM-DD form, independent of local time. */
23+
export function isRealDate(value) {
24+
if (typeof value !== "string" || value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
25+
const [year, month, day] = value.split("-").map(Number);
26+
const date = new Date(0);
27+
// Unlike Date.UTC, this preserves years 0000-0099.
28+
date.setUTCFullYear(year, month - 1, day);
29+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
30+
}
31+
2232
export function verifiedAge(verified, now) {
23-
if (!verified) return { days: null, bucket: "unknown" };
33+
if (!isRealDate(verified)) return { days: null, bucket: "unknown" };
2434
const t = Date.parse(`${verified}T00:00:00Z`);
2535
if (Number.isNaN(t)) return { days: null, bucket: "unknown" };
2636
const days = Math.max(0, Math.floor((now - t) / 86400000));

‎site/lib/stacks.mjs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@
55
*/
66
import fs from "node:fs";
77
import path from "node:path";
8-
import { unquoteScalar } from "./shared.mjs";
8+
import { isRealDate, unquoteScalar } from "./shared.mjs";
99

1010
export class StackError extends Error {}
1111

1212
/** The `##` sections a stack may have, in the order they must appear. */
1313
export const STACK_SECTIONS = ["Art", "Audio", "Fonts", "Tools", "Gaps"];
1414

1515
const REQUIRED = ["id", "title", "task", "walked"];
16-
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1716
// - **Need:** [Entry name](path/to/entry.md). Why this pick.
1817
const PICK_RE = /^- \*\*([^*]+?):\*\* \[([^\]]+)\]\(([^)\s]+)\)\.\s+(\S.*)$/;
1918
// Names and shorthands matched in any case, as whole words. The exact ids
@@ -99,7 +98,7 @@ export function parseStack(text, { file, terms = [] }) {
9998
metaLine[key] = i;
10099
}
101100
for (const key of REQUIRED) if (!meta[key]) fail(0, `frontmatter is missing ${key}`);
102-
if (!DATE_RE.test(meta.walked)) fail(0, "walked is not YYYY-MM-DD");
101+
if (!isRealDate(meta.walked)) fail(0, "walked is not YYYY-MM-DD (a real calendar date is required)");
103102
noLicence(metaLine.title, meta.title, "title");
104103
noLicence(metaLine.task, meta.task, "task");
105104

‎site/validate.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
checkValueSpellings,
2121
} from "./checks.mjs";
2222
import { licenceTerms, listStackFiles } from "./lib/stacks.mjs";
23+
import { isRealDate } from "./lib/shared.mjs";
2324

2425
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2526
const ROOT = path.resolve(__dirname, "..");
@@ -81,7 +82,6 @@ const SKIP_WALK = new Set([".git", "node_modules", "dist", "RESEARCH"]);
8182
const ALLOWED_BINARY_PREFIXES = ["site/dist/", "docs/images/readme/"];
8283
const EMOJI_RE = /\p{Extended_Pictographic}/u;
8384
const MD_LINK_RE = /!\[[^\]]*\]\(([^)]+)\)|\[[^\]]*\]\(([^)]+)\)/g;
84-
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
8585
const EVIDENCE_DATE_RE = /\d{4}-\d{2}-\d{2}/;
8686
const COMMERCIAL_VALUES = new Set(["true", "false", "unknown", "varies"]);
8787
const STATUS_VALUES = new Set(["active", "needs-review", "deprecated"]);
@@ -315,7 +315,7 @@ function main() {
315315

316316
if (meta.verified) {
317317
const v = String(meta.verified);
318-
if (!DATE_RE.test(v)) errors.push(`${rel} verified is not YYYY-MM-DD`);
318+
if (!isRealDate(v)) errors.push(`${rel} verified is not a real YYYY-MM-DD date`);
319319
else if (v > today) errors.push(`${rel} verified ${v} is in the future`);
320320
}
321321

0 commit comments

Comments
 (0)