From 47e18019089732ead94f1484b2be81a6701fc855 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 22 Sep 2026 11:57:50 +0200 Subject: [PATCH] chore(db): entity --check reports stray files; class-field census is a ratchet; migrate-encryption typechecked --- .github/workflows/test.yml | 8 ++++---- server/scripts/generate-entities.ts | 11 +++++++++++ server/tests/unit/db/entity-class-fields.test.ts | 12 +++++++++--- server/tests/unit/db/generate-entities.test.ts | 15 +++++++++++++++ server/tsconfig.scripts.json | 2 +- server/vitest.config.ts | 1 + 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c456c0118..e49a423b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -181,10 +181,10 @@ jobs: run: cd server && npm run typecheck:tests - name: Typecheck entity generator - # tsconfig.scripts.json only covers scripts/generate-entities.ts — - # gen-plugin-facts.ts/migrate-encryption.ts don't compile under this - # config (import.meta needs a newer --module target), so the gate is - # named for what it actually covers rather than the whole directory. + # tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts; + # gen-plugin-facts.ts uses import.meta and does not compile under this + # config (needs a newer --module target), so the gate is named for what + # it actually covers rather than the whole directory. run: cd server && npm run typecheck:scripts - name: Entity files match the generator diff --git a/server/scripts/generate-entities.ts b/server/scripts/generate-entities.ts index 75556c9c5..7ab89a2ea 100644 --- a/server/scripts/generate-entities.ts +++ b/server/scripts/generate-entities.ts @@ -1363,6 +1363,17 @@ export function checkEntities(entitiesDir: string, repositoriesDir: string, file const onDiskIndex = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : undefined; if (onDiskIndex !== expectedIndex) differingFiles.push('index.ts'); + // The comparison must also run the other way: a hand-added `*.entity.ts` + // that the generator does not produce would be folded into index.ts by the + // next --write, so it is drift too. + const generated = new Set(files.keys()); + const onDiskEntityFiles = fs.existsSync(entitiesDir) + ? fs.readdirSync(entitiesDir).filter((f) => f.endsWith('.entity.ts')).sort() + : []; + for (const fileName of onDiskEntityFiles) { + if (!generated.has(fileName)) differingFiles.push(`${fileName} (not produced by the generator)`); + } + const missingRepositories: string[] = []; for (const fileName of files.keys()) { const className = fileName.replace(/\.entity\.ts$/, ''); diff --git a/server/tests/unit/db/entity-class-fields.test.ts b/server/tests/unit/db/entity-class-fields.test.ts index b7a5ed698..f2bc2c170 100644 --- a/server/tests/unit/db/entity-class-fields.test.ts +++ b/server/tests/unit/db/entity-class-fields.test.ts @@ -19,6 +19,9 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; * reintroduces a `NaN` initialiser fails loudly here, not silently at a * caller's first `em.create()`. */ +/** NOT NULL scalars with neither a column default nor a class-field initialiser (legitimate: the caller must supply them). */ +const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 423; + describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => { it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 125 entities', () => { const failures: string[] = []; @@ -44,7 +47,7 @@ describe('entity class fields never carry a NaN initialiser (task-4-review-shape * suddenly losing its default) shows up in test output without failing * the suite on a false positive. */ - it('CLASSFIELD-002 (report-only): NOT NULL scalar properties with no default and no class-field initialiser', async () => { + it('CLASSFIELD-002: exactly the known NOT NULL, no-default scalar properties have no class-field initialiser', async () => { const testDb = createSnapshotTestDb(); try { const t = await createTestOrm(testDb); @@ -66,8 +69,11 @@ describe('entity class fields never carry a NaN initialiser (task-4-review-shape } // Informational only — see the doc comment above. `console.info` (not // a `console.log` vitest might buffer away) so it survives a run. - console.info(`[CLASSFIELD-002] ${report.length} NOT NULL, no-default scalar propert${report.length === 1 ? 'y' : 'ies'} with no initialiser (expected, not a failure)`); - expect(report).toEqual(report); // always true — this test only ever reports, never fails + // A ratchet, not a census: the failure mode this file guards (a regeneration + // handing every numeric property a bogus default) makes this number DROP; + // a new NOT NULL column without a default bumps it by one — update the + // constant with the migration that adds the column. + expect(report.length, report.join(', ')).toBe(KNOWN_UNINITIALISED_NOT_NULL_SCALARS); } finally { await t.close(); } diff --git a/server/tests/unit/db/generate-entities.test.ts b/server/tests/unit/db/generate-entities.test.ts index 2e761faa8..845cd60f5 100644 --- a/server/tests/unit/db/generate-entities.test.ts +++ b/server/tests/unit/db/generate-entities.test.ts @@ -759,6 +759,21 @@ describe('checkEntities', () => { } }); + it('CHECK-005: a stray entity file the generator does not produce is reported as drift', async () => { + const files = new Map([['X.entity.ts', 'content']]); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gen-entities-check-')); + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gen-entities-check-repos-')); + try { + fs.writeFileSync(path.join(dir, 'X.entity.ts'), 'content'); + fs.writeFileSync(path.join(dir, 'Stray.entity.ts'), 'export class Stray {}'); + const report = checkEntities(dir, repoDir, files); + expect(report.differingFiles).toEqual(['index.ts', 'Stray.entity.ts (not produced by the generator)']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + it('CHECK-004: a missing repository file is reported without being created (never writes)', async () => { const files = new Map([['X.entity.ts', 'content']]); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gen-entities-check-')); diff --git a/server/tsconfig.scripts.json b/server/tsconfig.scripts.json index 063b3a373..699b1d7c6 100644 --- a/server/tsconfig.scripts.json +++ b/server/tsconfig.scripts.json @@ -4,5 +4,5 @@ "rootDir": ".", "noEmit": true }, - "include": ["scripts/generate-entities.ts"] + "include": ["scripts/generate-entities.ts", "scripts/migrate-encryption.ts"] } diff --git a/server/vitest.config.ts b/server/vitest.config.ts index bcb7c71b7..7f0ce9704 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -172,6 +172,7 @@ export default defineConfig({ // src/demo/** is deliberately absent: it measures 0%, and a floor of // zero asserts nothing. It needs tests before it needs a threshold. 'src/app-config/**/*.ts': { statements: 99, branches: 95, functions: 99, lines: 99 }, + // Hand-maintained (scripts/coverage-thresholds.mjs only emits src/nest/* lines): floor(measured) - 1 after Plan 2. 'src/db/**/*.ts': { statements: 84, branches: 42, functions: 95, lines: 87 }, 'src/db/dialect/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 }, 'src/db/repositories/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 },