From d5a618e7816d1505e880621bf6dfca0a94c21ab4 Mon Sep 17 00:00:00 2001 From: Karibhou <157208370+Karibhou@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:04:17 +0200 Subject: [PATCH] fix(docs): match internal link aliases to sidebar routes Parse category and nested folder ancestry when validating docs links. Remove the obsolete Hive intro and redirect its URL to the synced introduction. Hive-Run: hivecommons/docs#400 Hive-Plan: issue-400-route-parity Hive-Spec: issue-400#expected Signed-off-by: Karibhou <157208370+Karibhou@users.noreply.github.com> --- changelog.d/fixed-400-doc-routes.md | 1 + docs/content/hive/overview/intro.md | 241 ----------------- netlify.toml | 5 + scripts/check-internal-links-helpers.test.ts | 53 ++-- scripts/check-internal-links-helpers.ts | 139 ++++++---- scripts/check-internal-links.main.test.ts | 53 +++- scripts/check-internal-links.ts | 15 +- .../check-internal-links-helpers.test.ts | 245 +++++------------- .../netlify-redirects.consistency.test.ts | 12 +- 9 files changed, 230 insertions(+), 534 deletions(-) create mode 100644 changelog.d/fixed-400-doc-routes.md delete mode 100644 docs/content/hive/overview/intro.md diff --git a/changelog.d/fixed-400-doc-routes.md b/changelog.d/fixed-400-doc-routes.md new file mode 100644 index 0000000..ce1234e --- /dev/null +++ b/changelog.d/fixed-400-doc-routes.md @@ -0,0 +1 @@ +- Internal link checks now match sidebar routes, including nested Hive reference pages, and the obsolete Hive intro redirects to the synced introduction. diff --git a/docs/content/hive/overview/intro.md b/docs/content/hive/overview/intro.md deleted file mode 100644 index 0be6035..0000000 --- a/docs/content/hive/overview/intro.md +++ /dev/null @@ -1,241 +0,0 @@ -# hive - -**AI agent orchestration for open source projects. One container runs a team of AI agents that triage issues, write fixes, review PRs, and keep CI green — governed by queue depth and gated by maturity levels.** - -Hive is a single Go binary that enumerates GitHub issues and PRs, classifies them by complexity, and dispatches work to AI agents (Claude, Copilot, Gemini, Goose, or self-hosted inference backends) on adaptive cadences. - -Hive separates decisions into two layers: a **deterministic pipeline** of shell scripts handles filtering, classification, merge-gating, and enforcement before any LLM sees the work. Agents only handle judgment calls — reading code, reasoning about fixes, writing PRs. - -> Evaluating whether hive is safe to run against your repos and API keys? See the [Security Model](../security-model.md). - ---- - -## Quick Start (Docker Compose) - -```bash -git clone -b v4 https://github.com/hivecommons/hive.git -cd hive/v2 - -cp hive.yaml.example hive.yaml -export HIVE_GITHUB_TOKEN=ghp_... -docker compose up -d -``` - -Dashboard at `http://localhost:3001`. The default `docker-compose.yaml` uses a pre-built image; run `docker compose build` first to build from source instead, or point it at the `stable` release channel image `ghcr.io/hivecommons/hive:stable` (see [Release Channels](../release-channels.md)). - ---- - -## Deployment options - -| Option | How | -|--------|-----| -| **Docker Compose** | `docker compose up -d` in `v2/` — quickest way to evaluate | -| **Kubernetes (self-hosted)** | Manifests in `v2/deploy/k8s/` — namespace, secrets, ConfigMap from `hive.yaml`, PVC, Deployment, Service, Ingress. The default PVC is `ReadWriteOnce` with a `Recreate` strategy; use an NFS-backed `ReadWriteMany` StorageClass for zero-downtime rolling upgrades. | -| **Hosted (Hive Hub)** | [hive.hivecommons.dev](https://hive.hivecommons.dev) provisions and hosts a hive for you — OAuth-protected dashboard, public registry, cross-hive leaderboards. No cluster required. | - -### Ports - -| Port | Purpose | -|------|---------| -| 3001 | Dashboard (supports auth token) | -| 3002 | Internal API | -| 7681 | ttyd web terminal | - -### Volumes - -| Mount Path | Purpose | -|------------|---------| -| `/etc/hive/hive.yaml` | Configuration (read-only, from ConfigMap) | -| `/data` | Persistent state: metrics, beads, logs, dashboard config overlay | -| `/secrets` | GitHub App key and other secrets (read-only) | - ---- - -## Web Dashboard - -The embedded dashboard (port 3001) is the primary control surface: - -- **Live updates via SSE** — agent states, governor mode, repo counts, and beads refresh continuously -- **Getting Started dialog** — a welcome checklist that auto-checks steps as setup completes and deep-links into the relevant configuration; minimizes into the `?` button when dismissed -- **Per-agent method + model dropdowns** — each agent card shows its current backend and model as the button label; models are **live-discovered** per backend (see below) -- **Model pinning** — pin an agent's model so automatic reconciliation never changes it out from under you; an explicit switch on a pinned agent retargets the pin instead of failing -- **Test Connection** — live probe of inference gateways that reports the gateway's actual response (including auth errors) instead of masking failures behind fallback aliases -- **Real token tracking** — inference usage is captured from actual API responses per agent; totals surface on the dashboard and, for hub-registered hives, on the hub's My Hives page -- **Kick buttons** — one-click kick for any agent -- **ACMM level control** — apply a maturity level and the full agent roster that level defines is reconciled automatically -- **Web terminal** — ttyd access to agent tmux sessions - -### Model discovery - -Model lists are discovered live per backend rather than hardcoded: - -| Backend | Discovery source | -|---------|-----------------| -| litellm / vllm / llm-d | The gateway's live `/v1/models` endpoint | -| copilot | Live per-plan model list (including enterprise API host discovery) using the stored OAuth token | -| gemini | Live models API when an API key is configured | -| claude / goose | Maintained model lists (no machine-readable source) | - -Discovery is best-effort with caching and fallbacks, so dropdowns never come up empty. An agent's model is switched automatically in exactly one case: live discovery shows the currently selected model is no longer available (and the model is not pinned) — the agent moves to an available model and the dashboard shows a notice. - ---- - -## How it works - -The **governor** evaluates issue/PR queue depth across your repos on a configurable interval (default 300s) and switches between four modes — **SURGE**, **BUSY**, **QUIET**, **IDLE** — each with per-agent cadences (or `pause`). Thresholds and cadences are all set in `hive.yaml`: - -```yaml -governor: - eval_interval_s: 300 - modes: - surge: - threshold: 20 - scanner: 15m - reviewer: pause - busy: - threshold: 10 - scanner: 15m - reviewer: 1h - quiet: - threshold: 2 - scanner: 15m - reviewer: 45m - idle: - threshold: 0 - scanner: 15m - reviewer: 15m -``` - -Agents run inside tmux sessions managed by the Go binary. The container runs three processes: the `hive` binary (agent orchestration, governor loop, dashboard API, health and token tracking), a Node.js proxy (dashboard frontend with SSE), and ttyd (web terminal). - ---- - -## ACMM levels - -Hive uses an **AI Codebase Maturity Model** (ACMM) with six levels that control what agents are allowed to do: - -| Level | Name | Agents | What agents can do | -|-------|------|--------|-------------------| -| L1 | Inception (Assisted) | 2 | Interactive advisor and project inception. Advisory beads only. | -| L2 | Advisory (Instructed) | 5 | Observe and report findings as dashboard beads. No GitHub interaction. | -| L3 | Quality-Gated (Measured) | 6 | Quality agent opens issues and hold-gated PRs. Others remain advisory. | -| L4 | Security-Aware (Adaptive) | 7 | All agents file issues. Quality, sec-check, and CI open hold-gated PRs. | -| L5 | Semi-Autonomous (Semi-Automated) | 9 | All agents open hold-gated PRs. Humans batch-review and approve. | -| L6 | Fully Autonomous | 10 | Agents open PRs and auto-merge on green CI. No hold label required. | - -Each level defines per-agent **policy modes**: advisory (observe only), measured (file issues), holdgated (PRs with hold label), or full (auto-merge). Applying a level reconciles the entire agent roster that level defines — at L5 that is 9 agents, including architect and strategist. - ---- - -## Deterministic pipeline - -Hive separates work into two layers: - -- **Deterministic layer** (shell scripts + JSON + config) — handles every decision where a human would give the same answer every time. Runs before agents wake up. -- **Non-deterministic layer** (LLM agents) — receives pre-computed data and focuses on judgment calls: reading code, reasoning about fixes, writing PRs. - -The rule: **if a human would give the same answer every time, it belongs in infrastructure, not in a prompt.** - -LLMs treat "NEVER" rules as suggestions. No amount of prompt engineering reliably prevents an agent from closing a hold-labeled issue or merging an untested PR. The deterministic pipeline removes those decisions from the agent entirely — enumerators fetch and filter the canonical work list, classifiers enrich items with metadata, gates pre-check eligibility, and enforcers block forbidden operations before any agent acts. - ---- - -## Backends - -Each agent picks its backend in `hive.yaml` (`agents..backend`): - -| Backend | Type | Description | -|---------|------|-------------| -| `claude` | CLI | Anthropic's CLI — runs Claude models directly | -| `copilot` | Aggregate | GitHub Copilot — routes to Claude, GPT, Gemini, and other vendor models | -| `gemini` | CLI | Google's CLI — runs Gemini models directly | -| `goose` | Aggregate | Block's Goose — routes to any model via config (cloud or local) | -| `litellm` / `vllm` / `llm-d` | Inference | Self-hosted OpenAI-compatible gateways — the agent CLI runs in bare mode and an in-process Anthropic-to-OpenAI translator forwards requests to your gateway | - -For inference backends, the gateway API key is entered once in the dashboard (obfuscated after entry) and stored to a Kubernetes Secret and an owner-only file on the PVC — never written into `hive.yaml`. See the [Security Model](../security-model.md) for how agents are kept away from real keys. - ---- - -## Configuration - -All config lives in a single `hive.yaml` with `${ENV_VAR}` interpolation for secrets. See `v2/hive.yaml.example` for the full reference. - -```yaml -project: - org: your-org - repos: - - repo-one - - repo-two - primary_repo: repo-one - ai_author: your-bot-user - -agents: - scanner: - enabled: true - backend: claude - model: claude-sonnet-4-6 - beads_dir: /data/beads/scanner - clear_on_kick: true - -github: - token: ${HIVE_GITHUB_TOKEN} - -hub: - enabled: true - url: https://hive.hivecommons.dev -``` - -On Kubernetes the file is mounted read-only from a ConfigMap; changes made in the dashboard are persisted as an overlay on the `/data` PVC so they survive pod restarts. - -### GitHub auth - -Use a personal access token, or a GitHub App (recommended for production — permissions are scoped to the repos you install it on): - -```yaml -github: - app_id: 12345 - installation_id: 67890 - key_file: /secrets/gh-app-key.pem -``` - ---- - -## Notifications - -Configure any combination of channels in `hive.yaml`: - -```yaml -notifications: - ntfy: - server: https://ntfy.sh - topic: my-hive-alerts - # slack: - # webhook: ${SLACK_WEBHOOK_URL} - # discord: - # webhook: ${DISCORD_WEBHOOK_URL} -``` - -An optional Discord bot (`discord.bot_token`) responds to `!hive` commands. - ---- - -## Contribute to a Hive - -Community members can contribute compute to any registered hive: - -```bash -brew install just gh -git clone -b v4 https://github.com/hivecommons/hive && cd hive -just contribute-setup claude -just contribute-hive -``` - -Supported CLIs: Claude Code, GitHub Copilot, Pi, Goose, Bob. Contributors start as newcomer (rate-limited) and auto-promote based on completed tasks. Your credentials never leave your machine. - -One relay session can also subscribe to multiple hives (added by [@hanthor](https://github.com/hanthor) in [hivecommons/hive#2846](https://github.com/hivecommons/hive/pull/2846)): register with each hive, then use comma-separated `HIVE_HUB` URLs and matching comma-separated `HIVE_REGISTRATION_TOKEN` values in the same order. The relay shares one CLI/tmux session, works on one task at a time, keeps each hub connected, and rotates only when the active hub reports no task is available. - -See the [Hive Hub](https://hive.hivecommons.dev) to browse registered hives, view leaderboards, and find hives accepting contributions. - ---- - -Apache 2.0 · [Architecture](../architecture.md) · [Security Model](../security-model.md) diff --git a/netlify.toml b/netlify.toml index 260a77a..4818d28 100644 --- a/netlify.toml +++ b/netlify.toml @@ -50,6 +50,11 @@ command = "NEXT_PUBLIC_BRANCH=${BRANCH:-main} npm run build" # Redirect project root pages to their first content page +[[redirects]] + from = "/docs/hive/overview/intro" + to = "/docs/hive/overview/introduction" + status = 301 + [[redirects]] from = "/docs/hive" to = "/docs/hive/overview/introduction" diff --git a/scripts/check-internal-links-helpers.test.ts b/scripts/check-internal-links-helpers.test.ts index 12f8955..e14452f 100644 --- a/scripts/check-internal-links-helpers.test.ts +++ b/scripts/check-internal-links-helpers.test.ts @@ -149,7 +149,7 @@ describe("parseNavStructures", () => { expect(parseNavStructures(src)).toEqual([]); }); - it("extracts entries and emits one row per section plus one bare row", () => { + it("extracts one route under the entry's own section", () => { const src = ` const NAV_STRUCTURE_HIVE: X = [ { title: 'Overview', items: [ @@ -158,26 +158,16 @@ describe("parseNavStructures", () => { ] `; const entries = parseNavStructures(src); - // One per section + one bare = 2 rows - expect(entries).toHaveLength(2); - const sectionRow = entries.find(e => e.sectionSlug === "overview"); - const bareRow = entries.find(e => e.sectionSlug === ""); - expect(sectionRow).toEqual({ - navName: "HIVE", - base: "docs/hive", - sectionSlug: "overview", - slug: "getting-started", - title: "Getting Started", - file: "getting-started.md", - }); - expect(bareRow).toEqual({ - navName: "HIVE", - base: "docs/hive", - sectionSlug: "", - slug: "getting-started", - title: "Getting Started", - file: "getting-started.md", - }); + expect(entries).toEqual([ + { + navName: "HIVE", + base: "docs/hive", + sectionSlug: "overview", + slug: "getting-started", + title: "Getting Started", + file: "getting-started.md", + }, + ]); }); it("accepts both single- and double-quoted title/file pairs", () => { @@ -192,7 +182,7 @@ describe("parseNavStructures", () => { const slugs = parseNavStructures(src) .map(e => e.slug) .sort(); - expect(slugs).toEqual(["deploy", "deploy", "rollback", "rollback"]); + expect(slugs).toEqual(["deploy", "rollback"]); }); it("skips entries whose file value is an http URL or an absolute path", () => { @@ -208,7 +198,7 @@ describe("parseNavStructures", () => { const files = parseNavStructures(src).map(e => e.file); expect(files).not.toContain("https://example.com/x.md"); expect(files).not.toContain("/other/place.md"); - expect(files.filter(f => f === "real.md")).toHaveLength(2); // section + bare + expect(files.filter(f => f === "real.md")).toHaveLength(1); }); it("supports .mdx entries", () => { @@ -224,7 +214,7 @@ describe("parseNavStructures", () => { expect(entries[0].file).toBe("tutorial.mdx"); }); - it("emits an entry per section for blocks with multiple sections", () => { + it("keeps each entry in its own section", () => { const src = ` const NAV_STRUCTURE_HIVE: X = [ { title: 'Overview', items: [] }, @@ -235,8 +225,7 @@ describe("parseNavStructures", () => { `; const entries = parseNavStructures(src); const sections = entries.map(e => e.sectionSlug).sort(); - // 2 sections + 1 bare = 3 rows for the single 'deploy' entry - expect(sections).toEqual(["", "ops", "overview"]); + expect(sections).toEqual(["ops"]); }); it("honors a custom projectForNav override", () => { @@ -256,20 +245,14 @@ describe("navEntryRoute", () => { const base: NavAliasEntry = { navName: "HIVE", base: "docs/hive", - sectionSlug: "", + sectionSlug: "ops", slug: "overview", title: "Overview", file: "overview.md", }; - it("emits a section-scoped route when sectionSlug is set", () => { - expect(navEntryRoute({ ...base, sectionSlug: "ops" })).toBe( - "/docs/hive/ops/overview" - ); - }); - - it("emits a bare route when sectionSlug is empty", () => { - expect(navEntryRoute(base)).toBe("/docs/hive/overview"); + it("emits the entry under its section", () => { + expect(navEntryRoute(base)).toBe("/docs/hive/ops/overview"); }); }); diff --git a/scripts/check-internal-links-helpers.ts b/scripts/check-internal-links-helpers.ts index 5749711..3e742b6 100644 --- a/scripts/check-internal-links-helpers.ts +++ b/scripts/check-internal-links-helpers.ts @@ -5,9 +5,10 @@ * without triggering the CLI script's top-level filesystem reads (contentRoot * walk, page-map.ts parse). The main script re-imports and reuses them. * - * All helpers here are deterministic and only use path/string primitives. + * All helpers here are deterministic and have no filesystem side effects. */ import path from "node:path"; +import ts from "typescript"; /** * Convert a human-readable nav title into a URL slug. @@ -99,9 +100,7 @@ export interface NavAliasEntry { /** Base path resolved via PROJECT_FOR_NAV, e.g. 'docs/hive'. */ base: string; /** - * The section slug this entry falls under, or "" if it is a - * bare (top-level) entry. Sections are the slugified `title:` fields - * of category objects in the NAV_STRUCTURE block. + * Slugified category and folder ancestors, joined with `/`. */ sectionSlug: string; /** The slug derived from the entry's title (via slugify()). */ @@ -129,54 +128,102 @@ export interface NavAliasEntry { * * Parsing rules (must mirror buildPageMap in src/app/docs/page-map.ts): * - Each `const NAV_STRUCTURE_ ... = [ ... ]` block is one nav. - * - Category objects `{ title: 'X', items: [...] }` contribute section - * slugs; slugify(X) is used as the section prefix. - * - `{ 'Title': 'file.md' }` (or "double quoted") pairs inside a nav - * block are entries; slugify('Title') is the slug. - * - Entries whose file starts with `http` or `/` are external and - * skipped. - * - Every entry is emitted once per section slug in the block AND - * once with `sectionSlug: ""` (bare form), mirroring the two - * validRoutes.add() calls the main script makes. + * - Each category's `title` starts the path; nested array-valued items + * contribute folder slugs. + * - Each string-valued leaf contributes exactly one route. General-section + * leaves use the file basename, as buildPageMap() does. */ export function parseNavStructures( pageMapSrc: string, projectForNav: Record = PROJECT_FOR_NAV ): NavAliasEntry[] { const results: NavAliasEntry[] = []; - const navBlockRe = /const NAV_STRUCTURE_([A-Z_]+)[^=]*=\s*(\[[\s\S]*?\n\])/g; - let nav: RegExpExecArray | null; - while ((nav = navBlockRe.exec(pageMapSrc))) { - const navName = nav[1]; - const base = projectForNav[navName]; - if (!base) continue; - const block = nav[2]; - - // Category section slugs come from `title:` bareword fields on - // category objects. These are slugified and used as path prefixes. - const sections: string[] = []; - const catRe = /title:\s*['"]([^'"]+)['"]/g; - let c: RegExpExecArray | null; - while ((c = catRe.exec(block))) sections.push(slugify(c[1])); - - // Entry pairs are `'Title': 'file.md'` or `"Title": "file.md"`. - const titleFileRe = /['"]([^'"]+)['"]\s*:\s*['"]([^'"]+\.mdx?)['"]/g; - const entries: { title: string; file: string; slug: string }[] = []; - let m: RegExpExecArray | null; - while ((m = titleFileRe.exec(block))) { - const title = m[1]; - const file = m[2]; - if (file.startsWith("http") || file.startsWith("/")) continue; - entries.push({ title, file, slug: slugify(title) }); + const source = ts.createSourceFile( + "page-map.ts", + pageMapSrc, + ts.ScriptTarget.Latest, + true + ); + const property = (node: ts.ObjectLiteralExpression, name: string) => + node.properties.find( + (p): p is ts.PropertyAssignment => + ts.isPropertyAssignment(p) && + p.name.getText(source).replace(/^['"]|['"]$/g, "") === name + ); + const visitItems = ( + items: ts.ArrayLiteralExpression, + ancestors: string[], + navName: string, + base: string + ) => { + for (const item of items.elements) { + if (!ts.isObjectLiteralExpression(item)) continue; + for (const member of item.properties) { + if (!ts.isPropertyAssignment(member)) continue; + const title = ts.isStringLiteral(member.name) + ? member.name.text + : member.name.getText(source); + if (ts.isArrayLiteralExpression(member.initializer)) { + visitItems( + member.initializer, + [...ancestors, slugify(title)], + navName, + base + ); + } else if (ts.isStringLiteral(member.initializer)) { + const file = member.initializer.text; + if ( + !/\.mdx?$/.test(file) || + file.startsWith("http") || + file.startsWith("/") + ) + continue; + const isGeneral = /^(contributing|community|news)\//.test(file); + const slug = isGeneral + ? path.posix.basename(file).replace(/\.mdx?$/, "") + : slugify(title); + results.push({ + navName, + base: isGeneral ? "docs" : base, + sectionSlug: ancestors.join("/"), + slug, + title, + file, + }); + } + } } - - // Emit each entry once per section prefix and once bare, matching - // the two `validRoutes.add(...)` calls the main script makes. - for (const { title, file, slug } of entries) { - for (const sectionSlug of sections) { - results.push({ navName, base, sectionSlug, slug, title, file }); + }; + for (const statement of source.statements) { + if ( + !ts.isVariableStatement(statement) || + !(statement.declarationList.flags & ts.NodeFlags.Const) + ) + continue; + for (const declaration of statement.declarationList.declarations) { + if ( + !ts.isIdentifier(declaration.name) || + !declaration.initializer || + !ts.isArrayLiteralExpression(declaration.initializer) + ) + continue; + const match = /^NAV_STRUCTURE_([A-Z_]+)$/.exec(declaration.name.text); + const navName = match?.[1]; + const base = navName && projectForNav[navName]; + if (!navName || !base) continue; + for (const category of declaration.initializer.elements) { + if (!ts.isObjectLiteralExpression(category)) continue; + const title = property(category, "title")?.initializer; + const items = property(category, "items")?.initializer; + if ( + title && + ts.isStringLiteral(title) && + items && + ts.isArrayLiteralExpression(items) + ) { + visitItems(items, [slugify(title.text)], navName, base); + } } - results.push({ navName, base, sectionSlug: "", slug, title, file }); } } return results; @@ -190,9 +237,7 @@ export function parseNavStructures( * the route shape only has to be made in one place. */ export function navEntryRoute(e: NavAliasEntry): string { - return e.sectionSlug - ? `/${e.base}/${e.sectionSlug}/${e.slug}` - : `/${e.base}/${e.slug}`; + return `/${e.base}/${e.sectionSlug}/${e.slug}`; } /** diff --git a/scripts/check-internal-links.main.test.ts b/scripts/check-internal-links.main.test.ts index 3202387..76a0343 100644 --- a/scripts/check-internal-links.main.test.ts +++ b/scripts/check-internal-links.main.test.ts @@ -226,7 +226,7 @@ describe("check-internal-links main()", () => { expect(r.out).toMatch(/Checked 1 internal links .* against 2 routes\./); }); - it("registers nav-alias routes (sectioned and bare) for entries whose file exists", async () => { + it("registers only the real nav route when its file exists", async () => { writePageMap(` type NavItem = { [key: string]: string } | string const NAV_STRUCTURE_HIVE: Array<{ title: string; items: NavItem[] }> = [ @@ -253,18 +253,43 @@ const NAV_STRUCTURE_HIVE: Array<{ title: string; items: NavItem[] }> = [ const r = await runChecker(); - // Routes: 2 flat (hive/readme, pluk/links) + 2 aliases for the existing - // readme.md. ghost.md does not exist, so its aliases are NOT registered and - // links to them are reported broken. - expect(r.out).toMatch(/Checked 4 internal links .* against 4 routes\./); + // Two flat routes plus the one real sidebar route for readme.md. + expect(r.out).toMatch(/Checked 4 internal links .* against 3 routes\./); expect(r.exitCode).toBe(1); - expect(r.err).toContain("Found 2 broken internal link(s)"); + expect(r.err).toContain("Found 3 broken internal link(s)"); + expect(r.err).toContain("resolves to (no route): /docs/hive/introduction"); expect(r.err).toContain( "resolves to (no route): /docs/hive/overview/ghost-page" ); expect(r.err).toContain("resolves to (no route): /docs/hive/ghost-page"); }); + it("accepts a nested sidebar URL and rejects a cross-section alias", async () => { + writePageMap(` +const NAV_STRUCTURE_HIVE = [ + { title: 'Reference', items: [ + { 'Architecture Decision Records': [ + { 'ADR index': 'adr/readme.md' }, + ] }, + ] }, + { title: 'Security', items: [] }, +] +`); + writeContent("hive/adr/readme.md", "# ADRs\n"); + writeContent( + "hive/links.md", + "[real](/docs/hive/reference/architecture-decision-records/adr-index)\n" + + "[invented](/docs/hive/security/adr-index)\n" + ); + + const r = await runChecker(); + expect(r.out).toMatch(/Checked 2 internal links .* against 3 routes\./); + expect(r.err).toContain("Found 1 broken internal link(s)"); + expect(r.err).toContain( + "resolves to (no route): /docs/hive/security/adr-index" + ); + }); + it("resolves GENERAL nav entries against docs/content directly", async () => { writePageMap(` const NAV_STRUCTURE_GENERAL: Array<{ title: string; items: NavItem[] }> = [ @@ -280,7 +305,7 @@ const NAV_STRUCTURE_GENERAL: Array<{ title: string; items: NavItem[] }> = [ writeContent( "hive/readme.md", [ - "[alias](/docs/community/community-meetings)", + "[alias](/docs/community/meetings)", "[flat](/docs/community/meetings)", ].join("\n") ); @@ -288,11 +313,11 @@ const NAV_STRUCTURE_GENERAL: Array<{ title: string; items: NavItem[] }> = [ const r = await runChecker(); expect(r.exitCode).toBeNull(); - // 2 flat routes + sectioned alias + bare alias. - expect(r.out).toMatch(/Checked 2 internal links .* against 4 routes\./); + // The nav route coincides with the direct route for this general file. + expect(r.out).toMatch(/Checked 2 internal links .* against 2 routes\./); }); - it("falls back to docs/content root when a project nav file is missing under its project dir", async () => { + it("does not register a project alias for a file outside that project", async () => { writePageMap(` const NAV_STRUCTURE_PLUK: Array<{ title: string; items: NavItem[] }> = [ { title: 'Guides', items: [ { 'Shared Guide': 'shared.md' } ] } @@ -304,9 +329,11 @@ const NAV_STRUCTURE_PLUK: Array<{ title: string; items: NavItem[] }> = [ const r = await runChecker(); - expect(r.exitCode).toBeNull(); - // 2 flat routes + sectioned alias + bare alias. - expect(r.out).toMatch(/Checked 1 internal links .* against 4 routes\./); + expect(r.exitCode).toBe(1); + expect(r.out).toMatch(/Checked 1 internal links .* against 2 routes\./); + expect(r.err).toContain( + "resolves to (no route): /docs/pluk/guides/shared-guide" + ); }); it("ignores NAV_STRUCTURE blocks with no PROJECT_FOR_NAV mapping", async () => { diff --git a/scripts/check-internal-links.ts b/scripts/check-internal-links.ts index a1faa83..8894d32 100644 --- a/scripts/check-internal-links.ts +++ b/scripts/check-internal-links.ts @@ -25,7 +25,6 @@ import fs from "fs"; import path from "path"; import { pathToFileURL } from "url"; import { - slugify, normRoute, isExternalOrAnchor, ASSET_EXT, @@ -64,18 +63,16 @@ collectFlatRoutes(contentRoot); // source the site uses, WITHOUT importing it directly (page-map.ts pulls in // nextra's bundler-only `normalizePageMap` export, which does not resolve under // a plain Node/tsx run). Instead we parse the NAV_STRUCTURE_* tables out of -// page-map.ts and replicate its title->slug rule. This keeps the checker a -// zero-heavy-dependency, deterministic gate. +// page-map.ts and mirror its category/folder/title route rules. const pageMapSrc = fs.readFileSync( path.join(process.cwd(), "src", "app", "docs", "page-map.ts"), "utf8" ); -// slugify, normRoute, isExternalOrAnchor and ASSET_EXT are imported from +// normRoute, isExternalOrAnchor and ASSET_EXT are imported from // ./check-internal-links-helpers.ts (side-effect free, unit-tested). // Extract every nav-alias entry, then filter to those whose target file -// actually exists on disk, and register both the sectioned and the bare -// form as valid routes. Splitting the pure parse (parseNavStructures) +// actually exists on disk, and register its one sidebar route. Splitting the pure parse (parseNavStructures) // from this fs-touching registration keeps the parser unit-testable. for (const entry of parseNavStructures(pageMapSrc, PROJECT_FOR_NAV)) { const fileAbs = path.join( @@ -83,11 +80,7 @@ for (const entry of parseNavStructures(pageMapSrc, PROJECT_FOR_NAV)) { entry.base.replace(/^docs\/?/, ""), entry.file ); - if (!fs.existsSync(fileAbs) && entry.base !== "docs") { - // Kubestellar nav references live under docs/content directly. - const alt = path.join(contentRoot, entry.file); - if (!fs.existsSync(alt)) continue; - } + if (!fs.existsSync(fileAbs)) continue; validRoutes.add(navEntryRoute(entry)); } diff --git a/src/__tests__/check-internal-links-helpers.test.ts b/src/__tests__/check-internal-links-helpers.test.ts index 5ef2066..f20e6ca 100644 --- a/src/__tests__/check-internal-links-helpers.test.ts +++ b/src/__tests__/check-internal-links-helpers.test.ts @@ -317,206 +317,85 @@ describe("PROJECT_FOR_NAV", () => { // mass-reports valid links as broken). // ───────────────────────────────────────────────────────────────────────── describe("parseNavStructures", () => { - it("returns an empty list on empty source", () => { - expect(parseNavStructures("")).toEqual([]); + it("ignores missing and unknown nav declarations", () => { + expect(parseNavStructures("const something = 1")).toEqual([]); + expect( + parseNavStructures( + "const NAV_STRUCTURE_UNKNOWN = [{ title: 'X', items: [{ 'A': 'a.md' }] }]" + ) + ).toEqual([]); }); - it("returns an empty list when no NAV_STRUCTURE_* declarations are present", () => { - const src = "const something = { title: 'X' };\nexport default {};\n"; - expect(parseNavStructures(src)).toEqual([]); - }); - - it("skips NAV_STRUCTURE_* blocks whose name is not in projectForNav", () => { - const src = ` - const NAV_STRUCTURE_UNKNOWN = [ - { 'Intro': 'intro.md' }, -] -`; - expect(parseNavStructures(src)).toEqual([]); - }); - - it("extracts a single bare entry from a HIVE block with no sections", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'Introduction': 'readme.md' }, -] -`; - const entries = parseNavStructures(src); - // Bare-form only (no section). - expect(entries).toEqual([ - { - navName: "HIVE", - base: "docs/hive", - sectionSlug: "", - slug: "introduction", - title: "Introduction", - file: "readme.md", - }, - ]); - expect(navEntryRoute(entries[0])).toBe("/docs/hive/introduction"); - }); - - it("emits one entry per section slug PLUS one bare entry", () => { - // Mirrors the main-script rule: register the route under every - // section slug AND once with no section prefix. - const src = ` - const NAV_STRUCTURE_HIVE = [ - { title: 'Overview', items: [ { 'Introduction': 'readme.md' } ] }, -] -`; - const entries = parseNavStructures(src); - expect(entries).toHaveLength(2); - expect(new Set(entries.map(navEntryRoute))).toEqual( - new Set(["/docs/hive/overview/introduction", "/docs/hive/introduction"]) - ); - }); - - it("emits an entry for every (section × entry) combination when a block has multiple sections", () => { - // The main script over-approximates by registering an entry under - // EVERY section slug in the block, not just the one it lexically - // belongs to. That is deliberate (safe: only adds valid routes). - // Locking that behavior in with a test protects against a "fix" - // that changes it and mass-reports real links as broken. + it("emits exactly the sidebar route for each entry", () => { const src = ` const NAV_STRUCTURE_HIVE = [ - { title: 'Overview', items: [ { 'Intro': 'intro.md' } ] }, - { title: 'Guides', items: [] }, -] -`; - const routes = new Set(parseNavStructures(src).map(navEntryRoute)); - expect(routes).toEqual( + { title: 'Overview', items: [{ 'Introduction': 'readme.md' }] }, + { title: 'Security', items: [{ 'Security model': 'security-model.md' }] }, + ] + `; + expect(new Set(parseNavStructures(src).map(navEntryRoute))).toEqual( new Set([ - "/docs/hive/overview/intro", - "/docs/hive/guides/intro", - "/docs/hive/intro", + "/docs/hive/overview/introduction", + "/docs/hive/security/security-model", ]) ); }); - it("skips entries whose file value is an http(s) URL", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'External': 'https://example.com/foo.md' }, - { 'Real': 'readme.md' }, -] -`; - const files = parseNavStructures(src).map(e => e.file); - expect(files).toEqual(["readme.md"]); - }); - - it("skips entries whose file value is a root-absolute path", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'Root': '/other-project/readme.md' }, - { 'Real': 'readme.md' }, -] -`; - const files = parseNavStructures(src).map(e => e.file); - expect(files).toEqual(["readme.md"]); - }); - - it("accepts both single- and double-quoted title:file pairs", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'Single': 'a.md' }, - { "Double": "b.md" }, -] -`; - const slugs = parseNavStructures(src).map(e => e.slug); - expect(slugs).toEqual(["single", "double"]); - }); - - it("accepts .md and .mdx file extensions but nothing else", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'A': 'a.md' }, - { 'B': 'b.mdx' }, - { 'C': 'c.txt' }, - { 'D': 'd' }, -] -`; - const files = parseNavStructures(src).map(e => e.file); - expect(files).toEqual(["a.md", "b.mdx"]); - }); - - it("slugifies titles the same way slugify() does — punctuation collapses", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { title: "Whats New", items: [ { 'v1.2.3 release notes': 'v1.md' } ] }, -] -`; - const [sectioned, bare] = parseNavStructures(src); - expect(sectioned.sectionSlug).toBe("whats-new"); - expect(sectioned.slug).toBe("v1-2-3-release-notes"); - expect(bare.sectionSlug).toBe(""); - expect(navEntryRoute(sectioned)).toBe( - "/docs/hive/whats-new/v1-2-3-release-notes" - ); - }); - - it("independently parses multiple NAV_STRUCTURE_* blocks in one source", () => { + it("tracks nested folders without inventing bare or cross-section routes", () => { const src = ` const NAV_STRUCTURE_HIVE = [ - { 'Intro': 'intro.md' }, -] - const NAV_STRUCTURE_HOTSHOT = [ - { 'Home': 'home.md' }, -] -`; - const byNav = new Map(); - for (const e of parseNavStructures(src)) { - const list = byNav.get(e.navName) ?? []; - list.push(navEntryRoute(e)); - byNav.set(e.navName, list); - } - expect(byNav.get("HIVE")).toEqual(["/docs/hive/intro"]); - expect(byNav.get("HOTSHOT")).toEqual(["/docs/hotshot/home"]); + { title: 'Reference', items: [ + { 'Architecture Decision Records': [ + { 'ADR index': 'adr/readme.md' }, + ] }, + ] }, + { title: 'Overview', items: [] }, + ] + `; + expect(parseNavStructures(src).map(navEntryRoute)).toEqual([ + "/docs/hive/reference/architecture-decision-records/adr-index", + ]); }); - it("handles the GENERAL base (root docs path, no sub-slug)", () => { + it("uses file basenames for general-section entries, matching buildPageMap", () => { const src = ` const NAV_STRUCTURE_GENERAL = [ - { 'Getting Started': 'getting-started.md' }, -] -`; - const entries = parseNavStructures(src); - expect(navEntryRoute(entries[0])).toBe("/docs/getting-started"); + { title: 'Community', items: [ + { 'What is Hive Commons?': 'community/what-is-hive-commons.md' }, + ] }, + ] + `; + expect(parseNavStructures(src).map(navEntryRoute)).toEqual([ + "/docs/community/what-is-hive-commons", + ]); }); - it("supports an override of PROJECT_FOR_NAV for injection in tests", () => { - const src = ` - const NAV_STRUCTURE_CUSTOM = [ - { 'X': 'x.md' }, -] -`; - const entries = parseNavStructures(src, { CUSTOM: "docs/injected" }); - expect(entries).toHaveLength(1); - expect(entries[0].base).toBe("docs/injected"); - expect(navEntryRoute(entries[0])).toBe("/docs/injected/x"); - }); - - it("does not confuse `title:` fields inside NAV_STRUCTURE with entry pairs", () => { - // Regression guard: the entry-pair regex is quoted-key based, so - // an unquoted `title: '...'` field must not be picked up as an - // entry. If someone loosens the entry regex, we want this to fail. + it("accepts quoted titles and mdx while excluding external, absolute and non-markdown entries", () => { const src = ` const NAV_STRUCTURE_HIVE = [ - { title: 'Overview', items: [] }, -] -`; - const entries = parseNavStructures(src); - expect(entries).toEqual([]); + { title: "Guides", items: [ + { "v1.2 Guide": "guide.mdx" }, + { 'External': 'https://example.com/x.md' }, + { 'Absolute': '/elsewhere.md' }, + { 'Text': 'other.txt' }, + ] }, + ] + `; + expect(parseNavStructures(src).map(navEntryRoute)).toEqual([ + "/docs/hive/guides/v1-2-guide", + ]); }); - it("does not match a NAV_STRUCTURE reference that is not a declaration", () => { - // Only `const NAV_STRUCTURE_` declarations should be parsed — - // uses/references to the same name in other positions must be - // ignored so we don't double-count. + it("honors a custom project mapping and ignores references", () => { const src = ` - const other = NAV_STRUCTURE_HIVE; - export { NAV_STRUCTURE_HIVE }; + const NAV_STRUCTURE_CUSTOM = [ + { title: 'Overview', items: [{ 'Home': 'home.md' }] }, + ] + const other = NAV_STRUCTURE_CUSTOM `; - expect(parseNavStructures(src)).toEqual([]); + expect( + parseNavStructures(src, { CUSTOM: "docs/custom" }).map(navEntryRoute) + ).toEqual(["/docs/custom/overview/home"]); }); }); @@ -527,19 +406,13 @@ describe("navEntryRoute", () => { const base: NavAliasEntry = { navName: "HIVE", base: "docs/hive", - sectionSlug: "", + sectionSlug: "overview", slug: "intro", title: "Intro", file: "intro.md", }; - it("returns // when sectionSlug is empty", () => { - expect(navEntryRoute(base)).toBe("/docs/hive/intro"); - }); - - it("returns /// when sectionSlug is set", () => { - expect(navEntryRoute({ ...base, sectionSlug: "overview" })).toBe( - "/docs/hive/overview/intro" - ); + it("returns the route under the entry's own category", () => { + expect(navEntryRoute(base)).toBe("/docs/hive/overview/intro"); }); }); diff --git a/src/__tests__/netlify-redirects.consistency.test.ts b/src/__tests__/netlify-redirects.consistency.test.ts index 25738a1..6b7ece7 100644 --- a/src/__tests__/netlify-redirects.consistency.test.ts +++ b/src/__tests__/netlify-redirects.consistency.test.ts @@ -94,11 +94,21 @@ describe("netlify.toml redirects", () => { const stale = docsRedirects .map(r => r.from) .filter( - from => !known.has(from.replace(/^\/docs\//, "").replace(/\/$/, "")) + from => + from !== "/docs/hive/overview/intro" && + !known.has(from.replace(/^\/docs\//, "").replace(/\/$/, "")) ); expect(stale).toEqual([]); }); + it("redirects the removed Hive intro to the synced introduction", () => { + const rule = docsRedirects.find( + r => r.from === "/docs/hive/overview/intro" + ); + expect(rule?.to).toBe("/docs/hive/overview/introduction"); + expect(rule?.status).toBe("301"); + }); + it("has exactly one redirect per /docs/* source path", () => { const seen = new Map(); for (const r of docsRedirects)