-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdocs.ts
More file actions
166 lines (150 loc) · 5.93 KB
/
Copy pathdocs.ts
File metadata and controls
166 lines (150 loc) · 5.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
#!/usr/bin/env bun
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, extname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
interface CatalogRule {
classification: string;
authority: string;
paths?: string[];
pattern?: string;
}
interface Catalog {
schema: number;
rules: CatalogRule[];
}
function repoPath(root: string, path: string): string {
return relative(root, path).split(sep).join("/");
}
function filesBelow(path: string): string[] {
if (!existsSync(path)) return [];
const files: string[] = [];
for (const entry of readdirSync(path)) {
const child = join(path, entry);
if (statSync(child).isDirectory()) files.push(...filesBelow(child));
else files.push(child);
}
return files;
}
function ruleMatches(rule: CatalogRule, path: string): boolean {
if (rule.paths?.includes(path)) return true;
return rule.pattern ? new RegExp(rule.pattern).test(path) : false;
}
function localTargets(root: string, markdownPath: string): string[] {
const body = readFileSync(markdownPath, "utf8");
const targets = [
...Array.from(body.matchAll(/!?\[[^\]]*\]\(([^)]+)\)/g), (match) => match[1]),
...Array.from(body.matchAll(/<(?:a|img)\b[^>]*(?:href|src)=["']([^"']+)["'][^>]*>/gi), (match) => match[1]),
];
return targets.flatMap((rawTarget) => {
let target = rawTarget.trim().replace(/^<|>$/g, "");
if (
!target ||
target.startsWith("#") ||
target.startsWith("/") ||
/^[a-z][a-z0-9+.-]*:/i.test(target)
) {
return [];
}
target = target.split("#", 1)[0].split("?", 1)[0];
if (!target) return [];
try {
return [repoPath(root, resolve(dirname(markdownPath), decodeURIComponent(target)))];
} catch {
return [`invalid:${target}`];
}
});
}
export function validateDocumentation(repositoryRoot: string): string[] {
const root = resolve(repositoryRoot);
const docsRoot = join(root, "docs");
const catalogPath = join(docsRoot, "catalog.json");
const errors: string[] = [];
if (!existsSync(catalogPath)) return ["docs/catalog.json is required"];
let catalog: Catalog;
try {
catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as Catalog;
} catch (error) {
return [`docs/catalog.json is invalid JSON: ${String(error)}`];
}
if (catalog.schema !== 1 || !Array.isArray(catalog.rules)) {
return ["docs/catalog.json must use schema 1 with a rules array"];
}
const docsFiles = filesBelow(docsRoot).map((path) => repoPath(root, path)).sort();
const classification = new Map<string, CatalogRule>();
for (const path of docsFiles) {
let matches: CatalogRule[] = [];
try {
matches = catalog.rules.filter((rule) => ruleMatches(rule, path));
} catch (error) {
errors.push(`catalog rule cannot be evaluated for ${path}: ${String(error)}`);
continue;
}
if (matches.length === 0) errors.push(`${path}: unclassified documentation file`);
if (matches.length > 1) {
errors.push(`${path}: ambiguous documentation classification (${matches.map((rule) => rule.classification).join(", ")})`);
}
if (matches.length === 1) classification.set(path, matches[0]);
}
for (const rule of catalog.rules) {
for (const path of rule.paths ?? []) {
if (!docsFiles.includes(path)) errors.push(`${path}: catalog entry does not exist`);
}
}
const localReferences = new Set<string>();
const markdownFiles = [
...["README.md", "AGENTS.md", "CONTEXT.md", "script/README.md"].map((path) => join(root, path)).filter(existsSync),
...filesBelow(docsRoot).filter((path) => extname(path) === ".md"),
...filesBelow(join(root, ".agents", "skills")).filter((path) => extname(path) === ".md"),
];
for (const markdownPath of markdownFiles) {
for (const target of localTargets(root, markdownPath)) {
localReferences.add(target);
if (target.startsWith("invalid:")) errors.push(`${repoPath(root, markdownPath)}: invalid local link ${target.slice(8)}`);
else if (!existsSync(join(root, target))) errors.push(`${repoPath(root, markdownPath)}: broken local link ${target}`);
}
}
for (const path of docsFiles) {
const rule = classification.get(path);
if (!rule) continue;
if (
/\d{4}-\d{2}-\d{2}/.test(path)
&& !new Set(["archive", "change-record", "change-stage", "change-evidence"]).has(rule.classification)
) {
errors.push(`${path}: dated snapshots belong in docs/archive or canonical change bundles`);
}
if (rule.classification === "change-record" || rule.classification === "change-stage") {
const body = readFileSync(join(root, path), "utf8");
const schema = path.endsWith("/change.md") ? "4" : "(?:3|5)";
if (!new RegExp(`^schema: ${schema}$`, "m").test(body)) errors.push(`${path}: canonical change must use schema ${schema === "4" ? "4" : "3 or 5"}`);
}
if (new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]).has(extname(path).toLowerCase())) {
if (!localReferences.has(path)) errors.push(`${path}: unreferenced documentation image`);
}
}
return errors;
}
function parseRoot(argv: string[]): string {
let root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] !== "--root" || !argv[index + 1]) {
throw new Error("usage: bun script/verify/docs.ts [--root PATH]");
}
root = argv[index + 1];
index += 1;
}
return root;
}
if (import.meta.main) {
try {
const errors = validateDocumentation(parseRoot(process.argv.slice(2)));
if (errors.length > 0) {
for (const error of errors) console.error(`[docs] error: ${error}`);
console.error(`[docs] failed with ${errors.length} error(s)`);
process.exit(1);
}
console.log("[docs] catalog, links, schemas, and assets valid");
} catch (error) {
console.error(`[docs] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}