igneum/tools/ci/link-check.mjs
igneum-labs 684c4a5eac CI on every push: igneum-pow tests, census build, simulator quick modes, site build + link check, identity grep
GitHub Actions workflow (.github/workflows/ci.yml) on push and pull_request with three jobs on the free runners:
igneum-pow `cargo test --release` and the igneum-census build; the two Python simulators' --quick modes under a
120-second timeout; the site build, an internal link check of site/*.html (tools/ci/link-check.mjs) and a gh-free
identity grep of the public export list (tools/ci/identity-check.sh over tools/ci/forbidden-strings.txt: machine
names, LAN and overlay addresses, home paths, local time zones, the log-intake key pattern; never a key or a name).
The node fork is too big for CI today and the workflow says so.

sim/finality_v2.py --quick is now a genuine smoke run (one day or hour per scenario, one partition and one eclipse
setting): 149 s at nice 19 on a loaded Mac, was 745 s. sim/difficulty/sim.py gains --quick (up50 and warmup-hard,
kaspa and igneum controllers, 36 s). One bench-log time-zone label reworded so the identity grep passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 09:57:34 +00:00

40 lines
2.3 KiB
JavaScript

// Internal link check of the site: every href="..." and src="..." in site/*.html that is not an external URL,
// an anchor, a mailto or a data URI must resolve to a file under site/ (clean URLs: /bench -> bench.html).
// Fragment links (#id) inside a page must name an element id in that page.
// node tools/ci/link-check.mjs exit 1 on the first broken link, listing every one
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const site = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'site');
const pages = readdirSync(site).filter(f => f.endsWith('.html'));
const broken = [];
let checked = 0;
function resolves(target) {
const clean = target.replace(/[?#].*$/, '');
if (clean === '' || clean === '/') return true;
const rel = clean.replace(/^\//, '');
const candidates = [join(site, rel), join(site, `${rel}.html`), join(site, rel, 'index.html')];
return candidates.some(c => existsSync(c) && statSync(c).isFile());
}
for (const page of pages) {
const html = readFileSync(join(site, page), 'utf8');
const ids = new Set([...html.matchAll(/\sid="([^"]+)"/g)].map(m => m[1]));
for (const m of html.matchAll(/\s(?:href|src)="([^"]*)"/g)) {
const t = m[1];
if (/^(https?:|mailto:|data:|tel:|javascript:)/i.test(t) || t.startsWith('//')) continue;
checked++;
if (t.startsWith('#')) { if (t.length > 1 && !ids.has(t.slice(1))) broken.push(`${page}: fragment ${t}`); continue; }
const [path, frag] = t.split('#');
if (!resolves(path)) { broken.push(`${page}: ${t}`); continue; }
if (frag) {
const rel = path.replace(/[?].*$/, '').replace(/^\//, '');
const file = [join(site, rel), join(site, `${rel}.html`), join(site, rel, 'index.html')].find(c => existsSync(c) && statSync(c).isFile());
if (file && file.endsWith('.html') && !new Set([...readFileSync(file, 'utf8').matchAll(/\sid="([^"]+)"/g)].map(x => x[1])).has(frag)) broken.push(`${page}: ${t} (no id ${frag})`);
}
}
}
if (broken.length) { console.error(`link check: ${broken.length} broken internal link(s) of ${checked}:`); for (const b of broken) console.error(` ${b}`); process.exit(1); }
console.log(`link check: ${checked} internal links across ${pages.length} pages, 0 broken`);