Files
hanzo.ai/scripts/link-audit.mjs
zeekayandHanzo Dev 16f59febdb scripts/link-audit.mjs — ask every outbound address whether it answers
The one-off version of this found that 118 of 144 docs.hanzo.ai links and 43
of 210 github.com links were dead, including the shared footer's Quickstarts
link on all 950 pages and /computer's primary call to action. A build, a
typecheck and 102 gates were green on every one of them, because none of them
asks what an href points AT.

It is a tool, not a gate, and must stay one: e2e/gates/links.spec.ts checks
internal links against out/ and stays offline on purpose, because a suite that
fetches the internet fails when somebody else's host is slow.

Three things it does that a naive checker does not, each learned by getting it
wrong first:

  403 IS NOT DEAD -- npmjs.com and openai.com refuse a script and serve a
  browser. Only 404 and 410 count; the rest is reported separately for a human.

  A SITEMAP IS NOT EVIDENCE -- docs.hanzo.ai lists pages in sitemap.xml that
  answer 404. Ask the URL.

  PRIVATE IS NOT MISSING -- a private GitHub repo 404s anonymously. Both are
  dead for a reader and the remedies are opposite (publish the repo, or drop
  the link), so --github re-asks with gh when it is authenticated.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 01:30:44 -07:00

128 lines
5.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Ask every address the export links to whether it answers.
*
* This is NOT a gate and must not become one: `e2e/gates/links.spec.ts` checks
* internal links against `out/` and stays offline on purpose, because a suite
* that fetches the internet fails when someone else's host is slow. This is the
* other half — the outbound links — and it is a tool you RUN, whose output is
* read by a person.
*
* It exists because the one-off version of it found that 118 of 144
* docs.hanzo.ai links and 43 of 210 github.com links were dead, including the
* shared footer's Quickstarts link on all 950 pages and /computer's primary
* call to action. None of that was visible to a build, a typecheck or 102 gates.
*
* Three things it does that a naive checker does not, each learned by getting
* it wrong first:
*
* - 403 IS NOT DEAD. npmjs.com and openai.com refuse a script and serve a
* browser. Only 404 and 410 count; everything else is reported separately
* so a human decides.
* - A SITEMAP IS NOT EVIDENCE. docs.hanzo.ai lists pages in sitemap.xml that
* answer 404. Ask the URL.
* - PRIVATE IS NOT MISSING. A private GitHub repo 404s anonymously. Both are
* dead for a reader and the remedies are opposite — publish the repo, or
* drop the link — so `--github` re-asks with `gh` when it is authenticated.
*
* Usage: node scripts/link-audit.mjs [--host docs.hanzo.ai] [--github] [--json]
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs'
import { join, relative } from 'node:path'
import { execFileSync } from 'node:child_process'
const OUT = 'out'
const args = process.argv.slice(2)
const only = args.includes('--host') ? args[args.indexOf('--host') + 1] : null
const wantGithub = args.includes('--github')
const asJson = args.includes('--json')
if (!existsSync(OUT)) {
console.error('no out/ — run `pnpm build` first; this reads the bytes that ship')
process.exit(2)
}
/** Every external URL in the export, with the pages that carry it. */
function collect() {
const urls = new Map()
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name)
if (e.isDirectory()) { walk(p); continue }
if (!e.name.endsWith('.html')) continue
for (const m of readFileSync(p, 'utf8').matchAll(/href="(https?:\/\/[^"]+)"/g)) {
const u = m[1].split('#')[0].replace(/\/+$/, '')
let host
try { host = new URL(u).host } catch { continue }
if (only && host !== only) continue
if (!urls.has(u)) urls.set(u, new Set())
urls.get(u).add('/' + relative(OUT, p).replace(/\.html$/, ''))
}
}
}
walk(OUT)
return urls
}
async function status(url) {
try {
const c = new AbortController()
const t = setTimeout(() => c.abort(), 20000)
const r = await fetch(url, {
signal: c.signal, redirect: 'follow',
headers: { 'user-agent': 'Mozilla/5.0 (compatible; hanzo-link-audit)' },
})
clearTimeout(t)
return r.status
} catch { return 'unreachable' }
}
/** A GitHub 404 is either "gone" or "private", and only an authenticated ask can tell. */
function githubKind(url) {
const m = /^https:\/\/github\.com\/([^/]+)\/([^/?#]+)$/.exec(url)
if (!m) return null
try {
const out = execFileSync('gh', ['api', `repos/${m[1]}/${m[2]}`, '--jq', '.private'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
return out === 'true' ? 'private' : out === 'false' ? 'public' : 'missing'
} catch { return 'missing' }
}
const urls = collect()
const list = [...urls.keys()]
const results = []
let i = 0
await Promise.all(Array.from({ length: 6 }, async () => {
while (i < list.length) {
const u = list[i++]
results.push({ url: u, status: await status(u), pages: [...urls.get(u)] })
}
}))
const dead = results.filter((r) => r.status === 404 || r.status === 410)
const odd = results.filter((r) => r.status !== 200 && !dead.includes(r))
if (wantGithub) for (const r of dead) if (r.url.startsWith('https://github.com/')) r.kind = githubKind(r.url)
if (asJson) { console.log(JSON.stringify({ results }, null, 1)); process.exit(dead.length ? 1 : 0) }
console.log(`[links] ${results.length} external URLs · ${results.length - dead.length - odd.length} answer · ${dead.length} DEAD · ${odd.length} other`)
const byHost = new Map()
for (const r of dead) {
const h = new URL(r.url).host
if (!byHost.has(h)) byHost.set(h, [])
byHost.get(h).push(r)
}
for (const [h, rows] of [...byHost].sort((a, b) => b[1].length - a[1].length)) {
console.log(`\n${h} — ${rows.length} dead`)
for (const r of rows.sort((a, b) => b.pages.length - a.pages.length).slice(0, 40)) {
const kind = r.kind ? ` [${r.kind}]` : ''
console.log(` ${String(r.pages.length).padStart(4)} pages ${r.url.replace(`https://${h}`, '') || '/'}${kind}`)
}
}
if (odd.length) {
console.log(`\nnot 200 and not dead — read these, do not assume:`)
for (const r of odd.slice(0, 20)) console.log(` ${String(r.status).padStart(11)} ${r.url}`)
}
process.exit(dead.length ? 1 : 0)