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,201 @@
# OpenSpec Change: enterprise-env
- **Change ID**: enterprise-env
- **Package**: `@suaveplan/env`
- **Category**: universal
- **Target path**: `packages/universal/env`
- **Status**: PROPOSED
- **Date**: 2026-04-19
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/env.md`
## Summary
Greenfield implementation of `@suaveplan/env` for the genesis branch. The existing main-branch env package provides the baseline public-API surface to preserve; this change delivers enterprise-grade type-safe environment variable loading with schema-first validation, a rich set of typed field builders (string, number, boolean, enum, URL, email, port, JSON, array), pluggable loaders (dotenv file, plain object), common presets for server apps, structured errors with `ENV.*` codes, Zod schemas, OTel instrumentation, property-based tests via fast-check, and benchmarks.
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
## Available workspace deps on genesis
| Package | Available |
|---------|-----------|
| `@suaveplan/error` | ✅ |
| `@suaveplan/types` | ✅ |
| `@suaveplan/logger` | ✅ |
| `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly |
| `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly |
External deps (via catalog):
- `zod` → `catalog:forms`
- `@opentelemetry/api` → `catalog:observability`
- `tslib` → `catalog:build`
Dev deps (via catalog):
- `vitest`, `@vitest/coverage-istanbul` → `catalog:testing`
- `fast-check` → `catalog:testing`
- `mitata` → `catalog:testing`
## Preserved public API surface (from main branch)
### Constants
- `DEFAULT_ARRAY_SEPARATOR` — default delimiter used by `ArrayField`
- `EMAIL_PATTERN` — regex for RFC-5322-compatible email validation
- `FALSY_VALUES` — string values coerced to `false` by `BooleanField`
- `INTERPOLATION_PATTERN` — regex matching `${VAR}` tokens in raw values
- `PORT_MAX` — upper bound for valid TCP port (65535)
- `PORT_MIN` — lower bound for valid TCP port (1)
- `TRUTHY_VALUES` — string values coerced to `true` by `BooleanField`
### Core
- `createEnv(schema, options?)` — loads env vars through configured loaders, validates all fields, collects or throws errors, returns typed record
- `CollectedErrors` — map of field key → array of error messages produced during a collect-mode run
- `EnvResult<S>` — the resolved typed record produced by `createEnv`
- `BaseField<T>` — abstract base class for all field builders; holds rules, default, sensitive flag, description
- `FieldConfig<T>` — runtime configuration snapshot extracted from any `BaseField`
- `RuleOptions` — options accepted by `BaseField.rules()` for adding a named validation rule
- `maskSensitive(values, schema)` — replaces sensitive field values with `***` in the result map
- `validate(input)` → `ValidationResult` — runs all rules for a single field, accumulating failures
- `ValidateInput` — `{ key, raw, field, coerced? }` fed into `validate`
- `ValidationResult` — `{ ok, errors }` where `errors` is an array of `{ rule, message }` pairs
### Types
- `AnyField<T, R>` — minimal interface every field builder satisfies; used for schema maps
- `CreateEnvOptions` — options accepted by `createEnv`: `loaders`, `errorMode`, `signal?`
- `EnvError` — `{ key, message, value? }` shape for a single validation failure
- `EnvSchema` — `Record<string, AnyField>` used as the schema argument to `createEnv`
- `ErrorMode` — `'throw' | 'collect' | 'warn'`
- `InferEnv<S>` — maps an `EnvSchema` to the resulting typed record type
- `InferFieldType<F>` — extracts the value type from a field builder
- `Loader` — `{ name: string; load(): LoaderResult }` interface for env sources
- `LoaderResult` — `{ values: Record<string, string>; source: string }`
- `OptionalKeys<S>` — keys whose fields may produce `undefined`
- `RequiredKeys<S>` — keys whose fields always produce a value
- `Simplify<T>` — intersection flattener utility type
- `ValidationRule<T>` — `{ name, message, validate(value): boolean }`
### Fields
- `StringField` / `str` — string field builder; rules: `minLength`, `maxLength`, `pattern`, `nonempty`
- `NumberField` / `num` — number field; rules: `min`, `max`, `integer`
- `BooleanField` / `bool` — boolean field; coerces TRUTHY_VALUES / FALSY_VALUES strings
- `EnumField<V>` / `enums` — enum field locked to a declared `EnumValues` tuple
- `EnumValues` — string-tuple type constraint for `EnumField`
- `UrlField` / `url` — URL field; validates via `URL` constructor, optional protocol restriction
- `EmailField` / `email` — email field; validates against `EMAIL_PATTERN`
- `PortField` / `port` — port field; integer in `[PORT_MIN, PORT_MAX]`
- `JsonField<T>` / `json` — JSON field; accepts a `JsonValidator<T>` for runtime shape checking
- `JsonValidator<T>` — `(parsed: unknown) => T` used with `JsonField`
- `ArrayField<T>` / `array` — array field; splits by `DEFAULT_ARRAY_SEPARATOR` (or custom), maps each item through an `ItemField`
- `ItemField<T>` — per-element field descriptor for `ArrayField`
### Loaders
- `dotenvLoader(options?)` — reads a `.env` file from disk and returns a `LoaderResult`
- `parseDotenv(content)` — pure function: parses a dotenv-format string into a `Record<string, string>`
- `DotenvLoaderOptions` — `{ path?, encoding?, override? }`
- `objectLoader(obj, options?)` — wraps an in-memory object as a loader
- `ObjectLoaderOptions` — `{ source? }` (source label for diagnostics)
### Presets — common
- `nodeEnvField` — `NODE_ENV` enum field locked to `'development' | 'test' | 'production' | 'staging'`
- `portField` — `PORT` with default 3000
- `hostField` — `HOST` with default `'0.0.0.0'`
- `logLevelField` — `LOG_LEVEL` enum locked to standard logger levels
- `debugField` — `DEBUG` boolean with default `false`
- `appNameField` — `APP_NAME` required string
- `appVersionField` — `APP_VERSION` string with default `'0.0.0'`
- `timezoneField` — `TZ` string with default `'UTC'`
- `workersField` — `WORKERS` integer number
- `commonFields` — composite preset: `nodeEnvField + portField + hostField + logLevelField + debugField`
### Presets — server
- `databaseUrlField` — `DATABASE_URL` sensitive required URL
- `redisUrlField` — `REDIS_URL` sensitive URL
- `sessionSecretField` — `SESSION_SECRET` sensitive required string with `minLength: 32`
- `corsOriginField` — `CORS_ORIGIN` string or array
- `rateLimitMaxField` — `RATE_LIMIT_MAX` integer number
- `rateLimitWindowField` — `RATE_LIMIT_WINDOW_MS` integer number
- `trustProxyField` — `TRUST_PROXY` boolean with default `false`
- `smtpHostField` — `SMTP_HOST` string
- `smtpPortField` — `SMTP_PORT` port field
- `smtpUserField` — `SMTP_USER` string
- `smtpPassField` — `SMTP_PASS` sensitive string
- `smtpFromField` — `SMTP_FROM` email field
- `serverFields` — composite: common server connection + SMTP fields
### Transforms (coerce utilities)
- `coerceString(raw)` — identity / trim
- `coerceNumber(raw)` — parses float, rejects NaN
- `coerceInteger(raw)` — parses integer, rejects non-integer
- `coerceBoolean(raw)` — maps truthy/falsy string sets
- `coerceArray(raw, separator?)` — splits and trims
- `coerceJson(raw)` — `JSON.parse` with structured error on failure
- `CoerceFn<T>` — `(raw: string) => T`
- `CoerceEntry<T>` — `{ coerce: CoerceFn<T> }` mixin used internally
### Utilities
- `EnvValidationError` extends `SuaveplanError` — thrown in `'throw'` error mode
- `MissingEnvError` extends `SuaveplanError` — thrown when a required key is absent
- `CoercionError` extends `SuaveplanError` — thrown when coercion fails for a raw value
- `DotenvParseError` extends `SuaveplanError` — thrown by `parseDotenv` on malformed input
- `createEnvError(key, message, value?)` — factory for `EnvError` value objects
- `interpolate(template, values)` — resolves `${VAR}` tokens in a string from a values map
- `hasInterpolation(value)` — returns `true` if the string contains any `${VAR}` tokens
- `extractVariables(template)` — returns the list of variable names referenced in a template string
## New genesis additions
### Structured errors
- `EnvError` base class extends `SuaveplanError` with codes under the `ENV.*` namespace registered via `defineCodes`
- Codes:
- `ENV.VALIDATION_FAILED` — one or more fields failed validation rules
- `ENV.MISSING_REQUIRED` — a required field has no value and no default
- `ENV.INVALID_FORMAT` — coercion of a raw string to the target type failed
- `ENV.PARSE_ERROR` — dotenv file parse failed or JSON parse failed
- `EnvValidationError`, `MissingEnvError`, `CoercionError`, `DotenvParseError` all extend `EnvError` using the appropriate code
- All errors carry `{ key, raw?, source? }` context for diagnostics
### Zod schemas
- `FieldConfigSchema` — validates the `FieldConfig<unknown>` shape at runtime
- `ValidationResultSchema` — validates `{ ok, errors }` produced by `validate`
- `EnvResultSchema` — validates the merged output record from `createEnv`
- `CreateEnvOptionsSchema` — validates options passed to `createEnv`
- `DotenvLoaderOptionsSchema`, `ObjectLoaderOptionsSchema` — validates loader options at the boundary
- All schemas exported from `*.schemas.ts` sibling files
### OTel instrumentation on createEnv
- Counter `env.validation.total` (labels: `outcome: 'success' | 'error'`) — incremented once per `createEnv` call
- Counter `env.validation.errors` (label: `field`) — incremented once per field that fails validation
- Histogram `env.load.duration_ms` — duration of the full `createEnv` call including all loaders
- All meters via `@opentelemetry/api`; no-op without exporter
### Property-based tests (fast-check)
- Field parsers round-trip: `coerce(raw)` produces a stable value for all valid `raw` strings
- `maskSensitive` never leaks values: for any schema with sensitive fields, the masked output never contains the original raw value at that key
- `validate` accumulates all errors: if N rules fail, `ValidationResult.errors.length === N`
- `parseDotenv` is pure: same input always yields same output
### Benchmarks (mitata)
- `createEnv` with a 20-field schema, object loader, no errors — hot-path throughput
- `validate` hot path — single field with 5 rules, all passing
## Impact
- Adds one new package: `packages/universal/env`
- Adds one new spec capability: `env`
- No existing package consumers on genesis yet

View File

@@ -0,0 +1,127 @@
# Capability Spec: Env
**Package**: `@suaveplan/env`
**Version**: 0.0.0 (genesis)
**Status**: ADDED
---
## ADDED Requirements
### ENV-01 — Schema-first type-safe loading
`createEnv(schema, options?)` MUST accept an `EnvSchema` (a `Record<string, AnyField>`) and an optional `CreateEnvOptions`. It MUST run all configured loaders in order, merge their `LoaderResult.values` maps (later loaders win by default), resolve `${VAR}` interpolation tokens, coerce each raw string through its field's `coerce()` method, run all registered validation rules, and return an `EnvResult<S>` whose type is fully inferred from the schema via `InferEnv<S>`. The returned object MUST be typed so that required fields are never `undefined` and optional fields may be `undefined`.
### ENV-02 — Error mode contract
`createEnv` MUST support three `ErrorMode` values:
- `'throw'` (default): throw `EnvValidationError` or `MissingEnvError` on the first field failure.
- `'collect'`: accumulate all field failures into `CollectedErrors` (a `Record<string, string[]>`) without throwing; return the partial result alongside the error map.
- `'warn'`: log each failure via `console.warn` and return the partial result; do not throw.
### ENV-03 — Loader contract
A `Loader` MUST implement `{ name: string; load(): LoaderResult }`. `LoaderResult` MUST be `{ values: Record<string, string>; source: string }`. `createEnv` MUST call each loader's `load()` in the order they appear in `options.loaders`. If no loaders are provided the raw values map is empty and only defaults and optional fields can produce output.
### ENV-04 — objectLoader
`objectLoader(obj, options?)` MUST wrap any `Record<string, string | undefined>` as a `Loader`. `ObjectLoaderOptions.source` (optional) sets the `LoaderResult.source` label; defaults to `'object'`. Values that are `undefined` MUST be omitted from `LoaderResult.values`.
### ENV-05 — dotenvLoader and parseDotenv
`parseDotenv(content)` MUST be a pure function that parses a dotenv-format string into a `Record<string, string>`. It MUST handle: quoted values (single and double), inline comments (`# …`), blank lines, and `=` characters inside values. Malformed lines that cannot be parsed MUST cause a `DotenvParseError` (code `ENV.PARSE_ERROR`). `dotenvLoader(options?)` MUST read the file at `options.path` (default `'.env'`), call `parseDotenv`, and return a `LoaderResult`. If the file does not exist the loader MUST return an empty `LoaderResult` (no error); this allows optional `.env` files. `DotenvLoaderOptions.override` (boolean, default `true`) controls whether dotenv values overwrite values already present from earlier loaders.
### ENV-06 — Interpolation
`interpolate(template, values)` MUST replace all `${VAR}` tokens in `template` with the corresponding entry from `values`, leaving unresolved tokens as-is. `hasInterpolation(value)` MUST return `true` if and only if the string contains at least one `${VAR}` token. `extractVariables(template)` MUST return all unique variable names referenced in the template. `createEnv` MUST expand interpolation on every raw value before coercion.
### ENV-07 — BaseField builder contract
`BaseField<T>` MUST provide the following fluent builder methods, each returning `this` for chaining:
- `optional()` — marks the field as not required; returns `undefined` when the key is absent and there is no default
- `default(v: T)` — sets a default value used when the key is absent
- `sensitive()` — marks the field so `maskSensitive` redacts its value in logs/output
- `describe(s: string)` — attaches a human-readable description
- `rules(opts: RuleOptions)` — appends a named custom validation rule
`BaseField` MUST declare `abstract coerce(raw: string): T`. Calling `coerce` with an uncoercible string MUST throw `CoercionError` (code `ENV.INVALID_FORMAT`).
### ENV-08 — Field type coercion and rules
Each field type MUST coerce its raw string through the corresponding transform and enforce its built-in rules before any custom rules run:
- `StringField` — identity coercion (trim); built-in rules: `minLength`, `maxLength`, `pattern`, `nonempty`
- `NumberField` — `parseFloat`; rejects `NaN`; built-in rules: `min`, `max`, `integer`
- `BooleanField` — maps `TRUTHY_VALUES` strings to `true`, `FALSY_VALUES` strings to `false`; any other value is a coercion error
- `EnumField<V>` — validates raw string is a member of the declared `EnumValues` tuple
- `UrlField` — validates via `new URL()`; optional `protocols` allowlist rule rejects disallowed schemes
- `EmailField` — validates against `EMAIL_PATTERN` regex
- `PortField` — coerces to integer; validates in `[PORT_MIN, PORT_MAX]`
- `JsonField<T>` — `JSON.parse`; applies optional `JsonValidator<T>` for shape checking
- `ArrayField<T>` — splits on `separator` (default `DEFAULT_ARRAY_SEPARATOR = ','`), trims each item, maps through `itemField.coerce`
### ENV-09 — validate accumulates all errors
`validate(input: ValidateInput)` MUST run all rules in `input.field._rules` and collect every failure. The returned `ValidationResult` MUST be `{ ok: boolean; errors: Array<{ rule: string; message: string }> }` where `errors` contains one entry per failing rule. `ok` MUST be `true` if and only if `errors` is empty.
### ENV-10 — maskSensitive never leaks
`maskSensitive(values, schema)` MUST return a shallow copy of `values` where every key whose corresponding `schema[key]._isSensitive` is `true` has its value replaced with the literal string `'***'`. The original `values` object MUST NOT be mutated.
### ENV-11 — Structured errors with ENV.* codes
All errors thrown by this package MUST extend the `EnvError` base (which extends `SuaveplanError`). Error codes MUST be registered via `defineCodes` in the `ENV.*` namespace:
| Code | Class | Trigger |
|---|---|---|
| `ENV.VALIDATION_FAILED` | `EnvValidationError` | One or more field rules failed |
| `ENV.MISSING_REQUIRED` | `MissingEnvError` | Required field has no value and no default |
| `ENV.INVALID_FORMAT` | `CoercionError` | Raw string cannot be coerced to target type |
| `ENV.PARSE_ERROR` | `DotenvParseError` | Dotenv file or JSON value cannot be parsed |
Every error instance MUST carry `{ key: string; raw?: string; source?: string }` in its structured context.
### ENV-12 — Zod schemas for runtime validation
The following Zod schemas MUST be exported from `*.schemas.ts` sibling files and used to validate inputs at public API boundaries:
- `FieldConfigSchema` — validates `FieldConfig<unknown>`
- `ValidationResultSchema` — validates `ValidationResult`
- `CreateEnvOptionsSchema` — validates `CreateEnvOptions`
- `DotenvLoaderOptionsSchema` — validates `DotenvLoaderOptions`
- `ObjectLoaderOptionsSchema` — validates `ObjectLoaderOptions`
Invalid options passed to `createEnv`, `dotenvLoader`, or `objectLoader` MUST throw `EnvValidationError` with code `ENV.VALIDATION_FAILED` and a context that includes the Zod parse error details.
### ENV-13 — Presets
The common preset fields (`nodeEnvField`, `portField`, `hostField`, `logLevelField`, `debugField`, `appNameField`, `appVersionField`, `timezoneField`, `workersField`) and the `commonFields` composite MUST be exported from `src/presets/common/`. The server preset fields (`databaseUrlField`, `redisUrlField`, `sessionSecretField`, `corsOriginField`, `rateLimitMaxField`, `rateLimitWindowField`, `trustProxyField`, `smtpHostField`, `smtpPortField`, `smtpUserField`, `smtpPassField`, `smtpFromField`) and the `serverFields` composite MUST be exported from `src/presets/server/`. All sensitive preset fields (database URL, Redis URL, session secret, SMTP password) MUST have `_isSensitive = true`.
### ENV-14 — OTel instrumentation
`createEnv` MUST emit the following metrics via `@opentelemetry/api` `getMeter`:
- Counter `env.validation.total` with label `outcome: 'success' | 'error'` — incremented once per `createEnv` invocation after all fields are processed
- Counter `env.validation.errors` with label `field: string` — incremented once per field that fails validation or coercion
- Histogram `env.load.duration_ms` — records the wall-clock duration of the full `createEnv` call
All instruments MUST be no-ops when no OTel SDK exporter is registered. The instrumentation MUST NOT throw or suppress errors from the calling code.
### ENV-15 — sideEffects: false + coverage 100%
`"sideEffects": false` MUST be set in `package.json` and enforced by a tree-shake guard test in `tests/`. Vitest coverage thresholds MUST be set to 100/100/100/100 (statement / branch / function / line). No `.skip` or `.only` markers are permitted in `src/`.
### ENV-16 — Property-based test contracts
The following fast-check properties MUST pass:
- For all valid raw strings `s`, `coerce(s)` produces the same stable value on repeated calls (field coerce functions are pure)
- For any schema with at least one sensitive field, `maskSensitive(values, schema)` MUST NOT contain the original raw value at any sensitive key
- For any N failing rules, `validate` MUST return `errors.length === N`
- `parseDotenv(content)` MUST be pure: same `content` input always produces an equal output map
### ENV-17 — No browser or Node-specific dependencies
The package MUST run in Bun, Node, and browser environments. It MUST NOT import `fs`, `path`, `process`, or any Node-only built-in in any code path that does not go through `dotenvLoader`. `dotenvLoader` itself MAY use file-system access; all other modules MUST be environment-agnostic.

View File

@@ -0,0 +1,128 @@
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/config/env.
# Tasks: enterprise-env
## 1. Scaffold
- [x] 1.1 Create `packages/universal/env/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph, no-disallowed, tree-shake
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
## 2. Constants + types + errors
- [x] 2.1 Implement `src/constants/index.ts` — `DEFAULT_ARRAY_SEPARATOR`, `EMAIL_PATTERN`, `FALSY_VALUES`, `INTERPOLATION_PATTERN`, `PORT_MAX`, `PORT_MIN`, `TRUTHY_VALUES`
- [x] 2.2 Implement `src/types/common.ts` — `AnyField`, `CreateEnvOptions`, `EnvError`, `EnvSchema`, `ErrorMode`, `InferEnv`, `InferFieldType`, `Loader`, `LoaderResult`, `OptionalKeys`, `RequiredKeys`, `Simplify`, `ValidationRule`
- [x] 2.3 Implement `src/errors/env-error.ts` — `EnvError` base extends `SuaveplanError`; `EnvValidationError`, `MissingEnvError`, `CoercionError`, `DotenvParseError` extend `EnvError`; `defineCodes` for `ENV.VALIDATION_FAILED`, `ENV.MISSING_REQUIRED`, `ENV.INVALID_FORMAT`, `ENV.PARSE_ERROR`
- [x] 2.4 Implement `src/errors/error-codes.ts` and auto-register via side-effect import in `src/index.ts`
- [x] 2.5 Zod schemas: `FieldConfigSchema`, `ValidationResultSchema`, `CreateEnvOptionsSchema`, `DotenvLoaderOptionsSchema`, `ObjectLoaderOptionsSchema` — exported from `*.schemas.ts` siblings
- [x] 2.6 Tests: error construction, code narrowing, `{ key, raw?, source? }` context fields on each error subclass
## 3. Core validator (validate + maskSensitive)
- [x] 3.1 Implement `src/core/validator/validator.types.ts` — `ValidateInput`, `ValidationResult`
- [x] 3.2 Implement `src/core/validator/validator.ts` — `validate(input): ValidationResult` (runs all `field._rules`, accumulates failures); `maskSensitive(values, schema): Record<string, string>` (replaces sensitive values with `***`)
- [x] 3.3 Unit tests: all rules passing, partial failure accumulation, all rules failing, sensitive masking
- [x] 3.4 Property-based tests (fast-check): if N rules fail, `result.errors.length === N`; `maskSensitive` never leaks a sensitive raw value
- [x] 3.5 Co-located `src/core/validator/validator.md`
## 4. BaseField abstract class
- [x] 4.1 Implement `src/core/field/field.types.ts` — `FieldConfig<T>`, `RuleOptions`
- [x] 4.2 Implement `src/core/field/field.ts` — `BaseField<T>` abstract class with `_isRequired`, `_hasDefault`, `_defaultValue`, `_isSensitive`, `_description`, `_rules`; fluent builder methods: `optional()`, `default(v)`, `sensitive()`, `describe(s)`, `rules(opts)`; abstract `coerce(raw: string): T`
- [x] 4.3 Unit tests: builder method chaining, `FieldConfig` snapshot, abstract coerce enforcement
- [x] 4.4 Co-located `src/core/field/field.md`
## 5. Fields
- [x] 5.1 `src/fields/string-field/string-field.ts` — `StringField` extends `BaseField<string>`; rules: `minLength`, `maxLength`, `pattern`, `nonempty`; factory `str()`
- [x] 5.2 `src/fields/number-field/number-field.ts` — `NumberField` extends `BaseField<number>`; coerces via `coerceNumber`; rules: `min`, `max`, `integer`; factory `num()`
- [x] 5.3 `src/fields/boolean-field/boolean-field.ts` — `BooleanField` extends `BaseField<boolean>`; coerces via `coerceBoolean`; factory `bool()`
- [x] 5.4 `src/fields/enum-field/enum-field.types.ts` — `EnumValues`; `src/fields/enum-field/enum-field.ts` — `EnumField<V extends EnumValues>` extends `BaseField<V[number]>`; factory `enums(values)`
- [x] 5.5 `src/fields/url-field/url-field.ts` — `UrlField` extends `BaseField<string>`; validates via `new URL()`; optional `protocols` allowlist rule; factory `url()`
- [x] 5.6 `src/fields/email-field/email-field.ts` — `EmailField` extends `BaseField<string>`; validates against `EMAIL_PATTERN`; factory `email()`
- [x] 5.7 `src/fields/port-field/port-field.ts` — `PortField` extends `BaseField<number>`; validates integer in `[PORT_MIN, PORT_MAX]`; factory `port()`
- [x] 5.8 `src/fields/json-field/json-field.types.ts` — `JsonValidator<T>`; `src/fields/json-field/json-field.ts` — `JsonField<T>` extends `BaseField<T>`; coerces via `coerceJson`; accepts optional `JsonValidator<T>`; factory `json<T>(validator?)`
- [x] 5.9 `src/fields/array-field/array-field.types.ts` — `ItemField<T>`; `src/fields/array-field/array-field.ts` — `ArrayField<T>` extends `BaseField<T[]>`; splits on `separator` (default `DEFAULT_ARRAY_SEPARATOR`), maps each item through `itemField`; factory `array(itemField, separator?)`
- [x] 5.10 Unit tests for each field: valid raw, invalid raw → `CoercionError`, all custom rules, default / optional / sensitive combinations
- [x] 5.11 Co-located `.md` for each field file
## 6. Transforms (coerce utilities)
- [x] 6.1 Implement `src/transforms/coerce/coerce.types.ts` — `CoerceFn<T>`, `CoerceEntry<T>`
- [x] 6.2 Implement `src/transforms/coerce/coerce.ts` — `coerceString`, `coerceNumber`, `coerceInteger`, `coerceBoolean`, `coerceArray`, `coerceJson`; each throws `CoercionError` with `ENV.INVALID_FORMAT` on failure
- [x] 6.3 Unit tests: happy-path and all rejection branches for each coerce function
- [x] 6.4 Property-based tests (fast-check): `coerceNumber(raw)` round-trips for all finite float strings; `coerceBoolean` is total over TRUTHY_VALUES ∪ FALSY_VALUES
- [x] 6.5 Co-located `src/transforms/coerce/coerce.md`
## 7. Loaders
- [x] 7.1 `src/loaders/object/object.ts` — `objectLoader(obj, options?)` → `Loader`; `ObjectLoaderOptions`; source label defaults to `'object'`
- [x] 7.2 `src/loaders/dotenv/dotenv.ts` — `parseDotenv(content): Record<string, string>` (pure, throws `DotenvParseError` on malformed lines); `dotenvLoader(options?)` → `Loader` reads file from `options.path` (default `.env`), calls `parseDotenv`; `DotenvLoaderOptions`
- [x] 7.3 Unit tests for `objectLoader`: wraps object, respects source label
- [x] 7.4 Unit tests for `parseDotenv`: quoted values, comments, blank lines, `=` in value, malformed input
- [x] 7.5 Unit tests for `dotenvLoader`: reads real temp file, file-not-found returns empty gracefully (or throws — match main-branch behaviour), `override` flag
- [x] 7.6 Property-based tests (fast-check): `parseDotenv` is pure — same input always produces same output
- [x] 7.7 Co-located `.md` for each loader file
## 8. Utilities — interpolation + error factory
- [x] 8.1 `src/utils/interpolation.ts` — `interpolate(template, values)`, `hasInterpolation(value)`, `extractVariables(template)`; uses `INTERPOLATION_PATTERN`
- [x] 8.2 `src/utils/errors.ts` — `createEnvError(key, message, value?)` factory returning `EnvError` value object (not the class — the plain interface shape used by `CollectedErrors`)
- [x] 8.3 Unit tests for all interpolation functions and the error factory
- [x] 8.4 Co-located `.md` for each utility file
## 9. createEnv orchestrator
- [x] 9.1 Implement `src/core/create-env/create-env.types.ts` — `CollectedErrors`, `EnvResult<S>`
- [x] 9.2 Implement `src/core/create-env/create-env.ts` — `createEnv(schema, options?)`:
- Runs each `Loader` in `options.loaders` in order, merging `LoaderResult.values` (later loaders win unless `override: false`)
- Expands `${VAR}` interpolation tokens in raw values
- For each schema key: resolves raw string → calls `coerce` → runs `validate` → checks `isRequired`
- In `'throw'` mode: throws `EnvValidationError` or `MissingEnvError` on first failure
- In `'collect'` mode: accumulates all failures into `CollectedErrors`, returns partial result
- In `'warn'` mode: logs via `console.warn`, returns partial result
- Calls `maskSensitive` before returning
- Returns `EnvResult<S>` (typed record)
- [x] 9.3 Unit tests: full schema resolution, missing required, coercion error, collect mode accumulation, throw mode, warn mode, multi-loader merge order, interpolation expansion
- [x] 9.4 Co-located `src/core/create-env/create-env.md`
## 10. Presets
- [x] 10.1 `src/presets/common/common.ts` — `nodeEnvField`, `portField`, `hostField`, `logLevelField`, `debugField`, `appNameField`, `appVersionField`, `timezoneField`, `workersField`, `commonFields`
- [x] 10.2 `src/presets/server/server.ts` — `databaseUrlField`, `redisUrlField`, `sessionSecretField`, `corsOriginField`, `rateLimitMaxField`, `rateLimitWindowField`, `trustProxyField`, `smtpHostField`, `smtpPortField`, `smtpUserField`, `smtpPassField`, `smtpFromField`, `serverFields`
- [x] 10.3 Unit tests: each preset field resolves correctly from a matching env object; `commonFields` and `serverFields` composites contain all expected keys
- [x] 10.4 Co-located `.md` for each preset file
## 11. OTel instrumentation
- [x] 11.1 Add `@opentelemetry/api` to `dependencies` in `package.json`
- [x] 11.2 Wire counter `env.validation.total` (label `outcome: 'success' | 'error'`) in `createEnv` — incremented once per call
- [x] 11.3 Wire counter `env.validation.errors` (label `field`) in `createEnv` — incremented once per field that fails validation
- [x] 11.4 Wire histogram `env.load.duration_ms` — duration of the full `createEnv` call
- [x] 11.5 All meters obtained from `@opentelemetry/api` `getMeter`; no-op without exporter (verified in test with no SDK registered)
- [x] 11.6 Unit test: metrics instruments are created without error when no exporter is present
## 12. Property tests + benchmarks
- [x] 12.1 Property-based tests (fast-check) for field parsers round-trip (valid raw → coerce → stable value)
- [x] 12.2 Property test: `maskSensitive` never leaks raw values for sensitive fields
- [x] 12.3 Property test: `validate` accumulates all N failing rules
- [x] 12.4 Property test: `parseDotenv` is pure and idempotent
- [x] 12.5 `src/__benchmarks__/create-env.bench.ts` — `createEnv` with 20-field schema, `objectLoader`, no errors; throughput via mitata
- [x] 12.6 `src/__benchmarks__/validate.bench.ts` — `validate` hot path: single field with 5 passing rules; iterations via mitata
- [x] 12.7 Publish baseline numbers in README
## 13. Docs
- [x] 13.1 Co-located `.md` for every implementation file (≥200 words, simple + advanced examples, API reference, implementation notes)
- [x] 13.2 Rewrite `README.md` with public API table, loader usage, preset reference, error code table, OTel metric names, benchmark numbers, consumer quick-start
- [x] 13.3 `bun run docs:check --filter=@suaveplan/env` ≥ 90%
## 14. Verify and publish
- [x] 14.1 `bunx turbo lint typecheck test --filter=@suaveplan/env` green
- [x] 14.2 `bunx turbo test:coverage --filter=@suaveplan/env` at 100/100/100/100
- [x] 14.3 `bunx turbo build --filter=@suaveplan/env` green
- [x] 14.4 `bun scripts/publish.ts --package @suaveplan/env` to Verdaccio at 0.0.0
- [x] 14.5 Archive OpenSpec change to `openspec/archive/2026-04-19-enterprise-env/`