mirror of
https://github.com/samuelncui/yatm.git
synced 2026-11-08 04:18:03 +00:00
Organize documentation by audience: packaged user guides in docs/operations (including Devices, Tape scripts, Auto read order, Jobs, offline migration and tar recovery), contributor procedures in docs/development, maintainer release procedures in docs/release, current behavior in docs/architecture (now with separate Tape and read-planning documents) and evidence in docs/research. Each rule keeps one owner and other pages link to it; the document checker, the packaged guide allowlist and their tests follow the new layout. The documents land together because the new guides and index link to each other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
4.3 KiB
JavaScript
93 lines
4.3 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const repositoryFiles = new Set(execFileSync(
|
|
"git",
|
|
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
|
{ cwd: repository, encoding: "utf8" },
|
|
).split("\0").filter(Boolean).filter((name) => fs.existsSync(path.join(repository, name))));
|
|
|
|
const markdown = [...repositoryFiles].filter((name) => name.endsWith(".md")).sort();
|
|
const documentation = markdown.filter((name) => name.startsWith("docs/"));
|
|
const allowedDocumentation = /^(?:docs\/README\.md|docs\/(?:architecture|development|operations|release|research)\/[a-z0-9-]+\.md)$/;
|
|
const retiredDocumentationReference = /docs\/(?:history|designs|decisions|releases)\/|docs\/(?:demo|e2e-test|physical-tape-e2e|platform-changes|platform-design)\.md|docs\/operations\/(?:demo|preview-build)\.md|docs\/architecture\/(?:api-ui|states)\.md/;
|
|
|
|
for (const document of documentation) {
|
|
assert.match(document, allowedDocumentation, `Documentation has no current owner: ${document}`);
|
|
}
|
|
|
|
for (const filename of repositoryFiles) {
|
|
const contents = fs.readFileSync(path.join(repository, filename));
|
|
if (contents.includes(0)) continue;
|
|
assert(!retiredDocumentationReference.test(contents.toString("utf8")),
|
|
`Repository content refers to retired documentation: ${filename}`);
|
|
}
|
|
|
|
const index = fs.readFileSync(path.join(repository, "docs/README.md"), "utf8");
|
|
for (const document of documentation) {
|
|
if (document === "docs/README.md") continue;
|
|
const relative = path.posix.relative("docs", document);
|
|
assert(index.includes(`](${relative})`), `Documentation is missing from docs/README.md: ${document}`);
|
|
}
|
|
|
|
const committableDirectory = (name) => [...repositoryFiles].some((file) => file.startsWith(`${name}/`));
|
|
const localLink = /!?\[[^\]]*\]\(([^\s)]+)(?:\s+[^)]*)?\)/g;
|
|
const anchorCache = new Map();
|
|
const markdownAnchors = (document) => {
|
|
if (anchorCache.has(document)) return anchorCache.get(document);
|
|
const anchors = new Set();
|
|
const occurrences = new Map();
|
|
const text = fs.readFileSync(path.join(repository, document), "utf8");
|
|
for (const match of text.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) {
|
|
const base = match[1]
|
|
.replace(/<[^>]*>/g, "")
|
|
.replace(/!?\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
.replace(/[`*_~]/g, "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^\p{L}\p{N}\-_ ]/gu, "")
|
|
.replace(/ /g, "-");
|
|
const seen = occurrences.get(base) ?? 0;
|
|
occurrences.set(base, seen + 1);
|
|
anchors.add(seen === 0 ? base : `${base}-${seen}`);
|
|
}
|
|
anchorCache.set(document, anchors);
|
|
return anchors;
|
|
};
|
|
for (const document of markdown) {
|
|
const text = fs.readFileSync(path.join(repository, document), "utf8");
|
|
assert(!/(?:^|[\s`'"(])\.local(?:\/|\b)/m.test(text), `Repository document refers to private workspace material: ${document}`);
|
|
|
|
for (const match of text.matchAll(localLink)) {
|
|
const href = match[1].replace(/^<|>$/g, "");
|
|
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(href)) continue;
|
|
assert(!path.isAbsolute(href), `Repository document uses a machine-local path: ${document} -> ${href}`);
|
|
|
|
let filename;
|
|
let fragment;
|
|
try {
|
|
const separator = href.indexOf("#");
|
|
filename = decodeURIComponent(separator === -1 ? href : href.slice(0, separator));
|
|
fragment = separator === -1 ? "" : decodeURIComponent(href.slice(separator + 1));
|
|
} catch {
|
|
assert.fail(`Repository document has an invalid encoded link: ${document} -> ${href}`);
|
|
}
|
|
const target = filename
|
|
? path.posix.normalize(path.posix.join(path.posix.dirname(document), filename))
|
|
: document;
|
|
assert(!target.startsWith("../"), `Repository document links outside the repository: ${document} -> ${href}`);
|
|
assert(repositoryFiles.has(target) || committableDirectory(target),
|
|
`Repository document links to a missing or ignored file: ${document} -> ${href}`);
|
|
if (fragment && target.endsWith(".md")) {
|
|
assert(markdownAnchors(target).has(fragment),
|
|
`Repository document links to a missing heading: ${document} -> ${href}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Validated ${markdown.length} repository Markdown files and ${documentation.length} indexed documents.`);
|