Initial Commit
This commit is contained in:
691
openspec/specs/types/spec.md
Normal file
691
openspec/specs/types/spec.md
Normal file
@@ -0,0 +1,691 @@
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/types` is the types-only foundation of the genesis monorepo. It owns the canonical shape of every cross-package interface (`Clock`, `IdGenerator`, `Rng`, `EventBusLike`, `LoggerLike`, `StorageLike`, `DatabaseLike`, plus the cross-package crypto contract `Authority` / `DomainTag` / `KeySlotPublic` / `AuthorityVerifier` / `AeadOps`) so any package can import and accept them without dragging in concrete implementations. The package emits zero runtime bytes — every `.js` file is a stub — making it safe to depend on from any layer, including the testing-infrastructure packages and any package that would otherwise risk a circular dependency.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Zero runtime code
|
||||
|
||||
The `@suaveplan/types` package MUST emit declaration files only. Every built `.js` file MUST be a no-op module stub — the full package's runtime surface is empty. Adding new type aliases, interfaces, or optional members does NOT change runtime emission and MUST keep every `dist/*.js` within the stub-size budget.
|
||||
|
||||
**Verified by:** `tests/zero-runtime.test.ts` (per-file ≤ 200-byte budget walk).
|
||||
|
||||
#### Scenario: No .js file exceeds the stub-size budget
|
||||
|
||||
- **WHEN** the package is built
|
||||
- **THEN** every `.js` file under `dist/` is ≤ 200 bytes AND contains no function declarations, class declarations, or non-export runtime statements
|
||||
|
||||
#### Scenario: No dependencies
|
||||
|
||||
- **WHEN** `package.json` is read
|
||||
- **THEN** `dependencies`, `peerDependencies`, and `optionalDependencies` are all empty or absent
|
||||
|
||||
### Requirement: Canonical shared types
|
||||
|
||||
The package SHALL export `Clock`, `MonotonicClock`, `IdGenerator`, `Rng`, `MessageResolver`, `EventBusLike`, `LoggerLike`, `StorageLike`, `DatabaseLike`, `CancellableOptions`, `TraceableOptions`, and `DeterministicOptions` as named type/interface exports.
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts` (every canonical type asserted via `expectTypeOf`).
|
||||
|
||||
#### Scenario: Consumer uses `import type`
|
||||
|
||||
- **WHEN** a consumer writes `import type { Clock } from "@suaveplan/types"`
|
||||
- **THEN** `tsc` accepts the import AND the built bundle contains zero bytes from `@suaveplan/types`
|
||||
|
||||
### Requirement: Testing packages may depend on types
|
||||
|
||||
The three testing-infrastructure packages (`@suaveplan/testing`, `@suaveplan/testing-dom`, `@suaveplan/testing-e2e`) SHALL be permitted to add `@suaveplan/types` as a `devDependency` — this is the one exception to the otherwise-strict no-`@suaveplan/*`-dep rule — because `@suaveplan/types` emits no runtime and therefore cannot create a circular test/subject dependency. The same carve-out SHALL also permit `@suaveplan/error` to declare `@suaveplan/types` as a runtime dependency for the `MessageResolver` type.
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts` (CLAUDE.md rule-8 carve-out check).
|
||||
|
||||
#### Scenario: Rule-8 carve-out documented
|
||||
|
||||
- **WHEN** `CLAUDE.md` rule 8 is read
|
||||
- **THEN** it explicitly states that `@suaveplan/types` is the sole exception to the no-workspace-dep rule
|
||||
|
||||
### Requirement: Test coverage at 100%
|
||||
|
||||
`vitest.config.ts` MUST enforce `statements: 100`, `branches: 100`, `functions: 100`, `lines: 100` — trivial for a package with no runtime. The guard test in `tests/zero-runtime.test.ts` is the one substantive test.
|
||||
|
||||
**Verified by:** `vitest.config.ts` thresholds and `bunx turbo test:coverage --filter=@suaveplan/types`.
|
||||
|
||||
#### Scenario: Coverage threshold enforced
|
||||
|
||||
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/types` runs
|
||||
- **THEN** it exits zero with 100/100/100/100 (or zero runtime covered at 100%)
|
||||
|
||||
### Requirement: MonotonicClock is high-precision
|
||||
|
||||
The package SHALL export `MonotonicClock = () => bigint`, distinct from `Clock`. The contract: a `MonotonicClock` returns nanoseconds since an arbitrary monotonic origin (process start, performance counter, etc.). Successive calls within a single process MUST be strictly non-decreasing. The result is `bigint` so it can express the full 2^64-nanosecond range without precision loss. `MonotonicClock` is NOT comparable across processes or machines and MUST NOT be used for human-readable timestamps or wire-format dates.
|
||||
|
||||
**Verified by:** `src/clock.ts` (type alias) and `tests/spec-conformance.test.ts` (`expectTypeOf<MonotonicClock>().toBeFunction()`).
|
||||
|
||||
#### Scenario: MonotonicClock returns bigint
|
||||
|
||||
- **WHEN** a consumer declares `const tick: MonotonicClock = () => 1n`
|
||||
- **THEN** `tsc` accepts the assignment AND the return type is `bigint` (not `number`)
|
||||
|
||||
#### Scenario: Clock and MonotonicClock are distinct
|
||||
|
||||
- **WHEN** a consumer assigns `const c: Clock = () => 1n` (bigint into Clock)
|
||||
- **THEN** `tsc` rejects (Clock is `() => number`)
|
||||
|
||||
### Requirement: Rng has optional bounded operations
|
||||
|
||||
The `Rng` interface SHALL declare `nextBytes(n: number): Uint8Array` and `nextIntInRange(min: number, maxExclusive: number): number` as OPTIONAL members. Implementations SHOULD provide them when efficient (CSPRNGs typically expose `getRandomValues` directly; deterministic seeded RNGs can derive both from `nextUint32`). Consumers needing them MUST narrow on presence (`if (rng.nextBytes)`). `nextIntInRange` is contractually expected to use unbiased rejection sampling, not the `% range` shortcut.
|
||||
|
||||
**Verified by:** `src/rng.ts` (interface) and `tests/spec-conformance.test.ts` (optional-member presence + minimal-impl assignability).
|
||||
|
||||
#### Scenario: nextBytes is optional
|
||||
|
||||
- **WHEN** a consumer declares `const r: Rng = { nextUint32: () => 0, nextFloat: () => 0 }`
|
||||
- **THEN** `tsc` accepts (no `nextBytes` provided)
|
||||
|
||||
#### Scenario: nextIntInRange signature
|
||||
|
||||
- **WHEN** an implementation provides `nextIntInRange(min, maxExclusive): number`
|
||||
- **THEN** `tsc` accepts AND `nextIntInRange(0, 10)` returns `number`
|
||||
|
||||
### Requirement: StorageLike has optional batch operations
|
||||
|
||||
The `StorageLike` interface SHALL declare `getMany(keys)`, `setMany(entries)`, and `deleteMany(keys)` as OPTIONAL methods. Adapters with native multi-key support SHOULD implement them; consumers without them fall back to N parallel `get`/`set`/`delete` calls. Atomicity is NOT guaranteed by the contract — adapters that provide it MUST document it explicitly.
|
||||
|
||||
**Verified by:** `src/storage-like.ts` (interface) and `tests/spec-conformance.test.ts`.
|
||||
|
||||
#### Scenario: Batch operations are optional
|
||||
|
||||
- **WHEN** a minimal `StorageLike` impl provides only `get`/`set`/`delete`/`exists`/`list`/`clear`
|
||||
- **THEN** `tsc` accepts (no batch methods required)
|
||||
|
||||
#### Scenario: getMany returns ordered map
|
||||
|
||||
- **WHEN** an adapter implements `getMany(keys)`
|
||||
- **THEN** the return type is `Promise<ReadonlyMap<string, Uint8Array | undefined>>`
|
||||
|
||||
### Requirement: LoggerLike supports child loggers
|
||||
|
||||
The `LoggerLike` interface SHALL declare `child(bindings: Readonly<Record<string, unknown>>): LoggerLike` as an OPTIONAL method. Implementations that propagate context (structured loggers, request-scoped trace context) SHOULD provide it. No-op fallbacks MAY omit it.
|
||||
|
||||
**Verified by:** `src/logger-like.ts` (interface) and `tests/spec-conformance.test.ts`.
|
||||
|
||||
#### Scenario: child is optional
|
||||
|
||||
- **WHEN** a no-op `LoggerLike` impl provides only `debug`/`info`/`warn`/`error`
|
||||
- **THEN** `tsc` accepts (no `child` required)
|
||||
|
||||
#### Scenario: child returns a LoggerLike
|
||||
|
||||
- **WHEN** a structured impl provides `child({ requestId: "abc" })`
|
||||
- **THEN** the return type is `LoggerLike` (composable; the result has the same surface)
|
||||
|
||||
### Requirement: DatabaseLike requires transactional rollback
|
||||
|
||||
Implementations claiming `DatabaseLike` MUST provide genuine commit-on-resolve / rollback-on-throw semantics in `transaction(fn)`. Partial mutations from a thrown `fn` MUST NOT be visible to subsequent queries. Drivers that cannot guarantee rollback MUST expose a narrower interface (e.g. `QueryableLike` covering `query` + `execute` only) instead of claiming `DatabaseLike`. The rollback contract is non-negotiable.
|
||||
|
||||
**Verified by:** `src/database-like.ts` (doc-comment) and downstream adapter conformance tests in `@suaveplan/db-sqlite` / equivalent.
|
||||
|
||||
#### Scenario: Driver without rollback cannot claim DatabaseLike
|
||||
|
||||
- **WHEN** a SurrealDB driver running in a non-transactional mode is wrapped
|
||||
- **THEN** the wrapper MUST NOT claim to satisfy `DatabaseLike` (no `transaction` impl, narrower interface used instead)
|
||||
|
||||
#### Scenario: Transaction body throw causes rollback
|
||||
|
||||
- **WHEN** `db.transaction(async (tx) => { await tx.execute("INSERT ..."); throw new Error("x"); })` runs against a conformant impl
|
||||
- **THEN** the inserted row is NOT visible to a subsequent `db.query("SELECT ...")` call
|
||||
|
||||
---
|
||||
|
||||
> The requirements below were folded in from the archived change `enterprise-types-cross-cutting` (2026-05-02). They add the cross-cutting interfaces consumed by `@suaveplan/drive`, `@suaveplan/drive-webrtc`, `@suaveplan/network-webrtc`, `@suaveplan/network-libp2p`, and any future P2P plugin so downstream packages do not redeclare local copies and so that no plugin needs to import drive or network directly.
|
||||
|
||||
### Requirement: ChunkSource interface
|
||||
|
||||
The package SHALL export a `ChunkSource` interface with `id`, `type`, `priority`, and optional `health` fields. It SHALL declare `isAvailable(): Promise<boolean>`, `getChunk(chunkId: string, signal?: AbortSignal): Promise<Uint8Array | null>`, `getChunks(chunkIds: string[], signal?: AbortSignal): Promise<(Uint8Array | null)[]>`, `hasChunk(chunkId: string): Promise<boolean>`, and optional `updateHealth` and `getMetrics` methods.
|
||||
|
||||
**Verified by:** `tests/cross-package-compat.test.ts`::"mock P2P source implements ChunkSource without runtime deps" AND `tests/spec-conformance.test.ts`::"ChunkSource.getChunk accepts optional AbortSignal"
|
||||
|
||||
#### Scenario: ChunkSource can be implemented by a P2P source
|
||||
|
||||
- **WHEN** a class implements `ChunkSource` with all required fields and methods
|
||||
- **THEN** it compiles without importing any runtime package
|
||||
|
||||
#### Scenario: getChunk accepts AbortSignal
|
||||
|
||||
- **WHEN** `getChunk` is called with a pre-aborted `AbortSignal`
|
||||
- **THEN** the implementing source MAY reject immediately or return `null`
|
||||
|
||||
### Requirement: ChunkSourceHealth union
|
||||
|
||||
The package SHALL export `ChunkSourceHealth` as the union `'healthy' | 'degraded' | 'failed' | 'unknown'`.
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts`::"ChunkSourceHealth exhaustiveness covers all four states"
|
||||
|
||||
#### Scenario: Exhaustiveness check
|
||||
|
||||
- **WHEN** a `switch` statement covers all four literals
|
||||
- **THEN** TypeScript reports no exhaustiveness errors
|
||||
|
||||
### Requirement: ChunkSourceMetrics interface
|
||||
|
||||
The package SHALL export `ChunkSourceMetrics` with `avgResponseTime: number`, `successRate: number`, `totalRequests: number`, `failedRequests: number`, and `lastAccess: Date`.
|
||||
|
||||
#### Scenario: All five fields satisfy the interface
|
||||
|
||||
- **WHEN** an object literal supplies `avgResponseTime`, `successRate`, `totalRequests`, `failedRequests`, and `lastAccess`
|
||||
- **THEN** the object is assignable to `ChunkSourceMetrics` and TypeScript narrows the field types correctly
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts`::"ChunkSourceMetrics has all five required fields"
|
||||
|
||||
### Requirement: ChunkRef interface
|
||||
|
||||
The package SHALL export `ChunkRef` with `fileId: string`, `version: number`, and `checksum: string`.
|
||||
|
||||
#### Scenario: Object with fileId / version / checksum compiles
|
||||
|
||||
- **WHEN** a literal `{ fileId, version, checksum }` is asserted as `ChunkRef`
|
||||
- **THEN** the assertion compiles and TypeScript narrows each field to the documented primitive type
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts`::"ChunkRef has fileId, version, and checksum"
|
||||
|
||||
### Requirement: Plugin lifecycle interface
|
||||
|
||||
The package SHALL export `Plugin<TConfig>` with `name`, `version`, `type: PluginType`, `initialize(config)`, and `destroy()`.
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts`::"Plugin generic config type flows through initialize"
|
||||
|
||||
#### Scenario: Generic config type inference
|
||||
|
||||
- **WHEN** `Plugin<MyConfig>` is implemented
|
||||
- **THEN** `initialize` receives `MyConfig` and TypeScript narrows correctly
|
||||
|
||||
### Requirement: PluginType union
|
||||
|
||||
The package SHALL export `PluginType` as `'storage' | 'transform' | 'hook' | 'middleware' | 'kms' | 'dedup' | 'cow'`.
|
||||
|
||||
#### Scenario: All seven categories are exhaustive
|
||||
|
||||
- **WHEN** a `switch (kind: PluginType)` enumerates every variant
|
||||
- **THEN** TypeScript's exhaustiveness checker reports no missing case AND a synthetic eighth literal fails type-narrowing
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts`::"PluginType exhaustiveness covers all seven categories"
|
||||
|
||||
### Requirement: PluginRegistration interface
|
||||
|
||||
The package SHALL export `PluginRegistration` with `plugin: Plugin`, `priority: number`, and `enabled: boolean`.
|
||||
|
||||
#### Scenario: PluginRegistration references Plugin
|
||||
|
||||
- **WHEN** a `PluginRegistration` literal supplies a `Plugin` instance, a `priority` number, and an `enabled` boolean
|
||||
- **THEN** the literal compiles and the `plugin` field's `name` / `version` / `type` are reachable via `.plugin`
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts`::"PluginRegistration references Plugin interface"
|
||||
|
||||
### Requirement: DrivePlugin hook-based interface
|
||||
|
||||
The package SHALL export `DrivePlugin` with `name: string`, `hooks: Record<string, PluginHook>`, and optional `priority: number`.
|
||||
|
||||
#### Scenario: Mock drive plugin compiles without runtime deps
|
||||
|
||||
- **WHEN** a consumer in `tests/cross-package-compat.test.ts` declares a `DrivePlugin`-shaped object literal with `name`, `hooks: {}`, and no `@suaveplan/*` runtime imports
|
||||
- **THEN** the literal is assignable to `DrivePlugin` AND the test file builds with zero `@suaveplan/*` runtime dependencies
|
||||
|
||||
**Verified by:** `tests/cross-package-compat.test.ts`::"mock drive plugin implements DrivePlugin without runtime deps"
|
||||
|
||||
### Requirement: PluginHook type
|
||||
|
||||
The package SHALL export `PluginHook` as `(context: unknown, next: NextFunction) => Promise<unknown>`.
|
||||
|
||||
#### Scenario: PluginHook signature matches expected shape
|
||||
|
||||
- **WHEN** a function `(context, next) => Promise.resolve(undefined)` is asserted as `PluginHook`
|
||||
- **THEN** the assertion compiles AND a function with a different arity (e.g. `(ctx) => ...`) fails to type-check
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts`::"PluginHook signature matches expected shape"
|
||||
|
||||
### Requirement: MiddlewareHandler type
|
||||
|
||||
The package SHALL export `MiddlewareHandler` as `(context: MiddlewareContext, next: NextFunction) => Promise<void>`.
|
||||
|
||||
**Verified by:** `tests/cross-package-compat.test.ts`::"middleware handler that skips next compiles correctly"
|
||||
|
||||
#### Scenario: Middleware can short-circuit
|
||||
|
||||
- **WHEN** a middleware handler does not call `next()`
|
||||
- **THEN** it still satisfies the `MiddlewareHandler` type signature
|
||||
|
||||
### Requirement: MiddlewareContext interface
|
||||
|
||||
The package SHALL export `MiddlewareContext` with `operation: string`, `path: string`, and an open index signature `[key: string]: unknown` so consumers can extend it with `drive`, `args`, or other properties without circular deps.
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts`::"MiddlewareContext accepts extra properties via index signature"
|
||||
|
||||
#### Scenario: Consumer extension
|
||||
|
||||
- **WHEN** an object literal includes `operation`, `path`, and `drive: myDrive`
|
||||
- **THEN** it is assignable to `MiddlewareContext`
|
||||
|
||||
### Requirement: NextFunction type
|
||||
|
||||
The package SHALL export `NextFunction` as `() => Promise<void>`.
|
||||
|
||||
#### Scenario: NextFunction is a no-arg promise-returning function
|
||||
|
||||
- **WHEN** a no-arg async function is asserted as `NextFunction`
|
||||
- **THEN** the assertion compiles AND a function that takes any argument fails to type-check
|
||||
|
||||
**Verified by:** `tests/spec-conformance.test.ts`::"NextFunction is a no-arg promise-returning function"
|
||||
|
||||
### Requirement: HealthStatus interface
|
||||
|
||||
The package SHALL export `HealthStatus` with `status: 'healthy' | 'degraded' | 'failed' | 'unknown'`, optional `latencyMs`, `availableSpaceBytes`, `totalSpaceBytes`, `error`, and required `checkedAt: Date`.
|
||||
|
||||
**Verified by:** `tests/health-status.test.ts`::"minimal HealthStatus compiles" AND `tests/health-status.test.ts`::"full HealthStatus compiles"
|
||||
|
||||
#### Scenario: Minimal health check
|
||||
|
||||
- **WHEN** only `status` and `checkedAt` are provided
|
||||
- **THEN** the object satisfies `HealthStatus`
|
||||
|
||||
#### Scenario: Full health check
|
||||
|
||||
- **WHEN** all fields including optional ones are provided
|
||||
- **THEN** the object satisfies `HealthStatus`
|
||||
|
||||
### Requirement: Cross-cutting types are zero-runtime
|
||||
|
||||
All cross-cutting type exports (`ChunkSource`, `ChunkSourceHealth`, `ChunkSourceMetrics`, `ChunkRef`, `Plugin`, `PluginType`, `PluginRegistration`, `DrivePlugin`, `PluginHook`, `MiddlewareHandler`, `MiddlewareContext`, `NextFunction`, `HealthStatus`) SHALL be declared as `interface`, `type`, or `export type`. No function bodies, no class definitions, and no side effects SHALL be emitted at runtime by these modules.
|
||||
|
||||
**Verified by:** `tests/zero-runtime.test.ts`::"all new exports are type-only"
|
||||
|
||||
#### Scenario: Bundle analysis
|
||||
|
||||
- **WHEN** a bundler tree-shakes a file that imports only `ChunkSource` and `HealthStatus`
|
||||
- **THEN** zero bytes from `@suaveplan/types` remain in the output
|
||||
|
||||
### Requirement: Subpath exports for cross-cutting types
|
||||
|
||||
`package.json` SHALL declare individual subpath exports for `./chunk-source`, `./plugin`, `./middleware`, `./health-status`, and `./chunk-ref`.
|
||||
|
||||
**Verified by:** `tests/zero-runtime.test.ts`::"subpath imports do not pull sibling modules"
|
||||
|
||||
#### Scenario: Direct subpath import
|
||||
|
||||
- **WHEN** a consumer imports `ChunkSource` from `@suaveplan/types/chunk-source`
|
||||
- **THEN** no other module from `@suaveplan/types` is loaded
|
||||
|
||||
### Requirement: DomainTag is a branded type alias
|
||||
|
||||
The package SHALL expose `type DomainTag = string & { readonly __brand: "DomainTag" }`. The brand is a phantom type — it exists in the type system only and has no runtime representation. Consumers cannot construct a `DomainTag` from an arbitrary string at the type level; they MUST import a named constant (e.g. `DOMAIN_VAULT_V1`, `DOMAIN_PUBLIC_KEYCHAIN_V1`) from `@suaveplan/crypto/domain-tags`. The closed list of registered tag VALUES and the `encodeDomainTag` / `buildSigningInput` runtime helpers stay in `@suaveplan/crypto`.
|
||||
|
||||
#### Scenario: type-only import compiles without dragging in `@suaveplan/crypto`
|
||||
|
||||
- **WHEN** a package that does NOT depend on `@suaveplan/crypto` imports `DomainTag` from `@suaveplan/types`
|
||||
- **THEN** TypeScript compilation succeeds AND the dep graph remains acyclic AND the package's bundled output contains zero references to `@suaveplan/crypto`
|
||||
|
||||
#### Scenario: arbitrary string is not assignable to DomainTag
|
||||
|
||||
- **WHEN** a caller writes `const t: DomainTag = "free-form"`
|
||||
- **THEN** TypeScript reports a type error (the brand prevents implicit conversion from `string`)
|
||||
|
||||
#### Scenario: zero-runtime preserved
|
||||
|
||||
- **WHEN** the `@suaveplan/types` package is built and the per-file byte-budget guard runs
|
||||
- **THEN** the compiled `dist/domain-tag.js` file is ≤ 200 bytes
|
||||
|
||||
**Verified by:** `tests/zero-runtime.test.ts`::"every .js file under dist/ is within the stub-size budget"
|
||||
|
||||
### Requirement: Authority interface owns the cross-package signing contract
|
||||
|
||||
The package SHALL expose `interface Authority` with five members: `sign(domain: DomainTag, slotId: SlotId, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array>`, `getPublicSlot(slotId: SlotId): KeySlotPublic<unknown> | undefined`, `hasSlot(slotId: SlotId): boolean`, `dispose(): void`, and `readonly disposed: boolean`. The package SHALL also expose `KeySlotPublic<TLayoutFields = unknown>`, `KeySlotPrivate<TLayoutFields = unknown>` (extends `KeySlotPublic`), and `type SlotId = string`. The `coordinate` field on `KeySlotPublic` is typed as `unknown` because its concrete shape lives in `@suaveplan/crypto`.
|
||||
|
||||
#### Scenario: `@suaveplan/crypto::InMemoryAuthority` satisfies the interface
|
||||
|
||||
- **WHEN** the existing `InMemoryAuthority` class in `@suaveplan/crypto/authority` declares `implements Authority` against the interface from `@suaveplan/types`
|
||||
- **THEN** TypeScript structural compatibility check passes WITHOUT any shape change to `InMemoryAuthority`'s methods
|
||||
|
||||
#### Scenario: `bitpacker-crypto`'s `SignedBitSchema` accepts an `Authority` from `@suaveplan/types`
|
||||
|
||||
- **WHEN** `bitpacker-crypto::defineSignedBitSchema` declares its `pack(data, authority: Authority, ...)` parameter using the `Authority` import from `@suaveplan/types`
|
||||
- **THEN** a caller passing an `InMemoryAuthority` instance compiles AND the call-site has no transitive dep on `@suaveplan/crypto` for the type itself
|
||||
|
||||
#### Scenario: `KeySlotPublic.coordinate` typed as `unknown` keeps the contract narrow
|
||||
|
||||
- **WHEN** a verifier reads `slot.coordinate` from a `KeySlotPublic<unknown>`
|
||||
- **THEN** TypeScript requires the verifier to narrow the value before using it (no implicit `any`)
|
||||
|
||||
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/signed/signed-bitschema.ts` (imports `Authority` from `@suaveplan/types`); `packages/core/types/src/authority.md`
|
||||
|
||||
### Requirement: AuthorityVerifier function-type for DI
|
||||
|
||||
The package SHALL expose `type AuthorityVerifier = (domain: DomainTag, publicSlot: KeySlotPublic<unknown>, payload: Uint8Array, signature: Uint8Array) => boolean`. Returns `true` when the signature verifies, `false` for bytes that simply don't match. Implementations MAY throw on structural boundary errors; primitives that need a boolean answer SHOULD swallow such throws and treat them as `false`.
|
||||
|
||||
#### Scenario: `verifyAuthoritySignature` from `@suaveplan/crypto` is assignable to `AuthorityVerifier`
|
||||
|
||||
- **WHEN** a caller writes `const v: AuthorityVerifier = verifyAuthoritySignature` (where `verifyAuthoritySignature` is the canonical implementation in `@suaveplan/crypto/authority`)
|
||||
- **THEN** TypeScript accepts the assignment with no shape adapter needed
|
||||
|
||||
#### Scenario: a `bitpacker-crypto` primitive's `verifier` option accepts the canonical impl
|
||||
|
||||
- **WHEN** `defineSignedBitSchema(codec, { domain, verifier: verifyAuthoritySignature })` is constructed
|
||||
- **THEN** TypeScript accepts the configuration AND the primitive's `unpack` / `verify` paths produce the same boolean answers as if the primitive had imported `verifyAuthoritySignature` directly
|
||||
|
||||
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/signed/signed-bitschema.test.ts`::"pack signs via Authority under the configured domain"
|
||||
|
||||
### Requirement: AeadOps interface for DI'd AEAD operations
|
||||
|
||||
The package SHALL expose `interface AeadOps` with two members: `encrypt(algorithm: AeadAlgorithm, key: Uint8Array, plaintext: Uint8Array, options: AeadOpsOptions): Uint8Array` and `decrypt(algorithm, key, ciphertext, options): Uint8Array`. The package SHALL expose `type AeadAlgorithm = "AES-256-GCM" | "ChaCha20-Poly1305" | "XChaCha20-Poly1305"` and `interface AeadOpsOptions { readonly nonce: Uint8Array; readonly aad?: Uint8Array }`. Both methods are synchronous because the canonical `@suaveplan/crypto::aeadEncrypt` / `aeadDecrypt` are synchronous.
|
||||
|
||||
#### Scenario: canonical `aeadEncrypt` / `aeadDecrypt` are assignable to `AeadOps`
|
||||
|
||||
- **WHEN** a caller writes `const ops: AeadOps = { encrypt: aeadEncrypt, decrypt: aeadDecrypt }` (where the right-hand-side functions come from `@suaveplan/crypto`)
|
||||
- **THEN** TypeScript accepts the assignment with no shape adapter needed
|
||||
|
||||
#### Scenario: `bitpacker-crypto::EncryptedBitSchema` accepts the canonical AEAD bundle
|
||||
|
||||
- **WHEN** `defineEncryptedBitSchema(codec, { key, aead: { encrypt: aeadEncrypt, decrypt: aeadDecrypt } })` is constructed and a payload is round-tripped
|
||||
- **THEN** the payload decrypts to the original value AND the primitive has no transitive dep on `@suaveplan/crypto`
|
||||
|
||||
#### Scenario: `AeadAlgorithm` discriminator matches `@suaveplan/crypto`'s union
|
||||
|
||||
- **WHEN** the three string literals (`"AES-256-GCM"`, `"ChaCha20-Poly1305"`, `"XChaCha20-Poly1305"`) are used in the type position of `AeadAlgorithm`
|
||||
- **THEN** they are byte-identical to the strings accepted by `@suaveplan/crypto::aeadEncrypt`'s first parameter
|
||||
|
||||
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/encrypted/encrypted-bitschema.test.ts`::"round-trips through XChaCha20-Poly1305"
|
||||
|
||||
### Requirement: StreamingAeadEngine cross-package interface
|
||||
|
||||
`@suaveplan/types` SHALL export a `StreamingAeadEngine` interface defining the cross-package contract for pluggable streaming-AEAD engines. The interface SHALL declare the engine's stable identity (`id`, `wireId`), its key / nonce / tag dimensions (`keyLength`, `nonceLength`, `nonceBaseLength`, `tagLength`), a `deriveNonce(baseNonce, counter, finalFlag)` method, and async `seal(input)` / `open(input)` methods consuming `StreamingChunkInput` and returning `Uint8Array`.
|
||||
|
||||
The canonical implementations (`aesGcmStreamingEngine`, `xchacha20StreamingEngine`) live in `@suaveplan/crypto-cipher` and SHALL satisfy this interface. The high-level streaming façade in `@suaveplan/crypto` SHALL accept any `StreamingAeadEngine` instance — caller-supplied custom engines (HSM-backed, threshold, recording test spies) substitute cleanly for the built-ins.
|
||||
|
||||
#### Scenario: interface is types-only
|
||||
|
||||
- **WHEN** the `@suaveplan/types` package is built
|
||||
- **THEN** `streaming-aead-engine.ts` emits zero runtime bytes (the package's byte-size test still passes)
|
||||
|
||||
**Verified by:** `packages/core/types/tests/byte-size.test.ts::"types package emits zero runtime bytes"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.spec-conformance.test.ts::"StreamingAeadEngine contract is stable for custom engines"`
|
||||
|
||||
### Requirement: StreamingChunkInput cross-package interface
|
||||
|
||||
`@suaveplan/types` SHALL export a `StreamingChunkInput` interface bundling the inputs passed to `StreamingAeadEngine.seal` / `StreamingAeadEngine.open`: `key`, `baseNonce`, `counter` (32-bit), `finalFlag` (`0 | 1`), `data`, optional `aad`. The interface is the cross-package contract for a single chunk's encrypt / decrypt invocation.
|
||||
|
||||
#### Scenario: shape is byte-only
|
||||
|
||||
- **WHEN** a consumer calls `engine.seal({ key, baseNonce, counter, finalFlag, data })`
|
||||
- **THEN** the type system enforces every field is a `Uint8Array` or `number` literal — no runtime validation needed at the boundary
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead-aes-gcm/streaming-aead-aes-gcm.test.ts::"seal then open round-trips bytes for a single chunk"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead-xchacha20/streaming-aead-xchacha20.test.ts::"seal then open round-trips bytes for a single chunk"`
|
||||
|
||||
### Requirement: StreamingChunkTag cross-package interface
|
||||
|
||||
`@suaveplan/types` SHALL export a `StreamingChunkTag` interface used by attestation / observability consumers that subscribe to per-chunk callbacks via `EncryptStreamAeadOptions.onChunk` and `DecryptStreamAeadOptions.onChunk`. The interface SHALL carry `counter`, `finalFlag`, and `tag: Uint8Array` (the AEAD authentication tag for the chunk).
|
||||
|
||||
#### Scenario: chunk tags are emitted to the onChunk callback
|
||||
|
||||
- **WHEN** `decryptStreamAead(source, { onChunk })` runs to completion over a multi-chunk stream
|
||||
- **THEN** the callback fires once per chunk with a `StreamingChunkTag` carrying the chunk's counter, final-flag, and AEAD tag bytes
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"decryptStreamAead reports the parsed header via onHeader"`
|
||||
|
||||
### Requirement: StreamingHeader cross-package interface
|
||||
|
||||
`@suaveplan/types` SHALL export a `StreamingHeader` interface used by attestation / observability consumers that subscribe to the once-per-stream `onHeader` callback. The interface SHALL carry `engineId`, `wireId`, `version`, `baseNonceLength`, `baseNonce`.
|
||||
|
||||
#### Scenario: header is emitted on stream start
|
||||
|
||||
- **WHEN** `encryptStreamAead(key, source, { onHeader, baseNonce, engine })` begins iteration
|
||||
- **THEN** the callback fires exactly once with a `StreamingHeader` whose `engineId` matches the engine in use and whose `baseNonceLength` matches `baseNonce.length`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"decryptStreamAead reports the parsed header via onHeader"`
|
||||
|
||||
### Requirement: EncryptStreamAeadOptions cross-package interface
|
||||
|
||||
`@suaveplan/types` SHALL export an `EncryptStreamAeadOptions` interface declaring the options bundle accepted by `encryptStreamAead`. Required: `baseNonce: Uint8Array` (caller-supplied; primitives never generate randomness). Optional: `engine`, `signal`, `onHeader`, `onChunk`.
|
||||
|
||||
#### Scenario: baseNonce is required at the type level
|
||||
|
||||
- **WHEN** a consumer omits `baseNonce` from an `EncryptStreamAeadOptions` literal
|
||||
- **THEN** the TypeScript compiler reports a missing-property error (no runtime guard required)
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"rejects a wrong-length base nonce on encrypt"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"round-trips a multi-chunk plaintext through encryptStreamAead and decryptStreamAead"`
|
||||
|
||||
### Requirement: DecryptStreamAeadOptions cross-package interface
|
||||
|
||||
`@suaveplan/types` SHALL export a `DecryptStreamAeadOptions` interface declaring the options bundle accepted by `decryptStreamAead`. All fields are optional: `engine`, `allowUnterminated`, `signal`, `onHeader`, `onChunk`. The decrypt path reads the base nonce from the wire header rather than from caller options, so no `baseNonce` field is exposed.
|
||||
|
||||
#### Scenario: allowUnterminated opts into truncated-stream tolerance
|
||||
|
||||
- **WHEN** `decryptStreamAead(key, source, { allowUnterminated: true })` is called against a stream whose final terminator frame has been dropped
|
||||
- **THEN** the call returns silently rather than throwing `STREAM_TAG_MISMATCH`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"decrypt: allowUnterminated returns silently on truncation"`
|
||||
|
||||
### Requirement: Backend interface for translation resource loading
|
||||
|
||||
The package SHALL export a `Backend` interface (in `src/i18n-backend.ts`, re-exported from `src/index.ts`) that any i18n backend implementation can satisfy without depending on `@suaveplan/i18n` core. The interface SHALL declare:
|
||||
|
||||
- `readonly name: string` — backend identifier
|
||||
- `readonly capabilities: BackendCapabilities` — capability flags
|
||||
- `read(locale: string, namespace: string): Promise<TranslationRecord>` — mandatory single-namespace load
|
||||
- `readMulti?(locale: string, namespaces: readonly string[]): Promise<Record<string, TranslationRecord>>` — optional bulk-load optimisation
|
||||
- `save?(locale: string, namespace: string, record: TranslationRecord): Promise<void>` — optional write surface
|
||||
- `lastModified?(locale: string, namespace: string): Promise<number | undefined>` — optional cache-busting hook
|
||||
- `init?(context: BackendInitContext): Promise<void> | void` — optional one-shot setup hook receiving the catalog's init context for watch-capable backends
|
||||
- `dispose?(): Promise<void> | void` — optional teardown hook called when the catalog is disposed
|
||||
|
||||
#### Scenario: minimal in-memory backend satisfies the interface
|
||||
|
||||
- **WHEN** a class implements only `name`, `capabilities`, and `read`
|
||||
- **THEN** `expectTypeOf<typeof MinimalBackend>().toMatchTypeOf<new () => Backend>()` passes
|
||||
|
||||
#### Scenario: backend with all optional methods satisfies the interface
|
||||
|
||||
- **WHEN** a class implements every optional method (`readMulti`, `save`, `lastModified`, `init`, `dispose`)
|
||||
- **THEN** `expectTypeOf<typeof FullBackend>().toMatchTypeOf<new () => Backend>()` passes
|
||||
|
||||
**Verified by:** `packages/core/types/tests/i18n-backend.test.ts::"minimal Backend implementation typechecks"` AND `packages/core/types/tests/i18n-backend.test.ts::"full Backend implementation typechecks"`
|
||||
|
||||
### Requirement: BackendCapabilities flags
|
||||
|
||||
The package SHALL export a `BackendCapabilities` interface declaring five required capability flags:
|
||||
|
||||
- `readonly canRead: true` — literal `true`; included for symmetry so the matrix is uniform across every dimension
|
||||
- `readonly canReadMulti: boolean` — true iff `readMulti?` is implemented
|
||||
- `readonly canSave: boolean` — true iff `save?` is implemented
|
||||
- `readonly canTrackModification: boolean` — true iff `lastModified?` is implemented
|
||||
- `readonly canWatch: boolean` — true iff the backend emits reactive `loaded` events via the `init` context
|
||||
|
||||
These flags SHALL be readable at runtime so the catalog can route reads to the most efficient backend in a `ChainedBackend`.
|
||||
|
||||
#### Scenario: capabilities are required fields
|
||||
|
||||
- **WHEN** a `BackendCapabilities` literal omits any of the five flags
|
||||
- **THEN** TypeScript reports a `Property '<flag>' is missing in type` error
|
||||
|
||||
#### Scenario: canRead must be the literal true
|
||||
|
||||
- **WHEN** a `BackendCapabilities` literal sets `canRead: false`
|
||||
- **THEN** TypeScript reports `Type 'false' is not assignable to type 'true'`
|
||||
|
||||
**Verified by:** `packages/core/types/tests/i18n-backend.test.ts::"BackendCapabilities requires all five flags"`
|
||||
|
||||
### Requirement: BackendInitContext for watch-capable backends
|
||||
|
||||
The package SHALL export a `BackendInitContext` interface declaring the context the catalog passes to `Backend.init` at registration:
|
||||
|
||||
- `readonly catalogName: string` — stable name of the catalog this backend is being attached to
|
||||
- `readonly emitLoaded: (locale: string, namespace: string, meta?: { backend: string }) => void` — emit a `loaded` event after the underlying source changes (file watcher fired, hot-reload socket message arrived, etc.)
|
||||
|
||||
#### Scenario: catalog name and emit callback are required
|
||||
|
||||
- **WHEN** a `BackendInitContext` literal is constructed and `emitLoaded("en", "common", { backend: "memory" })` is invoked
|
||||
- **THEN** the call typechecks and `catalogName` is readable
|
||||
|
||||
**Verified by:** `packages/core/types/tests/i18n-backend.test.ts::"BackendInitContext shape"`
|
||||
|
||||
### Requirement: BackendFactory convenience type
|
||||
|
||||
The package SHALL export a `BackendFactory<TOptions = unknown>` type alias equal to `(options?: TOptions) => Backend`. Concrete backends export both the class and a factory function (e.g. `class MemoryBackend implements Backend` plus `memoryBackend(opts?: MemoryBackendOptions): Backend`), and the factory's return type is annotated with `BackendFactory<MemoryBackendOptions>`.
|
||||
|
||||
#### Scenario: factory returns a Backend value
|
||||
|
||||
- **WHEN** a `BackendFactory<TOptions>` is constructed and invoked with options
|
||||
- **THEN** the return value is structurally a `Backend`
|
||||
|
||||
**Verified by:** `packages/core/types/tests/i18n-backend.test.ts::"BackendFactory<TOptions> returns Backend"`
|
||||
|
||||
### Requirement: LayoutOption discriminator union
|
||||
|
||||
The package SHALL export a `LayoutOption` string-literal union covering every layout shape used by storage- and filesystem-backed i18n backends:
|
||||
|
||||
- `"single-key-per-namespace"` — `<prefix>/<locale>/<namespace>.json` → serialised TranslationRecord
|
||||
- `"single-key-per-locale"` — `<prefix>/<locale>.json` → `Record<namespace, TranslationRecord>`
|
||||
- `"flat-keys"` — `<prefix>:<locale>:<namespace>:<dotPath>` → leaf string
|
||||
- `"nested"`, `"flat"`, `"glob"` — filesystem-only layouts
|
||||
|
||||
#### Scenario: every documented layout literal is assignable
|
||||
|
||||
- **WHEN** every documented literal is assigned to `LayoutOption`
|
||||
- **THEN** every assignment typechecks
|
||||
|
||||
#### Scenario: unknown layout literal is rejected
|
||||
|
||||
- **WHEN** a string outside the documented set is assigned to `LayoutOption`
|
||||
- **THEN** TypeScript reports a string-literal mismatch
|
||||
|
||||
**Verified by:** `packages/core/types/tests/i18n-backend.test.ts::"every documented layout literal is assignable"`
|
||||
|
||||
### Requirement: TranslationRecord recursive shape
|
||||
|
||||
The package SHALL export a `TranslationRecord` type that is `{ readonly [key: string]: string | TranslationRecord }`. The type SHALL allow arbitrary nesting depth so namespaces can be modelled as nested objects (e.g. `{ auth: { login: { title: "Sign in" } } }`).
|
||||
|
||||
#### Scenario: leaves are strings
|
||||
|
||||
- **WHEN** a `TranslationRecord` literal contains `{ greet: "hi" }`
|
||||
- **THEN** the literal is assignable to `TranslationRecord`
|
||||
|
||||
#### Scenario: nested records are valid
|
||||
|
||||
- **WHEN** a `TranslationRecord` literal contains `{ auth: { login: { title: "x" } } }`
|
||||
- **THEN** the literal is assignable to `TranslationRecord`
|
||||
|
||||
#### Scenario: non-string non-record values rejected
|
||||
|
||||
- **WHEN** a `TranslationRecord` literal contains `{ count: 42 }`
|
||||
- **THEN** TypeScript reports `Type 'number' is not assignable to type 'string | TranslationRecord'`
|
||||
|
||||
**Verified by:** `packages/core/types/tests/i18n-backend.test.ts::"TranslationRecord allows string leaves"` AND `packages/core/types/tests/i18n-backend.test.ts::"TranslationRecord allows nested records"` AND `packages/core/types/tests/i18n-backend.test.ts::"TranslationRecord rejects non-string non-record values"`
|
||||
|
||||
### Requirement: catalog data shapes exposed for cross-package consumption
|
||||
|
||||
The package SHALL also export the following catalog-related types so the slim core's re-export bridge (Wave 6) does not have to redeclare them:
|
||||
|
||||
- `LocaleNamespace` — `{ readonly locale: string; readonly namespace: string }`
|
||||
- `MessageLoader` — `(locale: string, namespace?: string) => Promise<TranslationRecord>`
|
||||
- `MissingKeyHandler` — `(info: { readonly locale: string; readonly namespace: string; readonly key: string }) => string | undefined`
|
||||
- `LookupResult` — `{ readonly value: string | undefined; readonly fellBackTo: string | undefined }`
|
||||
- `NamespacedCatalogMap` — `Map<string, TranslationRecord>`
|
||||
- `TranslationCatalogMap` — `Map<string, NamespacedCatalogMap>`
|
||||
|
||||
#### Scenario: every catalog type is importable from `@suaveplan/types`
|
||||
|
||||
- **WHEN** a downstream package writes `import type { LocaleNamespace, MessageLoader, MissingKeyHandler, LookupResult, NamespacedCatalogMap, TranslationCatalogMap } from "@suaveplan/types";`
|
||||
- **THEN** the import resolves with no `Module has no exported member` errors
|
||||
|
||||
**Verified by:** `packages/core/types/tests/i18n-backend.test.ts::"all catalog types are exported from the @suaveplan/types root"`
|
||||
|
||||
### Requirement: zero runtime contribution from i18n contract
|
||||
|
||||
The i18n cross-package contract SHALL NOT add any runtime code. The package's existing byte-size guard SHALL continue to pass.
|
||||
|
||||
#### Scenario: byte-size test stays green
|
||||
|
||||
- **WHEN** `bunx turbo build --filter=@suaveplan/types` runs
|
||||
- **THEN** the resulting runtime emits zero bytes (types-only)
|
||||
|
||||
**Verified by:** existing byte-size guard in `packages/core/types/tests/` (continues to pass)
|
||||
|
||||
### Requirement: TimersLike interface
|
||||
|
||||
The package SHALL export a `TimersLike` interface defining a minimal pluggable timer pair compatible with the platform `setTimeout` / `clearTimeout` shape but stripped to the lowest-common-denominator that works under both `globalThis` timers and test-controlled `FakeClock` backends.
|
||||
|
||||
```ts
|
||||
interface TimersLike {
|
||||
setTimer(callback: () => void, delayMs: number): number;
|
||||
clearTimer(handle: number): void;
|
||||
}
|
||||
```
|
||||
|
||||
`setTimer` SHALL schedule `callback` to run after `delayMs` and return an opaque numeric handle. `clearTimer` SHALL cancel a pending timer by handle and SHALL be a no-op for unknown handles (idempotent). Neither method SHALL accept variadic forwarding arguments — callers SHALL close over any state via the `callback` closure. The interface SHALL be types-only with zero runtime emission.
|
||||
|
||||
#### Scenario: globalThis-backed default works
|
||||
|
||||
- **GIVEN** a `TimersLike` instance backed by `(cb, ms) => globalThis.setTimeout(cb, ms)` and `(h) => globalThis.clearTimeout(h)`
|
||||
- **WHEN** `setTimer(fn, 10)` is called
|
||||
- **THEN** `fn` is invoked after at least 10 wall-clock milliseconds and the returned handle is accepted by `clearTimer` without throwing.
|
||||
|
||||
#### Scenario: FakeClock-backed implementation is satisfiable
|
||||
|
||||
- **GIVEN** a FakeClock-backed `TimersLike` whose `setTimer` registers `(callback, delayMs)` in an internal queue keyed by `clock.now() + delayMs`
|
||||
- **WHEN** `clock.advance(delayMs)` is called
|
||||
- **THEN** every registered callback whose deadline has elapsed fires synchronously in deadline order and zero real wall-clock time has elapsed.
|
||||
|
||||
#### Scenario: Idempotent clearTimer
|
||||
|
||||
- **GIVEN** any `TimersLike` implementation and a handle that has never been issued (or that was already cleared)
|
||||
- **WHEN** `clearTimer(handle)` is called
|
||||
- **THEN** it returns without throwing.
|
||||
|
||||
**Verified by:** consumers in `@suaveplan/cron` (`SchedulerTimers = TimersLike`) and `@suaveplan/retry` (`RetryConfig.timers`) — both pass 100% Istanbul coverage including FakeClock-driven test cases.
|
||||
|
||||
## Requirements (added by add-types-fetch-like — 2026-05-16)
|
||||
|
||||
### Requirement: FetchLike matches the WHATWG fetch shape
|
||||
|
||||
The package SHALL export a `FetchLike` type with the signature
|
||||
`(input: string | URL | Request, init?: RequestInit) => Promise<Response>`.
|
||||
The type SHALL be assignable from `globalThis.fetch` and SHALL accept any function whose first parameter is `string | URL | Request` and second parameter is optional `RequestInit`.
|
||||
|
||||
#### Scenario: globalThis.fetch is assignable to FetchLike
|
||||
|
||||
- **WHEN** a caller assigns `globalThis.fetch` to a `FetchLike`-typed variable
|
||||
- **THEN** the assignment typechecks without `as` casts
|
||||
|
||||
#### Scenario: narrower input shape still satisfies FetchLike
|
||||
|
||||
- **WHEN** a caller assigns `(input: string | URL, init?: RequestInit) => Promise<Response>` to a `FetchLike`-typed variable
|
||||
- **THEN** the assignment fails typecheck (input parameter is narrower — contravariant)
|
||||
- **AND** wrapping the function in `(input, init) => narrower(input as string | URL, init)` re-establishes assignability
|
||||
|
||||
**Verified by:** `src/fetch-like.test.ts::"globalThis.fetch is assignable to FetchLike"` AND `src/fetch-like.test.ts::"a function matching the full shape is assignable"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: FetchLike is exported from the package root and via a `./fetch-like` subpath
|
||||
|
||||
The package SHALL re-export `FetchLike` from `src/index.ts` so that `import type { FetchLike } from "@suaveplan/types"` works.
|
||||
The package SHALL also declare a `./fetch-like` subpath export in `package.json` so that `import type { FetchLike } from "@suaveplan/types/fetch-like"` works.
|
||||
|
||||
#### Scenario: root barrel exports FetchLike
|
||||
|
||||
- **WHEN** a consumer writes `import type { FetchLike } from "@suaveplan/types"`
|
||||
- **THEN** the import resolves and typechecks
|
||||
|
||||
#### Scenario: subpath export resolves
|
||||
|
||||
- **WHEN** a consumer writes `import type { FetchLike } from "@suaveplan/types/fetch-like"`
|
||||
- **THEN** the import resolves and typechecks
|
||||
|
||||
**Verified by:** `src/fetch-like.test.ts::"FetchLike is exported from the package root"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: FetchLike sibling documentation meets the docs-check contract
|
||||
|
||||
The package SHALL ship `src/fetch-like.md` meeting CLAUDE.md §4 — at minimum ≥200 words excluding code blocks, with sections `Purpose`, `Features`, `Usage: Basic Example`, `Usage: Advanced Example`, `API Reference`, `Implementation Notes`.
|
||||
|
||||
#### Scenario: docs-check passes
|
||||
|
||||
- **WHEN** `bun run docs:check` is run against the package
|
||||
- **THEN** the script reports `Docs coverage: N/N (100%)` and exits 0
|
||||
|
||||
**Verified by:** the package's CI gate `bun run docs:check`.
|
||||
Reference in New Issue
Block a user