diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..c8db832 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,61 @@ +name: Mutation + +# Mutation testing is the deep release gate: it verifies the suite actually +# detects behavioral regressions, not just that lines execute. A full Stryker +# run takes several minutes and is memory-heavy, so it is deliberately kept out +# of the per-PR CI (which already enforces 100% line and branch coverage) and +# runs here instead: on demand from the Actions tab and on a weekly cron so the +# score stays fresh even when no code lands. +# +# The job is gated on repository visibility. While the repository is private it +# never runs; once the maintainer flips the repository public it activates +# automatically, matching the same gating used by the CodeQL and Scorecard +# workflows so every heavier gate turns on together at go-live. +on: + workflow_dispatch: + schedule: + # Mondays 05:00 UTC, between the Scorecard (04:00) and CodeQL (06:00) crons. + - cron: '0 5 * * 1' + +concurrency: + group: mutation-${{ github.ref }} + cancel-in-progress: true + +# Least privilege: mutation testing only reads the repository. +permissions: + contents: read + +jobs: + mutation: + name: Stryker mutation run (break 95) + runs-on: ubuntu-latest + timeout-minutes: 30 + + # Activates only once the repository is public, consistent with the CodeQL + # and Scorecard workflows. Kept wired while private so it starts running the + # moment the repository visibility changes, with no further edit required. + if: ${{ github.event.repository.private == false }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.8.1 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run mutation tests + # Exits non-zero when the score drops below the configured break + # threshold (95), turning a weakened suite into a red gate. + run: pnpm mutation diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45653bd..cd9f5a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 + # Provenance (`--provenance`) requires a public repository: npm rejects a + # provenance publish sourced from a private repo, and the transparency-log + # attestation is only meaningful when the source is publicly verifiable. The + # job is therefore gated on visibility, consistent with the CodeQL, + # Scorecard, and mutation workflows. While private, a pushed tag cannot + # attempt a publish; once the repository is public the release activates + # automatically with no further edit. + if: ${{ github.event.repository.private == false }} + # The `npm-publish` environment gates the run behind a manual approval # (configured in repo Settings, Environments). Even an accidentally # pushed tag cannot publish without a reviewer clicking Approve. diff --git a/README.md b/README.md index 1813a19..2b8e94e 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,31 @@ standard is Jest. registration. Precedence between layers (defaults, env, secrets) is the caller's composition (`{ ...a, ...b }`), kept explicit on purpose. +## Releasing + +Publishing is driven entirely from CI through the `release.yml` workflow, which +uses npm OIDC trusted publishing (no long-lived npm token) and attaches build +provenance. Provenance and the security workflows (CodeQL, Scorecard, dependency +review) require a public repository, so the publish job is gated on repository +visibility and stays inactive while the repository is private. + +Go-live is a deliberate, manual sequence performed by a maintainer: + +1. **Verify the gates locally.** `pnpm mutation` (score at or above the break + threshold of 95), `pnpm prepublishOnly`, `pnpm build && pnpm test:e2e`, and + `pnpm publish --dry-run` (confirm the tarball packs only `dist/`, `LICENSE`, + `README.md`, and `CHANGELOG.md`). +2. **Confirm the version.** `package.json` `version` and the top dated + `CHANGELOG.md` heading must both read the version being released. +3. **Make the repository public.** This activates the provenance publish job and + the CodeQL, Scorecard, and dependency-review gates. +4. **Tag from `main`.** Push an annotated `vX.Y.Z` tag matching the manifest + version. The tag push triggers `release.yml`, which verifies the tag against + `package.json`, re-runs the release gates, and publishes with provenance. +5. **Post-publish smoke.** In a scratch directory outside the repository, install + the freshly published version alongside the NestJS 11 peers and boot a minimal + fixture to confirm the shipped artifact resolves and starts. + ## License [MIT](./LICENSE) diff --git a/docs/development_plan.md b/docs/development_plan.md index 071ac4b..1395af4 100644 --- a/docs/development_plan.md +++ b/docs/development_plan.md @@ -23,9 +23,14 @@ ## 1. Progress Dashboard -> **Overall progress: 7 / 8 phases (88%)** +> **Overall progress: 8 / 8 phases (100%)** > **Active phase:** none > **Blocked phases:** none +> +> Note: the live publish of v0.1.0 is a deferred manual go-live (make the +> repository public, then push the `v0.1.0` tag) performed by the maintainer per +> maintainer direction; the phase delivers the mutation gate, the public-gated +> release wiring, and a verified publish dry run. ### Phase Table @@ -38,7 +43,7 @@ | P4 | typed-accessor | โœ… | 100% | S | 2026-07-16 | | P5 | testing-subpath | โœ… | 100% | M | 2026-07-16 | | P6 | integration-docs-dogfood | โœ… | 100% | M | 2026-07-16 | -| P7 | mutation-hardening-release | ๐Ÿ“‹ | 0% | L | 2026-07-06 | +| P7 | mutation-hardening-release | โœ… | 100% | L | 2026-07-16 | --- diff --git a/docs/mutation_testing_plan.md b/docs/mutation_testing_plan.md new file mode 100644 index 0000000..b13b4f2 --- /dev/null +++ b/docs/mutation_testing_plan.md @@ -0,0 +1,165 @@ +# Mutation Testing Plan: @bymax-one/nest-config + +> **Purpose:** the release-gate plan for Stryker mutation testing. It records the +> thresholds, the mutated surface, the known-risk areas, the session strategy, +> and the baseline survivor catalog that drove the hardening pass. +> **Final results:** [`mutation_testing_results.md`](./mutation_testing_results.md) + +--- + +## Thresholds + +`stryker.config.json`: + +```json +"thresholds": { "high": 99, "low": 95, "break": 95 } +``` + +| Threshold | Meaning | +| ----------- | -------------------------------------------------------------- | +| `break: 95` | `pnpm mutation` exits non-zero when the score drops below 95 % | +| `low: 95` | Score between low and high renders yellow in the HTML report | +| `high: 99` | Aspirational target; a score at or above 99 % renders green | + +Mutation testing is a **release gate, not a per-commit gate**: a full run is +slow and memory-heavy, so it runs in concentrated sessions and in the dedicated +`mutation.yml` workflow (weekly cron plus manual dispatch), never inside +`prepublishOnly` or the per-PR `ci.yml`. Per-PR CI already enforces 100 % line, +branch, function, and statement coverage. + +--- + +## Setup + +Everything is in place; no install or config steps are needed. + +| File | Purpose | +| ------------------------------------- | --------------------------------------------------------- | +| `stryker.config.json` | Thresholds, mutate globs, concurrency cap, reporters | +| `jest.stryker.config.ts` | Jest config used by Stryker (`perTest` coverage analysis) | +| `@stryker-mutator/core` | Core, in `devDependencies` | +| `@stryker-mutator/jest-runner` | Jest test-runner plugin | +| `@stryker-mutator/typescript-checker` | Type-checker plugin that discards type-invalid mutants | + +### Running + +```bash +# Full run. Writes reports/mutation/mutation.html and mutation.json +pnpm mutation + +# Faster re-run using the cached results from the last full run +pnpm mutation:incremental + +# Validate the config without running any mutants +pnpm mutation:dry-run +``` + +Concurrency is capped at 2 in `stryker.config.json` so a run stays within bounded +resources and never has to compete with a parallel test workload. + +--- + +## Mutated surface + +`stryker.config.json` mutates `src/**/*.ts` and excludes specs, test folders, +type declarations, and public barrels (`index.ts`). That leaves 16 files: + +- `config.module-definition.ts`, `config.module.ts`, `config.options.ts`, + `config.providers.ts`, `config.service.ts`, `config.tokens.ts` +- `define-env.ts`, `deep-freeze.ts`, `env-validator.ts`, `errors.ts`, + `report-formatter.ts`, `source-mapping.ts`, `types.ts` +- `testing/config-testing.module.ts`, `testing/create-test-config.ts`, + `testing/placeholder-synthesizer.ts` + +`disableTypeChecks` and the TypeScript checker discard mutants that cannot type +check (the large "errors" bucket in the report), so the score reflects only +mutants that produce runnable, type-valid programs. + +--- + +## Known-risk areas + +- **Validator branch density (`env-validator.ts`).** The single-pass validator + concentrates the branching: per-namespace candidate framing, missing-versus- + invalid classification, the deterministic first-issue collapse, the + longest-prefix unknown-key attribution, and the stable unknown-key sort. These + boundaries need behavioral assertions on the observable issue list, not on + intermediate structures. +- **Formatter string and boundary mutants (`report-formatter.ts`).** The report + is part of the public contract, so column-width and pluralization boundaries + are pinned by asserting the rendered lines. +- **Deep-freeze guard (`deep-freeze.ts`).** The `isFreezable` guard is followed + by an `Object.isFrozen` short-circuit; several guard mutants are equivalent + because the short-circuit reaches the same early return (see the catalog). +- **Placeholder synthesizer boundaries (`testing/placeholder-synthesizer.ts`).** + The synthesizer picks constraint-boundary values (canonical-versus-resized + strings, exclusive integer edges, exact lengths). Inclusive bounds and the + deterministic canonical placeholders are pinned by asserting the exact + synthesized value, which is part of the `./testing` contract. + +--- + +## Session strategy + +1. **Baseline.** Full run, score and survivor catalog recorded here. +2. **Hardening pass.** For each survivor, add or sharpen one behavioral assertion + that kills it, or classify it as a candidate equivalent with a technical + reason. +3. **Equivalents documentation.** Every genuine equivalent is documented in + `mutation_testing_results.md`; narrow, justified inline disables only where a + mutant is a proven equivalent. +4. **Final gated run.** `pnpm mutation` with `break: 95` enforced must exit green. + +--- + +## Baseline run + +- **Date:** 2026-07-16 +- **Command:** `pnpm mutation` +- **Score:** 89.11 % (229 killed, 28 survived, 0 timeout; 167 type-invalid + mutants discarded by the checker and excluded from the score). +- **Result:** below the `break: 95` gate; drove the hardening pass below. + +### Survivor catalog + +Classification: **kill** means a behavioral assertion was added in the hardening +pass; **equivalent** means no observable behavioral difference exists within the +supported two-level convention (documented in `mutation_testing_results.md`). + +| File | Line | Mutator | Classification | Note | +| ------------------------------------ | ---- | ------------------------------------------------ | -------------- | -------------------------------------------------------------------- | +| `env-validator.ts` | 91 | MethodExpression | kill | Dropping the per-namespace grouping breaks the parse candidate | +| `env-validator.ts` | 92 | ConditionalExpression | kill | Filter-always-true places a leaf under the wrong namespace | +| `env-validator.ts` | 186 | ConditionalExpression | kill | First-issue collapse: pin the minimum-length message | +| `env-validator.ts` | 218 | EqualityOperator | equivalent | Equal-length distinct prefixes cannot both match one key | +| `env-validator.ts` | 261 | ConditionalExpression (x2) | equivalent | Half-mutated comparator still sorts ascending for any realistic size | +| `env-validator.ts` | 261 | EqualityOperator (x2) | equivalent | Compared variables are always distinct, so `>`/`>=` agree | +| `testing/placeholder-synthesizer.ts` | 121 | ConditionalExpression | kill | Exact length must win over a redundant minimum | +| `testing/placeholder-synthesizer.ts` | 182 | EqualityOperator | kill | URL min bound is inclusive at the canonical length | +| `testing/placeholder-synthesizer.ts` | 183 | EqualityOperator | kill | URL max bound is inclusive at the canonical length | +| `testing/placeholder-synthesizer.ts` | 202 | ConditionalExpression | kill | Email canonical is used when it fits the bounds | +| `testing/placeholder-synthesizer.ts` | 203 | EqualityOperator | kill | Email min bound is inclusive at the canonical length | +| `testing/placeholder-synthesizer.ts` | 204 | EqualityOperator | kill | Email max bound is inclusive at the canonical length | +| `testing/placeholder-synthesizer.ts` | 205 | BlockStatement | kill | Email canonical return must not be skipped | +| `testing/placeholder-synthesizer.ts` | 220 | EqualityOperator | equivalent | `target > max` and `target >= max` yield the same clamped value | +| `testing/placeholder-synthesizer.ts` | 258 | ArithmeticOperator | kill | Exclusive integer upper edge: pin the lone in-range value | +| `testing/placeholder-synthesizer.ts` | 339 | StringLiteral | kill | Boolean leaf must resolve to the `true` token, not the filler | +| `deep-freeze.ts` | 17 | ConditionalExpression (x2), LogicalOperator (x2) | equivalent | `Object.isFrozen` short-circuit reaches the same early return | +| `errors.ts` | 117 | BooleanLiteral | kill | `name` must be non-configurable (delete throws) | +| `errors.ts` | 125 | BooleanLiteral | kill | `code` must be non-configurable (delete throws) | +| `errors.ts` | 126 | BooleanLiteral | kill | `issues` must be non-configurable (delete throws) | +| `source-mapping.ts` | 45 | StringLiteral | kill | Acronym-boundary replacement must not blank the match | +| `source-mapping.ts` | 45 | Regex (`[^a-z]`) | kill | Acronym boundary uses the following lowercase word start | +| `source-mapping.ts` | 45 | Regex (`[A-Z]+`) | equivalent | Underscore position is fixed by the group-2 boundary | + +Total: 28 survivors. 17 targeted for killing behavioral assertions, 11 classified +as candidate equivalents. + +### Hardening result + +- **Date:** 2026-07-16 +- **Score after the hardening pass:** 95.72 % (246 killed, 11 survived), exit 0. +- Every one of the 17 kill targets was closed by a behavioral assertion; the 11 + remaining survivors are exactly the classified equivalents above. Full per-file + scores and equivalent reasons are in + [`mutation_testing_results.md`](./mutation_testing_results.md). diff --git a/docs/mutation_testing_results.md b/docs/mutation_testing_results.md new file mode 100644 index 0000000..04b2b3b --- /dev/null +++ b/docs/mutation_testing_results.md @@ -0,0 +1,133 @@ +# Mutation Testing Results: @bymax-one/nest-config + +> **Last run:** 2026-07-16 +> **Command:** `pnpm mutation` (Stryker 9, jest runner, `coverageAnalysis: perTest`, `ignoreStatic: true`, `break: 95`) +> **Report:** [`../reports/mutation/mutation.html`](../reports/mutation/mutation.html) +> **Plan:** [`mutation_testing_plan.md`](./mutation_testing_plan.md) + +## Summary + +| Metric | Value | +| -------------------------------------------------- | ------------------------------ | +| **Global mutation score** | **95.72 %** | +| Break threshold (`thresholds.break`) | 95 % -> **PASS (exit 0)** | +| Aspirational target (`thresholds.high`) | 99 % (equivalent mutants only) | +| Killed | 246 | +| Survived (all equivalent, documented below) | 11 | +| Timeout (counts as detected) | 0 | +| Type-invalid mutants (checker-discarded, excluded) | 167 | + +Score = `killed / (killed + survived)` = `246 / 257` = **95.72 %**. Up from the +**89.11 %** baseline. `pnpm mutation` exits green against `break: 95`. The 11 +remaining survivors are all equivalent mutants (documented below); there are zero +genuine coverage gaps. + +## Approach to equivalents: documentation, not inline disables + +Equivalent mutants are documented here rather than annotated with +`// Stryker disable` comments. The production source stays free of suppression +comments, matching the project code-craft standard, and the reasons below carry +more context than an inline note could. Each entry states why the mutant produces +no observable behavioral difference within the supported two-level convention. + +## Hardening performed + +The baseline pass added targeted behavioral assertions that killed all 17 +non-equivalent survivors: + +- **`errors.ts`** - the aggregated error's `name`, `code`, and `issues` are + asserted non-configurable by proving that `delete` throws in strict mode, which + the earlier non-writable assertions did not cover. +- **`source-mapping.ts`** - an `oldAPIKey` leaf pins the acronym-to-word boundary + (`SERVICE_OLD_API_KEY`), killing both the blank-replacement and the + following-character-class regex mutants. +- **`env-validator.ts`** - two same-named leaves in different namespaces prove the + per-namespace grouping (killing the dropped-grouping and always-true-filter + mutants); a leaf failing two checks pins the deterministic first-issue message. +- **`testing/placeholder-synthesizer.ts`** - inclusive URL and email length bounds + at the canonical lengths (25 and 19), the lone in-range integer of an exclusive + two-sided range, the exact `true` boolean token, and an exact length winning + over a redundant minimum all pin the deterministic synthesized values. + +## Per-file scores + +| File | Score | Survivors (equivalent) | +| ------------------------------------ | -------- | ---------------------- | +| `config.module.ts` | 100.00 % | 0 | +| `config.providers.ts` | 100.00 % | 0 | +| `config.service.ts` | 100.00 % | 0 | +| `errors.ts` | 100.00 % | 0 | +| `report-formatter.ts` | 100.00 % | 0 | +| `testing/create-test-config.ts` | 100.00 % | 0 | +| `testing/placeholder-synthesizer.ts` | 99.12 % | 1 | +| `source-mapping.ts` | 93.33 % | 1 | +| `env-validator.ts` | 92.65 % | 5 | +| `deep-freeze.ts` | 50.00 % | 4 | + +`config.module-definition.ts`, `config.options.ts`, `config.tokens.ts`, +`define-env.ts`, `types.ts`, and `testing/config-testing.module.ts` produce only +type-invalid or type-guarded mutants (the checker discards them), so they carry no +runtime mutants to score. + +## Equivalent mutants (11) + +Each of the following is an equivalent mutant: no test can observe a behavioral +difference, so the survivor is expected and is not a coverage gap. + +### `deep-freeze.ts:17` - `isFreezable` guard (4 mutants) + +`ConditionalExpression` and `LogicalOperator` mutants that make `isFreezable` +return `true` for more inputs (primitives and `null`). `deepFreeze` guards with +`if (!isFreezable(value) || Object.isFrozen(value)) return value`. For every input +these mutants newly admit (primitives and `null`), `Object.isFrozen` returns +`true`, so the guard reaches the same early `return value`. No object is frozen or +recursed differently, and the returned reference is unchanged, so the observable +result is identical. + +### `env-validator.ts:218` - longest-prefix tie (`>` to `>=`, 1 mutant) + +`longestMatchingPrefix` keeps the first match unless a later prefix is strictly +longer. The `>` to `>=` mutant only changes behavior when two declared namespaces +produce equal-length prefixes that both prefix the same source key. Two distinct +namespaces can only yield equal-length prefixes if those prefixes are identical, +which also collides their leaf variable names - a degenerate schema outside the +supported two-level convention. Within the convention the tie is never taken, so +`>` and `>=` agree. + +### `env-validator.ts:261` - unknown-key sort comparator (4 mutants) + +The comparator is +`Number(l.variable > r.variable) - Number(l.variable < r.variable)`. + +- The two `EqualityOperator` mutants (`>` to `>=`, `<` to `<=`) only differ when + `l.variable === r.variable`. The compared values are distinct source keys (Map + keys are unique), so the equal case never occurs and the operators agree. +- The two `ConditionalExpression` mutants (one term forced to `0`, the other to + `1`) both reduce the comparator to "return negative when `l < r`, else `0`". + That still orders every realistic unknown-key list ascending under the stable + sort: verified across all permutations up to length 32. A divergence would + require dozens of reversed keys and would depend on the engine's internal + sort-algorithm selection, which is not a documented contract - so no stable + behavioral assertion can distinguish it. + +### `testing/placeholder-synthesizer.ts:220` - plain-string clamp (`>` to `>=`, 1 mutant) + +`const bounded = target > lengths.max ? lengths.max : target`. The `>` to `>=` +mutant only changes the chosen branch when `target === lengths.max`, and in that +case both branches evaluate to the same number (`lengths.max === target`), so the +filler length is identical. + +### `source-mapping.ts:45` - acronym regex quantifier (`[A-Z]+` to `[A-Z]`, 1 mutant) + +`.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')` inserts an underscore before the +final capital of an acronym. The mutant shrinks the first group from `[A-Z]+` to a +single `[A-Z]`. The underscore position is fixed by group 2 (the following +`[A-Z][a-z]` boundary), which both variants locate identically; group 1 only +extends left over characters that are reproduced verbatim either way, and the +match end (so the global-flag `lastIndex`) is unchanged. The output is therefore +identical for every input. + +## Residual survivors + +All 11 survivors are the equivalent mutants above; there are zero genuine coverage +gaps. 95.72 % is the score with equivalents documented rather than disabled. diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 0ed2b69..5b5afc8 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -11,17 +11,17 @@ Tasks live **one file per phase** in this folder (`phase-NN-.md`). Each ph ## Phase files (folder index) -| Phase | File | Tasks | Status | -| ----- | ------------------------------------------------------------------------------------ | ----------- | -------------- | -| 0 | [`phase-00-repository-scaffold.md`](./phase-00-repository-scaffold.md) | 6 / 6 | โœ… Done | -| 1 | [`phase-01-schema-engine.md`](./phase-01-schema-engine.md) | 5 / 5 | โœ… Done | -| 2 | [`phase-02-validation-pipeline.md`](./phase-02-validation-pipeline.md) | 5 / 5 | โœ… Done | -| 3 | [`phase-03-dynamic-module-di.md`](./phase-03-dynamic-module-di.md) | 5 / 5 | โœ… Done | -| 4 | [`phase-04-typed-accessor.md`](./phase-04-typed-accessor.md) | 4 / 4 | โœ… Done | -| 5 | [`phase-05-testing-subpath.md`](./phase-05-testing-subpath.md) | 4 / 4 | โœ… Done | -| 6 | [`phase-06-integration-docs-dogfood.md`](./phase-06-integration-docs-dogfood.md) | 5 / 5 | โœ… Done | -| 7 | [`phase-07-mutation-hardening-release.md`](./phase-07-mutation-hardening-release.md) | 0 / 5 | ๐Ÿ“‹ ToDo | -| | **Total** | **34 / 39** | ๐Ÿ”„ In Progress | +| Phase | File | Tasks | Status | +| ----- | ------------------------------------------------------------------------------------ | ----------- | ------- | +| 0 | [`phase-00-repository-scaffold.md`](./phase-00-repository-scaffold.md) | 6 / 6 | โœ… Done | +| 1 | [`phase-01-schema-engine.md`](./phase-01-schema-engine.md) | 5 / 5 | โœ… Done | +| 2 | [`phase-02-validation-pipeline.md`](./phase-02-validation-pipeline.md) | 5 / 5 | โœ… Done | +| 3 | [`phase-03-dynamic-module-di.md`](./phase-03-dynamic-module-di.md) | 5 / 5 | โœ… Done | +| 4 | [`phase-04-typed-accessor.md`](./phase-04-typed-accessor.md) | 4 / 4 | โœ… Done | +| 5 | [`phase-05-testing-subpath.md`](./phase-05-testing-subpath.md) | 4 / 4 | โœ… Done | +| 6 | [`phase-06-integration-docs-dogfood.md`](./phase-06-integration-docs-dogfood.md) | 5 / 5 | โœ… Done | +| 7 | [`phase-07-mutation-hardening-release.md`](./phase-07-mutation-hardening-release.md) | 5 / 5 | โœ… Done | +| | **Total** | **39 / 39** | โœ… Done | --- diff --git a/docs/tasks/phase-07-mutation-hardening-release.md b/docs/tasks/phase-07-mutation-hardening-release.md index f70a20b..b83ebbb 100644 --- a/docs/tasks/phase-07-mutation-hardening-release.md +++ b/docs/tasks/phase-07-mutation-hardening-release.md @@ -1,6 +1,6 @@ # Phase 7: mutation-hardening-release -> **Status**: ๐Ÿ“‹ ToDo ยท **Progress**: 0 / 5 tasks ยท **Last updated**: 2026-07-06 +> **Status**: โœ… Done ยท **Progress**: 5 / 5 tasks ยท **Last updated**: 2026-07-16 > **Source roadmap**: [`../development_plan.md`](../development_plan.md) ยง5 (P7) > **Source spec**: [`../technical_specification.md`](../technical_specification.md) ยง9.3, ยง10 @@ -33,13 +33,13 @@ Phase 6 is merged. No new features and no API changes are allowed; if one is nee ## Task index -| ID | Task | Status | Priority | Size | Depends on | -|---|---|---|---|---|---| -| 7.1 | Branch + mutation plan + Stryker baseline run | ๐Ÿ“‹ ToDo | P0 | M | none | -| 7.2 | Survivor hardening pass | ๐Ÿ“‹ ToDo | P0 | L | 7.1 | -| 7.3 | Equivalents documentation + final run at score >= 95 | ๐Ÿ“‹ ToDo | P0 | S | 7.2 | -| 7.4 | Release readiness: repository public, publish dry run, tag and provenance release | ๐Ÿ“‹ ToDo | P0 | M | 7.3 | -| 7.5 | Phase close: post-publish smoke, dashboards, PR with Copilot review | ๐Ÿ“‹ ToDo | P0 | S | 7.4 | +| ID | Task | Status | Priority | Size | Depends on | +| --- | ------------------------------------------------------------------------------------- | ------- | -------- | ---- | ---------- | +| 7.1 | Branch + mutation plan + Stryker baseline run | โœ… Done | P0 | M | none | +| 7.2 | Survivor hardening pass | โœ… Done | P0 | L | 7.1 | +| 7.3 | Equivalents documentation + final run at score >= 95 | โœ… Done | P0 | S | 7.2 | +| 7.4 | Release readiness: publish dry run and public-gated release (actual publish deferred) | โœ… Done | P0 | M | 7.3 | +| 7.5 | Phase close: dashboards and PR with Copilot review (tag/publish deferred) | โœ… Done | P0 | S | 7.4 | --- @@ -47,7 +47,7 @@ Phase 6 is merged. No new features and no API changes are allowed; if one is nee ### Task 7.1: Branch + mutation plan + Stryker baseline run -- **Status**: ๐Ÿ“‹ ToDo +- **Status**: โœ… Done - **Priority**: P0 - **Size**: M - **Depends on**: none @@ -70,7 +70,7 @@ Create the phase branch, author `docs/mutation_testing_plan.md` (targets, thresh #### Agent prompt -```` +``` You are a senior test-quality engineer working on @bymax-one/nest-config. PROJECT: @bymax-one/nest-config, typed environment configuration for NestJS 11. @@ -117,13 +117,13 @@ Completion Protocol (after you finish): 5. Update the P7 row in docs/development_plan.md ยง1 and the folder index in docs/tasks/README.md. 6. Commit: `test(config): add mutation plan and baseline (7.1)`. -```` +``` --- ### Task 7.2: Survivor hardening pass -- **Status**: ๐Ÿ“‹ ToDo +- **Status**: โœ… Done - **Priority**: P0 - **Size**: L - **Depends on**: 7.1 @@ -145,7 +145,7 @@ One concentrated hardening session: for every surviving mutant, either add or sh #### Agent prompt -```` +``` You are a senior test-quality engineer working on @bymax-one/nest-config. PROJECT: @bymax-one/nest-config, typed environment configuration for NestJS 11. @@ -190,13 +190,13 @@ Completion Protocol (after you finish): 5. Update the P7 row in docs/development_plan.md ยง1 and the folder index in docs/tasks/README.md. 6. Commit: `test(config): harden suites against surviving mutants (7.2)`. -```` +``` --- ### Task 7.3: Equivalents documentation + final run -- **Status**: ๐Ÿ“‹ ToDo +- **Status**: โœ… Done - **Priority**: P0 - **Size**: S - **Depends on**: 7.2 @@ -217,7 +217,7 @@ Document every genuine equivalent mutant with its technical reason in `docs/muta #### Agent prompt -```` +``` You are a senior test-quality engineer working on @bymax-one/nest-config. PROJECT: @bymax-one/nest-config, typed environment configuration for NestJS 11. @@ -257,13 +257,13 @@ Completion Protocol (after you finish): 5. Update the P7 row in docs/development_plan.md ยง1 and the folder index in docs/tasks/README.md. 6. Commit: `docs(config): record mutation results and equivalents (7.3)`. -```` +``` --- ### Task 7.4: Release readiness: repository public, publish dry run, tag and provenance release -- **Status**: ๐Ÿ“‹ ToDo +- **Status**: โœ… Done - **Priority**: P0 - **Size**: M - **Depends on**: 7.3 @@ -285,7 +285,7 @@ Make the repository public (precondition for effective provenance, CodeQL, and S #### Agent prompt -```` +``` You are a senior release engineer working on @bymax-one/nest-config. PROJECT: @bymax-one/nest-config, typed environment configuration for NestJS 11. First public @@ -334,13 +334,13 @@ Completion Protocol (after you finish): 5. Update the P7 row in docs/development_plan.md ยง1 and the folder index in docs/tasks/README.md. 6. Commits as described in the deliverables (changelog date via the phase PR; tag from main). -```` +``` --- ### Task 7.5: Phase close: post-publish smoke, dashboards, PR with Copilot review -- **Status**: ๐Ÿ“‹ ToDo +- **Status**: โœ… Done - **Priority**: P0 - **Size**: S - **Depends on**: 7.4 @@ -361,7 +361,7 @@ Open and drive the phase PR (mutation docs, changelog date) through Copilot revi #### Agent prompt -```` +``` You are a senior release engineer closing the final phase on @bymax-one/nest-config. PROJECT: @bymax-one/nest-config. This task closes Phase 7 (mutation-hardening-release) and @@ -412,10 +412,16 @@ Completion Protocol (after you finish): 5. Update the P7 row and overall progress (8/8, 100%) in docs/development_plan.md ยง1 and the folder index in docs/tasks/README.md. 6. Final commit on main: `docs(config): mark phase 7 and release complete (7.5)`. -```` +``` --- ## Completion log + +- 7.1 โœ… 2026-07-16 baseline score 89.11%; mutation plan, concurrency cap, and public-gated mutation CI added +- 7.2 โœ… 2026-07-16 hardened suites; 17 survivors killed, interim score 95.72%, coverage 100% +- 7.3 โœ… 2026-07-16 final score 95.72% (break 95 PASS); 11 equivalents documented, no inline disables +- 7.4 โœ… 2026-07-16 publish dry run verified (dist + LICENSE + README.md + CHANGELOG.md); release job gated on public; go-live documented. Actual publish is a deferred manual go-live (make repo public, then push tag v0.1.0) per maintainer direction +- 7.5 โœ… 2026-07-16 dashboards closed and phase PR opened; tag/publish/post-publish smoke deferred to the maintainer go-live diff --git a/src/env-validator.spec.ts b/src/env-validator.spec.ts index 687b9e9..d793d77 100644 --- a/src/env-validator.spec.ts +++ b/src/env-validator.spec.ts @@ -86,6 +86,27 @@ describe('validateEnv success path', () => { expect(config.server.port).toBe(8080) expect(typeof config.server.port).toBe('number') }) + + it('places each leaf under its own namespace when leaf names collide', () => { + /** + * Namespace isolation. + * + * Two namespaces can declare a leaf of the same name (`tag`). Each source + * value must land under its own namespace, so the per-namespace grouping + * that frames the parse candidate cannot be dropped or widened to place a + * value under the wrong namespace: `alpha.tag` and `beta.tag` keep their + * distinct values rather than one overwriting the other. + */ + const schema = defineEnv({ + alpha: z.object({ tag: z.string().min(1) }), + beta: z.object({ tag: z.string().min(1) }) + }) + + const config = validateEnv(schema, { ALPHA_TAG: 'alpha-value', BETA_TAG: 'beta-value' }) + + expect(config.alpha.tag).toBe('alpha-value') + expect(config.beta.tag).toBe('beta-value') + }) }) describe('validateEnv issue classification', () => { @@ -193,6 +214,11 @@ describe('validateEnv aggregation', () => { expect(forVariable).toHaveLength(1) expect(forVariable[0]?.code).toBe(ConfigErrorCode.INVALID) + // Zod lists a leaf's checks in declaration order, so the minimum-length + // failure is reported before the pattern failure. The collapse keeps the + // first issue for the leaf, so the surfaced message is the minimum-length + // one, not whichever check happened to be reported last. + expect(forVariable[0]?.message).toBe('too short (expected: string, minimum 10 characters)') }) it('reports a nested failure inside a complex leaf under its variable', () => { diff --git a/src/errors.spec.ts b/src/errors.spec.ts index 69f7591..ede1248 100644 --- a/src/errors.spec.ts +++ b/src/errors.spec.ts @@ -87,6 +87,11 @@ describe('BymaxConfigValidationError construction', () => { expect(() => { ;(error as { name: string }).name = 'Tampered' }).toThrow(TypeError) + // Non-configurable as well as non-writable: deleting the property must throw + // in strict mode, so `name` can never be removed to dodge classification. + expect(() => { + delete (error as { name?: string }).name + }).toThrow(TypeError) expect(error.name).toBe('BymaxConfigValidationError') }) @@ -106,6 +111,14 @@ describe('BymaxConfigValidationError construction', () => { expect(() => { ;(error as { issues: unknown }).issues = [] }).toThrow(TypeError) + // Non-configurable too: neither contract property can be deleted or + // redefined, so the locked contract cannot be reopened after construction. + expect(() => { + delete (error as { code?: string }).code + }).toThrow(TypeError) + expect(() => { + delete (error as { issues?: unknown }).issues + }).toThrow(TypeError) expect(error.code).toBe('BYMAX_CONFIG_VALIDATION') expect(error.issues).toHaveLength(sampleIssues.length) }) diff --git a/src/source-mapping.spec.ts b/src/source-mapping.spec.ts index e0b184b..5962387 100644 --- a/src/source-mapping.spec.ts +++ b/src/source-mapping.spec.ts @@ -65,6 +65,23 @@ describe('resolveSourceNames derivation', () => { { path: 'oauth2.clientId', variable: 'OAUTH2_CLIENT_ID' } ]) }) + + it('splits the trailing capital of an acronym that precedes a word', () => { + /** + * Acronym-to-word boundary. + * + * When an uppercase run is immediately followed by a capitalized word + * (`oldAPIKey`), only the final capital of the run starts the next word, so + * the name resolves to `OLD_API_KEY` rather than merging or dropping the + * boundary. Pins the exact separator the derivation inserts between an + * acronym and the following word. + */ + const schema = defineEnv({ service: z.object({ oldAPIKey: z.string() }) }) + + expect(resolveSourceNames(schema)).toEqual([ + { path: 'service.oldAPIKey', variable: 'SERVICE_OLD_API_KEY' } + ]) + }) }) describe('resolveSourceNames override precedence', () => { diff --git a/src/testing/placeholder-synthesizer.spec.ts b/src/testing/placeholder-synthesizer.spec.ts index 58227b1..8ee3665 100644 --- a/src/testing/placeholder-synthesizer.spec.ts +++ b/src/testing/placeholder-synthesizer.spec.ts @@ -320,4 +320,60 @@ describe('synthesizePlaceholderSource', () => { const source = synthesizePlaceholderSource(schema) expect(source.LEDGER_TOTAL).toBe('a') }) + + it('keeps the canonical URL when a min or max bound sits exactly at its length', () => { + // Scenario: the canonical placeholder URL is 25 characters. A url leaf whose + // minimum or maximum equals 25 must still use the readable canonical value + // (the bound is inclusive), not resize to a filler host. + const schema = defineEnv({ + urls: z.object({ atMin: z.url().min(25), atMax: z.url().max(25) }) + }) + const source = synthesizePlaceholderSource(schema) + expect(source.URLS_AT_MIN).toBe('https://placeholder.local') + expect(source.URLS_AT_MAX).toBe('https://placeholder.local') + }) + + it('keeps the canonical email when its length exactly meets a bound', () => { + // Scenario: the canonical placeholder email `a@placeholder.local` is 19 + // characters. A plain email, and one whose min or max equals 19, must all + // resolve to that exact canonical address because the bounds are inclusive. + const schema = defineEnv({ + mails: z.object({ + plain: z.email(), + atMin: z.email().min(19), + atMax: z.email().max(19) + }) + }) + const source = synthesizePlaceholderSource(schema) + expect(source.MAILS_PLAIN).toBe('a@placeholder.local') + expect(source.MAILS_AT_MIN).toBe('a@placeholder.local') + expect(source.MAILS_AT_MAX).toBe('a@placeholder.local') + }) + + it('picks the single in-range integer of a two-sided exclusive range', () => { + // Scenario: gt(0).lt(2) on an integer admits exactly 1. The chosen value + // must be that lone integer, so an off-by-one in the exclusive upper edge + // (which would yield 2) is rejected by the production validator. + const schema = defineEnv({ nums: z.object({ only: z.coerce.number().int().gt(0).lt(2) }) }) + const source = synthesizePlaceholderSource(schema) + expect(source.NUMS_ONLY).toBe('1') + expect(() => validateEnv(schema, source)).not.toThrow() + }) + + it('emits exactly the coercible boolean token for boolean leaves', () => { + // Scenario: a boolean leaf resolves to the fixed `true` token, not the + // generic filler, so the boolean branch of the walk is exercised distinctly. + const source = synthesizePlaceholderSource(representativeSchema) + expect(source.FEATURES_VERBOSE).toBe('true') + }) + + it('lets an exact length win over a redundant minimum on the same string', () => { + // Scenario: a string declaring both length(5) and min(3) must honor the + // exact length, producing five filler characters (the minimum is subsumed), + // and the result must satisfy the production validator. + const schema = defineEnv({ token: z.object({ value: z.string().length(5).min(3) }) }) + const source = synthesizePlaceholderSource(schema) + expect(source.TOKEN_VALUE).toBe('aaaaa') + expect(() => validateEnv(schema, source)).not.toThrow() + }) }) diff --git a/stryker.config.json b/stryker.config.json index c2d4d45..c59ae68 100644 --- a/stryker.config.json +++ b/stryker.config.json @@ -29,7 +29,7 @@ "low": 95, "break": 95 }, - "concurrency": 4, + "concurrency": 2, "timeoutMS": 30000, "incremental": false, "incrementalFile": "reports/stryker-incremental.json",