@celilo/cli 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  4. package/drizzle/0028_capability_bindings.sql +26 -0
  5. package/drizzle/0029_module_instances.sql +58 -0
  6. package/drizzle/meta/_journal.json +22 -1
  7. package/package.json +2 -2
  8. package/src/capabilities/validation.test.ts +51 -0
  9. package/src/capabilities/validation.ts +22 -8
  10. package/src/cli/commands/module-show.ts +1 -0
  11. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  12. package/src/db/foreign-keys.test.ts +101 -0
  13. package/src/db/schema.ts +182 -9
  14. package/src/hooks/broker.test.ts +152 -0
  15. package/src/hooks/broker.ts +307 -0
  16. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  17. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  18. package/src/hooks/capability-loader.test.ts +10 -2
  19. package/src/hooks/capability-loader.ts +59 -2
  20. package/src/hooks/executor.ts +234 -111
  21. package/src/hooks/hook-protocol.test.ts +192 -0
  22. package/src/hooks/hook-protocol.ts +275 -0
  23. package/src/hooks/hook-runner.ts +231 -0
  24. package/src/hooks/hook-timeout.test.ts +103 -0
  25. package/src/hooks/hook-trespass.test.ts +201 -0
  26. package/src/hooks/injected-capabilities.test.ts +75 -0
  27. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  28. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  29. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  30. package/src/manifest/template-validator.test.ts +47 -0
  31. package/src/manifest/template-validator.ts +18 -1
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/import.ts +19 -1
  35. package/src/module/packaging/module-state-directory.test.ts +99 -0
  36. package/src/module/packaging/package-rules.ts +10 -2
  37. package/src/policy/capability-shape-baseline.ts +96 -0
  38. package/src/policy/capability-shape-drift.test.ts +162 -0
  39. package/src/policy/capability-shape.ts +129 -0
  40. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  41. package/src/policy/module-business-baseline.ts +68 -7
  42. package/src/services/alerting/ack.test.ts +2 -2
  43. package/src/services/alerting/deferral.test.ts +2 -2
  44. package/src/services/alerting/delivery-loop.test.ts +2 -2
  45. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  46. package/src/services/alerting/inbound-poller.test.ts +2 -2
  47. package/src/services/alerting/inbound.test.ts +2 -2
  48. package/src/services/alerting/notification-responder.test.ts +2 -2
  49. package/src/services/alerting/run-monitor.test.ts +2 -2
  50. package/src/services/alerting/store.test.ts +2 -2
  51. package/src/services/alerting/sweep-runner.test.ts +2 -2
  52. package/src/services/alerting/tokens.test.ts +2 -2
  53. package/src/services/capability-bindings.test.ts +104 -0
  54. package/src/services/capability-bindings.ts +107 -0
  55. package/src/services/capability-table-rows.test.ts +191 -0
  56. package/src/services/capability-table-rows.ts +103 -0
  57. package/src/services/consumer-cleanup.test.ts +40 -3
  58. package/src/services/consumer-cleanup.ts +13 -7
  59. package/src/services/dns-internal-records.test.ts +74 -3
  60. package/src/services/fleet-checks.test.ts +4 -4
  61. package/src/services/module-instances.test.ts +198 -0
  62. package/src/services/module-instances.ts +96 -0
  63. package/src/services/module-journal.test.ts +2 -2
  64. package/src/services/module-subscriptions.test.ts +1 -1
  65. package/src/services/module-validator/capability-versions.test.ts +6 -1
  66. package/src/services/port-forwards.test.ts +8 -4
  67. package/src/services/port-forwards.ts +0 -11
  68. package/src/services/trusted-sources.test.ts +3 -3
  69. package/src/services/trusted-sources.ts +0 -5
  70. package/src/templates/ingress-ip.test.ts +31 -0
  71. package/src/test-utils/database.ts +31 -1
  72. package/src/variables/context.ts +75 -10
  73. package/src/variables/lxc-nameserver.test.ts +144 -0
  74. package/src/test-utils/setup-test-db.ts +0 -80
@@ -1,4 +1,4 @@
1
- import { KNOWN_CAPABILITY_NAMES } from '@celilo/capabilities';
1
+ import { KNOWN_CAPABILITY_NAMES, isProviderView } from '@celilo/capabilities';
2
2
  import { parse as parseYaml } from 'yaml';
3
3
  import type { ZodError } from 'zod';
4
4
  import { validateModuleZoneRequirements } from '../services/zone-policy';
@@ -146,24 +146,31 @@ export function validateCapabilityRequirements(
146
146
  */
147
147
  export function validateCapabilityNames(manifest: ModuleManifest): ValidationError | null {
148
148
  const errors: Array<{ path: string; message: string }> = [];
149
- const knownNames: readonly string[] = KNOWN_CAPABILITY_NAMES;
149
+ // Provider views are registry entries, so a bare membership test would accept
150
+ // them. They are framework-injected into the PROVIDER's own hooks and no
151
+ // module can ask for one, so requiring one is always a mistake, and a silent
152
+ // one: the manifest would validate and the capability would simply never
153
+ // arrive. Excluded from the suggestion list too, for the same reason.
154
+ const requirableNames: readonly string[] = KNOWN_CAPABILITY_NAMES.filter(
155
+ (name) => !isProviderView(name),
156
+ );
157
+
158
+ const checkName = (name: string, path: string): void => {
159
+ if (requirableNames.includes(name)) return;
160
+ errors.push({
161
+ path,
162
+ message: isProviderView(name)
163
+ ? `'${name}' is a provider view, not a capability a module can declare. celilo injects it into the hooks of the module that PROVIDES the paired capability. Remove this declaration.`
164
+ : `Unknown capability '${name}'. Known capabilities: ${requirableNames.join(', ')}.`,
165
+ });
166
+ };
150
167
 
151
168
  for (const required of manifest.requires.capabilities) {
152
- if (!knownNames.includes(required.name)) {
153
- errors.push({
154
- path: `requires.capabilities.${required.name}`,
155
- message: `Unknown capability '${required.name}'. Known capabilities: ${knownNames.join(', ')}.`,
156
- });
157
- }
169
+ checkName(required.name, `requires.capabilities.${required.name}`);
158
170
  }
159
171
 
160
172
  for (const opt of manifest.optional?.capabilities ?? []) {
161
- if (!knownNames.includes(opt.name)) {
162
- errors.push({
163
- path: `optional.capabilities.${opt.name}`,
164
- message: `Unknown capability '${opt.name}'. Known capabilities: ${knownNames.join(', ')}.`,
165
- });
166
- }
173
+ checkName(opt.name, `optional.capabilities.${opt.name}`);
167
174
  }
168
175
 
169
176
  if (errors.length > 0) {
@@ -607,7 +607,25 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
607
607
  // Execution: Validate capability access if module requires capabilities
608
608
  if (manifest.requires?.capabilities && manifest.requires.capabilities.length > 0) {
609
609
  const { validateCapabilityAccess } = await import('../capabilities/validation');
610
- const accessResult = await validateCapabilityAccess(manifest, db.$client);
610
+ // Templates are where CLAUDE.md's Definition of Done tells module authors
611
+ // to put `$capability:` references, so the import-time gate has to see
612
+ // them (celilo#854, celilo#1027).
613
+ //
614
+ // This adds no parser. `validateModuleTemplates` above already reads and
615
+ // parses every `.tpl` on every import, roughly 25 lines before the gate
616
+ // runs — it was discarding the references it saw. So there is no new file
617
+ // walk, no new failure mode and no new ordering question: the data is
618
+ // already in scope and the gate simply was not looking at it.
619
+ //
620
+ // Fail-closed by that same ordering. An unreadable template makes
621
+ // `validateModuleTemplates` return success:false with no references, and
622
+ // the early return above fires BEFORE this check, so an empty reference
623
+ // set can never reach the gate as a silent pass.
624
+ const accessResult = await validateCapabilityAccess(
625
+ manifest,
626
+ db.$client,
627
+ templateValidation.capabilityReferences,
628
+ );
611
629
 
612
630
  if (!accessResult.success) {
613
631
  if (tempDir) await cleanupTempDir(tempDir);
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { moduleIntegrity, modules } from '../../db/schema';
6
+ import { cleanupTestDatabase, setupTestDatabase } from '../../test-utils/database';
7
+ import { auditModule } from './audit';
8
+ import { classifyModulePath } from './package-rules';
9
+
10
+ /**
11
+ * The recurrence gate for celilo#1000: a hook has a sanctioned place to write,
12
+ * and what it writes there never becomes a `module audit` finding.
13
+ *
14
+ * **The names below are generated, deliberately.** The failure this gate exists
15
+ * for is not "we forgot to allow `state/cursor.json`". It is that the allow-list
16
+ * was a list of literals (`screenshots/`, `cookies.json`) patched in one at a
17
+ * time after each one bit someone, so it could only ever cover filenames
18
+ * somebody had already been surprised by. A test asserting a literal filename
19
+ * reproduces exactly that weakness. A hook writes what it needs to write, and
20
+ * the framework does not get to know the name in advance.
21
+ */
22
+
23
+ /** A name nothing in the codebase anticipates, and that no allow-list can hold. */
24
+ function unanticipatedName(seed: number): string {
25
+ return `${seed.toString(36)}-${(seed * 7919).toString(36)}.dat`;
26
+ }
27
+
28
+ describe('celilo#1000: state/ is the hook-writable directory', () => {
29
+ test('any name a hook invents under state/ is derived, at any depth', () => {
30
+ for (let seed = 1; seed <= 25; seed++) {
31
+ const name = unanticipatedName(seed);
32
+ for (const path of [`state/${name}`, `state/nested/${name}`, `state/a/b/c/${name}`]) {
33
+ expect(`${path} => ${classifyModulePath(path)}`).toBe(`${path} => derived`);
34
+ }
35
+ }
36
+ });
37
+
38
+ /**
39
+ * The contrast is the point, and `package` rather than `unknown` is what the
40
+ * contrast actually is. `classifyModulePath` defaults to `package`, meaning
41
+ * "this belongs to the module and must match `checksums.json`", so the same
42
+ * name one directory up is scanned, found absent from the checksums, and
43
+ * reported. That is the reporting this change exempts `state/` from, and
44
+ * exempts nothing else from. If this half ever goes green alongside the half
45
+ * above, the fix widened rather than named.
46
+ */
47
+ test('the same names outside state/ are still checksum-bearing', () => {
48
+ for (let seed = 1; seed <= 25; seed++) {
49
+ const name = unanticipatedName(seed);
50
+ expect(`${name} => ${classifyModulePath(name)}`).toBe(`${name} => package`);
51
+ expect(`lib/${name} => ${classifyModulePath(`lib/${name}`)}`).toBe(`lib/${name} => package`);
52
+ }
53
+ });
54
+
55
+ test('a hook writing into state/ leaves module audit clean', async () => {
56
+ const db = await setupTestDatabase();
57
+ const root = mkdtempSync(join(tmpdir(), 'celilo-state-gate-'));
58
+ try {
59
+ // A minimal installed tree: one packaged file, recorded in checksums.
60
+ writeFileSync(join(root, 'manifest.yml'), 'id: state-gate\nversion: 1.0.0\n');
61
+ db.insert(modules)
62
+ .values({
63
+ id: 'state-gate',
64
+ name: 'state-gate',
65
+ version: '1.0.0',
66
+ sourcePath: root,
67
+ manifestData: { id: 'state-gate', version: '1.0.0' },
68
+ })
69
+ .run();
70
+ db.insert(moduleIntegrity)
71
+ .values({
72
+ moduleId: 'state-gate',
73
+ checksums: { 'manifest.yml': await xxhashOf(join(root, 'manifest.yml')) },
74
+ version: '1.0.0',
75
+ })
76
+ .run();
77
+
78
+ const before = await auditModule('state-gate', db);
79
+ expect(before.violations).toEqual([]);
80
+
81
+ // Now a hook runs and writes something nobody declared.
82
+ mkdirSync(join(root, 'state'), { recursive: true });
83
+ writeFileSync(join(root, 'state', unanticipatedName(42)), 'whatever the hook needed');
84
+
85
+ const after = await auditModule('state-gate', db);
86
+ expect(after.violations).toEqual([]);
87
+ expect(after.success).toBe(true);
88
+ } finally {
89
+ rmSync(root, { recursive: true, force: true });
90
+ await cleanupTestDatabase(db);
91
+ }
92
+ });
93
+ });
94
+
95
+ /** The audit's own hash, so the fixture's checksum is right by construction. */
96
+ async function xxhashOf(path: string): Promise<string> {
97
+ const { readFileSync } = await import('node:fs');
98
+ return Bun.hash.xxHash64(readFileSync(path)).toString(16);
99
+ }
@@ -94,8 +94,16 @@ export function classifyModulePath(relPath: string): ModulePathClass {
94
94
  if (name === 'tsconfig.json') return 'unknown';
95
95
  if (name.endsWith('.netapp') || name.endsWith('.test.ts')) return 'unknown';
96
96
 
97
- // Celilo's own output under the module's install root.
98
- if (segments[0] === 'generated' || segments[0] === 'screenshots') return 'derived';
97
+ // Celilo's own output under the module's install root, plus the one directory
98
+ // a MODULE may write to. `state/` is celilo#1000: hooks had nowhere sanctioned
99
+ // to put anything, so whatever they wrote surfaced as an `extra` finding, and
100
+ // the two entries beside it here (`screenshots/`, `cookies.json`) are what
101
+ // that looked like being solved one filename at a time. `derived` already
102
+ // means exactly what a scratch location needs (writable, survives `module
103
+ // update`, not audited, not pruned), so this names a directory rather than
104
+ // adding machinery.
105
+ if (segments[0] === 'generated' || segments[0] === 'screenshots' || segments[0] === 'state')
106
+ return 'derived';
99
107
  // A checksum manifest cannot list itself, nor the signature over it.
100
108
  if (relPath === 'checksums.json' || relPath === 'signature.sig') return 'derived';
101
109
  // Regenerated by `module import` from the manifest (HOOK_API_V2 Phase 2).
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Expected shape hashes per capability — the fixture behind
3
+ * `capability-shape-drift.test.ts`.
4
+ *
5
+ * `CAPABILITY_CONTRACT_VERSIONS` is bumped BY HAND, so the whole scheme rests on
6
+ * a human remembering. `capability-contract.ts` used to claim that risk was
7
+ * "mitigated by a CI check that compares interface AST hashes against an
8
+ * expected fixture". No such check existed. It was designed in the retired
9
+ * `apps/celilo/designs/CELILO_UPDATE.md` and never built, and three design
10
+ * documents reasoned from it anyway (celilo#1042). This is that check.
11
+ *
12
+ * Changing a capability's interface OR its table declaration changes its hash.
13
+ * The test then fails until BOTH the contract version and the entry below move,
14
+ * in the same commit — which is the whole point, because the version is what
15
+ * `compareProviderToRuntime` and `celilo system audit` compare against.
16
+ *
17
+ * Comments and formatting do NOT change a hash. The interface is re-printed from
18
+ * its AST with comments removed before hashing, so a doc-only edit stays a patch
19
+ * (the bump rule `capability-contract.ts` already states).
20
+ *
21
+ * WHY THIS LIVES IN apps/celilo AND NOT NEXT TO THE CONTRACT. It needs the
22
+ * TypeScript compiler API to parse, and `db/schema.ts` to check a declaration
23
+ * against the real table. `@celilo/capabilities` is bundled into every module
24
+ * (celilo#173), so adding a `typescript` dependency there to serve a test would
25
+ * be paid for by every module on the fleet. apps/celilo already depends on both.
26
+ */
27
+
28
+ export interface CapabilityShape {
29
+ /** The contract version this hash was recorded at. */
30
+ readonly version: string;
31
+ /** sha256 of the printed interface plus the capability's table declaration. */
32
+ readonly hash: string;
33
+ }
34
+
35
+ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>> = {
36
+ public_web: {
37
+ version: '3.2.0',
38
+ hash: 'a353bf184552f1ed82e5d97c9b4609dc243718c20051a9446d8dc7174561db50',
39
+ },
40
+ idp: {
41
+ version: '1.2.0',
42
+ hash: '99eee190ed670f53595554335f23e1745b872e3226dc9ffdb1d0bf6397d6013a',
43
+ },
44
+ dns_registrar: {
45
+ version: '4.0.0',
46
+ hash: 'f5d02196a3394ec71d865beb45825160103800baf632293a769a6e72dc76236a',
47
+ },
48
+ firewall: {
49
+ version: '1.1.0',
50
+ hash: '564a83ebf8ca937faf955afc4f7bf4278fe9ed01694f0237bfbc38e2eff38408',
51
+ },
52
+ source_forge: {
53
+ version: '1.0.0',
54
+ hash: '3bc797cf8c2990c61723fb680299f6a1d4b99ac42fd0371dd9dc64d05c588991',
55
+ },
56
+ registry_publish: {
57
+ version: '1.0.0',
58
+ hash: '693e7d08e9f105acd90dd00f8f9282ab3508def16e48237b47bc0ccba7885679',
59
+ },
60
+ dhcp_server: {
61
+ version: '1.0.0',
62
+ hash: '291a247d98bc11b18ab34d40b520bec679b95a031cbd674d513b313559fa3288',
63
+ },
64
+ dns_internal: {
65
+ version: '1.1.0',
66
+ hash: 'af72e861a23f6c81fe9a1a679121b7d3ea87ac5e8167efa20d5d6323010e5fef',
67
+ },
68
+ external_web: {
69
+ version: '1.0.0',
70
+ hash: '7396028ace76938f4e892e6ef8a53450183cdbcf8dff3ce767724bd9b2276dae',
71
+ },
72
+ notification: {
73
+ version: '1.0.0',
74
+ hash: '40629722b397a314d65a448a618d600fc8560c4aae0767dd7324d497d4cc9d41',
75
+ },
76
+ control_plane_vpn: {
77
+ version: '1.0.0',
78
+ hash: '2f7f81d0b3ecb2ab6822dbc9bf837a933b25e0e157eb94d0b9b8e801417f8f14',
79
+ },
80
+ private_web: {
81
+ version: '1.0.0',
82
+ hash: '0eddb82f821fe3c7fbd503d55a24c3d5a22369af8b22de06570a371d5f485c03',
83
+ },
84
+ cross_module_read: {
85
+ version: '1.0.0',
86
+ hash: 'e152f0738c88a7105b6057037f350ce0bbcbfc5c4ccbccc3af60f1cf3f1904af',
87
+ },
88
+ web_routes: {
89
+ version: '1.0.0',
90
+ hash: 'bbf6435528a799e205270681e0ffc34f4007efbf04240138d4b69594a2515dbb',
91
+ },
92
+ firewall_registry: {
93
+ version: '2.0.0',
94
+ hash: 'db266155107b9eba88dbff576f979069e012e10a6dfb80de4544f809d1157bfb',
95
+ },
96
+ };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The gate `capability-contract.ts` claimed for months and did not have.
3
+ *
4
+ * Two independent failures are caught here, and they are different enough to be
5
+ * worth naming separately.
6
+ *
7
+ * SCAN A — a declaration that disagrees with the real table. This is the failure
8
+ * mode `openspec/changes/capability-owned-tables` design D4 warns is *worse than
9
+ * today's*: a capability whose declared shape does not match the database, where
10
+ * the version check still passes, "because versions live in files and the table
11
+ * lives in the database". A missing migration at least says `no such table`.
12
+ * Nothing else in the tree compares the two.
13
+ *
14
+ * SCAN B — an interface or declaration that changed with no version bump.
15
+ * `CAPABILITY_CONTRACT_VERSIONS` is hand-maintained, and `compareProviderToRuntime`
16
+ * plus `celilo system audit` both trust it. A silently-changed shape makes both
17
+ * of them confidently wrong. Comments and formatting are excluded, so a doc-only
18
+ * edit stays a patch.
19
+ *
20
+ * The subject list is DERIVED from `CapabilityRegistry` rather than written out,
21
+ * so a new capability is covered the day it is added rather than the day someone
22
+ * remembers to add it here.
23
+ */
24
+
25
+ import { describe, expect, test } from 'bun:test';
26
+ import { CAPABILITY_CONTRACT_VERSIONS } from '@celilo/capabilities';
27
+ import { is } from 'drizzle-orm';
28
+ import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core';
29
+ import * as dbSchema from '../db/schema';
30
+ import { capabilitySubjects, shapeHash, tablesOf } from './capability-shape';
31
+ import { CAPABILITY_SHAPE_BASELINE } from './capability-shape-baseline';
32
+
33
+ /** Physical table name → the drizzle table the running code declares. */
34
+ function drizzleTables(): Map<string, ReturnType<typeof getTableConfig>> {
35
+ const out = new Map<string, ReturnType<typeof getTableConfig>>();
36
+ for (const value of Object.values(dbSchema)) {
37
+ if (!is(value, SQLiteTable)) continue;
38
+ const config = getTableConfig(value);
39
+ out.set(config.name, config);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ const DRIZZLE_TYPE_TO_DECLARED: Record<string, string> = {
45
+ SQLiteText: 'text',
46
+ SQLiteInteger: 'integer',
47
+ SQLiteBoolean: 'boolean',
48
+ SQLiteTimestamp: 'timestamp',
49
+ };
50
+
51
+ describe('Scan A — a declared table matches the real one', () => {
52
+ test('every declared table exists in db/schema.ts', async () => {
53
+ const tables = drizzleTables();
54
+ const missing: string[] = [];
55
+ for (const [capability, subject] of capabilitySubjects()) {
56
+ for (const [key, decl] of Object.entries(await tablesOf(subject.file))) {
57
+ if (!tables.has(decl.table)) missing.push(`${capability}.${key} → ${decl.table}`);
58
+ }
59
+ }
60
+ expect(missing).toEqual([]);
61
+ });
62
+
63
+ test('every declared column exists on the real table, claim included', async () => {
64
+ const tables = drizzleTables();
65
+ const missing: string[] = [];
66
+ for (const [capability, subject] of capabilitySubjects()) {
67
+ for (const [key, decl] of Object.entries(await tablesOf(subject.file))) {
68
+ const config = tables.get(decl.table);
69
+ if (!config) continue;
70
+ const present = new Set(config.columns.map((c) => c.name));
71
+ for (const column of [decl.claim.column, ...Object.keys(decl.columns)]) {
72
+ if (!present.has(column)) missing.push(`${capability}.${key}: ${decl.table}.${column}`);
73
+ }
74
+ }
75
+ }
76
+ expect(missing).toEqual([]);
77
+ });
78
+
79
+ /**
80
+ * The half that catches a rename or a retype rather than an absence. A column
81
+ * declared `text` that is really an integer reads as present to the check
82
+ * above, and every consumer of the declaration would then be typed wrong.
83
+ */
84
+ test('every declared column has the declared type and nullability', async () => {
85
+ const tables = drizzleTables();
86
+ const wrong: string[] = [];
87
+ for (const [capability, subject] of capabilitySubjects()) {
88
+ for (const [key, decl] of Object.entries(await tablesOf(subject.file))) {
89
+ const config = tables.get(decl.table);
90
+ if (!config) continue;
91
+ for (const [column, declared] of Object.entries(decl.columns)) {
92
+ const actual = config.columns.find((c) => c.name === column);
93
+ if (!actual) continue;
94
+ const base = DRIZZLE_TYPE_TO_DECLARED[actual.columnType] ?? actual.columnType;
95
+ const expected = declared.endsWith('?') ? declared.slice(0, -1) : declared;
96
+ const nullableDeclared = declared.endsWith('?');
97
+ if (base !== expected) {
98
+ wrong.push(
99
+ `${capability}.${key}: ${decl.table}.${column} is ${base}, declared ${expected}`,
100
+ );
101
+ }
102
+ if (nullableDeclared === actual.notNull) {
103
+ wrong.push(
104
+ `${capability}.${key}: ${decl.table}.${column} nullability disagrees (declared ${declared}, notNull=${actual.notNull})`,
105
+ );
106
+ }
107
+ }
108
+ }
109
+ }
110
+ expect(wrong).toEqual([]);
111
+ });
112
+ });
113
+
114
+ describe('Scan B — a shape change carries a version bump', () => {
115
+ test('every capability in the registry has a baseline entry', async () => {
116
+ const missing = [...capabilitySubjects().keys()].filter(
117
+ (name) => !(name in CAPABILITY_SHAPE_BASELINE),
118
+ );
119
+ expect(missing).toEqual([]);
120
+ });
121
+
122
+ /**
123
+ * The fixture records the version its hash was taken at. If the contract has
124
+ * moved since, the baseline describes a shape nobody re-measured — which is
125
+ * how a bump lands without the regenerate that is supposed to accompany it.
126
+ *
127
+ * This does NOT catch the reverse (regenerating without bumping), and cannot:
128
+ * telling a deliberate shape change from an accidental one needs history the
129
+ * gate does not have. That case shows in review as a hash moving while a
130
+ * version stands still, which is why the generator reads versions from the
131
+ * contract rather than inventing them.
132
+ */
133
+ test('every baseline entry was taken at the CURRENT contract version', () => {
134
+ const versions = CAPABILITY_CONTRACT_VERSIONS as Record<string, string>;
135
+ const stale: string[] = [];
136
+ for (const capability of capabilitySubjects().keys()) {
137
+ const recorded = CAPABILITY_SHAPE_BASELINE[capability];
138
+ if (!recorded) continue;
139
+ if (recorded.version !== versions[capability]) {
140
+ stale.push(
141
+ `${capability}: baseline recorded at ${recorded.version}, contract is now ${versions[capability]}. Re-run bun run apps/celilo/scripts/regenerate-capability-shape-baseline.ts`,
142
+ );
143
+ }
144
+ }
145
+ expect(stale).toEqual([]);
146
+ });
147
+
148
+ test('no capability changed shape without moving its baseline', async () => {
149
+ const drifted: string[] = [];
150
+ for (const capability of capabilitySubjects().keys()) {
151
+ const recorded = CAPABILITY_SHAPE_BASELINE[capability];
152
+ if (!recorded) continue;
153
+ const actual = await shapeHash(capability);
154
+ if (actual !== recorded.hash) {
155
+ drifted.push(
156
+ `${capability}: shape changed. Bump CAPABILITY_CONTRACT_VERSIONS.${capability} (currently recorded at ${recorded.version}) and set its baseline hash to ${actual}.`,
157
+ );
158
+ }
159
+ }
160
+ expect(drifted).toEqual([]);
161
+ });
162
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Reading a capability's SHAPE: its interface, and the tables it declares.
3
+ *
4
+ * Extracted from `capability-shape-drift.test.ts` so the gate and the tool that
5
+ * regenerates its fixture compute the same hash from the same code. Two
6
+ * implementations of "what is this capability's shape" would drift, and the one
7
+ * in the GENERATOR drifting is indistinguishable from the gate passing.
8
+ *
9
+ * Same split as `module-script-scan.ts` / `no-hand-built-ssh.test.ts` in this
10
+ * directory, for the same reason.
11
+ */
12
+
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join, resolve } from 'node:path';
16
+ import type { CapabilityTableDeclaration, CapabilityTables } from '@celilo/capabilities';
17
+ import ts from 'typescript';
18
+
19
+ /** Walk up to the repo root (the dir holding both modules/ and apps/). */
20
+ export function repoRoot(): string {
21
+ let dir = import.meta.dir;
22
+ for (let i = 0; i < 8; i++) {
23
+ if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir;
24
+ dir = resolve(dir, '..');
25
+ }
26
+ throw new Error('could not locate repo root (no ancestor with modules/ + apps/)');
27
+ }
28
+
29
+ const CAPABILITIES_SRC = join(repoRoot(), 'packages', 'capabilities', 'src');
30
+
31
+ function parse(file: string): ts.SourceFile {
32
+ return ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
33
+ }
34
+
35
+ /**
36
+ * capability name → { interface type name, source file }, read out of
37
+ * `capability-registry.ts` itself.
38
+ */
39
+ export function capabilitySubjects(): Map<string, { typeName: string; file: string }> {
40
+ const registryFile = join(CAPABILITIES_SRC, 'capability-registry.ts');
41
+ const source = parse(registryFile);
42
+
43
+ // `import type { XCapability } from './x'` → XCapability lives in ./x.ts
44
+ const fileOfType = new Map<string, string>();
45
+ for (const statement of source.statements) {
46
+ if (!ts.isImportDeclaration(statement)) continue;
47
+ const bindings = statement.importClause?.namedBindings;
48
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
49
+ const specifier = (statement.moduleSpecifier as ts.StringLiteral).text;
50
+ for (const element of bindings.elements) {
51
+ fileOfType.set(
52
+ element.name.text,
53
+ join(CAPABILITIES_SRC, `${specifier.replace('./', '')}.ts`),
54
+ );
55
+ }
56
+ }
57
+
58
+ const subjects = new Map<string, { typeName: string; file: string }>();
59
+ for (const statement of source.statements) {
60
+ if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'CapabilityRegistry') {
61
+ continue;
62
+ }
63
+ for (const member of statement.members) {
64
+ if (!ts.isPropertySignature(member) || !member.name || !member.type) continue;
65
+ const name = member.name.getText(source);
66
+ const typeName = member.type.getText(source);
67
+ const file = fileOfType.get(typeName);
68
+ if (!file) {
69
+ // LOUD, not skipped. A member whose type is not a bare imported
70
+ // identifier — an intersection, a generic, a locally-declared type —
71
+ // would otherwise drop out of coverage silently, and the gate would go
72
+ // green having checked one capability fewer. That is the same shape as
73
+ // the bugs this whole change kept finding: an absence that reads as a
74
+ // pass. If a registry member legitimately needs a composite type, teach
75
+ // this resolver about it rather than letting it vanish.
76
+ throw new Error(
77
+ `capability-shape: '${name}' has type '${typeName}', which is not a bare identifier imported into capability-registry.ts, so its shape cannot be hashed. Teach capabilitySubjects() how to resolve it.`,
78
+ );
79
+ }
80
+ subjects.set(name, { typeName, file });
81
+ }
82
+ }
83
+ return subjects;
84
+ }
85
+
86
+ /** The interface, re-printed from its AST with comments dropped. */
87
+ function printedInterface(file: string, typeName: string): string {
88
+ const source = parse(file);
89
+ const printer = ts.createPrinter({ removeComments: true });
90
+ for (const statement of source.statements) {
91
+ if (ts.isInterfaceDeclaration(statement) && statement.name.text === typeName) {
92
+ return printer.printNode(ts.EmitHint.Unspecified, statement, source);
93
+ }
94
+ }
95
+ throw new Error(`${typeName} not found in ${file}`);
96
+ }
97
+
98
+ /** Every `* satisfies CapabilityTables` const exported from a capability's module. */
99
+ export async function tablesOf(file: string): Promise<CapabilityTables> {
100
+ const loaded = (await import(file)) as Record<string, unknown>;
101
+ const out: Record<string, CapabilityTableDeclaration> = {};
102
+ for (const [exportName, value] of Object.entries(loaded)) {
103
+ if (!exportName.endsWith('_TABLES') || typeof value !== 'object' || value === null) continue;
104
+ for (const [key, decl] of Object.entries(value as Record<string, unknown>)) {
105
+ out[key] = decl as CapabilityTableDeclaration;
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** Order-independent so a reordered literal is not a shape change. */
112
+ function canonical(value: unknown): string {
113
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
114
+ if (value && typeof value === 'object') {
115
+ const entries = Object.entries(value as Record<string, unknown>)
116
+ .map(([k, v]) => `${k}:${canonical(v)}`)
117
+ .sort();
118
+ return `{${entries.join(',')}}`;
119
+ }
120
+ return JSON.stringify(value);
121
+ }
122
+
123
+ export async function shapeHash(capability: string): Promise<string> {
124
+ const subject = capabilitySubjects().get(capability);
125
+ if (!subject) throw new Error(`${capability} is not in CapabilityRegistry`);
126
+ const iface = printedInterface(subject.file, subject.typeName);
127
+ const tables = canonical(await tablesOf(subject.file));
128
+ return createHash('sha256').update(`${iface}\n--tables--\n${tables}`).digest('hex');
129
+ }