diff --git a/docs/audits/docs-last-walked.json b/docs/audits/docs-last-walked.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/docs/audits/docs-last-walked.json @@ -0,0 +1 @@ +{} diff --git a/scripts/__tests__/check-docs-last-walked.test.mjs b/scripts/__tests__/check-docs-last-walked.test.mjs new file mode 100644 index 0000000000..fdbe39ab5f --- /dev/null +++ b/scripts/__tests__/check-docs-last-walked.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { checkLastWalked, parseDate } from '../check-docs-last-walked.js'; + +const RUN = '2026-10-28'; +const PAGE = 'quickstart.mdx'; + +// Age in days of a walk date relative to RUN, built from the same clock the script uses. +const daysBefore = (n) => new Date(Date.parse(`${RUN}T00:00:00Z`) - n * 86400000).toISOString().slice(0, 10); + +test('a page with no sidecar entry warns', () => { + const w = checkLastWalked({ pages: [PAGE], walked: {}, runDate: RUN }); + assert.equal(w.length, 1); + assert.equal(w[0].page, PAGE); + assert.match(w[0].message, /no walk date/); +}); + +test('a walk 29 days before the run date is ok', () => { + const w = checkLastWalked({ pages: [PAGE], walked: { [PAGE]: daysBefore(29) }, runDate: RUN }); + assert.deepEqual(w, []); +}); + +test('a walk exactly 30 days before the run date is still ok', () => { + const w = checkLastWalked({ pages: [PAGE], walked: { [PAGE]: daysBefore(30) }, runDate: RUN }); + assert.deepEqual(w, []); +}); + +test('a walk 31 days before the run date warns and names the age', () => { + const w = checkLastWalked({ pages: [PAGE], walked: { [PAGE]: daysBefore(31) }, runDate: RUN }); + assert.equal(w.length, 1); + assert.match(w[0].message, /31 days before 2026-10-28 \(limit 30\)/); +}); + +test('a sidecar entry that names no page warns', () => { + const w = checkLastWalked({ pages: [PAGE], walked: { 'gone.mdx': RUN, [PAGE]: RUN }, runDate: RUN }); + assert.equal(w.length, 1); + assert.equal(w[0].page, 'gone.mdx'); + assert.match(w[0].message, /not a docs-site page/); +}); + +test('a date that is not a real YYYY-MM-DD warns instead of passing silently', () => { + const w = checkLastWalked({ pages: [PAGE], walked: { [PAGE]: '2026-02-30' }, runDate: RUN }); + assert.equal(w.length, 1); + assert.match(w[0].message, /not a real YYYY-MM-DD/); +}); + +test('parseDate rejects a non-date and accepts a real one', () => { + assert.equal(parseDate('10/28/2026'), null); + assert.equal(parseDate('2026-13-01'), null); + assert.notEqual(parseDate('2026-10-28'), null); +}); + +test('a bad run date throws rather than checking against NaN', () => { + assert.throws(() => checkLastWalked({ pages: [], walked: {}, runDate: 'today' }), /run date must be YYYY-MM-DD/); +}); diff --git a/scripts/check-docs-last-walked.js b/scripts/check-docs-last-walked.js new file mode 100644 index 0000000000..9b8135daa9 --- /dev/null +++ b/scripts/check-docs-last-walked.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * Last-walked guard for the docs site (TASK-020). + * + * Every page under `docs-site/` should carry a walk date: the day someone last + * followed its steps against prod. The dates live in a sidecar, + * `docs/audits/docs-last-walked.json`, not in page frontmatter, because public + * pages carry no walk records (Sam, 2026-10-28). The sidecar maps a page path + * (relative to `docs-site/`) to a YYYY-MM-DD date and nothing else. + * + * Warn-only: this script always exits 0. CI wiring comes in a later PR, once + * the dates exist. + * + * Warnings: + * - a page has no entry in the sidecar + * - a page's walk date is more than 30 days before the run date + * - a sidecar entry names no page + * - a sidecar date is not a real YYYY-MM-DD + * + * Usage: node scripts/check-docs-last-walked.js [YYYY-MM-DD] + * The run date defaults to today. Tests pass it explicitly. + */ + +const fs = require('fs'); +const path = require('path'); + +const MAX_AGE_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function parseDate(value) { + if (typeof value !== 'string' || !DATE_RE.test(value)) return null; + const ms = Date.parse(`${value}T00:00:00Z`); + if (Number.isNaN(ms) || new Date(ms).toISOString().slice(0, 10) !== value) return null; + return ms; +} + +/** + * Pure check. `pages` is an array of page paths, `walked` is the parsed sidecar + * object, `runDate` is YYYY-MM-DD. Returns an array of warning strings. + */ +function checkLastWalked({ pages, walked, runDate }) { + const runMs = parseDate(runDate); + if (runMs === null) throw new Error(`run date must be YYYY-MM-DD, got "${runDate}"`); + + const warnings = []; + const pageSet = new Set(pages); + + for (const page of pages) { + if (!Object.prototype.hasOwnProperty.call(walked, page)) { + warnings.push({ page, message: `has no walk date in docs/audits/docs-last-walked.json` }); + continue; + } + const walkedMs = parseDate(walked[page]); + if (walkedMs === null) { + warnings.push({ page, message: `walk date "${walked[page]}" is not a real YYYY-MM-DD` }); + continue; + } + const ageDays = Math.round((runMs - walkedMs) / DAY_MS); + if (ageDays > MAX_AGE_DAYS) { + warnings.push({ + page, + message: `last walked ${walked[page]}, ${ageDays} days before ${runDate} (limit ${MAX_AGE_DAYS})`, + }); + } + } + + for (const entry of Object.keys(walked)) { + if (!pageSet.has(entry)) { + warnings.push({ page: entry, message: `is in docs/audits/docs-last-walked.json but is not a docs-site page` }); + } + } + + return warnings; +} + +function listPages(docsDir) { + const out = []; + const walk = (dir) => { + for (const name of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, name.name); + if (name.isDirectory()) walk(full); + else if (name.name.endsWith('.mdx')) out.push(path.relative(docsDir, full).split(path.sep).join('/')); + } + }; + walk(docsDir); + return out.sort(); +} + +function main() { + const repoRoot = path.resolve(__dirname, '..'); + const docsDir = path.join(repoRoot, 'docs-site'); + const sidecarPath = path.join(repoRoot, 'docs', 'audits', 'docs-last-walked.json'); + const runDate = process.argv[2] || new Date().toISOString().slice(0, 10); + + const walked = fs.existsSync(sidecarPath) ? JSON.parse(fs.readFileSync(sidecarPath, 'utf8')) : {}; + const pages = listPages(docsDir); + const warnings = checkLastWalked({ pages, walked, runDate }); + + for (const w of warnings) { + console.warn(`::warning file=docs-site/${w.page}::${w.page} ${w.message}`); + } + console.log( + `${warnings.length} last-walked warning(s) across ${pages.length} docs-site page(s) at ${runDate} (warn-only, exit 0).`, + ); +} + +if (require.main === module) main(); + +module.exports = { checkLastWalked, parseDate, MAX_AGE_DAYS };