import { access, readFile, readdir } from "node:fs/promises"; const designDirectory = new URL("../design/", import.meta.url); const reservedFiles = new Set(["index.md", "log.md"]); const allowedStatuses = new Set(["proposed", "in-progress", "implemented", "verified"]); const failures = []; const storyIds = new Map(); async function markdownFiles(directory, relativeDirectory = "") { const files = []; for (const entry of await readdir(directory, { withFileTypes: true })) { const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name; if (entry.isDirectory()) { files.push(...await markdownFiles(new URL(`${entry.name}/`, directory), relativePath)); } else if (entry.isFile() && entry.name.endsWith(".md")) { files.push({ name: entry.name, relativePath, url: new URL(entry.name, directory) }); } } return files; } for (const file of await markdownFiles(designDirectory)) { const contents = await readFile(file.url, "utf8"); for (const link of contents.matchAll(/\[[^\]]+\]\(([^)\s]+)\)/g)) { const target = link[1].split("#", 1)[0]; if (!target || target.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(target)) continue; const targetUrl = target.startsWith("/") ? new URL(target.slice(1), designDirectory) : new URL(target, file.url); try { await access(targetUrl); } catch { failures.push(`${file.relativePath}: broken link ${target}`); } } if (reservedFiles.has(file.name)) continue; const frontmatter = contents.match(/^---\n([\s\S]*?)\n---\n/); if (!frontmatter) { failures.push(`${file.relativePath}: missing YAML frontmatter`); continue; } const metadata = Object.fromEntries( frontmatter[1] .split("\n") .map((line) => line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/)) .filter(Boolean) .map((match) => [match[1], match[2].replace(/^['"]|['"]$/g, "")]), ); if (!metadata.type) failures.push(`${file.relativePath}: missing required type`); if (metadata.type === "User Story") { for (const field of ["story_id", "title", "description", "status", "timestamp"]) { if (!metadata[field]) failures.push(`${file.relativePath}: missing ${field}`); } if (metadata.status && !allowedStatuses.has(metadata.status)) { failures.push(`${file.relativePath}: invalid status ${metadata.status}`); } if (metadata.timestamp && Number.isNaN(Date.parse(metadata.timestamp))) { failures.push(`${file.relativePath}: timestamp is not ISO 8601`); } if (metadata.story_id) { const duplicate = storyIds.get(metadata.story_id); if (duplicate) failures.push(`${file.relativePath}: duplicate ${metadata.story_id} also used by ${duplicate}`); storyIds.set(metadata.story_id, file.relativePath); } } } const index = await readFile(new URL("index.md", designDirectory), "utf8"); for (const [storyId, filename] of storyIds) { if (!index.includes(`(${filename})`)) failures.push(`index.md: missing ${storyId} link to ${filename}`); } const log = await readFile(new URL("log.md", designDirectory), "utf8"); for (const heading of log.matchAll(/^##\s+(.+)$/gm)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(heading[1])) { failures.push(`log.md: invalid date heading ${heading[1]}`); } } if (failures.length) { console.error("OKF validation failed:\n" + failures.map((failure) => `- ${failure}`).join("\n")); process.exit(1); } console.log(`OKF validation passed: ${storyIds.size} user stories.`);