diff --git a/.gitignore b/.gitignore
index 21da30a370..4263ff63dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -115,4 +115,9 @@ license.txt
# Agent planning documents (local working files).
docs/plans/
+
+# Local audit reports (e.g. site/scripts/audit-docs-paths.mjs output).
+# The file is for local inspection only.
+docs/.audit/
+
/release-action
diff --git a/site/scripts/audit-docs-paths.mjs b/site/scripts/audit-docs-paths.mjs
new file mode 100644
index 0000000000..fade7cdc2a
--- /dev/null
+++ b/site/scripts/audit-docs-paths.mjs
@@ -0,0 +1,453 @@
+// Audit script for docs-URL drift.
+//
+// Cross-references docs(...) and string-literal docs-URL references in TS/TSX
+// files against the source side of every /docs/* rule in
+// coder.com/redirects.json. Anything that matches a redirect source is stale
+// and needs to be updated to the redirect's destination.
+//
+// Usage:
+// node site/scripts/audit-docs-paths.mjs \
+// --redirects=/path/to/coder.com/redirects.json \
+// --roots=/path/to/coder/site,/path/to/coder.com/src \
+// --out=docs/.audit/redirects-audit-YYYY-MM-DD.md
+//
+// All flags are optional. Defaults assume a standard Coder dev layout under
+// /home/coder/. The script never modifies source files; it only emits the
+// report. The output file defaults to today's date so each run produces a
+// dated snapshot.
+//
+// docs/.audit/ is gitignored; the report file is a local working artifact.
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { parseArgs } from "node:util";
+
+// ---------------------------------------------------------------------------
+// Redirect indexing.
+
+export function loadRedirects(p) {
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
+}
+
+// Filter all entries to the /docs/* subset. Order matters because Next.js
+// picks the first matching rule.
+export function docsRedirects(all) {
+ if (!Array.isArray(all)) {
+ throw new TypeError(
+ `docsRedirects: expected an array of redirect rules, got ${typeof all}`,
+ );
+ }
+ return all.filter(
+ (r) => typeof r.source === "string" && r.source.startsWith("/docs/"),
+ );
+}
+
+// Match a path against a single redirect source. Returns the redirect's
+// destination with path params substituted, or null if no match.
+export function matchRedirect(refPath, redirect) {
+ const src = redirect.source;
+ const dst = redirect.destination;
+
+ if (src === refPath) return dst;
+
+ // Trailing /:path* wildcard (Next.js's "match anything below this prefix").
+ if (src.endsWith("/:path*")) {
+ const prefix = src.slice(0, -"/:path*".length);
+ if (refPath === prefix) {
+ return dst.endsWith("/:path*") ? dst.slice(0, -"/:path*".length) : dst;
+ }
+ if (refPath.startsWith(prefix + "/")) {
+ const tail = refPath.slice(prefix.length); // includes leading slash
+ if (dst.endsWith("/:path*")) {
+ return dst.slice(0, -"/:path*".length) + tail;
+ }
+ return dst;
+ }
+ }
+
+ // :slug(.*) at the end: same idea but params named "slug".
+ if (src.endsWith(":slug(.*)")) {
+ const prefix = src.slice(0, -":slug(.*)".length);
+ if (refPath.startsWith(prefix)) {
+ const tail = refPath.slice(prefix.length);
+ if (dst.endsWith(":slug")) {
+ return dst.slice(0, -":slug".length) + tail;
+ }
+ return dst;
+ }
+ }
+
+ // :version capture groups appear in the new versioned redirects and are
+ // rare elsewhere. The audit only cares whether a literal source path is
+ // stale, so paths containing @version segments would never appear as
+ // literals in TS/TSX. Skip.
+ return null;
+}
+
+export function findMatchingRedirect(refPath, redirects) {
+ for (const r of redirects) {
+ const dst = matchRedirect(refPath, r);
+ if (dst !== null) return { redirect: r, suggestedDestination: dst };
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Reference extraction.
+
+// docs("/path") | docs('/path') | docs(`/path`) with NO ${expr}.
+export const DOCS_LITERAL_RE = /\bdocs\(\s*(['"`])([^'"`)$]+)\1/g;
+
+// docs(`/path/${expr}/more`). Captures the whole literal segment between the
+// backticks so we can flag the literal prefix.
+export const DOCS_TEMPLATE_RE = /\bdocs\(\s*`([^`]*\$\{[^`]*\}[^`]*)`/g;
+
+// "https://coder.com/docs/..." wrapped in any string-literal delimiter.
+// The backreference (\1) requires the closing delimiter to match the opening
+// one, mirroring DOCS_LITERAL_RE.
+export const HARDCODED_URL_RE =
+ /(['"`])https?:\/\/(?:[a-z0-9-]+\.)?coder\.com(\/docs\/[^'"`)\s]+)\1/g;
+
+// Markdown-link form: [text](https://coder.com/docs/...) or [text](/docs/...).
+// Used inside notification bodies, doc strings, and other prose. The URL is
+// bounded by ( and ), not by string delimiters.
+export const MARKDOWN_LINK_RE =
+ /\]\(\s*(?:https?:\/\/(?:[a-z0-9-]+\.)?coder\.com)?(\/docs\/[^\s)]+)\)/g;
+
+export function stripQueryAndFragment(p) {
+ // Remove hash fragment and query string before redirect matching.
+ let out = p;
+ const hash = out.indexOf("#");
+ if (hash !== -1) out = out.slice(0, hash);
+ const query = out.indexOf("?");
+ if (query !== -1) out = out.slice(0, query);
+ return out;
+}
+
+export function literalPrefix(tmpl) {
+ // Return the literal portion before the first ${...} so we can do a
+ // partial redirect match on the static prefix.
+ const idx = tmpl.indexOf("${");
+ return idx === -1 ? tmpl : tmpl.slice(0, idx);
+}
+
+// Map a regex match's 0-based string index to a 1-based line number using
+// a precomputed array of line-start offsets.
+function buildLineIndex(content) {
+ const lineStarts = [0];
+ for (let i = 0; i < content.length; i++) {
+ if (content[i] === "\n") lineStarts.push(i + 1);
+ }
+ return lineStarts;
+}
+
+function indexToLine(idx, lineStarts) {
+ // Binary search for the largest lineStart <= idx.
+ let lo = 0;
+ let hi = lineStarts.length - 1;
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >>> 1;
+ if (lineStarts[mid] <= idx) lo = mid;
+ else hi = mid - 1;
+ }
+ return lo + 1; // 1-based
+}
+
+export function extractReferences(filePath, content) {
+ const refs = [];
+ const lineStarts = buildLineIndex(content);
+
+ const push = (m, kind, rawArg, docsPath, dynamic) => {
+ refs.push({
+ file: filePath,
+ lineNo: indexToLine(m.index, lineStarts),
+ kind,
+ rawArg,
+ docsPath,
+ dynamic,
+ });
+ };
+
+ for (const m of content.matchAll(DOCS_LITERAL_RE)) {
+ push(m, "docs-literal", m[2], "/docs" + stripQueryAndFragment(m[2]), false);
+ }
+
+ for (const m of content.matchAll(DOCS_TEMPLATE_RE)) {
+ push(
+ m,
+ "docs-template",
+ m[1],
+ "/docs" + stripQueryAndFragment(literalPrefix(m[1])),
+ true,
+ );
+ }
+
+ for (const m of content.matchAll(HARDCODED_URL_RE)) {
+ push(m, "hardcoded-url", m[2], stripQueryAndFragment(m[2]), false);
+ }
+
+ for (const m of content.matchAll(MARKDOWN_LINK_RE)) {
+ push(m, "markdown-link", m[1], stripQueryAndFragment(m[1]), false);
+ }
+
+ return refs;
+}
+
+// ---------------------------------------------------------------------------
+// File discovery.
+
+const SKIP_DIRS = new Set([
+ "node_modules",
+ "dist",
+ "build",
+ ".next",
+ ".cache",
+ "out",
+ ".audit",
+ ".style",
+ "storybook-static",
+ "__generated__",
+]);
+
+export function walk(dir, exts, results = []) {
+ let entries;
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch (e) {
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return results;
+ throw e;
+ }
+ for (const e of entries) {
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ if (SKIP_DIRS.has(e.name)) continue;
+ walk(full, exts, results);
+ } else if (e.isFile() && exts.some((ext) => e.name.endsWith(ext))) {
+ results.push(full);
+ }
+ }
+ return results;
+}
+
+// ---------------------------------------------------------------------------
+// Report rendering.
+
+export function repoForFile(file) {
+ if (file.includes("/coder/site/")) return "coder/coder/site";
+ if (file.includes("/coder.com/src/")) return "coder/coder.com/src";
+ return "unknown";
+}
+
+export function relForFile(file) {
+ // Strip absolute prefix so the report is portable.
+ return file
+ .replace(/^.*\/coder\/site\//, "site/")
+ .replace(/^.*\/coder\.com\/src\//, "src/");
+}
+
+export function escapeMd(s) {
+ return s.replace(/\|/g, "\\|");
+}
+
+export function suggestedFixForKind(kind, suggestedDestination) {
+ // Reverse the /docs prefix transformation for docs() callsites so the
+ // suggested fix is the literal string the developer should paste in.
+ if (kind === "hardcoded-url") {
+ return "https://coder.com" + suggestedDestination;
+ }
+ if (kind === "markdown-link") {
+ return suggestedDestination;
+ }
+ // docs-literal / docs-template: helper prepends /docs, so strip it.
+ return suggestedDestination.startsWith("/docs")
+ ? suggestedDestination.slice("/docs".length)
+ : suggestedDestination;
+}
+
+export function buildReport({ findings, redirectsPath, roots, startedAt }) {
+ const byRepo = Map.groupBy(findings, (f) => repoForFile(f.file));
+
+ // Stable sort: dynamic last; otherwise by file then line.
+ for (const list of byRepo.values()) {
+ list.sort((a, b) => {
+ if (a.dynamic !== b.dynamic) return a.dynamic ? 1 : -1;
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
+ return a.lineNo - b.lineNo;
+ });
+ }
+
+ const sectionRows = (list) =>
+ list
+ .map((f) => {
+ const rel = relForFile(f.file);
+ const fix = suggestedFixForKind(f.kind, f.match.suggestedDestination);
+ const fragmentNote = f.rawArg.includes("#")
+ ? " (preserve `#anchor` from current value)"
+ : "";
+ return `| \`${rel}:${f.lineNo}\` | \`${escapeMd(f.rawArg)}\` | \`${escapeMd(f.match.redirect.source)}\` -> \`${escapeMd(f.match.redirect.destination)}\` | \`${escapeMd(fix)}\`${fragmentNote} | ${f.dynamic ? "Yes" : "No"} |`;
+ })
+ .join("\n");
+
+ const totalDynamic = findings.filter((f) => f.dynamic).length;
+ const totalStatic = findings.length - totalDynamic;
+
+ const out = [
+ "# Redirects audit: TS/TSX docs-URL references",
+ "",
+ `Generated: ${startedAt.toISOString()}`,
+ "",
+ "## Method",
+ "",
+ 'This audit cross-references every static `docs("...")` call, every `docs(`...`)` template literal with a literal prefix, every hardcoded `coder.com/docs/...` URL, and every `](/docs/...)`-style Markdown link in TS/TSX files against the source side of every `/docs/*` rule in `coder/coder.com/redirects.json`. Anything that matches a redirect source is stale and needs to be updated to the destination.',
+ "",
+ `Source of truth for the redirect set: \`${redirectsPath}\` at audit time.`,
+ "",
+ "Scanned roots:",
+ "",
+ ...roots.map((r) => `* \`${r}\``),
+ "",
+ "Pattern matchers:",
+ "",
+ '* `docs("/...")` and `docs(\'/...\')` and `docs(`/...`)` (no `${}`).',
+ "* `docs(`/.../${expr}/...`)` (literal prefix only; flagged as dynamic for manual review).",
+ "* Any string literal containing `https://coder.com/docs/...` or `https://*.coder.com/docs/...`.",
+ "* Markdown-link form `](https://coder.com/docs/...)` or `](/docs/...)` inside prose, notifications, and mock data.",
+ "",
+ "Hash fragments (`#anchor`) and query strings (`?foo`) are stripped before redirect matching.",
+ "",
+ "## Summary",
+ "",
+ "| Total findings | Auto-fixable (literal) | Manual review (dynamic) |",
+ "|---|---|---|",
+ `| ${findings.length} | ${totalStatic} | ${totalDynamic} |`,
+ "",
+ ];
+
+ const repoOrder = ["coder/coder/site", "coder/coder.com/src"];
+ for (const repo of repoOrder) {
+ const list = byRepo.get(repo) ?? [];
+ out.push(`## ${repo}`);
+ out.push("");
+ if (list.length === 0) {
+ out.push("No findings.");
+ out.push("");
+ continue;
+ }
+ out.push(`${list.length} ${list.length === 1 ? "finding" : "findings"}.`);
+ out.push("");
+ out.push(
+ "| File:Line | Current path | Redirect rule | Suggested fix | Dynamic? |",
+ );
+ out.push("|---|---|---|---|---|");
+ out.push(sectionRows(list));
+ out.push("");
+ }
+
+ const unknown = byRepo.get("unknown") ?? [];
+ if (unknown.length > 0) {
+ out.push("## Unclassified findings");
+ out.push("");
+ out.push(
+ "These came from a path that did not match the known repo prefixes. Investigate.",
+ );
+ out.push("");
+ out.push(
+ "| File:Line | Current path | Redirect rule | Suggested fix | Dynamic? |",
+ );
+ out.push("|---|---|---|---|---|");
+ out.push(sectionRows(unknown));
+ out.push("");
+ }
+
+ out.push("## Notes");
+ out.push("");
+ out.push(
+ "* Dynamic findings have a `${...}` expression somewhere in the path. The suggested fix shows what the literal prefix should become; the developer must keep the dynamic suffix intact.",
+ );
+ out.push(
+ "* Findings under `docs/.audit/` are excluded by file discovery (`.audit` is in SKIP_DIRS) to avoid feedback loops on the audit itself.",
+ );
+ out.push(
+ "* Re-run with `node site/scripts/audit-docs-paths.mjs` from the repo root.",
+ );
+ out.push("");
+
+ return out.join("\n");
+}
+
+// ---------------------------------------------------------------------------
+// CLI entry point.
+
+function defaultOutForToday() {
+ const today = new Date().toISOString().slice(0, 10);
+ return `/home/coder/coder/docs/.audit/redirects-audit-${today}.md`;
+}
+
+export function runCli(argv) {
+ const { values: args } = parseArgs({
+ args: argv,
+ options: {
+ redirects: { type: "string" },
+ roots: { type: "string" },
+ out: { type: "string" },
+ },
+ });
+ const redirectsPath = args.redirects ?? "/home/coder/coder.com/redirects.json";
+ const roots = (
+ args.roots ?? "/home/coder/coder/site/src,/home/coder/coder.com/src"
+ )
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ const outPath = args.out ?? defaultOutForToday();
+
+ const startedAt = new Date();
+ console.error(`Loading redirects from ${redirectsPath}`);
+ const redirects = docsRedirects(loadRedirects(redirectsPath));
+ console.error(` ${redirects.length} /docs/* redirect rules indexed`);
+
+ const exts = [".ts", ".tsx"];
+ const allFiles = [];
+ for (const root of roots) {
+ if (!fs.existsSync(root)) {
+ console.error(` WARNING: ${root} does not exist; skipping`);
+ continue;
+ }
+ console.error(`Scanning ${root}`);
+ const found = walk(root, exts);
+ console.error(` ${found.length} files`);
+ allFiles.push(...found);
+ }
+
+ const findings = [];
+ for (const file of allFiles) {
+ const content = fs.readFileSync(file, "utf-8");
+ const refs = extractReferences(file, content);
+ for (const ref of refs) {
+ const match = findMatchingRedirect(ref.docsPath, redirects);
+ if (match) findings.push({ ...ref, match });
+ }
+ }
+
+ console.error(`Total findings: ${findings.length}`);
+
+ const report = buildReport({
+ findings,
+ redirectsPath,
+ roots,
+ startedAt,
+ });
+
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
+ fs.writeFileSync(outPath, report);
+ const totalDynamic = findings.filter((f) => f.dynamic).length;
+ const totalStatic = findings.length - totalDynamic;
+ console.error(`Wrote ${outPath}`);
+ console.error(` Static: ${totalStatic}`);
+ console.error(` Dynamic: ${totalDynamic}`);
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ runCli(process.argv.slice(2));
+}
diff --git a/site/scripts/audit-docs-paths.test.mjs b/site/scripts/audit-docs-paths.test.mjs
new file mode 100644
index 0000000000..13f9721dc2
--- /dev/null
+++ b/site/scripts/audit-docs-paths.test.mjs
@@ -0,0 +1,715 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ DOCS_LITERAL_RE,
+ DOCS_TEMPLATE_RE,
+ HARDCODED_URL_RE,
+ MARKDOWN_LINK_RE,
+ buildReport,
+ docsRedirects,
+ escapeMd,
+ extractReferences,
+ findMatchingRedirect,
+ literalPrefix,
+ matchRedirect,
+ relForFile,
+ repoForFile,
+ runCli,
+ stripQueryAndFragment,
+ suggestedFixForKind,
+ walk,
+} from "./audit-docs-paths.mjs";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const rule = (source, destination) => ({ source, destination, permanent: true });
+
+describe("docsRedirects", () => {
+ it("keeps only /docs/* sources", () => {
+ const all = [
+ rule("/docs/a", "/docs/b"),
+ rule("/api/old", "/api/new"),
+ rule("/docs/c/:path*", "/docs/d/:path*"),
+ { source: 42, destination: "/" }, // malformed; must be skipped
+ ];
+ expect(docsRedirects(all)).toEqual([
+ rule("/docs/a", "/docs/b"),
+ rule("/docs/c/:path*", "/docs/d/:path*"),
+ ]);
+ });
+
+ it("throws a TypeError when given a non-array (e.g. object)", () => {
+ expect(() => docsRedirects({})).toThrowError(TypeError);
+ expect(() => docsRedirects({})).toThrowError(/got object/);
+ });
+
+ it("throws a TypeError when given a non-array (e.g. string)", () => {
+ expect(() => docsRedirects("hello")).toThrowError(TypeError);
+ expect(() => docsRedirects("hello")).toThrowError(/got string/);
+ });
+
+ it("throws a TypeError when given null", () => {
+ expect(() => docsRedirects(null)).toThrowError(TypeError);
+ });
+});
+
+describe("matchRedirect", () => {
+ it("returns destination on an exact match", () => {
+ expect(
+ matchRedirect("/docs/admin/rbac", rule("/docs/admin/rbac", "/docs/x")),
+ ).toBe("/docs/x");
+ });
+
+ it("returns null when nothing matches", () => {
+ expect(matchRedirect("/docs/foo", rule("/docs/bar", "/docs/baz"))).toBe(null);
+ });
+
+ it("matches /:path* with a real subpath and substitutes the tail", () => {
+ expect(
+ matchRedirect(
+ "/docs/old/sub/page",
+ rule("/docs/old/:path*", "/docs/new/:path*"),
+ ),
+ ).toBe("/docs/new/sub/page");
+ });
+
+ it("matches /:path* with an empty path (bare prefix)", () => {
+ expect(
+ matchRedirect("/docs/old", rule("/docs/old/:path*", "/docs/new/:path*")),
+ ).toBe("/docs/new");
+ });
+
+ it("matches /:path* when the destination drops the wildcard", () => {
+ expect(
+ matchRedirect("/docs/old/x/y", rule("/docs/old/:path*", "/docs/new")),
+ ).toBe("/docs/new");
+ });
+
+ it("matches :slug(.*) and substitutes the tail", () => {
+ expect(
+ matchRedirect(
+ "/docs/platforms/kubernetes",
+ rule("/docs/platforms/:slug(.*)", "/docs/install/:slug"),
+ ),
+ ).toBe("/docs/install/kubernetes");
+ });
+
+ it("matches :slug(.*) when destination drops the slug", () => {
+ expect(
+ matchRedirect(
+ "/docs/platforms/aws",
+ rule("/docs/platforms/:slug(.*)", "/docs/install/cloud"),
+ ),
+ ).toBe("/docs/install/cloud");
+ });
+
+ it("does not partial-match a path that overshoots the prefix", () => {
+ expect(
+ matchRedirect(
+ "/docs/oldsibling",
+ rule("/docs/old/:path*", "/docs/new/:path*"),
+ ),
+ ).toBe(null);
+ });
+});
+
+describe("findMatchingRedirect", () => {
+ const redirects = [
+ rule("/docs/admin/rbac", "/docs/admin/templates/template-permissions"),
+ rule("/docs/old/:path*", "/docs/new/:path*"),
+ ];
+
+ it("returns the first matching rule and its destination", () => {
+ const got = findMatchingRedirect("/docs/admin/rbac", redirects);
+ expect(got).not.toBeNull();
+ expect(got.redirect).toBe(redirects[0]);
+ expect(got.suggestedDestination).toBe(
+ "/docs/admin/templates/template-permissions",
+ );
+ });
+
+ it("returns null when no rule matches", () => {
+ expect(findMatchingRedirect("/docs/never", redirects)).toBeNull();
+ });
+});
+
+describe("stripQueryAndFragment", () => {
+ it("removes a hash fragment", () => {
+ expect(stripQueryAndFragment("/docs/a#section")).toBe("/docs/a");
+ });
+
+ it("removes a query string", () => {
+ expect(stripQueryAndFragment("/docs/a?x=1")).toBe("/docs/a");
+ });
+
+ it("removes both when query precedes hash", () => {
+ expect(stripQueryAndFragment("/docs/a?x=1#section")).toBe("/docs/a");
+ });
+
+ it("removes both when hash precedes query", () => {
+ // Slices at the first hash; query suffix after the hash is dropped too.
+ expect(stripQueryAndFragment("/docs/a#section?x=1")).toBe("/docs/a");
+ });
+
+ it("is a no-op when neither is present", () => {
+ expect(stripQueryAndFragment("/docs/a/b")).toBe("/docs/a/b");
+ });
+});
+
+describe("literalPrefix", () => {
+ it("returns the whole string when there is no interpolation", () => {
+ expect(literalPrefix("/docs/a/b")).toBe("/docs/a/b");
+ });
+
+ it("returns the prefix up to the first ${...}", () => {
+ expect(literalPrefix("/docs/a/${slug}/b")).toBe("/docs/a/");
+ });
+
+ it("handles interpolation at position zero", () => {
+ expect(literalPrefix("${root}/docs/a")).toBe("");
+ });
+});
+
+describe("regex patterns", () => {
+ const exec = (re, content) => [...content.matchAll(re)].map((m) => [...m]);
+
+ it("DOCS_LITERAL_RE matches docs(\"...\"), docs('...'), docs(`...`)", () => {
+ const src = `docs("/a/b") docs('/c/d') docs(\`/e/f\`)`;
+ const m = exec(DOCS_LITERAL_RE, src);
+ expect(m.map((row) => row[2])).toEqual(["/a/b", "/c/d", "/e/f"]);
+ });
+
+ it("DOCS_LITERAL_RE skips template literals with interpolation", () => {
+ const src = "docs(`/a/${x}/b`)";
+ expect(exec(DOCS_LITERAL_RE, src)).toHaveLength(0);
+ });
+
+ it("DOCS_TEMPLATE_RE matches docs(`/.../${expr}/...`)", () => {
+ const src = "docs(`/a/${x}/b`)";
+ const m = exec(DOCS_TEMPLATE_RE, src);
+ expect(m).toHaveLength(1);
+ expect(m[0][1]).toBe("/a/${x}/b");
+ });
+
+ it("HARDCODED_URL_RE matches https://coder.com URLs in any quote", () => {
+ const src = `a = "https://coder.com/docs/foo"; b = 'https://coder.com/docs/bar';`;
+ const m = exec(HARDCODED_URL_RE, src);
+ expect(m.map((row) => row[2])).toEqual(["/docs/foo", "/docs/bar"]);
+ });
+
+ it("HARDCODED_URL_RE matches *.coder.com subdomains", () => {
+ const src = `"https://dev.coder.com/docs/foo"`;
+ const m = exec(HARDCODED_URL_RE, src);
+ expect(m).toHaveLength(1);
+ expect(m[0][2]).toBe("/docs/foo");
+ });
+
+ it("HARDCODED_URL_RE does NOT match markdown-link form", () => {
+ // The URL is bounded by ( and ), not by quotes. This pattern misses
+ // the URL; MARKDOWN_LINK_RE catches it.
+ const src = `[label](https://coder.com/docs/foo)`;
+ expect(exec(HARDCODED_URL_RE, src)).toHaveLength(0);
+ });
+
+ it("HARDCODED_URL_RE rejects mismatched opening and closing delimiters", () => {
+ // Backreference ensures "...' or '...\" cannot match.
+ const src = `a = "https://coder.com/docs/foo'; b = 'https://coder.com/docs/bar";`;
+ expect(exec(HARDCODED_URL_RE, src)).toHaveLength(0);
+ });
+
+ it("MARKDOWN_LINK_RE matches full URLs in markdown links", () => {
+ const src = `[label](https://coder.com/docs/foo) and [x](https://dev.coder.com/docs/bar)`;
+ const m = exec(MARKDOWN_LINK_RE, src);
+ expect(m.map((row) => row[1])).toEqual(["/docs/foo", "/docs/bar"]);
+ });
+
+ it("MARKDOWN_LINK_RE matches relative /docs/... links too", () => {
+ const src = `See [the docs](/docs/admin/rbac) for details.`;
+ const m = exec(MARKDOWN_LINK_RE, src);
+ expect(m.map((row) => row[1])).toEqual(["/docs/admin/rbac"]);
+ });
+
+ it("MARKDOWN_LINK_RE ignores trailing punctuation outside the parentheses", () => {
+ const src = `See [the docs](/docs/x).`;
+ const m = exec(MARKDOWN_LINK_RE, src);
+ expect(m[0][1]).toBe("/docs/x");
+ });
+});
+
+describe("extractReferences", () => {
+ it("captures all four kinds and sets line numbers (1-based)", () => {
+ const content = [
+ 'docs("/admin/rbac");', // line 1
+ "const x = `${base}/docs/admin/groups`;", // line 2: no match
+ 'const y = "https://coder.com/docs/admin/quotas";', // line 3
+ "// see [the docs](/docs/admin/audit-logs)", // line 4
+ "docs(`/templates/${slug}/edit`);", // line 5
+ ].join("\n");
+ const refs = extractReferences("/tmp/foo.ts", content);
+ const byKind = (k) => refs.filter((r) => r.kind === k);
+
+ expect(byKind("docs-literal")).toEqual([
+ expect.objectContaining({
+ lineNo: 1,
+ rawArg: "/admin/rbac",
+ docsPath: "/docs/admin/rbac",
+ dynamic: false,
+ }),
+ ]);
+
+ expect(byKind("hardcoded-url")).toEqual([
+ expect.objectContaining({
+ lineNo: 3,
+ rawArg: "/docs/admin/quotas",
+ docsPath: "/docs/admin/quotas",
+ dynamic: false,
+ }),
+ ]);
+
+ expect(byKind("markdown-link")).toEqual([
+ expect.objectContaining({
+ lineNo: 4,
+ rawArg: "/docs/admin/audit-logs",
+ docsPath: "/docs/admin/audit-logs",
+ dynamic: false,
+ }),
+ ]);
+
+ expect(byKind("docs-template")).toEqual([
+ expect.objectContaining({
+ lineNo: 5,
+ docsPath: "/docs/templates/",
+ dynamic: true,
+ }),
+ ]);
+ });
+
+ it("strips fragments from the redirect-matching docsPath but preserves rawArg", () => {
+ const content = `docs("/admin/rbac#perms");`;
+ const refs = extractReferences("/tmp/foo.ts", content);
+ expect(refs[0].rawArg).toBe("/admin/rbac#perms");
+ expect(refs[0].docsPath).toBe("/docs/admin/rbac");
+ });
+
+ it("returns an empty array when content has no docs refs", () => {
+ expect(extractReferences("/tmp/foo.ts", "const x = 1;")).toEqual([]);
+ });
+
+ it("handles a multi-line docs() invocation", () => {
+ const content = [
+ "link={docs(",
+ ' "/admin/rbac",',
+ ")}",
+ ].join("\n");
+ const refs = extractReferences("/tmp/foo.tsx", content);
+ expect(refs).toHaveLength(1);
+ expect(refs[0].kind).toBe("docs-literal");
+ expect(refs[0].docsPath).toBe("/docs/admin/rbac");
+ // The match starts on the line with "docs(", which is line 1.
+ expect(refs[0].lineNo).toBe(1);
+ });
+});
+
+describe("repoForFile / relForFile", () => {
+ it("classifies coder/coder/site/ files", () => {
+ expect(repoForFile("/home/coder/coder/site/src/app.tsx")).toBe(
+ "coder/coder/site",
+ );
+ });
+
+ it("classifies coder/coder.com/src/ files", () => {
+ expect(repoForFile("/home/coder/coder.com/src/foo.ts")).toBe(
+ "coder/coder.com/src",
+ );
+ });
+
+ it("returns 'unknown' for paths that do not match a known repo", () => {
+ expect(repoForFile("/var/tmp/foo.ts")).toBe("unknown");
+ });
+
+ it("rewrites coder/coder/site paths to a site/ relative form", () => {
+ expect(relForFile("/home/coder/coder/site/src/app.tsx")).toBe(
+ "site/src/app.tsx",
+ );
+ });
+
+ it("rewrites coder.com paths to an src/ relative form", () => {
+ expect(relForFile("/home/coder/coder.com/src/data/x.ts")).toBe(
+ "src/data/x.ts",
+ );
+ });
+});
+
+describe("escapeMd", () => {
+ it("escapes pipes so table rows do not break", () => {
+ expect(escapeMd("a|b|c")).toBe("a\\|b\\|c");
+ });
+
+ it("leaves other characters alone", () => {
+ expect(escapeMd("/docs/a-b.c")).toBe("/docs/a-b.c");
+ });
+});
+
+describe("suggestedFixForKind", () => {
+ it("strips /docs from docs-literal suggestions", () => {
+ expect(suggestedFixForKind("docs-literal", "/docs/admin/x")).toBe(
+ "/admin/x",
+ );
+ });
+
+ it("strips /docs from docs-template suggestions", () => {
+ expect(suggestedFixForKind("docs-template", "/docs/admin/x")).toBe(
+ "/admin/x",
+ );
+ });
+
+ it("prefixes hardcoded-url suggestions with https://coder.com", () => {
+ expect(suggestedFixForKind("hardcoded-url", "/docs/admin/x")).toBe(
+ "https://coder.com/docs/admin/x",
+ );
+ });
+
+ it("returns markdown-link suggestions verbatim", () => {
+ expect(suggestedFixForKind("markdown-link", "/docs/admin/x")).toBe(
+ "/docs/admin/x",
+ );
+ });
+});
+
+describe("buildReport", () => {
+ const finding = (file, lineNo, overrides = {}) => ({
+ file,
+ lineNo,
+ kind: "docs-literal",
+ rawArg: "/admin/rbac",
+ docsPath: "/docs/admin/rbac",
+ dynamic: false,
+ match: {
+ redirect: rule(
+ "/docs/admin/rbac",
+ "/docs/admin/templates/template-permissions",
+ ),
+ suggestedDestination: "/docs/admin/templates/template-permissions",
+ },
+ ...overrides,
+ });
+
+ const startedAt = new Date("2026-05-27T12:00:00.000Z");
+
+ it("renders an empty report with zero findings", () => {
+ const report = buildReport({
+ findings: [],
+ redirectsPath: "/redirects.json",
+ roots: ["/home/coder/coder/site/src"],
+ startedAt,
+ });
+ expect(report).toContain("# Redirects audit");
+ expect(report).toContain("| 0 | 0 | 0 |");
+ expect(report).toContain("## coder/coder/site");
+ expect(report).toContain("No findings.");
+ expect(report).toContain("/redirects.json");
+ expect(report).toContain("`/home/coder/coder/site/src`");
+ });
+
+ it("groups findings by repo, counts dynamic/static, and sorts within sections", () => {
+ const findings = [
+ // Dynamic finding under site (sorted last in section).
+ finding("/home/coder/coder/site/src/b.tsx", 5, {
+ kind: "docs-template",
+ rawArg: "/templates/${slug}/edit",
+ docsPath: "/docs/templates/",
+ dynamic: true,
+ }),
+ // Static finding under site, file b, later line.
+ finding("/home/coder/coder/site/src/b.tsx", 20),
+ // Static finding under site, file a (sorts before file b).
+ finding("/home/coder/coder/site/src/a.tsx", 10),
+ // Static finding under coder.com/src.
+ finding("/home/coder/coder.com/src/page.ts", 3),
+ ];
+ const report = buildReport({
+ findings,
+ redirectsPath: "/redirects.json",
+ roots: ["/home/coder/coder/site/src", "/home/coder/coder.com/src"],
+ startedAt,
+ });
+
+ // Summary: 4 total, 3 static, 1 dynamic.
+ expect(report).toContain("| 4 | 3 | 1 |");
+
+ // Both repo sections present, each with finding counts.
+ expect(report).toMatch(/## coder\/coder\/site\n\n3 findings\./);
+ expect(report).toMatch(/## coder\/coder\.com\/src\n\n1 finding\./);
+
+ // Within the site section, file a should appear before file b, and the
+ // dynamic finding should appear after the static ones from the same file.
+ const siteIdx = report.indexOf("## coder/coder/site");
+ const comIdx = report.indexOf("## coder/coder.com/src");
+ const siteSection = report.slice(siteIdx, comIdx);
+ const aIdx = siteSection.indexOf("site/src/a.tsx:10");
+ const bStaticIdx = siteSection.indexOf("site/src/b.tsx:20");
+ const bDynamicIdx = siteSection.indexOf("site/src/b.tsx:5");
+ expect(aIdx).toBeGreaterThan(-1);
+ expect(bStaticIdx).toBeGreaterThan(-1);
+ expect(bDynamicIdx).toBeGreaterThan(-1);
+ expect(aIdx).toBeLessThan(bStaticIdx);
+ expect(bStaticIdx).toBeLessThan(bDynamicIdx);
+
+ // Dynamic? column reflects the dynamic flag.
+ expect(siteSection).toMatch(/site\/src\/a\.tsx:10\b[^\n]*\| No \|/);
+ expect(siteSection).toMatch(/site\/src\/b\.tsx:5\b[^\n]*\| Yes \|/);
+ });
+
+ it("annotates findings whose rawArg contains a # fragment", () => {
+ const report = buildReport({
+ findings: [
+ finding("/home/coder/coder/site/src/a.tsx", 1, {
+ rawArg: "/admin/rbac#perms",
+ }),
+ ],
+ redirectsPath: "/redirects.json",
+ roots: ["/home/coder/coder/site/src"],
+ startedAt,
+ });
+ expect(report).toContain("(preserve `#anchor` from current value)");
+ });
+
+ it("emits an Unclassified findings section for paths outside known repos", () => {
+ const report = buildReport({
+ findings: [finding("/var/tmp/foo.ts", 1)],
+ redirectsPath: "/redirects.json",
+ roots: ["/var/tmp"],
+ startedAt,
+ });
+ expect(report).toContain("## Unclassified findings");
+ expect(report).toContain("/var/tmp/foo.ts:1");
+ });
+});
+
+describe("walk", () => {
+ // Builds a temp tree of the shape:
+ //