1#!/usr/bin/env node
2
30
31import { readFileSync, readdirSync, existsSync } from "node:fs";
32import { dirname, join, relative } from "node:path";
33import { fileURLToPath } from "node:url";
34
35const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
36const requirePhase15Ticked = process.argv.includes("--require-phase-15-ticked");
37
38
39
40let failures = 0;
41const results = [];
42
43function pass(msg) {
44 results.push(` [PASS] ${msg}`);
45}
46function fail(msg) {
47 failures += 1;
48 results.push(` [FAIL] ${msg}`);
49}
50function read(p) {
51 return readFileSync(join(ROOT, p), "utf8");
52}
53function listDir(p) {
54 const abs = join(ROOT, p);
55 if (!existsSync(abs)) return [];
56 return readdirSync(abs, { withFileTypes: true })
57 .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !e.name.includes("node_modules"))
58 .map((e) => join(p, e.name));
59}
60function listFiles(p) {
61 const abs = join(ROOT, p);
62 if (!existsSync(abs)) return [];
63 return readdirSync(abs, { withFileTypes: true })
64 .filter((e) => e.isFile() && !e.name.startsWith("."))
65 .map((e) => join(p, e.name));
66}
67
68
69
70function normalizePath(p) {
71 return p
72 .split("/")
73 .map((s) => (/^[{:]/.test(s) ? ":P" : s))
74 .join("/");
75}
76
77
78
79
80
81const PATH_TOKEN = /(?:^|[\s`'"(])((?:\/[A-Za-z0-9_\-:.{}]+)+)/g;
82
83
84
85console.log("== 1. README completeness ==");
86{
87 const packageDirs = [""].concat(
88 ...["apps", "packages", "tests"].map((base) =>
89 listDir(base).filter((d) => existsSync(join(ROOT, d, "package.json")))
90 )
91 );
92 const missing = [];
93 const thin = [];
94 for (const d of packageDirs) {
95 const rel = d ? `${d}/README.md` : "README.md";
96 if (!existsSync(join(ROOT, d, "README.md"))) {
97 missing.push(rel);
98 continue;
99 }
100 const text = read(rel);
101 if (text.length < 40 || !/^#\s+/m.test(text)) thin.push(rel);
102 }
103 if (missing.length) fail(`missing README.md: ${missing.join(", ")}`);
104 if (thin.length) fail(`README.md too thin (needs a # heading, >= 40 bytes): ${thin.join(", ")}`);
105 if (!missing.length && !thin.length) pass(`all ${packageDirs.length} package dirs ship a real README.md`);
106}
107
108
109
110console.log("== 2. OpenAPI + API-doc coverage ==");
111
112
113
114
115
116
117
118
119
120const KNOWN_UNDOCUMENTED_GROUPS = new Set([
121 "debug",
122 "docs",
123 "health",
124 "notifications",
125 "share",
126 "shares",
127]);
128
129
130const CORE_DOCUMENTED_GROUPS = [
131 "ai",
132 "auth",
133 "automations",
134 "blocks",
135 "comments",
136 "databases",
137 "files",
138 "forms",
139 "oauth",
140 "orgs",
141 "pages",
142 "public",
143 "records",
144 "search",
145 "sessions",
146 "users",
147 "webhooks",
148 "workspaces",
149];
150
151{
152 const specRel = "docs/api/openapi.yaml";
153 if (!existsSync(join(ROOT, specRel))) {
154 fail(`missing ${specRel}`);
155 } else {
156 const spec = read(specRel);
157 if (!/^paths:\s*$/m.test(spec)) {
158 fail(`${specRel}: no top-level "paths:" key — not a valid OpenAPI document`);
159 } else {
160 const specPaths = [...spec.matchAll(/^ (\/\S+):/gm)].map((m) => m[1]);
161 if (specPaths.length < 50) {
162 fail(`${specRel}: only ${specPaths.length} top-level paths — spec looks truncated (expected >= 50)`);
163 } else {
164 pass(`${specRel}: ${specPaths.length} top-level paths under "paths:"`);
165 }
166
167
168 const byGroup = new Map();
169 for (const p of specPaths) {
170 const g = p.replace(/^\
171 if (!byGroup.has(g)) byGroup.set(g, new Set());
172 byGroup.get(g).add(normalizePath(p));
173 }
174
175 const docFiles = listFiles("docs/api")
176 .filter((f) => f.endsWith(".md"))
177 .map((f) => ({ file: f, text: read(f) }));
178 const docPaths = docFiles.map(({ file, text }) => {
179 const found = new Set();
180 for (const m of text.matchAll(PATH_TOKEN)) found.add(normalizePath(m[1]));
181 return { file, found };
182 });
183
184 const documented = (group) =>
185 docPaths.some(({ found }) => [...byGroup.get(group)].some((p) => found.has(p)));
186
187 const uncovered = [...byGroup.keys()].filter((g) => !documented(g));
188 const undocumented = uncovered.filter((g) => !KNOWN_UNDOCUMENTED_GROUPS.has(g));
189 const unlisted = uncovered.filter((g) => KNOWN_UNDOCUMENTED_GROUPS.has(g) && documented(g));
190
191 if (undocumented.length) {
192 fail(`spec groups with NO docs/api/*.md page (and not in KNOWN_UNDOCUMENTED_GROUPS): ${undocumented.join(", ")}`);
193 } else {
194 pass(`every spec path group is documented or explicitly tracked (${uncovered.length} tracked gaps: ${uncovered.join(", ")})`);
195 }
196
197 const coreUndocumented = CORE_DOCUMENTED_GROUPS.filter((g) => !documented(g));
198 if (coreUndocumented.length) {
199 fail(`core product groups lost their API docs: ${coreUndocumented.join(", ")}`);
200 } else {
201 pass(`all ${CORE_DOCUMENTED_GROUPS.length} core product groups documented in docs/api/`);
202 }
203
204
205
206 const stale = [...KNOWN_UNDOCUMENTED_GROUPS].filter(
207 (g) => !byGroup.has(g) || documented(g)
208 );
209 if (stale.length) {
210 fail(`KNOWN_UNDOCUMENTED_GROUPS is stale (documented or no longer in the spec — remove the entry): ${stale.join(", ")}`);
211 } else {
212 pass(`KNOWN_UNDOCUMENTED_GROUPS matches the spec (no stale entries)`);
213 }
214 if (unlisted.length) {
215 fail(`tracked as undocumented but actually documented (remove from the list): ${unlisted.join(", ")}`);
216 }
217 }
218 }
219}
220
221
222
223console.log("== 3. ROADMAP Phase 15 Definition of Done ==");
224{
225 const roadmap = read("ROADMAP.md");
226 const lines = roadmap.split(/\r?\n/);
227 let start = lines.findIndex((l) => /^##\s+Phase 15\b/.test(l));
228 if (start < 0) {
229 fail("ROADMAP.md: no '## Phase 15' section");
230 } else {
231 let end = lines.length;
232 for (let i = start + 1; i < lines.length; i++) {
233 if (/^##\s+/.test(lines[i])) {
234 end = i;
235 break;
236 }
237 }
238 const section = lines.slice(start, end);
239 const boxes = section.filter((l) => /^\s*- \[[ xX]\]/.test(l));
240 const ticked = boxes.filter((l) => /^\s*- \[[xX]\]/.test(l));
241 if (boxes.length < 5) {
242 fail(`ROADMAP.md Phase 15: only ${boxes.length} Definition-of-Done checkboxes (need >= 5)`);
243 } else {
244 pass(`ROADMAP.md Phase 15 section has ${boxes.length} Definition-of-Done checkboxes (ticked: ${ticked.length}/${boxes.length})`);
245 }
246 if (requirePhase15Ticked) {
247 if (boxes.length >= 5 && ticked.length === boxes.length) {
248 pass(`--require-phase-15-ticked: all ${boxes.length} boxes ticked`);
249 } else {
250 fail(`--require-phase-15-ticked: ${boxes.length - ticked.length} of ${boxes.length} boxes still unticked`);
251 }
252 } else if (ticked.length === boxes.length && boxes.length > 0) {
253 fail("ROADMAP.md Phase 15 boxes are ALL ticked — run in default mode only while Phase 15 is in flight (use --require-phase-15-ticked to assert completion)");
254 } else {
255 pass(`ROADMAP.md Phase 15 boxes present and not fully ticked (in flight)`);
256 }
257 }
258}
259
260
261
262console.log("== 4. README links ==");
263{
264 const readme = read("README.md");
265 const wanted = [
266 "docs/development/admin-guide.md",
267 "docs/support-matrix.md",
268 ];
269 const broken = wanted.filter((link) => !readme.includes(link));
270 const missingTargets = wanted.filter((link) => !existsSync(join(ROOT, link)));
271 if (broken.length) fail(`README.md does not link to: ${broken.join(", ")}`);
272 else pass("README.md links to admin-guide and support-matrix");
273 if (missingTargets.length) fail(`linked target missing on disk: ${missingTargets.join(", ")}`);
274 else pass("linked doc targets exist on disk");
275}
276
277
278
279console.log("");
280for (const r of results) console.log(r);
281console.log("");
282if (failures) {
283 console.log(`docs-completeness: FAIL (${failures} check${failures === 1 ? "" : "s"} failed)`);
284 process.exit(1);
285} else {
286 console.log("docs-completeness: PASS (all checks green)");
287 process.exit(0);
288}
289