# Spec: Fix prebuild source mutation

**Created by:** AnaPlan
**Date:** 2026-05-15
**Scope:** .ana/plans/active/fix-prebuild-source-mutation/scope.md

## Approach

Delete `updateDynamicMdxValues` and replace it with three things:

1. **A server component** (`DocsStat`) that renders a single computed value as a `<span>`. MDX authors compose sentences around it. Registered in `mdxComponents` like every other docs component.

2. **A shared values module** (`docsStatValues.ts`) in `lib/docs-data/` that provides two functions: one that builds a `Record<string, string>` map from raw data, and one that resolves `<DocsStat>` tags in a text string using that map. This is the single source of truth for the value-key → computation mapping. Three consumers: the component, the prebuild `stripJsx` path, and the lib `stripJsx`.

3. **Resolution steps** in both `stripJsx` implementations. The lib version imports data functions, builds the values map, and resolves before stripping. The prebuild version receives a pre-built values map via `generateLlmsTxt` (which gets it from `main()`).

The 6 current tag types decompose into 9 atomic value keys: `proofCount`, `rejections`, `findings`, `skillCount`, `gotchaCount`, `medianThink`, `medianPlan`, `medianBuild`, `medianVerify`. Each key maps to one number. The MDX author controls all surrounding prose.

For the two NextCards callsites where dynamic values are inside `description` string props: `NextCards` already types `description` as `ReactNode`. No component changes needed — change the MDX syntax from a string literal to a JSX fragment containing `<DocsStat>`.

Gitignore the three tracked generated files (`llms.txt`, `llms-full.txt`, `search-index.json`) and remove them from git tracking. Same rationale as the existing `data/docs/` entry — prebuild output regenerated by every build.

## Output Mockups

### MDX before (source mutation markers)

```
21 of Anatomia's own 103 proofs had rejection cycles. {/* ana:dynamic rejectionProofCount */}
```

```
## All 8 skills {/* ana:dynamic skillCount */}
```

```
The median across all 103 proofs: 9m think, 10m plan, 27m build, 7m verify.</p>{/* ana:dynamic medianTimings */}
```

```
description: "103 verified pipeline runs." /* ana:dynamic proofSummary */
```

### MDX after (component usage)

```
<DocsStat value="rejections" /> of Anatomia's own <DocsStat value="proofCount" /> proofs had rejection cycles.
```

```
## All <DocsStat value="skillCount" /> skills
```

```
The median across all <DocsStat value="proofCount" /> proofs: <DocsStat value="medianThink" />m think, <DocsStat value="medianPlan" />m plan, <DocsStat value="medianBuild" />m build, <DocsStat value="medianVerify" />m verify.</p>
```

```
description: <><DocsStat value="proofCount" /> verified pipeline runs.</>
```

### Rendered output (unchanged)

The rendered HTML output on each docs page is identical before and after — the same numbers appear in the same positions. The difference is mechanical: numbers come from server component calls at SSG time instead of regex-replaced source text.

### llms-full.txt (resolved, no tags)

```
21 of Anatomia's own 103 proofs had rejection cycles.
```

The `resolveDocsStatTags` function replaces `<DocsStat value="rejections" />` with `21` (etc.) before `stripJsx` strips remaining JSX. Plain numbers appear in the output, not component tags, not empty strings.

## File Changes

### `website/lib/docs-data/docsStatValues.ts` (create)
**What changes:** New module with two exported functions. The first accepts raw data (proof count, rejections, findings, skill count, gotcha count, median timings) and returns `Record<string, string>` mapping the 9 value keys to their string representations. The second accepts text and a values map, replaces `<DocsStat value="..." />` tags with computed values via regex, and returns the resolved text.
**Pattern to follow:** `website/lib/docs-data/skills.ts` — same module structure, named exports, explicit return types, JSDoc on exports.
**Why:** Single source of truth for the value-key → computation mapping. Without this, the component, prebuild stripJsx, and lib stripJsx each define their own mapping and drift independently.

### `website/lib/docs-data/index.ts` (modify)
**What changes:** Re-export the two functions from `docsStatValues.ts`.
**Pattern to follow:** Existing re-exports in this barrel file.
**Why:** Consumers import from `@/lib/docs-data` by convention.

### `website/components/docs/content/DocsStat.tsx` (create)
**What changes:** Server component that accepts a `value` prop (string), imports data functions from `@/lib/docs-data`, calls the shared `buildDocsStatValues` to get the map, looks up the key, and renders the result in a `<span>`. No styling — the surrounding MDX context provides all formatting.
**Pattern to follow:** `website/components/docs/content/StatsStrip.tsx` — server component in the same directory, imports from `@/lib/docs-data`, renders data. The new component is much simpler (one prop, one span).
**Why:** Replaces the prebuild regex mutation with a standard React rendering pattern. Values flow from `lib/docs-data` functions at SSG time.

### `website/app/docs/[...slug]/page.tsx` (modify)
**What changes:** Import `DocsStat` and add it to the `mdxComponents` object. No other changes needed — the lib `stripJsx` (used for copy-as-markdown) handles DocsStat resolution internally.
**Pattern to follow:** Existing component registrations in the same file (lines 21-32). Import from `@/components/docs/content/DocsStat`, add `DocsStat` key to the object.
**Why:** MDX can only render custom components if they're registered in the components map.

### `website/lib/docs-data/stripJsx.ts` (modify)
**What changes:** Import `buildDocsStatValues`, `resolveDocsStatTags` from `./docsStatValues`, and the data functions from sibling modules (`./proofs`, `./skills`, `./gotchas`). At the top of the function, before any stripping, build the values map and resolve DocsStat tags. This way, `<DocsStat value="proofCount" />` becomes `103` before the generic self-closing-component regex strips it to empty string.
**Pattern to follow:** The existing structure of the function — each transformation step is a clearly commented block. Add the resolution as the first block.
**Why:** Without resolution, `stripJsx` would strip `<DocsStat ... />` to empty string (it matches the self-closing component regex). Copy-as-markdown would lose all dynamic values.

### `website/scripts/extract-docs-data.ts` (modify)
**What changes:** Three modifications:
1. **Delete `updateDynamicMdxValues`** (lines 956-1054) and its call in `main()` (line 1171).
2. **Import** `buildDocsStatValues` and `resolveDocsStatTags` from `../lib/docs-data/docsStatValues.js`. The prebuild script runs via `tsx` which resolves relative TypeScript imports.
3. **Expand `generateLlmsTxt`** to accept a values map parameter. In the `concatMdx` inner function, resolve DocsStat tags before calling `stripJsx`: pipe the MDX body through `resolveDocsStatTags(body, values)` then through `stripJsx`. In `main()`, build the values map from already-computed data (proofEntries, skillTemplates.length, gotchas.length, and median timings computed from proofEntries) and pass it to `generateLlmsTxt`.
**Pattern to follow:** The existing `generateLlmsTxt` function signature and how `main()` passes data to it. Extend the same pattern.
**Why:** After deleting the source mutation, MDX files contain `<DocsStat>` tags. The llms-full.txt generator reads raw MDX and must resolve these tags to numbers before stripping JSX.

### 7 MDX files (modify)

All files are in `website/content/docs/`. Each one: remove the `{/* ana:dynamic ... */}` comment marker, remove the hardcoded numbers, replace with `<DocsStat value="..." />` component calls. Add an import statement at the top of each file: `import { DocsStat } from '@/components/docs/content/DocsStat'`. (Fumadocs MDX compilation resolves `@/` imports via the Next.js tsconfig paths — this matches how other components are imported in MDX files.)

Wait — check whether existing MDX files import components or rely on `mdxComponents` injection. Read one of the MDX files that uses `NextCards` or `Callout` to determine the import pattern. If they don't import (relying on `mdxComponents`), then `DocsStat` needs no import either — just register it in `mdxComponents` and use it directly.

**`content/docs/concepts/pipeline.mdx`:**
Line 24 — replace `21 of Anatomia's own 103 proofs had rejection cycles. {/* ana:dynamic rejectionProofCount */}` with `<DocsStat value="rejections" /> of Anatomia's own <DocsStat value="proofCount" /> proofs had rejection cycles.`

**`content/docs/start.mdx`:**
Line 75 — same pattern as pipeline.mdx. "Anatomia's own" possessive variant.

**`content/docs/guides/troubleshooting.mdx`:**
Line 43 — `rejectionProofCount` with "our" possessive variant: `<DocsStat value="rejections" /> of our <DocsStat value="proofCount" /> proofs had rejection cycles.`
Line 133 — `proofSummary` inside NextCards description. Change from string to JSX fragment: `description: <><DocsStat value="proofCount" /> verified pipeline runs.</>`

**`content/docs/guides/verifying-changes.mdx`:**
Line 67 — `rejectionProofCount`, "Anatomia's own" variant.

**`content/docs/guides/using-ana-learn.mdx`:**
Line 116 — `proofFindings` inside NextCards description. Change to: `description: <><DocsStat value="proofCount" /> proofs, <DocsStat value="findings" /> findings to triage.</>`

**`content/docs/guides/reading-a-proof.mdx`:**
Line 66 — `medianTimings`. The `<p>` wrapper stays (it's authored MDX with inline styles). Replace the sentence content: `The median across all <DocsStat value="proofCount" /> proofs: <DocsStat value="medianThink" />m think, <DocsStat value="medianPlan" />m plan, <DocsStat value="medianBuild" />m build, <DocsStat value="medianVerify" />m verify.` Remove the trailing `{/* ana:dynamic medianTimings */}`.

**`content/docs/concepts/skills.mdx`:**
Line 43 — `skillCount` in heading: `## All <DocsStat value="skillCount" /> skills`
Line 62 — `gotchaCount`: `<DocsStat value="gotchaCount" /> pre-curated gotchas ship with Anatomia. Each matches when all trigger conditions are satisfied — compound triggers prevent irrelevant advice.` Remove the trailing `{/* ana:dynamic gotchaCount */}`.

### `website/.gitignore` (modify)
**What changes:** Add three lines under the existing `data/docs/` entry (same section, same rationale): `public/llms.txt`, `public/llms-full.txt`, `public/search-index.json`.
**Pattern to follow:** The existing `data/docs/` gitignore entry with its `# fumadocs` section comment.
**Why:** These files are regenerated by every prebuild run. Tracking them causes rebase conflicts on every docs-touching branch.

After adding to `.gitignore`, run `git rm --cached` on the three files to stop tracking them. They remain on disk (regenerated by prebuild) but no longer appear in git status.

## Acceptance Criteria

- [ ] AC1: Running `pnpm build` in `website/` produces zero unstaged changes to tracked files (MDX files and public/ are clean after build)
- [ ] AC2: All 9 dynamic values render correctly on their respective docs pages with current data from `lib/docs-data` functions
- [ ] AC3: `public/llms-full.txt` contains resolved numeric values (not component tags, not empty strings) for all 9 dynamic positions
- [ ] AC4: Copy-as-markdown output (RightRail `pageContent`) contains resolved numeric values for pages with dynamic content
- [ ] AC5: `updateDynamicMdxValues` function no longer exists in `extract-docs-data.ts`
- [ ] AC6: `public/llms.txt`, `public/llms-full.txt`, and `public/search-index.json` are in `.gitignore` and not tracked by git
- [ ] AC7: `pnpm build` succeeds end-to-end (prebuild + next build) with no errors
- [ ] AC8: SearchOverlay client-side fetch of `/search-index.json` still works (file generated at build time, served from public/)
- [ ] AC9: No build errors or TypeScript errors in modified files
- [ ] AC10: `docsStatValues.ts` is the single source of truth for the value-key mapping — no other file defines its own key-to-computation map

## Testing Strategy

This scope modifies the website package, which has no unit test infrastructure (no vitest config, no test files). The CLI test suite (104 files, 2297 tests) is unaffected — no CLI files change.

- **Functional verification:** `pnpm build` in `website/` is the integration test. It runs prebuild (extract-docs-data.ts) then Next.js SSG. If any component fails to resolve, the build errors.
- **Manual spot checks after build:**
  - `grep '<DocsStat' website/public/llms-full.txt` should return zero matches (tags fully resolved)
  - `grep 'ana:dynamic' website/content/docs/**/*.mdx` should return zero matches (markers removed)
  - Open a page with dynamic values in the Next.js dev server to visually confirm rendered numbers
- **Edge case: dev mode without prebuild.** If `data/docs/` doesn't exist, `DocsStat` will fail when data functions try to read missing JSON files. This is the existing behavior for all data-dependent components — not introduced by this change. The error message from `readFileSync` is clear enough.
- **Regression: CLI tests.** Run `(cd packages/cli && pnpm vitest run)` to confirm no regressions. Expected: 104 files, 2297 passed, 2 skipped — identical to baseline.

## Dependencies

- `data/docs/` JSON files must exist before `DocsStat` renders. This is already guaranteed by the prebuild lifecycle hook — `tsx scripts/extract-docs-data.ts` runs before `next build`.
- The prebuild script must be able to import from `../lib/docs-data/docsStatValues.js`. The script runs via `tsx` which resolves relative TypeScript imports with `.js` extensions.

## Constraints

- `DocsStat` must render as `<span>` (inline element), not `<div>`. The `medianTimings` callsite is inside a `<p>` tag — a `<div>` would break HTML nesting and cause hydration errors.
- The `resolveDocsStatTags` regex must match the exact component syntax used in MDX: `<DocsStat value="..." />`. Self-closing, double-quoted value prop, optional whitespace.
- The existing `stripJsx` regex for self-closing components (`/<[A-Z]\w*\s*(?:[^>]*?)?\s*\/>/g`) will match `<DocsStat ... />`. Resolution MUST happen before this regex runs, or the values are stripped to empty string.

## Gotchas

- **Execution order in prebuild.** `generateLlmsTxt` must receive the values map from `main()`. The proof entries, skill count, and gotcha count are already computed earlier in `main()` — but median timings are not. The prebuild `updateDynamicMdxValues` computed medians locally. After deletion, `main()` must compute median timings (same algorithm as `getMedianTimings()` in `lib/docs-data/proofs.ts`) or call a shared helper to build the map. The simplest path: compute the values map in `main()` using the same logic as `buildDocsStatValues`, passing the already-available `proofEntries`, `skillTemplates.length`, and `gotchas.length`. This duplicates the median calculation but it's 5 lines and runs once at build time.

  Actually — since the prebuild can import `buildDocsStatValues` from `../lib/docs-data/docsStatValues.js`, and `buildDocsStatValues` takes raw data (not file-reading functions), it works cleanly. The prebuild computes the median from its own `proofEntries`, packages it with skill/gotcha counts, and calls `buildDocsStatValues`. No duplication of the values map.

- **MDX import vs mdxComponents injection.** Check whether MDX files in this project import components or rely on the `mdxComponents` injection in `page.tsx`. If they use injection (likely — `Callout`, `NextCards`, etc. are in `mdxComponents`), then `DocsStat` needs no import in MDX files. If some files DO import, follow the same pattern. Read one MDX file that uses `Callout` to verify.

- **The two `stripJsx` copies remain separate.** This fix does not unify them — that's a separate concern (and noted as a proof finding). Both get DocsStat resolution, but via different mechanisms: lib version imports data functions directly, prebuild version receives a values map as parameter.

- **`git rm --cached` on the 3 public files.** Other branches that track these files will see a merge artifact on rebase. One-time cost, trivially resolved (files are regenerated). The alternative — not fixing — means every branch pays rebase conflicts forever.

- **NextCards `description` JSX fragment syntax.** The change from `description: "text"` to `description: <><DocsStat ... /> text</>` requires that MDX compilation handles JSX fragments inside object literal props. This is standard MDX/JSX — fumadocs compiles MDX via `@mdx-js/mdx` which supports this. But verify by building after the change.

## Build Brief

### Rules That Apply
- Named exports only, no defaults — this is enforced project-wide.
- Explicit return types on all exported functions.
- Exported functions require `@param` and `@returns` JSDoc tags.
- Relative imports use `.js` extensions (`import { foo } from './bar.js'`).
- Use `import type` for type-only imports, separate from value imports.
- `DocsStat` is a server component — no `'use client'` directive.

### Pattern Extracts

**StatsStrip — server component structure** (`website/components/docs/content/StatsStrip.tsx:1-17`):
```typescript
interface Stat {
  value: string;
  label: string;
}

interface StatsStripProps {
  items: Stat[];
}

export function StatsStrip({ items }: StatsStripProps) {
  return (
    <div
      className="docs-stats-grid"
      style={{
```

**mdxComponents registration** (`website/app/docs/[...slug]/page.tsx:21-32`):
```typescript
const mdxComponents = {
  pre: CodeBlock,
  h2: ({ children, id, ...props }: { children?: React.ReactNode; id?: string } & Record<string, unknown>) => (
    <HeadingWithAnchor level={2} id={id} {...props}>{children}</HeadingWithAnchor>
  ),
  Callout,
  NextCards,
  StatsStrip,
  ForPlatform,
  PipelineDiagram,
  TroubleCard,
};
```

**Lib barrel re-exports** (`website/lib/docs-data/index.ts:20-26`):
```typescript
export { getProofEntries, getProofBySlug, getProofStats, getMedianTimings } from './proofs';
export { getAgentTemplates, getAgentByName, getAgentCount } from './agents';
export { getSkillTemplates, getSkillByName, getSkillCount } from './skills';
export { getCommands, getCommandCount, getCommandGroups } from './commands';
export { getContextFiles } from './context';
export { getGotchas, getGotchaCount } from './gotchas';
export { getBuildMeta } from './meta';
```

**Prebuild main() data flow** (`website/scripts/extract-docs-data.ts:1170-1185`):
```typescript
  // Update dynamic values in MDX files
  updateDynamicMdxValues(proofEntries, skillTemplates.length, gotchas.length);

  // Generate search index
  const searchIndex = generateSearchIndex(proofEntries, commands, agentTemplates, skillTemplates);
  writeJSON('search-index.json', searchIndex);
  const publicDir = path.join(WEBSITE_DIR, 'public');
  fs.writeFileSync(path.join(publicDir, 'search-index.json'), JSON.stringify(searchIndex, null, 2) + '\n', 'utf-8');

  // Generate llms.txt files
  const { llmsTxt, llmsFullTxt } = generateLlmsTxt(searchIndex);
```

**Prebuild stripJsx usage in generateLlmsTxt** (`website/scripts/extract-docs-data.ts:933-941`):
```typescript
      } else if (item.endsWith('.mdx')) {
        const mdxContent = fs.readFileSync(fullPath, 'utf-8');
        const { frontmatter, body } = parseFrontmatter(mdxContent);
        const title = (frontmatter.title as string) || item.replace('.mdx', '');
        fullLines.push('---', '', `## ${title}`, '');
        if (frontmatter.description) {
          fullLines.push(`> ${frontmatter.description}`, '');
        }
        fullLines.push(stripJsx(body), '');
```

### Proof Context

**`website/scripts/extract-docs-data.ts`** — 3 relevant findings:
- [code] Duplicate stripJsx implementation — one in lib, another inlined in script *(Known, not addressed by this scope — noted as out-of-scope)*
- [build concern] Prebuild MDX text replacement relies on fragile regex patterns around ana:dynamic markers *(This is the disease we're curing)*
- [code] Unused variable 'other' in generateLlmsTxt *(Unrelated, don't touch)*

No active proof findings for `stripJsx.ts` or `page.tsx`.

### Checkpoint Commands

- After creating `docsStatValues.ts` + `DocsStat.tsx`: `npx tsc --noEmit -p website/tsconfig.json` — Expected: no type errors on new files
- After all MDX + script changes: `(cd website && pnpm build)` — Expected: prebuild succeeds, Next.js SSG succeeds, no errors
- After build: `grep '<DocsStat' website/public/llms-full.txt` — Expected: zero matches
- After build: `grep 'ana:dynamic' website/content/docs/**/*.mdx` — Expected: zero matches
- CLI regression: `(cd packages/cli && pnpm vitest run)` — Expected: 104 files, 2297 passed, 2 skipped

### Build Baseline
- Current tests: 2297 passed, 2 skipped
- Current test files: 104
- Command used: `(cd packages/cli && pnpm vitest run)`
- After build: expected 2297 passed, 2 skipped in 104 files (no new tests — website has no test infra)
- Regression focus: none in CLI. Website build (`pnpm build`) is the functional test.
