Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a4e57b1
tooling: add redirect destination auditor
huklaa Aug 12, 2026
a6ef380
test: cover redirect audit edge cases
huklaa Aug 12, 2026
eb85a4d
docs: document redirect audit workflow
huklaa Aug 12, 2026
fea39b5
test: cover docs route collection
huklaa Aug 12, 2026
2398697
fix: treat protocol-relative redirects as external
huklaa Aug 12, 2026
442899c
fix: report malformed relative redirect destinations
huklaa Aug 12, 2026
a13ac27
test: cover malformed relative redirect targets
huklaa Aug 12, 2026
7271b7b
fix: include markdown pages in redirect route collection
huklaa Aug 12, 2026
2f98f93
test: cover markdown redirect route targets
huklaa Aug 12, 2026
24c8572
fix: exclude hidden docs pages from redirect routes
huklaa Aug 13, 2026
50ce392
Merge remote-tracking branch 'upstream/master' into agent/audit-broke…
huklaa Aug 19, 2026
bb56398
fix: count hidden docs as valid redirect routes
huklaa Aug 20, 2026
47bc7b6
test: keep hidden docs routable in redirect audit
huklaa Aug 20, 2026
39cf0ba
fix: exclude mintignored pages from redirect routes
huklaa Aug 20, 2026
989933b
test: cover mintignored redirect destinations
huklaa Aug 20, 2026
405700d
fix: handle wildcard redirect destinations
huklaa Aug 21, 2026
b5dec44
test: cover wildcard redirect destinations
huklaa Aug 21, 2026
9d77acf
fix: exclude non-published docs files from redirect routes
huklaa Aug 21, 2026
81ec6c9
test: exclude unpublished docs artifacts from routes
huklaa Aug 21, 2026
8224377
test: cover nested mintignore redirect routes
huklaa Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ node scripts/lint-mdx.js all
node scripts/lint-mdx.js all || exit 1
```

## Redirect destination auditor

`audit-redirects.js` checks every internal destination in `docs/docs.json` against the current MDX route tree. It follows redirect chains, detects cycles, and groups repeated broken destinations so large redirect migrations can be audited without guessing from individual entries.

```bash
# Report broken internal redirect destinations without failing
node scripts/audit-redirects.js

# Exit with code 1 when broken destinations are found
node scripts/audit-redirects.js --strict

# Run the focused unit tests
node --test scripts/audit-redirects.test.js
```

External redirect destinations are treated as valid terminal targets. The default report-only mode is useful while known redirect debt is being repaired; `--strict` can be used once the tree is clean or in targeted validation workflows.

## Docs index generators

Two generators emit AI-facing site indexes from the `docs/` tree. Both share helpers in `lib/docs-utils.js` (frontmatter parser, `.mintignore` loader, file walker, section discovery).
Expand Down
27 changes: 27 additions & 0 deletions scripts/audit-redirects-mintignore.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

const { collectRoutes } = require('./audit-redirects');

test('excludes an entire nested .mintignore directory subtree from redirect routes', (t) => {
const docsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-redirects-mintignore-'));
t.after(() => fs.rmSync(docsDir, { recursive: true, force: true }));

fs.writeFileSync(path.join(docsDir, '.mintignore'), '/drafts/private/*\n');
fs.writeFileSync(path.join(docsDir, 'index.mdx'), '# Home\n');

fs.mkdirSync(path.join(docsDir, 'drafts', 'private', 'nested'), { recursive: true });
fs.writeFileSync(path.join(docsDir, 'drafts', 'private', 'page.mdx'), '# Private\n');
fs.writeFileSync(path.join(docsDir, 'drafts', 'private', 'nested', 'page.mdx'), '# Nested private\n');

fs.mkdirSync(path.join(docsDir, 'drafts', 'public'), { recursive: true });
fs.writeFileSync(path.join(docsDir, 'drafts', 'public', 'page.mdx'), '# Public\n');

assert.deepEqual(
[...collectRoutes(docsDir)].sort(),
['/', '/drafts/public/page'],
);
});
192 changes: 192 additions & 0 deletions scripts/audit-redirects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/env node

const fs = require('fs');
const path = require('path');
const { CONSTANTS, loadMintIgnore } = require('./lib/docs-utils');

function isExternalDestination(value) {
return (
typeof value === 'string' &&
(value.startsWith('//') || /^[A-Za-z][A-Za-z\d+.-]*:/.test(value))
);
}

function normalizeInternalPath(value) {
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) return null;

const clean = value.split(/[?#]/, 1)[0].replace(/\/+$/, '');
return clean || '/';
}

function matchesRoutePattern(value, routes) {
const internal = normalizeInternalPath(value);
if (!internal) return false;

const match = internal.match(/^(.*)\/:([A-Za-z][A-Za-z\d_]*)\*$/);
if (!match) return false;

const prefix = match[1] || '/';
return routes.has(prefix) || [...routes].some((route) => route.startsWith(`${prefix}/`));
}

function isMintIgnored(docsDir, fullPath, ignored) {
const relative = path.relative(docsDir, fullPath).split(path.sep).join('/');
const withoutExtension = relative.replace(/\.mdx?$/, '');
const basenameWithoutExtension = path.posix.basename(withoutExtension);

if (ignored.files.has(relative) || ignored.files.has(withoutExtension)) return true;
if (ignored.bareFiles.has(withoutExtension) || ignored.bareFiles.has(basenameWithoutExtension)) {
return true;
}

for (const ignoredDir of ignored.dirs) {
if (relative === ignoredDir || relative.startsWith(`${ignoredDir}/`)) return true;
}

return false;
}

function collectRoutes(docsDir) {
const routes = new Set(['/']);
const ignored = loadMintIgnore(path.join(docsDir, '.mintignore'));

function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
if (CONSTANTS.skipFiles.includes(entry.name)) continue;
if (entry.isDirectory() && CONSTANTS.skipDirs.includes(entry.name)) continue;

const fullPath = path.join(dir, entry.name);

if (isMintIgnored(docsDir, fullPath, ignored)) continue;

if (entry.isDirectory()) {
walk(fullPath);
continue;
}

const extension = path.extname(entry.name).toLowerCase();
if (!entry.isFile() || !CONSTANTS.extensions.includes(extension)) continue;

let route = path
.relative(docsDir, fullPath)
.split(path.sep)
.join('/')
.replace(/\.mdx?$/, '');

if (route === 'index') route = '';
if (route.endsWith('/index')) route = route.slice(0, -'/index'.length);

routes.add(`/${route}`.replace(/\/+$/, '') || '/');
}
}

walk(docsDir);
return routes;
}

function auditRedirects(config, routes) {
const redirects = Array.isArray(config.redirects) ? config.redirects : [];
const redirectMap = new Map();

for (const redirect of redirects) {
const source = normalizeInternalPath(redirect.source);
if (source && typeof redirect.destination === 'string') {
redirectMap.set(source, redirect.destination);
}
}

function resolve(destination) {
let current = destination;
const visited = new Set();

while (true) {
if (isExternalDestination(current)) {
return { ok: true, terminal: current, reason: 'external' };
}

const internal = normalizeInternalPath(current);
if (!internal) return { ok: false, terminal: current, reason: 'invalid' };
if (routes.has(internal)) return { ok: true, terminal: internal, reason: 'page' };
if (matchesRoutePattern(internal, routes)) {
return { ok: true, terminal: internal, reason: 'pattern' };
}
if (visited.has(internal)) return { ok: false, terminal: internal, reason: 'cycle' };

visited.add(internal);
const next = redirectMap.get(internal);
if (!next) return { ok: false, terminal: internal, reason: 'missing' };
current = next;
}
}

const brokenByDestination = new Map();

for (const redirect of redirects) {
if (typeof redirect.destination !== 'string') continue;
if (isExternalDestination(redirect.destination)) continue;

const destination = normalizeInternalPath(redirect.destination) || redirect.destination;
const result = resolve(redirect.destination);
if (result.ok) continue;

const existing = brokenByDestination.get(destination) || {
destination,
terminal: result.terminal,
reason: result.reason,
count: 0,
sources: [],
};

existing.count += 1;
if (typeof redirect.source === 'string') existing.sources.push(redirect.source);
brokenByDestination.set(destination, existing);
}

return [...brokenByDestination.values()].sort(
(a, b) => b.count - a.count || a.destination.localeCompare(b.destination),
);
}

function printReport(broken) {
if (broken.length === 0) {
console.log('All internal redirect destinations resolve to an existing docs page.');
return;
}

const totalEntries = broken.reduce((sum, item) => sum + item.count, 0);
console.log(
`Found ${broken.length} broken internal redirect destinations across ${totalEntries} redirect entries.`,
);
console.log('');
console.log('Count\tDestination\tTerminal\tReason');

for (const item of broken) {
console.log(`${item.count}\t${item.destination}\t${item.terminal}\t${item.reason}`);
}
}

function main() {
const repoRoot = path.resolve(__dirname, '..');
const docsDir = path.join(repoRoot, 'docs');
const configPath = path.join(docsDir, 'docs.json');
const strict = process.argv.includes('--strict');

const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const routes = collectRoutes(docsDir);
const broken = auditRedirects(config, routes);

printReport(broken);

if (strict && broken.length > 0) process.exitCode = 1;
}

if (require.main === module) main();

module.exports = {
auditRedirects,
collectRoutes,
isExternalDestination,
matchesRoutePattern,
normalizeInternalPath,
};
Loading