Files
2026-05-27 23:29:53 +01:00

392 lines
19 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: electrobun
**Package**: `@suaveplan/electrobun`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: ELECTROBUN_CODES registry MUST enumerate every typed failure path
The package MUST export `ELECTROBUN_CODES = defineCodes("ELECTROBUN", [...])` exposing
exactly the following codes: `INVALID_OPTIONS`, `OUTPUT_NOT_EMPTY`,
`CONFIG_PARSE_FAILED`, `ICON_SOURCE_MISSING`, `ICON_RENDER_FAILED`,
`TEMPLATE_RENDER_FAILED`, `FS_WRITE_FAILED`, `BINARY_UNAVAILABLE`, `RUNNER_FAILED`,
`PROMPT_ABORTED`, `UNKNOWN_FLAG`. Every code MUST be reachable from a call site
exercised by a test in `src/`. The exported `ElectrobunError` MUST extend
`SuaveplanError<ElectrobunCode>` and carry a `details: Record<string, unknown>`
field for structured diagnostics.
#### Scenario: registry exposes the full code set
- **WHEN** the consumer imports `ELECTROBUN_CODES` and reads `Object.values(ELECTROBUN_CODES)`
- **THEN** the result contains exactly the eleven declared codes (no more, no less)
#### Scenario: ElectrobunError carries code and details
- **WHEN** `new ElectrobunError(ELECTROBUN_CODES.INVALID_OPTIONS, "bad", { details: { field: "x" } })`
- **THEN** the instance has `code === "ELECTROBUN.INVALID_OPTIONS"`,
`details.field === "x"`, and `instanceof SuaveplanError === true`
**Verified by:** `src/error-codes/error-codes.test.ts::"registry exposes the full code set"` AND `src/error-codes/error-codes.test.ts::"ElectrobunError carries code and details"`
---
### Requirement: Public options MUST be Zod-validated at the boundary
Every public entry point (`generate`, `collectFiles`, `generateIcons`, `loadConfig`,
`runPrompts`, `createCommand`, `devCommand`, `buildCommand`, `packageCommand`,
`signCommand`, `runCli`) MUST validate its option object against an exported Zod
schema. A schema parse failure MUST surface as `ElectrobunError` with code
`ELECTROBUN.INVALID_OPTIONS` and a `details.issues` array carrying the Zod issue
list.
#### Scenario: ProjectOptionsSchema rejects empty name
- **WHEN** `ProjectOptionsSchema.safeParse({ ...validOptions, name: "" })` runs
- **THEN** the parse fails with at least one issue on path `["name"]`
#### Scenario: collectFiles raises INVALID_OPTIONS on bad input
- **WHEN** `collectFiles({ name: "", identifier: "x", description: "y", version: "0.0.1", outputDir: "/tmp/a", features: [], networkingComplexity: "basic", r3fModules: [] })` runs
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.INVALID_OPTIONS"` is thrown
**Verified by:** `src/schemas/schemas.test.ts::"ProjectOptionsSchema rejects empty name"` AND `src/generator/generator.test.ts::"collectFiles raises INVALID_OPTIONS on empty name"`
---
### Requirement: collectFiles MUST return the deterministic core file list
`collectFiles(options)` MUST return a `GeneratedFile[]` containing — in order — the
nine core paths `electrobun.config.ts`, `package.json`, `tsconfig.json`,
`src/bun/index.ts`, `src/mainview/index.html`, `src/mainview/index.css`,
`src/mainview/index.tsx`, `src/shared/types.ts`, `README.md`, followed by one file
per selected feature in the canonical order `i18n`, `networking`, `api`,
`api-schemas`, `storage`, `auth`, `form-builder`, `r3f`. Feature ordering MUST
not depend on `options.features` insertion order.
#### Scenario: minimal options emit the nine core files
- **WHEN** `collectFiles({ ...validOptions, features: [] })` runs
- **THEN** the returned `paths` equals the nine-element core list
#### Scenario: all eight features emit all feature paths
- **WHEN** `collectFiles({ ...validOptions, features: ["i18n","networking","api","api-schemas","storage","auth","form-builder","r3f"], r3fModules: ["camera"] })`
- **THEN** the returned paths contain `src/features/i18n.ts`,
`src/features/network.ts`, `src/features/api.ts`,
`src/features/schemas.ts`, `src/features/storage.ts`,
`src/features/auth.ts`, `src/features/form-builder.ts`,
`src/features/r3f.tsx`
#### Scenario: feature order is canonical regardless of input order
- **WHEN** `collectFiles({ ...validOptions, features: ["r3f","i18n","api"] })`
- **THEN** the feature paths in the returned list appear in the order
`src/features/i18n.ts`, `src/features/api.ts`, `src/features/r3f.tsx`
**Verified by:** `src/generator/generator.test.ts::"minimal options emit the nine core files"` AND `src/generator/generator.test.ts::"all eight features emit all feature paths"` AND `src/generator/generator.test.ts::"feature order is canonical regardless of input order"`
---
### Requirement: generate MUST write files via the injectable FsAdapter
`generate(options, deps?)` MUST resolve `deps.fs ?? nodeFsAdapter`, write every
file from `collectFiles(options)` to `options.outputDir` via that adapter, write
the `.suaveplan-electrobun.json` config snapshot, optionally run the icon pipeline
when `options.iconSource` is set, and return a `GenerateReport` carrying
`{ outputDir, filesCreated, durationMs, icons }` where `icons` is the icon
report (or `undefined` when no icon source was supplied). The histogram
`electrobun_generate_duration_ms` MUST receive one observation per call.
#### Scenario: generate writes every file from collectFiles
- **WHEN** `generate({ ...validOptions, outputDir: <tmpDir> }, { fs })` runs
- **THEN** `fs` has received one `writeFile` call per file in `collectFiles(options)`,
plus one for `.suaveplan-electrobun.json`
#### Scenario: generate skips icons when iconSource is absent
- **WHEN** `generate({ ...validOptions, iconSource: undefined }, deps)` runs
- **THEN** the returned `report.icons` is `undefined`
**Verified by:** `src/generator/generator.test.ts::"generate writes every file from collectFiles"` AND `src/generator/generator.test.ts::"generate skips icons when iconSource is absent"`
---
### Requirement: generateIcons MUST run the icon pipeline through the IconRendererAdapter
`generateIcons({ inputPath, outputDir }, deps?)` MUST resolve
`deps.renderer ?? iconzRendererAdapter`, invoke `renderer.render({ input,
output, icons })` with the canonical icon descriptor (icns / ico / png set), and
return an `IconReport` of shape `{ generated: boolean; files: readonly string[];
error?: string }`. A missing input path MUST surface as
`ELECTROBUN.ICON_SOURCE_MISSING`. A renderer throw MUST surface as
`ELECTROBUN.ICON_RENDER_FAILED` with the message preserved in `details.message`.
#### Scenario: missing source raises ICON_SOURCE_MISSING
- **WHEN** `generateIcons({ inputPath: "/does/not/exist", outputDir: "/tmp/a" })`
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.ICON_SOURCE_MISSING"`
is thrown
#### Scenario: renderer throw is wrapped as ICON_RENDER_FAILED
- **WHEN** `generateIcons({ inputPath, outputDir }, { renderer: throwingRenderer })`
- **THEN** the returned report has `generated === false` AND `error` contains
the underlying message
**Verified by:** `src/icons/icons.test.ts::"missing source raises ICON_SOURCE_MISSING"` AND `src/icons/icons.test.ts::"renderer throw is wrapped as ICON_RENDER_FAILED"`
---
### Requirement: loadConfig MUST stack defaults, file, and CLI layers
`loadConfig(cliArgs, deps?)` MUST resolve `deps.fs ?? nodeFsAdapter`, locate
`.suaveplan-electrobun.json` in `cwd` or `home` via the adapter, and build an
`ElectrobunConfig` through `@suaveplan/config`'s `createConfig`/`defaultsLayer` +
`fileLayer` + `cliLayer` pipeline. CLI args MUST map `--name → name`,
`--id → identifier`, `--icon → iconSource`, `--version → version`. Defaults MUST
fill `version="0.0.1"`, `features=[]`, `networkingComplexity="basic"`,
`r3fModules=[]`. A malformed config file MUST surface as
`ELECTROBUN.CONFIG_PARSE_FAILED`.
#### Scenario: CLI args override file values
- **WHEN** `loadConfig(["--name", "cli-app"], { fs: fsWithFile({ name: "file-app" }) })`
- **THEN** the returned config has `name === "cli-app"`
#### Scenario: malformed file raises CONFIG_PARSE_FAILED
- **WHEN** `loadConfig([], { fs: fsWithCorruptFile() })`
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.CONFIG_PARSE_FAILED"`
is thrown
**Verified by:** `src/config/config.test.ts::"CLI args override file values"` AND `src/config/config.test.ts::"malformed file raises CONFIG_PARSE_FAILED"`
---
### Requirement: Templates MUST render every project and feature file deterministically
The `renderProjectFile(kind, options)` and `renderFeature(feature, options)`
functions MUST cover the full upstream set: `electrobun-config`, `main-process`,
`package-json`, `tsconfig`, `readme`, `mainview-html`, `mainview-css`,
`mainview-entry`, `shared-types` (project) and `i18n`, `network`, `network-full`,
`api`, `api-schemas`, `storage`, `auth`, `form-builder`, `r3f` (feature). For a
fixed `ProjectOptions` input, each renderer MUST produce a byte-identical output
across runs.
#### Scenario: renderProjectFile output is byte-stable
- **WHEN** `renderProjectFile("package-json", validOptions)` runs twice
- **THEN** both invocations return identical strings
#### Scenario: networking feature complexity selects the correct renderer
- **WHEN** `renderFeature("networking", { ...opts, networkingComplexity: "full" })`
- **THEN** the output contains `state:` AND `governance:`
#### Scenario: r3f renderer emits the requested modules
- **WHEN** `renderFeature("r3f", { ...opts, r3fModules: ["physics","particles"] })`
- **THEN** the output contains `@react-three/rapier` AND `Sparkles`
**Verified by:** `src/templates/templates.test.ts::"renderProjectFile output is byte-stable"` AND `src/templates/features.test.ts::"networking feature complexity selects the correct renderer"` AND `src/templates/features.test.ts::"r3f renderer emits the requested modules"`
---
### Requirement: FsAdapter MUST expose every operation the generator needs
The exported `FsAdapter` contract MUST expose at minimum
`readFile(path): Promise<string>`, `writeFile(path, content): Promise<void>`,
`mkdir(path, opts): Promise<void>`, `stat(path): Promise<{ isDirectory(): boolean }>`,
`readdir(path): Promise<readonly string[]>`. The `nodeFsAdapter` default MUST be
backed by `node:fs/promises` and MUST round-trip a real file under a real
`mkdtemp` directory. A `writeFile` failure MUST surface as
`ELECTROBUN.FS_WRITE_FAILED`.
#### Scenario: nodeFsAdapter round-trips a file
- **WHEN** `await fs.writeFile(p, "x")` then `await fs.readFile(p)`
- **THEN** the read returns `"x"`
#### Scenario: writeFiles surfaces FS_WRITE_FAILED on adapter throw
- **WHEN** `writeFiles(outputDir, files, { fs: throwingFs })` runs
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.FS_WRITE_FAILED"`
is thrown
**Verified by:** `src/fs/fs.test.ts::"nodeFsAdapter round-trips a file"` AND `src/fs/fs.test.ts::"writeFiles surfaces FS_WRITE_FAILED on adapter throw"`
---
### Requirement: CommandRunner MUST shape every electrobun shell-out
The exported `CommandRunner` contract MUST be
`(input: CommandInput) => Promise<CommandResult>` where `CommandInput` carries
`{ command, args, cwd?, env?, signal? }` and `CommandResult` carries
`{ exitCode, stdout, stderr, durationMs }`. `probeElectrobun(runner)` MUST
invoke the runner with `command="bunx"` and `args=["electrobun","--version"]`
and return `{ available: boolean; version?: string }`. A non-zero exit MUST
classify as `available === false`. The `nodeCommandRunner` default MUST spawn
via `node:child_process.spawn` and resolve stdout / stderr as UTF-8 strings.
#### Scenario: probeElectrobun returns available with version on success
- **WHEN** `probeElectrobun(runnerReturning({ exitCode: 0, stdout: "1.2.3\n" }))`
- **THEN** the result is `{ available: true, version: "1.2.3" }`
#### Scenario: probeElectrobun returns unavailable on non-zero exit
- **WHEN** `probeElectrobun(runnerReturning({ exitCode: 127, stdout: "" }))`
- **THEN** the result is `{ available: false }`
**Verified by:** `src/runner/runner.test.ts::"probeElectrobun returns available with version on success"` AND `src/runner/runner.test.ts::"probeElectrobun returns unavailable on non-zero exit"`
---
### Requirement: dev / build / package / sign commands MUST orchestrate the binary
Each of `devCommand`, `buildCommand`, `packageCommand`, `signCommand` MUST:
1. validate its option schema (raising `ELECTROBUN.INVALID_OPTIONS` on failure);
2. probe the electrobun binary (raising `ELECTROBUN.BINARY_UNAVAILABLE` when
absent);
3. spawn the binary with the canonical argv shape (`dev` → `electrobun dev`;
`build` → `electrobun build --target <target> [--update]`; `package` →
`electrobun package --target <target>`; `sign` → `electrobun sign --identity
<identity> [--entitlements <path>] [--notarize]`);
4. classify a non-zero exit as `ELECTROBUN.RUNNER_FAILED` with `details.command`,
`details.exitCode`, and `details.stderr`;
5. record one observation in `electrobun_runner_duration_ms` and one increment
in `electrobun_runner_invocations_total` per call;
6. return a `*Report` carrying `{ command, exitCode, durationMs, stdout, stderr }`.
#### Scenario: buildCommand passes --update when options.update is true
- **WHEN** `buildCommand({ cwd, target: "mac", update: true }, { runner: recordingRunner })`
- **THEN** the recorded args include `"--update"`
#### Scenario: signCommand forwards identity, entitlements, and notarize
- **WHEN** `signCommand({ cwd, identity: "Dev ID", entitlements: "/e.plist", notarize: true }, deps)`
- **THEN** the recorded args contain `"--identity"`, `"Dev ID"`,
`"--entitlements"`, `"/e.plist"`, `"--notarize"`
#### Scenario: non-zero exit raises RUNNER_FAILED
- **WHEN** `devCommand({ cwd }, { runner: failingRunner })` runs
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.RUNNER_FAILED"` is
thrown AND `details.exitCode` is preserved
#### Scenario: missing binary raises BINARY_UNAVAILABLE
- **WHEN** `buildCommand({ cwd, target: "mac" }, { runner: missingBinaryRunner })`
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.BINARY_UNAVAILABLE"`
is thrown
**Verified by:** `src/cli/cli.test.ts::"buildCommand passes --update when options.update is true"` AND `src/cli/cli.test.ts::"signCommand forwards identity, entitlements, and notarize"` AND `src/cli/cli.test.ts::"non-zero exit raises RUNNER_FAILED"` AND `src/cli/cli.test.ts::"missing binary raises BINARY_UNAVAILABLE"`
---
### Requirement: runPrompts MUST drive the interactive scaffolder via the injectable Prompter
`runPrompts(config, outputDir, prompter?)` MUST prompt for `name`, `identifier`,
`description`, `version`, `iconSource`, `features`, `networkingComplexity` (only
when `features` includes `"networking"`), `r3fModules` (only when `features`
includes `"r3f"`), and a `proceed` confirmation. A negative confirmation MUST
surface as `ELECTROBUN.PROMPT_ABORTED`. The function MUST default
`networkingComplexity` to `"basic"` and `r3fModules` to `[]` when the gating
feature is not selected.
#### Scenario: runPrompts assembles a complete ProjectOptions
- **WHEN** `runPrompts(emptyConfig, undefined, scriptedPrompter)` runs
- **THEN** the returned object satisfies `ProjectOptionsSchema.safeParse(result).success === true`
#### Scenario: negative confirmation aborts the prompt
- **WHEN** `runPrompts(emptyConfig, undefined, scriptedPrompter({ proceed: false }))`
- **THEN** an `ElectrobunError` with `code === "ELECTROBUN.PROMPT_ABORTED"`
is thrown
**Verified by:** `src/prompts/prompts.test.ts::"runPrompts assembles a complete ProjectOptions"` AND `src/prompts/prompts.test.ts::"negative confirmation aborts the prompt"`
---
### Requirement: runCli MUST dispatch every subcommand and never call process.exit
`runCli(argv, opts?)` MUST recognise the subcommands `create`, `dev`, `build`,
`package`, `sign`, and `--help` / `-h`. Unknown subcommands or unknown flags
MUST raise `ELECTROBUN.UNKNOWN_FLAG`. The function MUST return
`{ code: 0 }` on success and `{ code: 1 }` on a handled
`ElectrobunError`. No code path inside `runCli` MUST call
`process.exit`; only the binary entry MUST own the exit.
#### Scenario: --help returns code 0
- **WHEN** `runCli(["--help"], { out: capture })` runs
- **THEN** the result is `{ code: 0 }` AND `capture` recorded a help string
#### Scenario: unknown subcommand raises UNKNOWN_FLAG
- **WHEN** `runCli(["zzz"])` runs
- **THEN** the result is `{ code: 1 }` AND the captured stderr contains
`"ELECTROBUN.UNKNOWN_FLAG"`
**Verified by:** `src/cli/cli.test.ts::"--help returns code 0"` AND `src/cli/cli.test.ts::"unknown subcommand raises UNKNOWN_FLAG"`
---
### Requirement: Telemetry MUST emit the documented OTel surface
The package MUST expose `electrobun_projects_generated_total`,
`electrobun_icons_generated_total`, `electrobun_runner_invocations_total`,
`electrobun_runner_failures_total`, `electrobun_prompts_total` counters and
`electrobun_generate_duration_ms`, `electrobun_runner_duration_ms` histograms,
each on the `@suaveplan/electrobun` meter, and MUST provide `recordProject`,
`recordIcons`, `recordRunnerInvocation`, `recordRunnerFailure`,
`recordPromptOutcome`, `recordGenerateDuration`, `recordRunnerDuration` helpers
that increment / observe with the documented label keys.
#### Scenario: recordRunnerInvocation increments the counter once per call
- **WHEN** `recordRunnerInvocation("build")`
- **THEN** the in-memory `MeterProvider` reports exactly one data point on
`electrobun_runner_invocations_total` with `command="build"`
#### Scenario: recordGenerateDuration writes to the histogram
- **WHEN** `recordGenerateDuration(123, 2)`
- **THEN** the in-memory `MeterProvider` reports one observation of `123`
on `electrobun_generate_duration_ms` with `features_count="2"`
**Verified by:** `src/telemetry/telemetry.test.ts::"recordRunnerInvocation increments the counter once per call"` AND `src/telemetry/telemetry.test.ts::"recordGenerateDuration writes to the histogram"`
---
### Requirement: Package MUST be server-tier with per-feature subpath exports
The `package.json` MUST declare `"type": "module"`, `"sideEffects": false`,
`bin: { "suaveplan-electrobun": "./dist/bin/suaveplan-electrobun.js" }`, and
subpath exports for `.`, `./error-codes`, `./telemetry`, `./types`, `./schemas`,
`./fs`, `./runner`, `./config`, `./templates`, `./icons`, `./generator`,
`./prompts`, `./cli`. Internal deps MUST use `workspace:^`; external deps MUST
use `catalog:*` references (`zod` → `forms`, `@opentelemetry/api` → `observability`,
`tslib` → `build`, `iconz` → `cli`). No bare version pins.
#### Scenario: package.json declares the documented subpath exports
- **WHEN** the consumer reads `Object.keys(pkg.exports).filter(k => k.startsWith("./") && k !== "./package.json")`
- **THEN** the result contains all thirteen documented subpaths
#### Scenario: bin entry resolves to the published binary
- **WHEN** the consumer reads `pkg.bin["suaveplan-electrobun"]`
- **THEN** the value is `"./dist/bin/suaveplan-electrobun.js"`
**Verified by:** `tests/package-shape.test.ts::"package.json declares the documented subpath exports"` AND `tests/package-shape.test.ts::"bin entry resolves to the published binary"`