mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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:
|
||||
// <tmpDir>/a.ts
|
||||
// <tmpDir>/b.tsx
|
||||
// <tmpDir>/c.js
|
||||
// <tmpDir>/nested/d.ts
|
||||
// <tmpDir>/nested/e.md
|
||||
// <tmpDir>/node_modules/skipme.ts
|
||||
// <tmpDir>/dist/also-skipped.ts
|
||||
// <tmpDir>/.audit/audit-report.ts
|
||||
const buildTree = (root) => {
|
||||
fs.writeFileSync(path.join(root, "a.ts"), "export const a = 1;");
|
||||
fs.writeFileSync(path.join(root, "b.tsx"), "export const B = () => null;");
|
||||
fs.writeFileSync(path.join(root, "c.js"), "module.exports = {};");
|
||||
fs.mkdirSync(path.join(root, "nested"));
|
||||
fs.writeFileSync(path.join(root, "nested", "d.ts"), "export const d = 1;");
|
||||
fs.writeFileSync(path.join(root, "nested", "e.md"), "# notes");
|
||||
fs.mkdirSync(path.join(root, "node_modules"));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "node_modules", "skipme.ts"),
|
||||
"export const x = 1;",
|
||||
);
|
||||
fs.mkdirSync(path.join(root, "dist"));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "dist", "also-skipped.ts"),
|
||||
"export const x = 1;",
|
||||
);
|
||||
fs.mkdirSync(path.join(root, ".audit"));
|
||||
fs.writeFileSync(
|
||||
path.join(root, ".audit", "audit-report.ts"),
|
||||
"export const x = 1;",
|
||||
);
|
||||
};
|
||||
|
||||
it("returns matching files, recurses, and filters by extension", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-"));
|
||||
try {
|
||||
buildTree(tmpDir);
|
||||
const found = walk(tmpDir, [".ts", ".tsx"]).sort();
|
||||
const rel = found.map((f) => path.relative(tmpDir, f)).sort();
|
||||
expect(rel).toEqual([
|
||||
"a.ts",
|
||||
"b.tsx",
|
||||
path.join("nested", "d.ts"),
|
||||
]);
|
||||
// c.js excluded by extension filter; e.md excluded by extension filter;
|
||||
// node_modules/dist/.audit excluded by SKIP_DIRS.
|
||||
expect(rel).not.toContain("c.js");
|
||||
expect(rel).not.toContain(path.join("nested", "e.md"));
|
||||
expect(rel).not.toContain(path.join("node_modules", "skipme.ts"));
|
||||
expect(rel).not.toContain(path.join("dist", "also-skipped.ts"));
|
||||
expect(rel).not.toContain(path.join(".audit", "audit-report.ts"));
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("honors the extension filter (.tsx only)", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-"));
|
||||
try {
|
||||
buildTree(tmpDir);
|
||||
const found = walk(tmpDir, [".tsx"]);
|
||||
const rel = found.map((f) => path.relative(tmpDir, f));
|
||||
expect(rel).toEqual(["b.tsx"]);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty array when the directory does not exist", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-"));
|
||||
try {
|
||||
const missing = path.join(tmpDir, "does-not-exist");
|
||||
expect(walk(missing, [".ts"])).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty array when handed a file instead of a directory", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-"));
|
||||
try {
|
||||
const filePath = path.join(tmpDir, "a.ts");
|
||||
fs.writeFileSync(filePath, "export const a = 1;");
|
||||
expect(walk(filePath, [".ts"])).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("appends to an existing results array instead of replacing it", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, "a.ts"), "export const a = 1;");
|
||||
const seed = ["/seed/value.ts"];
|
||||
const out = walk(tmpDir, [".ts"], seed);
|
||||
expect(out).toBe(seed);
|
||||
expect(out).toContain("/seed/value.ts");
|
||||
expect(out.some((f) => f.endsWith("a.ts"))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCli", () => {
|
||||
// Minimal redirects file written to a tmp dir so the CLI can load it.
|
||||
const writeTmpRedirects = (tmpDir) => {
|
||||
const redirectsPath = path.join(tmpDir, "redirects.json");
|
||||
fs.writeFileSync(
|
||||
redirectsPath,
|
||||
JSON.stringify([
|
||||
rule("/docs/admin/rbac", "/docs/admin/templates/template-permissions"),
|
||||
]),
|
||||
);
|
||||
return redirectsPath;
|
||||
};
|
||||
|
||||
it("warns and continues when a --roots directory does not exist", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-cli-"));
|
||||
try {
|
||||
const redirectsPath = writeTmpRedirects(tmpDir);
|
||||
const missingRoot = path.join(tmpDir, "does-not-exist");
|
||||
const outPath = path.join(tmpDir, "out.md");
|
||||
|
||||
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
let stderr;
|
||||
try {
|
||||
runCli([
|
||||
`--redirects=${redirectsPath}`,
|
||||
`--roots=${missingRoot}`,
|
||||
`--out=${outPath}`,
|
||||
]);
|
||||
stderr = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
} finally {
|
||||
errSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(stderr).toContain(missingRoot);
|
||||
expect(stderr).toContain("does not exist");
|
||||
|
||||
// Report was still written with zero findings.
|
||||
expect(fs.existsSync(outPath)).toBe(true);
|
||||
const report = fs.readFileSync(outPath, "utf-8");
|
||||
expect(report).toContain("| 0 | 0 | 0 |");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("writes a report when --roots contains a real directory with no matches", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-cli-"));
|
||||
try {
|
||||
const redirectsPath = writeTmpRedirects(tmpDir);
|
||||
const emptyRoot = path.join(tmpDir, "empty");
|
||||
fs.mkdirSync(emptyRoot);
|
||||
const outPath = path.join(tmpDir, "out.md");
|
||||
|
||||
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
runCli([
|
||||
`--redirects=${redirectsPath}`,
|
||||
`--roots=${emptyRoot}`,
|
||||
`--out=${outPath}`,
|
||||
]);
|
||||
} finally {
|
||||
errSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fs.existsSync(outPath)).toBe(true);
|
||||
const report = fs.readFileSync(outPath, "utf-8");
|
||||
expect(report).toContain("| 0 | 0 | 0 |");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("wires the full pipeline end-to-end and reports a real finding", () => {
|
||||
// Exercises walk -> readFile -> extractReferences ->
|
||||
// findMatchingRedirect -> buildReport -> writeFile against a seeded
|
||||
// .ts file. The path layout mimics coder/coder/site so repoForFile
|
||||
// classifies the finding into the expected report section.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-cli-"));
|
||||
try {
|
||||
const redirectsPath = writeTmpRedirects(tmpDir);
|
||||
const siteRoot = path.join(tmpDir, "coder", "site", "src");
|
||||
const nested = path.join(siteRoot, "pages");
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
const sourceFile = path.join(nested, "Example.tsx");
|
||||
fs.writeFileSync(
|
||||
sourceFile,
|
||||
'import { docs } from "./docs";\nconst href = docs("/admin/rbac");\n',
|
||||
);
|
||||
const outPath = path.join(tmpDir, "out.md");
|
||||
|
||||
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
runCli([
|
||||
`--redirects=${redirectsPath}`,
|
||||
`--roots=${siteRoot}`,
|
||||
`--out=${outPath}`,
|
||||
]);
|
||||
} finally {
|
||||
errSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fs.existsSync(outPath)).toBe(true);
|
||||
const report = fs.readFileSync(outPath, "utf-8");
|
||||
|
||||
// Summary row reflects exactly one auto-fixable finding.
|
||||
expect(report).toContain("| 1 | 1 | 0 |");
|
||||
// Repo section header for coder/coder/site.
|
||||
expect(report).toContain("## coder/coder/site");
|
||||
expect(report).toContain("1 finding.");
|
||||
// Table row includes the relative file path, the raw arg, the
|
||||
// matched redirect, and the suggested fix.
|
||||
expect(report).toContain("site/src/pages/Example.tsx:2");
|
||||
expect(report).toContain("`/admin/rbac`");
|
||||
expect(report).toContain(
|
||||
"`/docs/admin/rbac` -> `/docs/admin/templates/template-permissions`",
|
||||
);
|
||||
expect(report).toContain("`/admin/templates/template-permissions`");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ const PaywallAIGovernance = () => {
|
||||
<span>
|
||||
Visit{" "}
|
||||
<a
|
||||
href={docs("/ai-coder/ai-bridge")}
|
||||
href={docs("/ai-coder/ai-gateway")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-content-link"
|
||||
|
||||
@@ -22,7 +22,7 @@ const AIBridgeSessionsLayout: FC<PropsWithChildren> = () => {
|
||||
Review and audit AI activity, token usage, and prompt history across
|
||||
sessions.{" "}
|
||||
<Link
|
||||
href={docs("/ai-coder/ai-bridge/audit")}
|
||||
href={docs("/ai-coder/ai-gateway/audit")}
|
||||
className="ml-auto"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const AIBridgeSetupAlert: FC = () => {
|
||||
AI Gateway is included in your license, but not set up yet.
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You have access to AI Governance, but it still needs to be setup. Check
|
||||
You have access to AI Governance, but it still needs to be set up. Check
|
||||
out the{" "}
|
||||
<Link href={docs("/ai-coder/ai-gateway")} target="_blank">
|
||||
AI Gateway
|
||||
|
||||
@@ -319,7 +319,7 @@ const ThreadItem: FC<ThreadItemProps> = ({ thread, initiator }) => {
|
||||
</p>
|
||||
<Link
|
||||
href={docs(
|
||||
"/ai-coder/ai-bridge/audit#human-vs-agent-attribution",
|
||||
"/ai-coder/ai-gateway/audit#human-vs-agent-attribution",
|
||||
)}
|
||||
target="_blank"
|
||||
className="text-sm"
|
||||
|
||||
@@ -58,7 +58,9 @@ export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
|
||||
const width = menus.organization ? DEFAULT_USER_FILTER_WIDTH : undefined;
|
||||
return (
|
||||
<Filter
|
||||
learnMoreLink={docs("/admin/security/audit-logs#filtering-logs")}
|
||||
learnMoreLink={docs(
|
||||
"/admin/security/audit-logs#how-to-filter-audit-logs",
|
||||
)}
|
||||
presets={PRESET_FILTERS}
|
||||
isLoading={menus.user.isInitializing}
|
||||
filter={filter}
|
||||
|
||||
+1
-3
@@ -41,9 +41,7 @@ export const UserAuthSettingsPageView = ({
|
||||
|
||||
<SettingsHeader
|
||||
actions={
|
||||
<SettingsHeaderDocsLink
|
||||
href={docs("/admin/users/oidc-auth#openid-connect")}
|
||||
/>
|
||||
<SettingsHeaderDocsLink href={docs("/admin/users/oidc-auth")} />
|
||||
}
|
||||
>
|
||||
<SettingsHeaderTitle level="h2" hierarchy="secondary">
|
||||
|
||||
@@ -60,7 +60,7 @@ export const TemplatesFilter: FC<TemplatesFilterProps> = ({
|
||||
{ query: "deprecated:true", name: "Deprecated templates" },
|
||||
]}
|
||||
// TODO: Add docs for this
|
||||
// learnMoreLink={docs("/templates#template-filtering")}
|
||||
// learnMoreLink={docs("/admin/templates#template-filtering")}
|
||||
isLoading={false}
|
||||
filter={filter}
|
||||
error={error}
|
||||
|
||||
@@ -4955,7 +4955,7 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
|
||||
name: "Workspace Marked as Dormant",
|
||||
title_template: 'Workspace "{{.Labels.name}}" marked as dormant',
|
||||
body_template:
|
||||
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\nDormant workspaces are [automatically deleted](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\nTo prevent deletion, use your workspace with the link below.",
|
||||
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\nDormant workspaces are [automatically deleted](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\nTo prevent deletion, use your workspace with the link below.",
|
||||
actions:
|
||||
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
|
||||
group: "Workspace Events",
|
||||
@@ -4981,7 +4981,7 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
|
||||
name: "Workspace Marked for Deletion",
|
||||
title_template: 'Workspace "{{.Labels.name}}" marked for deletion',
|
||||
body_template:
|
||||
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\nTo prevent deletion, use your workspace with the link below.",
|
||||
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\nTo prevent deletion, use your workspace with the link below.",
|
||||
actions:
|
||||
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
|
||||
group: "Workspace Events",
|
||||
|
||||
Reference in New Issue
Block a user