diff --git a/site/astro.config.mjs b/site/astro.config.mjs index be7f953f..c2202b5d 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -6,9 +6,15 @@ import { readFileSync } from "node:fs"; import { rehypeBaseLinks } from "./scripts/rehype-base-links.ts"; import { rehypeTableShell } from "./scripts/rehype-table-shell.ts"; -// Served from https://arbiterforge.github.io/codeArbiter/ — shared by the -// `base` option below and the rehype plugin that base-prefixes markdown links. -const BASE = "/codeArbiter"; +// Served from https://codearbiter.dev/ — shared by the `base` option below and +// the rehype plugin that base-prefixes markdown links. +// +// The value lives in ./base.mjs, the single source of truth, because the link +// auditor and several tests need it too and must never keep their own copy. +// See that file for why it is "" and not "/" on an apex domain. Re-exported +// here so importers of astro.config.mjs keep working unchanged. +export { BASE } from "./base.mjs"; +import { BASE } from "./base.mjs"; // Build the reference sidebar groups from the generator's output. `predev` and // `prebuild` run `npm run gen` first, so sidebar.json exists before this loads. @@ -34,8 +40,8 @@ try { } export default defineConfig({ - // GitHub Pages project site: served from https://arbiterforge.github.io/codeArbiter/. - // `base` also applies in local dev — the dev server serves at /codeArbiter/. + // GitHub Pages site on the apex custom domain: served from https://codearbiter.dev/. + // `base` also applies in local dev — the dev server serves at the root. // // BASE-PATH-SAFE LINK PATTERN for downstream authors: // - Starlight does NOT rewrite root-absolute markdown links through the @@ -44,18 +50,27 @@ export default defineConfig({ // - Instead, the `markdown.processor` below runs our local // `rehypeBaseLinks(BASE)` plugin (scripts/rehype-base-links.ts) over // every rendered page. It walks the HAST tree and prefixes any - // root-absolute href/src ("/overview") with BASE ("/codeArbiter/overview"), - // idempotently, so plain root-relative markdown links stay base-safe. + // root-absolute href/src ("/overview") with BASE, idempotently, so plain + // root-relative markdown links stay base-safe. On the apex domain BASE is + // "" and the prefixing is a no-op, but the plugin stays wired so the site + // survives a future move back to a subpath. // - The plugin is passed to `unified({ ... })` from `@astrojs/markdown-remark`, // NOT to the top-level `markdown.rehypePlugins` key. Astro 7.1 deprecated // `markdown.remarkPlugins` / `rehypePlugins` / `remarkRehype` ("will be // removed in a future major") — see the note on `markdown:` below. // - In Astro component href props (not markdown), still use // import.meta.env.BASE_URL: href={`${import.meta.env.BASE_URL}overview/`} - // - Never hardcode "/codeArbiter/" in href strings. That value desyncs - // when the Astro base is changed and is invisible to linting. - site: "https://arbiterforge.github.io", - base: BASE, + // - Diagram tags in .md/.mdx are the documented exception: raw HTML in + // markdown is not walked by rehypeBaseLinks, so those srcs carry the base + // literally. test/generator/diagram-href-convention.test.ts is the guard, + // and it derives the expected prefix from BASE below rather than repeating + // it — when the base moved to "" for the apex domain, 19 such attributes + // across 16 pages had to change with it, and a hardcoded guard would have + // had to be edited by hand in lockstep. + site: "https://codearbiter.dev", + // `BASE || "/"`: Astro needs a real path, while rehypeBaseLinks needs the + // empty string (see the constant above for why "/" would corrupt links). + base: BASE || "/", // Old `-2` skill URLs from before per-collection slug dedup (see generate.ts): // these six skills shared a name with a same-named command, so the combined // dedup pass pushed the skill page to a `-2` slug. Redirect the old URLs to diff --git a/site/base.mjs b/site/base.mjs new file mode 100644 index 00000000..a7ca4ed8 --- /dev/null +++ b/site/base.mjs @@ -0,0 +1,24 @@ +/** base.mjs — the site's base path, single source of truth. + * + * Imported by astro.config.mjs (which sets Astro's `base` and feeds + * rehypeBaseLinks), by scripts/link-audit/lib.ts, and by the tests that assert + * base-dependent output. It lives in its own module so none of those has to + * import the full Astro config just to learn one string. + * + * This file exists because the literal was previously copied into four places + * — astro.config.mjs, link-audit/lib.ts, and three test files — each with a + * comment saying it had to match the others. Moving the site to the + * codearbiter.dev apex domain desynced all four at once: four test failures and + * 19,940 link-audit failures from a one-line change. + * + * MUST be "" for an apex domain, never "/". rehypeBaseLinks prefixes every + * root-absolute href/src with this value, so "/" would rewrite + * "/diagrams/x.svg" into "//diagrams/x.svg" — a protocol-relative URL that + * resolves against a different host and fails silently rather than 404-ing. + * Empty string makes the prefixing a correct no-op. Consumers that need a real + * path (Astro's own `base`) use `BASE || "/"`. + * + * For a project subpath the value is "/" with no trailing slash, e.g. + * "/codeArbiter" when served from arbiterforge.github.io/codeArbiter/. + */ +export const BASE = ""; diff --git a/site/public/CNAME b/site/public/CNAME new file mode 100644 index 00000000..4beb2a09 --- /dev/null +++ b/site/public/CNAME @@ -0,0 +1 @@ +codearbiter.dev diff --git a/site/scripts/link-audit/lib.ts b/site/scripts/link-audit/lib.ts index 76914c72..ee258901 100644 --- a/site/scripts/link-audit/lib.ts +++ b/site/scripts/link-audit/lib.ts @@ -29,7 +29,13 @@ import { readdirSync, statSync, readFileSync, existsSync } from "node:fs"; import { join, posix, relative, sep } from "node:path"; -export const BASE = "/codeArbiter"; // must match astro.config.mjs `base` +// Single source of truth, shared with astro.config.mjs — never a local copy. +// This constant previously held its own literal with a "must match +// astro.config.mjs" comment, and the apex-domain move desynced it: 19,940 +// link-audit failures from a one-line base change. +// @ts-expect-error -- untyped .mjs module +import { BASE as SHARED_BASE } from "../../base.mjs"; +export const BASE: string = SHARED_BASE; /** Recursively collect every *.html file under dir. */ export function htmlFiles(dir: string): string[] { diff --git a/site/src/content/docs/codearbiter-directory.md b/site/src/content/docs/codearbiter-directory.md index 6fbd591d..a0479757 100644 --- a/site/src/content/docs/codearbiter-directory.md +++ b/site/src/content/docs/codearbiter-directory.md @@ -72,7 +72,7 @@ frontmatter are both in place, the orchestrator persona loads on every session i
Activation classification flow: a missing CONTEXT.md or missing leading frontmatter is dormant; an unclosed block is malformed and surfaces an error; a closed block containing arbiter enabled activates the persona and gates. - Gate model. A soft gate surfaces a decision bubble and waits for the user. A hard gate is a closed cross-bar that is never auto-decided. + Gate model. A soft gate surfaces a decision bubble and waits for the user. A hard gate is a closed cross-bar that is never auto-decided.
Soft gates surface and wait. Hard gates stop, and only the user can clear them.
@@ -53,7 +53,7 @@ commit. It's the only path to a commit. [`refactor`](/reference/skills/refactor/ phases, all built around proving behavioral parity through unmodified pre-existing tests.
- Commit-gate's nine phases as a left-to-right gated pipeline: permission, branch, classification, verification, behavioral proof, diff review, selective stage, message, commit. Each phase is a hard BLOCK gate. Phase 5.5, provenance auto-heal, is a conditional side-lane that only runs on drift, then rejoins before diff review. + Commit-gate's nine phases as a left-to-right gated pipeline: permission, branch, classification, verification, behavioral proof, diff review, selective stage, message, commit. Each phase is a hard BLOCK gate. Phase 5.5, provenance auto-heal, is a conditional side-lane that only runs on drift, then rejoins before diff review.
Nine phases, permission through commit. 5.5 is a conditional side-lane, not a tenth gate.
diff --git a/site/src/content/docs/concepts/jit-context-injection.md b/site/src/content/docs/concepts/jit-context-injection.md index 154b6b6c..5e467005 100644 --- a/site/src/content/docs/concepts/jit-context-injection.md +++ b/site/src/content/docs/concepts/jit-context-injection.md @@ -54,7 +54,7 @@ the read hook. The hook is implemented as `pre-read.py`, backed by `_readinjectlib.py`; see the [hooks and gates reference](/reference/hooks-gates/) for the full gate catalog.
- The four-tier file-to-knowledge map: a Read is matched against security-controls.md, accepted ADRs, approved specs, and provenance in priority order, and the highest-priority match's pointer is injected within a 150-token budget. + The four-tier file-to-knowledge map: a Read is matched against security-controls.md, accepted ADRs, approved specs, and provenance in priority order, and the highest-priority match's pointer is injected within a 150-token budget.
Four tiers, evaluated in priority order. The highest match governs; a non-governed Read injects nothing.
diff --git a/site/src/content/docs/concepts/provenance-drift.md b/site/src/content/docs/concepts/provenance-drift.md index 7122e1f6..a436c0c8 100644 --- a/site/src/content/docs/concepts/provenance-drift.md +++ b/site/src/content/docs/concepts/provenance-drift.md @@ -32,7 +32,7 @@ Re-baselining is an evidence claim, not a way to silence the warning. If you can text remains correct, re-scout or defer.
- Context-drift provenance flow: a tracked source change is detected by a git hash-object mismatch, surfaced at SessionStart, and healed by commit-gate which re-baselines or proposes a doc update with the work commit. + Context-drift provenance flow: a tracked source change is detected by a git hash-object mismatch, surfaced at SessionStart, and healed by commit-gate which re-baselines or proposes a doc update with the work commit.
Provenance tracks source hashes. A mismatch is detected at SessionStart and healed when the commit lands.
diff --git a/site/src/content/docs/enforcement.md b/site/src/content/docs/enforcement.md index 2901e2c0..60a550d9 100644 --- a/site/src/content/docs/enforcement.md +++ b/site/src/content/docs/enforcement.md @@ -56,7 +56,7 @@ codeArbiter is dormant until a repository opts in. Every enforcement hook calls
Activation classification flow: a missing CONTEXT.md or missing leading frontmatter is dormant; an unclosed block is malformed and surfaces an error; a closed block containing arbiter enabled activates the persona and gates. ca-sandbox boundary: the untrusted repository lives in a Docker named volume inside a non-root, read-only container with dropped capabilities, no new privileges, resource limits, and network disabled by default. Host bind mounts and the Docker socket are blocked. Files leave only through host-initiated docker cp. - +
Preview is not unfinished. It is shipped work still earning the right to become the default.
@@ -29,7 +29,7 @@ That is the two-axis model. Read them together and a release is legible: SemVer whole payload, the Feature Forge governs each feature's maturity.
- Two-axis labeling model. SemVer governs the whole plugin payload; the Feature Forge governs each feature as preview (opt-in, dormant) or stable (on by default, evidence-promoted). + Two-axis labeling model. SemVer governs the whole plugin payload; the Feature Forge governs each feature as preview (opt-in, dormant) or stable (on by default, evidence-promoted).
Two axes: SemVer for the whole payload, the Feature Forge for per-feature maturity.
diff --git a/site/src/content/docs/getting-started/claude-code-and-codex.md b/site/src/content/docs/getting-started/claude-code-and-codex.md index 3d1aee60..45c2bc23 100644 --- a/site/src/content/docs/getting-started/claude-code-and-codex.md +++ b/site/src/content/docs/getting-started/claude-code-and-codex.md @@ -63,7 +63,7 @@ payloads; the live run proves Codex actually discovers, trusts, invokes, and hon hooks.
- Core fan-out: one shared source, core/pysrc plus core/surface, is deterministically generated by sync-core.py and build-surface.py into three host plugins — ca for Claude Code, ca-codex for Codex, and ca-pi for Pi. A CI byte-identity check gates every fan-out edge. + Core fan-out: one shared source, core/pysrc plus core/surface, is deterministically generated by sync-core.py and build-surface.py into three host plugins — ca for Claude Code, ca-codex for Codex, and ca-pi for Pi. A CI byte-identity check gates every fan-out edge.
One source, generated into three host plugins; a CI byte-identity check gates every fan-out edge.
diff --git a/site/src/content/docs/guides/adding-a-dependency.md b/site/src/content/docs/guides/adding-a-dependency.md index 11e3d7d1..2bf20ed6 100644 --- a/site/src/content/docs/guides/adding-a-dependency.md +++ b/site/src/content/docs/guides/adding-a-dependency.md @@ -20,7 +20,7 @@ Route every new dependency through `/ca:add-dep` before touching the package man
The /ca:add-dep lane in two rows: Commands (/ca:add-dep) and Agents (dependency-reviewer), with a connector from the command to the agent. The skills row is omitted because this lane uses no skills. The autonomous sprint lane from one combined spec and plan approval through small implementation cells, SMARTS-logged decisions, review, verification, commit, and pull request. diff --git a/site/src/content/docs/guides/feature-lane.md b/site/src/content/docs/guides/feature-lane.md index ec97ccb7..3b78d3ef 100644 --- a/site/src/content/docs/guides/feature-lane.md +++ b/site/src/content/docs/guides/feature-lane.md @@ -29,7 +29,7 @@ and the author and reviewer agents it dispatches on demand.
The /ca:feature lane in three rows: Commands (/ca:feature, /ca:commit, /ca:pr), Skills (brainstorming, writing-plans, tdd, commit-gate), and Agents (author agents, reviewer agents), with a connector weaving through them in execution order. The /ca:init lane in two rows: Commands (/ca:init, which forks to /ca:create-context for existing code or /ca:decompose for a new project) and Skills (create-context backs the first path, decompose the second). Two connectors fork from /ca:init, one to each path. The architecture decision lane from a decision prompt through numbered ADR authoring, user attribution, validation, and the decision log. diff --git a/site/src/content/docs/guides/releasing-a-version.md b/site/src/content/docs/guides/releasing-a-version.md index 0ea3d736..ff3be2a3 100644 --- a/site/src/content/docs/guides/releasing-a-version.md +++ b/site/src/content/docs/guides/releasing-a-version.md @@ -17,7 +17,7 @@ already landed on a clean release branch and its verification is green.
The release lane from selecting one plugin target through derived semantic version, changelog and manifest updates, verification, annotated tag, and explicitly authorized publication. diff --git a/site/src/content/docs/guides/the-statusline.md b/site/src/content/docs/guides/the-statusline.md index 427473ad..ac69a29a 100644 --- a/site/src/content/docs/guides/the-statusline.md +++ b/site/src/content/docs/guides/the-statusline.md @@ -22,7 +22,7 @@ dependency-free (native font glyphs only, no Nerd Font) and renders on every ses
The codeArbiter statusline: a folder row; a git row with repo, branch, and a model pill; rate-limit percentages with reset countdowns; an arbiter row showing stage, tasks, open questions, and overrides; and session/today token, cost, burn, and context-usage segments. Activation classification flow: a missing CONTEXT.md or missing leading frontmatter is dormant; an unclosed block is malformed and surfaces an error; a closed block containing arbiter enabled activates the persona and gates. - Lane flow: a command invocation routes to the owning skill, clears its gate, then ships to version control. + Lane flow: a command invocation routes to the owning skill, clears its gate, then ships to version control.
One lane, five steps: command, route, gate, ship to a PR.
diff --git a/site/test/astro-config.test.ts b/site/test/astro-config.test.ts index ca023022..4777c238 100644 --- a/site/test/astro-config.test.ts +++ b/site/test/astro-config.test.ts @@ -20,7 +20,8 @@ import { isUnifiedProcessor } from "@astrojs/markdown-remark"; // @ts-expect-error -- untyped .mjs config module import config from "../astro.config.mjs"; -const BASE = "/codeArbiter"; +// @ts-expect-error -- untyped .mjs config module +import { BASE } from "../astro.config.mjs"; describe("astro.config markdown wiring", () => { it("configures no markdown plugins through the deprecated top-level keys", () => { @@ -41,7 +42,14 @@ describe("astro.config markdown wiring", () => { const { code } = await renderer.render("[Overview](/overview)"); expect(code).toContain(`href="${BASE}/overview"`); - expect(code).not.toContain(`href="/overview"`); + + // Only meaningful when the site is served from a subpath. On the apex + + // domain BASE is "" and the prefixing is a correct no-op, so asserting the + + // bare form is absent would assert the opposite of the intended behaviour. + + if (BASE !== "") expect(code).not.toContain(`href="/overview"`); }); it("leaves an external link untouched through the configured processor", async () => { diff --git a/site/test/content/documentation-presentation.test.ts b/site/test/content/documentation-presentation.test.ts index 1d871c79..ae3d33b1 100644 --- a/site/test/content/documentation-presentation.test.ts +++ b/site/test/content/documentation-presentation.test.ts @@ -1,6 +1,8 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +// @ts-expect-error -- untyped .mjs config module +import { BASE } from "../../astro.config.mjs"; const siteRoot = process.cwd(); const docsRoot = join(siteRoot, "src", "content", "docs"); @@ -96,8 +98,8 @@ describe("documentation presentation regressions", () => { const artwork = join(siteRoot, "public", "art", "feature-forge.webp"); expect(forge).toContain('class="ca-art-banner'); - expect(forge).toContain('/codeArbiter/art/feature-forge.webp'); - expect(forge).toContain('/codeArbiter/diagrams/two-axis-model.svg'); + expect(forge).toContain(`${BASE}/art/feature-forge.webp`); + expect(forge).toContain(`${BASE}/diagrams/two-axis-model.svg`); expect(existsSync(artwork)).toBe(true); expect(themeStyles).toContain(".ca-art-banner"); }); diff --git a/site/test/generator/diagram-href-convention.test.ts b/site/test/generator/diagram-href-convention.test.ts index 8c653f41..3460a99f 100644 --- a/site/test/generator/diagram-href-convention.test.ts +++ b/site/test/generator/diagram-href-convention.test.ts @@ -5,9 +5,10 @@ * * - In .md / .mdx pages (which cannot import an Astro component or read * import.meta.env): the root-absolute, base-safe literal - * src="/codeArbiter/diagrams/.svg" - * The base /codeArbiter is already owned by astro.config.mjs, so this adds - * no coupling the config doesn't already carry. + * src="/diagrams/.svg" + * The base is owned by astro.config.mjs and imported here, so this adds no + * coupling the config doesn't already carry. On the apex domain BASE is "" + * and the sanctioned form is simply "/diagrams/.svg". * * - In .astro components: import.meta.env.BASE_URL, the base-safe form for * that context, e.g. src={`${baseUrl}/diagrams/.svg`} — the config's @@ -17,6 +18,8 @@ * "../diagrams/x.svg", a different hardcoded base) fails the guard. */ import { describe, it, expect } from "vitest"; +// @ts-expect-error -- untyped .mjs config module +import { BASE } from "../../astro.config.mjs"; import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, dirname, extname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -45,8 +48,18 @@ function isSanctioned(value: string, ext: string): boolean { // import.meta.env.BASE_URL form: `${baseUrl}/diagrams/.svg` return /^\$\{baseUrl\}\/diagrams\/[\w.-]+\.svg$/.test(value); } - // .md / .mdx: the root-absolute base-safe literal. - return /^\/codeArbiter\/diagrams\/[\w.-]+\.svg$/.test(value); + // .md / .mdx: the root-absolute base-safe literal, with the base derived from + // base.mjs rather than repeated here. Repeating it is what let this guard and + // the config disagree when the base moved to the apex domain. + // + // The base is matched as a plain string prefix, never interpolated into a + // RegExp. A base such as "/docs.v2" or "/v1.0" contains regex metacharacters, + // and an unescaped `.` matches any character — the guard would then accept + // "/docsXv2/diagrams/a.svg" and quietly stop guarding. Since this file exists + // to survive a base change, it must not break on one. + const prefix = `${BASE}/diagrams/`; + if (!value.startsWith(prefix)) return false; + return /^[\w.-]+\.svg$/.test(value.slice(prefix.length)); } describe("diagram convention (Task 21)", () => { diff --git a/site/test/generator/diagrams.test.ts b/site/test/generator/diagrams.test.ts index 743adc74..c4268156 100644 --- a/site/test/generator/diagrams.test.ts +++ b/site/test/generator/diagrams.test.ts @@ -11,6 +11,8 @@ * is no src/assets copy to keep in sync. */ import { describe, it, expect } from "vitest"; +// @ts-expect-error -- untyped .mjs config module +import { BASE } from "../../astro.config.mjs"; import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -104,7 +106,7 @@ describe("concept diagrams (AC-9)", () => { }); it("annotates the unchanged statusline capture with nine table keys", () => { - expect(statuslineGuide).toContain("/codeArbiter/diagrams/statusline.png"); + expect(statuslineGuide).toContain(`${BASE}/diagrams/statusline.png`); expect(statuslineGuide.match(/ca-statusline-map__marker--\d/g)).toHaveLength(9); for (let key = 1; key <= 9; key += 1) { expect(statuslineGuide).toContain(`| ${key} |`); diff --git a/site/test/link-audit/cli.test.ts b/site/test/link-audit/cli.test.ts index 93ecf1b4..727d7cbd 100644 --- a/site/test/link-audit/cli.test.ts +++ b/site/test/link-audit/cli.test.ts @@ -82,14 +82,14 @@ describe("link-audit CLI", () => { }, 60_000); it("exits zero on a minimal complete dist", () => { - const dist = makeDist({ "index.html": `icon` }); + const dist = makeDist({ "index.html": `icon` }); const { status, stdout } = runCli(dist); expect(status).toBe(0); expect(stdout).toContain("link-audit: OK"); }, 60_000); it("exits non-zero on a dangling internal link", () => { - const dist = makeDist({ "index.html": `dangling` }); + const dist = makeDist({ "index.html": `dangling` }); const { status, stderr } = runCli(dist); expect(status).toBe(1); expect(stderr).toContain("link failure"); diff --git a/site/test/link-audit/lib.test.ts b/site/test/link-audit/lib.test.ts index 82c38e98..f72abc43 100644 --- a/site/test/link-audit/lib.test.ts +++ b/site/test/link-audit/lib.test.ts @@ -8,9 +8,17 @@ import { resolveToDistFile, auditDist, missingRequiredAssets, - BASE, } from "../../scripts/link-audit/lib"; +/** A fixed subpath base for the algorithm cases below. + * + * Deliberately NOT the site's live BASE. These cases exercise prefix stripping + * and the outside-base classification, which only exist when the base is + * non-empty; binding them to the live value made all nine disappear the moment + * the site moved to an apex domain. The apex case (base "") is covered by its + * own describe block at the end of this file. */ +const TEST_BASE = "/codeArbiter"; + /** Build a throwaway dist/ tree. `assets` controls the chrome the audit pins; * `pages` is a map of dist-relative path -> file contents. */ function makeDist(options: { @@ -69,7 +77,7 @@ describe("resolveToDistFile", () => { const distRoot = "/fake/dist"; it("resolves a base-prefixed root-absolute target to a dist file", () => { - const result = resolveToDistFile("/codeArbiter/overview/", "/codeArbiter/x", distRoot, BASE); + const result = resolveToDistFile("/codeArbiter/overview/", "/codeArbiter/x", distRoot, TEST_BASE); expect(result).toEqual({ kind: "resolved", distFile: join(distRoot, "overview", "index.html"), @@ -77,7 +85,7 @@ describe("resolveToDistFile", () => { }); it("classifies a base-less root-absolute target as outside-base (regression: previously silently skipped)", () => { - const result = resolveToDistFile("/overview/", "/codeArbiter/x", distRoot, BASE); + const result = resolveToDistFile("/overview/", "/codeArbiter/x", distRoot, TEST_BASE); expect(result).toEqual({ kind: "outside-base", normalizedPath: "/overview/" }); }); @@ -86,7 +94,7 @@ describe("resolveToDistFile", () => { "../concepts/", "/codeArbiter/guides/troubleshooting", distRoot, - BASE, + TEST_BASE, ); expect(result).toEqual({ kind: "resolved", @@ -95,12 +103,12 @@ describe("resolveToDistFile", () => { }); it("classifies a page-relative target that normalizes outside the base as outside-base", () => { - const result = resolveToDistFile("../../overview/", "/codeArbiter/x", distRoot, BASE); + const result = resolveToDistFile("../../overview/", "/codeArbiter/x", distRoot, TEST_BASE); expect(result?.kind).toBe("outside-base"); }); it("maps an extensionless route to its directory index", () => { - const result = resolveToDistFile("/codeArbiter/overview", "/codeArbiter/x", distRoot, BASE); + const result = resolveToDistFile("/codeArbiter/overview", "/codeArbiter/x", distRoot, TEST_BASE); expect(result).toEqual({ kind: "resolved", distFile: join(distRoot, "overview", "index.html"), @@ -108,7 +116,7 @@ describe("resolveToDistFile", () => { }); it("maps a file-like target (has an extension) verbatim", () => { - const result = resolveToDistFile("/codeArbiter/favicon.svg", "/codeArbiter/x", distRoot, BASE); + const result = resolveToDistFile("/codeArbiter/favicon.svg", "/codeArbiter/x", distRoot, TEST_BASE); expect(result).toEqual({ kind: "resolved", distFile: join(distRoot, "favicon.svg"), @@ -116,7 +124,7 @@ describe("resolveToDistFile", () => { }); it("returns null for an empty target", () => { - expect(resolveToDistFile("", "/codeArbiter/x", distRoot, BASE)).toBeNull(); + expect(resolveToDistFile("", "/codeArbiter/x", distRoot, TEST_BASE)).toBeNull(); }); }); @@ -145,7 +153,7 @@ describe("auditDist", () => { }); it("resolves base-prefixed internal links and reports base-less ones and dangling ones as failures", () => { - const result = auditDist(dist, BASE); + const result = auditDist(dist, TEST_BASE); const messages = result.failures.map((f) => f.message); expect(messages.some((m) => m.includes("outside base path"))).toBe(true); @@ -173,7 +181,7 @@ describe("auditDist page-inventory invariant", () => { // "found nothing wrong" and "looked at nothing" are different outcomes. const dist = track(makeDist({ pages: {} })); - const result = auditDist(dist, BASE); + const result = auditDist(dist, TEST_BASE); expect(result.pageCount).toBe(0); expect(missingRequiredAssets(dist)).toEqual([]); @@ -184,7 +192,7 @@ describe("auditDist page-inventory invariant", () => { it("fails a dist that contains only non-HTML files", () => { const dist = track(makeDist({ pages: { "robots.txt": "User-agent: *" } })); - const result = auditDist(dist, BASE); + const result = auditDist(dist, TEST_BASE); expect(result.pageCount).toBe(0); expect(result.failures.some((f) => /zero HTML pages/i.test(f.message))).toBe(true); @@ -197,7 +205,7 @@ describe("auditDist page-inventory invariant", () => { }), ); - const result = auditDist(dist, BASE); + const result = auditDist(dist, TEST_BASE); expect(result.pageCount).toBe(1); expect(result.checked).toBe(1); @@ -263,3 +271,44 @@ describe("missingRequiredAssets", () => { }); } }); + +describe("resolveToDistFile on an apex domain (base '')", () => { + const distRoot = "/fake/dist"; + const APEX_BASE = ""; + + // The site's live configuration. With an empty base every root-absolute + // target is inside the base by definition, so the outside-base classification + // that the subpath cases above exercise cannot fire here — that is the + // behaviour change the apex move introduced, pinned rather than assumed. + it("resolves a root-absolute target with no prefix to strip", () => { + const result = resolveToDistFile("/overview/", "/x", distRoot, APEX_BASE); + expect(result).toEqual({ + kind: "resolved", + distFile: join(distRoot, "overview", "index.html"), + }); + }); + + it("treats every root-absolute target as inside the base", () => { + const result = resolveToDistFile("/anything/", "/x", distRoot, APEX_BASE); + expect(result?.kind).toBe("resolved"); + }); + + it("maps a file-like target verbatim", () => { + const result = resolveToDistFile("/diagrams/x.svg", "/x", distRoot, APEX_BASE); + expect(result).toEqual({ kind: "resolved", distFile: join(distRoot, "diagrams", "x.svg") }); + }); + + it("cannot classify anything as outside-base, so escapes fall to the missing-file check", () => { + // posix.normalize clamps at the root, so "../../../etc/passwd" from "/x" + // becomes "/etc/passwd" — which is inside a base of "". The outside-base + // classification is therefore INERT on an apex domain; what still catches a + // bad target is auditDist's dangling-file check, not this guard. Pinned so + // the next reader does not assume a protection that is no longer load-bearing. + // "passwd" is extensionless, so it maps to a directory index like any route. + const result = resolveToDistFile("../../../etc/passwd", "/x", distRoot, APEX_BASE); + expect(result).toEqual({ + kind: "resolved", + distFile: join(distRoot, "etc", "passwd", "index.html"), + }); + }); +});