@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.
- package/CELILO_CORE_MODULES.md +3 -0
- package/CELILO_SUBSYSTEMS.md +7 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +2 -2
- package/src/capabilities/validation.test.ts +51 -0
- package/src/capabilities/validation.ts +22 -8
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +182 -9
- package/src/hooks/broker.test.ts +152 -0
- package/src/hooks/broker.ts +307 -0
- package/src/hooks/capability-loader-bindings.test.ts +163 -0
- package/src/hooks/capability-loader-firewall.test.ts +108 -0
- package/src/hooks/capability-loader.test.ts +10 -2
- package/src/hooks/capability-loader.ts +59 -2
- package/src/hooks/executor.ts +234 -111
- package/src/hooks/hook-protocol.test.ts +192 -0
- package/src/hooks/hook-protocol.ts +275 -0
- package/src/hooks/hook-runner.ts +231 -0
- package/src/hooks/hook-timeout.test.ts +103 -0
- package/src/hooks/hook-trespass.test.ts +201 -0
- package/src/hooks/injected-capabilities.test.ts +75 -0
- package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
- package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
- package/src/manifest/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/import.ts +19 -1
- package/src/module/packaging/module-state-directory.test.ts +99 -0
- package/src/module/packaging/package-rules.ts +10 -2
- package/src/policy/capability-shape-baseline.ts +96 -0
- package/src/policy/capability-shape-drift.test.ts +162 -0
- package/src/policy/capability-shape.ts +129 -0
- package/src/policy/dns-aspect-coverage.test.ts +100 -0
- package/src/policy/module-business-baseline.ts +68 -7
- package/src/services/alerting/ack.test.ts +2 -2
- package/src/services/alerting/deferral.test.ts +2 -2
- package/src/services/alerting/delivery-loop.test.ts +2 -2
- package/src/services/alerting/deploy-hooks.test.ts +2 -2
- package/src/services/alerting/inbound-poller.test.ts +2 -2
- package/src/services/alerting/inbound.test.ts +2 -2
- package/src/services/alerting/notification-responder.test.ts +2 -2
- package/src/services/alerting/run-monitor.test.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -2
- package/src/services/alerting/sweep-runner.test.ts +2 -2
- package/src/services/alerting/tokens.test.ts +2 -2
- package/src/services/capability-bindings.test.ts +104 -0
- package/src/services/capability-bindings.ts +107 -0
- package/src/services/capability-table-rows.test.ts +191 -0
- package/src/services/capability-table-rows.ts +103 -0
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/consumer-cleanup.ts +13 -7
- package/src/services/dns-internal-records.test.ts +74 -3
- package/src/services/fleet-checks.test.ts +4 -4
- package/src/services/module-instances.test.ts +198 -0
- package/src/services/module-instances.ts +96 -0
- package/src/services/module-journal.test.ts +2 -2
- package/src/services/module-subscriptions.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +6 -1
- package/src/services/port-forwards.test.ts +8 -4
- package/src/services/port-forwards.ts +0 -11
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/services/trusted-sources.ts +0 -5
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- package/src/variables/context.ts +75 -10
- package/src/variables/lxc-nameserver.test.ts +144 -0
- package/src/test-utils/setup-test-db.ts +0 -80
package/src/manifest/validate.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/module/import.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|