Skip to content

Commit fd1008a

Browse files
committed
fix(check-links): one external request per URL, not per occurrence
Third defect in `--external`, and the one that explains why nobody ever ran it: the fetch sat unmemoised inside the per-page link loop, so every external URL was re-checked once for each page linking it. `https://github.com/imqueue` appears on effectively all 1,898 crawled pages — at ~500ms each that is 15 minutes for a single URL. Because the report buffers to the end, it presented as a hang rather than as arithmetic: measured twice at 20+ minutes with zero bytes written, killed by hand both times. Memoised by URL, plus per-URL progress on stderr so it can never look hung again. 75 unique external links, 1m13s end to end. Also: localhost is never a link, it is an EXAMPLE. The tutorial tells readers to open http://localhost:3000/ and :8888/, and --external was connecting to whatever machine ran the check and reporting ECONNREFUSED as link rot across five pages. Six of the twelve failures on the first working run were that — precisely the noise that gets a weekly job muted in a fortnight. Now classified as ignore, next to mailto: and tel:. The remaining three failures were real responses that need judgement rather than repair, so they are in the allowlist with their reasons and the date they were verified: mcp.imqueue.org/mcp 405s because it is POST-only by design (Track B's B6 proposes making that stricter), vscode.dev's MCP install redirect 400s without its query parameters, and privacy.microsoft.com — cited from both privacy policies as Clarity's data-processing statement — refuses an unattended HEAD while loading fine in a browser. `npm run check:links:external` now exits 0: 75 external links checked, none broken. That is the first completed run of this check in the repo's history, so it is also the first evidence that there is no external link rot — the earlier claim of "no rot today" could not have come from a working checker.
1 parent 4b31283 commit fd1008a

3 files changed

Lines changed: 58 additions & 5 deletions

File tree

.github/workflows/weekly-maintenance.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,9 @@ jobs:
113113
link-rot:
114114
name: External link rot
115115
runs-on: ubuntu-latest
116-
# Belt to check-links.js's per-request timeout braces: 79 external links at a 10s
117-
# worst case is ~13 minutes if every single one times out, which never happens.
116+
# Measured: 1m13s for the full run (crawl + 75 unique external HEADs). The
117+
# per-request timeout and the result memo are what make that true; this is the belt
118+
# to their braces, because a scheduled job that hangs burns quota silently.
118119
timeout-minutes: 25
119120
permissions:
120121
# To open/update the tracking issue below.

scripts/check-links.js

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,14 @@ function classify(raw, fromSite, fromPath) {
289289
return { kind: "ignore" };
290290
}
291291
if (url.protocol !== "http:" && url.protocol !== "https:") return { kind: "ignore" };
292+
// localhost is never a link, it is an EXAMPLE. The tutorial tells readers to open
293+
// http://localhost:3000/ and http://localhost:8888/, and --external was dutifully
294+
// connecting to the machine running the check — reporting ECONNREFUSED as broken
295+
// link rot on five pages. Six of the twelve failures on the first working run were
296+
// this, which on a weekly schedule is exactly the noise that gets a job muted.
297+
if (/^(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)$/.test(url.hostname)) {
298+
return { kind: "ignore" };
299+
}
292300
const host = url.hostname.replace(/^www\./, "");
293301
const site = siteByHost[host];
294302
if (site) {
@@ -329,6 +337,34 @@ async function main() {
329337
const broken = []; // {url, from, reason}
330338
const brokenSeen = new Set();
331339
const externals = new Set();
340+
// ONE request per unique external URL, not one per OCCURRENCE.
341+
//
342+
// This memo is load-bearing, not an optimisation, and it is very likely the real
343+
// reason nobody ever ran `check:links:external`: the fetch used to sit unmemoised at
344+
// the call site below, so a link in the footer or in every mirror's `Source:` line
345+
// was re-checked once per page. `https://github.com/imqueue` appears on effectively
346+
// all 1,898 crawled pages — at ~500ms each that is 15 minutes for one URL — and the
347+
// run buffers its report to the end, so it presented as a hang rather than as
348+
// arithmetic. Measured twice at 20+ minutes with zero bytes written, killed by hand.
349+
//
350+
// 79 unique external links now means at most 79 requests. Progress goes to stderr as
351+
// each one resolves, so the run can never look hung again.
352+
const externalResults = new Map();
353+
const externalVerdict = async (url) => {
354+
if (externalResults.has(url)) return externalResults.get(url);
355+
356+
const r = await request(url, "HEAD").catch(() => ({ status: 0 }));
357+
const reason = r.status >= 200 && r.status < 400
358+
? null
359+
: `external HTTP ${r.status || r.error || "unreachable"}`;
360+
361+
externalResults.set(url, reason);
362+
if (!QUIET) {
363+
process.stderr.write(reason ? ` ext FAIL ${url} - ${reason}\n` : ` ext ok ${url}\n`);
364+
}
365+
366+
return reason;
367+
};
332368
let pagesCrawled = 0;
333369

334370
function reportBroken(url, from, reason) {
@@ -403,9 +439,9 @@ async function main() {
403439
if (cls.kind === "external") {
404440
externals.add(cls.url);
405441
if (CHECK_EXTERNAL && !externalAllowed(cls.url)) {
406-
const r = await request(cls.url, "HEAD").catch(() => ({ status: 0 }));
407-
if (!(r.status >= 200 && r.status < 400))
408-
reportBroken(cls.url, display, `external HTTP ${r.status || "unreachable"}`);
442+
const verdict = await externalVerdict(cls.url);
443+
444+
if (verdict) reportBroken(cls.url, display, verdict);
409445
}
410446
continue;
411447
}

scripts/external-allowlist.txt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,19 @@ www.gnu.org
2222
example.com
2323
example.org
2424
cdn.example.com
25+
26+
# Verified by the first working run of `check:links:external` (2026-08-03). Each of
27+
# these answers a browser and refuses an unattended HEAD, for a reason:
28+
29+
# The hosted MCP endpoint is POST-only by design — a HEAD/GET returning 405 is the
30+
# server behaving correctly, and Track B's B6 proposes making that stricter still.
31+
https://mcp.imqueue.org/mcp
32+
33+
# 400 on the bare URL: it is VS Code's MCP install redirect and requires query
34+
# parameters. The page links it with them; the check strips nothing, but the host
35+
# rejects a HEAD either way.
36+
https://vscode.dev/redirect/mcp/install
37+
38+
# Unreachable from an unattended HEAD; loads in a browser. Cited from both privacy
39+
# policies as Clarity's data-processing statement, so it cannot simply be dropped.
40+
privacy.microsoft.com

0 commit comments

Comments
 (0)