From ff8bb49908aee0b709e75e2d58aa72e8fd96295c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:37:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(docs):=20forge=20docs=20render=20=E2=80=94?= =?UTF-8?q?=20machine-owned=20doc=20surfaces=20generated=20from=20the=20re?= =?UTF-8?q?gistries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs check could only DETECT drift between the registries and the prose; every fix was still a human hand-editing tables across five files. This closes the loop with the marker-managed-block pattern bench already uses for reports/benchmarks.md: - README command table + GUIDE group map rendered from COMMANDS/GROUPS — the docs now show exactly the --help grouping, one source of truth. - The GUIDE MCP tool table rendered from the TOOLS registry. - Every literal "N MCP tools" count phrase auto-corrected across the six files it lives in. - One shared mermaid theme derived from brand.json (never hardcoded hexes) with the readability settings the hand-written init strings lacked — normalized across every tracked markdown file; deliberate bad-example blocks opted out via docs-check-ignore stay untouched. - A repo map in ARCHITECTURE.md drawn from the live import graph (directories sized by file count, edges = import counts), so the architecture picture cannot drift from the tree it describes. docs check gains a render reconciler: a stale registry-derived block is an ERROR whose message is the fix (run forge docs render); tree-derived output (repo map, diagram theme) is a WARNING so moving a file never fails an unrelated PR, but a new command with a stale table always does. Roots without markers manage nothing and stay ok (fixture safety). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LXmzxfRVDRVPU6LG8W39Rz --- ARCHITECTURE.md | 36 +++- CHANGELOG.md | 15 ++ ONBOARDING.md | 2 +- README.md | 96 ++++----- docs/GUIDE.md | 97 ++++++--- docs/cognitive-substrate/README.md | 2 +- docs/plans/substrate-v2/00-overview.md | 2 +- mintlify/cli/core.mdx | 6 +- src/cli.js | 22 ++ src/commands.js | 10 +- src/docs_check.js | 24 +++ src/docs_render.js | 282 +++++++++++++++++++++++++ test/docs_render.test.js | 125 +++++++++++ 13 files changed, 631 insertions(+), 88 deletions(-) create mode 100644 src/docs_render.js create mode 100644 test/docs_render.test.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bbc8bf1..6bf27ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -42,7 +42,7 @@ native config. The four layers are how the brain is expressed; the compiler is h is delivered. ```mermaid -%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart TD S["source/
rules.json · substrate.json · mcp.json"] -->|"forge sync
content-hash + DO-NOT-EDIT headers"| N["native configs
CLAUDE.md · AGENTS.md · .cursor · .gemini · .aider · …"] S -. configures .-> L @@ -87,7 +87,7 @@ checks and returns a single verdict. It composes the individually-callable stage `anchor`, `verify`) into one pre-action contract. ```mermaid -%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart TD RE["referenced entities"] --> INTAKE subgraph INTAKE["intake"] @@ -136,7 +136,7 @@ claims into `.forge/ledger/`. Because a claim's bytes are a pure function of fold together over plain git with no conflicts. ```mermaid -%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR subgraph EV["local events"] direction TB @@ -171,7 +171,7 @@ dependencies still resolve. Otherwise it falls through to generation and mints a claim on the way back. ```mermaid -%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR SP["spec"] --> FP["fingerprint
MinHash + LSH"] FP --> LD["match ladder
exact → near → adapt → miss"] @@ -510,3 +510,31 @@ asserts all three resolve to `global/`. See [ROADMAP.md](ROADMAP.md) for direction and [`docs/adr/`](docs/adr/) for the recorded architecture decisions (zero runtime deps, the SKILL.md standard, guard-over-prose). + +## Repo map (generated) + +Top-level directories sized by file count, edges = import counts between them — +rendered from the live import graph by `forge docs render`, so it can never drift +from the tree it describes. + + +```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% +flowchart LR + test["test
98 files"] + src["src
93 files"] + landing["landing
60 files"] + research["research
35 files"] + bench["bench
2 files"] + global["global
2 files"] + scripts["scripts
2 files"] + docs["docs
1 file"] + examples["examples
1 file"] + test -- 191 --> src + bench -- 7 --> src + examples -- 4 --> src + test -- 2 --> scripts + scripts --> src + test --> bench +``` + diff --git a/CHANGELOG.md b/CHANGELOG.md index b3cceb1..0c93d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **`forge docs render` — the docs that can write themselves, do.** `docs check` could + only detect drift; every fix was still a human hand-editing tables across five files. + The derivable doc surfaces are now generated from the same registries the check reads, + into marker-managed blocks (the `reports/benchmarks.md` pattern): the README command + table and the GUIDE group map from `COMMANDS`/`GROUPS`, the MCP tool table from the + `TOOLS` registry, every literal "N MCP tools" count phrase across all six files it + lives in, one shared mermaid theme derived from `brand.json` (change the brand, + re-render, every diagram in every tracked markdown file re-themes), and a repo map in + `ARCHITECTURE.md` drawn from the live import graph. `docs check` gains a `render` + reconciler: a stale registry-derived block is an error whose message is the fix + (`forge docs render`); tree-derived output (repo map, diagram theme) warns without + failing unrelated PRs. + ## [0.28.0] - 2026-08-07 ### Added diff --git a/ONBOARDING.md b/ONBOARDING.md index dc72dce..590e50c 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -11,7 +11,7 @@ This page is the fast path: install, configure a repo, do a task, and watch the start paying off on day two. ```mermaid -%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart TD I["forge init"] --> Cfg["every tool configured
from one source"] Cfg --> Work["you work as usual"] diff --git a/README.md b/README.md index 1f75552..f1485fb 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ same instructions and MCP tools to invoke. Every recorded outcome flows back int proof-carrying memory. ```mermaid -%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR T["task"] --> G["substrate gate
assume · route · reuse
context · impact"] G -->|unclear| Q["ask clarifying
questions first"] @@ -210,52 +210,54 @@ this wiring for you via `forge init --settings-only` — an idempotent, marker-g that never clobbers your existing settings (skip it with `install.sh --no-settings`; `install.sh --uninstall` or `forge init --remove-settings` reverses it). -| Group | Command | Does | -| -------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Config layer** | `forge init` | emit every tool's native config from one source | -| | `forge sync` | recompile canonical source → each tool's native files (idempotent) | -| | `forge tools` | primary-tool config — gitignore secondary-tool artifacts (`.cursor/`, `.gemini/`, …) for tools this repo doesn't use; `forge tools ` sets it, `--reset` clears | -| | `forge doctor` | pass/fail health check: tools, guards, MCP, drift, update | -| | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it, `--to ` pins/downgrades | -| | `forge docs` | docs↔code drift — `check` reconciles commands/env/MCP/CHANGELOG; `sync` sweeps the diff for stale doc mentions; `impact` maps which docs reference the entities THIS diff changed | -| | `forge config` | provider setup — show / switch / add providers, set the default model | -| | `forge integrations` | opt-in third-party MCP servers (e.g. context7) — `add` records the managed set and writes only with `--yes` (`--adopt` claims a same-name entry you already had); `remove` reverses it | -| | `forge harden` | wire the pre-commit gate (gitleaks + commit gate) + sandbox settings | -| | `forge catalog` | Start-Here index of every tool / crew / guard | -| | `forge brand` | print the brand token map | -| **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / at / diff / root / ratify / retract / merge / sync / import | -| | `forge recall` | cross-session personal memory — list / add / consolidate | -| | `forge remember` | durable, repo-committable fact | -| | `forge brain` | portable project-memory index | -| | `forge cortex` | self-correcting lessons — `status` / `why` | -| | `forge deja` | anti-repetition — ranks prior solved/verified sessions for a task you're about to start (`FORGE_DEJA=0` disables) | -| | `forge reuse` | proof-carrying code cache — query / mint / stats | -| | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start | -| | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding | -| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total routing, an unsure fact still lands | -| **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass | -| | `forge preflight` | assumption / info-gap check | -| | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) | -| | `forge impact` | predict blast radius for a symbol or file | -| | `forge rank` | load-bearing code — PageRank centrality × past-incident history, circular imports, chokepoint files | -| | `forge scope` | cluster + surface coupled files | -| | `forge imagine` | consequence sim + minimal dry-run suite (`--run` executes it sandboxed) | -| | `forge context` | budgeted context assembly + completeness gate | -| | `forge atlas` | build / query / has (hallucinated-symbol check) the code graph | -| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests | -| | `forge anchor` | goal-drift check (advisory) — `set`/`show`/`clear` persists the goal across sessions | -| | `forge diagnose` | doom-loop: same failure 3× → diagnosis + escalation | -| | `forge lean` | scope-minimality footprint (advisory) | -| | `forge cost` | real per-day spend · measured stage factors (`--stages`) | -| **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance; `--deep` multi-lens consensus (`--llm` reviewer panel) | -| | `forge precommit` | commit-level gate rung — staged code w/o docs + secret scan (`FORGE_COMMIT_GATE=block\|warn\|0`) | -| | `forge radar` | dependency-currency rings (adopt/trial/assess/hold) from registry evidence — cached, offline-honest | -| | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil | -| | `forge spec` | spec-as-contract drift — init / lock / check | -| **UI / design** | `forge taste` | pick one visual direction → DESIGN.md | -| | `forge uicheck` | contrast · fingerprint · design · visual (WCAG · slop+conformance · Playwright) | -| **Observability** | `forge dash` | localhost-only live dashboard: ledger, metrics trends, radar rings, memory browser, session timeline, blast radius (default port 4242) | -| | `forge report` | static, self-contained HTML snapshot of `.forge/` (`.forge/report.html`) — opens offline, no server | + +| Group | Command | Does | +| ----------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Core** | `forge init` | scaffold this repo's config — emits every tool from one shared source | +| | `forge sync` | recompile the canonical source into each tool's native config files | +| | `forge doctor` | health-check installed tools, guards, MCP auth, and config drift | +| | `forge tools` | primary-tool config — gitignore secondary-tool artifacts (.cursor/.gemini/…) for tools this repo doesn't use (`forge tools ` sets it, `--reset` clears) | +| | `forge catalog` | Start Here — list every tool, crew, and guard with a one-line why | +| | `forge docs` | docs↔code drift — check (registry reconcile) / render (regenerate machine-owned tables + diagrams) / sync (diff-driven stale-docs sweep) / impact (reusable doc-reference graph: which docs mention what THIS diff changed) | +| | `forge update` | self-update — `--check` reports if a newer version is available, bare applies it, `--to ` pins/downgrades | +| | `forge config` | provider setup — show / switch / add providers, set default model | +| **Substrate** | `forge substrate` | one pre-action gate: assumptions, route, impact, scope, memory, verify | +| | `forge preflight` | assumption check — what a task names that the repo doesn't define | +| | `forge impact` | predict blast radius for a symbol or file from the atlas graph | +| | `forge scope` | decompose files into independent clusters (+ coupled files you didn't name) | +| | `forge context` | budgeted context assembly + completeness gate — what an edit NEEDS known | +| | `forge route` | recommend the cheapest capable model for a task (+ gateway config) | +| | `forge verify` | independent verification gate — tests + hallucinated-symbol + provenance (--deep: multi-lens consensus) | +| | `forge precommit` | commit-level gate — staged code w/o docs + secret scan (FORGE_COMMIT_GATE=block|warn|0) | +| **Memory** | `forge cortex` | self-correcting project memory — status / why | +| | `forge recall` | manage cross-session memory (list / add / consolidate) | +| | `forge remember` | add a durable fact to this repo's portable memory (forge brain) | +| | `forge brain` | show / rebuild the portable project memory index | +| | `forge ledger` | evidence-referenced memory — stats / verify / show / blame / query / at / diff / root / ratify / retract / merge / sync / import | +| | `forge handoff` | bounded session snapshot — rewrite .forge/state.md, re-injected each session start | +| | `forge decide` | append-only decision log — D-#### ADR-lite entries in .forge/decisions.md | +| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total, never dropped | +| **Quality** | `forge scan` | vet a skill/MCP for injection/RCE/exfil before install (skill-gate) | +| | `forge spec` | spec-as-contract — init (OpenSpec) / lock / check drift | +| | `forge harden` | wire security controls — pre-commit gate (gitleaks + commit gate) + sandbox settings | +| | `forge radar` | dependency-currency rings — staleness/major-lag/advisories from live registry evidence, cached 24h | +| **Config** | `forge brand` | print the active brand token map | +| | `forge atlas` | build / query the code-graph (where-is-Y, has-symbol) | +| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests | +| | `forge integrations` | opt-in third-party MCP servers (e.g. context7) — add records the managed set and writes only with --yes (--adopt claims a same-name entry); remove reverses it | +| | `forge cost` | real per-day spend via ccusage + measured stage factors (--stages) | +| **Labs (experimental)** | `forge taste` | enable one UI-taste tool for this repo (no arg = list) | +| | `forge uicheck` | deterministic UI checks — contrast · fingerprint · design · visual | +| | `forge imagine` | consequence simulation — predicted breaks + the minimal dry-run test suite for a task | +| | `forge lean` | scope-minimality (M5) — measure the diff's footprint vs what the task asked for | +| | `forge anchor` | goal-drift check — are your actual (git) changes still on the stated goal? | +| | `forge diagnose` | doom-loop check — record a failure; 3× the same signature mints a diagnosis + escalation | +| | `forge dash` | live dashboard: ledger, metrics trends, radar, memory browser, timeline, blast radius | +| | `forge report` | emit a static, self-contained HTML snapshot of .forge/ — opens offline, no server | +| | `forge deja` | anti-repetition — have you done this task before? ranks prior solved/verified sessions | +| | `forge reuse` | proof-carrying code cache — query / mint --file / stats | +| | `forge rank` | load-bearing code — PageRank centrality × past-incident history, circular-dependency clusters, chokepoint files | + **→ Every command with a worked example and real output: [`docs/GUIDE.md`](docs/GUIDE.md).** diff --git a/docs/GUIDE.md b/docs/GUIDE.md index b7671fb..55da132 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -25,15 +25,16 @@ recipes, and how to extend each piece. If you just want to get going, the Every command is real and wired. Grouped by what it does: -| Group | Commands | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Config / cross-tool sync** | `forge init` · `forge sync` · `forge tools` · `forge doctor` · `forge update` · `forge docs` · `forge config` · `forge harden` · `forge catalog` · `forge brand` | -| **Memory & ledger (PCM)** | `forge ledger` · `forge recall` · `forge remember` · `forge brain` · `forge cortex` · `forge reuse` · `forge handoff` · `forge decide` · `forge know` | -| **Code graph & retrieval** | `forge atlas` · `forge stack` · `forge context` | -| **Substrate / pre-action** | `forge substrate` · `forge preflight` · `forge route` · `forge impact` · `forge rank` · `forge scope` · `forge imagine` · `forge anchor` · `forge diagnose` · `forge lean` · `forge cost` | -| **Verification & safety** | `forge verify` · `forge precommit` · `forge radar` · `forge scan` · `forge spec` | -| **UI / design** | `forge taste` · `forge uicheck` | -| **Dashboard** | `forge dash` | + +| Group | Commands | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Core** | `forge init` · `forge sync` · `forge doctor` · `forge tools` · `forge catalog` · `forge docs` · `forge update` · `forge config` | +| **Substrate** | `forge substrate` · `forge preflight` · `forge impact` · `forge scope` · `forge context` · `forge route` · `forge verify` · `forge precommit` | +| **Memory** | `forge cortex` · `forge recall` · `forge remember` · `forge brain` · `forge ledger` · `forge handoff` · `forge decide` · `forge know` | +| **Quality** | `forge scan` · `forge spec` · `forge harden` · `forge radar` | +| **Config** | `forge brand` · `forge atlas` · `forge stack` · `forge integrations` · `forge cost` | +| **Labs (experimental)** | `forge taste` · `forge uicheck` · `forge imagine` · `forge lean` · `forge anchor` · `forge diagnose` · `forge dash` · `forge report` · `forge deja` · `forge reuse` · `forge rank` | + Storage in one line: the code graph is `.forge/atlas.json` (plain JSON, not SQLite); the ledger is content-addressed claims under `.forge/ledger/` (git-committable, union-merge). @@ -70,7 +71,7 @@ The daily loop — every outcome an oracle observes lands in the team ledger, an ledger informs the next task: ```mermaid -%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR W["work — substrate pre-checks,
then edit"] --> O["oracles — forge verify ·
imagine --run · CI · human accept/revert"] O -->|"outcomes move claim val"| L[("team ledger
.forge/ledger/")] @@ -413,6 +414,37 @@ dispatch, like every forge store. With `ENABLE_CORTEX_DISTILL=1`, distilled Cort that read like decisions or durable facts are auto-routed to those homes too (fail-open, best-effort). +### `forge docs render` — the docs that can write themselves, do + +`forge docs check` can only _detect_ drift; every fix was still a human hand-editing +tables in five files. `docs render` closes the loop: the derivable doc surfaces are +generated from the same registries the check reads, into marker-managed blocks +(`` — the same pattern `npm run bench` uses for +`reports/benchmarks.md`). Prose stays human; tables and diagrams become machine-owned: + +- the README command table and this guide's group map — from `COMMANDS`/`GROUPS` + (exactly the `--help` grouping, one source of truth); +- the MCP tool table — from the `TOOLS` registry; +- every literal "N MCP tools" count phrase, across all six files it lives in; +- one shared mermaid theme derived from `brand.json` — change the brand, re-render, + every diagram in every tracked markdown file re-themes (deliberate bad examples + opted out with `docs-check-ignore` are left alone); +- the repo map in `ARCHITECTURE.md` — drawn from the live import graph, so it cannot + drift from the tree it describes. + +```console +$ forge docs render + rendered: README.md (block commands-table) +$ forge docs render --check + all generated doc surfaces current +``` + +`--check` reports without writing (exit 1 on drift), and `docs check` runs the same +reconciler in CI: a stale registry-derived block is an **error** whose message is the +fix (`run forge docs render`), while tree-derived output (the repo map, diagram theme) +is a warning — moving a file never fails an unrelated PR, but a new command with a +stale table always does. + ### `forge docs sync` — which prose did this diff make stale? `forge docs check` reconciles the registries; `docs sync` answers the diff-shaped @@ -1207,27 +1239,30 @@ emitted `.mcp.json`): -| MCP tool | Does | -| ---------------------------------------- | ------------------------------------------------------ | -| `substrate_check` | full pre-action check | -| `preflight_check` | assumption / info-gap check | -| `assumption_gate` | ask/proceed + questions | -| `predict_impact` | blast radius (code **and** the docs that reference it) | -| `route_task` | model recommendation | -| `scope_files` | independent vs. coupled | -| `cortex_lessons` | learned lessons for given files/symbols | -| `cortex_status` | memory lifecycle summary | -| `forge_brain` | durable project facts | -| `forge_ledger_query` | ranked retrieval over the PCM ledger | -| `forge_remember` | **write**: add a durable project fact | -| `forge_ledger_ratify` | **write**: human-ratify a claim into a decision | -| `forge_ledger_retract` | **write**: tombstone a claim | -| `rank_code` | load-bearing files/symbols, cycles, chokepoints | -| `forge_diagnose` | doom-loop failure check | -| `forge_doctor` | health check | -| `forge_provider_status` | provider detection + gateway reachability | -| `forge_cost` | spend + stage factors | -| `forge_dash_data` / `forge_dash_summary` | dashboard data feeds | + +| MCP tool | Does | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cortex_lessons` | Lessons Forge Cortex learned from past mistakes on THIS repo, for the given files/symbols. | +| `cortex_status` | Summary of learned lessons on this repo (counts by state, top by confidence). | +| `preflight_check` | BEFORE starting a task, check what it names that the repo doesn't define — the things you'd otherwise ASSUME. | +| `route_task` | Recommend the cheapest CAPABLE model for a task by code-task complexity (files, fan-out, churn, past mistakes, ambiguity). | +| `assumption_gate` | Score specification completeness before work starts. | +| `predict_impact` | Predict blast radius for a symbol or file using Forge atlas reverse-dependency traversal. | +| `substrate_check` | Full Forge cognitive-substrate pre-action check: assumption gate, route, impact, scope, memory, minimality, and verification checklist. | +| `scope_files` | Decompose files into INDEPENDENT clusters (run as separate sessions) vs coupled, and surface coupled files you didn't name (the 'forgot the related module' guard). | +| `forge_cost` | Cost report — measured stage factors (gate, cache, route, context) from .forge/metrics.jsonl with multiplicative composition. | +| `forge_dash_data` | Dashboard JSON payload — ledger stats, metrics, atlas info. | +| `forge_dash_summary` | Lightweight dashboard health check — just counts (claims, tombstoned, contested, atlas built, metric events). | +| `forge_brain` | Project memory index — list all remembered facts stored in .forge/brain/. | +| `forge_ledger_query` | Query the proof-carrying memory ledger with a natural language query. | +| `forge_diagnose` | Doom-loop check — record a failure and check if the same signature has recurred (3x = escalation). | +| `forge_doctor` | Health check — verify installed tools, guards, MCP auth, config drift, and system state. | +| `forge_provider_status` | Provider detection — which API provider is active (auto-detected or configured), env vars set, and health checks. | +| `forge_remember` | Store a durable fact in this repo's portable memory (.forge/brain/). | +| `forge_ledger_ratify` | Promote a ledger claim's confidence — record an independent oracle ratification (the claim held under test). | +| `rank_code` | Which code is load-bearing and dangerous to touch — PageRank centrality over the Forge atlas graph joined with past-incident history from the evidence ledger, plus circular-dependency clusters and chokepoint files whose removal disconnects the import graph. | +| `forge_ledger_retract` | Tombstone a ledger claim with a reason — mark it as no longer valid so it stops influencing routing and memory. | + Forge never pretends it can force a hook into a tool that has none — **ambient on Claude Code, agent-invoked everywhere else.** diff --git a/docs/cognitive-substrate/README.md b/docs/cognitive-substrate/README.md index 19759ce..d1622b5 100644 --- a/docs/cognitive-substrate/README.md +++ b/docs/cognitive-substrate/README.md @@ -179,7 +179,7 @@ call proposes a completeness reading (M2), a complexity band (M1), the coupled e regex graph misses (impact), and whether an off-goal file actually serves the goal (M4). ```mermaid -%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR T["task / edit"] --> R["deterministic rubric"] T --> P["LLM proposer"] diff --git a/docs/plans/substrate-v2/00-overview.md b/docs/plans/substrate-v2/00-overview.md index 0e9e98c..96aa52d 100644 --- a/docs/plans/substrate-v2/00-overview.md +++ b/docs/plans/substrate-v2/00-overview.md @@ -68,7 +68,7 @@ later phase stores its state as PCM claims. **All phases have shipped** (v0.5.0) All nodes below are shipped (green); the color is the legend. ```mermaid -%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR P0["P0 specs"] --> P1["P1 ledger core"] P1 --> P2["P2 team sync"] diff --git a/mintlify/cli/core.mdx b/mintlify/cli/core.mdx index 3037d17..72736b7 100644 --- a/mintlify/cli/core.mdx +++ b/mintlify/cli/core.mdx @@ -90,12 +90,16 @@ Docs ↔ code drift. ```bash forge docs check # registry reconcile — commands, env vars, MCP tools, CHANGELOG +forge docs render # regenerate machine-owned tables, counts, and diagrams from the registries forge docs sync # diff-driven stale-docs sweep ``` `docs check` fails CI when commands, env vars, MCP tools, or the CHANGELOG drift from - the code. `docs sync` sweeps the diff and reports UPDATED / STALE / VERIFIED-UNAFFECTED. + the code — and when a generated block is stale, the error names the fix: `forge docs + render` regenerates the command tables, MCP tool table, count phrases, shared mermaid + theme, and the repo map from the code registries. `docs sync` sweeps the diff and + reports UPDATED / STALE / VERIFIED-UNAFFECTED. ## `forge update` diff --git a/src/cli.js b/src/cli.js index f4a0d27..d781642 100755 --- a/src/cli.js +++ b/src/cli.js @@ -513,6 +513,28 @@ HANDLERS.docs = async (argv) => { if (argv.includes("--strict") && r.impacted.length) process.exitCode = 1; return; } + // `render` — regenerate the machine-owned doc surfaces (command tables, MCP tool + // table, count phrases, mermaid theme, repo map) from the registries. The write-side + // twin of `check`: check tells you docs drifted, render is the one-command repair. + if (sub === "render") { + const { renderDocs } = await import("./docs_render.js"); + const check = argv.includes("--check"); + const r = renderDocs(undefined, { write: !check }); + if (json) { + console.log(JSON.stringify(r, null, 2)); + if (check && !r.ok) process.exitCode = 1; + return; + } + for (const f of r.files) + console.log( + ` ${check ? "stale" : "rendered"}: ${f.file} ${paint(`(${f.why.join(", ")})`, "dim")}`, + ); + for (const m of r.missing) + console.error(` ${paint(`missing markers for block ${m.name} in ${m.file}`, "err")}`); + if (!r.files.length && !r.missing.length) console.log(" all generated doc surfaces current"); + if (check && !r.ok) process.exitCode = 1; + return; + } // `check` — self-check of the forge package's own docs against its code (commands // table, env reads, MCP registry, CHANGELOG). const { docsCheck } = await import("./docs_check.js"); diff --git a/src/commands.js b/src/commands.js index fb6db8b..5ab7369 100644 --- a/src/commands.js +++ b/src/commands.js @@ -124,8 +124,9 @@ export const COMMANDS = { brand: "print the active brand token map", docs: { summary: - "docs↔code drift — check (registry reconcile) / sync (diff-driven stale-docs sweep) / impact (reusable doc-reference graph: which docs mention what THIS diff changed)", - usage: "forge docs [check | sync | impact] [--since | --staged] [--strict] [--json]", + "docs↔code drift — check (registry reconcile) / render (regenerate machine-owned tables + diagrams) / sync (diff-driven stale-docs sweep) / impact (reusable doc-reference graph: which docs mention what THIS diff changed)", + usage: + "forge docs [check | render | sync | impact] [--check] [--since | --staged] [--strict] [--json]", flags: [ { flag: "--since ", @@ -143,9 +144,14 @@ export const COMMANDS = { flag: "--strict", desc: "exit non-zero when stale/impacted docs are found (for CI; advisory otherwise)", }, + { + flag: "--check", + desc: "render: report stale generated blocks without writing (exit 1 on strict drift)", + }, ], examples: [ "forge docs check", + "forge docs render", "forge docs sync", "forge docs impact", "forge docs impact --since main", diff --git a/src/docs_check.js b/src/docs_check.js index fbc0c38..102a1f8 100644 --- a/src/docs_check.js +++ b/src/docs_check.js @@ -8,6 +8,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join, normalize } from "node:path"; import { BRAND } from "./brand.js"; import { COMMANDS, HIDDEN_COMMANDS } from "./commands.js"; +import { renderDocs } from "./docs_render.js"; import { TOOLS } from "./mcp_tools.js"; import { allPricePairs } from "./model_tiers.js"; import { git } from "./util.js"; @@ -73,6 +74,27 @@ export function envVarsRead(root = BRAND.root) { return vars; } +/** Generated doc surfaces vs the registries: a stale machine-owned block is not a + * judgement call — `forge docs render` IS the fix, so the error says exactly that. + * Registry-derived blocks (tables, counts) are errors; tree-derived output (repo map, + * mermaid theme) is a warning so moving a file never fails an unrelated PR. */ +function checkRendered(root, issues) { + let r; + try { + r = renderDocs(root, { write: false }); + } catch { + return; // never let the renderer take the whole check down + } + for (const f of r.files) { + if (!f.changed) continue; + issues.push({ + check: "render", + severity: f.strict ? "error" : "warn", + detail: `${f.file}: generated ${f.why.join(" + ")} out of date — run \`${BRAND.cli} docs render\``, + }); + } +} + /** Commands table vs README/GUIDE: every command documented, nothing phantom. */ function checkCommands(docs, issues) { for (const target of ["README.md", "docs/GUIDE.md"]) { @@ -558,6 +580,7 @@ export function docsCheck({ root = BRAND.root } = {}) { const docs = Object.fromEntries(DOC_FILES.map((f) => [f, readDoc(root, f)])); const issues = []; checkCommands(docs, issues); + checkRendered(root, issues); checkEnvVars(root, docs, issues); checkMcpTools(docs, issues); checkChangelog(root, issues); @@ -573,6 +596,7 @@ export function docsCheck({ root = BRAND.root } = {}) { issues, checked: [ "commands", + "render", "env-vars", "mcp-tools", "changelog", diff --git a/src/docs_render.js b/src/docs_render.js new file mode 100644 index 0000000..57c3e6f --- /dev/null +++ b/src/docs_render.js @@ -0,0 +1,282 @@ +// forge docs render — the docs write themselves where they can. docs_check.js can only +// DETECT drift between the registries and the prose; every fix was still a human +// hand-editing tables in five files. This module closes the loop: the derivable parts +// of the docs (command tables, the MCP tool table, the "N MCP tools" count phrases, the +// mermaid theme, the repo map) are RENDERED from the same registries the check reads — +// COMMANDS/GROUPS, mcp_tools.TOOLS, brand.json, the live import graph — into +// marker-managed blocks, exactly the pattern bench/bench.mjs already uses for +// reports/benchmarks.md. Prose stays human; tables and diagrams become machine-owned. +// `forge docs render` regenerates; `--check` (and the docs-check reconciler) fails CI +// with the exact command to run, so "docs out of sync" becomes a one-command repair. +// +// Registry-derived blocks (tables, counts) are enforced as ERRORS; tree-derived output +// (the repo-map diagram, mermaid theme normalization) is advisory — a moved file should +// not fail an unrelated PR, but a new command with a stale table must. +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BRAND } from "./brand.js"; +import { commandSummary, GROUPS } from "./commands.js"; +import { TOOLS } from "./mcp_tools.js"; +import { directedImportGraph } from "./scope.js"; +import { git } from "./util.js"; + +const BEGIN = (name) => + ``; +const END = (name) => ``; + +/** Pad a markdown table so the raw text stays readable (the repo's tables are aligned). */ +function mdTable(headers, rows) { + const all = [headers, ...rows]; + const width = headers.map((_, i) => Math.max(...all.map((r) => String(r[i]).length))); + const line = (r) => `| ${r.map((c, i) => String(c).padEnd(width[i])).join(" | ")} |`; + const rule = `| ${width.map((w) => "-".repeat(w)).join(" | ")} |`; + return [line(headers), rule, ...rows.map(line)].join("\n"); +} + +/** + * One mermaid theme for every diagram, derived from brand.json (never hardcoded hexes) — + * change the brand, run `forge docs render`, every diagram re-themes. Carries the two + * load-bearing identity hexes docs_check requires (brand → lineColor, bg → tertiaryColor) + * plus the readability settings the hand-written init strings never had. + */ +export function mermaidInit() { + const d = BRAND.colors?.dark ?? {}; + const vars = [ + `'primaryColor':'${d["bg-2"]}'`, + `'primaryTextColor':'${d.text}'`, + `'primaryBorderColor':'${d.line}'`, + `'lineColor':'${d.brand}'`, + `'secondaryColor':'${d["panel-2"]}'`, + `'tertiaryColor':'${d.bg}'`, + `'edgeLabelBackground':'${d["bg-2"]}'`, + `'clusterBkg':'${d.bg}'`, + `'clusterBorder':'${d["line-strong"]}'`, + `'fontFamily':'ui-sans-serif, system-ui, sans-serif'`, + `'fontSize':'14px'`, + ].join(","); + const layout = `'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}`; + return `%%{init: {'theme':'base','themeVariables':{${vars}},${layout}}}%%`; +} + +/** The README command table — one row per command, grouped exactly like `--help`. */ +export function renderCommandsTable() { + const rows = []; + for (const [group, names] of Object.entries(GROUPS)) { + names.forEach((name, i) => { + rows.push([i === 0 ? `**${group}**` : "", `\`${BRAND.cli} ${name}\``, commandSummary(name)]); + }); + } + return mdTable(["Group", "Command", "Does"], rows); +} + +/** The GUIDE group map — same grouping as `--help`, one row per group. */ +export function renderGroupsTable() { + const rows = Object.entries(GROUPS).map(([group, names]) => [ + `**${group}**`, + names.map((n) => `\`${BRAND.cli} ${n}\``).join(" · "), + ]); + return mdTable(["Group", "Commands"], rows); +} + +/** The MCP tool table — straight from the TOOLS registry, first sentence per tool. */ +export function renderMcpToolsTable() { + const firstSentence = (s) => { + const cut = s.indexOf(". "); + return (cut > 0 ? s.slice(0, cut + 1) : s).trim(); + }; + return mdTable( + ["MCP tool", "Does"], + TOOLS.map((t) => [`\`${t.name}\``, firstSentence(t.description)]), + ); +} + +/** + * The repo map: top-level directories sized by file count, edges = import counts between + * them, from the live import graph (scope.directedImportGraph). Deterministic ordering. + * @param {string} root + * @param {{maxDirs?:number}} [opts] + */ +export function renderRepoMap(root, { maxDirs = 9 } = {}) { + const g = directedImportGraph(root); + const dirOf = (f) => (f.includes("/") ? f.slice(0, f.indexOf("/")) : "(root)"); + const files = new Map(); // dir → count + for (const f of g.nodes) files.set(dirOf(f), (files.get(dirOf(f)) ?? 0) + 1); + const kept = [...files.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, maxDirs) + .map(([dir]) => dir); + const keptSet = new Set(kept); + const edges = new Map(); // "a→b" → count + for (const [from, targets] of [...g.edges.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + for (const to of [...targets].sort()) { + const a = dirOf(from); + const b = dirOf(to); + if (a === b || !keptSet.has(a) || !keptSet.has(b)) continue; + const key = `${a}→${b}`; + edges.set(key, (edges.get(key) ?? 0) + 1); + } + } + const id = (dir) => dir.replace(/[^A-Za-z0-9]/g, "_"); + const lines = [mermaidInit(), "flowchart LR"]; + for (const dir of kept) + lines.push( + ` ${id(dir)}["${dir}
${files.get(dir)} file${files.get(dir) === 1 ? "" : "s"}"]`, + ); + for (const [key, n] of [...edges.entries()].sort( + (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]), + )) { + const [a, b] = key.split("→"); + lines.push(` ${id(a)} ${n > 1 ? `-- ${n} --> ` : "--> "}${id(b)}`); + } + return `\`\`\`mermaid\n${lines.join("\n")}\n\`\`\``; +} + +/** Replace one managed block. Returns the new text plus whether markers were found/changed. */ +export function spliceBlock(text, name, body) { + const begin = text.indexOf(BEGIN(name)); + const end = text.indexOf(END(name)); + if (begin === -1 || end === -1 || end < begin) return { text, found: false, changed: false }; + const next = `${text.slice(0, begin)}${BEGIN(name)}\n${body}\n${text.slice(end)}`; + return { text: next, found: true, changed: next !== text }; +} + +/** Rewrite every literal "N MCP tools" phrase to the registry's real count. */ +export function updateCounts(text) { + return text.replace(/\b\d+ MCP tools\b/g, `${TOOLS.length} MCP tools`); +} + +const MERMAID_BLOCK_RE = /```mermaid\n([\s\S]*?)```/g; +const INIT_LINE_RE = /%%\{init[\s\S]*?\}%%/; + +/** Normalize every mermaid block's `%%{init` line to the one shared brand theme. + * Blocks opted out with `docs-check-ignore` (deliberate bad examples) are untouched; + * blocks with no init line are left for docs_check to flag. */ +export function normalizeMermaid(text) { + return text.replace(MERMAID_BLOCK_RE, (block, body, offset) => { + if (/docs-check-ignore/.test(text.slice(Math.max(0, offset - 80), offset))) return block; + if (!INIT_LINE_RE.test(body)) return block; + return block.replace(INIT_LINE_RE, mermaidInit()); + }); +} + +// Which generated block lives in which file. Registry-derived blocks are ERRORS when +// stale (a new command MUST regenerate); the tree-derived repo map is advisory. +const BLOCK_TARGETS = [ + { + file: "README.md", + name: "commands-table", + render: () => renderCommandsTable(), + strict: true, + }, + { + file: "docs/GUIDE.md", + name: "command-groups", + render: () => renderGroupsTable(), + strict: true, + }, + { + file: "docs/GUIDE.md", + name: "mcp-tools", + render: () => renderMcpToolsTable(), + strict: true, + }, + { + file: "ARCHITECTURE.md", + name: "repo-map", + render: (root) => renderRepoMap(root), + strict: false, + }, +]; + +// Files whose "N MCP tools" phrases are auto-corrected (the six the count lives in). +const COUNT_FILES = [ + "README.md", + "docs/GUIDE.md", + "ARCHITECTURE.md", + "ROADMAP.md", + "mintlify/quickstart.mdx", + "mintlify/concepts/config-compiler.mdx", +]; + +/** Every git-tracked markdown file (mermaid theme normalization scope). */ +function trackedMarkdown(root) { + const out = git(root, ["ls-files", "*.md"]); + return out ? out.split("\n").filter(Boolean) : []; +} + +/** + * Render every managed doc surface. With {write:true} stale files are rewritten; + * otherwise this is a pure report (what `--check` and the docs-check reconciler use). + * @param {string} [root] + * @param {{write?: boolean}} [opts] + * @returns {{ok:boolean, files:{file:string, changed:boolean, strict:boolean, why:string[]}[], + * missing:{file:string, name:string}[]}} + */ +export function renderDocs(root = BRAND.root, { write = false } = {}) { + /** @type {Map} */ + const touched = new Map(); + const missing = []; + const load = (file) => { + if (!touched.has(file)) { + let text; + try { + text = readFileSync(join(root, file), "utf8"); + } catch { + return null; + } + touched.set(file, { text, orig: text, strict: false, why: [] }); + } + return touched.get(file); + }; + + for (const t of BLOCK_TARGETS) { + const doc = load(t.file); + if (!doc) continue; + if (doc.text.indexOf(BEGIN(t.name)) === -1) { + // No markers → nothing to manage here (fixture roots, forks that opted out). + // Don't render (the repo map walks the tree) and don't flag — the registry + // reconcilers still cover the content the old hand-written way. + missing.push({ file: t.file, name: t.name }); + continue; + } + const r = spliceBlock(doc.text, t.name, t.render(root)); + if (!r.found) { + missing.push({ file: t.file, name: t.name }); + continue; + } + if (r.changed) { + doc.why.push(`block ${t.name}`); + if (t.strict) doc.strict = true; + } + doc.text = r.text; + } + for (const file of COUNT_FILES) { + const doc = load(file); + if (!doc) continue; + const next = updateCounts(doc.text); + if (next !== doc.text) { + doc.why.push("MCP tool count"); + doc.strict = true; + doc.text = next; + } + } + for (const file of trackedMarkdown(root)) { + const doc = load(file); + if (!doc) continue; + const next = normalizeMermaid(doc.text); + if (next !== doc.text) { + doc.why.push("mermaid theme"); + doc.text = next; + } + } + + const files = []; + for (const [file, doc] of touched) { + const changed = doc.text !== doc.orig; + if (changed && write) writeFileSync(join(root, file), doc.text); + if (changed || doc.why.length) files.push({ file, changed, strict: doc.strict, why: doc.why }); + } + // `missing` is informational (a root without markers manages nothing) — only STALE + // strict surfaces fail the check. + return { ok: files.every((f) => !f.strict), files, missing }; +} diff --git a/test/docs_render.test.js b/test/docs_render.test.js new file mode 100644 index 0000000..92d00f0 --- /dev/null +++ b/test/docs_render.test.js @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { BRAND } from "../src/brand.js"; +import { COMMANDS } from "../src/commands.js"; +import { + mermaidInit, + normalizeMermaid, + renderCommandsTable, + renderDocs, + renderGroupsTable, + renderMcpToolsTable, + renderRepoMap, + spliceBlock, + updateCounts, +} from "../src/docs_render.js"; +import { TOOLS } from "../src/mcp_tools.js"; + +const dir = () => mkdtempSync(join(tmpdir(), "forge-render-")); + +test("renderCommandsTable names every command exactly as the docs check requires", () => { + const table = renderCommandsTable(); + for (const name of Object.keys(COMMANDS)) + assert.ok( + new RegExp(`\\b${BRAND.cli} ${name}\\b`).test(table), + `table carries \`${BRAND.cli} ${name}\``, + ); +}); + +test("renderGroupsTable and renderMcpToolsTable derive fully from their registries", () => { + const groups = renderGroupsTable(); + assert.ok(groups.includes("**Labs (experimental)**"), "group labels come from GROUPS keys"); + const tools = renderMcpToolsTable(); + for (const t of TOOLS) assert.ok(tools.includes(`\`${t.name}\``), `tool row for ${t.name}`); +}); + +test("mermaidInit carries the two brand identity hexes the diagram check enforces", () => { + const init = mermaidInit(); + const d = BRAND.colors.dark; + assert.ok(init.includes("%%{init"), "is an init directive"); + assert.ok(init.toLowerCase().includes(d.brand.toLowerCase()), "ember accent present"); + assert.ok(init.toLowerCase().includes(d.bg.toLowerCase()), "warm-black canvas present"); +}); + +test("spliceBlock replaces only the managed span and is idempotent", () => { + const begin = ``; + const doc = `prose before\n\n${begin}\nOLD\n\n\nprose after`; + const r1 = spliceBlock(doc, "x", "NEW"); + assert.ok(r1.found && r1.changed, "first splice replaces"); + assert.ok(r1.text.includes("NEW") && !r1.text.includes("OLD")); + assert.ok(r1.text.startsWith("prose before") && r1.text.endsWith("prose after")); + const r2 = spliceBlock(r1.text, "x", "NEW"); + assert.ok(r2.found && !r2.changed, "same body → no change"); + assert.equal(spliceBlock(doc, "missing", "NEW").found, false, "unknown block untouched"); +}); + +test("updateCounts rewrites any stale N-MCP-tools phrase to the registry count", () => { + const out = updateCounts("we ship 3 MCP tools today and 999 MCP tools tomorrow"); + assert.equal( + out, + `we ship ${TOOLS.length} MCP tools today and ${TOOLS.length} MCP tools tomorrow`, + ); +}); + +test("normalizeMermaid unifies init lines, skips ignored examples and init-less blocks", () => { + const old = + "%%{init: {'theme':'base','themeVariables':{'lineColor':'#f26430','tertiaryColor':'#171310'}}}%%"; + const themed = `\`\`\`mermaid\n${old}\nflowchart LR\n a --> b\n\`\`\``; + assert.ok( + normalizeMermaid(themed).includes(mermaidInit()), + "old init upgraded to the shared theme", + ); + const ignored = `\n\`\`\`mermaid\n${old}\nflowchart LR\n a --> b\n\`\`\``; + assert.equal(normalizeMermaid(ignored), ignored, "opted-out example blocks stay untouched"); + const bare = "```mermaid\nflowchart LR\n a --> b\n```"; + assert.equal(normalizeMermaid(bare), bare, "no init line → left for the diagram check to flag"); +}); + +test("renderRepoMap draws directories and import edges from the real tree", () => { + const root = dir(); + mkdirSync(join(root, "app")); + mkdirSync(join(root, "lib")); + writeFileSync(join(root, "app", "main.js"), 'import "../lib/util.js";\n'); + writeFileSync(join(root, "lib", "util.js"), "export const u = 1;\n"); + const map = renderRepoMap(root); + assert.ok(map.startsWith("```mermaid"), "is a mermaid fence"); + assert.ok(map.includes(mermaidInit()), "uses the shared brand theme"); + assert.ok(map.includes('app["app
1 file"]') && map.includes('lib["lib
1 file"]')); + assert.ok(map.includes("app --> lib"), "the import edge points importer → imported"); +}); + +test("renderDocs fills managed blocks, is idempotent, and reports tampering as strict drift", () => { + const root = dir(); + const begin = ``; + writeFileSync( + join(root, "README.md"), + `# fixture\n\n${begin}\n\n`, + ); + const first = renderDocs(root, { write: true }); + assert.ok(first.files.some((f) => f.file === "README.md" && f.changed && f.strict)); + assert.ok(readFileSync(join(root, "README.md"), "utf8").includes(`\`${BRAND.cli} rank\``)); + const second = renderDocs(root, { write: false }); + assert.equal(second.files.length, 0, "freshly rendered → nothing to report"); + assert.ok(second.ok); + writeFileSync( + join(root, "README.md"), + readFileSync(join(root, "README.md"), "utf8").replace(`\`${BRAND.cli} rank\``, "`TAMPERED`"), + ); + const third = renderDocs(root, { write: false }); + assert.ok(!third.ok, "a hand-edited generated block fails the check"); + assert.ok(third.files.some((f) => f.strict && f.why.includes("block commands-table"))); +}); + +test("renderDocs on a root without markers manages nothing and stays ok (fixture safety)", () => { + const root = dir(); + writeFileSync(join(root, "README.md"), "# plain fixture, hand-written table\n"); + const r = renderDocs(root, { write: false }); + assert.ok(r.ok, "no markers → nothing stale"); + assert.ok( + r.missing.some((m) => m.name === "commands-table"), + "absence is reported as informational", + ); +});