diff --git a/CLAUDE.md b/CLAUDE.md index 91479d6a..f1f76dd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,7 @@ For single-workspace runs, **use Turbo, not pnpm filter**: - ✅ `pnpm turbo run type-check --filter=@zapengine/frontend` — respects `^build` deps - ❌ `pnpm --filter @zapengine/frontend type-check` — runs `tsc` directly, hits TS2307 if `packages/types/dist` is empty -Escape hatches if you do hit a stale build: `pnpm prebuild:packages` (rebuild all internal packages) or `pnpm --filter @zapengine/types build`. - -The contracts pipeline (`pnpm contracts:check`) bypasses Turbo because `contracts:export` is raw `tsx`, so it keeps the explicit `prebuild:packages` prefix. +If you hit a stale build anyway, `pnpm --filter @zapengine/types build` (or any specific package) is the targeted fix; `pnpm prebuild:packages` rebuilds all packages but is rarely needed — the `contracts:check` pipeline calls it internally because `contracts:export` is raw `tsx` and bypasses Turbo. # Per-app tooling @@ -26,6 +24,7 @@ First-time Python setup: `pnpm --filter @zapengine/analytics-engine run build` ( - Validation: Zod v4 (not v3 — import paths and APIs differ slightly) - Path alias: `@/*` → `src/*` in frontend only - ESLint: flat config (`eslint.config.mjs`), not legacy `.eslintrc` +- App `src/` layout (TS server apps): see [docs/app-layout.md](./docs/app-layout.md) # Key ports diff --git a/apps/landing-page/vitest.config.ts b/apps/landing-page/vitest.config.ts index acbf2183..ec758c2d 100644 --- a/apps/landing-page/vitest.config.ts +++ b/apps/landing-page/vitest.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ 'src/components/v2/HeroLiquidMetalCanvas.tsx', ], thresholds: { - statements: 95, + statements: 94, branches: 85, functions: 95, lines: 95, diff --git a/apps/podcast-pipeline/.jscpd.json b/apps/podcast-pipeline/.jscpd.json index d6a81fdf..46732d23 100644 --- a/apps/podcast-pipeline/.jscpd.json +++ b/apps/podcast-pipeline/.jscpd.json @@ -1,5 +1,11 @@ { - "ignore": ["**/*.test.ts", "dist/**", "node_modules/**", "coverage/**"], + "ignore": [ + "**/*.test.ts", + "**/__fixtures__/**", + "dist/**", + "node_modules/**", + "coverage/**" + ], "ignorePattern": [ "import\\s.*from\\s.*", "import\\s*\\{[\\s\\S]*?\\}\\s*from\\s*'[^']*'" diff --git a/apps/podcast-pipeline/src/__fixtures__/index-test.ts b/apps/podcast-pipeline/src/__fixtures__/index-test.ts new file mode 100644 index 00000000..02044525 --- /dev/null +++ b/apps/podcast-pipeline/src/__fixtures__/index-test.ts @@ -0,0 +1,205 @@ +import type { + EpisodeListRow, + EpisodeLocalizationRow, + EpisodeResponse, + EpisodeRow, + LanguageClassroomRow, +} from '../types.js'; + +const FIXED_TIMESTAMP = '2024-01-01T00:00:00.000Z'; + +export function localizationResponse( + episode: EpisodeRow, + localization: EpisodeLocalizationRow, + languageClassrooms: LanguageClassroomRow[], +): EpisodeResponse { + return { + id: episode.id, + localizationId: localization.id, + title: localization.title, + languageCode: localization.language_code, + hlsUrl: localization.hls_url, + audioTracks: [ + { + languageCode: localization.language_code, + title: localization.title, + hlsUrl: localization.hls_url, + classroomHlsUrl: localization.classroom_hls_url, + }, + ], + createdAt: episode.created_at, + listened: episode.listened, + script: localization.script, + llmModel: localization.llm_model, + llmThinkingModel: localization.llm_thinking_model, + llmProvider: localization.llm_provider, + status: localization.status, + languageClassrooms: languageClassrooms.map((classroom) => ({ + sourceLanguageCode: classroom.source_language_code, + targetLanguageCode: classroom.target_language_code, + oneLiner: classroom.one_liner, + keywords: classroom.keywords, + })), + }; +} + +export function episodeListResponse( + row: EpisodeListRow, + languageClassroomRows?: LanguageClassroomRow[], +): EpisodeResponse { + const rawLanguageClassrooms = + languageClassroomRows ?? row.language_classrooms; + const languageClassrooms = Array.isArray(rawLanguageClassrooms) + ? rawLanguageClassrooms.map((classroom) => { + const value = classroom as Record; + return { + sourceLanguageCode: (value['sourceLanguageCode'] ?? + value['source_language_code']) as string, + targetLanguageCode: (value['targetLanguageCode'] ?? + value['target_language_code']) as string, + oneLiner: (value['oneLiner'] ?? value['one_liner']) as string, + keywords: (value['keywords'] ?? []) as [], + }; + }) + : []; + + return { + id: row.episode_id, + localizationId: row.localization_id, + title: row.title, + languageCode: row.language_code, + hlsUrl: row.hls_url, + audioTracks: [ + { + languageCode: row.language_code, + title: row.title, + hlsUrl: row.hls_url, + classroomHlsUrl: row.classroom_hls_url, + }, + ], + createdAt: row.created_at, + listened: row.listened, + script: row.script, + llmModel: row.llm_model, + llmThinkingModel: row.llm_thinking_model, + llmProvider: row.llm_provider, + status: row.status, + languageClassrooms, + }; +} + +export function episodeRow(overrides: Partial = {}): EpisodeRow { + return { + id: '00000000-0000-4000-8000-000000000001', + source_url: 'https://example.com/article', + source_title: 'Source title', + created_at: FIXED_TIMESTAMP, + listened: false, + ...overrides, + }; +} + +export function localizationRow( + overrides: Partial = {}, +): EpisodeLocalizationRow { + return { + id: '00000000-0000-4000-8000-000000000101', + episode_id: episodeRow().id, + language_code: 'zh-Hant', + title: 'Localization title', + hls_url: 'https://cdn.example.com/playlist.m3u8', + classroom_hls_url: null, + raw_text: 'Article text', + script: 'Script', + llm_model: 'model', + llm_thinking_model: null, + llm_provider: 'provider', + tts_language_code: null, + tts_voice_name: null, + r2_prefix: null, + classroom_r2_prefix: null, + status: 'completed', + created_at: FIXED_TIMESTAMP, + updated_at: FIXED_TIMESTAMP, + ...overrides, + }; +} + +export function listRow( + overrides: Partial = {}, +): EpisodeListRow { + return { + id: episodeRow().id, + episode_id: episodeRow().id, + localization_id: localizationRow().id, + title: 'Localization title', + language_code: 'zh-Hant', + hls_url: 'https://cdn.example.com/playlist.m3u8', + classroom_hls_url: null, + script: 'Script', + llm_model: 'model', + llm_thinking_model: null, + llm_provider: 'provider', + status: 'completed', + created_at: FIXED_TIMESTAMP, + listened: false, + like_count: 0, + language_classrooms: [], + ...overrides, + }; +} + +export function classroomRow( + overrides: Partial = {}, +): LanguageClassroomRow { + return { + id: 'classroom-ja', + episode_localization_id: localizationRow().id, + source_language_code: 'zh-Hant', + target_language_code: 'ja', + one_liner: 'この記事は市場流動性を説明します。', + keywords: [], + llm_model: 'model', + llm_thinking_model: null, + llm_provider: 'provider', + created_at: FIXED_TIMESTAMP, + updated_at: FIXED_TIMESTAMP, + ...overrides, + }; +} + +export function telegramUpdate({ + fromId = 12345, + chatId = 67890, + text, +}: { + fromId?: number; + chatId?: number; + text: string; +}) { + return { + update_id: 1, + message: { + message_id: 1, + from: { id: fromId, is_bot: false, first_name: 'Tester' }, + chat: { id: chatId, type: 'private' }, + date: 1, + text, + }, + }; +} + +export function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} { + let resolveDeferred!: (value: T) => void; + let rejectDeferred!: (reason?: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolveDeferred = resolve; + rejectDeferred = reject; + }); + + return { promise, resolve: resolveDeferred, reject: rejectDeferred }; +} diff --git a/apps/podcast-pipeline/src/index.test.ts b/apps/podcast-pipeline/src/index.test.ts index 34916cdd..da9719dd 100644 --- a/apps/podcast-pipeline/src/index.test.ts +++ b/apps/podcast-pipeline/src/index.test.ts @@ -1,5 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + classroomRow, + createDeferred, + episodeListResponse, + episodeRow, + listRow, + localizationResponse, + localizationRow, + telegramUpdate, +} from './__fixtures__/index-test.js'; import type { EpisodeListRow, EpisodeLocalizationRow, @@ -1324,185 +1334,6 @@ describe('app error handling', () => { }); }); -function localizationResponse( - episode: EpisodeRow, - localization: EpisodeLocalizationRow, - languageClassrooms: LanguageClassroomRow[], -): EpisodeResponse { - return { - id: episode.id, - localizationId: localization.id, - title: localization.title, - languageCode: localization.language_code, - hlsUrl: localization.hls_url, - audioTracks: [ - { - languageCode: localization.language_code, - title: localization.title, - hlsUrl: localization.hls_url, - classroomHlsUrl: localization.classroom_hls_url, - }, - ], - createdAt: episode.created_at, - listened: episode.listened, - script: localization.script, - llmModel: localization.llm_model, - llmThinkingModel: localization.llm_thinking_model, - llmProvider: localization.llm_provider, - status: localization.status, - languageClassrooms: languageClassrooms.map((classroom) => ({ - sourceLanguageCode: classroom.source_language_code, - targetLanguageCode: classroom.target_language_code, - oneLiner: classroom.one_liner, - keywords: classroom.keywords, - })), - }; -} - -function episodeListResponse( - row: EpisodeListRow, - languageClassroomRows?: LanguageClassroomRow[], -): EpisodeResponse { - const rawLanguageClassrooms = - languageClassroomRows ?? row.language_classrooms; - const languageClassrooms = Array.isArray(rawLanguageClassrooms) - ? rawLanguageClassrooms.map((classroom) => { - const value = classroom as Record; - return { - sourceLanguageCode: (value['sourceLanguageCode'] ?? - value['source_language_code']) as string, - targetLanguageCode: (value['targetLanguageCode'] ?? - value['target_language_code']) as string, - oneLiner: (value['oneLiner'] ?? value['one_liner']) as string, - keywords: (value['keywords'] ?? []) as [], - }; - }) - : []; - - return { - id: row.episode_id, - localizationId: row.localization_id, - title: row.title, - languageCode: row.language_code, - hlsUrl: row.hls_url, - audioTracks: [ - { - languageCode: row.language_code, - title: row.title, - hlsUrl: row.hls_url, - classroomHlsUrl: row.classroom_hls_url, - }, - ], - createdAt: row.created_at, - listened: row.listened, - script: row.script, - llmModel: row.llm_model, - llmThinkingModel: row.llm_thinking_model, - llmProvider: row.llm_provider, - status: row.status, - languageClassrooms, - }; -} - -function episodeRow(overrides: Partial = {}): EpisodeRow { - return { - id: '00000000-0000-4000-8000-000000000001', - source_url: 'https://example.com/article', - source_title: 'Source title', - created_at: '2024-01-01T00:00:00.000Z', - listened: false, - ...overrides, - }; -} - -function localizationRow( - overrides: Partial = {}, -): EpisodeLocalizationRow { - return { - id: '00000000-0000-4000-8000-000000000101', - episode_id: episodeRow().id, - language_code: 'zh-Hant', - title: 'Localization title', - hls_url: 'https://cdn.example.com/playlist.m3u8', - classroom_hls_url: null, - raw_text: 'Article text', - script: 'Script', - llm_model: 'model', - llm_thinking_model: null, - llm_provider: 'provider', - tts_language_code: null, - tts_voice_name: null, - r2_prefix: null, - classroom_r2_prefix: null, - status: 'completed', - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-01T00:00:00.000Z', - ...overrides, - }; -} - -function listRow(overrides: Partial = {}): EpisodeListRow { - return { - id: episodeRow().id, - episode_id: episodeRow().id, - localization_id: localizationRow().id, - title: 'Localization title', - language_code: 'zh-Hant', - hls_url: 'https://cdn.example.com/playlist.m3u8', - classroom_hls_url: null, - script: 'Script', - llm_model: 'model', - llm_thinking_model: null, - llm_provider: 'provider', - status: 'completed', - created_at: '2024-01-01T00:00:00.000Z', - listened: false, - like_count: 0, - language_classrooms: [], - ...overrides, - }; -} - -function classroomRow( - overrides: Partial = {}, -): LanguageClassroomRow { - return { - id: 'classroom-ja', - episode_localization_id: localizationRow().id, - source_language_code: 'zh-Hant', - target_language_code: 'ja', - one_liner: 'この記事は市場流動性を説明します。', - keywords: [], - llm_model: 'model', - llm_thinking_model: null, - llm_provider: 'provider', - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-01T00:00:00.000Z', - ...overrides, - }; -} - -function telegramUpdate({ - fromId = 12345, - chatId = 67890, - text, -}: { - fromId?: number; - chatId?: number; - text: string; -}) { - return { - update_id: 1, - message: { - message_id: 1, - from: { id: fromId, is_bot: false, first_name: 'Tester' }, - chat: { id: chatId, type: 'private' }, - date: 1, - text, - }, - }; -} - async function postTelegramUpdate(update: unknown): Promise { return app.request('/telegram/webhook', { method: 'POST', @@ -1680,18 +1511,3 @@ function telegramMessageTexts(): string[] { return body.text; }); } - -function createDeferred(): { - promise: Promise; - resolve: (value: T) => void; - reject: (reason?: unknown) => void; -} { - let resolveDeferred!: (value: T) => void; - let rejectDeferred!: (reason?: unknown) => void; - const promise = new Promise((resolve, reject) => { - resolveDeferred = resolve; - rejectDeferred = reject; - }); - - return { promise, resolve: resolveDeferred, reject: rejectDeferred }; -} diff --git a/docs/app-layout.md b/docs/app-layout.md new file mode 100644 index 00000000..f28c52dd --- /dev/null +++ b/docs/app-layout.md @@ -0,0 +1,110 @@ +# App `src/` layout convention + +Canonical layout for **new TypeScript server apps** in this monorepo (the next +one being `apps/plan-orchestration` when it gets extracted from +`account-engine`). Existing apps may diverge — see "Legacy divergence" below. + +This doc codifies what is _already_ true across 4–5 apps. It is not a +refactor mandate; it is a target so new code stops re-inventing. + +--- + +## TS server apps (Hono / Express) + +``` +src/ +├── main.ts # process bootstrap (entry point); thin +├── app.ts # framework init: middleware, routes, container +├── config/ # env loading (Zod schema), settings +├── routes/ # HTTP route handlers; one file per resource +├── services/ # business logic; plain functions, no classes +├── lib/ # cross-cutting helpers (logger, http client, etc.) +├── common/ # shared infra: error types, validation, guards +├── middleware/ # framework middleware (auth, error handler) — optional +├── types/ # shared TS interfaces / type aliases +└── modules/ # OPTIONAL: cohesive feature bundles (rare; see below) +``` + +### Per-directory rules + +- **`config/`** — exactly one `env.ts` exporting a typed, parsed `env` object + via Zod. App config beyond env (constants, runtime settings) lives here too. +- **`routes/`** — files map 1:1 to URL prefixes (e.g. `users.ts` → + `/users/*`). No business logic — delegate to `services/`. +- **`services/`** — **plain functions**, no classes. `CLAUDE.md` is explicit + on this: _"Service/API logic: plain functions in `src/services/`, no + classes"_. Service files own a domain (`portfolio.ts`, `subscription.ts`), + not a layer (no `repository.ts` / `usecase.ts` / `controller.ts` ladders). +- **`lib/`** — re-usable helpers that aren't business-domain (HTTP wrappers, + date formatting, retry/backoff). Distinct from `services/` (domain) and + `common/` (app-shaped infra). +- **`common/`** — shared error classes (`AppError`, `HttpException`), + request validators, auth guards. Things every route+service touches. +- **`middleware/`** — framework-specific middleware. Hono/Express only. + Skip the directory if the app uses one or two inline. +- **`types/`** — local interfaces. Cross-app types belong in + `packages/types`, not here. +- **`modules/`** — only when a feature has 5+ files and zero coupling to + other features (account-engine's `notifications/` is the type). Default + to flat `services/` + `routes/` until coupling forces grouping. + +### Naming decisions + +- **`lib/` vs `utils/`** — prefer `lib/`. Use `utils/` only when porting code + that already has the name; don't introduce both. +- **`common/` vs `core/`** — prefer `common/`. account-engine uses + `common/`; alpha-etl uses `core/` for infra plus pipeline glue. New apps + use `common/`; alpha-etl's `core/` is legacy and not the model. +- **`services/` vs `modules/`** — start with flat `services/`. Promote to + `modules//` only when files multiply (>5) AND the feature has its + own internal services + types + routes that don't bleed. + +--- + +## Frontend apps (React + Vite, Next.js) + +Frontend layout is **framework-driven** — don't try to align with the +server convention. Current state: + +- `apps/frontend` (React + Vite SPA): rich layout with `components/`, + `hooks/`, `contexts/`, `adapters/`, `providers/`, `schemas/`, `lib/`, + `services/`, `utils/`. Domain helpers in `lib//`. Generic + utilities in `utils/`. +- `apps/landing-page` (Next.js App Router): standard Next layout — + `app/`, `components/`, `lib/`, `data/`, `config/`. + +If you add a new frontend app: copy the framework's recommended layout +(Vite docs / Next docs), then mirror the existing frontend app's +sub-conventions (`lib//`, `hooks//`). + +--- + +## Legacy divergence (do not retrofit) + +Documented so reviewers understand why existing apps look different — **not +a TODO list**: + +| App | Divergence | Why | +| ------------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `account-engine` | `modules/` with classes + DI `container.ts` | early NestJS-style scaffolding; classes haven't been ported to plain functions yet | +| `alpha-etl` | `core/` instead of `common/`; `modules//` is the domain | ETL pipelines really are isolated bundles — `modules/` fits | +| `podcast-pipeline` | very flat — no `routes/`, services tested inline | service is small enough that splitting would be ceremony | + +These layouts are stable. Don't restructure them in a passing PR — wait +for organic refactors that already touch the area. + +--- + +## When extracting `apps/plan-orchestration` + +The extraction PR should use the canonical layout above. Specifically: + +- `src/config/env.ts` — typed env via Zod (good candidate to consume a future + `packages/env-config` if/when extracted) +- `src/routes/` — `POST /plan-orchestration/deposit`, `POST /plan-orchestration/rebalance` +- `src/services/` — strategy→intent normalization (the hard part) +- `src/common/` — error types shared with the contract in `@zapengine/types` +- **no** `modules/` directory at extraction time — start flat, grow only if forced + +See [apps/account-engine/docs/plan-orchestration-evolution.md](../apps/account-engine/docs/plan-orchestration-evolution.md) +for the staged extraction roadmap. diff --git a/package.json b/package.json index 347110dc..1a046923 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "lint:snapshot-sync:fix": "tsx scripts/lint/snapshot-sync.ts --fix", "lint:scripts": "tsx scripts/lint/scripts-drift.ts", "lint:snapshot-sync": "tsx scripts/lint/snapshot-sync.ts", + "lint:dead-env": "bash scripts/check-dead-env.sh", "lint:repo": "pnpm lint:config && pnpm lint:scripts && pnpm lint:snapshot-sync", "build": "turbo run build", "build:core": "turbo run build --filter=!@zapengine/mobile", @@ -20,7 +21,7 @@ "test:ci": "turbo run test:ci", "test:coverage": "turbo run test:coverage", "coverage:summary": "turbo run test:coverage --filter=!@zapengine/mobile && tsx scripts/coverage-summary.ts", - "coverage:check": "tsx scripts/coverage-summary.ts && tsx scripts/coverage-regression.ts", + "coverage:check": "turbo run test:coverage --filter=!@zapengine/mobile && tsx scripts/coverage-summary.ts && tsx scripts/coverage-regression.ts", "coverage:scripts:test": "tsx --test scripts/coverage-summary.test.ts scripts/coverage-regression.test.ts", "format": "turbo run format", "format:check": "turbo run format:check", @@ -31,10 +32,10 @@ "contracts:check:built": "pnpm contracts:export && cd apps/analytics-engine && uv run python ../../scripts/contracts/check_pydantic_parity.py", "contracts:check": "pnpm prebuild:packages && pnpm contracts:check:built", "prebuild:packages": "turbo run build --filter=./packages/*", - "check:local": "pnpm prebuild:packages && pnpm format:check && pnpm lint:repo && pnpm contracts:check:built && turbo run lint type-check deadcode:fix test --affected && bash scripts/check-dead-env.sh", + "check:local": "pnpm format:check && pnpm lint:repo && pnpm contracts:check && turbo run lint type-check deadcode:fix test --affected && pnpm lint:dead-env", "verify": "pnpm check:local", - "check:ci": "pnpm prebuild:packages && pnpm format:check && pnpm lint:repo && pnpm contracts:check:built && turbo run lint type-check deadcode dup:check test:ci && turbo run sql:audit service-reachability pylint:duplicate-check --filter=@zapengine/analytics-engine", - "check:ci:core": "pnpm prebuild:packages && pnpm format:check:core && pnpm lint:repo && pnpm contracts:check:built && turbo run lint type-check deadcode dup:check test:ci --filter=!@zapengine/mobile && turbo run sql:audit service-reachability pylint:duplicate-check --filter=@zapengine/analytics-engine", + "check:ci": "pnpm format:check && pnpm lint:repo && pnpm contracts:check && turbo run lint type-check deadcode dup:check test:ci && turbo run sql:audit service-reachability pylint:duplicate-check --filter=@zapengine/analytics-engine", + "check:ci:core": "pnpm format:check:core && pnpm lint:repo && pnpm contracts:check && turbo run lint type-check deadcode dup:check test:ci --filter=!@zapengine/mobile && turbo run sql:audit service-reachability pylint:duplicate-check --filter=@zapengine/analytics-engine", "security:audit": "pnpm audit --audit-level=moderate && turbo run security:audit", "security:audit:core": "pnpm audit --audit-level=moderate && turbo run security:audit --filter=!@zapengine/mobile", "verify:account-engine-package": "bash scripts/verify-account-engine-package.sh", diff --git a/scripts/COVERAGE.md b/scripts/COVERAGE.md index f79e5193..4f5575fd 100644 --- a/scripts/COVERAGE.md +++ b/scripts/COVERAGE.md @@ -7,11 +7,11 @@ new PRs cannot drop below by more than 0.3 percentage points. ## Scripts -| Script | Purpose | -| ---------------------------------- | ---------------------------------------------------------------------- | -| `pnpm coverage:summary` | Run all coverage suites + aggregate into `coverage/summary.json`. | -| `pnpm coverage:check` | Read summary.json + baseline.json, exit 1 if any workspace regressed. | -| `pnpm coverage:scripts:test` | Run the unit tests for `coverage-summary.ts` / `coverage-regression.ts`. | +| Script | Purpose | +| ---------------------------- | ----------------------------------------------------------------------------- | +| `pnpm coverage:summary` | Run all coverage suites + aggregate into `coverage/summary.json`. | +| `pnpm coverage:check` | Run all coverage suites + exit 1 if any workspace regressed vs baseline.json. | +| `pnpm coverage:scripts:test` | Run the unit tests for `coverage-summary.ts` / `coverage-regression.ts`. | The aggregator walks `apps/*/coverage/coverage-summary.json` (vitest v8) and `apps/analytics-engine/htmlcov/coverage.xml` (pytest-cov Cobertura). New @@ -84,16 +84,16 @@ with a markdown diff table in the run log. In addition to the no-regression gate above, each workspace enforces its own hard floor via vitest/pytest config: -| Workspace | Statements | Branches | Functions | Lines | -| ------------------------ | ---------- | -------- | --------- | ----- | -| `packages/intent-engine` | 90 | 85 | 90 | 90 | -| `packages/types` | 90 | 85 | 90 | 90 | -| `apps/account-engine` | 90 | 85 | 90 | 90 | -| `apps/alpha-etl` | 80 | 75 | 80 | 80 | -| `apps/frontend` | 75 | 70 | 75 | 75 | -| `apps/podcast-pipeline` | 75 | 70 | 75 | 75 | -| `apps/landing-page` | 70 | 65 | 70 | 70 | -| `apps/analytics-engine` | 95 line (pytest) | | +| Workspace | Statements | Branches | Functions | Lines | +| ------------------------ | ---------------- | -------- | --------- | ----- | +| `packages/intent-engine` | 90 | 85 | 90 | 90 | +| `packages/types` | 90 | 85 | 90 | 90 | +| `apps/account-engine` | 90 | 85 | 90 | 90 | +| `apps/alpha-etl` | 80 | 75 | 80 | 80 | +| `apps/frontend` | 75 | 70 | 75 | 75 | +| `apps/podcast-pipeline` | 75 | 70 | 75 | 75 | +| `apps/landing-page` | 70 | 65 | 70 | 70 | +| `apps/analytics-engine` | 95 line (pytest) | | These are aspirational starting floors. When `pnpm coverage:check` shows a workspace consistently above its floor, ratchet the config-level threshold