/** * The gate `capability-contract.ts` claimed for months and did not have. * * Two independent failures are caught here, and they are different enough to be * worth naming separately. * * SCAN A — a declaration that disagrees with the real table. This is the failure * mode `openspec/changes/capability-owned-tables` design D4 warns is *worse than * today's*: a capability whose declared shape does not match the database, where * the version check still passes, "because versions live in files and the table * lives in the database". A missing migration at least says `no such table`. * Nothing else in the tree compares the two. * * SCAN B — an interface or declaration that changed with no version bump. * `CAPABILITY_CONTRACT_VERSIONS` is hand-maintained, and `compareProviderToRuntime` * plus `celilo system audit` both trust it. A silently-changed shape makes both * of them confidently wrong. Comments and formatting are excluded, so a doc-only * edit stays a patch. * * The subject list is DERIVED from `CapabilityRegistry` rather than written out, * so a new capability is covered the day it is added rather than the day someone * remembers to add it here. */ import { describe, expect, test } from 'bun:test'; import { CAPABILITY_CONTRACT_VERSIONS } from '@celilo/capabilities'; import { is } from 'drizzle-orm'; import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core'; import * as dbSchema from '../db/schema'; import { type RequestTypeShape, capabilitySubjects, classifyRequestChange, requestTypes, requestsKey, shapeHash, tablesOf, } from './capability-shape'; import { CAPABILITY_SHAPE_BASELINE } from './capability-shape-baseline'; /** Physical table name → the drizzle table the running code declares. */ function drizzleTables(): Map> { const out = new Map>(); for (const value of Object.values(dbSchema)) { if (!is(value, SQLiteTable)) continue; const config = getTableConfig(value); out.set(config.name, config); } return out; } const DRIZZLE_TYPE_TO_DECLARED: Record = { SQLiteText: 'text', SQLiteInteger: 'integer', SQLiteBoolean: 'boolean', SQLiteTimestamp: 'timestamp', }; describe('Scan A — a declared table matches the real one', () => { test('every declared table exists in db/schema.ts', async () => { const tables = drizzleTables(); const missing: string[] = []; for (const [capability, subject] of capabilitySubjects()) { for (const [key, decl] of Object.entries(await tablesOf(subject.file))) { if (!tables.has(decl.table)) missing.push(`${capability}.${key} → ${decl.table}`); } } expect(missing).toEqual([]); }); test('every declared column exists on the real table, claim included', async () => { const tables = drizzleTables(); const missing: string[] = []; for (const [capability, subject] of capabilitySubjects()) { for (const [key, decl] of Object.entries(await tablesOf(subject.file))) { const config = tables.get(decl.table); if (!config) continue; const present = new Set(config.columns.map((c) => c.name)); for (const column of [decl.claim.column, ...Object.keys(decl.columns)]) { if (!present.has(column)) missing.push(`${capability}.${key}: ${decl.table}.${column}`); } } } expect(missing).toEqual([]); }); /** * The half that catches a rename or a retype rather than an absence. A column * declared `text` that is really an integer reads as present to the check * above, and every consumer of the declaration would then be typed wrong. */ test('every declared column has the declared type and nullability', async () => { const tables = drizzleTables(); const wrong: string[] = []; for (const [capability, subject] of capabilitySubjects()) { for (const [key, decl] of Object.entries(await tablesOf(subject.file))) { const config = tables.get(decl.table); if (!config) continue; for (const [column, declared] of Object.entries(decl.columns)) { const actual = config.columns.find((c) => c.name === column); if (!actual) continue; const base = DRIZZLE_TYPE_TO_DECLARED[actual.columnType] ?? actual.columnType; const expected = declared.endsWith('?') ? declared.slice(0, -1) : declared; const nullableDeclared = declared.endsWith('?'); if (base !== expected) { wrong.push( `${capability}.${key}: ${decl.table}.${column} is ${base}, declared ${expected}`, ); } if (nullableDeclared === actual.notNull) { wrong.push( `${capability}.${key}: ${decl.table}.${column} nullability disagrees (declared ${declared}, notNull=${actual.notNull})`, ); } } } } expect(wrong).toEqual([]); }); }); describe('Scan B — a shape change carries a version bump', () => { test('every capability in the registry has a baseline entry', async () => { const missing = [...capabilitySubjects().keys()].filter( (name) => !(name in CAPABILITY_SHAPE_BASELINE), ); expect(missing).toEqual([]); }); /** * The fixture records the version its hash was taken at. If the contract has * moved since, the baseline describes a shape nobody re-measured — which is * how a bump lands without the regenerate that is supposed to accompany it. * * This does NOT catch the reverse (regenerating without bumping), and cannot: * telling a deliberate shape change from an accidental one needs history the * gate does not have. That case shows in review as a hash moving while a * version stands still, which is why the generator reads versions from the * contract rather than inventing them. */ test('every baseline entry was taken at the CURRENT contract version', () => { const versions = CAPABILITY_CONTRACT_VERSIONS as Record; const stale: string[] = []; for (const capability of capabilitySubjects().keys()) { const recorded = CAPABILITY_SHAPE_BASELINE[capability]; if (!recorded) continue; if (recorded.version !== versions[capability]) { stale.push( `${capability}: baseline recorded at ${recorded.version}, contract is now ${versions[capability]}. Re-run bun run apps/celilo/scripts/regenerate-capability-shape-baseline.ts`, ); } } expect(stale).toEqual([]); }); test('no capability changed shape without moving its baseline', async () => { const drifted: string[] = []; for (const capability of capabilitySubjects().keys()) { const recorded = CAPABILITY_SHAPE_BASELINE[capability]; if (!recorded) continue; const actual = await shapeHash(capability); if (actual !== recorded.hash) { drifted.push( `${capability}: shape changed. Bump CAPABILITY_CONTRACT_VERSIONS.${capability} (currently recorded at ${recorded.version}) and set its baseline hash to ${actual}.`, ); } } expect(drifted).toEqual([]); }); }); describe('Scan C — a request-contract change carries a MAJOR bump', () => { const majorOf = (version: string): number => Number.parseInt(version.replace(/^v/, '').split('-')[0].split('.')[0], 10) || 0; /** * The regenerate script refuses to absorb a breaking request diff (removed * member, optional → required, type change) without a major bump. This test * is the same predicate against the COMMITTED baseline, so hand-editing the * fixture does not route around the script — and it is what makes the rule * a gate rather than a convention (celilo#1361, where `sourceDir` left three * request types with no version move at all). */ test('a request-shape change against the baseline means the major moved', () => { const versions = CAPABILITY_CONTRACT_VERSIONS as Record; const violations: string[] = []; for (const capability of capabilitySubjects().keys()) { const recorded = CAPABILITY_SHAPE_BASELINE[capability]; if (!recorded) continue; if (recorded.requests === undefined) { violations.push( `${capability}: baseline has no requests field. Re-run bun run apps/celilo/scripts/regenerate-capability-shape-baseline.ts`, ); continue; } const current = requestsKey(capability); if (current === recorded.requests) continue; const change = classifyRequestChange( JSON.parse(recorded.requests) as RequestTypeShape[], requestTypes(capability), ); if ( change.kind === 'breaking' && majorOf(recorded.version) >= majorOf(versions[capability]) ) { violations.push( `${capability}: request contract changed without a MAJOR bump (${change.reasons.join('; ')}). ` + `Bump CAPABILITY_CONTRACT_VERSIONS.${capability} and regenerate the baseline.`, ); } } expect(violations).toEqual([]); }); });