mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(devex): generate frontend provider-chain docs + CI drift gate (#3975)
This commit is contained in:
@@ -32,6 +32,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
frontend: ${{ steps.filter.outputs.frontend }}
|
frontend: ${{ steps.filter.outputs.frontend }}
|
||||||
i18n: ${{ steps.filter.outputs.i18n }}
|
i18n: ${{ steps.filter.outputs.i18n }}
|
||||||
|
docs: ${{ steps.filter.outputs.docs }}
|
||||||
rust-core: ${{ steps.filter.outputs['rust-core'] }}
|
rust-core: ${{ steps.filter.outputs['rust-core'] }}
|
||||||
rust-tauri: ${{ steps.filter.outputs['rust-tauri'] }}
|
rust-tauri: ${{ steps.filter.outputs['rust-tauri'] }}
|
||||||
playwright: ${{ steps.filter.outputs.playwright }}
|
playwright: ${{ steps.filter.outputs.playwright }}
|
||||||
@@ -61,6 +62,13 @@ jobs:
|
|||||||
i18n:
|
i18n:
|
||||||
- '.github/workflows/pr-ci.yml'
|
- '.github/workflows/pr-ci.yml'
|
||||||
- 'app/src/**'
|
- '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:
|
rust-core:
|
||||||
- '.github/workflows/pr-ci.yml'
|
- '.github/workflows/pr-ci.yml'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
@@ -177,6 +185,31 @@ jobs:
|
|||||||
- name: Verify i18n coverage (missing / extra / drifted keys across locales)
|
- name: Verify i18n coverage (missing / extra / drifted keys across locales)
|
||||||
run: pnpm i18n:check
|
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:
|
rust-quality:
|
||||||
name: Rust Quality (fmt, clippy)
|
name: Rust Quality (fmt, clippy)
|
||||||
needs: [changes]
|
needs: [changes]
|
||||||
@@ -808,6 +841,7 @@ jobs:
|
|||||||
- changes
|
- changes
|
||||||
- frontend-quality
|
- frontend-quality
|
||||||
- i18n-coverage
|
- i18n-coverage
|
||||||
|
- docs-drift
|
||||||
- rust-quality
|
- rust-quality
|
||||||
- build-playwright-e2e-artifact
|
- build-playwright-e2e-artifact
|
||||||
- frontend-coverage
|
- frontend-coverage
|
||||||
@@ -827,6 +861,7 @@ jobs:
|
|||||||
["Detect Changed Areas"]="${{ needs.changes.result }}"
|
["Detect Changed Areas"]="${{ needs.changes.result }}"
|
||||||
["Frontend Quality"]="${{ needs['frontend-quality'].result }}"
|
["Frontend Quality"]="${{ needs['frontend-quality'].result }}"
|
||||||
["i18n Coverage"]="${{ needs['i18n-coverage'].result }}"
|
["i18n Coverage"]="${{ needs['i18n-coverage'].result }}"
|
||||||
|
["Docs Drift"]="${{ needs['docs-drift'].result }}"
|
||||||
["Rust Quality"]="${{ needs['rust-quality'].result }}"
|
["Rust Quality"]="${{ needs['rust-quality'].result }}"
|
||||||
["Build Playwright E2E Artifact"]="${{ needs['build-playwright-e2e-artifact'].result }}"
|
["Build Playwright E2E Artifact"]="${{ needs['build-playwright-e2e-artifact'].result }}"
|
||||||
["Frontend Coverage"]="${{ needs['frontend-coverage'].result }}"
|
["Frontend Coverage"]="${{ needs['frontend-coverage'].result }}"
|
||||||
|
|||||||
@@ -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`).
|
- **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.
|
- **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.
|
- **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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,28 @@ function App() {
|
|||||||
const socketWrapped = (children: React.ReactNode) =>
|
const socketWrapped = (children: React.ReactNode) =>
|
||||||
onMobile ? <>{children}</> : <SocketProvider>{children}</SocketProvider>;
|
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 (
|
return (
|
||||||
<Sentry.ErrorBoundary
|
<Sentry.ErrorBoundary
|
||||||
fallback={({ error, componentStack, resetError }) => (
|
fallback={({ error, componentStack, resetError }) => (
|
||||||
|
|||||||
@@ -70,24 +70,34 @@ OpenHuman’s desktop UI is a **React 19** app (`app/src/`) that:
|
|||||||
|
|
||||||
### Provider chain
|
### Provider chain
|
||||||
|
|
||||||
```
|
<!-- BEGIN GENERATED: provider-chain — source app/src/App.tsx via scripts/generate-architecture-docs.mjs; do not edit between markers (run `pnpm docs:generate`) -->
|
||||||
Redux Provider
|
|
||||||
└─ PersistGate
|
_Generated from `app/src/App.tsx` by `scripts/generate-architecture-docs.mjs`. Do not edit by hand — run `pnpm docs:generate` to refresh._
|
||||||
└─ UserProvider
|
|
||||||
└─ SocketProvider
|
| # | Component | Role |
|
||||||
└─ AIProvider
|
| --- | --- | --- |
|
||||||
└─ SkillProvider
|
| 1 | `Sentry.ErrorBoundary` | Crash boundary; renders ErrorFallbackScreen |
|
||||||
└─ HashRouter
|
| 2 | `Provider` | Redux store; enables useAppSelector / dispatch app-wide |
|
||||||
└─ AppRoutes (pages + settings)
|
| 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**
|
**Why this order**
|
||||||
|
|
||||||
1. Redux is outermost for `useAppSelector` / dispatch everywhere.
|
1. Redux `Provider` is outermost so `useAppSelector` / dispatch work everywhere.
|
||||||
2. `PersistGate` rehydrates persisted slices before children assume stable auth.
|
2. `PersistGate` rehydrates persisted slices before children assume stable auth/session.
|
||||||
3. `SocketProvider` uses the auth token for Socket.io.
|
3. `BootCheckGate` / `CoreStateProvider` resolve the core boot snapshot (auth, onboarding) before feature providers mount.
|
||||||
4. `AIProvider` / `SkillProvider` wrap features that depend on socket and store state.
|
4. `SocketProvider` (desktop only) and `ChatRuntimeProvider` depend on that core state for realtime events and approvals.
|
||||||
5. `HashRouter` supplies navigation to all routes.
|
5. `Router` supplies navigation to all routes.
|
||||||
|
|
||||||
### Module relationships (simplified)
|
### Module relationships (simplified)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm --filter openhuman-app build",
|
"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",
|
"compile": "pnpm --filter openhuman-app compile",
|
||||||
"dev": "pnpm --filter openhuman-app dev",
|
"dev": "pnpm --filter openhuman-app dev",
|
||||||
"dev:app": "pnpm --filter openhuman-app dev:app",
|
"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'));
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user