docs(design): add OKF user story catalog
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
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.`);
|
||||
Reference in New Issue
Block a user