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)