Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/cli-audit-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@codefast/cli": minor
---

`codefast audit links` — a read-only scan for markdown cross-references that resolve to nothing.

It reports three things: a relative path that does not exist, an in-document anchor with no matching heading or `<a id>`, and an anchor into another document that the target does not offer. The third is why the command exists — a wrong `#fragment` fails silently in a browser by scrolling to the top, so unlike a broken path it leaves no trace to notice. Six such breakages had accumulated in this repo before anything looked.

External URLs are skipped as somebody else's to verify, and so are links inside fenced code, which are examples rather than references. Exits non-zero when breakages remain, so it can gate CI; intentional exceptions go in `audit.links.allowlist` as a bare target or `repo/relative/doc.md:target`.

The scan defaults to the repo root rather than a configured path. A link audit scoped to one package cannot see the cross-package references, which are the ones most likely to rot.
5 changes: 5 additions & 0 deletions .github/workflows/reusable-verify-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ jobs:
- name: Validate Code Quality and Type Safety
run: npx turbo run lint format:check check-types --filter="./packages/**"

# Repo-wide on purpose: the cross-package references are the ones most likely to rot, and a
# dangling anchor into another document degrades to a scroll rather than a visible 404.
- name: Verify Documentation Cross-References
run: pnpm cli:audit:links

- name: Execute Automated Tests with Coverage
if: ${{ inputs.run-tests }}
run: npx turbo run test:coverage --filter="./packages/**"
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ These are project rules the linters do not fully enforce:
- **No inline prop types.** Declare `interface XxxProps extends ComponentProps<"element">` (matching the host element rendered), spread `{...props}` **last** on that element, and merge classes via `cn(base, className)`. `Omit` any attr the wrapper hard-sets. When forwarding to another component (not a DOM element), extend `ComponentProps<typeof ThatComponent>` and `Omit` the required props the wrapper supplies. Exception: a handler the component must own (e.g. a `CopyButton`'s `onClick`) goes _after_ `{...props}` with a comment.
- **RTL: keep physical classes that sit under a side variant.** `packages/ui` is RTL-hardened with logical utilities + `rtl:` overrides, but physical `left-/right-/border-l/r/slide-in-from-*` classes gated behind `data-[side=…]` (or the custom `data-side-left`/`data-side-right`) are intentional — Radix resolves `side` per reading direction, so converting them to logical double-flips. Run `pnpm run codefast audit rtl` (the RTL scan lives in the `codefast` CLI, config under `audit.rtl` in `codefast.config.js`) to check for genuine gaps.

## Documentation cross-references

`pnpm cli:audit:links` scans every `.md` in the repo for a relative path that does not exist, an in-document anchor with no matching heading, and an anchor into another document the target does not offer — the last of which fails silently in a browser. It gates CI, so a doc link that rots is a red build rather than a discovery months later. Cite a section by an explicit `<a id="…"></a>` anchor rather than a number: a number shifts the moment a section is inserted, and nothing checks it.

## Releases

Versioning is via **Changesets**. Commits follow **Conventional Commits** (enforced by commitlint). Use the `release` skill for the full publish workflow.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"cli:arrange:simplify": "pnpm run codefast arrange simplify",
"cli:arrange:simplify:preview": "pnpm run codefast arrange simplify --dry-run",
"cli:audit:rtl": "pnpm run codefast audit rtl",
"cli:audit:links": "pnpm run codefast audit links",
"cli:mirror": "pnpm run codefast mirror",
"cli:mirror:preview": "pnpm run codefast mirror --dry-run",
"codefast": "node ./packages/cli/dist/bin.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The `@codefast/cli` package is a **small Node CLI** (top-level commands: `arrang
| [`core/`](src/core/) | Shared primitives: `Result` / `AppError` / `messageFrom`, filesystem (`nodeFilesystem`), workspace discovery, Zod `parseWithSchema`, `logger`, `consumeCliAppError`, path-lesson text edits, TypeScript file walking. |
| [`core/config/`](src/core/config/) | Zod schema ([`schema.ts`](src/core/config/schema.ts)), config load + cache ([`loader.ts`](src/core/config/loader.ts)), warning lines ([`warnings.ts`](src/core/config/warnings.ts)). [`core/config.ts`](src/core/config.ts) exposes `loadCodefastConfig` as the public `Result`-based API. |
| [`arrange/`](src/arrange/) | Tailwind `cn()` / `tv()` tooling: Commander tree ([`command.ts`](src/arrange/command.ts)), orchestration ([`analyze.ts`](src/arrange/analyze.ts), [`sync.ts`](src/arrange/sync.ts), [`workspace.ts`](src/arrange/workspace.ts), …), [`output.ts`](src/arrange/output.ts) for stdout, [`domain/`](src/arrange/domain/) for pure logic + [`domain/ast/`](src/arrange/domain/ast/) for AST. |
| [`audit/`](src/audit/) | Read-only source audits: `audit rtl` scans for physical-direction Tailwind classes ([`command.ts`](src/audit/command.ts), [`run.ts`](src/audit/run.ts), [`domain/`](src/audit/domain/)). |
| [`audit/`](src/audit/) | Read-only audits ([`command.ts`](src/audit/command.ts), [`domain/`](src/audit/domain/)): `audit rtl` scans source for physical-direction Tailwind classes ([`run.ts`](src/audit/run.ts)); `audit links` scans markdown for cross-references that resolve to nothing ([`run-links.ts`](src/audit/run-links.ts)). |
| [`mirror/`](src/mirror/) | `package.json` exports sync: commands, `prepare` / `sync`, workspace package sync implementation, progress presenter, [`domain/exports.ts`](src/mirror/domain/exports.ts) for export map generation. |
| [`tag/`](src/tag/) | `@since` JSDoc tagging: commands, `prepare` / `sync`, target discovery, since-writer, presenters. |

Expand Down
17 changes: 17 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pnpm run cli:arrange:simplify:preview
pnpm run cli:mirror # codefast mirror
pnpm run cli:mirror:preview # codefast mirror --dry-run
pnpm run cli:audit:rtl # codefast audit rtl
pnpm run cli:audit:links # codefast audit links
```

Standalone install (Node >= 24):
Expand Down Expand Up @@ -113,6 +114,22 @@ codefast audit rtl --json # machine-readable summary

Configure intentional exceptions via `audit.rtl.allowlist` in `codefast.config` — each entry is a bare class token or `repo/relative/path.tsx:token`.

## `audit links`

Read-only scan for markdown cross-references that point at nothing: a relative path that does not exist, an in-document anchor with no matching heading or `<a id>`, and an anchor into another document that the target does not offer. That last one is the reason this exists — it fails silently in a browser by scrolling to the top, so nothing else notices. External URLs are somebody else's to check and are skipped, as are links inside fenced code, which are examples rather than references. Exits non-zero when breakages remain so it can gate CI.

```bash
codefast audit links # whole repo
codefast audit links packages/di # explicit target
codefast audit links --json # machine-readable summary
```

| Flag | Description |
| -------- | --------------------------------- |
| `--json` | Print one JSON summary on stdout. |

Configure intentional exceptions via `audit.links.allowlist` in `codefast.config` — each entry is a bare link target or `repo/relative/doc.md:target`.

## `tag`

Adds `@since <version>` tags to doc comments of exported declarations that lack one, creating the doc block when missing. The version comes from the nearest `package.json` walking up from each target file. Declarations that already carry `@since` are left alone.
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/audit/cli-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,30 @@ export const rtlAuditRunRequestSchema: z.ZodType<RtlAuditRunRequest> = z.object(
json: z.boolean(),
});

/**
* Resolved request for a single link audit run.
*
* @since 0.5.0
*/
export type LinkAuditRunRequest = {
readonly rootDir: string;
readonly targetPath: string;
readonly allowlist?: ReadonlyArray<string> | undefined;
readonly json: boolean;
};

/**
* Zod schema for {@link LinkAuditRunRequest}.
*
* @since 0.5.0
*/
export const linkAuditRunRequestSchema: z.ZodType<LinkAuditRunRequest> = z.object({
rootDir: z.string().min(1),
targetPath: z.string().min(1),
allowlist: z.array(z.string()).optional(),
json: z.boolean(),
});

/**
* Resolve a path that may be absolute or relative to `rootDir`.
*
Expand Down
57 changes: 53 additions & 4 deletions packages/cli/src/audit/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,26 @@ import process from "node:process";

import { Command } from "commander";

import { rtlAuditRunRequestSchema } from "#/audit/cli-schema";
import { exitCodeForRtlAuditResult, formatRtlAuditJsonOutput, presentRtlAuditResult } from "#/audit/output";
import { prepareRtlAudit } from "#/audit/prepare";
import { linkAuditRunRequestSchema, rtlAuditRunRequestSchema } from "#/audit/cli-schema";
import {
exitCodeForLinkAuditResult,
exitCodeForRtlAuditResult,
formatLinkAuditJsonOutput,
formatRtlAuditJsonOutput,
presentLinkAuditResult,
presentRtlAuditResult,
} from "#/audit/output";
import { prepareLinkAudit, prepareRtlAudit } from "#/audit/prepare";
import { runRtlAudit } from "#/audit/run";
import { runLinkAudit } from "#/audit/run-links";
import { readOptionalPositionalArg } from "#/core/cli/positional";
import { consumeCliAppError } from "#/core/cli/result-handle";
import { nodeFilesystem } from "#/core/filesystem/node";
import { logger } from "#/core/logger";
import { parseWithSchema } from "#/core/schema-parse";

/**
* Top-level `audit` command — currently hosts the RTL physical-class scan.
* Top-level `audit` command — the read-only scans, grouped because none of them writes.
*
* @since 1.0.0-canary.7
*/
Expand Down Expand Up @@ -61,5 +69,46 @@ export function createAuditCommand(): Command {
process.exitCode = exitCodeForRtlAuditResult(outcome.value);
});

cmd
.command("links")
.description("Report markdown links pointing at a missing path or an anchor the target does not offer")
.argument("[target]", "Directory or file to scan (default: the repo root)")
.option("--json", "Print one JSON summary on stdout", false)
.action(async (target: string | undefined, opts: { json?: boolean }) => {
const prelude = await prepareLinkAudit(nodeFilesystem, {
currentWorkingDirectory: process.cwd(),
rawTarget: readOptionalPositionalArg(target),
});
if (!consumeCliAppError(prelude)) {
return;
}
const { rootDir, targetPath, allowlist } = prelude.value;
const parsed = parseWithSchema(linkAuditRunRequestSchema, {
rootDir,
targetPath,
allowlist,
json: !!opts.json,
});
if (!consumeCliAppError(parsed)) {
return;
}

const outcome = runLinkAudit(nodeFilesystem, {
rootDir: parsed.value.rootDir,
targetPath: parsed.value.targetPath,
allowlist: parsed.value.allowlist ?? [],
});
if (!consumeCliAppError(outcome)) {
return;
}

if (parsed.value.json) {
logger.out(formatLinkAuditJsonOutput(outcome.value, rootDir));
} else {
presentLinkAuditResult(outcome.value);
}
process.exitCode = exitCodeForLinkAuditResult(outcome.value);
});

return cmd;
}
108 changes: 108 additions & 0 deletions packages/cli/src/audit/domain/markdown-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Extracts the two things a markdown cross-reference can get wrong: where it points and what it lands on.
*/

/**
* One `[text](target)` whose target is a path in this repository.
*
* @since 0.5.0
*/
export type MarkdownLinkReference = {
readonly line: number;
/** The path as written, with any fragment stripped. Empty when the link is fragment-only. */
readonly targetPath: string;
/** The `#fragment`, without the hash, or `null`. */
readonly anchor: string | null;
};

/**
* The anchors a document offers, and the references it makes.
*
* @since 0.5.0
*/
export type MarkdownLinkScan = {
readonly references: ReadonlyArray<MarkdownLinkReference>;
readonly anchors: ReadonlySet<string>;
};

// Anything with a scheme, a protocol-relative host, or a bare mail address is somebody else's to check.
const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;

/**
* The anchor ids a rendered document exposes: explicit `<a id>` targets plus every heading's slug.
*
* @remarks Slugging matches GitHub's — lowercase, drop everything that is not a letter, number, space
* or hyphen, then hyphenate spaces. Duplicate headings get a `-1` suffix there; this returns the base
* only, so a link to the second copy reads as dangling rather than being silently accepted.
*
* @since 0.5.0
*/
export function collectMarkdownAnchors(content: string): Set<string> {
const anchors = new Set<string>();

for (const match of content.matchAll(/<a\s+id="([^"]+)"\s*><\/a>/g)) {
anchors.add(match[1]!);
}
for (const match of content.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) {
anchors.add(
match[1]!
.toLowerCase()
.replaceAll(/[^\p{L}\p{N} -]/gu, "")
.trim()
.replaceAll(/\s+/g, "-"),
);
}

return anchors;
}

/**
* Every repo-local link a document makes, with the anchors it offers.
*
* @remarks Fenced code is stripped first: a fence showing a link is an example, not a reference, and
* checking it would make the audit fail on documentation that is doing its job.
*
* @since 0.5.0
*/
export function scanMarkdownLinks(content: string): MarkdownLinkScan {
const withoutFences = content.replaceAll(/^```[\s\S]*?^```/gm, (block) => block.replaceAll(/[^\n]/g, " "));
const references: Array<MarkdownLinkReference> = [];

for (const match of withoutFences.matchAll(/\[[^\]]*]\(\s*([^)\s]+?)\s*\)/g)) {
const raw = match[1]!;
if (EXTERNAL.test(raw)) {
continue;
}
const hashAt = raw.indexOf("#");
const targetPath = hashAt === -1 ? raw : raw.slice(0, hashAt);
const anchor = hashAt === -1 ? null : raw.slice(hashAt + 1);
if (targetPath === "" && anchor === null) {
continue;
}
references.push({
line: lineNumberAt(withoutFences, match.index),
targetPath: decodeTarget(targetPath),
anchor: anchor === null || anchor === "" ? null : decodeTarget(anchor),
});
}

return { references, anchors: collectMarkdownAnchors(content) };
}

function decodeTarget(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}

function lineNumberAt(content: string, index: number): number {
let line = 1;
for (let position = 0; position < index; position++) {
if (content.charCodeAt(position) === 10) {
line++;
}
}
return line;
}
29 changes: 29 additions & 0 deletions packages/cli/src/audit/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,32 @@ export type RtlAuditResult = {
readonly allowlistedCount: number;
readonly scannedFileCount: number;
};

/**
* @since 0.5.0
*/
export type LinkBreakage = {
readonly line: number;
/** The link target as written, fragment included. */
readonly raw: string;
readonly reason: string;
};

/**
* @since 0.5.0
*/
export type LinkFileBreakages = {
readonly relativePath: string;
readonly breakages: Array<LinkBreakage>;
};

/**
* @since 0.5.0
*/
export type LinkAuditResult = {
readonly files: Array<LinkFileBreakages>;
readonly breakageCount: number;
readonly allowlistedCount: number;
readonly linkCount: number;
readonly scannedFileCount: number;
};
49 changes: 48 additions & 1 deletion packages/cli/src/audit/output.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RtlAuditResult } from "#/audit/domain/types";
import type { LinkAuditResult, RtlAuditResult } from "#/audit/domain/types";
import { CLI_EXIT_GENERAL_ERROR, CLI_EXIT_SUCCESS } from "#/core/exit-codes";
import { logger } from "#/core/logger";

Expand Down Expand Up @@ -46,3 +46,50 @@ export function formatRtlAuditJsonOutput(result: RtlAuditResult, rootDir: string
result,
});
}

/**
* Exit `1` when any non-allowlisted broken link remains.
*
* @since 0.5.0
*/
export function exitCodeForLinkAuditResult(result: LinkAuditResult): number {
return result.breakageCount > 0 ? CLI_EXIT_GENERAL_ERROR : CLI_EXIT_SUCCESS;
}

/**
* Human-readable link audit report.
*
* @since 0.5.0
*/
export function presentLinkAuditResult(result: LinkAuditResult): void {
for (const file of result.files) {
logger.out(`\n${file.relativePath}`);
for (const { line, raw, reason } of file.breakages) {
logger.out(` ${line}: ${raw} → ${reason}`);
}
}

const allowlistSuffix = result.allowlistedCount > 0 ? ` (${result.allowlistedCount} allowlisted)` : "";

if (result.breakageCount > 0) {
logger.out(`\n✖ ${result.breakageCount} broken link(s)${allowlistSuffix}`);
} else {
logger.out(
`✓ ${result.linkCount} repo-local link(s) across ${result.scannedFileCount} document(s) all resolve${allowlistSuffix}`,
);
}
}

/**
* Machine-readable link audit summary for `--json`.
*
* @since 0.5.0
*/
export function formatLinkAuditJsonOutput(result: LinkAuditResult, rootDir: string): string {
return JSON.stringify({
schemaVersion: 1 as const,
ok: result.breakageCount === 0,
cwd: rootDir,
result,
});
}
Loading