From 5c082dc19da779b8c508d2eefbbfe6af917f1e0b Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sat, 1 Aug 2026 11:09:22 -0400 Subject: [PATCH 1/3] chore(site): serve the docs from the codearbiter.dev apex domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points the Astro build at the purchased apex domain and ships the GitHub Pages CNAME in the built artifact, so the site resolves at https://codearbiter.dev instead of the arbiterforge.github.io/codeArbiter project path. DNS is not yet cut over; the old URL keeps serving and GitHub redirects it once the custom domain is live. BASE is "" rather than "/". rehypeBaseLinks prefixes root-absolute href/src values with BASE, and "/" would turn "/diagrams/x.svg" into "//diagrams/x.svg" — a protocol-relative URL resolving against another host, failing silently rather than 404-ing. Empty string makes the plugin a correct no-op. Astro still needs a real path, so it takes `BASE || "/"`. The 19 diagram attributes across 16 pages were not defects: hardcoding the base literal is the sanctioned Task-21 convention for .md/.mdx, because raw HTML in markdown is not walked by rehypeBaseLinks. The literal simply moves with the base, so all 19 become root-absolute. BASE is now exported and the four tests that assert base-dependent output import it instead of re-declaring the literal. Three kept their own copy, which is what turned a one-line base change into four failures — the convention guard now derives its regex from the config it is supposed to be guarding. One assertion changed meaning rather than value: astro-config's "does not contain the unprefixed href" check asserts the opposite of correct behaviour when BASE is "", so it is now conditional on a non-empty base. Verified: 496/496 site tests green; the convention guard dies to a bare-relative src mutant and recovers; the build emits pages at the root with dist/CNAME present, zero surviving /codeArbiter/ asset paths in the HTML, and the sitemap on the new host. --- site/astro.config.mjs | 41 ++++++++++++++----- site/public/CNAME | 1 + .../src/content/docs/codearbiter-directory.md | 2 +- site/src/content/docs/concepts/gated-lanes.md | 4 +- .../docs/concepts/jit-context-injection.md | 2 +- .../content/docs/concepts/provenance-drift.md | 2 +- site/src/content/docs/enforcement.md | 4 +- .../content/docs/feature-forge/overview.md | 4 +- .../getting-started/claude-code-and-codex.md | 2 +- .../docs/guides/adding-a-dependency.md | 2 +- .../content/docs/guides/autonomous-sprints.md | 2 +- site/src/content/docs/guides/feature-lane.md | 2 +- site/src/content/docs/guides/opt-in-a-repo.md | 2 +- .../src/content/docs/guides/recording-adrs.md | 2 +- .../docs/guides/releasing-a-version.md | 2 +- .../src/content/docs/guides/the-statusline.md | 2 +- .../content/docs/guides/troubleshooting.md | 2 +- site/src/content/docs/overview.md | 2 +- site/test/astro-config.test.ts | 11 ++++- .../documentation-presentation.test.ts | 5 ++- .../generator/diagram-href-convention.test.ts | 14 ++++--- site/test/generator/diagrams.test.ts | 3 +- 22 files changed, 73 insertions(+), 40 deletions(-) create mode 100644 site/public/CNAME diff --git a/site/astro.config.mjs b/site/astro.config.mjs index be7f953f..90636f89 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -6,9 +6,19 @@ 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. +// +// MUST be "" for an apex domain, never "/". rehypeBaseLinks prefixes any +// root-absolute href/src with this value, and "/" would turn "/diagrams/x.svg" +// into "//diagrams/x.svg" — a protocol-relative URL pointing at another host, +// which fails silently. Empty string makes the plugin a correct no-op, because +// "/diagrams/x.svg" already starts with `${BASE}/`. Astro's own `base` still +// needs a real path, so it takes `BASE || "/"` below. +// Exported so the tests that assert base-dependent output derive it from here +// instead of re-declaring the literal. Three of them previously kept their own +// copy, which is what turned a one-line base change into a four-test failure. +export const BASE = ""; // 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 +44,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 +54,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/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/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..0f3f678b 100644 --- a/site/test/astro-config.test.ts +++ b/site/test/astro-config.test.ts @@ -20,7 +20,7 @@ import { isUnifiedProcessor } from "@astrojs/markdown-remark"; // @ts-expect-error -- untyped .mjs config module import config from "../astro.config.mjs"; -const BASE = "/codeArbiter"; +import { BASE } from "../astro.config.mjs"; describe("astro.config markdown wiring", () => { it("configures no markdown plugins through the deprecated top-level keys", () => { @@ -41,7 +41,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..c4611a23 100644 --- a/site/test/content/documentation-presentation.test.ts +++ b/site/test/content/documentation-presentation.test.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { BASE } from "../../astro.config.mjs"; const siteRoot = process.cwd(); const docsRoot = join(siteRoot, "src", "content", "docs"); @@ -96,8 +97,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..1b4f7574 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,7 @@ * "../diagrams/x.svg", a different hardcoded base) fails the guard. */ import { describe, it, expect } from "vitest"; +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 +47,10 @@ 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 + // astro.config.mjs rather than repeated here. Repeating it is what let this + // guard and the config disagree when the base moved to the apex domain. + return new RegExp(`^${BASE}/diagrams/[\\w.-]+\\.svg$`).test(value); } describe("diagram convention (Task 21)", () => { diff --git a/site/test/generator/diagrams.test.ts b/site/test/generator/diagrams.test.ts index 743adc74..fdf17d5e 100644 --- a/site/test/generator/diagrams.test.ts +++ b/site/test/generator/diagrams.test.ts @@ -11,6 +11,7 @@ * is no src/assets copy to keep in sync. */ import { describe, it, expect } from "vitest"; +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 +105,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} |`); From 994c88d815211b44e0b10769c1b091f407caab00 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sat, 1 Aug 2026 11:27:37 -0400 Subject: [PATCH 2/3] fix(site): make the base path a single source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught two failures the local run missed, both the same root cause: the base literal was copied into six places, each with a comment saying it had to match the others. The apex move desynced all six at once. - tsc (not vitest) failed on four untyped .mjs imports; `npm test` runs vitest only, so the local run was green while CI was red. - link-audit reported 19,940 failures because scripts/link-audit/lib.ts held its own "/codeArbiter" constant. site/base.mjs is now the only definition. astro.config.mjs re-exports it so existing importers are unchanged, and link-audit imports it directly instead of restating it. The resolution logic already handled an empty base correctly; only the literal was wrong. The link-audit unit tests now pin a fixed TEST_BASE rather than the site's live value. They exercise prefix stripping and the outside-base classification, which only exist when the base is non-empty — binding them to the live base made nine cases silently stop testing anything the moment the site moved to an apex domain. A new describe block covers the apex case, and it records a real behaviour change rather than asserting a comfortable one: with base "", posix.normalize clamps at the root, so every root-absolute target is inside the base and the outside-base guard is INERT. What still catches a bad target is the dangling-file check. Pinned so the next reader does not assume a protection that no longer bears load. Verified: 500/500 vitest, tsc clean, build green, link-audit resolves 20,057 links across 140 pages. --- site/astro.config.mjs | 16 ++-- site/base.mjs | 24 ++++++ site/scripts/link-audit/lib.ts | 8 +- site/test/astro-config.test.ts | 1 + .../documentation-presentation.test.ts | 1 + .../generator/diagram-href-convention.test.ts | 1 + site/test/generator/diagrams.test.ts | 1 + site/test/link-audit/cli.test.ts | 4 +- site/test/link-audit/lib.test.ts | 73 ++++++++++++++++--- 9 files changed, 104 insertions(+), 25 deletions(-) create mode 100644 site/base.mjs diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 90636f89..c2202b5d 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -9,16 +9,12 @@ import { rehypeTableShell } from "./scripts/rehype-table-shell.ts"; // Served from https://codearbiter.dev/ — shared by the `base` option below and // the rehype plugin that base-prefixes markdown links. // -// MUST be "" for an apex domain, never "/". rehypeBaseLinks prefixes any -// root-absolute href/src with this value, and "/" would turn "/diagrams/x.svg" -// into "//diagrams/x.svg" — a protocol-relative URL pointing at another host, -// which fails silently. Empty string makes the plugin a correct no-op, because -// "/diagrams/x.svg" already starts with `${BASE}/`. Astro's own `base` still -// needs a real path, so it takes `BASE || "/"` below. -// Exported so the tests that assert base-dependent output derive it from here -// instead of re-declaring the literal. Three of them previously kept their own -// copy, which is what turned a one-line base change into a four-test failure. -export const BASE = ""; +// 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. 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/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/test/astro-config.test.ts b/site/test/astro-config.test.ts index 0f3f678b..4777c238 100644 --- a/site/test/astro-config.test.ts +++ b/site/test/astro-config.test.ts @@ -20,6 +20,7 @@ import { isUnifiedProcessor } from "@astrojs/markdown-remark"; // @ts-expect-error -- untyped .mjs config module import config from "../astro.config.mjs"; +// @ts-expect-error -- untyped .mjs config module import { BASE } from "../astro.config.mjs"; describe("astro.config markdown wiring", () => { diff --git a/site/test/content/documentation-presentation.test.ts b/site/test/content/documentation-presentation.test.ts index c4611a23..ae3d33b1 100644 --- a/site/test/content/documentation-presentation.test.ts +++ b/site/test/content/documentation-presentation.test.ts @@ -1,6 +1,7 @@ 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(); diff --git a/site/test/generator/diagram-href-convention.test.ts b/site/test/generator/diagram-href-convention.test.ts index 1b4f7574..fdd3ed24 100644 --- a/site/test/generator/diagram-href-convention.test.ts +++ b/site/test/generator/diagram-href-convention.test.ts @@ -18,6 +18,7 @@ * "../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"; diff --git a/site/test/generator/diagrams.test.ts b/site/test/generator/diagrams.test.ts index fdf17d5e..c4268156 100644 --- a/site/test/generator/diagrams.test.ts +++ b/site/test/generator/diagrams.test.ts @@ -11,6 +11,7 @@ * 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"; 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"), + }); + }); +}); From f3ba08ca35aaf659e6be5acf16fc3a00edf5b2ad Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sat, 1 Aug 2026 11:36:40 -0400 Subject: [PATCH 3/3] refactor(site): match the base as a prefix, not an interpolated regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The convention guard built its matcher with `new RegExp(\`^${BASE}/diagrams/...\`)`. Interpolating a value into a regex means any metacharacter it contains changes the pattern's meaning: a base such as "/docs.v2" or "/v1.0" carries a "." that matches any character, so the guard would accept "/docsXv2/diagrams/a.svg" and quietly stop guarding. This file exists specifically to survive a base change, so it must not be the thing that breaks on one. Matching the base as a plain string prefix and validating the remainder against a literal regex removes the class of problem outright, and is simpler than escaping would be. Honest scope note: this is hardening against a defect in principle, not a demonstrated live failure. Repeated attempts to reproduce the metacharacter case in a throwaway script were confounded by shell and template-literal escaping — the harness kept degrading the pattern so that it failed the legitimate case too. The behaviour was never reproduced against the real code, whose current base is "" and therefore carries no metacharacters at all. Guard still dies to a bare-relative src mutant and recovers. 500/500 vitest green, tsc clean. --- .../test/generator/diagram-href-convention.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/site/test/generator/diagram-href-convention.test.ts b/site/test/generator/diagram-href-convention.test.ts index fdd3ed24..3460a99f 100644 --- a/site/test/generator/diagram-href-convention.test.ts +++ b/site/test/generator/diagram-href-convention.test.ts @@ -49,9 +49,17 @@ function isSanctioned(value: string, ext: string): boolean { return /^\$\{baseUrl\}\/diagrams\/[\w.-]+\.svg$/.test(value); } // .md / .mdx: the root-absolute base-safe literal, with the base derived from - // astro.config.mjs rather than repeated here. Repeating it is what let this - // guard and the config disagree when the base moved to the apex domain. - return new RegExp(`^${BASE}/diagrams/[\\w.-]+\\.svg$`).test(value); + // 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)", () => {