tsdocs · git:20260523.deb156a · 2026-05-23 · sha256 54ab2abea951304e

tsdocs git:20260523.deb156aC

Immutable. This exact content never changes and is served at /api/v1/blob/54ab2abea951304e.

# TSDocs Skill

## Overview

This skill is the practical companion to the project's TSDoc rules. It shows what good TSDoc looks like in *this* codebase, points at real files to mine for patterns, and calls out common gaps so you can fix them when you touch a file.

**TSDoc vs JSDoc.** TSDoc is a documentation comment standard maintained by Microsoft, designed specifically for TypeScript. JSDoc predates it and was built for JavaScript — its tags often double as type annotations (`@param {string}`), which is redundant when the language already has types. TSDoc drops type-in-comment annotations and standardizes the tag grammar so tools (API Extractor, VS Code, ESLint plugins) parse the same comments the same way. **This project uses TSDoc** so editor hovers, future generated docs, and lint rules all agree.

**Why TSDoc matters here.** It powers three concrete things:

1. **Editor hovers** — every `@param`, `@returns`, `@throws` you write shows up when a teammate hovers over the call site in VS Code.
2. **Generated docs** — if/when we publish API reference pages, TSDoc is what gets extracted.
3. **Reviewer attention** — a missing `@throws` is the single most common reason a caller forgets to handle an error.

## Authoritative reference

The canonical TSDoc rules for this repo live in **[`.github/instructions/documentation.instructions.md`](../../instructions/documentation.instructions.md)** — explicitly marked `SINGLE SOURCE OF TRUTH`. This skill **defers to that file for any conflict**. If something here disagrees with the instructions file, the instructions file wins and this skill should be updated.

In particular, the instructions file owns:

- The **proportionality rule** (TSDoc lines ≤ function body lines).
- The right-sizing table (≤10 LOC / 11–30 LOC / >30 LOC).
- The canonical **TSDoc Tags Reference** (when to include `@param`, `@returns`, `@throws`, `@example`, `@remarks`, `@see`, `@internal`, `@public`, `@deprecated`).
- Inline comment prefix conventions (`// PERF:`, `// NOTE:`, `// CRITICAL:`, `// TODO:`, `// FIXME:`).
- The anti-pattern catalogue.

Read it first. This file is examples.

## Anatomy of a good TSDoc comment in this repo

A clean, working example: `requireUserContext()` in [`src/lib/auth/context.ts`](../../../src/lib/auth/context.ts):

```typescript
/**
 * Like {@link getUserContext} but throws {@link UnauthorizedError} when the
 * request is unauthenticated. Use this inside API route handlers.
 */
export async function requireUserContext(): Promise<UserContext> {
  const ctx = await getUserContext();
  if (!ctx) throw new UnauthorizedError();
  return ctx;
}
```

What this gets right:

- **Proportional.** Two lines of prose for a three-line function — the instructions file's hard rule (TSDoc lines ≤ body lines) is satisfied.
- **Intent, not mechanics.** It says *what calling this means for you* ("throws when unauthenticated, use in API routes"), not "calls `getUserContext` and checks if null".
- **`{@link}` cross-refs** to related symbols so hover docs are navigable.
- **No `@param` / `@returns`** — there are no params, and the return type `Promise<UserContext>` is self-documenting. The instructions file explicitly allows skipping these when the signature speaks for itself.
- **No `@throws` placeholder.** It mentions `UnauthorizedError` in prose via `{@link}` — for a two-line summary that's enough. (For richer surfaces, see `withUserGuards` below.)

A longer, fully-tagged example: `withUserGuards()` in [`src/lib/security/guard.ts`](../../../src/lib/security/guard.ts):

```typescript
/**
 * Apply auth + rate-limit + concurrent-cap + audit logging around `work`.
 *
 * @throws {@link RateLimitedError} when the user is over the rate limit.
 * @throws {@link TooManyConcurrentSessionsError} when the user is over the
 *   concurrent-session cap.
 * @throws {@link UnauthorizedError} when the request is unauthenticated.
 */
export async function withUserGuards<T>(
  opts: GuardOptions,
  work: (ctx: UserContext) => Promise<T>,
): Promise<T> {
```

This pattern — one-line summary + a `@throws` per failure mode + an `@example` block at the file or symbol level — is the right shape for any exported function that can throw. Note the file-level `@example` directly above the export shows the canonical call site for reviewers, without bloating each function's own comment.

## Required tags by export kind

The canonical rules are in [`documentation.instructions.md` § TSDoc Tags Reference](../../instructions/documentation.instructions.md). Quoting the operative line:

> | `@param` | **Always** for all parameters | Parameter name is self-documenting AND ≤10 LOC function |
> | `@returns` | Non-void functions | Return type is obvious (e.g., `getName(): string`) |
> | `@throws` | Function can throw | Function never throws |

Practical translation by export kind:

| Export kind | Summary | `@param` | `@returns` | `@throws` | `@example` | `@deprecated` |
|---|---|---|---|---|---|---|
| **Exported function** | Required | Yes, unless self-documenting & ≤10 LOC | Yes, unless return type is obvious | One per throwable error | For non-obvious APIs / public surface | Only if phasing out (include migration path) |
| **Exported class** | Required (one summary on the class) | On the constructor and each public method | On each non-void method | Per throwable method | On the class for canonical usage | If the class is being removed |
| **Exported interface** | Required | TSDoc each field (one-liner is fine) | n/a | n/a | If the shape is non-obvious | Per-field if individual fields are being removed |
| **Exported type alias** | Single-line TSDoc is usually enough | n/a | n/a | n/a | Only for non-obvious unions/discriminants | Rare |
| **Exported constant** | Single-line TSDoc explaining intent / units | n/a | n/a | n/a | If usage is non-obvious | Rare |

Defer to the instructions file's right-sizing table for *how much* prose. The table above only covers *which tags* — length is a separate axis.

### Interface field documentation

The codebase already does this well. From `src/lib/security/rate-limit.ts`:

```typescript
export interface RateLimitResult {
  /** Whether the request is allowed under the current window. */
  allowed: boolean;
  /** Milliseconds the caller should wait before retrying. Only present when blocked. */
  retryAfterMs?: number;
}
```

One-line TSDoc per field. The optional `retryAfterMs` mentions *when it's present* — that's the kind of intent-over-mechanics detail that's worth the line.

## What NOT to document

Don't write TSDoc that:

1. **Restates the type.** `@param userId - The user id, a string.` adds nothing the signature didn't already say. Write *what role the value plays*, not its type.
2. **Narrates the implementation.** `// Loop through buckets and filter expired entries` is what the code itself shows. The instructions file's anti-pattern table calls this out explicitly.
3. **Lists obvious behaviour.** `getUserById(id: string): User | null` does not need "Returns the user with the given id, or null if not found." That's the signature in prose.
4. **Is a placeholder.** `TODO: write docs` is worse than nothing — it signals to readers "stop reading, this is unfinished." Either write it or omit the comment.
5. **Documents private helpers.** Internal helpers (`cleanupExpired` in `rate-limit.ts`) don't need TSDoc — a brief inline `// NOTE:` is enough when intent isn't obvious. Mark testing-only exports with `@internal` (see `__resetRateLimitState`).

## Examples of well-documented exports in this codebase

Three real exports worth mining when you write or review TSDoc. Each
shows a different right-sized shape, from one-liner field docs through
to fully-tagged thrower contracts. (These were rough spots earlier in
the project's life; H5 brought them up to standard. They are now the
benchmark, not the cautionary tale.)

### 1. `src/lib/auth/token-store.ts` — `TokenStore` interface methods

Every method on the `TokenStore` contract documents its **edge-case
behaviour**, because the contract is the Liskov boundary between the
in-memory and Cosmos implementations. Notice how each method explains
the *observable* corners — what "not found" looks like, whether the
operation is idempotent — rather than restating the signature:

```typescript
export interface TokenStore {
  /**
   * Look up the stored token for `userId`.
   *
   * @param userId - Stable GitHub numeric ID as a string.
   * @returns The {@link StoredToken}, or `null` when no record exists for
   *   the user **or** when the stored record has already expired
   *   (`expiresAt * 1000 <= now`). Callers must treat "not found" and
   *   "expired" identically — both mean "refresh / re-auth required".
   *   Never throws for missing users.
   */
  getToken(userId: string): Promise<StoredToken | null>;

  /**
   * Persist `token` for `userId`.
   *
   * @param userId - Stable GitHub numeric ID as a string.
   * @param token - The token payload to store. Overwrites (upserts) any
   *   previous record for the same `userId`; there is no separate update
   *   path.
   */
  setToken(userId: string, token: StoredToken): Promise<void>;

  /**
   * Remove the token record for `userId`.
   *
   * @param userId - Stable GitHub numeric ID as a string.
   * @remarks Idempotent: deleting a `userId` with no stored token is a
   *   successful no-op, not an error.
   */
  deleteToken(userId: string): Promise<void>;
}
```

What this gets right: every "what does the caller do with the return
value?" question is answered at the contract level, not deferred to
"see the implementation".

### 2. `src/lib/security/rate-limit.ts` — `checkRateLimit`

This function is a good template for "function with both a return value
*and* a side effect". The `@returns` tag describes both arms of the
discriminated result, and the prose calls out the side effect explicitly
so callers know that *asking is also recording*:

```typescript
/**
 * Check whether a user may make another request inside the sliding window.
 * Records the timestamp when allowed; the next call sees this request in
 * its window.
 *
 * @param userId - Stable user identifier (e.g. GitHub numeric ID).
 * @param limit - Max number of requests permitted within `windowMs`.
 * @param windowMs - Length of the sliding window in milliseconds.
 * @returns A {@link RateLimitResult}: `allowed: true` when the request fits
 *   in the window (and the timestamp has been recorded), or
 *   `allowed: false` with `retryAfterMs` indicating how long the caller
 *   must wait before the oldest in-window request ages out.
 */
export function checkRateLimit(
  userId: string,
  limit: number,
  windowMs: number,
): RateLimitResult { /* ... */ }
```

### 3. `src/lib/security/session-cap.ts` — `acquireSlot`

When a function returns a callback (release, dispose, unsubscribe), the
`@returns` tag is the *most important* part of the comment — it tells
the caller what their cleanup obligation is. `acquireSlot` makes that
contract structural rather than buried in prose:

```typescript
/**
 * Acquire a concurrency slot for `userId`.
 *
 * @param userId - Stable user identifier.
 * @param max - Maximum simultaneous slots permitted for the user.
 * @returns A release function. Call it (typically in a `finally` block)
 *   exactly once when the work completes; subsequent calls are no-ops.
 * @throws {@link TooManyConcurrentSessionsError} when the user already
 *   holds `max` slots.
 */
export async function acquireSlot(
  userId: string,
  max: number,
): Promise<() => void> { /* ... */ }
```

Two reusable patterns to copy: pair every throwable function with a
`@throws {@link ErrorClass}` tag (the linked class makes the error
discoverable from the hover), and when the return value carries a
cleanup obligation, say so in `@returns` — not in a trailing remark
the reader might skim past.

## Verifying TSDoc quality

There is **no automated TSDoc validator** wired up in this repo today. Specifically:

- `npx tsc --noEmit` checks types, not TSDoc. A nonsense `@param` name will compile fine.
- `npm run lint` (eslint) does not currently include a TSDoc plugin.
- There is no `tsdoc-validator` in `package.json` — don't assume it runs.

Practical verification today:

1. **Hover in VS Code.** The most reliable check. Open the file, hover over the symbol at a call site, and read the popup as if you'd never seen the function before. If anything is wrong, missing, or confusing — fix it. This catches stale `@param` names, missing `@returns`, and bad `{@link}` targets immediately.
2. **Cross-check `@param` names against the signature.** Stale `@param` names are the most common rot. After any signature change, re-read the comment.
3. **Re-read after refactor.** When you change behaviour, the existing TSDoc is the first thing that goes stale.
4. **Review.** Reviewers should treat missing `@throws` on a throwable function as a blocker; everything else is style.

If we add `eslint-plugin-tsdoc` or `api-extractor` later, this section gets updated to reference them.

## See also

- **[`.github/instructions/documentation.instructions.md`](../../instructions/documentation.instructions.md)** — canonical rules; this skill defers to it.
- **[`.github/skills/solid/SKILL.md`](../solid/SKILL.md)** — SOLID principles companion skill (sibling to this one).
- **[`.github/instructions/typescript.instructions.md`](../../instructions/typescript.instructions.md)** — TypeScript coding standards (DRY, clarity, lint rules) that complement TSDoc conventions.