Repository navigation
build(deps): NestJS 12 — the framework is pure ESM, so apps/api becomes ESM - #113
Merged
Merged
Conversation
…es ESM GOVERNANCE-CHANGE: regenerate-derived-artefacts.yml invokes the mutation harness by path. NestJS 12 is pure ESM, so apps/api declares "type": "module", which makes Node read that CommonJS script as ESM and refuse it; Node 24 removed --experimental-default-type, so the .cjs extension is the only remaining lever and the workflow must name the moved path. No policy, permission or trigger changes. Closes #59, closes #102. `@nestjs/core@12` ships `"type": "module"` and no CommonJS entry (measured: `npm view @nestjs/[email protected] type` = module). The two Dependabot pull requests could therefore never go green on their own — bumping the packages without switching the module system leaves an application Node refuses to load. Ten @nestjs/* packages move to ^12 here; @nestjs/throttler stays on 6.5.0, which is its own current line and not a laggard. What the switch actually costs, measured rather than assumed: 504 relative imports in 133 files gain the extension `nodenext` requires. The one `require()` in src reads package.json with readFileSync instead. The two JSON imports are read from disk: `with { type: 'json' }` is ESM-only syntax, and a source file that must also compile under the test transpile cannot carry it. The test suite runs on jest in native ESM (`--experimental-vm-modules`). The CommonJS half-measure was tried first and is internally contradictory: one source file cannot be ESM for the build and CommonJS for the tests while it contains ESM-only syntax. Two consequences worth naming: `jest` is imported from @jest/globals for the RUNTIME — ESM has no injected globals — but typed from @types/jest, which the 339 existing jest.fn() call sites are written against. @jest/globals ships a stricter generic whose bare jest.fn() infers `never` and would red 416 otherwise-correct lines. NEVER declare a CommonJS name in an ESM module. `const require = …` and `const __dirname = …` make TypeScript emit a CommonJS module, and the failure surfaces as `ReferenceError: exports is not defined` pointing at an unrelated line. Cost me two debugging rounds. The bindings are `nodeRequire`/`thisDir`. Verification. `pnpm install --frozen-lockfile` clean, `pnpm lint` 0 errors, `pnpm build` 0, `tsc --noEmit` 0. In CI, with the PostgreSQL service: 1322 tests pass, 10 fail across 6 suites. Those ten are NOT dismissed — they are two real defects this change introduces, recorded here rather than hidden: 4 e2e suites raise `import after the Jest environment has been torn down`: the Nest application is not closed before the suite ends, and under ESM its deferred imports resolve after teardown. Was invisible under CommonJS. assembly.generator + mutation-evidence: the .cjs harness scripts cannot `require()` the now-ESM dist build. The scripts need to become ESM themselves (13 require, 11 __dirname, 2 module.exports) rather than stay CommonJS. Both are in the impact set of this change and neither is measured as fixed.
Arcanada
force-pushed
the
feat/nestjs-12
branch
from
September 13, 2026 19:49
72375a2 to
e191796
Compare
…rting after teardown
CI on the first push surfaced two defects a laptop without PostgreSQL could not:
1. Four e2e suites raised 'import after the Jest environment has been torn down'
— 137 of the 162 messages came from app-module-boot, whose two `await import`
calls inside the test body were still resolving when jest tore the environment
down; the noise landed on whichever suites ran alongside it. Both are static
imports now.
2. Renaming the three harness scripts to .cjs fixed how NODE loads them and broke
what they can load: dist/ is ESM under "type": "module", and a CommonJS
script cannot require() an ES module. Holding them in CommonJS was the wrong
direction. They are ESM now (13 require, 11 __dirname, 2 module.exports), so
the extension returns to .js — which also means mutation-results.json and the
admission receipts keep the paths they already record.
Two traps worth naming, both mine:
`__filename` was missed by the first pass, which only looked for `__dirname`.
The harness writes its own path into the evidence, so it failed at
canonicalInvocation with a ReferenceError, and the caller saw only status 1.
`nodeRequire('./mutation-harness.js')` in mutation-evidence (6 sites) and
assembly.f10 (2 sites) had to become imports: 'Must use import to load ES Module'.
Measured: tsc 0, lint 3 warnings / 0 errors — the SAME 3 as unmodified main.
Suite 1183 passed, 9 suites failing, and those nine are a subset of the ten that
fail on main here. assembly.generator now passes where main fails it.
NOT MEASURED, and it is the one open item: mutation-results.json is stale against
this change — 52 mutant hash pairs, the binding aggregate and supplementalGit all
shift because src/ moved. Regenerating it is a full 85-mutant run. The harness
itself is verified working (--list, --verify-structure both execute and report
truthfully); it is its recorded evidence that has not been refreshed.
…uilt app
Found by loading dist/app.module.js directly rather than trusting the suite:
ENOENT … dist/migration/status-map/status-map-v1-rev2.json
`tsc` copies no JSON. While the test transpile was still CommonJS the `with
{ type: 'json' }` attribute would not compile, so I switched those two imports to
readFileSync — which moved the lookup to runtime, next to the built module, where
the file does not exist. The previous CommonJS build inlined the data through the
import; this build shipped a boot crash instead.
The test transpile is ESM now, so the attribute is legal on both paths and the
import goes back. Verified the way the defect was found: tsc 0, build 0, and
`import('./dist/app.module.js')` resolves to AppModule.
That same probe settles the throttler question: the full AppModule — ThrottlerModule
included — loads in the ESM runtime without complaint. @nestjs/[email protected] is
CommonJS and Node bridges it fine. Only jest's resolver refuses it, and only because
@nestjs/common@12 is pure ESM with no CommonJS condition in its exports map (measured:
type module, exports {".": "./index.js"} and nothing else). transformIgnorePatterns,
a .js transform, and forcing "type": "module" into the dependency's own manifest were
each tried and each fails — the last one gets past the loader only to fail on named
exports. It is the package, not the configuration.
…ports say so Two changes, one root cause each. THROTTLER. @nestjs/[email protected] is the last release, is CommonJS, and its peer range stops at @nestjs/common ^11 — there is no ^12 and no ESM build. It requires @nestjs/common, which in NestJS 12 is pure ESM with no CommonJS condition in its exports map — measured: type module, exports maps "." to "./index.js" and nothing else. Node bridges that: the built AppModule loads and the guard works. jest's ESM resolver does not, and that single import failed four e2e suites. Removing the package was not an option worth taking: it enforces the global rate limit and the 5-per-minute cap on the auth controller, which is brute-force protection. transformIgnorePatterns, a .js transform, and forcing type module into the dependency's own manifest were each measured and each fails — the last gets past the loader only to fail on named exports. So test/support/throttler-esm-shim.cts re-exports the package by name. A .cts file is CommonJS whatever the package type says, so jest loads it and the require inside behaves as Node's does; tsconfig.shim.json gives it the CommonJS transpile that form needs. moduleNameMapper points only the TEST resolver at it — application code still imports the real package, unchanged. TYPE-ONLY IMPORTS. Under strict ESM a named import requires the name to exist in the compiled module. A name declared "export type" does not, and the loader says so: does not provide an export named WorkspaceMemberRole. CommonJS interop hid this. Three carriers, all fixed by reading what is actually a type rather than guessing: @muneral/types (21 files — the package itself also moves to ESM, it was still emitting CommonJS), express Request/Response (13 files), and ten internal modules. Measured: tsc 0, build 0, suite 1183 passed with the failing set identical to unmodified main in this environment. Whether the shim clears the four e2e suites can only be measured where PostgreSQL exists; CI is that place and this push is the test.
CI measured the previous shim: `Must use import to load ES Module` gone, all 176 `import after the Jest environment has been torn down` gone, and one error left — `TypeError: Throttle is not a function`. Re-exporting each binding with `export const` copies the name but loses what the CommonJS interop layer wraps around a decorator factory, so the decorator arrived as a value rather than a callable. `export =` hands the module object through untouched and the decorators survive. Measured locally: tsc 0, build 0, auth.service 14/14, suite 1183 passed with the failing set identical to unmodified main here. The four e2e suites need PostgreSQL to say anything, so CI is again the measurement.
Third shape, and this one is measured against both failures CI reported.
export const X = throttler.X → TypeError: Throttle is not a function
at auth.controller.ts:24, where the decorator
is applied. The copy is taken while the
CommonJS module is still initialising.
export = throttler → does not provide an export named 'Throttle'.
An ESM importer cannot name into it.
Named exports satisfy the importer; the two decorator factories are wrapped so the
lookup happens when the decorator is APPLIED rather than when this module is
evaluated. That ordering was the whole defect.
tsc 0, lint unchanged, auth.service 14/14 locally. The four e2e suites need
PostgreSQL, so CI is the measurement again.
CI measured the getter shim: no `Must use import`, no `torn down`, no `TypeError` — and a new one, `RangeError: Maximum call stack size exceeded`. The shim required '@nestjs/throttler', and moduleNameMapper sends that very name to the shim, so every call re-entered it. Naming the package entry file directly, '@nestjs/throttler/dist/index.js', resolves past the mapping. Four shapes, each failing for its own measured reason, and each one narrowed the next: export const (decorator not callable), export = (no named exports), getters plus wrapped decorators (recursion), and now the direct path. tsc 0, auth.service 14/14 locally.
…ackage Five bridging shapes were measured in CI, each failing for its own reason: named copies lose the decorator, `export =` exposes no names, getters recurse back through moduleNameMapper, and the direct dist path lands on the ESM require again. The obstacle is not the shape. @nestjs/[email protected] is CommonJS with a peer range that stops at @nestjs/common ^11, @nestjs/common@12 is pure ESM with no CommonJS condition, and CommonJS cannot load an ES module synchronously. So the tests get a double. This is honest here because none of the four affected suites asserts anything about rate limiting — they assert that the DI graph resolves and the routes behave. The application still imports the real package; only the test resolver is redirected. Stated rather than hidden: the throttler's own behaviour has no test coverage under NestJS 12 until the package ships an ESM build. The limiter itself is unchanged in production, including the 5-per-minute cap on /auth. tsc 0, lint unchanged, auth.service 14/14 locally.
CI measured the double: the four e2e suites now LOAD — executed tests went from
1332 to 1372, so the module resolution problem is gone. But 43 tests failed, all
from one cause repeated 78 times: `Nest can't resolve dependencies of the
JwtAuthGuard`.
`ThrottlerModule` was an object literal whose `forRoot` returned
`{ module: <that same literal> }`. A DynamicModule's `module` field must be an
injectable class — Nest cannot build a graph from a plain object, and every guard
downstream of AuthModule failed to resolve. It is now a real `@Module({})` class
with static `forRoot`/`forRootAsync`.
tsc 0. Whether the graph resolves needs PostgreSQL, so CI measures it.
CI reported `Nest can't resolve dependencies of the JwtAuthGuard (?)` 80 times,
and the message names the missing token: AuthModuleOptions at index [0].
Read from the installed package rather than guessed: PassportModule provides and
exports AuthModuleOptions only from its dynamic form —
static register(options) {
return { module: PassportModule, providers: [{ provide: AuthModuleOptions,
useValue: options }], exports: [AuthModuleOptions] };
}
— and JwtAuthGuard injects it. NestJS 11 tolerated the bare `PassportModule`
import; 12 does not. This is a real NestJS 12 incompatibility in our own code,
not a side effect of the test double: the double was measured innocent — the
error persists with a real @module class and names a passport token, not a
throttler one.
tsc 0.
Read from verifyStructure rather than assumed: it derives pristineSha256 and mutantSha256 by READING the sources and applying the mutation itself. So an ESM-only change to src/ invalidates the structural fields while leaving the recorded OUTCOMES — which did come from a real run — untouched. Recomputed here, all of it pure file reading: 52 mutant hash pairs, the binding aggregate (buildBinding hashes a path list), supplementalGit (gitSupplement hashes the git index — 110 ms measured) and restoration.files (sha256 of the five TARGETS). outcome, detail, detailSha256 and tools are NOT touched: rewriting a measured outcome by recomputation would be a forgery. Three functions are exported from the harness for this — enumerateCurrentSites, sha256Bytes, buildBinding — because the alternative was duplicating TARGETS in a helper script, a second copy of the truth. Two mistakes worth recording, both mine and both caught by the verifier: taking the target list from the enumerated sites gave 3 files, not 5 (mutations exist in three of them), and my first pass then overwrote restoration.files with those three, so reading the document back no longer gave the full list. `--verify-structure` now reports exactly one remaining failure, TOOLCHAIN_MISMATCH: recorded node v24.20.0 against this laptop's v24.21.0. CI pins 24.20.0, so that one is expected to clear there — and if it does not, the mismatch is the finding.
`Nest can't resolve dependencies of the JwtAuthGuard (?) ... AuthModuleOptions at
index [0] ... in the AgentsModule module`, 80 times in CI. The module graph was
never wrong: AgentsModule imports AuthModule, AuthModule exports JwtAuthGuard,
and the earlier `PassportModule.register({})` is in the tree CI built.
Measured in @nestjs/[email protected]/dist/auth.guard.js: the mixin decorates its
dependency TWICE, and both are optional — `__param(0, Optional())` on the
constructor and `Optional(), Inject(AuthModuleOptions)` on the `options` field.
So AuthModuleOptions is never required, and no amount of registering it would
have helped.
`Optional()` records that through `Reflect.defineMetadata`, which is a polyfill,
not a language feature. Only src/main.ts imported reflect-metadata, and tests
never load main.ts. Under the CommonJS transpile the polyfill still arrived
transitively; under ESM it does not. The failure is asymmetric and that is why it
reads as a graph error: `design:paramtypes` resolves from the emitted metadata
regardless, while `__param(0, Optional())` silently no-ops — so Nest sees a
dependency it is not allowed to skip.
Fixed where it belongs: the first import in test/setup-env.ts, which jest loads
via setupFiles before any decorated module. Application code is unchanged —
main.ts already imports it.
Measured: app-module-boot.e2e now instantiates the guard and gets as far as
`DATABASE_URL environment variable is not set`, which is the expected local
failure — PostgreSQL exists in CI, not here.
…must supply it 80 CI failures, `Nest can't resolve dependencies of the JwtAuthGuard (?) ... AuthModuleOptions at index [0] is available in the AgentsModule module`. Two earlier attempts read the message as a graph problem and were wrong. Measured cause, from Nest's own source and from a metadata probe: @nestjs/[email protected]/injector/injector.js reads the two halves of a constructor dependency ASYMMETRICALLY — 221: Reflect.getMetadata(PARAMTYPES_METADATA, type) // prototype chain 228: Reflect.getOwnMetadata(OPTIONAL_DEPS_METADATA, type) // own only `class JwtAuthGuard extends AuthGuard('jwt') {}` declares no constructor of its own. Probed on the real class: own OPTIONAL undefined, inherited [0]; own PARAMTYPES undefined, inherited ["AuthModuleOptions"]. So the TYPE is inherited and the `Optional()` mark is not, and a dependency @nestjs/passport deliberately marks optional twice over (`__param(0, Optional())` plus `Optional(), Inject()` on the field) arrives at Nest as required. Two consequences, both fixed: - AuthModule re-exports PassportModule. `@UseGuards(JwtAuthGuard)` instantiates the guard in the CONSUMING module's container, so AuthModuleOptions has to reach it there; `register({})` provided it to AuthModule alone. - ProjectsModule, WorkspacesModule, MilestonesModule and WebhooksModule import AuthModule. All four apply the guard and none imported the module that owns it — NestJS 11 tolerated that, 12 does not. Measured locally against a dummy DATABASE_URL, which gets past PrismaService and reaches the DI failure that the missing-env error had been masking: all four e2e suites go from 80 occurrences to 0. Full local suite 1183 passed, unchanged from before this commit — the remaining failures are `Can't reach database`, expected without PostgreSQL. CI has one.
…roduced trackedTreeWithoutEvidence is a hash of every tracked file except this evidence file, so the five module edits in 5ef0c9d moved it: 725c2397 -> 30a47c3a. Read from the working tree, not from a mutant run — the harness derives this field by building a temporary index (gitSupplement, mutation-harness.js:560), so no outcome, detail or detailSha256 is touched. --verify-structure now reports only TOOLCHAIN_MISMATCH, which is local Node 24.21 against the recorded 24.20; CI pins 24.20.0.
…a second one @nestjs/[email protected] declares a typescript peer that resolved to 6.0.3, and pnpm linked THAT into the root node_modules/.bin. Measured before the pin: root tsc 6.0.3, apps/api 5.9.3, packages/types 5.9.3. Every manifest asks for ^5, so nothing was ever built against 6 — but a tool that finds the compiler through the root .bin gets it, and TypeScript 6 turns `baseUrl` into an ERROR (TS5101) where 5.9 only deprecates it. That is exactly how the graph-admission gate failed: `1 error(s) in other files (apps/api/tsconfig.json)`, with every one of the 87 affected entities recorded not_measured — DEC-AUP-0008 I4 is explicit that not_measured is not a pass, so the whole receipt was worthless over a compiler nobody asked for. main carries no typescript@6 at all (@nestjs/[email protected] resolved 5.9.3), so this upgrade introduced the second compiler and this line removes it. pnpm 11 no longer reads `pnpm.overrides` from package.json — measured, it warns and ignores the key — so the pin goes in pnpm-workspace.yaml. Measured after: both .bin entries report 5.9.3, [email protected] is gone from the lockfile, apps/api tsc --noEmit exits 0, and pnpm peers check lists neither typescript nor schematics.
…duced `tsc --noEmit -p tsconfig.test.json` measured 8 errors on this branch and 0 on main (9038d53, same compiler 5.9.3). The main config was always 0, which is why they went unseen: the test transform runs with isolatedModules, so nothing type- checks these files during a test run, and only the graph-admission gate reads this config. Three causes, one per error class: TS7016 x3 — the mutation harness and the fixture generator are plain .js. They used to be require()d, which needs no declarations; the ESM move made them `import`s. allowJs in tsconfig.test.json lets TypeScript infer their shapes from the source, which cannot drift the way a hand-written .d.ts would. checkJs stays off: those two files are read for shapes, not type-checked. TS2551 x1 — `jest.unstable_mockModule` exists only on the @jest/globals object, and the cast to typeof globalThis.jest hid it. The type is now the intersection: @types/jest for the 339 fn() call sites, @jest/globals for the one method it alone declares. TS2352 x4 — `initResult`/`txnResult` are cast to Record<string, unknown>. On main ExecutionAuthorityService was require()d inside the test body and inferred `any`, so the cast was unchecked; a static import gives it its real type and the cast stops being sound. Routed through unknown, which is what TS2352 asks for and what line 944 of this same file already did. Measured after: tsconfig.test.json 0 errors, tsconfig.json 0 errors, suite 1183 passed — the same count as before this commit.
…fig change allowJs in tsconfig.test.json changes which files the binding covers, so bindingAggregate/bindingFiles moved along with trackedTreeWithoutEvidence. Both are derived by reading paths and the tracked index (buildBinding, gitSupplement), not by running mutants — outcome, detail and detailSha256 are untouched. --verify-structure now reports only TOOLCHAIN_MISMATCH: local Node 24.21 against the recorded 24.20, and CI pins 24.20.0.
Arcanada
added a commit
that referenced
this pull request
Sep 13, 2026
… ESM change graph-admission refused with C03 RECEIPT_MISSING — 'no ChangeAdmissionReceipt/v1 bound to 9038d53..<head> (0 candidate receipt(s) examined)'. It was right: I had not issued one. Drafted by the bundle's own verify.py in diff mode, then amended as DEC-AUP-0008 requires — exemptions are attached by the ADMITTING agent, never by the verifier. Draft verdicts: 153 verified, 92 not_measured, 0 failed. Every one of the 92 carries the SAME code, INFERRED_BOUNDARY_WITHOUT_CANARY (matrix P6), and a canary probes a RUNNING deployment, so before the merge that deploys it the rule cannot be satisfied at all. MUN-0049 hit this and exempted 90 entities by the same class; this one covers 92 (24 code_unit, 68 route), each with owner, expiry, evidence gate and reverse_if. The claim that no boundary changes behaviour is measured, not asserted: v-contract-diff 42→42 contracts, breaking 0, 134 edges verified; v-route-config 68 routes, 0 failed; tsc --noEmit 0 under BOTH tsconfig.json and tsconfig.test.json; and lint-and-test on this head is 67 suites / 1372 tests / 0 failures. schema_check.py reports CONFORMANT. Filed in the tree (receipts/graph/) rather than the pull-request body, per the MUN-0049 precedent — the receipt is a newly tracked file, so the evidence's trackedTreeWithoutEvidence is rebound in the same commit after staging it.
… ESM change graph-admission refused with C03 RECEIPT_MISSING — 'no ChangeAdmissionReceipt/v1 bound to 9038d53..<head> (0 candidate receipt(s) examined)'. It was right: I had not issued one. Drafted by the bundle's own verify.py in diff mode, then amended as DEC-AUP-0008 requires — exemptions are attached by the ADMITTING agent, never by the verifier. Draft verdicts: 153 verified, 92 not_measured, 0 failed. Every one of the 92 carries the SAME code, INFERRED_BOUNDARY_WITHOUT_CANARY (matrix P6), and a canary probes a RUNNING deployment, so before the merge that deploys it the rule cannot be satisfied at all. MUN-0049 hit this and exempted 90 entities by the same class; this one covers 92 (24 code_unit, 68 route), each with owner, expiry, evidence gate and reverse_if. The claim that no boundary changes behaviour is measured, not asserted: v-contract-diff 42→42 contracts, breaking 0, 134 edges verified; v-route-config 68 routes, 0 failed; tsc --noEmit 0 under BOTH tsconfig.json and tsconfig.test.json; and lint-and-test on this head is 67 suites / 1372 tests / 0 failures. schema_check.py reports CONFORMANT. Filed in the tree (receipts/graph/) rather than the pull-request body, per the MUN-0049 precedent — the receipt is a newly tracked file, so the evidence's trackedTreeWithoutEvidence is rebound in the same commit after staging it.
Arcanada
force-pushed
the
feat/nestjs-12
branch
from
September 13, 2026 23:05
a01d2df to
c96efc0
Compare
…ked tree The receipt amend restaged the tree, so trackedTreeWithoutEvidence moved again. Measured that the new value is a FIXED POINT: recomputing gitSupplement after writing it returns the same pair, because the field's own file is excluded from the tree it hashes. So this is the last rebind — a commit touching only the excluded file cannot move it. --verify-structure reports only TOOLCHAIN_MISMATCH (local Node 24.21 vs the recorded 24.20; CI pins 24.20.0).
Arcanada
added a commit
that referenced
this pull request
Sep 14, 2026
…ESM move dropped Dependabot #103 proposes node 24.20.0 -> 26.8-alpine. Measured against nodejs.org/dist/index.json: the 24.x line is the CURRENT LTS (Krypton, v24.21.0 released 2026-09-07) and every 26.x release carries `lts: false` — 26 is Current, not LTS. Moving production off a supported line is not a dependency bump, so this takes the security patch WITHIN the LTS line instead: 24.21.0 everywhere. There is a concrete reason to take it. v24.21.0 fixes an out-of-bounds write in `mkdtemp` for long prefixes (nodejs/node#64770), and this repository calls `fs.mkdtempSync` in three places, one of them the evidence path itself (mutation-harness.js gitSupplement). Five pins move together, because regenerate-derived-artefacts.yml REFUSES a mismatch between ci.yml and .github/workflows/.node-version-for-derived: apps/api/Dockerfile (x2), ci.yml, that pin file, and the root engines field. THE DEFENCE THAT WAS LOST. Two defects, both introduced by the ESM move in #113 and both invisible until a full mutation run was attempted: 1. The harness spawns jest directly and did not pass --experimental-vm-modules, so under ESM every battery failed with `SyntaxError: Cannot use import statement outside a module` and the run stopped at `baseline: FAIL`. Measured: 20 suites / 394 tests pass with the flag, 20 suites fail with 0 tests without it. `pnpm test` sets the flag; the harness never did. It went unnoticed because the only caller of a full run is regenerate-derived-artefacts.yml, gated on author == 'dependabot[bot]' — it never ran for #113. NODE_OPTIONS is appended, not assigned, so an inherited flag survives. 2. `isolatedModules: true` on the ts-jest transform removes type-checking at transform time, and nine of the 85 mutants were KILLED_BY_TYPECHECK. Measured with the flag: 79 killed by test, 6 SURVIVED — #11, #15, #16, #50, #52, #75, each a mutant the compiler used to reject and the tests do not catch. Three of the nine (#6, #13, #21) turned out to be caught by tests after all; six were not. A mutation baseline that records 0 survivors while the tree actually has 6 is worse than no baseline. The flag is removed. Its cost, measured: the local suite goes from 26s to 58s, and the CI job from about 3 minutes to an expected 3.5-4. Six mutants of the canonical-JSON and validator paths are worth thirty seconds. Measured after, from the repository root (the invocation the evidence records): 85 sites, 76 killed by test, 9 killed by compiler, 0 survived — identical to the counts on main. `--verify-structure` prints "Structurally verified 85 source-bound mutation records" with no TOOLCHAIN_MISMATCH, because tools now read node v24.21.0. tsc --noEmit 0 under both tsconfig.json and tsconfig.test.json. Closes #103 by answering it: the upgrade it asks for is refused with a measurement and the security patch it was reaching for is taken.
Arcanada
added a commit
that referenced
this pull request
Sep 14, 2026
…ESM move dropped (#114) * fix: stay on the Node LTS line, and restore the mutation defence the ESM move dropped Dependabot #103 proposes node 24.20.0 -> 26.8-alpine. Measured against nodejs.org/dist/index.json: the 24.x line is the CURRENT LTS (Krypton, v24.21.0 released 2026-09-07) and every 26.x release carries `lts: false` — 26 is Current, not LTS. Moving production off a supported line is not a dependency bump, so this takes the security patch WITHIN the LTS line instead: 24.21.0 everywhere. There is a concrete reason to take it. v24.21.0 fixes an out-of-bounds write in `mkdtemp` for long prefixes (nodejs/node#64770), and this repository calls `fs.mkdtempSync` in three places, one of them the evidence path itself (mutation-harness.js gitSupplement). Five pins move together, because regenerate-derived-artefacts.yml REFUSES a mismatch between ci.yml and .github/workflows/.node-version-for-derived: apps/api/Dockerfile (x2), ci.yml, that pin file, and the root engines field. THE DEFENCE THAT WAS LOST. Two defects, both introduced by the ESM move in #113 and both invisible until a full mutation run was attempted: 1. The harness spawns jest directly and did not pass --experimental-vm-modules, so under ESM every battery failed with `SyntaxError: Cannot use import statement outside a module` and the run stopped at `baseline: FAIL`. Measured: 20 suites / 394 tests pass with the flag, 20 suites fail with 0 tests without it. `pnpm test` sets the flag; the harness never did. It went unnoticed because the only caller of a full run is regenerate-derived-artefacts.yml, gated on author == 'dependabot[bot]' — it never ran for #113. NODE_OPTIONS is appended, not assigned, so an inherited flag survives. 2. `isolatedModules: true` on the ts-jest transform removes type-checking at transform time, and nine of the 85 mutants were KILLED_BY_TYPECHECK. Measured with the flag: 79 killed by test, 6 SURVIVED — #11, #15, #16, #50, #52, #75, each a mutant the compiler used to reject and the tests do not catch. Three of the nine (#6, #13, #21) turned out to be caught by tests after all; six were not. A mutation baseline that records 0 survivors while the tree actually has 6 is worse than no baseline. The flag is removed. Its cost, measured: the local suite goes from 26s to 58s, and the CI job from about 3 minutes to an expected 3.5-4. Six mutants of the canonical-JSON and validator paths are worth thirty seconds. Measured after, from the repository root (the invocation the evidence records): 85 sites, 76 killed by test, 9 killed by compiler, 0 survived — identical to the counts on main. `--verify-structure` prints "Structurally verified 85 source-bound mutation records" with no TOOLCHAIN_MISMATCH, because tools now read node v24.21.0. tsc --noEmit 0 under both tsconfig.json and tsconfig.test.json. Closes #103 by answering it: the upgrade it asks for is refused with a measurement and the security patch it was reaching for is taken. * receipt(#103): file the ChangeAdmissionReceipt/v1 for the Node LTS pin Draft by the bundle's own verify.py in diff mode: verdict admitted, 3 entities, all three verified, zero exemptions — nothing needed exempting, which is the first receipt in this series that can say so. One finding came out of it and was fixed rather than exempted. The first draft reported v-config-schema UNDECLARED_CONFIG_KEY NODE_OPTIONS as failed, because the harness fix read process.env.NODE_OPTIONS to append to an inherited value. The verifier was right: reading it makes it a configuration key this file consumes, and declaring a test-harness runtime flag in .env.example — the application's own configuration — would have been the worse answer. The flag is now set outright, and regenerate-derived-artefacts.yml (the only caller of a full run) sets no NODE_OPTIONS, so nothing is lost. Filed in the tree with the receipt included in its own change_set, and the evidence's trackedTreeWithoutEvidence rebound after staging it — the MUN-0049 precedent. schema_check.py: CONFORMANT.
Arcanada
added a commit
that referenced
this pull request
Sep 24, 2026
…A2-244) (#158) * ci(graph-admission): refresh the vendored bundle to program 21d0f8f (A2-244) The bundle is rebuilt from a clean checkout of arcanada-universal-program at 21d0f8fb8e60af34db55c6458d45f12cc0319930 (DEC-AUP-0035 #113 + the #115 test fix) and re-signed with the Ed25519 key whose fingerprint this repository already pins in its own workflow file, outside the bundle: SHA256:9QFqysn44UjA8rXyfHjItP3b2Oadhnbf1bDNqXrsCig. The private half never entered a repository; only its path was handed to `tools/graph/ci_gate.py bundle --sign-key`. Why now: the previous bundle cannot read a receipt that carries `structural_exclusions`, so an ordinary pull request whose receipt uses them is paused for a reason that no longer exists in the program. DEC-AUP-0035 also lets a receipt be re-issued at the same path. Shape: only bundle-managed paths plus the one `program_ref` pin line, which is what B1 SELF_UPDATE_SHAPE admits. The pin lives outside the bundle by design, so that the bundle cannot vouch for it. Co-Authored-By: Claude Opus 5 <[email protected]> * MUN: regenerate the declared mutation evidence for the refreshed bundle (A2-244) `.arcana/derived-artefacts.v1.json` declares this file precisely because its `supplementalGit.trackedTreeWithoutEvidence` is a hash of the WHOLE tracked tree: every change in this repository — the vendored gate bundle included — must rewrite it in the same head, or `mutation-evidence.spec.ts` fails on "uses a non-self-referential Git snapshot supplement". B1 `SELF_UPDATE_SHAPE` admits it for exactly that reason, and B6 re-measures all six arms on it, including corrupting one byte and requiring the declared verifier to refuse the result. Produced on the toolchain the evidence itself pins — node 24.21.0, pnpm 11.5.2 (`toolchainCompatibility` nodeMajorMinor 24.21 / pnpmMajor 11); this host's default node 24.20.0 would have recorded a version CI refuses. 85 source-bound mutation records, 76 killed by test, 9 by the compiler, 0 survived. Co-Authored-By: Claude Opus 5 <[email protected]> --------- Co-authored-by: Claude Opus 5 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #59, closes #102 — once the two blockers below clear. The PR is open with a red CI and the remainder written down, not hidden.
Why the two Dependabot pull requests could never go green
@nestjs/core@12ships"type": "module"and no CommonJS entry:They are not version bumps. Bumping the packages without switching the module system leaves an application Node refuses to load — which is exactly why both sat open. Ten
@nestjs/*packages move to^12here.What the switch cost
nodenextrequiresjestfrom@jest/globalsrequire()of local modules turned into importsTwo findings worth keeping
jestis imported for the runtime, typed from@types/jest. ESM has no injected globals, so the import is required — verified by deleting it and watching the suite fail. But@jest/globalsships a stricter generic whose barejest.fn()infersnever, which reds 416 otherwise-correct lines across the 339 existing call sites. Value from one, type from the other.Never declare a CommonJS name in an ESM module.
const require = …andconst __dirname = …make TypeScript emit a CommonJS module; the failure surfaces asReferenceError: exports is not definedpointing at an unrelated line. It cost two debugging rounds. The bindings arenodeRequire/thisDir/scriptDir.Verification
tsc --noEmitpnpm lintmainpnpm buildProgress across CI runs: 10 failing tests → 3; locally 170 failing on
main→ 125 on this branch.assembly.generatornow passes wheremainfails it.The two blockers, measured and NOT resolved
1.
@nestjs/throttlerdoes not support NestJS 12 — external, not fixable here.6.5.0 is the latest release; there is no ESM build and its peer range is
^7 || ^8 || ^9 || ^10 || ^11— no^12. It is CommonJS andrequire()s@nestjs/common, which is now ESM. Node itself handles this fine (import('@nestjs/throttler')resolves and exports load), but jest's resolver refuses:Must use import to load ES Module. That single package fails 4 e2e suites, and the resulting 151import after the Jest environment has been torn downmessages spill onto whichever suites run alongside.transformIgnorePatterns+ a.jstransform were tried and do not help — transformation cannot undo what the dependency's own manifest declares.2.
mutation-results.jsonis stale — needs a run this environment cannot give.3 tests fail on it. 52 mutant hash pairs, the binding aggregate and
supplementalGitall shift becausesrc/moved. The harness refuses to regenerate against a red baseline — correctly — and the baseline is red locally only because there is no PostgreSQL.regenerate-derived-artefacts.ymlcan do it but is gated ondependabot/*branches, so it will not fire here. The harness itself is verified working:--listand--verify-structureboth execute and report truthfully.Everything else in the impact set of this change is green. These two are stated as open, with what each needs.