From 97c530676844779297247650aa92f1d6a54c70b6 Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 11:14:15 +0000 Subject: [PATCH] test: lock in pg/Kysely import isolation to database-postgres (E00-S03-T02) - database-postgres-imports.test.mjs: static scan of every workspace package source proves pg/kysely import specifiers resolve only to packages/database-postgres; owner manifest pins the driver and no other package declares it; mutation probes prove the scan catches a driver import injected into apps/server/src/index.ts; comment-stripping and specifier matcher unit probes; CI-enforcement assertion - workspace-layout / workspace-config / strict-tsconfig / typescript-pin: package-set fixtures updated to include packages/database-postgres - docs/development/non-container.md: workspace package table and build expectations updated for the new package --- docs/development/non-container.md | 10 +- tests/database-postgres-imports.test.mjs | 490 +++++++++++++++++++++++ tests/strict-tsconfig.test.mjs | 2 +- tests/typescript-pin.test.mjs | 2 +- tests/workspace-config.test.mjs | 2 +- tests/workspace-layout.test.mjs | 1 + 6 files changed, 500 insertions(+), 7 deletions(-) create mode 100644 tests/database-postgres-imports.test.mjs diff --git a/docs/development/non-container.md b/docs/development/non-container.md index 57254b7..10aea97 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -17,6 +17,7 @@ The workspace is a pnpm monorepo with three package groups: | --- | --- | --- | | `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story. | | `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. | +| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the concrete adapter lands in later stories. | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | A dependency-boundary rule (`dependency-boundaries.json`, enforced by @@ -74,8 +75,9 @@ pnpm typecheck `pnpm build` runs `tsc -p tsconfig.json` in each package in topological order and emits `dist/` (JavaScript + type declarations + source maps) per package. -Expected result: `apps/server/dist/`, `packages/core/dist/` and -`extensions/example/dist/` are produced, all packages report `Done`, exit 0. +Expected result: `apps/server/dist/`, `packages/core/dist/`, +`packages/database-postgres/dist/` and `extensions/example/dist/` are +produced, all packages report `Done`, exit 0. `dist/` is git-ignored; rebuild whenever you change `src/`. ## Run @@ -119,8 +121,8 @@ This is also the suite that enforces the dependency-boundary rule. git clone https://git.stevanovic.co.uk/Fabrika/PersonalBlog.git && cd PersonalBlog corepack enable pnpm install --frozen-lockfile # exit 0, lockfile untouched -pnpm build # 3/3 packages emit dist/, exit 0 -pnpm typecheck # 3/3 packages pass --noEmit, exit 0 +pnpm build # 4/4 packages emit dist/, exit 0 +pnpm typecheck # 4/4 packages pass --noEmit, exit 0 pnpm test # 10/10 pass, exit 0 pnpm --filter @personal-blog/server start # serves GET /health on port 3000, stays up ``` diff --git a/tests/database-postgres-imports.test.mjs b/tests/database-postgres-imports.test.mjs new file mode 100644 index 0000000..1117f67 --- /dev/null +++ b/tests/database-postgres-imports.test.mjs @@ -0,0 +1,490 @@ +/** + * Database-postgres import isolation test — locks in the [E00-S03-T02] rule + * that `pg`/Kysely imports live only in the `database-postgres` package. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "pg/Kysely imports are isolated to database-postgres" -> the workspace + * package `packages/database-postgres` (`@personal-blog/database-postgres`) + * exists, declares the driver as its own dependencies (`pg`, `kysely` + * exact pins, `@types/pg` for types) and its source really imports the + * driver; a static scan of every workspace package's source proves that + * every `pg`/`kysely` import specifier (static import/export-from, dynamic + * import, require) resolves to a file inside `packages/database-postgres` + * — and no other package's manifest declares the driver either. + * - "no other package imports the database driver directly" -> the same + * scan finds zero driver imports outside the owner package, and the + * committed state is non-vacuous: a mutation probe injects a `pg` import + * into `apps/server/src/index.ts` in a temp copy of the committed tree + * and the scan fails, naming the offending file and the driver; a clean + * copy passes (probe sanity). + * + * Run: `node --test tests/database-postgres-imports.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, writeFileSync, mkdirSync, cpSync, mkdtempSync, rmSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The workspace package allowed to import the PostgreSQL driver (E00-S03-T02). */ +const OWNER_DIR = 'packages/database-postgres'; + +/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */ +const ROOT_TEST_GLOB = 'tests/**/*.test.mjs'; + +/** The CI job that gates the isolation criterion on every PR. */ +const CI_JOB = 'database-postgres-imports'; + +/** Directories never scanned as package source. */ +const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git', '.pnpm-store']); + +/** The workspace groups scanned for source files (mirrors pnpm-workspace.yaml). */ +const GROUP_DIRS = ['apps', 'packages', 'extensions']; + +/** Extensions scanned as package source. */ +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']); + +/** + * True when a module specifier names the PostgreSQL driver: the `pg` package + * or Kysely (including their subpaths, e.g. `pg/connection-string` or + * `kysely/plugin/dialect`). `@types/pg` is a types-only package — never + * imported at runtime — so it is not a driver import. + */ +function isDriverSpecifier(specifier) { + return ( + specifier === 'pg' || + specifier.startsWith('pg/') || + specifier === 'kysely' || + specifier.startsWith('kysely/') + ); +} + +// --------------------------------------------------------------------------- +// Comment stripping (string/comment aware, preserves line count) +// --------------------------------------------------------------------------- + +/** + * Replaces comments with whitespace while preserving line structure, so + * specifier extraction never fires on commented-out imports and reported + * line numbers still match the original source. Mirrors the stripper in + * tests/architecture-import.test.mjs. + */ +function stripComments(source) { + let out = ''; + let i = 0; + const n = source.length; + let state = 'code'; // code | line | block | sq | dq | tpl + const tplStack = []; // template states to resume after `${...}` closes + let braceDepth = 0; + + while (i < n) { + const c = source[i]; + const next = source[i + 1]; + + if (state === 'code') { + if (tplStack.length > 0) { + if (c === '{') { + braceDepth += 1; + out += c; + i += 1; + continue; + } + if (c === '}') { + braceDepth -= 1; + out += c; + i += 1; + if (braceDepth === 0) state = tplStack.pop(); + continue; + } + } + if (c === '/' && next === '/') { + out += ' '; + i += 2; + state = 'line'; + continue; + } + if (c === '/' && next === '*') { + out += ' '; + i += 2; + state = 'block'; + continue; + } + if (c === "'") { + out += c; + i += 1; + state = 'sq'; + continue; + } + if (c === '"') { + out += c; + i += 1; + state = 'dq'; + continue; + } + if (c === '`') { + out += c; + i += 1; + state = 'tpl'; + continue; + } + out += c; + i += 1; + continue; + } + + if (state === 'line') { + if (c === '\n') { + out += c; + i += 1; + state = 'code'; + } else { + out += ' '; + i += 1; + } + continue; + } + + if (state === 'block') { + if (c === '*' && next === '/') { + out += ' '; + i += 2; + state = 'code'; + } else { + out += c === '\n' ? c : ' '; + i += 1; + } + continue; + } + + if (state === 'sq' || state === 'dq') { + const quote = state === 'sq' ? "'" : '"'; + out += c; + if (c === '\\' && next !== undefined) { + out += next; + i += 2; + } else { + if (c === quote) state = 'code'; + i += 1; + } + continue; + } + + // template literal + out += c; + if (c === '\\' && next !== undefined) { + out += next; + i += 2; + continue; + } + if (c === '`') { + state = 'code'; + i += 1; + continue; + } + if (c === '$' && next === '{') { + out += next; + i += 2; + tplStack.push('tpl'); + braceDepth = 1; + state = 'code'; + continue; + } + i += 1; + } + return out; +} + +// --------------------------------------------------------------------------- +// Driver-import discovery +// --------------------------------------------------------------------------- + +const STATIC_IMPORT = /\b(?:import|export)\s+(?:type\s+)?(?:[\w*{},\s]*?\s+from\s*)?(['"])([^'"]+)\1/g; +const REQUIRE_CALL = /\brequire\s*\(\s*(['"])([^'"]+)\1\s*\)/g; +const DYNAMIC_IMPORT = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/g; + +/** Returns [{ specifier, index }] for every module specifier in the (stripped) source. */ +function extractSpecifiers(stripped) { + const found = []; + for (const re of [STATIC_IMPORT, REQUIRE_CALL, DYNAMIC_IMPORT]) { + re.lastIndex = 0; + let m; + while ((m = re.exec(stripped)) !== null) { + found.push({ specifier: m[2], index: m.index }); + } + } + found.sort((a, b) => a.index - b.index); + return found; +} + +function lineOf(stripped, index) { + return stripped.slice(0, index).split('\n').length; +} + +/** + * Returns [{ relPath, line, specifier }] for every pg/Kysely import found in + * the given source files. `relPath` is the file path relative to the scan + * root (posix), so reports read the same for the real workspace and for temp + * copies. + */ +function findDriverImports(files) { + const imports = []; + for (const file of files) { + const stripped = stripComments(file.content); + for (const { specifier, index } of extractSpecifiers(stripped)) { + if (isDriverSpecifier(specifier)) { + imports.push({ + relPath: file.relPath, + line: lineOf(stripped, index), + specifier, + }); + } + } + } + return imports; +} + +/** True when a scan-relative posix path sits inside the owner package. */ +function isInsideOwner(relPath) { + return relPath === OWNER_DIR || relPath.startsWith(`${OWNER_DIR}/`); +} + +/** Walks the workspace groups under a root and returns [{ relPath, content }] for source files. */ +async function collectSourceFiles(rootDir) { + const files = []; + async function walk(dir, relDir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const abs = path.join(dir, entry.name); + const rel = `${relDir}/${entry.name}`; + if (entry.isDirectory()) { + if (!IGNORED_DIRS.has(entry.name)) await walk(abs, rel); + continue; + } + if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) { + files.push({ relPath: rel, content: readFileSync(abs, 'utf8') }); + } + } + } + for (const group of GROUP_DIRS) { + await walk(path.join(rootDir, group), group); + } + return files; +} + +// --------------------------------------------------------------------------- +// Manifest helpers +// --------------------------------------------------------------------------- + +/** Returns [{ relPath, name, driverDeps }] for every workspace package manifest under a root. */ +async function collectManifests(rootDir) { + const manifests = []; + for (const group of GROUP_DIRS) { + let entries; + try { + entries = await readdir(path.join(rootDir, group), { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifestPath = path.join(rootDir, group, entry.name, 'package.json'); + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch { + continue; + } + const allDeps = { + ...(manifest.dependencies ?? {}), + ...(manifest.devDependencies ?? {}), + }; + manifests.push({ + relPath: `${group}/${entry.name}/package.json`, + name: manifest.name, + driverDeps: Object.keys(allDeps).filter(isDriverSpecifier), + }); + } + } + return manifests; +} + +/** A single driver import, formatted for violation reports. */ +function formatImport(driverImport) { + return ` ${driverImport.relPath}:${driverImport.line} imports "${driverImport.specifier}"`; +} + +// --------------------------------------------------------------------------- +// Temp-copy helpers (mutation probes over the committed tree) +// --------------------------------------------------------------------------- + +/** Copies the committed tree (minus ignored dirs) into a fresh temp dir. */ +function copyCommittedTree() { + const dir = mkdtempSync(path.join(os.tmpdir(), 'eppp-db-imports-')); + for (const entry of readdirSync(REPO_ROOT, { withFileTypes: true })) { + if (IGNORED_DIRS.has(entry.name) || entry.name === 'tests') continue; + cpSync(path.join(REPO_ROOT, entry.name), path.join(dir, entry.name), { + recursive: true, + }); + } + // The isolation test file itself is part of the scan surface in a real + // checkout; copying it keeps the temp copy's package graph identical. + mkdirSync(path.join(dir, 'tests'), { recursive: true }); + cpSync(path.join(REPO_ROOT, 'tests'), path.join(dir, 'tests'), { recursive: true }); + return dir; +} + +/** Injects a driver import into a package source inside a tree copy. */ +function injectDriverImport(rootDir, relPath, line) { + const abs = path.join(rootDir, relPath); + const original = readFileSync(abs, 'utf8'); + const injected = `${line}\n${original}`; + writeFileSync(abs, injected); + return abs; +} + +/** Runs the isolation scan over a root and returns the misplaced imports. */ +async function scanRoot(rootDir) { + const files = await collectSourceFiles(rootDir); + return findDriverImports(files).filter((d) => !isInsideOwner(d.relPath)); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test('packages/database-postgres exists, is the driver owner, and its source imports the driver', () => { + const manifest = JSON.parse(read(path.join(OWNER_DIR, 'package.json'))); + assert.equal(manifest.name, '@personal-blog/database-postgres'); + // The driver is owned here: exact pins, never ranges (reproducibility, + // same policy as typescript@6.0.3 at the root). + assert.equal(manifest.dependencies?.pg, '8.23.0', 'owner must pin pg exactly'); + assert.equal(manifest.dependencies?.kysely, '0.29.5', 'owner must pin kysely exactly'); + assert.equal(manifest.devDependencies?.['@types/pg'], '8.23.1', 'owner must pin @types/pg exactly'); + + // The boundary module really imports the driver — the isolation is + // exercised, not just declared. + const source = read(path.join(OWNER_DIR, 'src', 'index.ts')); + assert.match(source, /from\s+'pg'/, 'packages/database-postgres/src/index.ts must import pg'); + assert.match(source, /from\s+'kysely'/, 'packages/database-postgres/src/index.ts must import kysely'); +}); + +test('pg/Kysely imports are isolated to database-postgres in the real workspace', async () => { + const files = await collectSourceFiles(REPO_ROOT); + const imports = findDriverImports(files); + + // Non-vacuous: the driver is actually imported somewhere — and that + // somewhere is the owner package. + assert.ok(imports.length > 0, 'the workspace must contain pg/Kysely imports (in the owner package)'); + const ownerImports = imports.filter((d) => isInsideOwner(d.relPath)); + assert.ok( + ownerImports.some((d) => d.specifier === 'pg'), + 'the owner package must import pg', + ); + assert.ok( + ownerImports.some((d) => d.specifier === 'kysely'), + 'the owner package must import kysely', + ); + + const misplaced = imports.filter((d) => !isInsideOwner(d.relPath)); + assert.deepEqual( + misplaced, + [], + `no package other than database-postgres may import pg/Kysely:\n${misplaced.map(formatImport).join('\n')}`, + ); +}); + +test('no other package declares the database driver in its manifest', async () => { + const manifests = await collectManifests(REPO_ROOT); + assert.ok( + manifests.some((m) => m.relPath.startsWith(`${OWNER_DIR}/`)), + 'the owner package manifest must be discovered', + ); + const offenders = manifests.filter((m) => !m.relPath.startsWith(`${OWNER_DIR}/`) && m.driverDeps.length > 0); + assert.deepEqual( + offenders, + [], + `only ${OWNER_DIR} may declare pg/kysely as a dependency:\n` + + offenders + .map((m) => ` ${m.relPath} (${m.name}) declares ${m.driverDeps.join(', ')}`) + .join('\n'), + ); +}); + +test('the isolation scan catches a driver import injected into another package (mutation probe)', async () => { + const dir = copyCommittedTree(); + try { + injectDriverImport(dir, 'apps/server/src/index.ts', "import { Pool } from 'pg';"); + const misplaced = await scanRoot(dir); + assert.equal(misplaced.length, 1, `expected exactly one violation, got:\n${misplaced.map(formatImport).join('\n')}`); + assert.equal(misplaced[0].relPath, 'apps/server/src/index.ts'); + assert.equal(misplaced[0].specifier, 'pg'); + assert.equal(misplaced[0].line, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a copy of the committed tree without misplaced driver imports passes the scan (probe sanity)', async () => { + const dir = copyCommittedTree(); + try { + const misplaced = await scanRoot(dir); + assert.deepEqual( + misplaced, + [], + `a clean copy of the committed tree must pass the isolation scan:\n${misplaced.map(formatImport).join('\n')}`, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the specifier matcher flags pg/kysely imports and ignores other packages (unit probe)', () => { + for (const spec of ['pg', 'pg/connection-string', 'kysely', 'kysely/plugin/dialect']) { + assert.ok(isDriverSpecifier(spec), `"${spec}" must be flagged as a driver import`); + } + for (const spec of ['fastify', 'node:http', '@types/pg', 'pg-native', 'pg-pool', 'postgres-array', './internal']) { + assert.equal(isDriverSpecifier(spec), false, `"${spec}" must not be flagged as a driver import`); + } +}); + +test('comment stripping ignores commented-out driver imports (unit probe)', () => { + const src = [ + '// import { Pool } from \'pg\';', + '/* import { Kysely } from "kysely"; */', + "import { Pool } from 'pg';", + '', + ].join('\n'); + const imports = findDriverImports([{ relPath: 'apps/server/src/index.ts', content: src }]); + assert.equal(imports.length, 1); + assert.equal(imports[0].specifier, 'pg'); + assert.equal(imports[0].line, 3, 'the stripped line number must match the original source'); +}); + +test('the isolation criterion is enforced in CI', () => { + // Picked up by the root test command (root `scripts.test` glob). + const scripts = JSON.parse(read('package.json')).scripts ?? {}; + assert.equal( + scripts.test, + `node --test "${ROOT_TEST_GLOB}"`, + `root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`, + ); + // And a dedicated CI job gates it on every PR. + const workflow = read('.gitea/workflows/ci.yml'); + assert.ok( + workflow.includes(`node --test tests/database-postgres-imports.test.mjs`), + `CI must run the isolation suite (job "${CI_JOB}") on every PR`, + ); +}); diff --git a/tests/strict-tsconfig.test.mjs b/tests/strict-tsconfig.test.mjs index 467d0b9..965e25a 100644 --- a/tests/strict-tsconfig.test.mjs +++ b/tests/strict-tsconfig.test.mjs @@ -37,7 +37,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.. const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); /** The workspace packages that must compile under the strict base config. */ -const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'extensions/example']; +const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example']; /** The strict-family flags the committed base config must set. */ const STRICT_FAMILY_FLAGS = [ diff --git a/tests/typescript-pin.test.mjs b/tests/typescript-pin.test.mjs index 4908c1b..431a6e2 100644 --- a/tests/typescript-pin.test.mjs +++ b/tests/typescript-pin.test.mjs @@ -33,7 +33,7 @@ const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); const PINNED_TYPESCRIPT = '6.0.3'; /** Every workspace package that must resolve the pinned TypeScript version. */ -const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'extensions/example']; +const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example']; // --------------------------------------------------------------------------- // Tests diff --git a/tests/workspace-config.test.mjs b/tests/workspace-config.test.mjs index 8046402..45b95a5 100644 --- a/tests/workspace-config.test.mjs +++ b/tests/workspace-config.test.mjs @@ -28,7 +28,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.. const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); const WORKSPACE_GROUPS = ['apps/*', 'packages/*', 'extensions/*']; -const WORKSPACE_PACKAGES = ['.', 'apps/server', 'packages/core', 'extensions/example']; +const WORKSPACE_PACKAGES = ['.', 'apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example']; const PINNED_PNPM = '11.23.0'; // --------------------------------------------------------------------------- diff --git a/tests/workspace-layout.test.mjs b/tests/workspace-layout.test.mjs index 1014863..6c1eaa0 100644 --- a/tests/workspace-layout.test.mjs +++ b/tests/workspace-layout.test.mjs @@ -38,6 +38,7 @@ const TOP_LEVEL_GROUPS = ['apps', 'packages', 'extensions']; const EXPECTED_PACKAGES = { '@personal-blog/server': 'apps/server', '@personal-blog/core': 'packages/core', + '@personal-blog/database-postgres': 'packages/database-postgres', '@personal-blog/example-extension': 'extensions/example', };