@celilo/cli 2.0.0 → 2.2.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 (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. package/src/variables/context.ts +16 -5
@@ -0,0 +1,47 @@
1
+ import type { HookStore, HookStoreBackedMap } from '@celilo/capabilities';
2
+
3
+ /**
4
+ * In-memory hook-owned-state stores for tests that build a HookContext
5
+ * without a broker. The four methods mutate the record directly; the
6
+ * manifest validation a real store applies lives broker-side
7
+ * (hook-store.ts) and has its own tests.
8
+ *
9
+ * Methods are defined non-enumerably, so the result spreads, serializes and
10
+ * `toEqual`s as the plain record the test wrote — the same shape a real
11
+ * broker-side context carries when executeHookScript frames it.
12
+ */
13
+ export function configStore(
14
+ values: Record<string, unknown> = {},
15
+ ): Record<string, unknown> & HookStore {
16
+ return attach(values);
17
+ }
18
+
19
+ export function secretStore(values: Record<string, string> = {}): HookStoreBackedMap {
20
+ // Downcast: the caller passed Record<string, string>; attach only adds
21
+ // methods and never writes, so the value type is unchanged.
22
+ return attach(values) as HookStoreBackedMap;
23
+ }
24
+
25
+ function attach(values: Record<string, unknown>): Record<string, unknown> & HookStore {
26
+ const store: HookStore = {
27
+ get: async (name) => values[name] as string | undefined,
28
+ set: async (name, value) => {
29
+ values[name] = value;
30
+ },
31
+ delete: async (name) => {
32
+ delete values[name];
33
+ },
34
+ transaction: async (fn) => {
35
+ await fn(store);
36
+ },
37
+ };
38
+ for (const [name, method] of Object.entries(store)) {
39
+ Object.defineProperty(values, name, {
40
+ value: method,
41
+ enumerable: false,
42
+ writable: true,
43
+ configurable: true,
44
+ });
45
+ }
46
+ return Object.assign(values, store);
47
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Test fixture: a hook that exercises the hook-owned-state accessor across
3
+ * the process boundary — set/get round trip on both stores, delete, a
4
+ * committed transaction, a discarded transaction, and the undeclared-name
5
+ * throw the whole design exists to produce.
6
+ */
7
+
8
+ import { defineHook } from '@celilo/capabilities';
9
+
10
+ export default defineHook({
11
+ hook: 'on_install',
12
+ requires: [],
13
+ handler: async (ctx) => {
14
+ const outputs: Record<string, unknown> = {};
15
+
16
+ // Round trip on each store.
17
+ await ctx.secrets.set('bot_token', 'token-value');
18
+ outputs.secretRoundTrip = await ctx.secrets.get('bot_token');
19
+
20
+ await ctx.config.set('public_ip', '203.0.113.7');
21
+ outputs.configRoundTrip = await ctx.config.get('public_ip');
22
+
23
+ // The map surface still answers from the values the context carried.
24
+ outputs.mapRead = ctx.config.mapOnlyValue;
25
+
26
+ // Delete removes; deleting a declared-but-never-written name is a no-op.
27
+ // (An UNDECLARED name on delete throws — that is 3.4, tested below.
28
+ // Declared-and-absent is the no-op case 3.7 pins.)
29
+ await ctx.secrets.delete('bot_token');
30
+ await ctx.secrets.set('api_key', 'to-delete');
31
+ await ctx.secrets.delete('api_key');
32
+ outputs.deletedIsGone = await ctx.secrets.get('api_key');
33
+
34
+ // Committed transaction: both writes land.
35
+ await ctx.secrets.transaction((s) => {
36
+ s.set('bot_token', 'committed-a');
37
+ s.set('api_key', 'committed-b');
38
+ });
39
+ outputs.transactionA = await ctx.secrets.get('bot_token');
40
+ outputs.transactionB = await ctx.secrets.get('api_key');
41
+
42
+ // Discarded transaction: nothing lands, including the overwrite of the
43
+ // committed value above.
44
+ try {
45
+ await ctx.secrets.transaction(async (s) => {
46
+ s.set('bot_token', 'discarded');
47
+ throw new Error('hook failed midway');
48
+ });
49
+ } catch (error) {
50
+ outputs.discarded = (error as Error).message;
51
+ }
52
+ outputs.afterDiscard = await ctx.secrets.get('bot_token');
53
+
54
+ // An undeclared name throws, naming the module and the declared set.
55
+ try {
56
+ await ctx.secrets.set('not_declared', 'x');
57
+ } catch (error) {
58
+ outputs.undeclaredSecret = (error as Error).message;
59
+ }
60
+
61
+ return outputs;
62
+ },
63
+ });
@@ -26,6 +26,7 @@ import { executeHookScript, hookChildEnv } from './executor';
26
26
  import type { MountSetWire } from './hook-protocol';
27
27
  import { createCapturingLogger } from './logger';
28
28
  import { deriveMountSet } from './mount-set';
29
+ import { configStore, secretStore } from './test-fixtures/store-backed';
29
30
  import type { HookContext } from './types';
30
31
  import { classifyAccess, mountSetEnvValue, parseLintMountSet } from './unjailed-lint';
31
32
 
@@ -54,10 +55,25 @@ function scratchModule(script: string): string {
54
55
 
55
56
  const describeSet = (overrides: Partial<MountSetWire>): MountSetWire => ({
56
57
  entries: [
57
- { path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run' },
58
- { path: '/srv/celilo/mod', mode: 'ro', reason: "the module's own tree" },
59
- { path: '/srv/celilo/mod/state', mode: 'rw', reason: 'the sanctioned writable directory' },
60
- { path: '/srv/staged', mode: 'rw', reason: "contract input 'backup_dir' (write)" },
58
+ { path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run', absence: 'runtime' },
59
+ {
60
+ path: '/srv/celilo/mod',
61
+ mode: 'ro',
62
+ reason: "the module's own tree",
63
+ absence: 'required',
64
+ },
65
+ {
66
+ path: '/srv/celilo/mod/state',
67
+ mode: 'rw',
68
+ reason: 'the sanctioned writable directory',
69
+ absence: 'required',
70
+ },
71
+ {
72
+ path: '/srv/staged',
73
+ mode: 'rw',
74
+ reason: "contract input 'backup_dir' (write)",
75
+ absence: 'required',
76
+ },
61
77
  ],
62
78
  chdir: '/srv/celilo/mod',
63
79
  ...overrides,
@@ -159,8 +175,8 @@ describe('the lint inside a real hook run', () => {
159
175
  mkdirSync(join(root, 'state'), { recursive: true });
160
176
  const { logger, messages } = createCapturingLogger();
161
177
  const context: HookContext = {
162
- config: {},
163
- secrets: {},
178
+ config: configStore(),
179
+ secrets: secretStore(),
164
180
  systems: [],
165
181
  logger,
166
182
  debug: false,
@@ -188,7 +204,10 @@ describe('the lint inside a real hook run', () => {
188
204
  export default defineHook({
189
205
  requires: [] as const,
190
206
  handler: async () => {
191
- try { readFileSync('/etc/hosts', 'utf-8'); } catch { /* the read is the point, not the bytes */ }
207
+ // A path outside the mount set. Not /etc/hosts — the resolver
208
+ // binding (celilo#1225) mounts that read-only on purpose, and the
209
+ // lint is right to stay silent about it.
210
+ try { readFileSync('/etc/passwd', 'utf-8'); } catch { /* the read is the point, not the bytes */ }
192
211
  return {};
193
212
  },
194
213
  });
@@ -196,7 +215,7 @@ describe('the lint inside a real hook run', () => {
196
215
 
197
216
  const warnings = advisories(messages);
198
217
  expect(warnings.length).toBe(1);
199
- expect(warnings[0]).toContain('/etc/hosts');
218
+ expect(warnings[0]).toContain('/etc/passwd');
200
219
  expect(warnings[0]).toContain('on a jailed host');
201
220
  // The one sentence the task forbids losing: what it is, and what it is not.
202
221
  expect(warnings[0]).toContain('Advisory lint, not a security boundary');
@@ -17,6 +17,7 @@ export const VariableSourceSchema = z.enum([
17
17
  'system',
18
18
  'terraform',
19
19
  'infrastructure',
20
+ 'hook',
20
21
  ]);
21
22
 
22
23
  /**
@@ -1,8 +1,9 @@
1
1
  import { execFileSync, execSync } from 'node:child_process';
2
- import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { readFile, readdir, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { basename, join, relative } from 'node:path';
6
+ import { gunzipSync } from 'node:zlib';
6
7
  import { create as tarCreate } from 'tar';
7
8
  import { parse as parseYaml } from 'yaml';
8
9
  import { log } from '../../cli/prompts';
@@ -120,6 +121,33 @@ export async function computeChecksums(sourceDir: string): Promise<ChecksumsData
120
121
  };
121
122
  }
122
123
 
124
+ /**
125
+ * Ceiling on the final tar+gzip write of the .netapp. Every step before it
126
+ * (staging, the module's own build command) already carries a timeout, but the
127
+ * tar stream did not — and 2026-09-04 it stalled mid-write (all samples in
128
+ * kevent64, artifact truncated at 59 percent) and sat there for two hours with
129
+ * no error and no exit. A bounded wait turns that into a nameable failure.
130
+ */
131
+ const PACKAGE_STREAM_TIMEOUT_MS = 300_000;
132
+
133
+ /**
134
+ * Validate that a produced .netapp is a complete gzip stream.
135
+ *
136
+ * A .netapp is a gzipped tar (tarCreate above runs with gzip: true), so a
137
+ * full gunzip pass is the whole check: a stream truncated mid-write fails
138
+ * with 'unexpected end of file' instead of surviving to the consumer, where
139
+ * the same zlib error reads as a truncated DOWNLOAD from an unrelated cause.
140
+ * Returns null when valid, otherwise the reason.
141
+ */
142
+ export function verifyNetappIntegrity(packagePath: string): string | null {
143
+ try {
144
+ gunzipSync(readFileSync(packagePath));
145
+ return null;
146
+ } catch (err) {
147
+ return err instanceof Error ? err.message : String(err);
148
+ }
149
+ }
150
+
123
151
  /**
124
152
  * Build a module package (.netapp file)
125
153
  *
@@ -321,7 +349,7 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
321
349
 
322
350
  // Create tarball
323
351
  const finalOutputPath = outputPath || join(process.cwd(), `${moduleId}.netapp`);
324
- await tarCreate(
352
+ const tarPromise = tarCreate(
325
353
  {
326
354
  file: finalOutputPath,
327
355
  cwd: buildDir,
@@ -329,6 +357,46 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
329
357
  },
330
358
  ['checksums.json', 'signature.sig', ...Object.keys(checksumsData.files)],
331
359
  );
360
+ let timer: ReturnType<typeof setTimeout> | undefined;
361
+ const timeout = new Promise<never>((_, reject) => {
362
+ timer = setTimeout(
363
+ () =>
364
+ reject(
365
+ new Error(
366
+ `packaging tar stream exceeded ${PACKAGE_STREAM_TIMEOUT_MS / 1000}s and was abandoned mid-write`,
367
+ ),
368
+ ),
369
+ PACKAGE_STREAM_TIMEOUT_MS,
370
+ );
371
+ });
372
+ timer?.unref?.();
373
+ try {
374
+ await Promise.race([tarPromise, timeout]);
375
+ } catch (tarError) {
376
+ // The losing side may still hold the output file open: swallow its
377
+ // eventual rejection and remove the partial artifact so nothing
378
+ // downstream mistakes a truncated write for a package.
379
+ tarPromise.catch(() => {});
380
+ rmSync(finalOutputPath, { force: true });
381
+ return {
382
+ success: false,
383
+ error: `Failed to build module: ${
384
+ tarError instanceof Error ? tarError.message : 'Unknown error'
385
+ }`,
386
+ };
387
+ }
388
+
389
+ // Refuse to declare success on an artifact we cannot read back. The tar
390
+ // step resolved, so this only trips if the stream left the file truncated
391
+ // anyway — the exact corruption build-infra shipped on 2026-09-04.
392
+ const integrityError = verifyNetappIntegrity(finalOutputPath);
393
+ if (integrityError) {
394
+ rmSync(finalOutputPath, { force: true });
395
+ return {
396
+ success: false,
397
+ error: `Produced package failed gzip integrity check (${finalOutputPath}): ${integrityError}`,
398
+ };
399
+ }
332
400
 
333
401
  return {
334
402
  success: true,
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { join } from 'node:path';
17
- import { MODULE_WEB_ROOT } from '@celilo/capabilities';
17
+ import { MODULE_STATE_WEB_ROOT, MODULE_WEB_ROOT } from '@celilo/capabilities';
18
18
  import { eq } from 'drizzle-orm';
19
19
  import type { DbClient } from '../db/client';
20
20
  import { modules } from '../db/schema';
@@ -33,3 +33,19 @@ export function resolveModuleWebRoot(moduleId: string, db: DbClient): string | u
33
33
  if (!module) return undefined;
34
34
  return join(module.sourcePath, MODULE_WEB_ROOT);
35
35
  }
36
+
37
+ /**
38
+ * Absolute path to `<module root>/state/site` — the state web overlay
39
+ * (celilo#1265) — or undefined when no such module is installed.
40
+ *
41
+ * Beside `resolveModuleWebRoot` for the same reason that function lives here:
42
+ * ONE application of the convention, by the one party that can resolve it for
43
+ * a module that is not running. Does NOT check that the directory exists — a
44
+ * module with no generated site content has no overlay, and deciding what an
45
+ * absent directory means belongs to the caller (see `requireWebRoot`).
46
+ */
47
+ export function resolveModuleStateWebRoot(moduleId: string, db: DbClient): string | undefined {
48
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
49
+ if (!module) return undefined;
50
+ return join(module.sourcePath, MODULE_STATE_WEB_ROOT);
51
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * A module's e2e fixtures must deploy (at least import) a provider for every
3
+ * capability its manifest requires.
4
+ *
5
+ * The failure this exists to catch is silent in the worst way: the manifest
6
+ * requirement makes stage 1 of the module's own suite fail at
7
+ * `module import <id>` with "Required capability 'X' not found", and every
8
+ * later stage cascade-skips on requireStage. Nothing else goes red — the
9
+ * suite is quarantined, or CI never runs it, so a fixture can sit
10
+ * structurally incapable of passing for six weeks (celilo#1260:
11
+ * registry-pipeline required idp@1.2.0 from 7e2d27b9 onward and never
12
+ * deployed an idp provider).
13
+ *
14
+ * Both sides of the comparison are computed sets. The required set comes
15
+ * from each module's manifest.yml parsed by the production validator; the
16
+ * provided set comes from the manifests of whatever modules the module's
17
+ * fixtures actually import (`module import <ref>` statements, scanned in
18
+ * source). A hand-written list of "modules that need an idp" would rot the
19
+ * moment the next module declares the requirement.
20
+ *
21
+ * The check runs the real `validateCapabilityAccess` against an in-memory
22
+ * database seeded with the fixtures' providers — the same code path the CLI
23
+ * executes at import time, including the secret-allowlist gate — so the
24
+ * assertion is over the actual contract stage 1 exercises, not a re-derivation
25
+ * of it.
26
+ *
27
+ * ## What this gate does NOT prove. Read before trusting green.
28
+ *
29
+ * - Import is necessary, not sufficient. A fixture can import the provider
30
+ * and still deploy the consumer before it, or never deploy the provider
31
+ * at all (the consumer's on_install would then fail against a dead idp).
32
+ * Only running the suite proves deploy order.
33
+ * - The import scan is a source scan. An import built dynamically in a way
34
+ * this scanner cannot parse is invisible here (the scanner requires a
35
+ * literal module id, a `${CONST}` reference to a string literal in the
36
+ * same file, or a trailing path segment).
37
+ * - Fixtures living outside `modules/<id>/e2e/*.test.ts` (shared harness
38
+ * files that deploy on the suite's behalf) are not scanned.
39
+ */
40
+
41
+ import { Database, type Database as DatabaseType } from 'bun:sqlite';
42
+ import { describe, expect, test } from 'bun:test';
43
+ import {
44
+ existsSync,
45
+ mkdirSync,
46
+ mkdtempSync,
47
+ readFileSync,
48
+ readdirSync,
49
+ rmSync,
50
+ writeFileSync,
51
+ } from 'node:fs';
52
+ import { tmpdir } from 'node:os';
53
+ import { join } from 'node:path';
54
+ import { validateCapabilityAccess } from '../capabilities/validation';
55
+ import type { ModuleManifest } from '../manifest/schema';
56
+ import { validateManifest } from '../manifest/validate';
57
+ import { repoRoot } from './capability-shape';
58
+
59
+ /** Every module manifest under `modulesDir`, parsed by the production validator. */
60
+ function loadModuleManifests(modulesDir: string): Map<string, ModuleManifest> {
61
+ const manifests = new Map<string, ModuleManifest>();
62
+ for (const id of readdirSync(modulesDir)) {
63
+ const path = join(modulesDir, id, 'manifest.yml');
64
+ if (!existsSync(path)) continue;
65
+ const result = validateManifest(readFileSync(path, 'utf8'));
66
+ if (result.success) manifests.set(id, result.data);
67
+ // Manifests that fail validation are another gate's job (validate.test.ts
68
+ // and scripts/check-modules.sh); scanning them here would double-report.
69
+ }
70
+ return manifests;
71
+ }
72
+
73
+ /**
74
+ * Module ids a fixture source imports, via `module import <ref>` statements.
75
+ *
76
+ * Handles the three shapes fixtures actually use: a bare literal
77
+ * (`'module import authentik'`), a path (`'module import /celilo/modules/authentik'`),
78
+ * and a `${CONST}` reference to a string literal defined in the same file
79
+ * (`module import ${PRIMARY}`). Trailing CLI flags (`--accept-aspects`) and
80
+ * timeouts are stripped. Anything that does not resolve to a plausible
81
+ * module id is skipped rather than guessed.
82
+ */
83
+ function scanFixtureImports(fixtureSource: string): string[] {
84
+ const constants = new Map<string, string>();
85
+ for (const match of fixtureSource.matchAll(/const\s+(\w+)\s*=\s*'([^']+)'/g)) {
86
+ constants.set(match[1], match[2]);
87
+ }
88
+
89
+ const imports: string[] = [];
90
+ for (const match of fixtureSource.matchAll(/module import ([^'"\n]+?)['"`]/g)) {
91
+ let ref = match[1].trim();
92
+ const constRef = ref.match(/^\$\{(\w+)\}/);
93
+ if (constRef) ref = constants.get(constRef[1]) ?? '';
94
+ const id = ref.split(/\s+/)[0]?.split('/').pop() ?? '';
95
+ if (id && /^[a-z0-9-]+$/.test(id)) imports.push(id);
96
+ }
97
+ return imports;
98
+ }
99
+
100
+ /** The union of modules any `*.test.ts` fixture in `moduleId`'s e2e/ imports. */
101
+ function fixtureImportedModules(modulesDir: string, moduleId: string): string[] {
102
+ const e2eDir = join(modulesDir, moduleId, 'e2e');
103
+ if (!existsSync(e2eDir)) return [];
104
+ const imported = new Set<string>();
105
+ for (const file of readdirSync(e2eDir)) {
106
+ if (!file.endsWith('.test.ts')) continue;
107
+ for (const id of scanFixtureImports(readFileSync(join(e2eDir, file), 'utf8'))) {
108
+ imported.add(id);
109
+ }
110
+ }
111
+ return [...imported];
112
+ }
113
+
114
+ /**
115
+ * An in-memory database shaped like the CLI's module registry, seeded with
116
+ * every capability `modules` provide — what `getProviderManifest` queries.
117
+ */
118
+ function providerDatabase(manifests: Map<string, ModuleManifest>): DatabaseType {
119
+ const db = new Database(':memory:');
120
+ db.exec('CREATE TABLE modules (id TEXT PRIMARY KEY, manifest_data TEXT)');
121
+ db.exec('CREATE TABLE capabilities (module_id TEXT, capability_name TEXT)');
122
+ const insertModule = db.prepare('INSERT INTO modules (id, manifest_data) VALUES (?, ?)');
123
+ const insertCapability = db.prepare(
124
+ 'INSERT INTO capabilities (module_id, capability_name) VALUES (?, ?)',
125
+ );
126
+ for (const [id, manifest] of manifests) {
127
+ insertModule.run(id, JSON.stringify(manifest));
128
+ for (const capability of manifest.provides?.capabilities ?? []) {
129
+ insertCapability.run(id, capability.name);
130
+ }
131
+ }
132
+ return db;
133
+ }
134
+
135
+ /**
136
+ * Modules with e2e fixtures whose required capabilities no imported module
137
+ * provides. The provider union is seeded into a real database and each
138
+ * consumer is checked with the production `validateCapabilityAccess`.
139
+ */
140
+ async function capabilityCoverageViolations(modulesDir: string): Promise<string[]> {
141
+ const manifests = loadModuleManifests(modulesDir);
142
+ const violations: string[] = [];
143
+ for (const [id, manifest] of manifests) {
144
+ if (fixtureImportedModules(modulesDir, id).length === 0) continue;
145
+ const imported = fixtureImportedModules(modulesDir, id)
146
+ .map((dep) => manifests.get(dep))
147
+ .filter((m): m is ModuleManifest => m !== undefined);
148
+ const db = providerDatabase(new Map(imported.map((m) => [m.id, m])));
149
+ try {
150
+ const result = await validateCapabilityAccess(manifest, db);
151
+ if (!result.success) {
152
+ violations.push(`${id}: ${result.error ?? 'unknown error'}`);
153
+ }
154
+ } finally {
155
+ db.close();
156
+ }
157
+ }
158
+ return violations;
159
+ }
160
+
161
+ describe('fixture capability coverage', () => {
162
+ test('every module with e2e fixtures imports a provider for each required capability', async () => {
163
+ const violations = await capabilityCoverageViolations(join(repoRoot(), 'modules'));
164
+ expect(violations).toEqual([]);
165
+ });
166
+
167
+ // Reach measurement (Rule 2 of the lane plan): the real scanner and checker
168
+ // run against a mirrored module tree with planted markers, so the pass and
169
+ // fail cases are both proven to fire — not reasoned about. This is the
170
+ // mutation test for the gate itself: the real-tree test above can only go
171
+ // green, so the mirror is where the gate is shown to fail.
172
+ describe('reach: the gate fires on a mirrored tree (synthetic modules)', () => {
173
+ function mirrorTree(
174
+ dir: string,
175
+ modules: Record<string, { manifest: string; fixtures?: string[] }>,
176
+ ): string {
177
+ mkdirSync(dir, { recursive: true });
178
+ for (const [id, spec] of Object.entries(modules)) {
179
+ const moduleDir = join(dir, id);
180
+ mkdirSync(join(moduleDir, 'e2e'), { recursive: true });
181
+ writeFileSync(join(moduleDir, 'manifest.yml'), spec.manifest);
182
+ for (const [i, fixture] of (spec.fixtures ?? []).entries()) {
183
+ writeFileSync(join(moduleDir, 'e2e', `fixture-${i}.test.ts`), fixture);
184
+ }
185
+ }
186
+ return dir;
187
+ }
188
+
189
+ const providerManifest = [
190
+ 'celilo_contract: "1.0"',
191
+ 'id: marker-provider',
192
+ 'name: Marker Provider',
193
+ 'version: 1.0.0',
194
+ 'description: synthetic provider',
195
+ 'provides:',
196
+ ' capabilities:',
197
+ ' - name: marker_cap',
198
+ ' version: 1.0.0',
199
+ '',
200
+ ].join('\n');
201
+
202
+ const consumerFixture = "await net.celilo('module import marker-provider');\n";
203
+
204
+ test('a fixture importing the provider satisfies the requirement', async () => {
205
+ const dir = mkdtempSync(join(tmpdir(), 'cap-coverage-ok-'));
206
+ try {
207
+ mirrorTree(dir, {
208
+ 'marker-provider': { manifest: providerManifest },
209
+ 'marker-consumer': {
210
+ manifest: [
211
+ 'celilo_contract: "1.0"',
212
+ 'id: marker-consumer',
213
+ 'name: Marker Consumer',
214
+ 'version: 1.0.0',
215
+ 'description: synthetic consumer',
216
+ 'requires:',
217
+ ' capabilities:',
218
+ ' - name: marker_cap',
219
+ ' version: 1.0.0',
220
+ '',
221
+ ].join('\n'),
222
+ fixtures: [consumerFixture],
223
+ },
224
+ });
225
+ expect(await capabilityCoverageViolations(dir)).toEqual([]);
226
+ } finally {
227
+ rmSync(dir, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test('a fixture missing the provider fails, naming the module and capability', async () => {
232
+ const dir = mkdtempSync(join(tmpdir(), 'cap-coverage-fail-'));
233
+ try {
234
+ mirrorTree(dir, {
235
+ 'marker-provider': { manifest: providerManifest },
236
+ 'marker-consumer': {
237
+ manifest: [
238
+ 'celilo_contract: "1.0"',
239
+ 'id: marker-consumer',
240
+ 'name: Marker Consumer',
241
+ 'version: 1.0.0',
242
+ 'description: synthetic consumer',
243
+ 'requires:',
244
+ ' capabilities:',
245
+ ' - name: marker_cap',
246
+ ' version: 1.0.0',
247
+ ' - name: marker_other',
248
+ ' version: 1.0.0',
249
+ '',
250
+ ].join('\n'),
251
+ // provider-b covers marker_cap; nothing covers marker_other
252
+ fixtures: [consumerFixture],
253
+ },
254
+ 'marker-nonprovider': {
255
+ manifest: providerManifest.replace('marker_cap', 'marker_other'),
256
+ },
257
+ });
258
+ const violations = await capabilityCoverageViolations(dir);
259
+ expect(violations).toEqual([
260
+ expect.stringContaining("marker-consumer: Required capability 'marker_other' not found"),
261
+ ]);
262
+ } finally {
263
+ rmSync(dir, { recursive: true, force: true });
264
+ }
265
+ });
266
+
267
+ test('a privilege the framework grants needs no provider', async () => {
268
+ const dir = mkdtempSync(join(tmpdir(), 'cap-coverage-priv-'));
269
+ try {
270
+ mirrorTree(dir, {
271
+ 'marker-consumer': {
272
+ manifest: [
273
+ 'celilo_contract: "1.0"',
274
+ 'id: marker-consumer',
275
+ 'name: Marker Consumer',
276
+ 'version: 1.0.0',
277
+ 'description: synthetic consumer',
278
+ 'requires:',
279
+ ' capabilities:',
280
+ ' - name: cross_module_read',
281
+ ' version: 1.0.0',
282
+ '',
283
+ ].join('\n'),
284
+ fixtures: ["await net.celilo('module import nothing-here');\n"],
285
+ },
286
+ });
287
+ expect(await capabilityCoverageViolations(dir)).toEqual([]);
288
+ } finally {
289
+ rmSync(dir, { recursive: true, force: true });
290
+ }
291
+ });
292
+
293
+ test('a ${CONST} import resolves through the same-file string literal', async () => {
294
+ const dir = mkdtempSync(join(tmpdir(), 'cap-coverage-const-'));
295
+ try {
296
+ mirrorTree(dir, {
297
+ 'marker-provider': { manifest: providerManifest },
298
+ 'marker-consumer': {
299
+ manifest: [
300
+ 'celilo_contract: "1.0"',
301
+ 'id: marker-consumer',
302
+ 'name: Marker Consumer',
303
+ 'version: 1.0.0',
304
+ 'description: synthetic consumer',
305
+ 'requires:',
306
+ ' capabilities:',
307
+ ' - name: marker_cap',
308
+ ' version: 1.0.0',
309
+ '',
310
+ ].join('\n'),
311
+ fixtures: [
312
+ "const PROVIDER = 'marker-provider';\nawait net.celilo(`module import ${PROVIDER} --accept-aspects`);\n",
313
+ ],
314
+ },
315
+ });
316
+ expect(await capabilityCoverageViolations(dir)).toEqual([]);
317
+ } finally {
318
+ rmSync(dir, { recursive: true, force: true });
319
+ }
320
+ });
321
+ });
322
+ });