Files
coder/site/scripts/audit-docs-paths.mjs
T
Nick VigilanteandCoder 6c95a618b4 fix: replace stale docs() paths with redirect destinations (DOCS-253) (#25740)
## Summary

Replaces 9 stale docs paths and 2 stale doc anchors in `site/src/`, and
adds a TS/TSX audit script (`site/scripts/audit-docs-paths.mjs`) plus
unit tests that scan the codebase for paths that resolve via
`coder.com/redirects.json`.

### Redirect-target updates (9)

Each of these paths' `/docs/...` source matches a Next.js redirect rule,
so requests today produce a 302 on `coder.com`. The audit script
identifies them by cross-referencing against `redirects.json`.

- Five update product-code references to `/ai-coder/ai-bridge` (renamed
to `/ai-coder/ai-gateway` in v2.33).
- One updates a commented-out reference to
`/templates#template-filtering` in `TemplatesFilter.tsx`.
- Three update notification-template mock data in
`testHelpers/entities.ts` that pointed at the renamed
`/docs/templates/schedule`.

### Anchor-only updates (2)

These paths are still live on `coder.com` (no redirect), but their
`#fragment` no longer matches a heading on the destination page.
Fragments are evaluated client-side and never sent to the server, so the
audit script does not catch them. Found and verified manually against
the current docs.

- `AuditFilter.tsx`: `/admin/security/audit-logs#filtering-logs` →
`/admin/security/audit-logs#how-to-filter-audit-logs`. The current
heading is `## How to Filter Audit Logs` in
[`docs/admin/security/audit-logs.md`](https://github.com/coder/coder/blob/main/docs/admin/security/audit-logs.md).
- `UserAuthSettingsPageView.tsx`: drops the stale `#openid-connect`
anchor; the path itself (`/admin/users/oidc-auth`) is unchanged. The
page H1 is now `# OpenID Connect`, so the bare path lands at the same
place the anchor used to.

None are user-visible label changes; only the doc target URLs change.

## Audit script

`site/scripts/audit-docs-paths.mjs` cross-references TS/TSX docs-URL
references against `coder.com/redirects.json` and reports anything that
resolves via a redirect (which means stale source). It catches four
forms:

- `docs("/...")` / `docs('/...')` / `` docs(`/...`) ``
- `` docs(`/.../${expr}/...`) `` (literal prefix, flagged as dynamic)
- `"https://coder.com/docs/..."` and other quoted forms
- `](https://coder.com/docs/...)` and `](/docs/...)` markdown-link forms

The full audit (26 findings: 9 in `coder/coder/site/`, 17 in
`coder/coder.com/src/`) lives in [DOCS-253 on
Linear](https://linear.app/codercom/issue/DOCS-253) rather than being
committed to the repo. Re-run locally with:

```bash
node site/scripts/audit-docs-paths.mjs \
  --redirects=/path/to/coder.com/redirects.json \
  --roots=/path/to/coder/site/src,/path/to/coder.com/src
```

Default output goes to `docs/.audit/redirects-audit-YYYY-MM-DD.md`,
which is gitignored.

## Tests

`site/scripts/audit-docs-paths.test.mjs` has 59 cases covering the four
regexes, `matchRedirect` (exact, `:path*`, `:slug(.*)`, miss),
`findMatchingRedirect`, `stripQueryAndFragment`, `literalPrefix`,
`extractReferences` end-to-end with line numbers and multi-line `docs()`
calls, `buildReport` (empty input, repo grouping and sort order,
fragment annotation, unclassified section), `walk` against a real temp
filesystem (recursion, extension filter, `SKIP_DIRS` pruning,
missing/file inputs, seeded results), and `runCli` (missing-root
warning, real-but-empty root). Run with `pnpm exec vitest run
scripts/audit-docs-paths.test.mjs --project=unit` from `site/`.

## Notes

- User-visible "AI Bridge" label text is not changed here. Renaming the
product surface from "AI Bridge" to "AI Gateway" is tracked separately
by the AI Governance team in AIGOV-233.
- A vitest assertion that no literal path in `site/src/` resolves via a
redirect will land in a follow-up PR (DOCS-257), so future drift fails
fast in CI.
- A generated `DocsPath` type for the `docs()` helper is planned in
DOCS-254.
- The `/docs/templates/schedule` drift in `entities.ts` also appears in
`coderd/notifications/testdata/*.golden` test fixtures and in historical
SQL migrations under `coderd/database/migrations/`. Those are tracked
under DOCS-256 (A2: non-TS audit) and not in scope here.

## Related work

- Linear: DOCS-253 (this PR), parent DOCS-209.
- Companion redirect rule on the coder.com side: coder/coder.com#826.
- Companion fixes for the 17 coder.com findings: coder/coder.com#876
(supersedes the closed coder/coder.com#827, which was made redundant by
coder/coder.com#832).

<details>
<summary>Implementation plan (Linear DOCS-209)</summary>

| Phase | Scope | Linear | Status |
|---|---|---|---|
| D | Versioned redirect for `/docs/@v2.33.x/ai-coder/ai-bridge` in
`coder.com/redirects.json` | DOCS-255 | coder/coder.com#826 open |
| A1 | TS/TSX audit + autofix in `coder/coder/site/` | DOCS-253 | This
PR |
| A1 follow-up | Same autofix in `coder/coder.com/src/` (3 remaining
findings after coder/coder.com#832) | DOCS-281 | coder/coder.com#876
open |
| A2 | Non-TS audit in `coder/coder` (Go, comments, markdown) | DOCS-256
| Backlog |
| A3 | code-server audit | DOCS-252 | Backlog |
| B | vitest assertion against `redirects.json` | DOCS-257 | Blocked by
A1 |
| C | Generated `DocsPath` type | DOCS-254 | Blocked by B |

</details>

---

Generated by Coder Agent on behalf of @nickvigilante.

---------

Co-authored-by: Coder <coder@users.noreply.github.com>
2026-06-17 12:19:08 -04:00

454 lines
14 KiB
JavaScript

// 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));
}