chore(db): entity --check reports stray files; class-field census is a ratchet; migrate-encryption typechecked

This commit is contained in:
jubnl
2026-09-22 11:57:50 +02:00
parent 11eeed46ee
commit 47e1801908
6 changed files with 41 additions and 8 deletions
+4 -4
View File
@@ -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
+11
View File
@@ -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$/, '');
@@ -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();
}
@@ -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-'));
+1 -1
View File
@@ -4,5 +4,5 @@
"rootDir": ".",
"noEmit": true
},
"include": ["scripts/generate-entities.ts"]
"include": ["scripts/generate-entities.ts", "scripts/migrate-encryption.ts"]
}
+1
View File
@@ -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 },