From 5af06d413615b91c3db5317a9f9188f617271624 Mon Sep 17 00:00:00 2001 From: firejune Date: Tue, 27 Oct 2026 11:59:20 +0900 Subject: [PATCH 1/2] test(site): validate fragments and report repository documentation drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/verify.ts checked that a linked file existed but dropped the fragment, so a link to a section that no longer exists passed. Same-page, relative, cross-page and absolute same-origin links are now resolved in the source page's URL context and checked against the generated HTML's element ids, across the twelve pages, their Markdown mirrors, llms.txt and each page section of llms-full.txt, with percent and entity decoding. Measured: a planted href="#planted-missing-anchor" in a private copy of /docs/core/ fails by source page and target (exit 1); repaired, exit 0; a generated-artifact mutant on both HTML and Markdown refuses even after a refreshed valid manifest. Targeted 38 pass; full suite 67 pass / 548 assertions; build 12 pages + 8 redirects; verify 0. Deterministic checks use no network. bun run check:external is a separate, bounded live report for rig-c repository documentation links (40 requests, 2 redirect hops, 1 MiB per body, 5 s per request, 45 s total; no retries, no credentials): only HTTP 404/410 count as missing; auth, rate limit, server error, timeout and network failures keep their own statuses. One run: 89 link entries, 0 missing, 63 success, 5 body-limit, 21 unvisited by budget, 26 anchors present / 19 unknown / 44 not requested — a bounded report, not an all-links pass. README documents both. Rejected: crawling arbitrary domains; recursing into copied demo bundles; guessing GitHub heading slugs; broad retries; any workflow or permission change. Closes #88 Co-Authored-By: Naru <342599188+fj-naru[bot]@users.noreply.github.com> Co-Authored-By: Roa <342598975+fj-roa[bot]@users.noreply.github.com> --- README.md | 30 ++++++++ package.json | 2 + src/build.test.ts | 19 +++++ src/external-links.test.ts | 120 +++++++++++++++++++++++++++++ src/external-links.ts | 151 +++++++++++++++++++++++++++++++++++++ src/links.test.ts | 125 ++++++++++++++++++++++++++++++ src/links.ts | 117 ++++++++++++++++++++++++++++ src/verify.ts | 38 ++-------- 8 files changed, 572 insertions(+), 30 deletions(-) create mode 100644 src/external-links.test.ts create mode 100644 src/external-links.ts create mode 100644 src/links.test.ts create mode 100644 src/links.ts diff --git a/README.md b/README.md index b3ae331..154376b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,36 @@ The builder copies **only this repository's** `assets/`, `demos/`, `styles.css` The landing HTML gives each adapter version its own escaped `` item separated by `
`. Its Markdown table remains one physical line per row, followed by a plain per-package list of all eight independently resolved packages; the same list appears in `llms.txt` and `llms-full.txt`. `@rig-c/core` and `@rig-c/adapters` are labels/aliases, not additional latest lookups. Docs badges and structured docs versions use the same build-time registry map. Recorded demo versions remain pinned to their sources. +## Link checks + +`bun run verify` checks links in the twelve generated pages, their Markdown mirrors, `llms.txt` and each page section of `llms-full.txt`. The same check runs alone with `bun run check:links`. It reads the actual element IDs from generated HTML, including IDs on non-heading elements; it does not guess heading slugs. Markdown is rendered by Bun to extract inline, reference, autolinks and raw HTML links. Code examples and HTML comments are not links. + +Same-page, relative, cross-page and absolute same-origin links are resolved in the source page's URL context. A link to a known `index.md#fragment` uses that page's generated HTML IDs. Each `llms-full.txt` section keeps its original page context, so another page's heading cannot satisfy its same-page fragment. Attributes decode semicolon-terminated named and numeric HTML entities once; URL paths and fragments decode percent escapes once, including Unicode. Empty `#` and `#top` navigate to the top. Missing files, missing IDs, invalid URL escapes and fragments on unsupported non-HTML files fail with the source and original href. + +The link crawl covers generated pages and mirrors. It does not recursively inspect copied demo/bundle links, apply their `` elements, execute scripts, or check dynamic links. Existing specialized demo and asset checks still run. A `` in a generated source page is refused rather than resolved against the wrong context. + +Use an external scratch directory for build output and test temporary files: + +```bash +CHECK_ROOT="$HOME/.cache/rig-c-site-checks" +mkdir -p "$CHECK_ROOT" +export TMPDIR="$CHECK_ROOT" +bun run build --out "$CHECK_ROOT/output" +bun run check:links --out "$CHECK_ROOT/output" +bun run verify --out "$CHECK_ROOT/output" +bun test src/links.test.ts src/external-links.test.ts +``` + +The tests plant a missing anchor in an existing private fixture file, run the real check CLI and assert exit 1 with the source and href, then repair the fixture and assert exit 0. They also cover HTML entities, Unicode, queries, Markdown-only and LLM-only failures, and page context. No authored page is mutated by these controls. Tests and verification remain offline; only the existing build's npm lookups require network access. + +### Bounded repository documentation report + +`bun run check:external --out "$CHECK_ROOT/output" > "$CHECK_ROOT/external-links.json"` is an optional live report, separate from test, build, verify and CI. It collects only public `https://github.com/rig-c/` README pages and `/blob/...` documents ending in `.md`, `.mdx`, `.rst`, `.txt`, or named `LICENSE`, `LICENCE`, `COPYING`, `NOTICE`. Other organizations, raw hosts, directory trees, issues and arbitrary domains are outside its scope. It follows no discovered links. + +Bounds are fixed: 40 HTTP requests total (redirects included), at most two redirect hops per page, 1 MiB per response body, five seconds per request including body reads, and 45 seconds for the request budget. Requests are sequential, deduplicated by URL without fragment, unauthenticated, and use manual redirects with the same scope checks at every hop. No cookies or authorization headers are sent or forwarded. There are no retries. Unvisited targets are reported as `budget`; oversized responses and refused redirects remain uncertain. + +Only HTTP 404/410 are `missing` and produce exit 1. Auth failures, rate limits, server errors, timeouts and network errors have distinct report statuses and never become missing targets. Exit 0 means the bounded report completed with no observed 404/410; it does not establish that every target is valid. Results retain source pages, requested and final URLs, HTTP status and, for completed bodies, length and SHA-256. GitHub can supply headings dynamically: an observed ID proves `anchor: present`, but an unobserved fragment stays `anchor: unknown`, even on HTTP 200. This report does not claim external anchor absence. Offline fixtures cover all these dispositions without live requests. + ## Actions Pages deployment `.github/workflows/pages.yml` runs on pushes to `main`, pull requests, manual dispatch and hourly at minute **17 UTC** (`17 * * * *`). Pull requests only test, build and verify. Main runs are serialized through deployment to keep the live comparison meaningful. Actions first runs the offline tests, then the real registry-backed build, then artifact verification; only successful, eligible and changed artifacts reach `actions/upload-pages-artifact@v4` and `actions/deploy-pages@v4`. Checkout uses `actions/checkout@v6` with credential persistence disabled. No bot writes source commits. diff --git a/package.json b/package.json index d836a5b..4f1e9f8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "build:ci": "bun scripts/build-site.ts", "compare:live": "bun scripts/compare-live.ts", "verify": "bun src/verify.ts", + "check:links": "bun src/links.ts", + "check:external": "bun src/external-links.ts", "serve": "bun src/serve.ts" } } diff --git a/src/build.test.ts b/src/build.test.ts index 11e156f..3021044 100644 --- a/src/build.test.ts +++ b/src/build.test.ts @@ -158,6 +158,25 @@ describe('isolated deployable site', () => { verifySite(out); }); + test('verification rejects missing HTML and mirror anchors despite a refreshed valid manifest', () => { + for (const path of ['docs/core/index.html', 'docs/core/index.md']) { + const absolute = join(out, path); + const original = readFileSync(absolute); + const manifest = readFileSync(join(out, 'deployment-manifest.json')); + try { + const planted = path.endsWith('.html') + ? 'Missing' + : '\n[Missing](#private-missing-anchor)'; + writeFileSync(absolute, original.toString() + planted); + writeManifest(out); + expect(() => verifySite(out)).toThrow('Verification failed'); + } finally { + writeFileSync(absolute, original); + writeFileSync(join(out, 'deployment-manifest.json'), manifest); + } + } + }); + test('output argument cannot target authored paths or root', () => { for (const path of [ROOT_DIR, '..', 'assets', 'content', 'src', 'docs']) expect(() => outputRoot(path)).toThrow(); }); diff --git a/src/external-links.test.ts b/src/external-links.test.ts new file mode 100644 index 0000000..8a44746 --- /dev/null +++ b/src/external-links.test.ts @@ -0,0 +1,120 @@ +import { expect, spyOn, test } from 'bun:test'; +import { checkExternalLinks, documentationLinks, EXTERNAL_BOUNDS, repositoryDoc, type DocumentationLink } from './external-links.ts'; +import { markdownDocument } from './links.ts'; + +const link = (url = 'https://github.com/rig-c/core#section'): DocumentationLink => ({ url, sources: ['docs/core/index.html'] }); +const fake = (handler: (url: string, init: RequestInit) => Response | Promise) => + ((url: string | URL | Request, init?: RequestInit) => handler(String(url), init!)) as typeof fetch; + +test('only public rig-c repository roots and document blobs enter the bounded report', () => { + const allowed = ['https://github.com/rig-c/core', 'https://github.com/rig-c/core/blob/main/docs/AUTHORING.md#x', 'https://github.com/rig-c/core/blob/main/LICENSE']; + const excluded = ['https://example.org/doc.md', 'https://github.com/else/core', 'https://github.com/rig-c/core/issues/88', 'https://github.com/rig-c/core/tree/main/docs', 'https://github.com/rig-c/core/blob/main/src/run.ts', 'https://token@github.com/rig-c/core', 'http://github.com/rig-c/core', 'https://raw.githubusercontent.com/rig-c/core/main/README.md']; + for (const url of allowed) expect(repositoryDoc(new URL(url))).toBe(true); + for (const url of excluded) expect(repositoryDoc(new URL(url))).toBe(false); + const md = [...allowed, ...excluded].map((url) => `[doc](${url})`).join('\n'); + const documents = [markdownDocument('one.md', '/', md), markdownDocument('two.md', '/', md)]; + const links = documentationLinks(documents); + expect(links).toHaveLength(allowed.length); + expect(links.every((item) => item.sources.join(',') === 'one.md,two.md')).toBe(true); +}); + +test('one page request covers multiple fragments, actual IDs establish presence, absent GitHub IDs stay unknown', async () => { + let calls = 0; + const report = await checkExternalLinks([link(), link('https://github.com/rig-c/core#dynamic'), link('https://github.com/rig-c/core')], fake((url, init) => { + calls++; + expect(url).toBe('https://github.com/rig-c/core'); + expect(init.credentials).toBe('omit'); + expect(init.redirect).toBe('manual'); + const headers = new Headers(init.headers); + expect(headers.has('authorization')).toBe(false); + expect(headers.has('cookie')).toBe(false); + return new Response('

Section

', { headers: { 'content-type': 'text/html' } }); + })); + expect(calls).toBe(1); + expect(report.results.map((item) => item.anchor)).toEqual(['present', 'unknown', 'not-requested']); + expect(report.missing).toBe(0); + expect(report.results[0]!.sha256).toHaveLength(64); +}); + +for (const [http, status, headers] of [ + [404, 'missing', {}], [410, 'missing', {}], [401, 'auth', {}], [403, 'auth', {}], + [403, 'rate-limit', { 'x-ratelimit-remaining': '0' }], [429, 'rate-limit', {}], + [500, 'transient', {}], [503, 'transient', {}], [408, 'transient', {}], [418, 'unknown', {}], +] as const) { + test(`HTTP ${http} / ${status} is classified without retries`, async () => { + const report = await checkExternalLinks([link()], fake(() => new Response('', { status: http, headers }))); + expect(report.requests).toBe(1); + expect(report.results[0]!.status).toBe(status); + expect(report.results[0]!.anchor).toBe('unknown'); + expect(report.missing).toBe(status === 'missing' ? 1 : 0); + }); +} + +test('redirects remain in scope, count toward request limit, stop at two hops, never forward credentials', async () => { + const locations: string[] = []; + const report = await checkExternalLinks([link()], fake((url, init) => { + locations.push(url); + expect(new Headers(init.headers).has('authorization')).toBe(false); + return new Response('', { status: 302, headers: { location: `${url}/` } }); + })); + expect(report.requests).toBe(3); + expect(report.results[0]!.status).toBe('redirect'); + expect(locations).toHaveLength(3); + for (const location of ['https://example.org/doc', 'https://secret@github.com/rig-c/core', '/login']) { + let calls = 0; + const blocked = await checkExternalLinks([link()], fake(() => { calls++; return new Response('', { status: 302, headers: { location } }); })); + expect(calls).toBe(1); + expect(blocked.results[0]!.status).toBe('redirect'); + expect(blocked.missing).toBe(0); + } +}); + +test('declared and streamed byte limits produce uncertainty and cancel oversized responses', async () => { + const declared = await checkExternalLinks([link()], fake(() => new Response('x', { headers: { 'content-length': String(EXTERNAL_BOUNDS.responseBytes + 1) } }))); + expect(declared.results[0]!.status).toBe('body-limit'); + let cancelled = false; + const streamed = await checkExternalLinks([link()], fake(() => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(EXTERNAL_BOUNDS.responseBytes + 1)); }, + cancel() { cancelled = true; }, + })))); + expect(streamed.results[0]!.status).toBe('body-limit'); + expect(cancelled).toBe(true); +}); + +test('request budget is strict and out-of-scope input makes no request', async () => { + const links = Array.from({ length: EXTERNAL_BOUNDS.requests + 2 }, (_, i) => link(`https://github.com/rig-c/repo-${i}`)); + links.push(link('https://example.org/not-in-scope')); + const report = await checkExternalLinks(links, fake(() => new Response('ok'))); + expect(report.requests).toBe(EXTERNAL_BOUNDS.requests); + expect(report.results.filter((item) => item.status === 'budget')).toHaveLength(2); + expect(report.results.at(-1)!.status).toBe('unknown'); +}); + +test('elapsed total-time budget stops further requests', async () => { + let now = 1_000; + const clock = spyOn(Date, 'now').mockImplementation(() => now); + try { + const report = await checkExternalLinks([link(), link('https://github.com/rig-c/parts')], fake(() => { + now += EXTERNAL_BOUNDS.totalMs + 1; + return new Response('ok'); + })); + expect(report.requests).toBe(1); + expect(report.results[1]!.status).toBe('budget'); + } finally { clock.mockRestore(); } +}); + +test('network errors are uncertain and do not retry', async () => { + const report = await checkExternalLinks([link()], fake(() => { throw new Error('fixture connection refused'); })); + expect(report.requests).toBe(1); + expect(report.results[0]!.status).toBe('network'); + expect(report.missing).toBe(0); +}); + +test('timeout aborts the request and remains uncertain', async () => { + const report = await checkExternalLinks([link()], fake((_url, init) => new Promise((_resolve, reject) => { + init.signal!.addEventListener('abort', () => reject(new Error('fixture abort')), { once: true }); + }))); + expect(report.results[0]!.status).toBe('timeout'); + expect(report.requests).toBe(1); + expect(report.missing).toBe(0); +}, 10_000); diff --git a/src/external-links.ts b/src/external-links.ts new file mode 100644 index 0000000..aaf3d4c --- /dev/null +++ b/src/external-links.ts @@ -0,0 +1,151 @@ +import { createHash } from 'node:crypto'; +import { parseOutputArgs } from './output.ts'; +import { decodeAttribute, inspectHtml, siteDocuments, type LinkDocument } from './links.ts'; +import { SITE_ORIGIN } from './content.ts'; + +export const EXTERNAL_BOUNDS = Object.freeze({ + requests: 40, redirects: 2, responseBytes: 1_048_576, requestMs: 5_000, totalMs: 45_000, +}); +export type ExternalStatus = 'ok' | 'missing' | 'auth' | 'rate-limit' | 'transient' + | 'timeout' | 'network' | 'body-limit' | 'budget' | 'redirect' | 'unknown'; +export interface DocumentationLink { url: string; sources: string[] } +export interface ExternalResult extends DocumentationLink { + status: ExternalStatus; + anchor: 'present' | 'unknown' | 'not-requested'; + http?: number; + finalUrl?: string; + bytes?: number; + sha256?: string; + detail?: string; +} + +// Repository roots expose README documentation. Direct document paths stay on +// github.com/rig-c; directory trees, issue pages, raw hosts and other domains do not. +export function repositoryDoc(url: URL): boolean { + if (url.protocol !== 'https:' || url.hostname !== 'github.com' || url.port || url.username || url.password) return false; + const parts = url.pathname.split('/').filter(Boolean); + if (parts[0] !== 'rig-c' || !/^[\w.-]+$/.test(parts[1] ?? '')) return false; + if (parts.length === 2) return true; + if (parts[2] !== 'blob' || parts.length < 5) return false; + const file = parts.at(-1)!; + return /\.(md|mdx|rst|txt)$/i.test(file) || /^(LICENSE|LICENCE|COPYING|NOTICE)$/i.test(file); +} + +export function documentationLinks(documents: readonly LinkDocument[]): DocumentationLink[] { + const links = new Map>(); + for (const document of documents) { + for (const href of inspectHtml(document.html).hrefs) { + let url: URL; + try { url = new URL(decodeAttribute(href), `${SITE_ORIGIN}${document.route}`); } catch { continue; } + if (!repositoryDoc(url)) continue; + const sources = links.get(url.href) ?? new Set(); + sources.add(document.source); + links.set(url.href, sources); + } + } + return [...links].sort(([a], [b]) => a.localeCompare(b)).map(([url, sources]) => ({ url, sources: [...sources].sort() })); +} + +export async function checkExternalLinks(links: readonly DocumentationLink[], fetchPublic: typeof fetch = fetch) { + const started = Date.now(); + const deadline = started + EXTERNAL_BOUNDS.totalMs; + let requests = 0; + // All fragments of one page share a single request chain and bounded body. + const cache = new Map; ids?: Set }>(); + const results: ExternalResult[] = []; + async function requestPage(initial: string) { + let current = initial; + for (let redirects = 0; ; redirects++) { + if (requests >= EXTERNAL_BOUNDS.requests || Date.now() >= deadline) return { result: { status: 'budget' as const, detail: 'Request or total-time limit reached' } }; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.min(EXTERNAL_BOUNDS.requestMs, deadline - Date.now())); + try { + requests++; + const response = await fetchPublic(current, { + method: 'GET', redirect: 'manual', credentials: 'omit', signal: controller.signal, + headers: { Accept: 'text/html', 'User-Agent': 'rig-c-site-documentation-links' }, + }); + const http = response.status; + const result = { http, finalUrl: current }; + if (http >= 300 && http < 400) { + await response.body?.cancel(); + const location = response.headers.get('location'); + let next: URL; + try { next = new URL(location ?? '', current); } catch { return { result: { ...result, status: 'redirect' as const, detail: 'Invalid redirect' } }; } + if (!location || redirects >= EXTERNAL_BOUNDS.redirects || !repositoryDoc(next)) { + return { result: { ...result, status: 'redirect' as const, detail: 'Redirect refused by scope or hop limit' } }; + } + next.hash = ''; + current = next.href; + continue; + } + let status: ExternalStatus = 'unknown'; + if (http === 404 || http === 410) status = 'missing'; + else if (http === 429 || ((http === 403) && (response.headers.get('x-ratelimit-remaining') === '0' || response.headers.has('retry-after')))) status = 'rate-limit'; + else if (http === 401 || http === 403) status = 'auth'; + else if (http >= 500 || [408, 425].includes(http)) status = 'transient'; + else if (http >= 200 && http < 300) status = 'ok'; + if (status !== 'ok') { await response.body?.cancel(); return { result: { ...result, status } }; } + const declared = Number(response.headers.get('content-length')); + if (declared > EXTERNAL_BOUNDS.responseBytes) { + await response.body?.cancel(); + return { result: { ...result, status: 'body-limit' as const, detail: 'Declared response exceeds byte limit' } }; + } + const chunks: Uint8Array[] = []; + let bytes = 0; + const reader = response.body?.getReader(); + if (reader) { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > EXTERNAL_BOUNDS.responseBytes) { + await reader.cancel(); + return { result: { ...result, status: 'body-limit' as const, detail: 'Streamed response exceeds byte limit' } }; + } + chunks.push(chunk.value); + } + } + const body = Buffer.concat(chunks); + const sha256 = createHash('sha256').update(body).digest('hex'); + const ids = response.headers.get('content-type')?.toLowerCase().includes('text/html') + ? inspectHtml(body.toString('utf8')).ids : undefined; + return { result: { ...result, status, bytes, sha256 }, ids }; + } catch (error) { + return { result: { status: controller.signal.aborted ? 'timeout' as const : 'network' as const, finalUrl: current, detail: controller.signal.aborted ? 'Request/total deadline reached' : String(error) } }; + } finally { clearTimeout(timer); } + } + } + for (const link of links) { + const url = new URL(link.url); + if (!repositoryDoc(url)) { + results.push({ ...link, status: 'unknown', anchor: 'unknown', detail: 'Outside repository-document scope; no request' }); + continue; + } + let fragment: string; + try { fragment = decodeURIComponent(url.hash.slice(1)); } catch { + results.push({ ...link, status: 'unknown', anchor: 'unknown', detail: 'Invalid fragment encoding' }); + continue; + } + url.hash = ''; + let page = cache.get(url.href); + if (!page) { page = await requestPage(url.href); cache.set(url.href, page); } + // GitHub renders some headings dynamically and prefixes some IDs. Observing + // an ID proves presence; failing to extract one never proves absence. + const present = page.ids?.has(fragment) || page.ids?.has(`user-content-${fragment}`); + results.push({ ...link, ...page.result, anchor: !fragment ? 'not-requested' : present ? 'present' : 'unknown' }); + } + return { + schema: 1, startedAt: new Date(started).toISOString(), elapsedMs: Date.now() - started, + scope: 'Unauthenticated public https://github.com/rig-c repository roots and blob documentation links from generated pages/mirrors/LLM sections; no crawl or retries. Unobserved GitHub anchors are unknown.', + bounds: EXTERNAL_BOUNDS, requests, links: results.length, + missing: results.filter((result) => result.status === 'missing').length, + results, + }; +} + +if (import.meta.main) { + const report = await checkExternalLinks(documentationLinks(siteDocuments(parseOutputArgs()))); + console.log(JSON.stringify(report, null, 2)); + if (report.missing) process.exitCode = 1; +} diff --git a/src/links.test.ts b/src/links.test.ts new file mode 100644 index 0000000..8ba122b --- /dev/null +++ b/src/links.test.ts @@ -0,0 +1,125 @@ +import { afterEach, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { htmlRel, mdRoute, PAGES, SITE_ORIGIN } from './content.ts'; +import { checkInternalLinks, decodeAttribute, inspectHtml, markdownDocument, siteDocuments } from './links.ts'; + +const temporary: string[] = []; +function fixture() { + const root = mkdtempSync(join(realpathSync(tmpdir()), 'site-links-')); + temporary.push(root); + for (const [index, page] of PAGES.entries()) { + const html = join(root, htmlRel(page.route)); + mkdirSync(join(html, '..'), { recursive: true }); + writeFileSync(html, Bun.markdown.html(`# Page ${index}\n\n## Section ${index}`, { headings: { ids: true } })); + writeFileSync(join(root, mdRoute(page.route)), `# Page ${index}\n\n[Section](#section-${index})`); + } + return root; +} +afterEach(() => { for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }); }); +const document = (html: string, route = '/docs/core/', source = 'docs/core/index.html') => ({ html, route, source }); + +// Controls fixed in issue acceptance: actual IDs are emitted by Bun or explicit markup. +test('same/cross-page, relative, query and absolute same-origin links inspect generated IDs', () => { + const root = fixture(); + const target = join(root, 'docs/core/index.html'); + writeFileSync(target, Bun.markdown.html('# Core\n\n## café & tea', { headings: { ids: true } }) + '
'); + const heading = [...inspectHtml(readFileSync(target, 'utf8')).ids].find((id) => id !== 'core' && id !== 'dialog')!; + const html = `unicode + queryrelative + absolutemirror`; + expect(checkInternalLinks(root, [document(html)])).toEqual([]); +}); + +test('numeric and named HTML URL/ID entities decode once, including non-ASCII and literal percent', () => { + const root = fixture(); + writeFileSync(join(root, 'docs/core/index.html'), '
'); + expect(checkInternalLinks(root, [document('xpercentone decode')])).toEqual([]); + expect(decodeAttribute('≂̸')).toBe('≂̸'); +}); + +test('empty # and top work, external/protocol-relative URLs are not local paths', () => { + const root = fixture(); + expect(checkInternalLinks(root, [document('toptoptopremotemail')])).toEqual([]); +}); + +test('missing anchor on existing HTML reports source and original href', () => { + const root = fixture(); + const href = '/docs/core/?x=1&y=2#planted-missing'; + const errors = checkInternalLinks(root, [document(`broken`, '/', 'index.html')]); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('[index.html]'); + expect(errors[0]).toContain(href); + expect(errors[0]).toContain('Missing anchor #planted-missing'); +}); + +test('missing file, malformed percent, moved routes and non-HTML fragment targets fail explicitly', () => { + const root = fixture(); + writeFileSync(join(root, 'data.txt'), 'no HTML anchor semantics'); + for (const href of ['/absent/#x', '#bad%zz', '/bad%zz/', '/data.txt#x', '/core/#x']) { + const errors = checkInternalLinks(root, [document(`x`)]); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]).toContain(href); + } + expect(checkInternalLinks(root, [document('x')])[0]).toContain('Unsupported base href'); +}); + +test('Markdown links use rendered inline, reference, autolink and raw HTML; code/comments are not links', () => { + const root = fixture(); + const md = `[inline](/docs/core/#section-5) [reference][target]\n\n[target]: /docs/core/index.md#section-5\n\n<${SITE_ORIGIN}/docs/core/#section-5>\n\nraw\n\n\`[code](/absent/)\`\n\n\`\`\`html\ncode\n\`\`\`\n\n`; + expect(checkInternalLinks(root, [markdownDocument('mirror.md', '/docs/core/index.md', md)])).toEqual([]); + expect(checkInternalLinks(root, [markdownDocument('mirror.md', '/docs/core/index.md', '[bad](#missing)')])[0]).toContain('[mirror.md]'); +}); + +function llms(root: string) { + const full = PAGES.map((page, i) => `================================================================================\nURL: ${SITE_ORIGIN}${page.route}\nMarkdown: ${SITE_ORIGIN}${mdRoute(page.route)}\nRepository: https://github.com/rig-c/core\n================================================================================\n\n# Page ${i}\n\n[Own section](#section-${i})\n`).join('\n'); + writeFileSync(join(root, 'llms-full.txt'), full); + writeFileSync(join(root, 'llms.txt'), `[Core](${SITE_ORIGIN}/docs/core/index.md#section-5)`); +} + +test('llms-full resolves every same-page fragment in original page context; LLM-only defect fails', () => { + const root = fixture(); + llms(root); + expect(checkInternalLinks(root, siteDocuments(root))).toEqual([]); + const fullPath = join(root, 'llms-full.txt'); + writeFileSync(fullPath, readFileSync(fullPath, 'utf8').replace('[Own section](#section-5)', '[Own section](#section-0)')); + const errors = checkInternalLinks(root, siteDocuments(root)); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('[llms-full.txt (/docs/core/)]'); + expect(errors[0]).toContain('#section-0'); +}); + +test('Markdown-only and llms-index-only missing anchors fail; missing/duplicate corpus sections refuse', () => { + const root = fixture(); + llms(root); + writeFileSync(join(root, 'docs/core/index.md'), '# Core\n[Bad](#md-only-defect)'); + writeFileSync(join(root, 'llms.txt'), '[Bad](/docs/parts/#llms-index-defect)'); + const errors = checkInternalLinks(root, siteDocuments(root)); + expect(errors).toHaveLength(2); + expect(errors.some((e) => e.includes('[docs/core/index.md]') && e.includes('#md-only-defect'))).toBe(true); + expect(errors.some((e) => e.includes('[llms.txt]') && e.includes('#llms-index-defect'))).toBe(true); + const full = readFileSync(join(root, 'llms-full.txt'), 'utf8'); + writeFileSync(join(root, 'llms-full.txt'), full + full); + expect(() => siteDocuments(root)).toThrow('Expected one section'); +}); + +test('actual CLI process is RED for planted missing anchor, then GREEN after private fixture repair', async () => { + const root = fixture(); + const path = join(root, 'docs/core/index.html'); + const good = readFileSync(path, 'utf8'); + writeFileSync(path, good + 'planted'); + async function run() { + const child = Bun.spawn([process.execPath, join(import.meta.dir, 'links.ts'), '--out', root], { stdout: 'pipe', stderr: 'pipe' }); + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + return { code, stdout, stderr }; + } + const red = await run(); + console.log(`PLANTED RED exit=${red.code}: ${red.stderr.trim()}`); + expect(red.code).toBe(1); + expect(red.stderr).toContain('[docs/core/index.html] href="#planted-missing-anchor": Missing anchor'); + writeFileSync(path, good); + const green = await run(); + console.log(`REPAIRED GREEN exit=${green.code}: ${green.stdout.trim()}`); + expect(green.code).toBe(0); +}); diff --git a/src/links.ts b/src/links.ts new file mode 100644 index 0000000..ea4e768 --- /dev/null +++ b/src/links.ts @@ -0,0 +1,117 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { extname, join, relative, resolve, sep } from 'node:path'; +import { parseOutputArgs } from './output.ts'; +import { htmlRel, mdRoute, PAGES, REDIRECTS, SITE_ORIGIN } from './content.ts'; + +export interface LinkDocument { + source: string; + route: string; + html: string; +} + +// Generated markup uses semicolon-terminated HTML entities. Let Bun's entity table +// decode those references without interpreting the rest of an attribute as Markdown. +export function decodeAttribute(value: string): string { + return value.replace(/&(?:#[xX][\da-fA-F]+|#\d+|[a-zA-Z][\da-zA-Z]+);/g, + (entity) => Bun.markdown.render(entity, { text: (text) => text })); +} + +export function inspectHtml(html: string): { ids: Set; hrefs: string[]; base: boolean } { + const ids = new Set(); + const hrefs: string[] = []; + let base = false; + new HTMLRewriter() + .on('[id]', { element: (element) => { ids.add(decodeAttribute(element.getAttribute('id')!)); } }) + .on('[href]', { element: (element) => { hrefs.push(element.getAttribute('href')!); } }) + .on('base[href]', { element: () => { base = true; } }) + .transform(html); + return { ids, hrefs, base }; +} + +export function markdownDocument(source: string, route: string, markdown: string): LinkDocument { + // Resolve inline/reference/autolinks and raw HTML through the site's own renderer. + return { source, route, html: Bun.markdown.html(markdown, { headings: { ids: true } }) }; +} + +export function siteDocuments(root: string): LinkDocument[] { + const documents: LinkDocument[] = []; + const read = (path: string) => readFileSync(join(root, path), 'utf8'); + for (const page of PAGES) { + const html = htmlRel(page.route); + const md = mdRoute(page.route).slice(1); + if (existsSync(join(root, html))) documents.push({ source: html, route: page.route, html: read(html) }); + if (existsSync(join(root, md))) documents.push(markdownDocument(md, `/${md}`, read(md))); + } + if (existsSync(join(root, 'llms.txt'))) documents.push(markdownDocument('llms.txt', '/llms.txt', read('llms.txt'))); + if (existsSync(join(root, 'llms-full.txt'))) { + const full = read('llms-full.txt'); + const marker = /^={80}\nURL: (https:\/\/rig-c\.org\/[^\n]*)\nMarkdown: [^\n]+(?:\nLatest published packages \(npm at build time\): [^\n]+)?\nRepository: [^\n]+\n={80}\n/gm; + const sections = [...full.matchAll(marker)]; + for (const page of PAGES) { + if (sections.filter((section) => section[1] === `${SITE_ORIGIN}${page.route}`).length !== 1) { + throw new Error(`[llms-full.txt] Expected one section for ${page.route}`); + } + } + if (sections.length !== PAGES.length) throw new Error('[llms-full.txt] Unexpected page sections'); + const start = sections[0]?.index ?? full.length; + documents.push(markdownDocument('llms-full.txt header', '/llms-full.txt', full.slice(0, start))); + sections.forEach((section, index) => { + const route = new URL(section[1]!).pathname; + const body = full.slice(section.index! + section[0].length, sections[index + 1]?.index ?? full.length); + documents.push(markdownDocument(`llms-full.txt (${route})`, route, body)); + }); + } + return documents; +} + +export function checkInternalLinks(root: string, documents: readonly LinkDocument[]): string[] { + const errors: string[] = []; + const anchorCache = new Map>(); + const mirrors = new Map(PAGES.map((page) => [mdRoute(page.route), htmlRel(page.route)])); + const absoluteRoot = resolve(root); + for (const document of documents) { + const markup = inspectHtml(document.html); + if (markup.base) { + errors.push(`[${document.source}] Unsupported base href in generated page`); + continue; + } + for (const original of markup.hrefs) { + const fail = (reason: string) => errors.push(`[${document.source}] href="${original}": ${reason}`); + try { + const href = decodeAttribute(original); + const url = new URL(href, `${SITE_ORIGIN}${document.route}`); + if (url.origin !== SITE_ORIGIN) continue; + const pathname = decodeURIComponent(url.pathname); + const fragment = decodeURIComponent(url.hash.slice(1)); + if (pathname in REDIRECTS) fail(`Moved route ${pathname}; use ${REDIRECTS[pathname]}`); + let target = resolve(absoluteRoot, `.${pathname}`); + const rel = relative(absoluteRoot, target); + if (rel === '..' || rel.startsWith(`..${sep}`)) { fail('Target escapes output directory'); continue; } + if (pathname.endsWith('/') || (existsSync(target) && statSync(target).isDirectory())) target = join(target, 'index.html'); + if (!existsSync(target) || !statSync(target).isFile()) { fail(`Missing target ${pathname}`); continue; } + if (!fragment) continue; // An empty # navigates to the top of an existing resource. + const mirror = mirrors.get(pathname); + if (mirror) target = join(absoluteRoot, mirror); + if (!existsSync(target)) { fail(`Missing HTML anchor target for ${pathname}`); continue; } + if (!['.html', '.htm'].includes(extname(target).toLowerCase())) { + fail(`Cannot validate fragment #${fragment} on non-HTML target ${pathname}`); + continue; + } + let ids = anchorCache.get(target); + if (!ids) { ids = inspectHtml(readFileSync(target, 'utf8')).ids; anchorCache.set(target, ids); } + if (!ids.has(fragment) && fragment.toLowerCase() !== 'top') fail(`Missing anchor #${fragment} in ${pathname}`); + } catch (error) { + fail(`Invalid local URL or target: ${String(error)}`); + } + } + } + return errors; +} + +if (import.meta.main) { + const root = parseOutputArgs(); + const errors = checkInternalLinks(root, siteDocuments(root)); + for (const error of errors) console.error(error); + console.log(`Internal links: ${errors.length} error(s)`); + if (errors.length) process.exitCode = 1; +} diff --git a/src/verify.ts b/src/verify.ts index ac15829..77e18ff 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -1,3 +1,4 @@ +import { checkInternalLinks, siteDocuments } from './links.ts'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { artifactManifest, verifyManifest } from './deployment.ts'; @@ -5,20 +6,6 @@ import { verifyCurvaturePage, verifyHeadTurnBundle } from './head-turn.ts'; import { DEFAULT_OUTPUT, outputRoot, parseOutputArgs, ROOT_DIR as SOURCE_ROOT } from './output.ts'; import { htmlRel, mdRoute, PAGES, REDIRECTS, SITE_ORIGIN } from './content.ts'; -function resolveLocalHref(href: string, ROOT_DIR: string): string | null { - const clean = href.split('#')[0]?.split('?')[0] ?? ''; - if (!clean.startsWith('/')) { - return null; - } - if (clean === '/') { - return join(ROOT_DIR, 'index.html'); - } - if (clean.endsWith('/')) { - return join(ROOT_DIR, clean.slice(1), 'index.html'); - } - return join(ROOT_DIR, clean.slice(1)); -} - export function verifySite(outDir = DEFAULT_OUTPUT): void { const ROOT_DIR = outputRoot(outDir); const errors: string[] = []; @@ -105,21 +92,6 @@ export function verifySite(outDir = DEFAULT_OUTPUT): void { } } - const hrefRegex = /href="([^"]+)"/g; - let match: RegExpExecArray | null = hrefRegex.exec(html); - while (match !== null) { - const href = match[1] ?? ''; - const targetPath = resolveLocalHref(href, ROOT_DIR); - if (targetPath && !existsSync(targetPath)) { - errors.push(`[${route}] Broken internal link href="${href}" -> ${targetPath}`); - } - const bare = href.split('#')[0] ?? ''; - if (bare in REDIRECTS) { - errors.push(`[${route}] Links to moved route ${bare}; link ${REDIRECTS[bare]} instead`); - } - match = hrefRegex.exec(html); - } - const fullUrl = `${SITE_ORIGIN}${route}`; if (!sitemap.includes(`${fullUrl}`)) { errors.push(`[${route}] Missing ${fullUrl} in sitemap.xml`); @@ -135,6 +107,12 @@ export function verifySite(outDir = DEFAULT_OUTPUT): void { } } + try { + errors.push(...checkInternalLinks(ROOT_DIR, siteDocuments(ROOT_DIR))); + } catch (error) { + errors.push(String(error)); + } + for (const [from, to] of Object.entries(REDIRECTS)) { const stubPath = join(ROOT_DIR, htmlRel(from)); if (!existsSync(stubPath)) { @@ -181,7 +159,7 @@ export function verifySite(outDir = DEFAULT_OUTPUT): void { } console.log( - `PASS: Verified ${PAGES.length} static HTML pages, ${PAGES.length} Markdown mirrors, ${Object.keys(REDIRECTS).length} redirect stubs, JSON-LD schemas, canonical tags, internal links, llms.txt, llms-full.txt, sitemap.xml, and robots.txt.`, + `PASS: Verified ${PAGES.length} static HTML pages, ${PAGES.length} Markdown mirrors, ${Object.keys(REDIRECTS).length} redirect stubs, JSON-LD schemas, canonical tags, internal paths and fragments in HTML/Markdown/LLM sections, llms.txt, llms-full.txt, sitemap.xml, and robots.txt.`, ); } From f6e49ef1bc9cfc40ad4eb5dfe7b346566ab8cfa1 Mon Sep 17 00:00:00 2001 From: firejune Date: Tue, 27 Oct 2026 12:10:10 +0900 Subject: [PATCH 2/2] fix(links): refuse a link check whose output or source documents are missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #94 (P2): check:links run before a build, or with a nonexistent --out, skipped every input and reported "Internal links: 0 error(s)" with exit 0 — a check that measured nothing printing green. siteDocuments now preflights the output directory and every regular HTML, Markdown and LLM source file; the CLI refuses by name, exit 1, naming what is missing, for the standalone command and the shared reader alike. Measured: before the fix, a nonexistent output and a deleted Markdown input both exited 0 (kept as the red control); after, the nonexistent output and each of the 26 required documents deleted one at a time refuse by name, a directory replacement refuses, and the restored fixture exits 0 with the original stdout/stderr. Targeted 21 pass / 232 assertions; full suite 68 pass / 634 assertions; build 12 pages + 8 redirects; verify 0. README states the build-first requirement. Rejected: creating missing documents silently; removing the standalone command; a wider refactor. Co-Authored-By: Naru <342599188+fj-naru[bot]@users.noreply.github.com> Co-Authored-By: Roa <342598975+fj-roa[bot]@users.noreply.github.com> --- README.md | 2 +- src/links.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/links.ts | 32 ++++++++++++++++++++++++-------- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 154376b..78632a2 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The landing HTML gives each adapter version its own escaped `` item separa ## Link checks -`bun run verify` checks links in the twelve generated pages, their Markdown mirrors, `llms.txt` and each page section of `llms-full.txt`. The same check runs alone with `bun run check:links`. It reads the actual element IDs from generated HTML, including IDs on non-heading elements; it does not guess heading slugs. Markdown is rendered by Bun to extract inline, reference, autolinks and raw HTML links. Code examples and HTML comments are not links. +`bun run verify` checks links in the twelve generated pages, their Markdown mirrors, `llms.txt` and each page section of `llms-full.txt`. The same check runs alone with `bun run check:links`. Build the output first: standalone checks refuse a nonexistent output directory or any missing expected page HTML, Markdown mirror, `llms.txt` or `llms-full.txt`, naming the missing input and exiting 1. Expected documents must be regular files. It reads the actual element IDs from generated HTML, including IDs on non-heading elements; it does not guess heading slugs. Markdown is rendered by Bun to extract inline, reference, autolinks and raw HTML links. Code examples and HTML comments are not links. Same-page, relative, cross-page and absolute same-origin links are resolved in the source page's URL context. A link to a known `index.md#fragment` uses that page's generated HTML IDs. Each `llms-full.txt` section keeps its original page context, so another page's heading cannot satisfy its same-page fragment. Attributes decode semicolon-terminated named and numeric HTML entities once; URL paths and fragments decode percent escapes once, including Unicode. Empty `#` and `#top` navigate to the top. Missing files, missing IDs, invalid URL escapes and fragments on unsupported non-HTML files fail with the source and original href. diff --git a/src/links.test.ts b/src/links.test.ts index 8ba122b..6edf365 100644 --- a/src/links.test.ts +++ b/src/links.test.ts @@ -15,6 +15,7 @@ function fixture() { writeFileSync(html, Bun.markdown.html(`# Page ${index}\n\n## Section ${index}`, { headings: { ids: true } })); writeFileSync(join(root, mdRoute(page.route)), `# Page ${index}\n\n[Section](#section-${index})`); } + llms(root); return root; } afterEach(() => { for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -123,3 +124,46 @@ test('actual CLI process is RED for planted missing anchor, then GREEN after pri console.log(`REPAIRED GREEN exit=${green.code}: ${green.stdout.trim()}`); expect(green.code).toBe(0); }); + +// These are process controls: absent inputs must never become a zero-link PASS. +test('standalone CLI refuses missing output and every expected source, and restored output stays green', async () => { + const root = fixture(); + async function run(output = root) { + const child = Bun.spawn([process.execPath, join(import.meta.dir, 'links.ts'), '--out', output], { stdout: 'pipe', stderr: 'pipe' }); + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + return { code, stdout, stderr }; + } + const good = await run(); + expect(good.code).toBe(0); + expect(good.stdout).toBe('Internal links: 0 error(s)\n'); + const absent = join(root, 'not-built'); + const noOutput = await run(absent); + expect(noOutput.code).toBe(1); + expect(noOutput.stderr).toContain(`Missing output directory: ${absent}`); + expect(noOutput.stdout).toBe(''); + console.log(`INPUT REFUSAL nonexistent exit=${noOutput.code}: ${noOutput.stderr.trim()}`); + const expected = [...PAGES.flatMap((page) => [htmlRel(page.route), mdRoute(page.route).slice(1)]), 'llms.txt', 'llms-full.txt']; + for (const path of expected) { + const absolute = join(root, path); + const original = readFileSync(absolute); + try { + rmSync(absolute); + const refusal = await run(); + expect(refusal.code).toBe(1); + expect(refusal.stderr).toContain(`Missing expected source document(s) (regular files required): ${path}.`); + expect(refusal.stdout).toBe(''); + console.log(`INPUT REFUSAL deleted=${path} exit=${refusal.code}: ${refusal.stderr.trim()}`); + if (path === 'docs/core/index.md') { + mkdirSync(absolute); + const directory = await run(); + expect(directory.code).toBe(1); + expect(directory.stderr).toContain(path); + console.log(`INPUT REFUSAL directory=${path} exit=${directory.code}: ${directory.stderr.trim()}`); + rmSync(absolute, { recursive: true }); + } + } finally { writeFileSync(absolute, original); } + } + const restored = await run(); + expect(restored).toEqual(good); + console.log(`INPUT RESTORED GREEN exit=${restored.code}: ${restored.stdout.trim()}`); +}, 30_000); diff --git a/src/links.ts b/src/links.ts index ea4e768..77e1726 100644 --- a/src/links.ts +++ b/src/links.ts @@ -34,16 +34,27 @@ export function markdownDocument(source: string, route: string, markdown: string } export function siteDocuments(root: string): LinkDocument[] { + if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) { + throw new Error(`Missing output directory: ${root}. Run build first.`); + } + const expected = [ + ...PAGES.flatMap((page) => [htmlRel(page.route), mdRoute(page.route).slice(1)]), + 'llms.txt', 'llms-full.txt', + ]; + const missing = expected.filter((path) => !statSync(join(root, path), { throwIfNoEntry: false })?.isFile()); + if (missing.length) { + throw new Error(`Missing expected source document(s) (regular files required): ${missing.join(', ')}. Run build first.`); + } const documents: LinkDocument[] = []; const read = (path: string) => readFileSync(join(root, path), 'utf8'); for (const page of PAGES) { const html = htmlRel(page.route); const md = mdRoute(page.route).slice(1); - if (existsSync(join(root, html))) documents.push({ source: html, route: page.route, html: read(html) }); - if (existsSync(join(root, md))) documents.push(markdownDocument(md, `/${md}`, read(md))); + documents.push({ source: html, route: page.route, html: read(html) }); + documents.push(markdownDocument(md, `/${md}`, read(md))); } - if (existsSync(join(root, 'llms.txt'))) documents.push(markdownDocument('llms.txt', '/llms.txt', read('llms.txt'))); - if (existsSync(join(root, 'llms-full.txt'))) { + documents.push(markdownDocument('llms.txt', '/llms.txt', read('llms.txt'))); + { const full = read('llms-full.txt'); const marker = /^={80}\nURL: (https:\/\/rig-c\.org\/[^\n]*)\nMarkdown: [^\n]+(?:\nLatest published packages \(npm at build time\): [^\n]+)?\nRepository: [^\n]+\n={80}\n/gm; const sections = [...full.matchAll(marker)]; @@ -110,8 +121,13 @@ export function checkInternalLinks(root: string, documents: readonly LinkDocumen if (import.meta.main) { const root = parseOutputArgs(); - const errors = checkInternalLinks(root, siteDocuments(root)); - for (const error of errors) console.error(error); - console.log(`Internal links: ${errors.length} error(s)`); - if (errors.length) process.exitCode = 1; + try { + const errors = checkInternalLinks(root, siteDocuments(root)); + for (const error of errors) console.error(error); + console.log(`Internal links: ${errors.length} error(s)`); + if (errors.length) process.exitCode = 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } }