Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,400 @@
# Design: `@suaveplan/readline` + `@suaveplan/cli`
**Date:** 2026-04-20
**Branch:** genesis
**Status:** Approved — ready for implementation planning
---
## 1. Problem
The `@suaveplan/cli` package needs to land on genesis at enterprise quality. Its `prompt` module currently depends on `node:readline`, which ties the package to the Node runtime and blocks the "universal" guarantee. No standalone readline primitive exists in the monorepo.
---
## 2. Solution
Introduce `@suaveplan/readline` as a new `universal/platform` package, then port `@suaveplan/cli` as a full enterprise rewrite that depends on it. Both land in dependency order within the same session.
---
## 3. Package Locations
| Package | Path | FOLDERS.yml domain |
|---|---|---|
| `@suaveplan/readline` | `universal/platform/readline` | `platform` |
| `@suaveplan/cli` | `universal/platform/cli` | `platform` |
`FOLDERS.yml` gains `readline` in the `universal/platform` list. `cli` already appears there — only `readline` is added, bringing the total to 7 entries:
```yaml
platform: [ cli, electrobun, macos-contacts, opentelemetry,
profiler, readline, sitemap ] # 7
```
---
## 4. Dependency Chain
```text
@opentelemetry/api zod tslib
↓
@suaveplan/error @suaveplan/types
↓
@suaveplan/readline
↓
@suaveplan/cli
```
---
## 5. `@suaveplan/readline`
### 5.1 Architecture
Three feature modules share one internal byte scanner:
```text
src/
scanner/ ← internal byte engine (not exported)
reader/ ← public: readLines() AsyncGenerator API
interface/ ← public: createInterface() interactive prompt API
errors/ ← public: ReadlineError + stable code registry
```
`"sideEffects": false` must be set in `package.json`.
No root `"."` barrel export. Consumers import directly from subpaths to enable tree-shaking.
### 5.2 `scanner` (internal)
Ports the [maleck13/readline](https://github.com/maleck13/readline) byte scanner from JavaScript to TypeScript. Replaces `EventEmitter` with an internal bounded async push queue (backpressure-safe). Handles LF, CR, and CRLF line endings. Not exported via `package.json` subpaths.
```ts
interface ScannerOptions {
maxLineLength?: number; // default 4096
encoding?: BufferEncoding;
}
function createScanner(input: Readable, opts?: ScannerOptions): AsyncGenerator<string>
```
### 5.3 `reader` — `@suaveplan/readline/reader`
```ts
interface ReadLinesOptions {
maxLineLength?: number;
encoding?: BufferEncoding;
skipEmpty?: boolean;
signal?: AbortSignal;
}
async function* readLines(
input: Readable | ReadableStream | string,
options?: ReadLinesOptions
): AsyncGenerator<string, void, unknown>
```
Accepts a Node `Readable`, a WHATWG `ReadableStream`, or a file path string. Honours `AbortSignal` at each yield point.
### 5.4 `interface` — `@suaveplan/readline/interface`
```ts
interface ReadlineOptions {
input: Readable;
output?: Writable;
signal?: AbortSignal;
}
interface ReadlineInterface {
question(prompt: string, signal?: AbortSignal): Promise<string>;
close(): void;
readonly closed: boolean;
[Symbol.asyncIterator](): AsyncIterator<string>;
}
function createInterface(options: ReadlineOptions): ReadlineInterface
```
The async iterator inherits the `AbortSignal` from `ReadlineOptions`. `ReadlineInterface` satisfies `@suaveplan/cli`'s existing `InputReader` type (`{ question, close }`) by structural typing — no adapter required.
`createInterface()` emits an OTel span covering the interface lifetime (start on creation, end on `close()`, error status on abort).
### 5.5 Error codes
```text
READLINE.SCANNER.LINE_TOO_LONG
READLINE.SCANNER.STREAM_ERROR
READLINE.INTERFACE.CLOSED
READLINE.INTERFACE.ABORTED
READLINE.READER.STREAM_ERROR
```
### 5.6 Subpath exports
```json
{
"./reader/reader": { "import": { "types": "...", "default": "..." } },
"./interface/interface": { "import": { "types": "...", "default": "..." } },
"./errors/errors": { "import": { "types": "...", "default": "..." } }
}
```
`scanner` is internal — no subpath export.
### 5.7 Key `package.json` fields
```json
{
"sideEffects": false,
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"zod": "catalog:forms",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"vitest": "catalog:testing"
}
}
```
---
## 6. `@suaveplan/cli`
### 6.1 Architecture
Full enterprise rewrite of `suaveplan/packages/universal/cli`. Preserves the complete public API surface. Adds structured errors, Zod schemas, OTel instrumentation, AbortSignal support, and replaces `node:readline` with `@suaveplan/readline/interface`.
```text
src/
app/ command/ parser/ style/
errors/ output/ spinner/ progress/ prompt/
```
`"sideEffects": false` must be set in `package.json`.
### 6.2 `errors`
```ts
class CliError extends SuaveplanError { ... }
```
Stable codes:
```text
CLI.PARSE.UNKNOWN_OPTION
CLI.PARSE.MISSING_VALUE
CLI.PARSE.INVALID_VALUE
CLI.PARSE.MISSING_ARGUMENT
CLI.PARSE.MISSING_REQUIRED_OPTION
CLI.COMMAND.NO_ACTION
CLI.COMMAND.UNKNOWN_SUBCOMMAND
CLI.APP.RUN_FAILED
CLI.APP.ABORTED
```
### 6.3 `parser`
Preserved API. Throws `CliError` (CLI.PARSE.*) instead of bare `Error`. Adds `ParserConfigSchema` and `ParsedArgsSchema` Zod exports.
```ts
function parseArgs(argv: string[], config?: ParserConfig): ParsedArgs
function parseOptionFlags(flags, description?, defaultValue?): { name, config }
function parseArgumentSpec(spec, description?): ArgumentConfig
```
### 6.4 `command`
Preserved fluent API. `execute()` gains an OTel span. Throws `CliError` on missing action and unknown subcommand.
```ts
class Command {
description / option / argument / action / command / alias / hidden / example
execute(argv: string[], parentName?: string): Promise<void>
helpText(parentName?: string): string
getParserConfig(): ParserConfig
}
```
### 6.5 `app`
Preserved API. `run()` gains an OTel span and an optional `AbortSignal`. Throws `CliError(CLI.APP.ABORTED)` when the signal fires.
```ts
class App {
run(argv?: string[], options?: { signal?: AbortSignal }): Promise<void>
helpText(): string
description / option / argument / command / action / example
}
function cli(name: string, version?: string): App
```
Zod export: `AppConfigSchema`.
### 6.6 `style`, `output/format`, `output/table`
API unchanged. Zod schemas added for option types. Bare `Error` throws replaced with `CliError`.
`output/format` and `output/table` follow the standard five-file co-located layout:
```text
src/output/
format/
format.ts format.types.ts format.test.ts format.md index.ts
table/
table.ts table.types.ts table.test.ts table.md index.ts
```
### 6.7 `spinner` + `progress`
`AbortSignal` added to options. Stream remains injectable (`WritableStreamLike`).
```ts
interface SpinnerOptions { text?, frames?, interval?, stream?, signal?: AbortSignal }
interface ProgressOptions { total, width?, stream?, signal?: AbortSignal }
```
### 6.8 `prompt`
`createDefaultReader()` uses `createInterface()` from `@suaveplan/readline/interface`. All five prompt functions (`text`, `password`, `confirm`, `select`, `multiselect`) gain an optional `signal?: AbortSignal`. `node:readline` is absent from the dependency graph.
### 6.9 Subpath exports
```json
{
".": "barrel",
"./app/app": "...",
"./command/command": "...",
"./parser/parser": "...",
"./style/style": "...",
"./output/format/format": "...",
"./output/table/table": "...",
"./spinner/spinner": "...",
"./progress/progress": "...",
"./prompt/prompt": "...",
"./errors/errors": "..."
}
```
### 6.10 Key `package.json` fields
```json
{
"sideEffects": false,
"dependencies": {
"@suaveplan/readline": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"zod": "catalog:forms",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"vitest": "catalog:testing"
}
}
```
---
## 7. Testing Strategy
### Unit tests (co-located `.test.ts`)
| Module | Key scenarios |
|---|---|
| `scanner` | LF / CR / CRLF, max-line overflow, empty input, mid-stream abort |
| `reader` | file path, WHATWG `ReadableStream`, `AbortSignal` mid-iteration |
| `interface` | real `Readable`/`Writable` pair, question/close lifecycle, `closed` guard, async iterator abort via inherited signal, OTel span created / ended on close / error status on abort |
| `parser` | all option types, `--no-*`, `--`, variadic, unknown options |
| `command` | subcommand routing, aliases, help text, error paths |
| `spinner` / `progress` | injectable stream (no TTY), AbortSignal cancellation |
| `prompt` | injected mock `InputReader`, validation retries, all five prompt types |
### Property-based tests (fast-check)
- **scanner/parser round-trip** — split any string into lines, scan bytes, reassemble → original string
- **parser safety** — `parseArgs(argv, { allowUnknown: true })` never throws for any `argv`
- **style round-trip** — `stripAnsi(style.bold.red(s)) === s` for all strings `s`
- **table invariant** — column widths always ≥ max cell content width
### Coverage gate
100% statement + branch + function + line (istanbul) for both packages.
---
## 8. Benchmarks
### `@suaveplan/readline` (`src/__benchmarks__/`)
| Benchmark | Description |
|---|---|
| `scanner-throughput` | 1 MB and 10 MB streams, lines/sec |
| `reader-file` | `readLines()` over a temp file vs `node:readline` baseline |
### `@suaveplan/cli` (`src/__benchmarks__/`)
| Benchmark | Description |
|---|---|
| `parser-throughput` | 10 000 `parseArgs()` calls with a 20-flag config |
| `app-run` | Full `App.run()` dispatch cycle |
Baselines published in each package's `README.md`. A >10% regression fails CI. Each package's `package.json` must include a `"bench"` script and the turbo pipeline must include a `bench` task.
---
## 9. OpenSpec Changes
```text
openspec/changes/
enterprise-readline/
proposal.md
tasks.md
specs/readline/spec.md
enterprise-cli/
proposal.md
tasks.md
specs/cli/spec.md
```
Both archive on ship to `openspec/archive/2026-04-20-enterprise-readline/` and `openspec/archive/2026-04-20-enterprise-cli/`.
---
## 10. CLAUDE.md Additions
Add two entries to the genesis package listing under `platform`:
- **readline** — terminal I/O primitive: AsyncGenerator line reader (`readLines`), interactive prompt interface (`createInterface`), byte scanner engine. Depends on `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `zod`.
- **cli** — CLI toolkit: argument parser, command router, ANSI styling, output formatting, table renderer, spinner, progress bar, interactive prompts. Depends on `@suaveplan/readline`, `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `zod`.
---
## 11. Acceptance Criteria
Both packages are enterprise-done when:
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/readline` green
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/cli` green
- [ ] 100% istanbul coverage on both packages
- [ ] Zero `any`, `TODO`, `FIXME`, `ts-ignore`, `.skip` in `src/`
- [ ] Every `.ts` implementation file has a sibling `.md` (≥200 words)
- [ ] Zod schemas exported for all public types
- [ ] OTel spans on `App.run()`, `Command.execute()`, `createInterface()`
- [ ] AbortSignal honoured in `readLines()`, `question()`, async iterator, `spinner`, `progress`, all prompt functions
- [ ] `node:readline` absent from `@suaveplan/cli` dependency graph
- [ ] `"sideEffects": false` in both `package.json` files
- [ ] mitata benchmarks in `src/__benchmarks__/` with `"bench"` script wired in `package.json` and turbo pipeline
- [ ] Baselines published in both `README.md` files
- [ ] OpenSpec changes archived; `FOLDERS.yml` and `CLAUDE.md` updated

View File

@@ -0,0 +1,186 @@
# Design: `@suaveplan/utils` Migration — genesis
**Date:** 2026-04-20
**Branch:** genesis
**Source:** `@suaveplan/utils` from main (`/mnt/s/web/suaveplan/packages/universal/utils`)
**Reference:** `/mnt/s/web/suaveplan/enterprise/utils.md`
---
## Context
The old `@suaveplan/utils` package is a 14-module grab-bag imported by 109 reverse-consumer workspaces. On genesis, we rewrite it fresh at enterprise quality and split it into four packages, each with a clear, single responsibility. Nothing is ported mechanically — every module is rewritten to meet the genesis enterprise checklist.
---
## Decision: Split into 4 packages
| Package | Domain path | Purpose |
|---------|-------------|---------|
| `@suaveplan/utils` | `universal/data/utils` | Zero-external-dep odds-and-sods utilities |
| `@suaveplan/json` | `universal/data/json` | Prototype-pollution-safe JSON parse/stringify |
| `@suaveplan/bytes` | `universal/primitives/bytes` | Byte encoding / format conversion |
| `@suaveplan/locale-data` | `universal/i18n/locale-data` | ISO country + language static data |
**Scrapped:** `string/parseTemplate` only — duplicate of `@suaveplan/templates` which already handles `{{var}}` interpolation. `string/parseTextWithLinks` is distinct (link annotation, not template interpolation) and is retained in `@suaveplan/utils`.
---
## Package 1: `@suaveplan/utils`
**Principle:** Zero external deps beyond the genesis standard (`zod`, `@opentelemetry/api`, `@suaveplan/error`, `@suaveplan/types`). Odds-and-sods utilities that are not worth a dedicated package.
### Modules
| Module | Key exports |
|--------|-------------|
| `debug` | `debug.log/warn/error` — dedup-buffered console logger (suppresses repeated messages within a rolling 10-item buffer) |
| `object` | `deepMerge`, `deepMergeAll`, `deepClone`, `pick`, `omit`, `flattenObject`, `getNestedValue`, `setNestedValue`, `hasPath`, `isEmpty`, `isObject`, `isPlainObject`, `isObjectOrArray`, `keepKeys`, `removeKeys` |
| `platform` | `getPlatform`, `getMemoryUsage`, `getCpuUsage`, `isNode`, `isBrowser`, `isBun` — injectable platform adapter |
| `number` | `packFloat16/32/64`, `unpackFloat16/32/64`, `isInFloat16/32/64Range`, `min16/32/64`, `max16/32/64` |
| `random` | `pseudoRandom<T>(arr, index, seed?)` — seeded deterministic array element selection |
| `email` | `normalizeEmail` — Gmail dots/plus, Yahoo dashes, Microsoft plus, generic normalization |
| `sound` | `parseSoundVariations`, `getSoundKey`, `convertSprites` — audio sprite key parsing + variation selection |
| `string` | `generateId`, `parseTextWithLinks` |
| `validate` | `isHex` |
| `validation` | `validateSize`, `formatSize`, `parseSize`, `truncateToSize`, `isWithinSizeLimit`, `getDataSize`, `MessageSizeError`, `SIZE_LIMITS`, `DEFAULT_MAX_MESSAGE_SIZE` |
### Dependencies
```text
@suaveplan/error workspace:^
@suaveplan/types workspace:^
zod catalog:zod
@opentelemetry/api catalog:otel-api
```
---
## Package 2: `@suaveplan/json`
**Principle:** Safe, hardened JSON operations. No external deps.
### Modules
| Module | Key exports |
|--------|-------------|
| `safe-json` | `safeParse`, `safeStringify`, `tryParse`, `isValidJSON`, `getStringifySize`, `SafeJSONError` |
### Features
- Prototype pollution guard: rejects `__proto__`, `constructor`, `prototype` keys by default (configurable)
- Circular reference detection with structured `SafeJSONError`
- `tryParse` returns `undefined` instead of throwing
- `getStringifySize` measures serialised byte length without full allocation
- All errors extend `@suaveplan/error` with stable codes (`JSON.PARSE_FAILED`, `JSON.CIRCULAR`, `JSON.PROTOTYPE_POLLUTION`)
### Dependencies
```text
@suaveplan/error workspace:^
@suaveplan/types workspace:^
zod catalog:zod
@opentelemetry/api catalog:otel-api
```
---
## Package 3: `@suaveplan/bytes`
**Principle:** Single canonical home for byte encoding and format conversion in the monorepo. `@suaveplan/crypto-utils` currently duplicates some of this; a follow-up task updates `crypto-utils` to consume `@suaveplan/bytes` instead.
### Modules
| Module | Key exports |
|--------|-------------|
| `encoding` | `bytesToHex`, `hexToBytes`, `bytesToBase64`, `base64ToBytes`, `bytesToUtf8`, `utf8ToBytes` |
| `uint8` | `concatUint8Arrays`, `sliceUint8Array`, `uint8ArraysEqual`, `inputToUint8Array`, `detectInputFormat`, `validateInputTypeFormat`, `formatOutput`, `convertDataFormat` |
| `uuid` | `uint8ArrayToUuid`, `uuidToUint8Array`, `generateUuid` — uses `crypto.randomUUID()` natively; no `uuid` npm package needed |
### Features
- Format auto-detection (`detectInputFormat`: hex, base64, utf8, Uint8Array)
- Round-trip guarantees tested via fast-check property tests
- All errors extend `@suaveplan/error` with stable codes (`BYTES.INVALID_FORMAT`, `BYTES.ENCODE_FAILED`, etc.)
### Dependencies
```text
@suaveplan/error workspace:^
@suaveplan/types workspace:^
zod catalog:zod
@opentelemetry/api catalog:otel-api
```
### Follow-up
After `@suaveplan/bytes` is published, open an OpenSpec change to update `@suaveplan/crypto-utils` to depend on `@suaveplan/bytes` and remove the duplicate hex/base64/utf8/bytes/concat encoding utilities. **`base58` stays in `crypto-utils`** — it is blockchain/crypto-specific (Bitcoin address encoding) and not a general-purpose byte encoding concern; moving it to `bytes` would introduce a crypto-domain concept into a general primitive.
---
## Package 4: `@suaveplan/locale-data`
**Principle:** Static ISO reference data. Too large and domain-specific to be an odds-and-sods utility. Feeds `@suaveplan/i18n`.
### Modules
| Module | Key exports |
|--------|-------------|
| `countries` | `CountryItem` type, per-country named exports (`AD`, `AE`, ...), `getCountry(code)`, `getAllCountries()`, `getCountriesByContinent(continent)`, `getCountriesByCurrency(currency)` |
| `languages` | `LanguageItem` type, `getLanguages()`, `getLanguage(code)`, `getRtlLanguages()`, `isRtlLanguage(code)` |
### Features
- All data typed with Zod schemas
- Lookup helpers with structured errors for unknown codes
- Zero runtime deps — data is bundled at build time, not fetched
### Dependencies
```text
@suaveplan/error workspace:^
@suaveplan/types workspace:^
zod catalog:zod
@opentelemetry/api catalog:otel-api
```
---
## Build sequence
All four packages share the same dependency set (genesis standard), so they can land in parallel or any order. Recommended sequence:
1. `@suaveplan/bytes` — unblocks `crypto-utils` refactor
2. `@suaveplan/json` — self-contained
3. `@suaveplan/locale-data` — self-contained; enables `i18n` to consume it
4. `@suaveplan/utils` — self-contained; the catch-all
---
## Enterprise checklist (applies to all four)
- [ ] 100% statement + branch + function + line coverage (Istanbul)
- [ ] 100% docs coverage (`bun run docs:check`)
- [ ] Every `.ts` implementation file has a sibling `.md` (≥200 words, simple + advanced examples, API reference)
- [ ] All public types have Zod schemas in sibling `*.schemas.ts`
- [ ] All thrown errors extend `@suaveplan/error` with stable `PACKAGE.DOMAIN.REASON` codes
- [ ] Injectable `clock`, `rng`, `signal` where applicable
- [ ] `AbortSignal` on every iterative helper
- [ ] OTel counters + histograms + spans on public entrypoints
- [ ] fast-check property tests for pure functions
- [ ] mitata benchmarks with CI regression gate
- [ ] `sideEffects: false`; one subpath export per feature module
- [ ] `@vitest/coverage-istanbul` in `devDependencies`
- [ ] OpenSpec change created, verified, archived
---
## OpenSpec changes to create
| Change ID | Scope |
|-----------|-------|
| `enterprise-bytes` | New `@suaveplan/bytes` package |
| `enterprise-json` | New `@suaveplan/json` package |
| `enterprise-locale-data` | New `@suaveplan/locale-data` package |
| `enterprise-utils` | New `@suaveplan/utils` package (genesis rewrite) |
| `crypto-utils-bytes-refactor` | Update `@suaveplan/crypto-utils` to consume `@suaveplan/bytes` (follow-up) |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
# Test-Suite Documentation Audit — Genesis Monorepo (2026-04-24 wave)
**Date:** 2026-04-25
**Scope:** review of `openspec/specs/testing*/spec.md` (canonical, 2026-04-19) plus the 2026-04-24 testbed design + plans in `docs/superpowers/`.
**Goal:** identify gaps and improvements to elevate the test-suite documentation to enterprise-class quality.
## Executive summary — top critical gaps
1. **Spec drift is severe.** The canonical `openspec/specs/testing*/spec.md` files (2026-04-19) say nothing about `testing-integration*`, `console-monitor`, `network-monitor`, `multi-session`, or `extension-fixture`. All recent 2026-04-24 work lives only in `docs/superpowers/plans/` and has not been folded into the contract — there is no normative spec for the integration substrate that the entire testbed and CI nightly job depends on.
2. **Test-pyramid is missing several mandatory enterprise tiers.** Zero accessibility (axe-core), zero visual-regression, zero contract testing, zero mutation testing, zero load/perf, zero fuzzing for non-pure code, zero chaos/fault injection beyond the single "disable signalling route" test.
3. **Service-fixture coverage is too narrow for the package list.** `oauth-client`, `mcp`, `server-emailer`, `search`, future Kafka/NATS-style event pipes, and any object-store/secret/identity work have no fixtures. The minimum missing set: MongoDB, MySQL/MariaDB, OIDC issuer (Keycloak/dex/Hydra), MinIO (broader-S3 parity vs Garage), an OTel collector, and a NATS/Kafka broker.
4. **`apps/testbed` only partially enforces CLAUDE.md's six "Web App & Page Verification" bullets.** Console + network monitors and Docker staging are wired; rendered-DOM content checks per page, mixed-content, asset-404 detection, and the "zero-defect-to-live" gate are not formalised in the plan.
5. **Test-suite observability is absent.** No JUnit/Allure output, no OTel export of test execution despite OTel being a first-class concern in `testing/spec.md`, no flake-rate metric, no coverage trend, no trace-on-failure shipped to a sink. CI matrix is also single-arch (no arm64, no Bun version matrix, no Node fallback parity check).
---
## 1. Coverage gaps in service fixtures
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | No OIDC issuer fixture, but `@suaveplan/oauth-client` already exists | new plugin `testing-integration-oidc` (Keycloak or `oauth2-mock-server`) under `packages/testing/` | Add to `enterprise-testbed-design.md §4.1` and create plan; required to integration-test `oauth-client` against a real Authorization Code + PKCE flow. |
| Critical | No Mongo / MySQL fixture | `enterprise-testbed-design.md §4.1` | Add `testing-integration-mongodb` (mongo:7) and `testing-integration-mysql` (mysql:8) — many real apps use these and there is no parity coverage for `db-*` siblings of `db-sqlite`. |
| High | No object-store parity beyond Garage | new `testing-integration-minio` | Garage has quirks (admin-port health, layout assignment) that mask AWS-S3 differences; MinIO is the closest free AWS-equivalent and exposes a different surface. |
| High | No Kafka/NATS/RabbitMQ broker fixture | `§4.1` | Add at least one (NATS is lightest) — events, schema-registry, idempotency, and any future stream-processing package needs it. |
| High | No OpenTelemetry collector fixture | `§4.1` | Required to actually verify the OTel instrumentation that every package ships; today you can only assert "no SDK registered = noop", not end-to-end export. |
| Medium | No Elasticsearch/OpenSearch fixture | `§4.1` | Typesense covers search but Elastic-API consumers are common — flag for later wave. |
| Medium | No Vault / secrets fixture, no LocalStack | `§4.1` | Mark as deferred; document the gap so consumers aren't surprised. |
| Low | No Caddy/nginx reverse-proxy test fixture | `§4.1` | Caddy already runs in `testbed` stack; expose a minimal fixture for routing tests. |
## 2. Test-pyramid completeness
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | No accessibility testing | `testing-e2e/spec.md` + `testbed-pages-and-e2e.md §7.4` | Add `attachAxe(page)` helper (using `@axe-core/playwright`) and require every spec's invariant list to include `await axe.assertNoViolations()` alongside console/network monitors. |
| Critical | No visual regression | `testing-e2e/spec.md` + `testbed-foundation.md` Storybook section | Add `expect(page).toHaveScreenshot()` baseline strategy plus a Storybook test-runner job; or pin a `pixelmatch`-based helper into `testing-e2e`. Decide and write the spec — "stories exist" is not visual coverage. |
| High | No mutation testing | `testing/spec.md` quality gate | Add a Stryker job (Bun-compatible via `@stryker-mutator/core` + node) for `crypto*`, `error`, `validation`, `serialize`, and other foundation packages; gate at e.g. ≥ 70% mutation score. |
| High | No load/perf gate beyond `mitata` micro-bench | `testing/spec.md` benchmark requirement | Extend with a k6/autocannon "API smoke" gate against `apps/testbed`'s Hono routes, run nightly; current 10% mitata gate doesn't cover end-to-end latency or concurrency. |
| High | No contract tests (consumer/provider) | new `testing-contract` package or `testing-integration` extension | `oauth-client`, `webhook-client`, `schema-registry`, `api-schemas` are textbook Pact candidates; spec a `recordPactInteraction()` helper. |
| Medium | Property tests (fast-check) only mandated for `@suaveplan/testing` itself | `testing/spec.md` "Property-based coverage" requirement | Generalise into a project-wide rule: every pure-function package SHALL include fast-check round-trip tests; cite `crypto-utils`, `diff`, `pagination`, `validation`. |
| Medium | No fuzzing for parsers | per-package specs (`mime`, `dmarc`, `password-formats`, `metadata`, `exif`, `serialize`) | Require a `*.fuzz.test.ts` using `fast-check` arbitraries seeded from real-world corpora; these are all attack-surface parsers. |
| Medium | No chaos/fault injection beyond single "disable route" | `testing-e2e/spec.md` + multi-session specs | Add network-throttle, latency, and abort fixtures (`page.route` with random failure injection); already half-built in `network-monitor`. |
| Low | No snapshot strategy doc | `testing-dom/spec.md` | One-paragraph stance on inline snapshots vs file snapshots; right now nothing forbids snapshot drift. |
## 3. Cross-cutting concerns
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | Multi-fixture lifecycle order is parallel-start / reverse-stop only — no declared dependencies between fixtures | `testing-integration-core.md` `TestEnvironment` section + `testing` spec delta | Add `dependsOn?: string[]` to `ServiceFixture`; topo-sort start order; document for cases like "OIDC issuer must be ready before app under test seeds clients". |
| Critical | No deterministic time/RNG injection contract for integration tests | new requirement in `testing-integration` spec | The `testing` spec mandates `Clock`/`Rng` injection but `testing-integration` fixtures don't propagate; add `clock`/`rng` to `TestEnvironmentOptions` and require fixture configs to pass them through where applicable. |
| High | No flake quarantine / retry policy for integration tests | `testing-integration` spec | Add `quarantine.json` + a `markFlaky()` helper that retries N times and reports — without it, transient Docker failures will block PRs. |
| High | No leak/handle-tracking on teardown | `TestEnvironment` requirement | After `teardown()`, assert via `process._getActiveHandles()` (or Bun equivalent) that no handles attributable to fixtures remain; fail the run if so. |
| High | No CI matrix on Bun versions, OS, or Node-fallback parity | `testbed-pages-and-e2e.md §7.6` | Add matrix: `bun-1.x` + `bun-canary`, `ubuntu-latest` + `macos-14` + `windows-latest` (Playwright at minimum); add a Node-runner parity job for browser-runnable packages. |
| High | No secret management policy for CI | `testing-integration` spec + `docker/README.md` | Document where `GARAGE_S3_SECRET_ACCESS_KEY` and any future OIDC client secrets live (GitHub OIDC + Vault/SOPS); current `.env.example` leaves them blank with no guidance. |
| Medium | Port collision policy is static range only | `enterprise-testbed-design.md §5.2` | testcontainers already uses random ports for the integration path; spec the dev/test-stack collision behaviour (fail-loud vs auto-shift) and document in `docker/README.md`. |
| Medium | No OOM/timeout budget per spec | `testing/spec.md` and Playwright config | Per-spec memory cap and global-suite wall-clock budget; current 30 s test timeout is per-test, no suite ceiling. |
| Medium | No network sandboxing rule | `testing-e2e/spec.md` | Default `page.route('**/*')` to block external origins unless explicitly allow-listed; today specs could hit live internet. |
## 4. Observability of the tests themselves
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | No JUnit / Allure / structured test-result output | `testing/spec.md` + Playwright config | Mandate `junit` + `playwright html` reporters and an Allure aggregation step in CI; currently only `["html","github"]` is set. |
| High | OTel test-execution export missing despite first-class OTel in spec | new requirement in `testing/spec.md` | Wire test runner to the `testing-integration-otel-collector` fixture (see §1) and export every test's span; failed-test traces become triage gold. |
| High | No flake-rate / coverage trend tracking | `testing/spec.md` quality gate | Persist Istanbul + JUnit artefacts to a static bucket (Garage S3 itself!) and compute per-package flake rate over 30 days; gate "new flaky test" on PR. |
| Medium | Trace-on-failure exists locally (`trace: on-first-retry`) but is not exported anywhere | Playwright config | Upload `trace.zip` artefacts to the same bucket on red runs. |
## 5. Operational rigor
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | Images pinned by tag, not digest | `docker-infrastructure.md` + plugin specs | Replace every `image: foo:tag` with `image: foo:tag@sha256:…`; tag-based pinning is non-reproducible (especially `axllent/mailpit:latest`, which is wrong for any "enterprise" claim). |
| Critical | `axllent/mailpit:latest` in particular | `docker-infrastructure.md` Chunk 1 + `testing-integration-mailpit` plugin | Pin to a specific minor (`v1.x`) and digest. |
| High | No arm64 ↔ amd64 matrix for testcontainers | `testbed-pages-and-e2e.md §7.6` | Garage and Typesense images don't always ship arm64; CI must run both arches or the spec must document amd64-only. |
| High | No airgap / offline-cache test | new requirement | Verify the test suite runs after `bun install --offline` with all images pre-pulled; many enterprises require this. |
| High | No Verdaccio integration in test path | `testing-integration` spec | The repo uses Verdaccio at `npmjs.fq.io` — there should be a `testing-integration-verdaccio` fixture (or compose service) so publish flows are testable, not just lint+typecheck+test. |
| Medium | No container resource limits | `docker-compose.*.yml` | Add `deploy.resources.limits` (cpu, memory) — without them, a runaway test can starve CI. |
| Medium | Healthcheck timeouts not coherent with WSL2 fork-pool timing | `testing-integration-core.md` HealthPoller defaults + CLAUDE.md "Vitest / WSL2" | 120 s default is fine, but document interaction with `fileParallelism: false` for browser tests; a parallel sweep can serialise health polls and blow the budget. |
## 6. Documentation / spec coherence
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | None of the 2026-04-24 work is in `openspec/specs/` | `openspec/specs/testing-integration/spec.md` (new), and ADDED requirements in `openspec/specs/testing-e2e/spec.md` | Create an OpenSpec change folder, write deltas covering: `ServiceFixture<T>`, `TestEnvironment`, `createIntegrationSetup`, `skipIfUnavailable`, `HealthPoller`, env-bridge, plus the new e2e modules (`console-monitor`, `network-monitor`, `multi-session`, `extension-fixture`); fold on ship. |
| High | Six service-fixture plugins have no spec at all | new `openspec/specs/testing-integration-<svc>/spec.md` per plugin (or one umbrella) | Each needs at minimum: image policy, env-bridge contract, healthcheck contract, config shape, error code prefix. |
| Critical | `testing` spec mandates ≥ 95 % coverage; project rule is **100 % for every package, no exceptions** (statements / branches / functions / lines, Istanbul provider) | `openspec/specs/testing/spec.md` | Replace the 95 % threshold with a flat 100 % gate. No "foundation-only" carve-out. Pre-publish gate (`bunx turbo test:coverage --filter=@suaveplan/<pkg>`) must hard-fail under 100 %. Remove every reference to 95 % in plans and specs. |
| Medium | The `testing` spec's `provide("__integration_failed__", …)` interface is invented in plans without being mirrored in any spec | `testing-integration` spec | Document it as a stable inject-key contract or rename to `__suaveplan_integration_failed__` to avoid name collisions. |
## 7. apps/testbed verification rigor
CLAUDE.md mandates six bullets; the plan delivers ~3.5 of them.
| Severity | Bullet | Status | Concrete fix |
|---|---|---|---|
| Met | Docker staging environment | `docker-compose.testbed.yml` is fine | — |
| Partial | Full Playwright sweep of every page + every component | Pages enumerated via `routes.ts`; components only via Storybook stories — there is no spec that says "every component on every page is interacted with" | `testbed-pages-and-e2e.md §7.4` + spec | Add a per-page `interaction.spec.ts` template that asserts every `data-testid="demo-card"` triggers `data-testid="output"` to populate; baseline only navigates. |
| Critical | Never trust HTTP 200 alone — verify rendered DOM | Plans require `data-testid="page-title"` non-empty but not other rendered content | `enterprise-testbed-design.md §7.4` | Strengthen invariant 5 to "`output` AND at least one demo-specific assertion per page", and forbid baseline-only specs as the sole coverage for a route. |
| Met | Inspect browser console | `attachConsoleMonitor` covers it | — |
| Partial | Inspect network activity | `attachNetworkMonitor` exists; no rules for missing-asset / mixed-content / CORS | `testing-e2e/spec.md` | Extend `NetworkMonitor` contract: `assertNoMissingAssets()`, `assertNoMixedContent()`, `assertNoCorsErrors()` as separate methods, all called by invariant. |
| Critical | Zero-defect push to live | No gate exists | CI workflow + `enterprise-testbed-design.md` | Add a "release readiness" job that fails on any of: console error, network failure, axe violation, visual diff, broken-link crawler hit. Without this, the bullet is aspirational. |
## 8. Test-suite onboarding
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| High | No single "how do I write a test for X" entry point | new `docs/testing/README.md` (or `openspec/specs/testing/AGENTS.md`) | Decision tree: pure fn → vitest+fast-check; React → `testing-dom`; needs Docker → `testing-integration` + plugin; cross-browser → `testing-e2e`; performance → mitata + k6. Link from root README and CLAUDE.md. |
| Medium | No "first test in 5 minutes" tutorial against `apps/testbed` | `apps/testbed/README.md` | The plan's README requirements list operational topics but not a tutorial; add one. |
| Medium | No discoverability index of fixtures | `docker/README.md` or new `docs/testing/fixtures.md` | Table mapping "I need to test X" → "use fixture Y" for every plugin. |
---
## Universal coverage rule
**100 % Istanbul coverage (statements / branches / functions / lines) is mandatory for every `@suaveplan/*` package — no exceptions, no "foundation only", no "95 % is fine for X".** The pre-publish gate (`bunx turbo test:coverage --filter=@suaveplan/<pkg>`) MUST hard-fail below 100 %. Every spec, plan, and tooling default in this repo SHALL state 100 % and only 100 %. Any 95 % reference is a defect to fix on sight.
Mutation testing, contract testing, and property/fuzz coverage are layered ON TOP of 100 % line coverage — they do not substitute for it, and 100 % line coverage does not substitute for them.
## Recommended sequencing
1. **Lift 2026-04-24 plans into normative specs.** Open an OpenSpec change adding `openspec/specs/testing-integration/spec.md` + per-plugin specs + ADDED requirements in `testing-e2e/spec.md` for the new modules. Without this, every future plugin compounds drift.
2. **Replace every 95 % coverage reference with 100 %** in the same change — `testing/spec.md`, the archived `2026-04-19-enterprise-testing` design notes if still authoritative, and any plan defaults. Add an explicit "100 %, no exceptions" line to `openspec/specs/testing/spec.md` quality requirements.
3. **Add the missing pyramid tiers as separate OpenSpec changes**, in this order: a11y (cheap, high-signal) → visual regression → OTel-collector fixture + test-execution export → contract testing → mutation testing → load/perf gate.
4. **Tighten testbed verification** to fully meet CLAUDE.md's six bullets — strengthen invariants, add missing-asset/mixed-content/CORS assertions, add the release-readiness gate.
5. **Fix operational hygiene last** — digest-pin all images, add resource limits, add CI matrix, add airgap rebuild test.
## Files referenced
- `/mnt/s/web/genesis/openspec/specs/testing/spec.md`
- `/mnt/s/web/genesis/openspec/specs/testing-dom/spec.md`
- `/mnt/s/web/genesis/openspec/specs/testing-e2e/spec.md`
- `/mnt/s/web/genesis/docs/superpowers/specs/2026-04-24-enterprise-testbed-design.md`
- `/mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-docker-infrastructure.md`
- `/mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-testing-integration-core.md`
- `/mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-testing-integration-plugins.md`
- `/mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-testbed-foundation.md`
- `/mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-testbed-pages-and-e2e.md`

View File

@@ -0,0 +1,873 @@
# Universe Family — Design Spec
**Date:** 2026-04-25
**Status:** Draft
**Packages:** 9 new packages across `geo`, `astronomy`, and `gaming` domains
**Replaces:** `@suaveplan/day-night` (retired), `@suaveplan/geo` (restructured into 3 packages)
---
## 1. Motivation
The existing `@suaveplan/day-night` package mixes gaming concerns (ECS components, gameplay modifiers, in-game clock) with simplified pseudo-astronomy (hardcoded sinusoidal sun arc, 30-day moon cycle, no lat/lon). The existing `@suaveplan/geo` covers Earth-surface geometry only. Neither is enterprise-grade, and neither is extensible.
This spec defines a nine-package family that:
- Provides real, observatory-accurate astronomical calculations via a pluggable provider architecture
- Generalises Earth-surface geometry to any planet via configurable ellipsoid + physical parameters
- Supports an observer-centric model: stars, planets, tides, and lighting are computed from the point of view of a single observer position — on any planet surface, in atmosphere, or anywhere in space
- Runs all of the above at configurable N× speed for game engines, with physically-derived lighting and gameplay modifiers replacing hardcoded presets
- Locks in a binary wire protocol (BitSchemas via `@suaveplan/bitpacker`) for efficient server→client delivery of star query results, deferring the SurrealDB implementation to a later phase
---
## 2. Package Family & Dependency Graph
### Locations in genesis monorepo
```
Layer 0 — Abstract geometry
universal/geo/geo @suaveplan/geo
Layer 1 — Concrete surfaces
universal/geo/geo-earth @suaveplan/geo-earth
universal/geo/geo-planet @suaveplan/geo-planet
Layer 2 — Astronomical observer + plugin hub
universal/astronomy/universe @suaveplan/universe
universal/astronomy/solar-system @suaveplan/solar-system
universal/astronomy/stars @suaveplan/stars
universal/primitives/tides @suaveplan/tides (enterprise rewrite)
Layer 3 — Game layer
universal/gaming/gaming-universe @suaveplan/gaming-universe
universal/gaming/gaming-environment-lighting @suaveplan/gaming-environment-lighting
```
### Dependency graph
```
geo (zero workspace deps)
geo-planet → geo, error, types
geo-earth → geo, geo-planet, error, types ← geo-earth IS geo-planet configured for Earth
universe → geo, geo-planet, bitpacker, error, types
solar-system → universe, error, types
stars → universe, bitpacker, error, types
tides → universe, geo-planet, error, types
gaming-universe → universe, timing, events, error, types
gaming-environment-lighting → gaming-universe, events, error, types
```
### What happens to existing packages
- **`@suaveplan/day-night`** — retired. `gaming-environment-lighting` is its enterprise replacement.
- **`@suaveplan/geo`** — restructured. The enterprise migration of the existing package becomes `geo-earth`. Abstract math is extracted into `geo`. `geo-planet` is new.
- **`@suaveplan/tides`** — enterprise rewrite in place; gains `TidalProvider` adapter for `universe` integration.
---
## 3. `@suaveplan/geo` — Abstract Geometry
### Purpose
Pure spherical and ellipsoidal geometry math. No planet-specific constants, no astronomical meaning. Every operation that needs a body's shape takes an explicit `Ellipsoid` parameter.
### Dependencies
`@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`. Zero other workspace deps.
### Core types
```ts
interface Ellipsoid {
semiMajorAxis: number; // equatorial radius, metres
semiMinorAxis: number; // polar radius, metres
flattening: number; // derived: (a − b) / a
eccentricity: number; // derived
}
interface SphericalCoord {
latitude: number; // degrees, −90 to 90
longitude: number; // degrees, −180 to 180
altitude?: number; // metres above surface
}
interface Vec3 { x: number; y: number; z: number }
interface Mat3x3 { /* 9-element row-major matrix */ }
```
### Subpath exports
| Subpath | Contents |
|---|---|
| `geo/ellipsoid` | `Ellipsoid`, `defineEllipsoid(a, b)` |
| `geo/transforms` | `sphericalToCartesian`, `cartesianToSpherical`, `toRadians`, `toDegrees` |
| `geo/distance` | `greatCircleDistance(a, b, ellipsoid, unit?)`, `vincentyDistance(a, b, ellipsoid, unit?)` |
| `geo/bearing` | `bearing(from, to)`, `destination(start, bearingDeg, distM, ellipsoid)`, `midpoint(a, b)` |
| `geo/angular` | `angularSeparation(a, b)` — angle between two unit-sphere points |
| `geo/bounds` | `BoundingBox`, `createBoundingBox`, `containsPoint`, `expandBox`, `mergeBoxes`, `intersects` |
| `geo/polygon` | `pointInPolygon`, `polygonArea`, `polygonPerimeter`, `simplifyPolygon` |
| `geo/units` | `DistanceUnit`, `convertDistance` |
### Explicit non-scope
- No numeric constant for any specific body (Earth, Mars, etc.)
- No geocoding providers
- No geohash
- No atmospheric models
- No astronomical meaning — nothing about observers, stars, or coordinate frames
---
## 4. `@suaveplan/geo-planet` — Configurable Planet Surface
### Purpose
Defines `PlanetConfig` — the single source of truth for any planet's physical, rotational, atmospheric, and satellite properties. Implements surface geometry on any configured ellipsoid.
### Dependencies
`@suaveplan/geo`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### Core types
```ts
interface PlanetConfig {
name: string;
mass: number; // kg
radius: number; // metres, equatorial
polarRadius: number; // metres
surfaceGravity: number; // m/s²
rotationPeriod: number; // seconds (sidereal day)
axialTilt: number; // degrees
atmosphere: AtmosphereConfig | null;
satellites: SatelliteConfig[];
orbital?: OrbitalConfig; // Keplerian elements — for game-defined planets
textures?: PlanetTextureManifest; // identifier refs only, no bundled assets
}
interface AtmosphereConfig {
surfacePressure: number; // Pa
scaleHeight: number; // metres (pressure halving altitude)
rayleighCoefficient: number; // for sky colour computation
mieCoefficient: number; // for Mie scattering (dust, haze)
extinctionPerAirMass: number; // magnitudes of star dimming per air mass
}
interface SatelliteConfig {
name: string;
mass: number; // kg
radius: number; // metres
orbitalRadius: number; // metres, semi-major axis
orbitalPeriod: number; // seconds
orbitalInclination: number; // degrees
eccentricity: number;
albedo: number; // 0–1, reflectivity
}
interface OrbitalConfig {
semiMajorAxisAU: number;
eccentricity: number;
inclinationDeg: number;
argumentOfPerihelionDeg: number;
ascendingNodeDeg: number;
meanAnomalyAtEpochDeg: number;
epochJd: number; // Julian date of reference epoch
}
interface PlanetTextureManifest {
diffuse?: string; // URL or asset identifier
normal?: string;
specular?: string;
clouds?: string;
night?: string; // city lights, etc.
}
```
### PlanetSurface
```ts
class PlanetSurface {
constructor(planet: PlanetConfig)
distance(a: SphericalCoord, b: SphericalCoord, unit?: DistanceUnit): number
bearing(from: SphericalCoord, to: SphericalCoord): number
destination(start: SphericalCoord, bearingDeg: number, distanceM: number): SphericalCoord
pointInPolygon(point: SphericalCoord, polygon: SphericalCoord[]): boolean
atmosphericDensity(altitudeMetres: number): number // kg/m³, derived from AtmosphereConfig
airMass(altitudeDeg: number): number // Kasten–Young formula
}
```
---
## 5. `@suaveplan/geo-earth` — Earth Configuration
### Purpose
`geo-earth` is `geo-planet` pre-configured for Earth. It is not a sibling of `geo-planet` — it depends on `geo-planet` and exposes Earth's `PlanetConfig` as a constant, plus Earth-specific additions that do not generalise to other planets.
### Dependencies
`@suaveplan/geo`, `@suaveplan/geo-planet`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### What it provides
```ts
// Earth's PlanetConfig constant — WGS84 ellipsoid, Moon as satellite, ISA atmosphere
export const EARTH: PlanetConfig
// Pre-built PlanetSurface for Earth — no config required
export const earthSurface: PlanetSurface
// Earth-specific additions
export { GeocodingProvider, GeocodingResult, createInMemoryProvider, batchGeocode }
export { encodeGeohash, decodeGeohash }
export { parseCoordinate, formatCoordinate } // DMS parsing / formatting
```
The enterprise migration of the existing `@suaveplan/geo` lands here. The existing public API is preserved — only the internal implementation changes (math now delegates to `geo-planet`).
---
## 6. `@suaveplan/universe` — Observer Model, Frames, Plugin Registry, Wire Protocol
### Purpose
The semantic core of the family. Where geometry becomes astronomy. Owns: the unified observer position type, coordinate frame definitions and converters, provider interfaces, the plugin registry, and wire BitSchemas.
### Dependencies
`@suaveplan/geo`, `@suaveplan/geo-planet`, `@suaveplan/bitpacker`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### 6a — ObserverPosition
```ts
type ObserverPosition =
| { type: 'surface'; planet: PlanetConfig; coord: SphericalCoord }
| { type: 'atmospheric'; planet: PlanetConfig; coord: SphericalCoord; altitudeMetres: number }
| { type: 'space'; position: Vec3; frame: CoordinateFrame }
```
The `PlanetConfig` is carried inline on surface/atmospheric positions — no lookup required at query time.
### 6b — CoordinateFrame
```ts
type CoordinateFrame =
| { type: 'heliocentric' } // Sun at origin, AU scale, J2000 equatorial
| { type: 'galactic' } // Galactic centre, light-year scale
| { type: 'custom'; origin: Vec3; basisMatrix: Mat3x3 }
// Converters — pure functions, fully testable
function convertFrame(position: Vec3, from: CoordinateFrame, to: CoordinateFrame): Vec3
function toHeliocentric(position: Vec3, from: CoordinateFrame): Vec3
function toGalactic(position: Vec3, from: CoordinateFrame): Vec3
```
Heliocentric is used within the solar system. Galactic for interstellar travel. Custom allows game-defined local frames.
### 6c — Provider interfaces
```ts
interface CelestialBody {
name: string
type: 'star' | 'planet' | 'moon' | 'asteroid' | 'comet'
altitude: number // degrees above observer horizon
azimuth: number // degrees clockwise from north
apparentMagnitude: number
angularDiameterArcmin: number // apparent size in arcminutes
distanceAU: number
heliocentricPosition: Vec3
}
// Raw catalog record — no observer-relative fields
interface CatalogStar {
catalogId: string // e.g. 'HR 7001' (BSC5), 'HIP 91262' (Hipparcos)
ra: number // Right Ascension, degrees J2000
dec: number // Declination, degrees J2000
distance?: number // parsecs (undefined if no parallax data)
apparentMagnitude: number
spectralType: string
}
// Observer-relative star — returned by getVisibleStars; altitude/azimuth always present
interface ObservedStar extends CatalogStar {
altitude: number // degrees above observer horizon
azimuth: number // degrees clockwise from north
extinctedMagnitude: number // apparentMagnitude dimmed by atmosphere (= apparentMagnitude on vacuum/space)
}
// Union used in StarProvider — queryCone returns CatalogStar, getVisibleStars returns ObservedStar
type Star = CatalogStar | ObservedStar
interface StarQueryOptions {
magnitudeLimit?: number
atmosphericExtinction?: boolean
minAltitudeDeg?: number
maxResults?: number
}
interface TideExtreme {
type: 'high' | 'low'
height: number // metres above chart datum
time: Date
satellite?: string // which moon caused this extreme (multi-moon planets)
}
interface SolarProvider {
getBodies(observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise<CelestialBody[]>
getBody(name: string, observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise<CelestialBody>
// ObserverPosition carries PlanetConfig for surface/atmospheric; space observer uses heliocentric frame
getSunrise(observer: ObserverPosition, date: Date, signal?: AbortSignal): Promise<Date>
getSunset(observer: ObserverPosition, date: Date, signal?: AbortSignal): Promise<Date>
}
interface StarProvider {
// Returns ObservedStar[] — altitude/azimuth always populated
getVisibleStars(observer: ObserverPosition, time: Date, options: StarQueryOptions, signal?: AbortSignal): Promise<ObservedStar[]>
// Returns CatalogStar[] — catalog-space query, no altitude/azimuth
queryCone(ra: number, dec: number, radiusDeg: number, options?: StarQueryOptions, signal?: AbortSignal): Promise<CatalogStar[]>
getStar(catalogId: string, signal?: AbortSignal): Promise<CatalogStar>
}
interface TidalProvider {
predictHeight(planet: PlanetConfig, coord: SphericalCoord, time: Date, signal?: AbortSignal): Promise<number>
getExtremes(planet: PlanetConfig, coord: SphericalCoord, date: Date, signal?: AbortSignal): Promise<TideExtreme[]>
}
// WeatherProvider — locked now, implementation deferred (@suaveplan/weather)
// Modulates star visibility (cloud cover occludes), sky colour (Mie scatter from fog/dust),
// and gameplay modifier visibilityRangeMetres. UniverseClient passes weather state to
// gaming-environment-lighting without breaking changes when @suaveplan/weather lands.
interface WeatherState {
cloudCoverage: number // 0–1 (0 = clear, 1 = overcast)
cloudOpticalDepth: number // effective magnitude extinction from clouds (0 = none)
fogDensity: number // 0–1, drives Mie coefficient boost in sky colour
precipitationRate: number // mm/hr, for surface wetness / visibility degradation
}
interface WeatherProvider {
getWeather(observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise<WeatherState>
}
```
### 6d — UniverseClient
```ts
interface SceneOptions {
includeSolar?: boolean // default true — compute sun/moon/planet positions
includeStars?: boolean // default true — query visible stars
includeTides?: boolean // default true — compute tidal extremes (surface only)
starOptions?: StarQueryOptions // passed through to StarProvider.getVisibleStars
}
class UniverseClient {
register(domain: 'solar', provider: SolarProvider): this
register(domain: 'stars', provider: StarProvider): this
register(domain: 'tides', provider: TidalProvider): this
// weather slot: locked now, implementation deferred — gaming-environment-lighting
// reads WeatherState from UniverseScene without a breaking API change when @suaveplan/weather lands
register(domain: 'weather', provider: WeatherProvider): this
// signal cancels all in-flight provider calls
getScene(observer: ObserverPosition, time: Date, options?: SceneOptions, signal?: AbortSignal): Promise<UniverseScene>
}
interface UniverseScene {
bodies: CelestialBody[]
stars: ObservedStar[]
tides?: TideExtreme[]
weather?: WeatherState // undefined until WeatherProvider registered
}
```
### 6e — Wire BitSchemas
Star data is quantised before packing — rendering does not require float64 precision.
```ts
// ~11 bytes per star vs ~200 bytes JSON — ~18× compression
const StarPacket = defineBitSchema([
{ name: 'ra', type: 'uint', bits: 19 }, // 0–360,000 (0.001° steps)
{ name: 'dec', type: 'int', bits: 18 }, // ±90,000
{ name: 'magnitude', type: 'uint', bits: 11 }, // 0–1,200 (×10 + 20 offset: −2.0 to 10.0)
{ name: 'altitude', type: 'int', bits: 15 }, // ±9,000 (0.01° steps)
{ name: 'azimuth', type: 'uint', bits: 16 }, // 0–36,000
{ name: 'spectralClass', type: 'uint', bits: 4 }, // OBAFGKM + 8 reserved
]);
// CelestialBodyPacket — apparentMagnitude: uint, ×10 + 300 offset, 0.1 magnitude resolution
// covers −30.0 to +174.7 (Sun = −26.7 → stored as 33; Jupiter = −2.9 → stored as 271; full 11-bit range used)
const CelestialBodyPacket = defineBitSchema([
{ name: 'altitude', type: 'int', bits: 15 }, // ±9,000 (0.01° steps)
{ name: 'azimuth', type: 'uint', bits: 16 }, // 0–36,000 (0.01° steps)
{ name: 'angularDiameterArcmin', type: 'uint', bits: 14 }, // 0–10,000 (0.01′ steps, covers up to 166°)
{ name: 'apparentMagnitude', type: 'uint', bits: 11 }, // 0–2,047 (×10 + 300 offset: −30.0 to +74.7)
{ name: 'bodyType', type: 'uint', bits: 3 }, // 0=star 1=planet 2=moon 3=asteroid 4=comet
]);
// PackedObservedStar — only the fields recoverable from StarPacket bits
// catalogId and distance are NOT in the wire format; use the catalog source to re-fetch if needed
interface PackedObservedStar {
ra: number; dec: number; apparentMagnitude: number
altitude: number; azimuth: number
spectralClass: number // 0-6 = OBAFGKM, 7 = unknown
}
// Pack/unpack helpers — consumers never touch raw bit widths
// packStars requires ObservedStar (altitude/azimuth always present)
function packStars(stars: ObservedStar[]): Uint8Array
function unpackStars(buf: Uint8Array, count: number): PackedObservedStar[]
// PackedCelestialBody — only the fields recoverable from CelestialBodyPacket bits
// name, distanceAU, heliocentricPosition are NOT in the wire format
interface PackedCelestialBody {
altitude: number; azimuth: number
angularDiameterArcmin: number; apparentMagnitude: number
bodyType: 0 | 1 | 2 | 3 | 4 // 0=star 1=planet 2=moon 3=asteroid 4=comet
}
function packBodies(bodies: CelestialBody[]): Uint8Array
function unpackBodies(buf: Uint8Array, count: number): PackedCelestialBody[]
```
---
## 7. `@suaveplan/solar-system`
### Purpose
Implements `SolarProvider` using [astronomy.js](https://github.com/cosinekitty/astronomy) for real solar system bodies, and a Keplerian propagator for game-defined planets carrying `OrbitalConfig`.
### Dependencies
`@suaveplan/universe`, `@suaveplan/error`, `@suaveplan/types`, `astronomy-engine`, `zod`, `@opentelemetry/api`.
### Two-engine model
**Real bodies** (Sun, Moon, Mercury–Neptune, Pluto, Ceres): astronomy.js computes positions, rise/set/transit, phase, illumination, angular diameter — correct to arcsecond precision.
**Configured bodies** (game planets with `OrbitalConfig`): Keplerian solver propagates position from epoch. Physically plausible, not observatory-grade.
Per `CelestialBody` computed from `ObserverPosition`:
| Field | Source |
|---|---|
| `altitude` / `azimuth` | Observer lat/lon + time → horizontal coordinates |
| `angularDiameterArcmin` | `2 × arctan(radius / distance) × (180/π × 60)` |
| `apparentMagnitude` | Phase angle + distance (astronomy.js for real bodies) |
| `heliocentricPosition` | Vec3 in heliocentric frame |
For `SurfacePosition` / `AtmosphericPosition` observers, `PlanetConfig.atmosphere` applies extinction to magnitudes near the horizon.
### Additional exports (standalone, outside `SolarProvider`)
```ts
// planet defaults to EARTH — pass any PlanetConfig with axialTilt and OrbitalConfig for other planets
getSeason(date: Date, planet?: PlanetConfig): Season
getNextEquinox(after: Date, planet?: PlanetConfig): Date
getNextSolstice(after: Date, planet?: PlanetConfig): Date
getLunarPhase(time: Date): LunarPhase // phase 0–1, illumination %, name (Earth/Moon only)
predictEclipse(after: Date, type: 'solar' | 'lunar'): EclipseEvent // Earth only
getConstellation(ra: number, dec: number): string
```
All functions return `Date` in UTC. Consumers needing timezone-localised sunrise/sunset/season dates should convert via `@suaveplan/datetime`, which handles tz-aware date math and is the monorepo-standard for timezone operations.
### Injectable engine
```ts
interface AstronomyEngine {
bodyPosition(body: string, observer: AstroObserver, time: AstroTime): EquatorialCoords
riseSetTransit(body: string, observer: AstroObserver, date: AstroTime): RiseSetTransit
}
function createSolarSystemProvider(options?: { engine?: AstronomyEngine }): SolarProvider
```
Tests inject a deterministic engine. Production uses astronomy.js.
---
## 8. `@suaveplan/stars`
### Purpose
Implements `StarProvider` with a two-level plugin system: swappable catalog sources within `stars`, and `stars` itself registered as a plugin to `universe`.
### Dependencies
`@suaveplan/universe`, `@suaveplan/bitpacker`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### Core types
```ts
// Native catalog record — the shape every StarCatalogSource works with internally
interface RawStar {
catalogId: string
ra: number // degrees J2000
dec: number // degrees J2000
apparentMagnitude: number
spectralType: string
distance?: number // parsecs
properMotionRa?: number // mas/yr — present in Hipparcos and above
properMotionDec?: number // mas/yr
parallax?: number // mas — present in Hipparcos and above
}
```
### Catalog source interface
```ts
interface StarCatalogSource {
readonly id: string
readonly magnitudeLimit: number
readonly starCount: number
queryCone(ra: number, dec: number, radiusDeg: number, options?: StarQueryOptions, signal?: AbortSignal): Promise<RawStar[]>
getById(catalogId: string, signal?: AbortSignal): Promise<RawStar | null>
}
```
### Bundled catalog — BSC5
Yale Bright Star Catalog (~9,096 stars, magnitude ≤ 6.5). Ships as compact binary (bitpacked at build time). ~100 KB vs ~2 MB JSON.
```ts
import { createBsc5Source } from '@suaveplan/stars/catalogs/bsc5'
const source = createBsc5Source() // no config, no network, offline
```
### External catalog interfaces (contracts locked in, implementations deferred)
```ts
createHipparcosSource(options: HipparcosSourceOptions): StarCatalogSource // 118k stars
createTycho2Source(options: Tycho2SourceOptions): StarCatalogSource // 2.5M stars
createGaiaSource(options: GaiaSourceOptions): StarCatalogSource // 1.5B sources
```
Each takes injectable `fetchFn` (wrap with `@suaveplan/retry`'s circuit breaker + jittered backoff — Hipparcos/Tycho/Gaia are flaky external APIs) and optional `cacheFn` (wire `@suaveplan/cache`'s `get`/`set` — catalog queries are read-mostly and TTL-friendly).
### SurrealDB provider interface (locked in, implementation deferred)
`SurrealAdapter` is defined structurally within `stars` — `@suaveplan/surrealdb` satisfies it at runtime but is not a hard dependency, keeping `stars` free of the database package when the SurrealDB source is not used.
```ts
// Structural interface — satisfied by SurrealAdapter from @suaveplan/surrealdb
interface SurrealAdapterLike {
query<T>(sql: string, vars?: Record<string, unknown>): Promise<T[]>
}
interface SurrealStarProviderOptions {
adapter: SurrealAdapterLike
namespace: string
database: string
coneFn: string // SurrealDB JS function name, default: 'stars::cone_search'
extinctionFn: string // default: 'stars::apply_extinction'
atmosphereDensity?: number // g/cm², derived from observer's PlanetConfig.atmosphere
}
function createSurrealStarSource(options: SurrealStarProviderOptions): StarCatalogSource
```
Results arrive bitpacked (`StarPacket`), unpacked client-side using `unpackStars()`.
### Atmospheric extinction
```ts
// Pogson's formula — applied when ObserverPosition has AtmosphereConfig
extinctedMagnitude = rawMagnitude + extinctionCoeff × airMass(altitudeDeg)
// Kasten–Young formula — accurate to ~5° altitude
function airMass(altitudeDeg: number): number
```
`SpacePosition` and vacuum-planet observers receive raw magnitudes with no extinction.
### StarProvider implementation
```ts
class StarProviderImpl implements StarProvider {
constructor(sources: StarCatalogSource[]) // priority-ordered
getVisibleStars(observer, time, options, signal?): Promise<ObservedStar[]>
// 1. Compute horizon frame from ObserverPosition
// 2. queryCone full visible hemisphere
// 3. Filter by minAltitudeDeg, magnitudeLimit
// 4. Apply atmospheric extinction from PlanetConfig
// 5. Sort by brightness
queryCone(ra, dec, radiusDeg, options?, signal?): Promise<CatalogStar[]>
getStar(catalogId, signal?): Promise<CatalogStar>
}
```
---
## 9. `@suaveplan/tides` — Enterprise Rewrite
### Purpose
Enterprise-grade tidal prediction for Earth (via real harmonic data) and any planet (via physics-based model). Implements `TidalProvider` for `universe` integration.
### Dependencies
`@suaveplan/universe`, `@suaveplan/geo-planet`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### What's preserved
TICON and ADMIRALTY providers are kept intact — same harmonic constituent engine, same station data, same multi-provider fallback chain. Earth API is unchanged.
### Enterprise additions (cross-cutting)
- Zod schemas for all public option types and wire formats
- `AbortSignal` on every async method
- `TidesError` extending `@suaveplan/error` with stable `TIDES.*` codes
- OTel spans, counters, histograms on every provider call
- Property-based tests (fast-check) on harmonic prediction engine
- Injectable `fetchFn` (ADMIRALTY is a flaky external API — wrap with `@suaveplan/retry` circuit breaker + jitter), `clockFn`, optional `cacheFn` (wire `@suaveplan/cache` — tide tables are read-mostly and TTL-friendly)
### Multi-satellite physics model
```ts
// Tidal height from all satellites in PlanetConfig, superposed
function tidalHeight(planet: PlanetConfig, coord: SphericalCoord, time: Date): number
// Individual satellite contribution — useful for visualisation
function satelliteTidalContribution(
planet: PlanetConfig,
satellite: SatelliteConfig,
coord: SphericalCoord,
time: Date
): number
```
Physics: `tidalRange ∝ (satelliteMass / orbitalRadius³) × (planetRadius⁴ / planetMass)`. Multiple satellites superpose — orbital resonances produce complex spring/neap interference patterns automatically.
```ts
createPhysicsBasedProvider(): TidalProvider // any PlanetConfig with satellites
createTiconProvider(): TidalProvider // Earth, offline harmonic data
createAdmiraltyProvider(options): TidalProvider // Earth, UKHO API 600+ stations
```
### TidalProvider routing
```ts
class TidalProviderImpl implements TidalProvider {
constructor(options: {
earthProviders: TidalProvider[] // tried in order for Earth
physicsProvider: TidalProvider // fallback for non-Earth planets
})
predictHeight(planet, coord, time, signal?): Promise<number>
getExtremes(planet, coord, date, signal?): Promise<TideExtreme[]>
}
```
Earth identification uses `planet.name.toLowerCase() === 'earth'` — a named string match, not a floating-point mass/radius comparison, which would be fragile. The `EARTH` constant exported from `@suaveplan/geo-earth` has `name: 'Earth'`. Game-defined planets must use a different name. Non-Earth → physics model. Planets with no satellites → zero height, empty extremes.
---
## 10. `@suaveplan/gaming-universe`
### Purpose
Wraps `UniverseClient` with a configurable N× game clock. All queries use game time. The `startTime` being a real calendar date gives the real historical or projected sky.
### Dependencies
`@suaveplan/universe`, `@suaveplan/timing`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### Clock design
`GamingUniverse` does **not** invent its own scheduler. It wraps `@suaveplan/timing`'s `AsyncScheduler` (which already provides AbortSignal drain, interval tick, and injectable clock) to drive game time. `setTimeScale`/`pause`/`resume` control the multiplier applied to `timing`'s real-time ticks before accumulating into game time. This makes cancellation and clock injection uniform with the rest of the monorepo, and makes `@suaveplan/game-loop` (when it exists) the natural per-tick integration point for calling `getScene()`.
### API
```ts
interface GamingUniverseOptions {
startTime: Date // real calendar date — determines actual star/planet positions
timeScale: number // initial multiplier: 1 = real, 3600 = 1s = 1hr game time
universe: UniverseClient
clock?: Clock // injectable — from @suaveplan/types; defaults to Date.now
}
class GamingUniverse {
constructor(options: GamingUniverseOptions)
setTimeScale(n: number): void
setGameTime(date: Date): void
pause(): void
resume(): void
isPaused(): boolean
getGameTime(): Date
// Full scene at current game time — signal cancels in-flight provider calls
getScene(observer: ObserverPosition, options?: SceneOptions, signal?: AbortSignal): Promise<UniverseScene>
readonly universe: UniverseClient
// Stops the clock and cancels all in-flight queries; idempotent
dispose(): void
}
```
### Events
```ts
// All emitted via @suaveplan/events
hourChanged { gameTime: Date }
dayChanged { gameTime: Date; dayNumber: number }
seasonChanged { season: 'spring' | 'summer' | 'autumn' | 'winter' }
sunriseStarted { planet: PlanetConfig; coord: SphericalCoord }
sunsetStarted { planet: PlanetConfig; coord: SphericalCoord }
moonriseStarted { satellite: SatelliteConfig; altitude: number } // altitude at horizon crossing
moonsetStarted { satellite: SatelliteConfig; altitude: number } // altitude at horizon crossing (typically ≈0°)
eclipseStarted { type: 'solar' | 'lunar'; totality: number }
```
---
## 11. `@suaveplan/gaming-environment-lighting`
### Purpose
Replaces `@suaveplan/day-night`. Light schedule, sky colour, star visibility thresholds, and gameplay modifiers are all physically derived from astronomical state — no hardcoded presets.
### Dependencies
`@suaveplan/gaming-universe`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### Sky colour — Rayleigh/Mie scattering
```
sunAltitude → airMass → scatteringPath → RGB sky colour
```
Driven by `AtmosphereConfig.rayleighCoefficient` and `mieCoefficient`:
- Earth noon: deep blue
- Earth sunset: orange/red (long scatter path)
- Mars noon: butterscotch (CO₂ + dust Mie scatter)
- Vacuum world: black always, stars always visible
### Twilight star visibility thresholds
```
sun altitude > 0° no stars visible
sun altitude ≤ 0° civil twilight magnitude ≤ 1
sun altitude ≤ −6° nautical twilight magnitude ≤ 3
sun altitude ≤ −12° astronomical twilight magnitude ≤ 6
sun altitude ≤ −18° full dark all naked-eye stars
```
On vacuum planets (no `AtmosphereConfig`): threshold collapses, stars always visible.
### Satellite lighting
```ts
// Per satellite contribution, summed across all PlanetConfig.satellites
moonLight ∝ phaseIllumination × angularDiameter² × albedo
```
Multiple moons sum. Large close moons can brightly illuminate the surface at "night".
### Gameplay modifiers — continuous functions of ambient light
```ts
interface EnvironmentModifiers {
totalAmbientLight: number // 0–1, sun + all moons
stealthBonus: number // 1 − totalAmbientLight
visibilityRangeMetres: number // base × totalAmbientLight × atmosphereClarity
npcSleepProbability: number // sigmoid of localHour relative to sunrise/sunset
}
```
### Environment component shapes
These are **plain TypeScript interfaces** — framework-agnostic data shapes. They are not registered as ECS archetypes and carry no dependency on `@suaveplan/ecs`. Consumers wiring them into an ECS (e.g. bitecs, miniplex) do so by reading these shapes and mapping fields to their own component storage.
```ts
interface DayNightClock { gameTime: Date; timeScale: number; paused: boolean }
interface LightState { skyColour: RGB; sunAltitude: number; totalAmbientLight: number;
moonContributions: MoonLight[]; weather?: WeatherState }
interface DayNightAffected { modifiers: EnvironmentModifiers }
```
### Render payload
`gaming-environment-lighting` publishes a `RenderPayload` each tick — the canonical shape for downstream render integrations (`@suaveplan/r3f-environment`, Three.js custom shaders, etc.). Locking this now prevents churn when R3F integration lands.
```ts
interface RenderPayload {
// Sky
skyColour: RGB
sunAltitude: number // degrees
sunAzimuth: number // degrees
horizonColour: RGB // for gradient blending at low sun angles
// Lighting
totalAmbientLight: number // 0–1
moonContributions: MoonLight[]
// Atmosphere
fogDensity: number // from AtmosphereConfig + WeatherState
rayleighTint: RGB // for shader uniforms
// Textures (from PlanetTextureManifest — identifiers only, no assets)
textures: PlanetTextureManifest
// Weather (undefined if no WeatherProvider registered)
weather?: WeatherState
}
```
`gaming-environment-lighting` never loads or stores texture assets — `PlanetTextureManifest` identifiers are passed through verbatim.
---
## 12. Implementation Order
Packages must land in dependency order:
```
1. geo
2. geo-planet
3. geo-earth (depends on geo-planet)
4. universe (depends on geo, geo-planet)
5. solar-system (depends on universe)
6. stars (depends on universe)
7. tides (depends on universe, geo-planet)
8. gaming-universe (depends on universe)
9. gaming-environment-lighting (depends on gaming-universe)
```
Each package ships at `0.1.0` per genesis convention, with full 100% test coverage gate before publish.
---
## 13. Deferred (Out of Scope for This Spec)
- SurrealDB star storage schema and SurrealQL JS function bodies (`stars::cone_search`, `stars::apply_extinction`)
- Hipparcos, Tycho-2, Gaia catalog source implementations
- R3F / Three.js rendering integration for sky, terrain, planet textures
- Multiplayer synchronisation of game time across clients
- `@suaveplan/weather` integration with atmospheric lighting
The SurrealDB provider interface (`SurrealStarProviderOptions`, `createSurrealStarSource`) is locked in this spec — any future implementation must satisfy it.
---
## 14. Cross-Cutting Conventions
All packages in this family follow the monorepo standard:
- **Logging:** `@suaveplan/logger` + `@opentelemetry/api` together — OTel spans carry trace IDs; logger emits structured entries correlated to those spans. Neither is used without the other at public entrypoints.
- **Retries:** `@suaveplan/retry` (circuit breaker + jittered backoff) wraps all `fetchFn` parameters that hit external APIs (ADMIRALTY, Hipparcos, Tycho, Gaia).
- **Caching:** `@suaveplan/cache` is the canonical `cacheFn` adapter for read-mostly data (star catalog queries, tide tables). Consumers wire it in; packages don't import it directly.
- **Dates:** All public APIs return `Date` (UTC). Consumers needing timezone-aware values use `@suaveplan/datetime`.
- **ECS:** Component shapes in `gaming-environment-lighting` are plain TypeScript interfaces — no `@suaveplan/ecs` dependency. ECS consumers map fields to their own component storage.
## 15. Acceptance Criteria
Each package is complete when:
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/<pkg>` green
- [ ] `bunx turbo test:coverage --filter=@suaveplan/<pkg>` at 100%
- [ ] Every exported symbol has a co-located `.md`
- [ ] No `TODO`, `FIXME`, `any`, `ts-ignore`, or `.skip` in `src/`
- [ ] Zod schemas for all public option types
- [ ] `@suaveplan/logger` + OTel spans + counters on all public entrypoints
- [ ] `AbortSignal` on all async methods
- [ ] OpenSpec change proposal created and archived on ship

File diff suppressed because it is too large Load Diff