feat(devex): generate frontend provider-chain docs + CI drift gate (#3975)

This commit is contained in:
Mega Mind
2026-06-23 11:47:22 -07:00
committed by GitHub
parent 98daeca334
commit 8be534447d
7 changed files with 363 additions and 15 deletions
+35
View File
@@ -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 }}"
+1
View File
@@ -233,6 +233,7 @@ Each domain owns `bus.rs` with handlers. Convention: `<Purpose>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 `<!-- BEGIN/END GENERATED: … -->` 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`.
---
+22
View File
@@ -103,6 +103,28 @@ function App() {
const socketWrapped = (children: React.ReactNode) =>
onMobile ? <>{children}</> : <SocketProvider>{children}</SocketProvider>;
/*
* @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: `<order>. <Component> — <role>` (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 (
<Sentry.ErrorBoundary
fallback={({ error, componentStack, resetError }) => (
+25 -15
View File
@@ -70,24 +70,34 @@ OpenHumans desktop UI is a **React 19** app (`app/src/`) that:
### Provider chain
```
Redux Provider
└─ PersistGate
└─ UserProvider
└─ SocketProvider
└─ AIProvider
└─ SkillProvider
└─ HashRouter
└─ AppRoutes (pages + settings)
```
<!-- BEGIN GENERATED: provider-chain — source app/src/App.tsx via scripts/generate-architecture-docs.mjs; do not edit between markers (run `pnpm docs:generate`) -->
_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 |
<!-- END GENERATED: provider-chain -->
**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)
+3
View File
@@ -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",
@@ -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',
'<!-- BEGIN GENERATED: provider-chain -->',
'stale row',
'<!-- END GENERATED: provider-chain -->',
'after',
].join('\n');
const out = spliceGeneratedBlock(doc, ['', 'fresh', '']);
assert.equal(
out,
[
'# Title',
'<!-- BEGIN GENERATED: provider-chain -->',
'',
'fresh',
'',
'<!-- END GENERATED: provider-chain -->',
'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'));
});
+171
View File
@@ -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();
}