diff --git a/apps/gateway/src/database/database.module.spec.ts b/apps/gateway/src/database/database.module.spec.ts index 246b761f7..acca97c94 100644 --- a/apps/gateway/src/database/database.module.spec.ts +++ b/apps/gateway/src/database/database.module.spec.ts @@ -18,7 +18,7 @@ */ import 'reflect-metadata'; -import { mkdir, mkdtemp } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -68,9 +68,30 @@ beforeEach(() => { vi.clearAllMocks(); }); -afterEach(() => { +/** + * Disposable fixture roots are removed, not merely created. + * + * A spec that mkdtemps per case and never unlinks leaks one directory per run + * forever; verification runs of this suite left 166 of them on the verifier's + * host. A suite that litters the machine it runs on is not a suite that can + * claim to prove disposability of anything. + */ +const tempRoots: string[] = []; + +async function trackTempRoot(path: string): Promise { + tempRoots.push(path); + return path; +} + +afterEach(async () => { process.env = { ...envSnapshot }; vi.resetModules(); + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root !== undefined) { + await rm(root, { recursive: true, force: true }); + } + } }); async function loadModuleUnderTest(): Promise<{ @@ -92,7 +113,7 @@ async function loadModuleUnderTest(): Promise<{ /** Establish a valid frozen local policy and return its state root. */ async function establishLocalChannel(): Promise { - const tempRoot = await mkdtemp(join(tmpdir(), 'kbn-02a-dbmod-')); + const tempRoot = await trackTempRoot(await mkdtemp(join(tmpdir(), 'kbn-02a-dbmod-'))); const anchor = join(tempRoot, 'repo', 'apps', 'gateway', 'src'); const homeBase = join(tempRoot, 'home'); const stateRoot = join(tempRoot, 'session-state'); @@ -254,18 +275,31 @@ describe('KBN-101-02a DatabaseModule — migrations are a lifecycle effect, not expect(runPgliteMigrationsMock).not.toHaveBeenCalled(); }); - it('migrates against the frozen policy rather than the injected config', async () => { + // Acceptance #6 — the positive anchor, and the only case in this file that is + // not a refusal. + // + // A refusal-only matrix is fully satisfied by an onModuleInit() that always + // throws. Dead production behavior and correct fail-closed behavior would be + // indistinguishable, and the item would ship having made local start safe by + // making it not work. The previous case here swallowed the error + // (`.catch(() => undefined)`) and then asserted zero migration, so it was + // satisfied by the same dead implementation it was supposed to exclude. + // + // This is the case that fails if the sterile local route stops working. + it('completes the sterile local lifecycle: PGlite and adapter migrations both run, no Postgres seam', async () => { vi.resetModules(); await establishLocalChannel(); - // The injected config claims a tier the policy does not allow. A lifecycle - // hook that branches on `this.config.tier` follows the injected value; one - // that reads the frozen policy does not. const { instance, adapterMigrate } = await instantiate({ - tier: 'standalone', + tier: 'local', storage: { type: 'pglite' }, }); - await instance.onModuleInit().catch(() => undefined); - expect(adapterMigrate).not.toHaveBeenCalled(); + await expect(instance.onModuleInit()).resolves.toBeUndefined(); + expect(runPgliteMigrationsMock, 'the sterile local route never migrated').toHaveBeenCalled(); + expect(adapterMigrate, 'the local storage adapter was never migrated').toHaveBeenCalled(); + expect( + createDbMock, + 'a Postgres handle was constructed on the sterile local route', + ).not.toHaveBeenCalled(); }); }); diff --git a/apps/gateway/src/env.spec.ts b/apps/gateway/src/env.spec.ts index ede1cea1e..918ce7f37 100644 --- a/apps/gateway/src/env.spec.ts +++ b/apps/gateway/src/env.spec.ts @@ -18,7 +18,7 @@ * before implementation. It is not claimed as a RED. */ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -47,8 +47,14 @@ beforeEach(() => { vi.clearAllMocks(); }); -afterEach(() => { +/** Temp roots created by makeBed, removed after every case. */ +const tempRoots: string[] = []; + +afterEach(async () => { process.env = { ...envSnapshot }; + for (const root of tempRoots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } vi.resetModules(); }); @@ -60,6 +66,7 @@ interface Bed { async function makeBed(): Promise { const tempRoot = await mkdtemp(join(tmpdir(), 'kbn-02a-env-')); + tempRoots.push(tempRoot); const anchor = join(tempRoot, 'repo', 'apps', 'gateway', 'src'); const homeBase = join(tempRoot, 'home'); const stateRoot = join(tempRoot, 'session-state'); diff --git a/apps/gateway/src/local-dev.spec.ts b/apps/gateway/src/local-dev.spec.ts index f70414cb0..f75774013 100644 --- a/apps/gateway/src/local-dev.spec.ts +++ b/apps/gateway/src/local-dev.spec.ts @@ -12,7 +12,7 @@ import { spawnSync } from 'node:child_process'; import { mkdtempSync, readFileSync } from 'node:fs'; -import { readFile, mkdtemp, realpath } from 'node:fs/promises'; +import { readFile, mkdtemp, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -72,18 +72,38 @@ function expectZeroSideEffects(): void { let envSnapshot: NodeJS.ProcessEnv; +/** + * Every temp root this file creates, removed after the case that made it. + * + * Both the in-process beds and the child-process route beds register here. The + * child route's root is created synchronously inside the spawn arguments, which + * is precisely the placement most likely to be missed — it is not next to any + * other setup. + */ +const tempRoots: string[] = []; + beforeEach(() => { envSnapshot = { ...process.env }; vi.clearAllMocks(); }); -afterEach(() => { +afterEach(async () => { process.env = { ...envSnapshot }; + for (const root of tempRoots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } vi.resetModules(); }); type LocalDevModule = typeof LocalDevNamespace; +/** A state root for the executed child route, registered for teardown. */ +function makeRouteStateRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'kbn-02a-route-')); + tempRoots.push(root); + return root; +} + async function freshLocalDev(): Promise { vi.resetModules(); return import('./local-dev.js'); @@ -92,6 +112,7 @@ async function freshLocalDev(): Promise { /** A well-formed local declaration, planted in the ambient environment. */ async function declareAdmissibleLocalStart(mod: LocalDevModule): Promise { const stateRoot = await realpath(await mkdtemp(join(tmpdir(), 'kbn-02a-localdev-'))); + tempRoots.push(stateRoot); process.env[mod.LOCAL_DEV_TIER_VAR] = 'local'; process.env[mod.LOCAL_DEV_STORAGE_VAR] = 'pglite'; process.env[mod.LOCAL_DEV_QUEUE_VAR] = 'local'; @@ -334,7 +355,7 @@ describe('KBN-101-02a local-dev — the package route refuses, not merely the mo MOSAIC_LOCAL_DEV_TIER: 'local', MOSAIC_LOCAL_DEV_STORAGE: 'pglite', MOSAIC_LOCAL_DEV_QUEUE: 'local', - MOSAIC_LOCAL_DEV_STATE_ROOT: mkdtempSync(join(tmpdir(), 'kbn-02a-route-')), + MOSAIC_LOCAL_DEV_STATE_ROOT: makeRouteStateRoot(), }); expect(signal, 'the route hung and had to be killed').toBeNull(); diff --git a/apps/gateway/src/local-start/local-start-policy.spec.ts b/apps/gateway/src/local-start/local-start-policy.spec.ts index 985a3a1b5..2675b0c9e 100644 --- a/apps/gateway/src/local-start/local-start-policy.spec.ts +++ b/apps/gateway/src/local-start/local-start-policy.spec.ts @@ -11,7 +11,7 @@ */ import type * as NodeFs from 'node:fs'; -import { chmod, mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -36,11 +36,16 @@ async function freshPolicy(): Promise { /** Undo module substitutions so one case's mock cannot leak into the next. */ const mockedFsCases: (() => void)[] = []; +/** Temp roots created by makeBed, removed after every case. */ +const tempRoots: string[] = []; -afterEach(() => { +afterEach(async () => { while (mockedFsCases.length > 0) { mockedFsCases.pop()?.(); } + for (const root of tempRoots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } vi.resetModules(); }); @@ -54,6 +59,7 @@ interface Bed { /** A clean layout: no source carries anything forbidden. */ async function makeBed(): Promise { const tempRoot = await mkdtemp(join(tmpdir(), 'kbn-02a-policy-')); + tempRoots.push(tempRoot); const anchor = join(tempRoot, 'repo', 'apps', 'gateway', 'src'); const homeBase = join(tempRoot, 'home'); const stateRoot = join(tempRoot, 'session-state'); diff --git a/apps/gateway/src/local-start/source-inventory.spec.ts b/apps/gateway/src/local-start/source-inventory.spec.ts index e9d249870..ac5c32bd0 100644 --- a/apps/gateway/src/local-start/source-inventory.spec.ts +++ b/apps/gateway/src/local-start/source-inventory.spec.ts @@ -11,7 +11,7 @@ * These read fixture files under a temp root. Nothing here connects to anything. */ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -52,6 +52,13 @@ async function writeFixture(path: string, contents: string): Promise { async function makeLayout(): Promise { const tempRoot = await mkdtemp(join(tmpdir(), 'kbn-02a-inventory-')); + // The stack above is drained in afterEach. It existed before this line did, + // and nothing pushed to it — so every case in this file left its temp root + // behind. Registering at the point of creation is the only placement that + // cannot be forgotten by a case added later. + cleanups.push(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); const repo = join(tempRoot, 'repo'); const anchor = join(repo, 'apps', 'gateway', 'src'); const homeBase = join(tempRoot, 'home'); @@ -150,7 +157,10 @@ describe('KBN-101-02a inventoryLocalStartSources', () => { env: { MOSAIC_STORAGE_TIER: 'standalone' }, }); - expect(find(sources, 'process-environment').declaredTier).toBe('standalone'); + // Classification, not quotation: 'standalone' is the planted VALUE, and the + // inventory type cannot carry it. Demanding it here would be a red no + // compliant implementation could turn green. + expect(find(sources, 'process-environment').declaredTier).toBe('non-local'); }); // Row 2 — shared dotenv, one case per file so they fail independently. @@ -204,7 +214,7 @@ describe('KBN-101-02a inventoryLocalStartSources', () => { const daemonJson = find(sources, 'daemon-json'); expect(daemonJson.carriesPostgresDsn).toBe(true); - expect(daemonJson.declaredStorageType).toBe('postgres'); + expect(daemonJson.declaredStorageType).toBe('non-pglite'); }); it('row 4: detects postgres storage declared in the anchored app mosaic.config.json', async () => { @@ -439,3 +449,99 @@ describe('KBN-101-02a inventoryLocalStartSources — no source value is retained expect(written.join('\n')).not.toContain(CANARY); }); }); + +/** + * Readability is reported per source, and absence is its own classification. + * + * The refusal path in local-start-policy.ts consumes these rows, and it treats + * "read, and clean" as the opposite of "contents unknown". If an absent source + * has to be described as `readable`, those two collapse into one value and the + * refusal loses the distinction it is built on. So all four states are asserted + * directly here rather than inferred from a refusal downstream. + * + * The unreadable fixture is a DIRECTORY where a file is expected. `chmod 0o000` + * would be the obvious choice and is worthless: root ignores the mode bits, so + * the case would pass vacuously wherever CI runs as root. EISDIR holds for every + * uid. + */ +describe('KBN-101-02a inventoryLocalStartSources — readability is classified per source', () => { + it('classifies an absent source as absent, never as readable', async () => { + const layout = await makeLayout(); + // Nothing planted: the anchored app JSON does not exist. + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + const appJson = find(sources, 'app-json'); + expect(appJson.present).toBe(false); + expect(appJson.readability).toBe('absent'); + }); + + it('classifies a present, parseable source as readable', async () => { + const layout = await makeLayout(); + await writeFixture(layout.paths['app-json'], CLEAN_LOCAL_JSON); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + const appJson = find(sources, 'app-json'); + expect(appJson.present).toBe(true); + expect(appJson.readability).toBe('readable'); + }); + + it('classifies a source that cannot be read as unreadable, not as absent', async () => { + const layout = await makeLayout(); + // A directory at the file's path: open() fails with EISDIR for every uid. + await mkdir(layout.paths['app-json'], { recursive: true }); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + const appJson = find(sources, 'app-json'); + expect(appJson.readability).toBe('unreadable'); + expect(appJson.readability, 'an unreadable source was reported as absent').not.toBe('absent'); + }); + + it('classifies a present but unparseable source as malformed, not as readable', async () => { + const layout = await makeLayout(); + await writeFixture(layout.paths['app-json'], '{ "tier": "local", '); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + const appJson = find(sources, 'app-json'); + expect(appJson.present).toBe(true); + expect(appJson.readability).toBe('malformed'); + }); + + it('holds present === false exactly when readability === absent, across every source', async () => { + const layout = await makeLayout(); + // A deliberately mixed bed: one readable, one malformed, one unreadable, + // the rest absent. The invariant has to hold for all seven rows at once. + await writeFixture(layout.paths['monorepo-root-json'], CLEAN_LOCAL_JSON); + await writeFixture(layout.paths['app-json'], '{ not json'); + await mkdir(layout.paths['gateway-local-dotenv'], { recursive: true }); + + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + expect(sources.length).toBe(LOCAL_START_SOURCE_LABELS.length); + for (const source of sources) { + expect( + source.readability === 'absent', + `${source.label}: present=${String(source.present)} readability=${source.readability}`, + ).toBe(source.present === false); + } + }); +}); diff --git a/apps/gateway/src/local-start/source-inventory.ts b/apps/gateway/src/local-start/source-inventory.ts index e73370fea..c3225b706 100644 --- a/apps/gateway/src/local-start/source-inventory.ts +++ b/apps/gateway/src/local-start/source-inventory.ts @@ -50,8 +50,16 @@ export type DeclaredStorageClass = 'pglite' | 'non-pglite'; * unreadable or malformed source is a source whose contents are UNKNOWN, and an * unknown source cannot be shown to be free of a forbidden value — so it refuses * rather than being skipped. + * + * `absent` is a member rather than an implication of `present: false`. Without + * it the two fields can disagree — an absent source has to be reported as + * `readable`, which is the one classification that means "read, and clean". A + * reader that stats a path and finds nothing there would then be indistinguishable + * from a reader that opened a file and found it safe, and the refusal path treats + * those two as opposites. The invariant is exact and total: + * `present === false` if and only if `readability === 'absent'`. */ -export type SourceReadability = 'readable' | 'unreadable' | 'malformed'; +export type SourceReadability = 'readable' | 'unreadable' | 'malformed' | 'absent'; /** * What one source was observed to carry. @@ -93,6 +101,8 @@ export interface InventoryInput { * `$HOME/.config/mosaic/gateway/.env` regardless of the redirect; * - a source that cannot be read or parsed is reported as such, never skipped * and never treated as clean; + * - `present === false` if and only if `readability === 'absent'`; an absent + * source is never reported as `readable`; * - no precedence is applied and no value is retained. */ export function inventoryLocalStartSources(_input: InventoryInput): InventoriedSource[] { diff --git a/apps/gateway/src/main.spec.ts b/apps/gateway/src/main.spec.ts index 2da2b5f95..f12b41b90 100644 --- a/apps/gateway/src/main.spec.ts +++ b/apps/gateway/src/main.spec.ts @@ -7,7 +7,7 @@ import type * as NestCore from '@nestjs/core'; import type * as MosaicConfigModule from '@mosaicstack/config'; import type { MosaicConfig } from '@mosaicstack/config'; import type * as MosaicStorage from '@mosaicstack/storage'; -import { describe, expect, it, vi, type MockInstance } from 'vitest'; +import { afterEach, describe, expect, it, vi, type MockInstance } from 'vitest'; // Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs. const MODULE_IMPORT_TIMEOUT_MS = 120_000; @@ -209,6 +209,7 @@ describe('KBN-101-02a main.ts — importing the entrypoint starts nothing', () = // would leave these cases unable to go green for the reason they state even // after the defect is fixed. The HTTP instance is a permissive proxy so the // double does not have to track every route the controllers mount. + const listenMock = vi.fn(async () => {}); const nestCreateMock = vi.fn(async () => ({ enableCors: vi.fn(), register: vi.fn(async () => {}), @@ -217,7 +218,7 @@ describe('KBN-101-02a main.ts — importing the entrypoint starts nothing', () = getHttpAdapter: vi.fn(() => ({ getInstance: () => new Proxy({}, { get: () => vi.fn() }), })), - listen: vi.fn(async () => {}), + listen: listenMock, })); const detectAndAssertTierMock = vi.fn(async () => {}); const loadConfigMock = vi.fn(() => ({ @@ -227,8 +228,46 @@ describe('KBN-101-02a main.ts — importing the entrypoint starts nothing', () = memory: { type: 'keyword' as const }, })); + const tempRoots: string[] = []; + + afterEach(async (): Promise => { + for (const root of tempRoots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } + }); + + /** + * Import `main.js` with the current self-invocation effect captured. + * + * `main.ts:71` invokes bootstrap on import; it rejects, and the tail calls + * `process.exit(1)`. Vitest instruments `process.exit`, so that raised a + * global error attributed to no case at all — which both falsified this + * document's zero-non-assertion-failures claim and is exactly the shape + * Vitest warns can produce false positives. + * + * Stubbing `process.exit` non-throwing for the duration and awaiting a settle + * turn keeps the effect inside the case that provokes it. The behavioral + * assertions stay red — importing an entrypoint must not do this — but the + * error is now owned rather than loose. The spy is returned so callers can + * assert against it. + */ + async function importMainCapturingExit(): Promise<{ + mod: Record; + exitSpy: MockInstance; + }> { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as unknown as typeof process.exit); + const mod = (await import('./main.js')) as Record; + // Two turns: one for the bootstrap rejection, one for its catch handler. + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + return { mod, exitSpy }; + } + async function establishLocalChannel(): Promise { const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'kbn-02a-main-')); + tempRoots.push(tempRoot); const anchor = join(tempRoot, 'repo', 'apps', 'gateway', 'src'); const homeBase = join(tempRoot, 'home'); const stateRoot = join(tempRoot, 'session-state'); @@ -274,24 +313,20 @@ describe('KBN-101-02a main.ts — importing the entrypoint starts nothing', () = 'does not bootstrap, probe, or exit merely by being imported', async (): Promise => { const originalEnv = snapshotProcessEnv(); - const exitMock = vi.fn(); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock); + let exitSpy: MockInstance | undefined; try { delete process.env['BETTER_AUTH_SECRET']; vi.resetModules(); vi.clearAllMocks(); substituteStartupSurface(); - await import('./main.js'); - // A self-invoking entrypoint fails asynchronously, so give the rejection - // a turn to land before concluding that nothing happened. - await new Promise((resolve) => setImmediate(resolve)); + ({ exitSpy } = await importMainCapturingExit()); expect(exitSpy, 'importing main.ts exited the process').not.toHaveBeenCalled(); expect(detectAndAssertTierMock, 'importing main.ts probed the tier').not.toHaveBeenCalled(); expect(nestCreateMock, 'importing main.ts created the Nest app').not.toHaveBeenCalled(); } finally { - exitSpy.mockRestore(); + exitSpy?.mockRestore(); undoSubstitutions(); vi.resetModules(); restoreProcessEnv(originalEnv); @@ -307,8 +342,12 @@ describe('KBN-101-02a main.ts — importing the entrypoint starts nothing', () = try { vi.resetModules(); substituteStartupSurface(); - const mod = (await import('./main.js')) as { bootstrap?: unknown }; - expect(typeof mod.bootstrap, 'main.ts does not export bootstrap').toBe('function'); + const { mod, exitSpy } = await importMainCapturingExit(); + try { + expect(typeof mod['bootstrap'], 'main.ts does not export bootstrap').toBe('function'); + } finally { + exitSpy.mockRestore(); + } } finally { undoSubstitutions(); vi.resetModules(); @@ -333,14 +372,18 @@ describe('KBN-101-02a main.ts — importing the entrypoint starts nothing', () = }); await establishLocalChannel(); - const mod = (await import('./main.js')) as { bootstrap?: () => Promise }; - await mod.bootstrap?.(); + const { mod, exitSpy } = await importMainCapturingExit(); + try { + await (mod['bootstrap'] as (() => Promise) | undefined)?.(); - expect( - loadConfigMock, - 'bootstrap re-read configuration independently', - ).not.toHaveBeenCalled(); - expect(detectAndAssertTierMock, 'bootstrap probed the tier').not.toHaveBeenCalled(); + expect( + loadConfigMock, + 'bootstrap re-read configuration independently', + ).not.toHaveBeenCalled(); + expect(detectAndAssertTierMock, 'bootstrap probed the tier').not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + } } finally { undoSubstitutions(); vi.resetModules(); @@ -425,4 +468,58 @@ describe('KBN-101-02a main.ts — importing the entrypoint starts nothing', () = }, MODULE_IMPORT_TIMEOUT_MS, ); + + // The positive anchor for acceptance #6. Every other case in this file is a + // refusal or an absence, and a refusal-only matrix is satisfied by production + // code that does nothing at all: an exported `bootstrap()` with an empty body + // passes all of them. This case is the one that fails against that, so "safe" + // cannot come to mean "does not work". It is in-process and substituted at the + // Nest seam — no port is bound, no database is reached, nothing is started for + // real. + it( + 'starts the application through the frozen policy: create and listen are reached with no independent read', + async (): Promise => { + const originalEnv = snapshotProcessEnv(); + try { + process.env['BETTER_AUTH_SECRET'] = 'fixture-secret'; + vi.resetModules(); + vi.clearAllMocks(); + substituteStartupSurface(); + vi.doMock('@mosaicstack/config', async () => { + const actual = await vi.importActual('@mosaicstack/config'); + return { ...actual, loadConfig: loadConfigMock }; + }); + await establishLocalChannel(); + + const { mod, exitSpy } = await importMainCapturingExit(); + try { + const bootstrap = mod['bootstrap'] as (() => Promise) | undefined; + expect(typeof bootstrap, 'main.ts does not export bootstrap').toBe('function'); + await expect( + bootstrap!(), + 'bootstrap rejected on the established local channel', + ).resolves.toBeUndefined(); + + expect(nestCreateMock, 'bootstrap never created the Nest application').toHaveBeenCalled(); + expect(listenMock, 'bootstrap never reached listen').toHaveBeenCalled(); + expect( + loadConfigMock, + 'the started path re-read configuration independently', + ).not.toHaveBeenCalled(); + expect( + detectAndAssertTierMock, + 'the started path probed the tier independently', + ).not.toHaveBeenCalled(); + expect(exitSpy, 'the started path exited the process').not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + } + } finally { + undoSubstitutions(); + vi.resetModules(); + restoreProcessEnv(originalEnv); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); }); diff --git a/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md b/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md index 91a05ecea..4f877fc2f 100644 --- a/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md +++ b/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md @@ -442,9 +442,25 @@ its stated reason is the same defect class as the span gap that created this ite ### RED phase result -Focused matrix, gateway package: **77 failing / 15 passing (92 cases, 6 files)**. Typecheck and -lint clean. Zero non-assertion failures — no module-resolution, mock-setup or masking errors, so -every red is a behavioral assertion that names its own guard. +Focused matrix, gateway package: **82 failing / 16 passing (98 cases, 6 files)**, terminal +`Errors 0`. Typecheck, lint and Prettier clean. Every failure is a behavioral assertion that names +its own guard — the matrix output was scanned specifically for `TypeError`, `ReferenceError`, +missing-export and module-resolution text, not merely counted. + +**Correction to the previous report's error claim.** It said "zero non-assertion failures", and +that was wrong at the time it was written: the run also carried one out-of-band +`process.exit unexpectedly called with "1"`, raised by `main.ts:71` self-invoking on import and +rejecting after the importing case had already returned. Vitest attributes such an error to no +case and warns it can produce false positives. It is now captured inside the case that provokes +it — `process.exit` is stubbed non-throwing for the duration of the import and the case awaits a +settle turn — so the behavioral red is unchanged and the run reports `Errors 0`. The claim above +is a measurement, not an inference. + +The matrix leaves **zero `/tmp/kbn-02a-*` residue**, measured by removing all matching roots +before the run and counting after it. Every `mkdtemp` site in the six files registers its root for +teardown at the point of creation, including the child-process route's root, which is created +inside the spawn arguments and was the one most easily missed. The earlier passes leaked one +directory per case. Ten of the sixteen fenced paths are touched in RED. The six GREEN targets (`package.json`, `env.ts`, `app.module.ts`, `config/config.module.ts`, `database/database.module.ts`, `main.ts`) @@ -472,32 +488,59 @@ The bars are therefore proven through the actual entrypoint for the probe and ap side effects, and through the real database lifecycle for the migration side effects. They are not yet proven through a full boot, and this document does not claim they are. -The fifteen passing cases are itemized rather than counted as coverage, because a green that +**Positive anchors — added because a refusal-only matrix is satisfiable by dead code.** Every +other case in this item asserts an absence: no read, no probe, no migration, no application. A +`bootstrap()` with an empty body and an `onModuleInit()` that always refuses would satisfy all of +them, and "safe" would come to mean "does not work". Two cases fail against exactly that: +`main.spec.ts` requires the exported `bootstrap()` to reach `NestFactory.create` and `listen` +under an established frozen policy with zero independent read or probe (in-process, substituted at +the Nest seam — no port is bound), and `database.module.spec.ts` requires the sterile local +lifecycle to actually run both PGlite and adapter migrations with no Postgres handle constructed. +The second is green today and must stay green; it replaced a case that awaited +`onModuleInit().catch(() => undefined)` and asserted only an absence, which a total failure +satisfied. + +The sixteen passing cases are itemized rather than counted as coverage, because a green that cannot fail for its stated reason is the same defect class as the span gap that created this item: -| Passing case | Why it is green | -| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | -| env: config read on import, no policy | legacy regression guard — expected green | -| env: planted dotenv loads, no policy | legacy regression guard — expected green | -| main: anchored config reaches `detectAndAssertTier` | current-contract regression guard; its import-time-exit premise is superseded and must migrate at GREEN | -| local-dev: production `dev` script untouched | regression guard — expected green | -| local-dev: no static import of `main.js` | structural, three forms (binding, side-effect, `require`); must stay green | -| local-dev: importing starts nothing | structural; green today only because nothing invokes `runLocalDev` | -| policy: refuses to serve when unestablished | scaffolding implements this correctly already | -| policy: no fallback read when unestablished | scaffolding implements this correctly already | -| inventory: retains no raw DSN text | **vacuous today** — the stub returns an empty inventory | -| inventory: retains no planted canary | **vacuous today** — same empty inventory | -| inventory: writes no planted canary to the console | **vacuous today** — the stub reads no source, so it can log none | -| policy: children beneath the state root | **vacuous today** — the stub concatenates the declared root | -| policy: unaffected by post-establish input mutation | **vacuous today** — the stub captured strings, it did not clone | -| policy: frozen policy survives a source file gaining a DSN | **vacuous today** — the stub re-serves the same object it built | -| policy: frozen policy survives the environment gaining a DSN | **vacuous today** — same | +| Passing case | Why it is green | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| env: config read on import, no policy | legacy regression guard — expected green | +| env: planted dotenv loads, no policy | legacy regression guard — expected green | +| main: anchored config reaches `detectAndAssertTier` | current-contract regression guard; its import-time-exit premise is superseded and must migrate at GREEN | +| local-dev: production `dev` script untouched | regression guard — expected green | +| local-dev: no static import of `main.js` | structural, three forms (binding, side-effect, `require`); must stay green | +| local-dev: importing starts nothing | structural; green today only because nothing invokes `runLocalDev` | +| policy: refuses to serve when unestablished | scaffolding implements this correctly already | +| policy: no fallback read when unestablished | scaffolding implements this correctly already | +| inventory: retains no raw DSN text | **vacuous today** — the stub returns an empty inventory | +| inventory: retains no planted canary | **vacuous today** — same empty inventory | +| inventory: writes no planted canary to the console | **vacuous today** — the stub reads no source, so it can log none | +| policy: children beneath the state root | **vacuous today** — the stub concatenates the declared root | +| policy: unaffected by post-establish input mutation | **vacuous today** — the stub captured strings, it did not clone | +| policy: frozen policy survives a source file gaining a DSN | **vacuous today** — the stub re-serves the same object it built | +| policy: frozen policy survives the environment gaining a DSN | **vacuous today** — same | +| database: sterile local lifecycle migrates, no Postgres seam | positive anchor — green today, must stay green; the one case that fails if GREEN makes "safe" mean "inert" | -Seven greens are vacuous until the implementation exists, up from three, because this pass added -retention and post-freeze-mutation cases that a stub satisfies trivially. They are evidence of -nothing today and are re-checked at GREEN. The count rising is the honest outcome of asserting +Seven greens are vacuous until the implementation exists, up from three, because an earlier pass +added retention and post-freeze-mutation cases that a stub satisfies trivially. They are evidence +of nothing today and are re-checked at GREEN. The count rising is the honest outcome of asserting more about a module that does not exist yet — it is recorded here so it cannot later be read as -coverage. +coverage. Of the nine non-vacuous greens, seven are guards that must stay green (two legacy +continuity, the untouched `dev` script, the structural no-static-import, the two unestablished- +policy refusals, and the database positive anchor) and two are green for a weaker reason than they +read: `local-dev` "starts nothing on import" is green only because nothing yet invokes +`runLocalDev`, and the `main.ts` anchoring case guards a current contract whose import-time-exit +premise `-02a` inverts, so it must be migrated at GREEN rather than deleted. + +**Classification is a type obligation, not only a wording one.** Two inventory cases demanded the +raw source values `'standalone'` and `'postgres'`, which the value-free classification unions +cannot represent — a compliant implementation could never have turned them green. They now assert +`'non-local'` and `'non-pglite'`. In the same vein `SourceReadability` gained `absent`: without +it an absent source has to be reported `readable`, which is the one value meaning "read, and +clean", collapsing the exact distinction the refusal path is built on. The invariant +`present === false` if and only if `readability === 'absent'` is asserted directly, per state and +across all seven rows at once. ### Scope discipline