@bymax-one/nest-config 1.2.3 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,77 @@ as the GitHub Release body, so each released version needs a matching
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.4] - 2026-11-01
15
+
16
+ ### Fixed
17
+
18
+ - **A `source` that is present but `undefined` fails the boot.** `BymaxConfigModule.forRoot({ schema,
19
+ source: loadSource() })` used to validate `process.env` when the loader returned nothing, and boot
20
+ successfully on configuration the host never meant to use. Passing the key with the value `undefined` or
21
+ `null` now throws a `TypeError` ("source was provided but is undefined or null"); omitting the key still reads
22
+ `process.env`.
23
+
24
+ #### Apply to a derived backend
25
+
26
+ Search for a `source:` option that can be `undefined`. If the host relied on the fall-back to
27
+ `process.env`, leave the key out instead of passing `undefined`.
28
+
29
+ - **A `meta({ env })` that cannot name a variable is an issue.** An `env` that is present but is not
30
+ a non-empty string (`''`, `undefined`, a number) used to be skipped, and the leaf read the variable
31
+ derived from its path. The aggregated validation report now lists it, naming the variable the leaf
32
+ would read and the kind of value found, on any layer of the leaf: an inner layer whose `env` is
33
+ unusable is reported even when an outer layer's usable one wins. The issue is its own line for
34
+ that leaf, before the line about its value. A leaf with no `env` at all is unchanged, and
35
+ `resolveSourceNames` still answers the derived name.
36
+
37
+ #### Apply to a derived backend
38
+
39
+ Search the schemas for `meta({ env: ... })` whose argument can be empty or undefined (for example
40
+ `meta({ env: process.env.X })`) and give it a real name or remove the call.
41
+
42
+ - **A test override that matches no leaf throws.** `createTestConfig` and `configTestingModule` used to
43
+ drop an override for a path the schema does not declare (a misspelled leaf, an undeclared namespace,
44
+ a stale override after a rename) and answer with a config that lacked it. They now throw a
45
+ `BymaxConfigValidationError` listing every unmatched path, before any provider is built, even when
46
+ the override's value is `undefined`. The path is the name shown in the report, and its footer asks
47
+ for a fix in the test rather than a restart of the process. A namespace given something other than
48
+ an object is reported once, by name, instead of once per character.
49
+
50
+ #### Apply to a derived backend
51
+
52
+ Run the test suites: an override that used to be silently ignored now fails. Correct the path or
53
+ delete the override.
54
+
55
+ ### Documentation
56
+
57
+ - A leaf that ends in `.catch(fallback)` replaces an invalid value with its fallback and reports no
58
+ issue; the README now says so.
59
+
60
+ ### Changed
61
+
62
+ - **A release that changes the public declarations must say so.** `check:api`
63
+ (`scripts/check-api-surface.mjs`) compares the built declarations with the previous published
64
+ version and fails when they differ unless the released version's CHANGELOG section has an
65
+ "Apply to a derived backend" note. Tooling only: nothing to apply in a derived backend.
66
+ - **The mutation gate is 100.** `thresholds.break` moves from 95 to 100, and `high` and `low` with it,
67
+ because a cold run on `main` now has no survivor. Tooling only: nothing to apply in
68
+ a derived backend.
69
+ - **The camelCase-to-variable converter has no equivalent mutant.** The acronym boundary is now a
70
+ zero-width lookaround instead of a capturing group with a quantifier whose shrunken form no input
71
+ could tell apart. Every derived variable name is unchanged: a new spec compares the converter
72
+ with the previous two-expression form on every string up to seven characters over
73
+ `a`, `B`, `C`, `1` and `_`. Nothing to apply in a derived backend.
74
+
75
+ ### Tests
76
+
77
+ - **The README Quick Start is executed, not only type-checked.** A new spec takes the schema, the
78
+ `BymaxConfigModule.forRoot` registration and the `FeatureProvider` from the README text,
79
+ compiles and runs them against a real Nest testing graph, and asserts the stated outcomes: a
80
+ complete environment boots and the provider returns `<database url>::3000`, and an environment
81
+ missing both required variables fails with one error naming both. A wrong argument shape or a
82
+ value that fails validation now fails a test instead of reaching a reader. No source or
83
+ published-surface change, so there is nothing to apply in a derived backend.
84
+
14
85
  ## [1.2.3] - 2026-10-31
15
86
 
16
87
  ### Fixed
@@ -484,4 +555,5 @@ have regressed from. They are kept because the reasoning is worth having.
484
555
  [1.2.1]: https://github.com/bymaxone/nest-config/compare/v1.2.0...v1.2.1
485
556
  [1.2.2]: https://github.com/bymaxone/nest-config/compare/v1.2.1...v1.2.2
486
557
  [1.2.3]: https://github.com/bymaxone/nest-config/compare/v1.2.2...v1.2.3
487
- [Unreleased]: https://github.com/bymaxone/nest-config/compare/v1.2.3...HEAD
558
+ [1.2.4]: https://github.com/bymaxone/nest-config/compare/v1.2.3...v1.2.4
559
+ [Unreleased]: https://github.com/bymaxone/nest-config/compare/v1.2.4...HEAD
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  <a href="https://www.npmjs.com/package/@bymax-one/nest-config"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-config?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
15
15
  <a href="https://github.com/bymaxone/nest-config/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-config/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
16
16
  <a href="https://github.com/bymaxone/nest-config/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square&colorA=000000" alt="coverage" /></a>
17
- <a href="https://github.com/bymaxone/nest-config/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-99.71%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
17
+ <a href="https://github.com/bymaxone/nest-config/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-100.00%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
18
18
  <a href="https://scorecard.dev/viewer/?uri=github.com/bymaxone/nest-config"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-config/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
19
19
  <a href="https://github.com/bymaxone/nest-config/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-config?style=flat-square&colorA=000000&colorB=000000" alt="license" /></a>
20
20
  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
@@ -206,8 +206,10 @@ BymaxConfigValidationError: environment validation failed (3 issues)
206
206
  Fix the variables above and restart the process.
207
207
  ```
208
208
 
209
- The report has one line per variable: when a leaf fails several rules, the line shows the first
210
- one Zod lists, and fixing it can reveal the next on the following start.
209
+ The report has one line per violation: when a leaf fails several rules, the line shows the first
210
+ one Zod lists, and fixing it can reveal the next on the following start. A `meta({ env })` that
211
+ cannot name a variable adds its own line for that leaf, before the line about its value, because it
212
+ is a different mistake: merging the two would hide the one that explains the other.
211
213
 
212
214
  In a message this package generates, raw values are never printed — not
213
215
  truncated, not masked, absent. That is a hard guarantee, verified by tests. The
@@ -265,6 +267,10 @@ including `'false'`, becomes `true`, so use `z.stringbool()` for a flag. And a v
265
267
  empty string is present, not missing: `z.string()` accepts it and `z.coerce.number()` turns it into
266
268
  `0`, so add `.min(1)` or a bound where empty should fail.
267
269
 
270
+ A leaf that ends in `.catch(fallback)` replaces an invalid value with its fallback and reports no
271
+ issue, so a mistyped variable reaches the application as the fallback; leave `.catch()` off a
272
+ leaf whose failure should stop the boot.
273
+
268
274
  A leaf can override its derived variable name through schema metadata when a
269
275
  legacy name must be preserved:
270
276
 
@@ -276,9 +282,12 @@ database: z.object({
276
282
  ```
277
283
 
278
284
  The override is found wherever `meta` sits in the chain: `z.string().meta({ env: 'LEGACY' }).default('x')`
279
- and `z.string().default('x').meta({ env: 'LEGACY' })` both read `LEGACY`. When more than one layer sets it, the outermost usable one wins, and an
280
- empty or non-string value is skipped. `resolveSourceNames` answers the same, because validation
281
- reads its variables through it.
285
+ and `z.string().default('x').meta({ env: 'LEGACY' })` both read `LEGACY`. When more than one layer sets it, the outermost usable one wins. An `env` that is present but
286
+ is not a non-empty string (`''`, `undefined`, a number) cannot name a variable, so validation reports it
287
+ as an issue instead of letting the leaf read the variable derived from its path, on any layer: an
288
+ inner layer whose `env` is unusable is reported even when an outer layer's usable one wins. A leaf
289
+ with no `env` at all is unaffected. `resolveSourceNames` skips an unusable `env` and answers the
290
+ derived name.
282
291
 
283
292
  A namespace can declare itself **open**, which waives strict unknown-key
284
293
  detection for the rest of its prefix. Use it when the prefix is shared with
@@ -309,7 +318,7 @@ Write `.meta({ open: true })` last: methods that build a new object schema, such
309
318
  | Field | Type | Required | Notes |
310
319
  | ------------------- | ------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
311
320
  | `schema` | `EnvSchema` | yes | Produced by `defineEnv`. |
312
- | `source` | `Record<string, string \| undefined>` | no | Defaults to `process.env` in the provider factory. Injectable for tests. |
321
+ | `source` | `Record<string, string \| undefined>` | no | Defaults to `process.env` when the key is omitted; a key present with the value `undefined` or `null` throws at boot. Injectable for tests. |
313
322
  | `onValidationError` | `(issues: ReadonlyArray<ConfigIssue>) => unknown` | no | Observability hook invoked before the fail-fast throw; a promise it returns is awaited. Cannot suppress the failure. |
314
323
  | `strict` | `boolean` | no | When true, source variables matching a namespace prefix but no declared leaf raise `BYMAX_CONFIG_UNKNOWN_KEY`. Defaults to `false`. Read [Strict mode and shared prefixes](#strict-mode-and-shared-prefixes) before enabling it. |
315
324
 
@@ -604,7 +613,8 @@ resolves its variables through, so the list cannot drift from what `forRoot` rea
604
613
  `SourceBinding` is `{ path: string; variable: string }`: the leaf's dot path and the
605
614
  environment variable it reads. The list has one entry per declared leaf, in declaration
606
615
  order. A leaf reads its `meta({ env })` override when that is a non-empty string, and
607
- otherwise `NAMESPACE_LEAF` in SCREAMING_SNAKE_CASE, with a camelCase key split into words.
616
+ `NAMESPACE_LEAF` in SCREAMING_SNAKE_CASE, with a camelCase key split into words, when it has no
617
+ `env` at all.
608
618
  It reads the schema only, never a source record, so it works on a schema exported as a
609
619
  value.
610
620
 
@@ -816,9 +826,8 @@ Configuration decides whether a process may start at all, so the suite is held t
816
826
  beyond "the tests pass".
817
827
 
818
828
  - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
819
- - ✅ **99.71% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
820
- `break: 95`; ten provable equivalents carry their reason inline, and the one survivor left
821
- is an equivalent no directive can reach, so it stays counted rather than silenced
829
+ - ✅ **100.00% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
830
+ `break: 100`; provable equivalents carry their reason inline and nothing else survives
822
831
  ([report](./docs/mutation_testing_results.md))
823
832
  - ✅ **The value-free guarantee is tested, not assumed** — a sentinel secret is placed in a
824
833
  source and the assertion is that it appears nowhere in the rendered report or in the thrown
@@ -827,7 +836,8 @@ beyond "the tests pass".
827
836
  proven is the guarantee this package makes for its own generated text
828
837
  - ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
829
838
  system does, `check:runtime` loads every subpath from the packed tarball in ESM and
830
- CommonJS, and `check:published` compiles this README's snippets against `dist/`
839
+ CommonJS, `check:published` compiles this README's snippets against `dist/`, and `check:api`
840
+ requires an "Apply to a derived backend" CHANGELOG note when the built declarations change
831
841
  - ✅ **Every suppression carries its reason** — no coverage directives anywhere; each
832
842
  `// Stryker disable` in the production source names, after the `:` Stryker reads it from,
833
843
  why the mutant it silences is behaviour-preserving, and `check:mutants` proves those
@@ -837,7 +847,7 @@ beyond "the tests pass".
837
847
  ```bash
838
848
  pnpm test # unit suite
839
849
  pnpm test:cov # unit suite with the 100% coverage gate
840
- pnpm mutation # Stryker mutation testing (break: 95)
850
+ pnpm mutation # Stryker mutation testing (break: 100)
841
851
  pnpm typecheck # tsc strict check
842
852
  pnpm lint # ESLint
843
853
  ```
@@ -901,7 +911,9 @@ const moduleRef = await Test.createTestingModule({
901
911
 
902
912
  Overrides mirror the `namespace.leaf` shape with every key optional and typed from the schema. Each value is converted with `String(value)` before validation, so give
903
913
  a leaf what its environment variable would hold; an object becomes `'[object Object]'`, and an override
904
- of `undefined` is ignored, so a defaulted leaf cannot be unset. Neither helper takes `strict`, a
914
+ of `undefined` is ignored, so a defaulted leaf cannot be unset. An override whose path matches no leaf
915
+ of the schema throws, listing every such path, even when its value is `undefined`; a namespace given
916
+ something other than an object is reported once, by name. Neither helper takes `strict`, a
905
917
  custom `source`, a hook or `isGlobal`: `configTestingModule` registers the module globally, and a
906
918
  failure case (a missing variable, a strict violation) is built with
907
919
  `BymaxConfigModule.forRoot({ schema, source, strict: true })` and a source you write.
@@ -53,25 +53,30 @@ var MIN_VARIABLE_COLUMN = 20;
53
53
  var VARIABLE_GUTTER = 2;
54
54
  var REPORT_HEADER = "environment validation failed";
55
55
  var REPORT_FOOTER = "Fix the variables above and restart the process.";
56
+ var OVERRIDE_FOOTER = "Fix the overrides above in the test.";
56
57
  function pluralizeIssues(count) {
57
58
  const noun = count === 1 ? "issue" : "issues";
58
59
  return `${count} ${noun}`;
59
60
  }
61
+ function nameOf(issue) {
62
+ return issue.variable === "" ? issue.path : issue.variable;
63
+ }
60
64
  function computeColumnWidth(issues) {
61
65
  let longest = MIN_VARIABLE_COLUMN;
62
66
  for (const issue of issues) {
63
- longest = Math.max(longest, issue.variable.length);
67
+ longest = Math.max(longest, nameOf(issue).length);
64
68
  }
65
69
  return longest + VARIABLE_GUTTER;
66
70
  }
67
71
  function formatIssueLine(issue, columnWidth) {
68
- return `${REPORT_INDENT}${issue.variable.padEnd(columnWidth)}${issue.message}`;
72
+ return `${REPORT_INDENT}${nameOf(issue).padEnd(columnWidth)}${issue.message}`;
69
73
  }
70
74
  function formatIssueReport(issues) {
71
75
  const columnWidth = computeColumnWidth(issues);
72
76
  const lines = issues.map((issue) => formatIssueLine(issue, columnWidth));
73
77
  const header = `${REPORT_HEADER} (${pluralizeIssues(issues.length)})`;
74
- return [header, "", ...lines, "", REPORT_FOOTER].join("\n");
78
+ const aboutOverrides = issues.length > 0 && issues.every((issue) => issue.variable === "");
79
+ return [header, "", ...lines, "", aboutOverrides ? OVERRIDE_FOOTER : REPORT_FOOTER].join("\n");
75
80
  }
76
81
 
77
82
  // src/errors.ts
@@ -119,7 +124,7 @@ var BymaxConfigValidationError = class _BymaxConfigValidationError extends Error
119
124
 
120
125
  // src/source-mapping.ts
121
126
  function toScreamingSnake(segment) {
122
- return segment.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").toUpperCase();
127
+ return segment.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/(?<=[A-Z])(?=[A-Z][a-z])/g, "_").toUpperCase();
123
128
  }
124
129
  function readEnvOverride(leaf) {
125
130
  for (let node = leaf; node !== void 0; ) {
@@ -143,6 +148,33 @@ function resolveSourceNames(schema) {
143
148
  }))
144
149
  );
145
150
  }
151
+ function describeUnusable(value) {
152
+ if (value === "") return "an empty string";
153
+ if (value === void 0) return "undefined";
154
+ if (value === null) return "null";
155
+ return `${typeof value === "object" ? "an" : "a"} ${typeof value}`;
156
+ }
157
+ function describeUnusableEnv(leaf) {
158
+ for (let node = leaf; node !== void 0; ) {
159
+ const meta = node.meta();
160
+ if (meta !== void 0 && "env" in meta) {
161
+ const value = meta.env;
162
+ if (typeof value !== "string" || value.length === 0) return describeUnusable(value);
163
+ }
164
+ node = node._zod.def.innerType ?? node._zod.def.in;
165
+ }
166
+ return void 0;
167
+ }
168
+ function findUnusableEnvOverrides(schema) {
169
+ const unusable = [];
170
+ for (const [namespace, namespaceSchema] of Object.entries(schema.shape)) {
171
+ for (const [leafKey, leafSchema] of Object.entries(namespaceSchema.shape)) {
172
+ const found = describeUnusableEnv(leafSchema);
173
+ if (found !== void 0) unusable.push({ path: `${namespace}.${leafKey}`, found });
174
+ }
175
+ }
176
+ return unusable;
177
+ }
146
178
  function resolveNamespacePrefixes(schema) {
147
179
  return Object.entries(schema.shape).map(([namespace, namespaceSchema]) => ({
148
180
  namespace,
@@ -302,6 +334,18 @@ function assertDistinctVariables(bindings) {
302
334
  }
303
335
  if (issues.length > 0) throw new BymaxConfigValidationError(issues);
304
336
  }
337
+ function unusableOverrideIssues(schema, bindings) {
338
+ const variables = new Map(bindings.map((binding) => [binding.path, binding.variable]));
339
+ return findUnusableEnvOverrides(schema).map(({ path, found }) => {
340
+ const variable = variables.get(path);
341
+ return {
342
+ path,
343
+ variable,
344
+ code: ConfigErrorCode.INVALID,
345
+ message: `meta({ env }) must be a non-empty string, found ${found}; the leaf would read ${variable}`
346
+ };
347
+ });
348
+ }
305
349
  function validateEnv(schema, source, options) {
306
350
  const bindings = resolveSourceNames(schema);
307
351
  assertDistinctVariables(bindings);
@@ -311,12 +355,13 @@ function validateEnv(schema, source, options) {
311
355
  const result = schema.safeParse(candidate);
312
356
  const prefixes = resolveNamespacePrefixes(schema);
313
357
  const unknown = options?.strict === true ? detectUnknownKeys(prefixes, bindings, sourceMap) : [];
358
+ const overrides = unusableOverrideIssues(schema, bindings);
314
359
  if (result.success) {
315
- if (unknown.length === 0) return result.data;
316
- throw new BymaxConfigValidationError(unknown);
360
+ if (unknown.length === 0 && overrides.length === 0) return result.data;
361
+ throw new BymaxConfigValidationError([...overrides, ...unknown]);
317
362
  }
318
363
  const issues = translateIssues(result.error.issues, bindings, prefixes, sourceMap);
319
- throw new BymaxConfigValidationError([...issues, ...unknown]);
364
+ throw new BymaxConfigValidationError([...overrides, ...issues, ...unknown]);
320
365
  }
321
366
 
322
367
  // src/config.providers.ts
@@ -334,6 +379,11 @@ async function reportIssues(hook, error) {
334
379
  }
335
380
  }
336
381
  function createValidatedConfig(options) {
382
+ if ("source" in options && (options.source === void 0 || options.source === null)) {
383
+ throw new TypeError(
384
+ "BymaxConfigModule: source was provided but is undefined or null; omit the key to validate process.env, or pass the source that failed to load"
385
+ );
386
+ }
337
387
  const source = options.source ?? process.env;
338
388
  return deepFreeze(validateEnv(options.schema, source, { strict: options.strict === true }));
339
389
  }
@@ -541,3 +591,4 @@ exports.assertDistinctVariables = assertDistinctVariables;
541
591
  exports.createValidatedConfig = createValidatedConfig;
542
592
  exports.defineEnv = defineEnv;
543
593
  exports.resolveSourceNames = resolveSourceNames;
594
+ exports.unusableOverrideIssues = unusableOverrideIssues;
@@ -557,6 +557,8 @@ declare function defineEnv<TShape extends EnvShape>(shape: TShape): EnvSchema<TS
557
557
  * @param options - The resolved module options (schema, source, strict).
558
558
  * @returns The validated, deep-frozen configuration output.
559
559
  * @throws {BymaxConfigValidationError} When the source fails the schema.
560
+ * @throws {TypeError} When `source` is present with the value `undefined` or `null`, which would
561
+ * otherwise validate `process.env` in place of the source that failed to load.
560
562
  * @example
561
563
  * ```typescript
562
564
  * const config = createValidatedConfig({ schema: envSchema });
@@ -644,5 +646,17 @@ declare function resolveSourceNames<TShape extends EnvShape = EnvShape>(schema:
644
646
  * @throws {BymaxConfigValidationError} When any variable is bound by more than one leaf.
645
647
  */
646
648
  declare function assertDistinctVariables(bindings: ReadonlyArray<SourceBinding>): void;
649
+ /**
650
+ * Describe every leaf whose `meta({ env })` is present but cannot name a variable.
651
+ *
652
+ * The leaf would silently read the variable derived from its path, so it is a mistake in the
653
+ * schema, reported beside the violations of the source. The message names the variable the
654
+ * leaf reads and the kind of value `env` held, never the value.
655
+ *
656
+ * @param schema - A schema produced by `defineEnv`.
657
+ * @param bindings - Every leaf binding of the schema, from `resolveSourceNames`.
658
+ * @returns One issue per affected leaf, in schema order.
659
+ */
660
+ declare function unusableOverrideIssues<TShape extends EnvShape>(schema: EnvSchema<TShape>, bindings: ReadonlyArray<SourceBinding>): ConfigIssue[];
647
661
 
648
- export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, type BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, type ConfigIssue, type ConfigIssueCode, ConfigService, type ConfigValidationCode, type EnvLeaf, type EnvNamespace, type EnvOutput, type EnvSchema, type EnvShape, type Path, type PathValue, type SourceBinding, assertDistinctVariables, createValidatedConfig, defineEnv, resolveSourceNames };
662
+ export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, type BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, type ConfigIssue, type ConfigIssueCode, ConfigService, type ConfigValidationCode, type EnvLeaf, type EnvNamespace, type EnvOutput, type EnvSchema, type EnvShape, type Path, type PathValue, type SourceBinding, assertDistinctVariables, createValidatedConfig, defineEnv, resolveSourceNames, unusableOverrideIssues };
@@ -557,6 +557,8 @@ declare function defineEnv<TShape extends EnvShape>(shape: TShape): EnvSchema<TS
557
557
  * @param options - The resolved module options (schema, source, strict).
558
558
  * @returns The validated, deep-frozen configuration output.
559
559
  * @throws {BymaxConfigValidationError} When the source fails the schema.
560
+ * @throws {TypeError} When `source` is present with the value `undefined` or `null`, which would
561
+ * otherwise validate `process.env` in place of the source that failed to load.
560
562
  * @example
561
563
  * ```typescript
562
564
  * const config = createValidatedConfig({ schema: envSchema });
@@ -644,5 +646,17 @@ declare function resolveSourceNames<TShape extends EnvShape = EnvShape>(schema:
644
646
  * @throws {BymaxConfigValidationError} When any variable is bound by more than one leaf.
645
647
  */
646
648
  declare function assertDistinctVariables(bindings: ReadonlyArray<SourceBinding>): void;
649
+ /**
650
+ * Describe every leaf whose `meta({ env })` is present but cannot name a variable.
651
+ *
652
+ * The leaf would silently read the variable derived from its path, so it is a mistake in the
653
+ * schema, reported beside the violations of the source. The message names the variable the
654
+ * leaf reads and the kind of value `env` held, never the value.
655
+ *
656
+ * @param schema - A schema produced by `defineEnv`.
657
+ * @param bindings - Every leaf binding of the schema, from `resolveSourceNames`.
658
+ * @returns One issue per affected leaf, in schema order.
659
+ */
660
+ declare function unusableOverrideIssues<TShape extends EnvShape>(schema: EnvSchema<TShape>, bindings: ReadonlyArray<SourceBinding>): ConfigIssue[];
647
661
 
648
- export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, type BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, type ConfigIssue, type ConfigIssueCode, ConfigService, type ConfigValidationCode, type EnvLeaf, type EnvNamespace, type EnvOutput, type EnvSchema, type EnvShape, type Path, type PathValue, type SourceBinding, assertDistinctVariables, createValidatedConfig, defineEnv, resolveSourceNames };
662
+ export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, type BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, type ConfigIssue, type ConfigIssueCode, ConfigService, type ConfigValidationCode, type EnvLeaf, type EnvNamespace, type EnvOutput, type EnvSchema, type EnvShape, type Path, type PathValue, type SourceBinding, assertDistinctVariables, createValidatedConfig, defineEnv, resolveSourceNames, unusableOverrideIssues };
@@ -51,25 +51,30 @@ var MIN_VARIABLE_COLUMN = 20;
51
51
  var VARIABLE_GUTTER = 2;
52
52
  var REPORT_HEADER = "environment validation failed";
53
53
  var REPORT_FOOTER = "Fix the variables above and restart the process.";
54
+ var OVERRIDE_FOOTER = "Fix the overrides above in the test.";
54
55
  function pluralizeIssues(count) {
55
56
  const noun = count === 1 ? "issue" : "issues";
56
57
  return `${count} ${noun}`;
57
58
  }
59
+ function nameOf(issue) {
60
+ return issue.variable === "" ? issue.path : issue.variable;
61
+ }
58
62
  function computeColumnWidth(issues) {
59
63
  let longest = MIN_VARIABLE_COLUMN;
60
64
  for (const issue of issues) {
61
- longest = Math.max(longest, issue.variable.length);
65
+ longest = Math.max(longest, nameOf(issue).length);
62
66
  }
63
67
  return longest + VARIABLE_GUTTER;
64
68
  }
65
69
  function formatIssueLine(issue, columnWidth) {
66
- return `${REPORT_INDENT}${issue.variable.padEnd(columnWidth)}${issue.message}`;
70
+ return `${REPORT_INDENT}${nameOf(issue).padEnd(columnWidth)}${issue.message}`;
67
71
  }
68
72
  function formatIssueReport(issues) {
69
73
  const columnWidth = computeColumnWidth(issues);
70
74
  const lines = issues.map((issue) => formatIssueLine(issue, columnWidth));
71
75
  const header = `${REPORT_HEADER} (${pluralizeIssues(issues.length)})`;
72
- return [header, "", ...lines, "", REPORT_FOOTER].join("\n");
76
+ const aboutOverrides = issues.length > 0 && issues.every((issue) => issue.variable === "");
77
+ return [header, "", ...lines, "", aboutOverrides ? OVERRIDE_FOOTER : REPORT_FOOTER].join("\n");
73
78
  }
74
79
 
75
80
  // src/errors.ts
@@ -117,7 +122,7 @@ var BymaxConfigValidationError = class _BymaxConfigValidationError extends Error
117
122
 
118
123
  // src/source-mapping.ts
119
124
  function toScreamingSnake(segment) {
120
- return segment.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").toUpperCase();
125
+ return segment.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/(?<=[A-Z])(?=[A-Z][a-z])/g, "_").toUpperCase();
121
126
  }
122
127
  function readEnvOverride(leaf) {
123
128
  for (let node = leaf; node !== void 0; ) {
@@ -141,6 +146,33 @@ function resolveSourceNames(schema) {
141
146
  }))
142
147
  );
143
148
  }
149
+ function describeUnusable(value) {
150
+ if (value === "") return "an empty string";
151
+ if (value === void 0) return "undefined";
152
+ if (value === null) return "null";
153
+ return `${typeof value === "object" ? "an" : "a"} ${typeof value}`;
154
+ }
155
+ function describeUnusableEnv(leaf) {
156
+ for (let node = leaf; node !== void 0; ) {
157
+ const meta = node.meta();
158
+ if (meta !== void 0 && "env" in meta) {
159
+ const value = meta.env;
160
+ if (typeof value !== "string" || value.length === 0) return describeUnusable(value);
161
+ }
162
+ node = node._zod.def.innerType ?? node._zod.def.in;
163
+ }
164
+ return void 0;
165
+ }
166
+ function findUnusableEnvOverrides(schema) {
167
+ const unusable = [];
168
+ for (const [namespace, namespaceSchema] of Object.entries(schema.shape)) {
169
+ for (const [leafKey, leafSchema] of Object.entries(namespaceSchema.shape)) {
170
+ const found = describeUnusableEnv(leafSchema);
171
+ if (found !== void 0) unusable.push({ path: `${namespace}.${leafKey}`, found });
172
+ }
173
+ }
174
+ return unusable;
175
+ }
144
176
  function resolveNamespacePrefixes(schema) {
145
177
  return Object.entries(schema.shape).map(([namespace, namespaceSchema]) => ({
146
178
  namespace,
@@ -300,6 +332,18 @@ function assertDistinctVariables(bindings) {
300
332
  }
301
333
  if (issues.length > 0) throw new BymaxConfigValidationError(issues);
302
334
  }
335
+ function unusableOverrideIssues(schema, bindings) {
336
+ const variables = new Map(bindings.map((binding) => [binding.path, binding.variable]));
337
+ return findUnusableEnvOverrides(schema).map(({ path, found }) => {
338
+ const variable = variables.get(path);
339
+ return {
340
+ path,
341
+ variable,
342
+ code: ConfigErrorCode.INVALID,
343
+ message: `meta({ env }) must be a non-empty string, found ${found}; the leaf would read ${variable}`
344
+ };
345
+ });
346
+ }
303
347
  function validateEnv(schema, source, options) {
304
348
  const bindings = resolveSourceNames(schema);
305
349
  assertDistinctVariables(bindings);
@@ -309,12 +353,13 @@ function validateEnv(schema, source, options) {
309
353
  const result = schema.safeParse(candidate);
310
354
  const prefixes = resolveNamespacePrefixes(schema);
311
355
  const unknown = options?.strict === true ? detectUnknownKeys(prefixes, bindings, sourceMap) : [];
356
+ const overrides = unusableOverrideIssues(schema, bindings);
312
357
  if (result.success) {
313
- if (unknown.length === 0) return result.data;
314
- throw new BymaxConfigValidationError(unknown);
358
+ if (unknown.length === 0 && overrides.length === 0) return result.data;
359
+ throw new BymaxConfigValidationError([...overrides, ...unknown]);
315
360
  }
316
361
  const issues = translateIssues(result.error.issues, bindings, prefixes, sourceMap);
317
- throw new BymaxConfigValidationError([...issues, ...unknown]);
362
+ throw new BymaxConfigValidationError([...overrides, ...issues, ...unknown]);
318
363
  }
319
364
 
320
365
  // src/config.providers.ts
@@ -332,6 +377,11 @@ async function reportIssues(hook, error) {
332
377
  }
333
378
  }
334
379
  function createValidatedConfig(options) {
380
+ if ("source" in options && (options.source === void 0 || options.source === null)) {
381
+ throw new TypeError(
382
+ "BymaxConfigModule: source was provided but is undefined or null; omit the key to validate process.env, or pass the source that failed to load"
383
+ );
384
+ }
335
385
  const source = options.source ?? process.env;
336
386
  return deepFreeze(validateEnv(options.schema, source, { strict: options.strict === true }));
337
387
  }
@@ -531,4 +581,4 @@ function defineEnv(shape) {
531
581
  return z.object(shape);
532
582
  }
533
583
 
534
- export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigValidationError, ConfigErrorCode, ConfigService, assertDistinctVariables, createValidatedConfig, defineEnv, resolveSourceNames };
584
+ export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigValidationError, ConfigErrorCode, ConfigService, assertDistinctVariables, createValidatedConfig, defineEnv, resolveSourceNames, unusableOverrideIssues };
@@ -167,19 +167,49 @@ function synthesizePlaceholderSource(schema) {
167
167
  }
168
168
 
169
169
  // src/testing/create-test-config.ts
170
- function flattenOverrides(overrides) {
170
+ function unplacedIssue(path, code, message) {
171
+ return { path, variable: "", code, message };
172
+ }
173
+ function flattenOverrides(overrides, declared) {
171
174
  const flat = /* @__PURE__ */ new Map();
175
+ const unplaced = [];
172
176
  for (const [namespace, leaves] of Object.entries(overrides)) {
177
+ if (typeof leaves !== "object") {
178
+ unplaced.push(
179
+ unplacedIssue(
180
+ namespace,
181
+ internal.ConfigErrorCode.INVALID,
182
+ `the override for \`${namespace}\` must be an object`
183
+ )
184
+ );
185
+ continue;
186
+ }
173
187
  for (const [leaf, value] of Object.entries(leaves)) {
174
- flat.set(`${namespace}.${leaf}`, value);
188
+ const path = `${namespace}.${leaf}`;
189
+ if (declared.has(path)) {
190
+ flat.set(path, value);
191
+ } else {
192
+ unplaced.push(
193
+ unplacedIssue(
194
+ path,
195
+ internal.ConfigErrorCode.UNKNOWN_KEY,
196
+ "no leaf of the schema is at this path; the override was not applied"
197
+ )
198
+ );
199
+ }
175
200
  }
176
201
  }
177
- return flat;
202
+ return { flat, unplaced };
178
203
  }
179
204
  function applyOverrides(schema, base, overrides, overridden) {
180
- const flat = flattenOverrides(overrides ?? {});
205
+ const bindings = internal.resolveSourceNames(schema);
206
+ const { flat, unplaced } = flattenOverrides(
207
+ overrides ?? {},
208
+ new Set(bindings.map((binding) => binding.path))
209
+ );
210
+ if (unplaced.length > 0) throw new internal.BymaxConfigValidationError(unplaced);
181
211
  const entries = new Map(Object.entries(base));
182
- for (const binding of internal.resolveSourceNames(schema)) {
212
+ for (const binding of bindings) {
183
213
  const value = flat.get(binding.path);
184
214
  if (value === void 0) continue;
185
215
  entries.set(binding.variable, String(value));
@@ -200,7 +230,10 @@ function explainUnsatisfiedLeaves(error, overridden) {
200
230
  );
201
231
  }
202
232
  function prepareTestConfig(schema, overrides) {
203
- internal.assertDistinctVariables(internal.resolveSourceNames(schema));
233
+ const bindings = internal.resolveSourceNames(schema);
234
+ internal.assertDistinctVariables(bindings);
235
+ const unusable = internal.unusableOverrideIssues(schema, bindings);
236
+ if (unusable.length > 0) throw new internal.BymaxConfigValidationError(unusable);
204
237
  const overridden = /* @__PURE__ */ new Set();
205
238
  const source = applyOverrides(schema, synthesizePlaceholderSource(schema), overrides, overridden);
206
239
  try {
@@ -1,4 +1,4 @@
1
- import { BymaxConfigModule, assertDistinctVariables, resolveSourceNames, createValidatedConfig, BymaxConfigValidationError } from '@bymax-one/nest-config/internal';
1
+ import { BymaxConfigModule, resolveSourceNames, assertDistinctVariables, unusableOverrideIssues, BymaxConfigValidationError, createValidatedConfig, ConfigErrorCode } from '@bymax-one/nest-config/internal';
2
2
 
3
3
  // src/testing/create-test-config.ts
4
4
  var FILLER_CHARACTER = "a";
@@ -165,19 +165,49 @@ function synthesizePlaceholderSource(schema) {
165
165
  }
166
166
 
167
167
  // src/testing/create-test-config.ts
168
- function flattenOverrides(overrides) {
168
+ function unplacedIssue(path, code, message) {
169
+ return { path, variable: "", code, message };
170
+ }
171
+ function flattenOverrides(overrides, declared) {
169
172
  const flat = /* @__PURE__ */ new Map();
173
+ const unplaced = [];
170
174
  for (const [namespace, leaves] of Object.entries(overrides)) {
175
+ if (typeof leaves !== "object") {
176
+ unplaced.push(
177
+ unplacedIssue(
178
+ namespace,
179
+ ConfigErrorCode.INVALID,
180
+ `the override for \`${namespace}\` must be an object`
181
+ )
182
+ );
183
+ continue;
184
+ }
171
185
  for (const [leaf, value] of Object.entries(leaves)) {
172
- flat.set(`${namespace}.${leaf}`, value);
186
+ const path = `${namespace}.${leaf}`;
187
+ if (declared.has(path)) {
188
+ flat.set(path, value);
189
+ } else {
190
+ unplaced.push(
191
+ unplacedIssue(
192
+ path,
193
+ ConfigErrorCode.UNKNOWN_KEY,
194
+ "no leaf of the schema is at this path; the override was not applied"
195
+ )
196
+ );
197
+ }
173
198
  }
174
199
  }
175
- return flat;
200
+ return { flat, unplaced };
176
201
  }
177
202
  function applyOverrides(schema, base, overrides, overridden) {
178
- const flat = flattenOverrides(overrides ?? {});
203
+ const bindings = resolveSourceNames(schema);
204
+ const { flat, unplaced } = flattenOverrides(
205
+ overrides ?? {},
206
+ new Set(bindings.map((binding) => binding.path))
207
+ );
208
+ if (unplaced.length > 0) throw new BymaxConfigValidationError(unplaced);
179
209
  const entries = new Map(Object.entries(base));
180
- for (const binding of resolveSourceNames(schema)) {
210
+ for (const binding of bindings) {
181
211
  const value = flat.get(binding.path);
182
212
  if (value === void 0) continue;
183
213
  entries.set(binding.variable, String(value));
@@ -198,7 +228,10 @@ function explainUnsatisfiedLeaves(error, overridden) {
198
228
  );
199
229
  }
200
230
  function prepareTestConfig(schema, overrides) {
201
- assertDistinctVariables(resolveSourceNames(schema));
231
+ const bindings = resolveSourceNames(schema);
232
+ assertDistinctVariables(bindings);
233
+ const unusable = unusableOverrideIssues(schema, bindings);
234
+ if (unusable.length > 0) throw new BymaxConfigValidationError(unusable);
202
235
  const overridden = /* @__PURE__ */ new Set();
203
236
  const source = applyOverrides(schema, synthesizePlaceholderSource(schema), overrides, overridden);
204
237
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-config",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Typed and validated environment configuration entry point for NestJS 11 applications, built on Zod v4.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
@@ -55,6 +55,7 @@
55
55
  },
56
56
  "scripts": {
57
57
  "build": "pnpm clean && tsup",
58
+ "check:api": "node scripts/check-api-surface.mjs",
58
59
  "check:exports": "attw --pack . --profile strict",
59
60
  "check:lint-graph": "node scripts/check-lint-graph-rules.mjs",
60
61
  "check:mutants": "node scripts/check-mutation-directives.mjs",