90 lines
3.4 KiB
JavaScript
90 lines
3.4 KiB
JavaScript
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.`);
|