@celilo/cli 2.3.0 → 3.0.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 (90) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/package.json +3 -3
  3. package/src/api/serve.ts +13 -1
  4. package/src/cli/commands/module-health.test.ts +35 -0
  5. package/src/cli/commands/module-health.ts +11 -3
  6. package/src/cli/commands/module-publish.test.ts +22 -0
  7. package/src/cli/commands/module-publish.ts +14 -12
  8. package/src/cli/commands/module-update.ts +69 -17
  9. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  10. package/src/cli/commands/module-upgrade.ts +43 -3
  11. package/src/cli/commands/publish/helpers.ts +4 -3
  12. package/src/cli/commands/publish/index.ts +13 -1
  13. package/src/cli/commands/publish/plan.test.ts +64 -0
  14. package/src/cli/commands/publish/plan.ts +52 -19
  15. package/src/cli/commands/publish/types.ts +16 -3
  16. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  17. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  18. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  19. package/src/cli/commands/subscribers-serve.ts +22 -4
  20. package/src/cli/commands/system-audit.ts +14 -5
  21. package/src/cli/commands/system-update.ts +11 -6
  22. package/src/cli/completion.ts +10 -1
  23. package/src/cli/fuel-gauge.ts +12 -4
  24. package/src/cli/index.ts +12 -0
  25. package/src/cli/json-output.test.ts +81 -0
  26. package/src/cli/types.ts +9 -0
  27. package/src/hooks/broker.test.ts +9 -3
  28. package/src/hooks/executor.test.ts +5 -2
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  30. package/src/hooks/hook-jail-unreachability.test.ts +10 -2
  31. package/src/hooks/hook-trespass.test.ts +13 -4
  32. package/src/hooks/run-named-hook.ts +19 -16
  33. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  34. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  35. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  36. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  37. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  38. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  39. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  40. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  41. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  42. package/src/manifest/contracts/v1.ts +19 -14
  43. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  44. package/src/manifest/schema.ts +66 -16
  45. package/src/manifest/validate.test.ts +47 -0
  46. package/src/policy/capability-shape-baseline.ts +63 -21
  47. package/src/policy/capability-shape-drift.test.ts +53 -1
  48. package/src/policy/capability-shape.test.ts +105 -0
  49. package/src/policy/capability-shape.ts +283 -2
  50. package/src/registry/client.test.ts +65 -0
  51. package/src/registry/client.ts +7 -7
  52. package/src/secrets/storage.test.ts +69 -3
  53. package/src/secrets/storage.ts +71 -1
  54. package/src/services/audit/health.test.ts +58 -0
  55. package/src/services/audit/health.ts +15 -3
  56. package/src/services/audit/index.test.ts +1 -1
  57. package/src/services/audit/interface-classification.test.ts +16 -5
  58. package/src/services/audit/interface-classification.ts +25 -2
  59. package/src/services/audit/module-versions.ts +5 -1
  60. package/src/services/audit/public-dns.test.ts +20 -0
  61. package/src/services/audit/public-dns.ts +7 -2
  62. package/src/services/audit/recurrence-gate.test.ts +225 -0
  63. package/src/services/audit/trusted-sources.test.ts +17 -0
  64. package/src/services/audit/trusted-sources.ts +21 -0
  65. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  66. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
  67. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  68. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  69. package/src/services/build-bus/hook-dispatch.ts +99 -121
  70. package/src/services/build-bus/hook-dispatcher.ts +106 -28
  71. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  72. package/src/services/build-bus/receiver-daemon.ts +355 -0
  73. package/src/services/build-bus/self-update.ts +156 -0
  74. package/src/services/capability-compat.test.ts +90 -0
  75. package/src/services/capability-compat.ts +128 -0
  76. package/src/services/deploy-terraform.ts +38 -1
  77. package/src/services/events-daemon.test.ts +57 -0
  78. package/src/services/events-daemon.ts +76 -0
  79. package/src/services/firewall-reach.ts +21 -8
  80. package/src/services/fleet-checks.test.ts +37 -1
  81. package/src/services/fleet-checks.ts +31 -1
  82. package/src/services/health-runner.ts +43 -5
  83. package/src/services/module-deploy-prune.test.ts +89 -0
  84. package/src/services/module-deploy.ts +72 -125
  85. package/src/services/module-types-drift.test.ts +1 -1
  86. package/src/services/module-validator/capability-versions.test.ts +13 -2
  87. package/src/services/terraform-safety.test.ts +83 -0
  88. package/src/services/terraform-safety.ts +53 -0
  89. package/src/services/update/orchestrator.test.ts +1 -1
  90. package/tsconfig.json +2 -13
@@ -71,15 +71,8 @@ export interface ContractHookSignature {
71
71
  * a name added to `HOOK_NAMES` without an entry here is a type error, and an
72
72
  * entry here for a name that is not a hook is one too. It was `Record<string,
73
73
  * …>`, which is why the drift went unnoticed for months.
74
- *
75
- * `on_upstream_publish` is named explicitly because it is NOT a `HookName` — an
76
- * array of build-bus match rules rather than an invokable hook. It has a
77
- * signature here because the executor passes its payload as env vars and the
78
- * contract is where that is written down.
79
74
  */
80
- export type ContractHooks = Record<HookName, ContractHookSignature> & {
81
- on_upstream_publish: ContractHookSignature;
82
- };
75
+ export type ContractHooks = Record<HookName, ContractHookSignature>;
83
76
 
84
77
  /**
85
78
  * Contract v1.0 — current canonical hook signatures.
@@ -370,14 +363,26 @@ export const V1_HOOKS: ContractHooks = {
370
363
  outputs: {},
371
364
  },
372
365
  /**
373
- * Build-bus upstream publish hook. The executor passes the
374
- * PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
375
- * CELILO_EVENT_PACKAGE_NAME, etc.) rather than as named inputs
376
- * here — see openspec/changes/build-bus-poll-cd/proposal.md Phase 4 + the hook-dispatch executor.
377
- * No structured outputs; hook reports success via exit code.
366
+ * Build-bus upstream publish hook (module-orchestrator-primitives slice 7).
367
+ * An ordinary hook: the build-bus receiver daemon dispatches it through the
368
+ * executor, jailed, with the verified PublishEvent as the `event` input in
369
+ * the hook's typed context. It used to be a bash script reached by env vars
370
+ * (`CELILO_EVENT_*`) on a second, unjailed execution path.
371
+ *
372
+ * `event` is a required input, which by the contract's own rule would be a
373
+ * breaking change needing a v2.0 contract. No v2 is minted because no module
374
+ * declares this hook as an invokable script: the only in-tree consumer was
375
+ * celilo-mgmt's bash entry, deleted in the same slice, and the dispatcher
376
+ * that invoked the old form never went through this table at all. The v1
377
+ * entry is being corrected to describe the shape the executor now actually
378
+ * passes, in the same @celilo/cli major that changes the manifest field's
379
+ * shape (tasks.md 7.6).
378
380
  */
379
381
  on_upstream_publish: {
380
- inputs: {},
382
+ inputs: {
383
+ /** The verified PublishEvent, as received from the build bus. */
384
+ event: { required: true },
385
+ },
381
386
  outputs: {},
382
387
  },
383
388
  };
@@ -45,7 +45,7 @@ function findActiveManifests(): string[] {
45
45
  const manifests: string[] = [];
46
46
 
47
47
  for (const entry of entries) {
48
- if (entry === 'archive' || entry.startsWith('.')) continue;
48
+ if (entry === '__archive__' || entry.startsWith('.')) continue;
49
49
  const dir = join(MODULES_DIR, entry);
50
50
  if (!statSync(dir).isDirectory()) continue;
51
51
  const manifestPath = join(dir, 'manifest.yml');
@@ -105,6 +105,25 @@ export const SecretGenerateSchema = z.object({
105
105
  identity: z.string().optional(),
106
106
  });
107
107
 
108
+ /**
109
+ * A recorded decision that a module needs no health check
110
+ * (openspec/changes/health-waiver-mechanism, D2). All three fields are
111
+ * required: an anonymous or undated waiver is a check that disappeared.
112
+ * Strict — an unknown field must fail manifest load, not silently vanish.
113
+ */
114
+ export const HealthWaiverSchema = z
115
+ .object({
116
+ /** The human's words, recorded verbatim. */
117
+ reason: z.string().min(1),
118
+ /** Who made the decision. */
119
+ by: z.string().min(1),
120
+ /** When — an ISO date. */
121
+ at: z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
122
+ message: 'health_waiver.at must be an ISO date string',
123
+ }),
124
+ })
125
+ .strict();
126
+
108
127
  export const SecretDeclareSchema = z.object({
109
128
  name: z.string().min(1),
110
129
  // `string-map` is `Record<string, string>` (e.g. domain → password). It's
@@ -304,14 +323,17 @@ export const HealthCheckHookSchema = LifecycleHookSchema.extend({
304
323
 
305
324
  /**
306
325
  * Build-bus upstream-publish hook. Fires when a publish event lands
307
- * on the local event bus from the receiver daemon
308
- * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4). Each module can declare multiple
309
- * entries, each with its own match rule + action script.
326
+ * on the local event bus from the receiver daemon. Each module can declare
327
+ * multiple entries, each with its own match rule, so one manifest can react
328
+ * to two different packages with two different scripts (celilo-mgmt did
329
+ * exactly that) without the hook itself having to filter.
310
330
  *
311
- * The action script runs with `CELILO_EVENT_PAYLOAD` in its env (the
312
- * full PublishEvent as JSON), plus `CELILO_EVENT_PACKAGE_NAME` /
313
- * `CELILO_EVENT_PACKAGE_VERSION` for the common fields. Exit status
314
- * non-zero is logged but doesn't propagate to the publisher.
331
+ * As of module-orchestrator-primitives slice 7 the script is an ordinary
332
+ * TypeScript `defineHook` module dispatched through the executor, jailed,
333
+ * with the verified PublishEvent as the `event` input in the hook's typed
334
+ * context. It used to be a bash path spawned detached on a second execution
335
+ * path with celilo's whole `process.env` (design D6). That path is gone; the
336
+ * match rules survive as receiver-side trigger filtering.
315
337
  */
316
338
  export const UpstreamPublishHookSchema = z.object({
317
339
  /** Optional display label for logs / `celilo subscribers status`. */
@@ -328,9 +350,13 @@ export const UpstreamPublishHookSchema = z.object({
328
350
  package_pattern: z.string().optional(),
329
351
  })
330
352
  .default({}),
331
- /** Path to the action script, relative to the module directory. */
353
+ /**
354
+ * Path to the hook script, relative to the module directory. A TypeScript
355
+ * `defineHook` module; the hook receives the verified PublishEvent as its
356
+ * `event` input. A shell script is not a hook language.
357
+ */
332
358
  script: z.string().min(1),
333
- /** Seconds before the script is killed. Default: 600. */
359
+ /** Milliseconds before the hook is killed. Default: 600000. */
334
360
  timeout: z.number().positive().optional(),
335
361
  });
336
362
 
@@ -368,19 +394,23 @@ const LIFECYCLE_HOOK_SCHEMAS = {
368
394
  list_peers: LifecycleHookSchema.optional(),
369
395
  reconcile_peers: LifecycleHookSchema.optional(),
370
396
  sweep_revisions: LifecycleHookSchema.optional(),
397
+ /**
398
+ * The one HookName whose schema entry is an ARRAY (see
399
+ * UpstreamPublishHookSchema): multiple match-rule entries per manifest,
400
+ * dispatched per event by the build-bus receiver daemon through the
401
+ * ordinary executor. Deliberately not a LifecycleHookSchema.
402
+ */
403
+ on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
371
404
  } satisfies Record<HookName, z.ZodTypeAny>;
372
405
 
373
406
  /**
374
- * What `manifest.hooks` accepts: every lifecycle hook, plus
375
- * `on_upstream_publish`, which is deliberately not a `HookName` — an ARRAY of
376
- * build-bus match rules dispatched by the receiver daemon rather than a hook
377
- * `runNamedHook` can invoke. It is spread in here rather than living in
378
- * `LIFECYCLE_HOOK_SCHEMAS` so the `satisfies` above stays exact.
379
- * See [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
407
+ * What `manifest.hooks` accepts: every lifecycle hook, each derived from the
408
+ * one list (celilo#821). `on_upstream_publish` lives in
409
+ * `LIFECYCLE_HOOK_SCHEMAS` with the rest now that it is a HookName; its array
410
+ * entry is commented there.
380
411
  */
381
412
  const HOOK_SCHEMAS = {
382
413
  ...LIFECYCLE_HOOK_SCHEMAS,
383
- on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
384
414
  };
385
415
 
386
416
  /**
@@ -794,6 +824,25 @@ export const ModuleManifestSchema = z
794
824
  .strict()
795
825
  .optional(),
796
826
 
827
+ /**
828
+ * A recorded, first-class decision that this module needs no health
829
+ * check (openspec/changes/health-waiver-mechanism, D1-D4).
830
+ *
831
+ * A waiver is NOT `skip`. `skip` says a check could not run this
832
+ * time. A waiver says a human decided this module needs no check,
833
+ * and records WHO and WHY. It annotates the module's `unmeasured`
834
+ * audit finding; the severity stays `unmeasured` and the fleet
835
+ * verdict stays UNKNOWN. It never re-greens the board.
836
+ *
837
+ * It lives here rather than in operator state because it must
838
+ * survive reinstall, reprovision and registry publish, and because
839
+ * `git diff` should show the decision changing. There is no expiry
840
+ * by design: a lapsed waiver would silently re-green, which is the
841
+ * failure being guarded. Revisiting the decision is a new manifest
842
+ * edit, not a timer.
843
+ */
844
+ health_waiver: HealthWaiverSchema.optional(),
845
+
797
846
  build: z
798
847
  .object({
799
848
  /** Inline shell command to build the module (run via bash -c). Mutually exclusive with script. */
@@ -1003,6 +1052,7 @@ export type VariableType = z.infer<typeof VariableTypeSchema>;
1003
1052
  export type AnsibleCollection = z.infer<typeof AnsibleCollectionSchema>;
1004
1053
  export type SystemResource = z.infer<typeof SystemResourceSchema>;
1005
1054
  export type SystemDeclaration = z.infer<typeof SystemDeclarationSchema>;
1055
+ export type HealthWaiver = z.infer<typeof HealthWaiverSchema>;
1006
1056
  export type BaseModuleAspect = NonNullable<ModuleManifest['base_module_aspect']>;
1007
1057
  export type BaseModuleAspectTrigger = BaseModuleAspect['triggers'][number];
1008
1058
 
@@ -1673,3 +1673,50 @@ requires:
1673
1673
  });
1674
1674
  });
1675
1675
  });
1676
+
1677
+ describe('health_waiver (openspec/changes/health-waiver-mechanism)', () => {
1678
+ const base = `\n${CONTRACT_LINE}\nid: namecheap\nname: Namecheap\nversion: 1.0.0\n`;
1679
+
1680
+ test('accepts a well-formed waiver and keeps all three fields', () => {
1681
+ const result = validateManifest(
1682
+ `${base}\nhealth_waiver:\n reason: "API-only: a real check is hard and may not be worth forcing"\n by: peba\n at: "2026-09-09"\n`,
1683
+ );
1684
+ expect(result.success).toBe(true);
1685
+ if (result.success) {
1686
+ expect(result.data.health_waiver).toEqual({
1687
+ reason: 'API-only: a real check is hard and may not be worth forcing',
1688
+ by: 'peba',
1689
+ at: '2026-09-09',
1690
+ });
1691
+ }
1692
+ });
1693
+
1694
+ test('rejects an unknown field inside the waiver (strict)', () => {
1695
+ const result = validateManifest(
1696
+ `${base}\nhealth_waiver:\n reason: r\n by: peba\n at: "2026-09-09"\n expires: "2027-01-01"\n`,
1697
+ );
1698
+ expect(result.success).toBe(false);
1699
+ if (!result.success) {
1700
+ expect(result.errors.some((e) => e.message.toLowerCase().includes('unrecognized'))).toBe(
1701
+ true,
1702
+ );
1703
+ }
1704
+ });
1705
+
1706
+ test('rejects a waiver with no reason', () => {
1707
+ const result = validateManifest(`${base}\nhealth_waiver:\n by: peba\n at: "2026-09-09"\n`);
1708
+ expect(result.success).toBe(false);
1709
+ });
1710
+
1711
+ test('rejects a waiver with no by', () => {
1712
+ const result = validateManifest(`${base}\nhealth_waiver:\n reason: r\n at: "2026-09-09"\n`);
1713
+ expect(result.success).toBe(false);
1714
+ });
1715
+
1716
+ test('rejects a waiver whose at is not a date', () => {
1717
+ const result = validateManifest(
1718
+ `${base}\nhealth_waiver:\n reason: r\n by: peba\n at: not-a-date\n`,
1719
+ );
1720
+ expect(result.success).toBe(false);
1721
+ });
1722
+ });
@@ -28,77 +28,119 @@
28
28
  export interface CapabilityShape {
29
29
  /** The contract version this hash was recorded at. */
30
30
  readonly version: string;
31
- /** sha256 of the printed interface plus the capability's table declaration. */
31
+ /** sha256 of the printed interface, the table declaration, and the request types. */
32
32
  readonly hash: string;
33
+ /**
34
+ * Canonical request/result type member text, so the regenerate script can
35
+ * classify a request-contract change as breaking (removed member, optional
36
+ * → required, type change → MAJOR) or additive (optional addition → minor)
37
+ * before it writes. Without this, a regenerate could absorb a request break
38
+ * into a patch, which is exactly how celilo#1361 shipped.
39
+ */
40
+ readonly requests: string;
33
41
  }
34
42
 
35
43
  export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>> = {
36
44
  public_web: {
37
- version: '3.2.0',
38
- hash: 'a353bf184552f1ed82e5d97c9b4609dc243718c20051a9446d8dc7174561db50',
45
+ version: '4.0.0',
46
+ hash: 'fa79b68744eea9506db431b9eb51bb0472736864ddeea7524af9945f4bf4f1b0',
47
+ requests:
48
+ '[{kind:"alias",members:[{name:"_self",optional:false,type:"Record<string, string | number | boolean>"}],name:"ClientConfig"},{kind:"interface",members:[{name:"defaultHostname",optional:false,type:"string"},{name:"getServerIp",optional:false,type:"Promise<string>"},{name:"publishStaticSite",optional:false,type:"Promise<PublishStaticSiteResult>"},{name:"register_route",optional:false,type:"Promise<RegisterRouteResult>"},{name:"registerReverseProxy",optional:false,type:"Promise<RegisterReverseProxyResult>"},{name:"upload_static_assets",optional:false,type:"Promise<UploadStaticAssetsResult>"}],name:"PublicWebCapability"},{kind:"interface",members:[{name:"clientConfig",optional:true,type:"ClientConfig"},{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"spaFallback",optional:true,type:"boolean"}],name:"PublishStaticSiteRequest"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string"},{name:"filesUploaded",optional:false,type:"number"},{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"PublishStaticSiteResult"},{kind:"interface",members:[{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"targetHost",optional:false,type:"string"},{name:"targetPort",optional:false,type:"number"},{name:"websocket",optional:true,type:"boolean"}],name:"RegisterReverseProxyRequest"},{kind:"interface",members:[{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"RegisterReverseProxyResult"},{kind:"interface",members:[{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"targetHost",optional:true,type:"string"},{name:"targetPort",optional:true,type:"number"},{name:"type",optional:false,type:"\'static\' | \'reverse_proxy\'"},{name:"websocket",optional:true,type:"boolean"}],name:"RegisterRouteRequest"},{kind:"interface",members:[{name:"caddyPath",optional:true,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"RegisterRouteResult"},{kind:"interface",members:[{name:"path",optional:false,type:"string"}],name:"UploadStaticAssetsRequest"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string"},{name:"filesUploaded",optional:false,type:"number"},{name:"success",optional:false,type:"boolean"}],name:"UploadStaticAssetsResult"}]',
39
49
  },
40
50
  idp: {
41
51
  version: '1.2.0',
42
- hash: '99eee190ed670f53595554335f23e1745b872e3226dc9ffdb1d0bf6397d6013a',
52
+ hash: 'dcb5758d18f18abb8f705c37b894dcdc49c93c34b939d74c4cb32b2818b009c9',
53
+ requests:
54
+ '[{kind:"interface",members:[{name:"client_name",optional:false,type:"string"},{name:"client_type",optional:true,type:"\'public\' | \'confidential\'"},{name:"groups",optional:true,type:"string[]"},{name:"redirect_uris",optional:false,type:"string[]"},{name:"scopes",optional:true,type:"string[]"}],name:"CreateOidcClientRequest"},{kind:"interface",members:[{name:"application_slug",optional:false,type:"string"},{name:"client_id",optional:false,type:"string"},{name:"client_secret",optional:false,type:"string"},{name:"provider_id",optional:false,type:"number"}],name:"CreateOidcClientResult"},{kind:"interface",members:[{name:"description",optional:true,type:"string"},{name:"identifier",optional:false,type:"string"},{name:"username",optional:false,type:"string"}],name:"CreateTokenRequest"},{kind:"interface",members:[{name:"created",optional:false,type:"boolean"},{name:"token",optional:false,type:"string"}],name:"CreateTokenResult"},{kind:"interface",members:[{name:"email",optional:false,type:"string"},{name:"groups",optional:true,type:"string[]"},{name:"password",optional:false,type:"string"},{name:"username",optional:false,type:"string"}],name:"CreateUserRequest"},{kind:"interface",members:[{name:"created",optional:false,type:"boolean"},{name:"user_id",optional:false,type:"number"}],name:"CreateUserResult"},{kind:"interface",members:[{name:"create_oidc_client",optional:false,type:"Promise<CreateOidcClientResult>"},{name:"create_token",optional:false,type:"Promise<CreateTokenResult>"},{name:"create_user",optional:false,type:"Promise<CreateUserResult>"},{name:"list_tokens",optional:false,type:"Promise<TokenInfo[]>"},{name:"revoke_oidc_client",optional:false,type:"Promise<void>"},{name:"revoke_token",optional:false,type:"Promise<RevokeTokenResult>"}],name:"IdpCapability"},{kind:"interface",members:[{name:"username",optional:false,type:"string"}],name:"ListTokensRequest"},{kind:"interface",members:[{name:"client_id",optional:false,type:"string"}],name:"RevokeOidcClientRequest"},{kind:"interface",members:[{name:"identifier",optional:false,type:"string"}],name:"RevokeTokenRequest"},{kind:"interface",members:[{name:"revoked",optional:false,type:"boolean"}],name:"RevokeTokenResult"},{kind:"interface",members:[{name:"description",optional:false,type:"string"},{name:"expires",optional:false,type:"string | null"},{name:"expiring",optional:false,type:"boolean"},{name:"identifier",optional:false,type:"string"}],name:"TokenInfo"}]',
43
55
  },
44
56
  dns_registrar: {
45
57
  version: '4.0.0',
46
- hash: 'f5d02196a3394ec71d865beb45825160103800baf632293a769a6e72dc76236a',
58
+ hash: '5108a4dc2df0b4c78862c922df596156a58f04c64d81a65947724720e732b50c',
59
+ requests:
60
+ '[{kind:"interface",members:[{name:"registerHost",optional:false,type:"Promise<HookResult>"}],name:"DnsRegistrarCapability"},{kind:"interface",members:[{name:"artifactPaths",optional:true,type:"string[]"},{name:"duration",optional:false,type:"number"},{name:"error",optional:true,type:"string"},{name:"outputs",optional:false,type:"Record<string, unknown>"},{name:"success",optional:false,type:"boolean"}],name:"HookResult"},{kind:"interface",members:[{name:"fqdn",optional:false,type:"string"}],name:"RegisterHostRequest"}]',
47
61
  },
48
62
  firewall: {
49
63
  version: '1.1.0',
50
- hash: '564a83ebf8ca937faf955afc4f7bf4278fe9ed01694f0237bfbc38e2eff38408',
64
+ hash: 'f8c1c43b7d53193ae5b1e7cb7420a91efdb5e02f5bcd9add094aad66c0ec4104',
65
+ requests:
66
+ '[{kind:"interface",members:[{name:"description",optional:false,type:"string"},{name:"externalPort",optional:false,type:"number"},{name:"internalIp",optional:false,type:"string"},{name:"internalPort",optional:false,type:"number"},{name:"protocol",optional:false,type:"string"}],name:"ExposedService"},{kind:"interface",members:[{name:"description",optional:false,type:"string"},{name:"ingressIp",optional:true,type:"string"},{name:"internalIp",optional:false,type:"string"},{name:"ports",optional:false,type:"PortOrRange[]"},{name:"protocol",optional:true,type:"Protocol"}],name:"ExposeServiceRequest"},{kind:"interface",members:[{name:"externalIp",optional:false,type:"string"},{name:"natIp",optional:false,type:"string"}],name:"ExposeServiceResult"},{kind:"interface",members:[{name:"exposeService",optional:false,type:"Promise<ExposeServiceResult>"},{name:"listExposedServices",optional:false,type:"Promise<ExposedService[]>"},{name:"listTrustedSources",optional:true,type:"Promise<TrustedSource[]>"},{name:"providerModuleId",optional:true,type:"string"},{name:"registerTrustedSource",optional:true,type:"Promise<void>"}],name:"FirewallCapability"},{kind:"alias",members:[{name:"_self",optional:false,type:"number | {\\n start: number;\\n end: number;\\n}"}],name:"PortOrRange"},{kind:"alias",members:[{name:"_self",optional:false,type:"\'TCP\' | \'UDP\'"}],name:"Protocol"},{kind:"interface",members:[{name:"description",optional:false,type:"string"},{name:"subnets",optional:false,type:"string[]"}],name:"RegisterTrustedSourceRequest"},{kind:"interface",members:[{name:"description",optional:false,type:"string"},{name:"registeredBy",optional:false,type:"string"},{name:"subnet",optional:false,type:"string"}],name:"TrustedSource"}]',
51
67
  },
52
68
  source_forge: {
53
69
  version: '1.0.0',
54
- hash: '3bc797cf8c2990c61723fb680299f6a1d4b99ac42fd0371dd9dc64d05c588991',
70
+ hash: 'cb005fe21e69f0984b03b862b2739ef07f868d363fd3a925feadf8e865620916',
71
+ requests:
72
+ '[{kind:"interface",members:[{name:"owner",optional:true,type:"string"},{name:"repo",optional:true,type:"string"},{name:"runnerName",optional:false,type:"string"},{name:"scope",optional:true,type:"SourceForgeScope"}],name:"IssueRunnerTokenRequest"},{kind:"interface",members:[{name:"token",optional:false,type:"string"}],name:"IssueRunnerTokenResult"},{kind:"interface",members:[{name:"defaultBranch",optional:true,type:"string"},{name:"publishToken",optional:false,type:"string"},{name:"repo",optional:false,type:"string"},{name:"requiredChecks",optional:true,type:"string[]"}],name:"RegisterRepoRequest"},{kind:"interface",members:[{name:"protectedBranch",optional:false,type:"string"},{name:"repo",optional:false,type:"string"}],name:"RegisterRepoResult"},{kind:"interface",members:[{name:"issue_runner_token",optional:false,type:"Promise<IssueRunnerTokenResult>"},{name:"registerRepo",optional:false,type:"Promise<RegisterRepoResult>"}],name:"SourceForgeCapability"},{kind:"alias",members:[{name:"_self",optional:false,type:"\'instance\' | \'org\' | \'user\' | \'repo\'"}],name:"SourceForgeScope"}]',
55
73
  },
56
74
  registry_publish: {
57
75
  version: '1.0.0',
58
- hash: '693e7d08e9f105acd90dd00f8f9282ab3508def16e48237b47bc0ccba7885679',
76
+ hash: 'fd8a9fd097ab03fc6e4989099a8bf8ad011d2f0e641ecb9d7c8152504de3ce7a',
77
+ requests:
78
+ '[{kind:"interface",members:[{name:"repo",optional:false,type:"string"},{name:"scope",optional:false,type:"string"}],name:"MintScopedTokenRequest"},{kind:"interface",members:[{name:"token",optional:false,type:"string"}],name:"MintScopedTokenResult"},{kind:"interface",members:[{name:"mintScopedToken",optional:false,type:"Promise<MintScopedTokenResult>"},{name:"revokeRepo",optional:false,type:"Promise<void>"}],name:"RegistryPublishCapability"},{kind:"interface",members:[{name:"repo",optional:false,type:"string"}],name:"RevokeRepoRequest"}]',
59
79
  },
60
80
  dhcp_server: {
61
81
  version: '1.0.0',
62
- hash: '291a247d98bc11b18ab34d40b520bec679b95a031cbd674d513b313559fa3288',
82
+ hash: 'fc5751082942224c9343cb0a1455669b7a32d5442a8ae912efe88bd82b7648ae',
83
+ requests:
84
+ '[{kind:"interface",members:[{name:"getDnsServers",optional:false,type:"Promise<string[]>"},{name:"getDomainName",optional:false,type:"Promise<string>"},{name:"setDnsServers",optional:false,type:"Promise<void>"},{name:"setDomainName",optional:false,type:"Promise<void>"}],name:"DhcpServerCapability"}]',
63
85
  },
64
86
  dns_internal: {
65
87
  version: '1.1.0',
66
- hash: 'af72e861a23f6c81fe9a1a679121b7d3ea87ac5e8167efa20d5d6323010e5fef',
88
+ hash: 'd15a5ead445269d29ca46460268c1746892ef7d613688ac809b8a3d3395c675c',
89
+ requests:
90
+ '[{kind:"interface",members:[{name:"deleteRecord",optional:false,type:"Promise<void>"},{name:"reconcileViews",optional:true,type:"Promise<void>"},{name:"registerRecord",optional:false,type:"Promise<void>"},{name:"registerTransferPeer",optional:true,type:"Promise<void>"}],name:"DnsInternalCapability"},{kind:"interface",members:[{name:"host",optional:false,type:"string"},{name:"type",optional:false,type:"string"},{name:"value",optional:false,type:"string"},{name:"zone",optional:true,type:"string"},{name:"zoneRoutableValue",optional:true,type:"string"}],name:"DnsRecordRequest"},{kind:"interface",members:[{name:"moduleId",optional:false,type:"string"},{name:"serverIp",optional:false,type:"string"}],name:"TransferPeer"},{kind:"interface",members:[{name:"host",optional:false,type:"string"},{name:"ip",optional:false,type:"string"}],name:"ViewOverride"}]',
67
91
  },
68
92
  external_web: {
69
- version: '1.0.0',
70
- hash: '7396028ace76938f4e892e6ef8a53450183cdbcf8dff3ce767724bd9b2276dae',
93
+ version: '2.0.0',
94
+ hash: 'a906dc2a6645eb5da2dfa05d832a42e20f5defa8c1af300a67d6c8ef853b8ea4',
95
+ requests:
96
+ '[{kind:"interface",members:[{name:"publishStaticSite",optional:false,type:"Promise<PublishExternalSiteResult>"}],name:"ExternalWebCapability"},{kind:"interface",members:[{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"spaFallback",optional:true,type:"boolean"}],name:"PublishExternalSiteRequest"},{kind:"interface",members:[{name:"filesUploaded",optional:false,type:"number"},{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"PublishExternalSiteResult"}]',
71
97
  },
72
98
  notification: {
73
99
  version: '1.0.0',
74
- hash: '40629722b397a314d65a448a618d600fc8560c4aae0767dd7324d497d4cc9d41',
100
+ hash: '9d98b38d007b6d190fff6d243b38e871aeb707da2e48e3c111c3cfcc70ab75da',
101
+ requests:
102
+ '[{kind:"interface",members:[{name:"body",optional:false,type:"string"},{name:"messageId",optional:false,type:"string"},{name:"receivedAt",optional:false,type:"number"},{name:"senderAddress",optional:false,type:"string"}],name:"InboundMessage"},{kind:"interface",members:[{name:"receive",optional:true,type:"Promise<ReceiveResult>"},{name:"send",optional:false,type:"Promise<SendNotificationResult>"}],name:"NotificationCapability"},{kind:"interface",members:[{name:"cursor",optional:false,type:"string | null"},{name:"messages",optional:false,type:"InboundMessage[]"}],name:"ReceiveResult"},{kind:"interface",members:[{name:"address",optional:false,type:"string"},{name:"body",optional:false,type:"string"},{name:"token",optional:true,type:"string"}],name:"SendNotificationRequest"},{kind:"interface",members:[{name:"messageId",optional:false,type:"string"}],name:"SendNotificationResult"}]',
75
103
  },
76
104
  control_plane_vpn: {
77
105
  version: '1.0.0',
78
- hash: '2f7f81d0b3ecb2ab6822dbc9bf837a933b25e0e157eb94d0b9b8e801417f8f14',
106
+ hash: '742a739054bb351cda8586c5079ccb5877c22227afc57643c59152a746234e7b',
107
+ requests:
108
+ '[{kind:"interface",members:[{name:"getEndpoint",optional:false,type:"Promise<ControlPlaneVpnEndpoint>"},{name:"listClients",optional:false,type:"Promise<RegisteredClient[]>"},{name:"registerClient",optional:false,type:"Promise<RegisterClientResult>"},{name:"revokeClient",optional:false,type:"Promise<RevokeClientResult>"}],name:"ControlPlaneVpnCapability"},{kind:"interface",members:[{name:"clientPool",optional:false,type:"string"},{name:"endpoint",optional:false,type:"string"},{name:"serverPublicKey",optional:false,type:"string"}],name:"ControlPlaneVpnEndpoint"},{kind:"interface",members:[{name:"address",optional:false,type:"string"},{name:"label",optional:false,type:"string"},{name:"publicKey",optional:false,type:"string"}],name:"RegisterClientRequest"},{kind:"interface",members:[{name:"created",optional:false,type:"boolean"}],name:"RegisterClientResult"},{kind:"interface",members:[{name:"address",optional:false,type:"string"},{name:"label",optional:false,type:"string"},{name:"publicKey",optional:false,type:"string"},{name:"registeredAt",optional:false,type:"string"}],name:"RegisteredClient"},{kind:"interface",members:[{name:"publicKey",optional:false,type:"string"}],name:"RevokeClientRequest"},{kind:"interface",members:[{name:"revoked",optional:false,type:"boolean"}],name:"RevokeClientResult"}]',
79
109
  },
80
110
  control_plane_api: {
81
111
  version: '1.0.0',
82
- hash: '2c5d41d37a9a828a8b9a6dc633a2d48f24670d0831d879ec18426f0f9f8e5be5',
112
+ hash: '189860acaa746326bdb6e9cd3a5c6af6bbb2f94ce37d84cc7faab01c69f7fb30',
113
+ requests:
114
+ '[{kind:"interface",members:[{name:"enrol_principal",optional:false,type:"Promise<EnrolPrincipalResult>"},{name:"revoke_principal",optional:false,type:"Promise<RevokePrincipalResult>"}],name:"ControlPlaneApiCapability"},{kind:"interface",members:[{name:"name",optional:false,type:"string"},{name:"publicKey",optional:false,type:"string"}],name:"EnrolPrincipalRequest"},{kind:"interface",members:[{name:"created",optional:false,type:"boolean"},{name:"grants",optional:false,type:"string[]"},{name:"name",optional:false,type:"string"}],name:"EnrolPrincipalResult"},{kind:"interface",members:[{name:"name",optional:false,type:"string"}],name:"RevokePrincipalRequest"},{kind:"interface",members:[{name:"revoked",optional:false,type:"boolean"}],name:"RevokePrincipalResult"}]',
83
115
  },
84
116
  private_web: {
85
- version: '1.0.0',
86
- hash: '0eddb82f821fe3c7fbd503d55a24c3d5a22369af8b22de06570a371d5f485c03',
117
+ version: '2.0.0',
118
+ hash: 'fb301f5e55ca14fdb0ede0d428d2ac72eabed77e277de8c795eaa0b955695483',
119
+ requests:
120
+ '[{kind:"alias",members:[{name:"_self",optional:false,type:"Record<string, string | number | boolean>"}],name:"ClientConfig"},{kind:"interface",members:[{name:"defaultHostname",optional:false,type:"string"},{name:"getCaCertificate",optional:false,type:"Promise<string>"},{name:"publishStaticSite",optional:false,type:"Promise<PublishStaticSiteResult>"},{name:"registerReverseProxy",optional:false,type:"Promise<RegisterReverseProxyResult>"}],name:"PrivateWebCapability"},{kind:"interface",members:[{name:"clientConfig",optional:true,type:"ClientConfig"},{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"spaFallback",optional:true,type:"boolean"}],name:"PublishStaticSiteRequest"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string"},{name:"filesUploaded",optional:false,type:"number"},{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"PublishStaticSiteResult"},{kind:"interface",members:[{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"targetHost",optional:false,type:"string"},{name:"targetPort",optional:false,type:"number"},{name:"websocket",optional:true,type:"boolean"}],name:"RegisterReverseProxyRequest"},{kind:"interface",members:[{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"RegisterReverseProxyResult"}]',
87
121
  },
88
122
  cross_module_read: {
89
123
  version: '1.0.0',
90
- hash: 'e152f0738c88a7105b6057037f350ce0bbcbfc5c4ccbccc3af60f1cf3f1904af',
124
+ hash: 'a8af728f3f3ec7bff57934fe6b4313eb58a32afa606f6a8d2b9923a9e492dd58',
125
+ requests:
126
+ '[{kind:"interface",members:[{name:"granted",optional:false,type:"true"}],name:"CrossModuleReadCapability"}]',
91
127
  },
92
128
  celilo_module_deploy_worker: {
93
129
  version: '1.0.0',
94
- hash: '0b0e1f26aab0ebcf5d28283b9c5ba711f6d182f8f7ad474379c4ab02c354a2b8',
130
+ hash: '51fe35fc321520b37532478490a956c34b97ed3592ee59b3a0f8aed4b8b96672',
131
+ requests:
132
+ '[{kind:"alias",members:[{name:"_self",optional:false,type:"{\\n accepted: true;\\n moduleId: string | null;\\n} | {\\n accepted: false;\\n reason: string;\\n}"}],name:"DestroyInstanceOutcome"},{kind:"interface",members:[{name:"instanceKey",optional:false,type:"string"},{name:"submodule",optional:false,type:"string"}],name:"DestroyInstanceRequest"},{kind:"alias",members:[{name:"_self",optional:false,type:"{\\n accepted: true;\\n moduleId: string;\\n} | {\\n accepted: false;\\n reason: string;\\n}"}],name:"InstanceRequestOutcome"},{kind:"alias",members:[{name:"_self",optional:false,type:"(typeof INSTANCE_STATES)[number]"}],name:"InstanceState"},{kind:"interface",members:[{name:"answers",optional:true,type:"Record<string, unknown>"},{name:"config",optional:true,type:"Record<string, unknown>"},{name:"instanceKey",optional:false,type:"string"},{name:"label",optional:true,type:"string"},{name:"submodule",optional:false,type:"string"}],name:"InstantiateRequest"},{kind:"interface",members:[{name:"states",optional:true,type:"readonly InstanceState[]"},{name:"submodule",optional:true,type:"string"}],name:"ListInstancesRequest"},{kind:"interface",members:[{name:"destroy",optional:false,type:"Promise<DestroyInstanceOutcome>"},{name:"instantiate",optional:false,type:"Promise<InstanceRequestOutcome>"},{name:"list",optional:false,type:"Promise<ModuleInstance[]>"}],name:"ModuleDeployWorkerCapability"},{kind:"interface",members:[{name:"failureReason",optional:false,type:"string | null"},{name:"instanceKey",optional:false,type:"string"},{name:"label",optional:false,type:"string | null"},{name:"moduleId",optional:false,type:"string"},{name:"retryable",optional:false,type:"boolean | null"},{name:"state",optional:false,type:"InstanceState"},{name:"submodule",optional:false,type:"string"}],name:"ModuleInstance"}]',
95
133
  },
96
134
  web_routes: {
97
135
  version: '1.0.0',
98
- hash: 'bbf6435528a799e205270681e0ffc34f4007efbf04240138d4b69594a2515dbb',
136
+ hash: '5ae5e43d2948a37dfc23a5aef490f4ef459c8b4ef09719c42961eea73b8da5ff',
137
+ requests:
138
+ '[{kind:"interface",members:[{name:"getAllRoutes",optional:false,type:"Promise<WebRoute[]>"},{name:"getRoutes",optional:false,type:"Promise<WebRoute[]>"}],name:"RouteReadView"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string | null"},{name:"createdAt",optional:false,type:"Date"},{name:"hostname",optional:false,type:"string"},{name:"id",optional:false,type:"number"},{name:"moduleId",optional:false,type:"string"},{name:"path",optional:false,type:"string"},{name:"slug",optional:false,type:"string"},{name:"targetHost",optional:false,type:"string | null"},{name:"targetPort",optional:false,type:"number | null"},{name:"type",optional:false,type:"\'static\' | \'reverse_proxy\'"},{name:"updatedAt",optional:false,type:"Date"},{name:"websocket",optional:false,type:"boolean"}],name:"WebRoute"}]',
99
139
  },
100
140
  firewall_registry: {
101
141
  version: '2.0.0',
102
- hash: 'db266155107b9eba88dbff576f979069e012e10a6dfb80de4544f809d1157bfb',
142
+ hash: 'd7a587e961500f1bc464c4a3427dd2ec85c7f80c1ed11a9c64beeb0decea524c',
143
+ requests:
144
+ '[{kind:"interface",members:[{name:"converge",optional:false,type:"Promise<void>"},{name:"withdrawConsumer",optional:false,type:"Promise<void>"}],name:"FirewallProviderView"}]',
103
145
  },
104
146
  };
@@ -27,7 +27,15 @@ import { CAPABILITY_CONTRACT_VERSIONS } from '@celilo/capabilities';
27
27
  import { is } from 'drizzle-orm';
28
28
  import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core';
29
29
  import * as dbSchema from '../db/schema';
30
- import { capabilitySubjects, shapeHash, tablesOf } from './capability-shape';
30
+ import {
31
+ type RequestTypeShape,
32
+ capabilitySubjects,
33
+ classifyRequestChange,
34
+ requestTypes,
35
+ requestsKey,
36
+ shapeHash,
37
+ tablesOf,
38
+ } from './capability-shape';
31
39
  import { CAPABILITY_SHAPE_BASELINE } from './capability-shape-baseline';
32
40
 
33
41
  /** Physical table name → the drizzle table the running code declares. */
@@ -160,3 +168,47 @@ describe('Scan B — a shape change carries a version bump', () => {
160
168
  expect(drifted).toEqual([]);
161
169
  });
162
170
  });
171
+
172
+ describe('Scan C — a request-contract change carries a MAJOR bump', () => {
173
+ const majorOf = (version: string): number =>
174
+ Number.parseInt(version.replace(/^v/, '').split('-')[0].split('.')[0], 10) || 0;
175
+
176
+ /**
177
+ * The regenerate script refuses to absorb a breaking request diff (removed
178
+ * member, optional → required, type change) without a major bump. This test
179
+ * is the same predicate against the COMMITTED baseline, so hand-editing the
180
+ * fixture does not route around the script — and it is what makes the rule
181
+ * a gate rather than a convention (celilo#1361, where `sourceDir` left three
182
+ * request types with no version move at all).
183
+ */
184
+ test('a request-shape change against the baseline means the major moved', () => {
185
+ const versions = CAPABILITY_CONTRACT_VERSIONS as Record<string, string>;
186
+ const violations: string[] = [];
187
+ for (const capability of capabilitySubjects().keys()) {
188
+ const recorded = CAPABILITY_SHAPE_BASELINE[capability];
189
+ if (!recorded) continue;
190
+ if (recorded.requests === undefined) {
191
+ violations.push(
192
+ `${capability}: baseline has no requests field. Re-run bun run apps/celilo/scripts/regenerate-capability-shape-baseline.ts`,
193
+ );
194
+ continue;
195
+ }
196
+ const current = requestsKey(capability);
197
+ if (current === recorded.requests) continue;
198
+ const change = classifyRequestChange(
199
+ JSON.parse(recorded.requests) as RequestTypeShape[],
200
+ requestTypes(capability),
201
+ );
202
+ if (
203
+ change.kind === 'breaking' &&
204
+ majorOf(recorded.version) >= majorOf(versions[capability])
205
+ ) {
206
+ violations.push(
207
+ `${capability}: request contract changed without a MAJOR bump (${change.reasons.join('; ')}). ` +
208
+ `Bump CAPABILITY_CONTRACT_VERSIONS.${capability} and regenerate the baseline.`,
209
+ );
210
+ }
211
+ }
212
+ expect(violations).toEqual([]);
213
+ });
214
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The breaking/additive classifier behind the request-contract gate
3
+ * (capability-shape-drift.test.ts Scan C and the regenerate script's guard).
4
+ *
5
+ * celilo#1361 shipped because a request type lost a required field and no
6
+ * version moved. These tests pin the classification that prevents the next
7
+ * one: member removal, optional → required, and type changes are BREAKING;
8
+ * an added optional member is additive.
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { type RequestTypeShape, classifyRequestChange, requestTypes } from './capability-shape';
13
+
14
+ function shape(
15
+ name: string,
16
+ members: Array<[name: string, type: string, optional?: boolean]>,
17
+ ): RequestTypeShape {
18
+ return {
19
+ name,
20
+ kind: 'interface',
21
+ members: members.map(([memberName, type, optional]) => ({
22
+ name: memberName,
23
+ type,
24
+ optional: optional ?? false,
25
+ })),
26
+ };
27
+ }
28
+
29
+ describe('classifyRequestChange', () => {
30
+ test('no members changed → unchanged', () => {
31
+ const before = [shape('R', [['path', 'string']])];
32
+ expect(classifyRequestChange(before, structuredClone(before))).toEqual({ kind: 'unchanged' });
33
+ });
34
+
35
+ test('a removed member is breaking', () => {
36
+ const before = [
37
+ shape('R', [
38
+ ['path', 'string'],
39
+ ['sourceDir', 'string'],
40
+ ]),
41
+ ];
42
+ const after = [shape('R', [['path', 'string']])];
43
+ const result = classifyRequestChange(before, after);
44
+ expect(result).toEqual({ kind: 'breaking', reasons: ['R.sourceDir was removed'] });
45
+ });
46
+
47
+ test('optional → required is breaking', () => {
48
+ const before = [shape('R', [['hostname', 'string', true]])];
49
+ const after = [shape('R', [['hostname', 'string']])];
50
+ expect(classifyRequestChange(before, after)).toEqual({
51
+ kind: 'breaking',
52
+ reasons: ['R.hostname went optional → required'],
53
+ });
54
+ });
55
+
56
+ test('a type change is breaking (the diff cannot tell widened from narrowed)', () => {
57
+ const before = [shape('R', [['port', 'number']])];
58
+ const after = [shape('R', [['port', 'number | string']])];
59
+ expect(classifyRequestChange(before, after).kind).toBe('breaking');
60
+ });
61
+
62
+ test('a new REQUIRED member is breaking', () => {
63
+ const before = [shape('R', [['path', 'string']])];
64
+ const after = [
65
+ shape('R', [
66
+ ['path', 'string'],
67
+ ['digest', 'string'],
68
+ ]),
69
+ ];
70
+ expect(classifyRequestChange(before, after).kind).toBe('breaking');
71
+ });
72
+
73
+ test('a new optional member is additive', () => {
74
+ const before = [shape('R', [['path', 'string']])];
75
+ const after = [
76
+ shape('R', [
77
+ ['path', 'string'],
78
+ ['spaFallback', 'boolean', true],
79
+ ]),
80
+ ];
81
+ const result = classifyRequestChange(before, after);
82
+ expect(result.kind).toBe('additive');
83
+ if (result.kind === 'additive') expect(result.note).toContain('spaFallback');
84
+ });
85
+
86
+ test('required → optional is additive', () => {
87
+ const before = [shape('R', [['hostname', 'string']])];
88
+ const after = [shape('R', [['hostname', 'string', true]])];
89
+ expect(classifyRequestChange(before, after).kind).toBe('additive');
90
+ });
91
+
92
+ test('a type leaving the reachable set is an interface change, not a request break', () => {
93
+ const before = [shape('Old', [['path', 'string']]), shape('New', [['path', 'string']])];
94
+ const after = [shape('New', [['path', 'string']])];
95
+ expect(classifyRequestChange(before, after)).toEqual({ kind: 'unchanged' });
96
+ });
97
+
98
+ test('public_web resolves its real request types, sourceDir no longer among them', () => {
99
+ const publish = requestTypes('public_web').find((t) => t.name === 'PublishStaticSiteRequest');
100
+ expect(publish).toBeDefined();
101
+ const names = publish?.members.map((m) => m.name) ?? [];
102
+ expect(names).toContain('path');
103
+ expect(names).not.toContain('sourceDir');
104
+ });
105
+ });