Initial Commit
This commit is contained in:
203
scripts/analyze-doc-tree.ts
Normal file
203
scripts/analyze-doc-tree.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* analyze-doc-tree — walk the documentation tree from the root README
|
||||
* and report every `.md` file on disk that is NOT reachable through
|
||||
* the relative-link chain.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Start at `<repo>/README.md`. Read it; follow every relative `.md`
|
||||
* link target. Recurse into each linked file the same way. Record
|
||||
* the visited set.
|
||||
* 2. Walk the filesystem for every `.md` under the repo (skipping
|
||||
* node_modules, dist, build, coverage, .turbo, .vite, openspec/archive
|
||||
* by default; see `--ignore`).
|
||||
* 3. The difference is the orphan list.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/analyze-doc-tree.ts # warn-only
|
||||
* bun scripts/analyze-doc-tree.ts --strict # exit 1 on orphans
|
||||
* bun scripts/analyze-doc-tree.ts --ignore openspec # add ignore segments
|
||||
* bun scripts/analyze-doc-tree.ts --quiet # counts only
|
||||
*/
|
||||
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { CONFIG } from "./lib/config.js";
|
||||
import { extractRelativeMdLinks, tryRead, walkMd } from "./lib/doc-tree.js";
|
||||
|
||||
const ROOT = CONFIG.paths.root;
|
||||
|
||||
const DEFAULT_IGNORE_SEGMENTS: readonly string[] = [
|
||||
"openspec/archive",
|
||||
"openspec/changes",
|
||||
"openspec/templates",
|
||||
"openspec/specs",
|
||||
"openspec/cross-cutting-designs",
|
||||
"docs",
|
||||
"docker",
|
||||
"ops",
|
||||
"assets",
|
||||
"apps/docs/docs",
|
||||
".claude",
|
||||
".cursor",
|
||||
];
|
||||
|
||||
/**
|
||||
* File-name suffixes that are excluded from the orphan check. These files
|
||||
* exist on disk but are NOT part of the doc tree (sibling type-docs that
|
||||
* pair with `.types.ts` files, bundled agent skill manifests, etc.).
|
||||
*/
|
||||
const DEFAULT_IGNORE_SUFFIXES: readonly string[] = [
|
||||
".types.md",
|
||||
"SKILL.md",
|
||||
"CHANGELOG.md",
|
||||
];
|
||||
|
||||
/**
|
||||
* Specific filenames at the repo root that are intentionally excluded —
|
||||
* `_intro.md` is the transcluded source for `README.md`, not its own
|
||||
* tree node.
|
||||
*/
|
||||
const DEFAULT_IGNORE_PATHS: readonly string[] = ["_intro.md"];
|
||||
|
||||
interface CliOptions {
|
||||
strict: boolean;
|
||||
quiet: boolean;
|
||||
extraIgnore: string[];
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): CliOptions {
|
||||
let strict = false;
|
||||
let quiet = false;
|
||||
const extraIgnore: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i] ?? "";
|
||||
if (arg === "--strict") strict = true;
|
||||
else if (arg === "--quiet" || arg === "-q") quiet = true;
|
||||
else if (arg === "--ignore") {
|
||||
const next = argv[i + 1];
|
||||
if (!next) throw new Error("--ignore requires a value");
|
||||
extraIgnore.push(next);
|
||||
i++;
|
||||
} else if (arg.startsWith("--ignore=")) {
|
||||
extraIgnore.push(arg.slice("--ignore=".length));
|
||||
} else {
|
||||
throw new Error(`Unknown flag: ${arg}`);
|
||||
}
|
||||
}
|
||||
return { strict, quiet, extraIgnore };
|
||||
}
|
||||
|
||||
function normalize(path: string): string {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
return resolve(path);
|
||||
}
|
||||
}
|
||||
|
||||
function crawlVisited(
|
||||
startPath: string,
|
||||
ignoreSegments: readonly string[]
|
||||
): Set<string> {
|
||||
const visited = new Set<string>();
|
||||
const queue: string[] = [normalize(startPath)];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as string;
|
||||
if (visited.has(current)) continue;
|
||||
if (!existsSync(current)) continue;
|
||||
visited.add(current);
|
||||
const content = tryRead(current);
|
||||
if (content === undefined) continue;
|
||||
const targets = extractRelativeMdLinks(content, current);
|
||||
for (const target of targets) {
|
||||
const resolved = normalize(target);
|
||||
if (ignoreSegments.some((s) => resolved.includes(`/${s}/`)))
|
||||
continue;
|
||||
if (!visited.has(resolved)) queue.push(resolved);
|
||||
}
|
||||
}
|
||||
return visited;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
let options: CliOptions;
|
||||
try {
|
||||
options = parseArgs(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ignoreSegments = [...DEFAULT_IGNORE_SEGMENTS, ...options.extraIgnore];
|
||||
|
||||
const visited = crawlVisited(join(ROOT, "README.md"), ignoreSegments);
|
||||
const ignoredAbs = new Set(
|
||||
DEFAULT_IGNORE_PATHS.map((p) => normalize(join(ROOT, p)))
|
||||
);
|
||||
const allMd = walkMd(ROOT, { ignoreSegments })
|
||||
.filter((p) => !DEFAULT_IGNORE_SUFFIXES.some((suf) => p.endsWith(suf)))
|
||||
.map(normalize)
|
||||
.filter((p) => !ignoredAbs.has(p));
|
||||
const onDisk = new Set(allMd);
|
||||
|
||||
const orphans: string[] = [];
|
||||
for (const path of onDisk) {
|
||||
if (!visited.has(path)) orphans.push(path);
|
||||
}
|
||||
const broken: string[] = [];
|
||||
for (const path of visited) {
|
||||
if (onDisk.has(path)) continue;
|
||||
if (path.endsWith("README.md")) continue;
|
||||
// Files intentionally excluded from the on-disk set (sibling type docs,
|
||||
// bundled agent skills, etc.) are not "broken" — they were filtered out
|
||||
// by `DEFAULT_IGNORE_SUFFIXES`.
|
||||
if (DEFAULT_IGNORE_SUFFIXES.some((suf) => path.endsWith(suf))) continue;
|
||||
if (!existsSync(path)) broken.push(path);
|
||||
}
|
||||
|
||||
orphans.sort();
|
||||
broken.sort();
|
||||
|
||||
if (!options.quiet) {
|
||||
console.log("");
|
||||
console.log(`📚 Visited from root README: ${visited.size}`);
|
||||
console.log(`📂 .md files on disk: ${onDisk.size}`);
|
||||
console.log(`❌ Orphans (unreachable): ${orphans.length}`);
|
||||
console.log(`⚠️ Broken link targets: ${broken.length}`);
|
||||
if (orphans.length > 0) {
|
||||
console.log("");
|
||||
console.log("Orphans:");
|
||||
for (const o of orphans.slice(0, 50)) {
|
||||
console.log(` - ${relative(ROOT, o)}`);
|
||||
}
|
||||
if (orphans.length > 50) {
|
||||
console.log(` … and ${orphans.length - 50} more`);
|
||||
}
|
||||
}
|
||||
if (broken.length > 0) {
|
||||
console.log("");
|
||||
console.log(
|
||||
"Broken link targets (referenced but missing on disk):"
|
||||
);
|
||||
for (const b of broken.slice(0, 50)) {
|
||||
console.log(` - ${relative(ROOT, b)}`);
|
||||
}
|
||||
if (broken.length > 50) {
|
||||
console.log(` … and ${broken.length - 50} more`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`visited=${visited.size} disk=${onDisk.size} orphans=${orphans.length} broken=${broken.length}`
|
||||
);
|
||||
}
|
||||
|
||||
if (options.strict && (orphans.length > 0 || broken.length > 0)) {
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user