#!/usr/bin/env node // @ts-check /** * generate-architecture-docs.mjs * -------------------------------- * * Minimal "docs generated from code" slice for issue #3892. Derives the * frontend **provider chain** from an explicit source marker in * `app/src/App.tsx` and renders it into a marked block in * `gitbooks/developing/architecture/frontend.md`, so the documented chain * cannot silently drift from the code (the exact drift called out in the * issue: the doc described `UserProvider` / `AIProvider` / `SkillProvider` * long after the code moved to `CoreStateProvider` / `ChatRuntimeProvider`). * * Modes: * node scripts/generate-architecture-docs.mjs # write (refresh docs) * node scripts/generate-architecture-docs.mjs --check # CI: fail on drift * * Source of truth: the `@generated-source:provider-chain` block in App.tsx. * This is an intentionally tiny, hand-auditable slice. Generating controller / * tool / skill reference tables from the Rust registry is the follow-up (see * the PR body); the splice + check harness here is built to extend to more * blocks without changing the CI wiring. */ import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, '..'); export const APP_TSX = resolve(REPO_ROOT, 'app/src/App.tsx'); export const FRONTEND_DOC = resolve(REPO_ROOT, 'gitbooks/developing/architecture/frontend.md'); /** Stable tokens used to locate the generated block in the doc. */ const BLOCK_BEGIN_TOKEN = 'BEGIN GENERATED: provider-chain'; const BLOCK_END_TOKEN = 'END GENERATED: provider-chain'; /** * Parse the ordered provider chain out of the `@generated-source:provider-chain` * marker block in App.tsx source text. * * @param {string} appSource - contents of App.tsx * @returns {{ order: number, name: string, role: string }[]} * @throws if the marker block is missing, empty, or rows are malformed / non-contiguous */ export function parseProviderChain(appSource) { const begin = appSource.indexOf('@generated-source:provider-chain'); const end = appSource.indexOf('@end-source:provider-chain'); if (begin === -1 || end === -1 || end < begin) { throw new Error( 'provider-chain source marker not found in App.tsx; expected ' + '`@generated-source:provider-chain` … `@end-source:provider-chain`' ); } const region = appSource.slice(begin, end); const rowRe = /^\s*\*?\s*(\d+)\.\s+(.+?)\s+—\s+(.+?)\s*$/; const providers = []; for (const line of region.split('\n')) { const m = rowRe.exec(line); if (!m) continue; providers.push({ order: Number(m[1]), name: m[2].trim(), role: m[3].trim() }); } if (providers.length === 0) { throw new Error('provider-chain source marker contained no `N. Component — role` rows'); } // Guard against drift in the marker itself: orders must be 1..N, in order, // names non-empty, and roles free of the `|` that would break the table. providers.forEach((p, i) => { if (p.order !== i + 1) { throw new Error( `provider-chain rows must be numbered 1..N in order; row ${i + 1} is numbered ${p.order}` ); } if (!p.name) throw new Error(`provider-chain row ${p.order} is missing a component name`); if (!p.role) throw new Error(`provider-chain row ${p.order} is missing a role`); if (p.name.includes('|') || p.role.includes('|')) { throw new Error(`provider-chain row ${p.order} must not contain a "|" character`); } }); return providers; } /** * Render the markdown body that lives between the BEGIN/END markers. * Returned as an array of lines (no trailing join) so splicing stays * deterministic and line-oriented. * * @param {{ order: number, name: string, role: string }[]} providers * @returns {string[]} */ export function renderProviderChainBody(providers) { return [ '', '_Generated from `app/src/App.tsx` by `scripts/generate-architecture-docs.mjs`. ' + 'Do not edit by hand — run `pnpm docs:generate` to refresh._', '', '| # | Component | Role |', '| --- | --- | --- |', ...providers.map(p => `| ${p.order} | \`${p.name}\` | ${p.role} |`), '', ]; } /** * Splice freshly rendered body lines into `docSource` between the BEGIN/END * marker lines (markers preserved). Returns the updated document text. * * @param {string} docSource * @param {string[]} bodyLines * @returns {string} * @throws if the markers are missing or out of order */ export function spliceGeneratedBlock(docSource, bodyLines) { const lines = docSource.split('\n'); const beginIdx = lines.findIndex(l => l.includes(BLOCK_BEGIN_TOKEN)); const endIdx = lines.findIndex(l => l.includes(BLOCK_END_TOKEN)); if (beginIdx === -1 || endIdx === -1) { throw new Error( `generated-block markers not found in ${FRONTEND_DOC}; expected HTML comments ` + `containing "${BLOCK_BEGIN_TOKEN}" and "${BLOCK_END_TOKEN}"` ); } if (endIdx <= beginIdx) { throw new Error('generated-block END marker must come after the BEGIN marker'); } const next = [...lines.slice(0, beginIdx + 1), ...bodyLines, ...lines.slice(endIdx)]; return next.join('\n'); } /** * Compute the desired doc content from the current sources. * @returns {{ updated: string, current: string }} */ export function computeFrontendDoc() { const providers = parseProviderChain(readFileSync(APP_TSX, 'utf8')); const current = readFileSync(FRONTEND_DOC, 'utf8'); const updated = spliceGeneratedBlock(current, renderProviderChainBody(providers)); return { updated, current }; } function main() { const check = process.argv.includes('--check'); const { updated, current } = computeFrontendDoc(); if (check) { if (updated !== current) { console.error( '✗ Generated architecture docs are stale.\n' + ` ${FRONTEND_DOC} no longer matches its code source (app/src/App.tsx).\n` + ' Run `pnpm docs:generate` and commit the result.' ); process.exit(1); } console.log('✓ Generated architecture docs are up to date.'); return; } if (updated !== current) { writeFileSync(FRONTEND_DOC, updated); console.log(`✓ Regenerated provider-chain block in ${FRONTEND_DOC}`); } else { console.log('✓ Generated architecture docs already up to date — nothing to write.'); } } // Only run when invoked directly, so the pure helpers above stay unit-testable. if (resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1] ?? '')) { main(); }