From 8be534447d1b41981b2275e0df12ff2a24f2788e Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:17:22 +0530 Subject: [PATCH] feat(devex): generate frontend provider-chain docs + CI drift gate (#3975) --- .github/workflows/pr-ci.yml | 35 ++++ AGENTS.md | 1 + app/src/App.tsx | 22 +++ gitbooks/developing/architecture/frontend.md | 40 ++-- package.json | 3 + .../generate-architecture-docs.test.mjs | 106 +++++++++++ scripts/generate-architecture-docs.mjs | 171 ++++++++++++++++++ 7 files changed, 363 insertions(+), 15 deletions(-) create mode 100644 scripts/__tests__/generate-architecture-docs.test.mjs create mode 100644 scripts/generate-architecture-docs.mjs diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 35f1cbd31..6dc3cc49e 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -32,6 +32,7 @@ jobs: outputs: frontend: ${{ steps.filter.outputs.frontend }} i18n: ${{ steps.filter.outputs.i18n }} + docs: ${{ steps.filter.outputs.docs }} rust-core: ${{ steps.filter.outputs['rust-core'] }} rust-tauri: ${{ steps.filter.outputs['rust-tauri'] }} playwright: ${{ steps.filter.outputs.playwright }} @@ -61,6 +62,13 @@ jobs: i18n: - '.github/workflows/pr-ci.yml' - 'app/src/**' + docs: + - '.github/workflows/pr-ci.yml' + - 'app/src/App.tsx' + - 'scripts/generate-architecture-docs.mjs' + - 'scripts/__tests__/generate-architecture-docs.test.mjs' + - 'gitbooks/developing/architecture/frontend.md' + - 'package.json' rust-core: - '.github/workflows/pr-ci.yml' - 'Cargo.toml' @@ -177,6 +185,31 @@ jobs: - name: Verify i18n coverage (missing / extra / drifted keys across locales) run: pnpm i18n:check + docs-drift: + name: Docs Drift (generated architecture docs) + needs: [changes] + if: needs.changes.outputs.docs == 'true' + runs-on: ubuntu-22.04 + timeout-minutes: 10 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 + steps: + - name: Checkout code + # Pinned to the v5 commit SHA for supply-chain hardening (CodeRabbit + # review on #3892). The other lanes still use the @v5 tag; migrating + # them is a separate repo-wide change. + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 1 + + # The generator + its tests use only Node built-ins (no workspace deps), + # so there is no `pnpm install` step — this lane stays fast. + - name: Generator unit tests + run: pnpm docs:test + + - name: Verify generated architecture docs are not stale + run: pnpm docs:check + rust-quality: name: Rust Quality (fmt, clippy) needs: [changes] @@ -808,6 +841,7 @@ jobs: - changes - frontend-quality - i18n-coverage + - docs-drift - rust-quality - build-playwright-e2e-artifact - frontend-coverage @@ -827,6 +861,7 @@ jobs: ["Detect Changed Areas"]="${{ needs.changes.result }}" ["Frontend Quality"]="${{ needs['frontend-quality'].result }}" ["i18n Coverage"]="${{ needs['i18n-coverage'].result }}" + ["Docs Drift"]="${{ needs['docs-drift'].result }}" ["Rust Quality"]="${{ needs['rust-quality'].result }}" ["Build Playwright E2E Artifact"]="${{ needs['build-playwright-e2e-artifact'].result }}" ["Frontend Coverage"]="${{ needs['frontend-coverage'].result }}" diff --git a/AGENTS.md b/AGENTS.md index f05a67e4b..1de0a83fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,6 +233,7 @@ Each domain owns `bus.rs` with handlers. Convention: `Subscriber`, `nam - **i18n**: all UI text through `useT()` from `app/src/lib/i18n/I18nContext`. Add key to `en.ts` **and real translations to all locale files** (`ar`, `bn`, `de`, `es`, `fr`, `hi`, `id`, `it`, `ko`, `pl`, `pt`, `ru`, `zh-CN`). CI enforces parity (`pnpm i18n:check`) and detects English placeholders (`pnpm i18n:english:check`). - **Dual socket sync**: keep `socketService`/MCP transport aligned with core socket behavior. - **Tauri guard**: use `isTauri()` or wrap `invoke(...)` in try/catch — never check `window.__TAURI__` directly. +- **Generated docs**: some architecture docs contain generated blocks marked `` sourced from code (today: the frontend provider chain in [`gitbooks/developing/architecture/frontend.md`](gitbooks/developing/architecture/frontend.md), from the `@generated-source:provider-chain` marker in `app/src/App.tsx`). Don't hand-edit between the markers — update the code source, then run `pnpm docs:generate`. CI (`pnpm docs:check`, the **Docs Drift** lane) fails on stale generated docs. Generator + tests: `scripts/generate-architecture-docs.mjs`. --- diff --git a/app/src/App.tsx b/app/src/App.tsx index 6f7d85d70..8e9e4394b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -103,6 +103,28 @@ function App() { const socketWrapped = (children: React.ReactNode) => onMobile ? <>{children} : {children}; + /* + * @generated-source:provider-chain + * Authoritative top-level provider / gate nesting for the desktop shell, + * outermost first. Keep this list in sync with the JSX returned below; + * `scripts/generate-architecture-docs.mjs` renders it into + * `gitbooks/developing/architecture/frontend.md` and CI (`pnpm docs:check`) + * fails if the doc drifts. Refresh the doc with `pnpm docs:generate`. + * Format per line: `. ` (role must not contain " — "). + * 1. Sentry.ErrorBoundary — Crash boundary; renders ErrorFallbackScreen + * 2. Provider — Redux store; enables useAppSelector / dispatch app-wide + * 3. PersistGate — Holds UI until persisted Redux slices rehydrate + * 4. ThemeProvider — Theme tokens and dark-mode handling + * 5. I18nProvider — Localization context consumed via useT + * 6. BootCheckGate — Blocks render until the core boot snapshot resolves + * 7. CoreStateProvider — Core app snapshot: auth, session, onboarding state + * 8. SocketProvider — Core socket.io events; desktop only (mobile uses the TunnelTransport relay) + * 9. ChatRuntimeProvider — Chat runtime events, tool timeline, and approvals + * 10. Router — HashRouter navigation for all routes + * 11. CommandProvider — Command palette context + * 12. ServiceBlockingGate — Blocks the shell until required services are configured + * @end-source:provider-chain + */ return ( ( diff --git a/gitbooks/developing/architecture/frontend.md b/gitbooks/developing/architecture/frontend.md index 6fad712c2..2c971e75f 100644 --- a/gitbooks/developing/architecture/frontend.md +++ b/gitbooks/developing/architecture/frontend.md @@ -70,24 +70,34 @@ OpenHuman’s desktop UI is a **React 19** app (`app/src/`) that: ### Provider chain -``` -Redux Provider - └─ PersistGate - └─ UserProvider - └─ SocketProvider - └─ AIProvider - └─ SkillProvider - └─ HashRouter - └─ AppRoutes (pages + settings) -``` + + +_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 | +| --- | --- | --- | +| 1 | `Sentry.ErrorBoundary` | Crash boundary; renders ErrorFallbackScreen | +| 2 | `Provider` | Redux store; enables useAppSelector / dispatch app-wide | +| 3 | `PersistGate` | Holds UI until persisted Redux slices rehydrate | +| 4 | `ThemeProvider` | Theme tokens and dark-mode handling | +| 5 | `I18nProvider` | Localization context consumed via useT | +| 6 | `BootCheckGate` | Blocks render until the core boot snapshot resolves | +| 7 | `CoreStateProvider` | Core app snapshot: auth, session, onboarding state | +| 8 | `SocketProvider` | Core socket.io events; desktop only (mobile uses the TunnelTransport relay) | +| 9 | `ChatRuntimeProvider` | Chat runtime events, tool timeline, and approvals | +| 10 | `Router` | HashRouter navigation for all routes | +| 11 | `CommandProvider` | Command palette context | +| 12 | `ServiceBlockingGate` | Blocks the shell until required services are configured | + + **Why this order** -1. Redux is outermost for `useAppSelector` / dispatch everywhere. -2. `PersistGate` rehydrates persisted slices before children assume stable auth. -3. `SocketProvider` uses the auth token for Socket.io. -4. `AIProvider` / `SkillProvider` wrap features that depend on socket and store state. -5. `HashRouter` supplies navigation to all routes. +1. Redux `Provider` is outermost so `useAppSelector` / dispatch work everywhere. +2. `PersistGate` rehydrates persisted slices before children assume stable auth/session. +3. `BootCheckGate` / `CoreStateProvider` resolve the core boot snapshot (auth, onboarding) before feature providers mount. +4. `SocketProvider` (desktop only) and `ChatRuntimeProvider` depend on that core state for realtime events and approvals. +5. `Router` supplies navigation to all routes. ### Module relationships (simplified) diff --git a/package.json b/package.json index c2d065b15..6fd4636dc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ }, "scripts": { "build": "pnpm --filter openhuman-app build", + "docs:generate": "node scripts/generate-architecture-docs.mjs", + "docs:check": "node scripts/generate-architecture-docs.mjs --check", + "docs:test": "node --test scripts/__tests__/generate-architecture-docs.test.mjs", "compile": "pnpm --filter openhuman-app compile", "dev": "pnpm --filter openhuman-app dev", "dev:app": "pnpm --filter openhuman-app dev:app", diff --git a/scripts/__tests__/generate-architecture-docs.test.mjs b/scripts/__tests__/generate-architecture-docs.test.mjs new file mode 100644 index 000000000..73391ad9e --- /dev/null +++ b/scripts/__tests__/generate-architecture-docs.test.mjs @@ -0,0 +1,106 @@ +// Tests for the architecture-docs generator (issue #3892). +// Run: node --test scripts/__tests__/generate-architecture-docs.test.mjs +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +import { + APP_TSX, + FRONTEND_DOC, + computeFrontendDoc, + parseProviderChain, + renderProviderChainBody, + spliceGeneratedBlock, +} from '../generate-architecture-docs.mjs'; + +const SAMPLE = ` + /* + * @generated-source:provider-chain + * 1. Sentry.ErrorBoundary — Crash boundary; renders ErrorFallbackScreen + * 2. Provider — Redux store; enables useAppSelector / dispatch app-wide + * @end-source:provider-chain + */ +`; + +test('parseProviderChain extracts ordered rows from the marker block', () => { + const providers = parseProviderChain(SAMPLE); + assert.equal(providers.length, 2); + assert.deepEqual(providers[0], { + order: 1, + name: 'Sentry.ErrorBoundary', + role: 'Crash boundary; renders ErrorFallbackScreen', + }); + assert.equal(providers[1].name, 'Provider'); +}); + +test('parseProviderChain throws when the marker block is missing', () => { + assert.throws(() => parseProviderChain('// no markers here'), /source marker not found/); +}); + +test('parseProviderChain throws when ordering is non-contiguous (drifted metadata)', () => { + const bad = `@generated-source:provider-chain + * 1. A — first + * 3. C — third + @end-source:provider-chain`; + assert.throws(() => parseProviderChain(bad), /numbered 1\.\.N/); +}); + +test('parseProviderChain rejects a role containing a table-breaking pipe', () => { + const bad = `@generated-source:provider-chain + * 1. A — has a | pipe + @end-source:provider-chain`; + assert.throws(() => parseProviderChain(bad), /must not contain a "\|"/); +}); + +test('renderProviderChainBody is deterministic and table-shaped', () => { + const body = renderProviderChainBody(parseProviderChain(SAMPLE)); + assert.equal(body[0], ''); + assert.equal(body[3], '| # | Component | Role |'); + assert.equal(body[4], '| --- | --- | --- |'); + assert.equal(body[5], '| 1 | `Sentry.ErrorBoundary` | Crash boundary; renders ErrorFallbackScreen |'); + assert.equal(body[body.length - 1], ''); +}); + +test('spliceGeneratedBlock replaces only the content between markers', () => { + const doc = [ + '# Title', + '', + 'stale row', + '', + 'after', + ].join('\n'); + const out = spliceGeneratedBlock(doc, ['', 'fresh', '']); + assert.equal( + out, + [ + '# Title', + '', + '', + 'fresh', + '', + '', + 'after', + ].join('\n') + ); +}); + +test('spliceGeneratedBlock throws when markers are absent', () => { + assert.throws(() => spliceGeneratedBlock('no markers', ['x']), /markers not found/); +}); + +test('committed frontend.md is in sync with App.tsx (the CI drift gate)', () => { + const { updated, current } = computeFrontendDoc(); + assert.equal(updated, current, 'run `pnpm docs:generate` and commit the result'); +}); + +test('App.tsx still reflects the post-CoreState/ChatRuntime provider chain', () => { + // Guards against regressing the doc back to the pre-audit provider names. + const names = parseProviderChain(readFileSync(APP_TSX, 'utf8')).map(p => p.name); + assert.ok(names.includes('CoreStateProvider')); + assert.ok(names.includes('ChatRuntimeProvider')); + assert.ok(!names.includes('UserProvider'), 'UserProvider was removed pre-audit'); + assert.ok(!names.includes('AIProvider'), 'AIProvider was removed pre-audit'); + assert.ok(!names.includes('SkillProvider'), 'SkillProvider was removed pre-audit'); + // Sanity: the doc path the gate guards actually exists. + assert.ok(FRONTEND_DOC.endsWith('gitbooks/developing/architecture/frontend.md')); +}); diff --git a/scripts/generate-architecture-docs.mjs b/scripts/generate-architecture-docs.mjs new file mode 100644 index 000000000..772a6e98f --- /dev/null +++ b/scripts/generate-architecture-docs.mjs @@ -0,0 +1,171 @@ +#!/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(); +}