@checkstack/backend 0.8.2 → 0.9.1

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 (42) hide show
  1. package/CHANGELOG.md +333 -0
  2. package/drizzle/0001_slim_mordo.sql +34 -0
  3. package/drizzle/meta/0001_snapshot.json +444 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +18 -13
  6. package/src/index.ts +276 -17
  7. package/src/plugin-deregistration.test.ts +137 -0
  8. package/src/plugin-manager/api-router.ts +35 -11
  9. package/src/plugin-manager/plugin-loader.ts +73 -0
  10. package/src/plugin-manager.ts +295 -105
  11. package/src/schema.ts +79 -1
  12. package/src/services/cache-manager.test.ts +172 -0
  13. package/src/services/cache-manager.ts +67 -14
  14. package/src/services/compatibility-checker.test.ts +146 -0
  15. package/src/services/compatibility-checker.ts +137 -0
  16. package/src/services/dev-auth.test.ts +87 -0
  17. package/src/services/dev-auth.ts +56 -0
  18. package/src/services/event-bus.test.ts +52 -0
  19. package/src/services/event-bus.ts +27 -1
  20. package/src/services/plugin-artifact-store.ts +131 -0
  21. package/src/services/plugin-bundle-resolver.ts +76 -0
  22. package/src/services/plugin-event-recorder.ts +87 -0
  23. package/src/services/plugin-installers/catalog-installer.ts +33 -0
  24. package/src/services/plugin-installers/github-installer.ts +207 -0
  25. package/src/services/plugin-installers/install-from-tarball.ts +69 -0
  26. package/src/services/plugin-installers/installer-registry.ts +51 -0
  27. package/src/services/plugin-installers/npm-installer.ts +156 -0
  28. package/src/services/plugin-installers/plugin-install-error.ts +37 -0
  29. package/src/services/plugin-installers/tarball-installer.ts +80 -0
  30. package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
  31. package/src/services/plugin-installers/tarball-utils.ts +172 -0
  32. package/src/services/plugin-manager-orchestrator.ts +522 -0
  33. package/src/services/plugin-manager-router.ts +219 -0
  34. package/src/services/queue-manager.ts +77 -2
  35. package/src/services/queue-proxy.ts +7 -0
  36. package/src/utils/plugin-discovery.test.ts +6 -0
  37. package/src/utils/plugin-discovery.ts +6 -1
  38. package/tsconfig.json +3 -0
  39. package/src/plugin-lifecycle.test.ts +0 -276
  40. package/src/plugin-manager/plugin-admin-router.ts +0 -89
  41. package/src/services/plugin-installer.test.ts +0 -90
  42. package/src/services/plugin-installer.ts +0 -70
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { z } from "zod";
3
+ import { CacheManagerImpl } from "./cache-manager";
4
+ import { CachePluginRegistryImpl } from "./cache-plugin-registry";
5
+ import type { CachePlugin, CacheProvider, CacheStats } from "@checkstack/cache-api";
6
+ import type { ConfigService, Logger } from "@checkstack/backend-api";
7
+ import { createMockLogger } from "@checkstack/test-utils-backend";
8
+
9
+ class FakeInMemoryCache implements CacheProvider {
10
+ store = new Map<string, unknown>();
11
+ hits = 0;
12
+ misses = 0;
13
+
14
+ async get<T>(key: string): Promise<T | undefined> {
15
+ if (this.store.has(key)) {
16
+ this.hits++;
17
+ return this.store.get(key) as T;
18
+ }
19
+ this.misses++;
20
+ return undefined;
21
+ }
22
+ async set<T>(key: string, value: T): Promise<void> {
23
+ this.store.set(key, value);
24
+ }
25
+ async delete(key: string): Promise<void> {
26
+ this.store.delete(key);
27
+ }
28
+ async deleteByPrefix(): Promise<number> {
29
+ return 0;
30
+ }
31
+ async has(key: string): Promise<boolean> {
32
+ return this.store.has(key);
33
+ }
34
+ async getStats(): Promise<CacheStats> {
35
+ return {
36
+ keyCount: this.store.size,
37
+ sizeBytes: null,
38
+ hits: this.hits,
39
+ misses: this.misses,
40
+ scope: "instance",
41
+ };
42
+ }
43
+ }
44
+
45
+ const memoryConfigSchema = z.record(z.string(), z.unknown());
46
+
47
+ const createMemoryPlugin = (
48
+ factory: () => FakeInMemoryCache,
49
+ ): CachePlugin<unknown> => ({
50
+ id: "memory",
51
+ displayName: "Memory",
52
+ configVersion: 1,
53
+ configSchema: memoryConfigSchema,
54
+ createProvider: () => factory(),
55
+ });
56
+
57
+ class StubConfigService implements ConfigService {
58
+ store = new Map<string, unknown>();
59
+ async set<T>(configId: string, _s: unknown, _v: number, data: T) {
60
+ this.store.set(configId, data);
61
+ }
62
+ async get<T>(configId: string): Promise<T | undefined> {
63
+ return this.store.get(configId) as T | undefined;
64
+ }
65
+ async getRedacted<T>(configId: string): Promise<Partial<T> | undefined> {
66
+ return this.store.get(configId) as Partial<T> | undefined;
67
+ }
68
+ async delete(configId: string): Promise<void> {
69
+ this.store.delete(configId);
70
+ }
71
+ async list(): Promise<string[]> {
72
+ return [...this.store.keys()];
73
+ }
74
+ }
75
+
76
+ describe("CacheManagerImpl provider proxy", () => {
77
+ let registry: CachePluginRegistryImpl;
78
+ let configService: StubConfigService;
79
+ let logger: Logger;
80
+ let manager: CacheManagerImpl;
81
+ let instances: FakeInMemoryCache[];
82
+
83
+ beforeEach(() => {
84
+ registry = new CachePluginRegistryImpl();
85
+ configService = new StubConfigService();
86
+ logger = createMockLogger() as unknown as Logger;
87
+ instances = [];
88
+ registry.register(
89
+ createMemoryPlugin(() => {
90
+ const inst = new FakeInMemoryCache();
91
+ instances.push(inst);
92
+ return inst;
93
+ }),
94
+ );
95
+ manager = new CacheManagerImpl(registry, configService, logger);
96
+ });
97
+
98
+ it("returns the same proxy reference across calls", async () => {
99
+ const a = manager.getProvider();
100
+ const b = manager.getProvider();
101
+ expect(a).toBe(b);
102
+ });
103
+
104
+ it("delegates writes to the active provider after loadConfiguration", async () => {
105
+ const provider = manager.getProvider();
106
+ await manager.loadConfiguration();
107
+ await provider.set("foo", "bar");
108
+ expect(instances[0].store.get("foo")).toBe("bar");
109
+ });
110
+
111
+ it("keeps a captured proxy reference live across setActiveBackend", async () => {
112
+ await manager.loadConfiguration();
113
+ const captured = manager.getProvider();
114
+ await captured.set("before", 1);
115
+
116
+ await manager.setActiveBackend("memory", {
117
+ maxEntries: 5000,
118
+ sweepIntervalMs: 30_000,
119
+ });
120
+
121
+ // The captured reference must now write to the NEW active provider,
122
+ // not the orphaned old one.
123
+ await captured.set("after", 2);
124
+
125
+ const oldInstance = instances[0];
126
+ const newInstance = instances.at(-1)!;
127
+ expect(oldInstance).not.toBe(newInstance);
128
+ expect(oldInstance.store.has("after")).toBe(false);
129
+ expect(newInstance.store.has("after")).toBe(true);
130
+ });
131
+
132
+ it("getStats reads from the currently active provider", async () => {
133
+ await manager.loadConfiguration();
134
+ const provider = manager.getProvider();
135
+ await provider.set("k1", "v1");
136
+ await provider.get("k1"); // hit
137
+ await provider.get("missing"); // miss
138
+
139
+ const stats = await provider.getStats!();
140
+ expect(stats.keyCount).toBe(1);
141
+ expect(stats.hits).toBe(1);
142
+ expect(stats.misses).toBe(1);
143
+ });
144
+
145
+ it("getStats returns all-null when active provider has no getStats impl", async () => {
146
+ // Plugin whose provider doesn't implement getStats.
147
+ const minimalPlugin: CachePlugin<unknown> = {
148
+ id: "noStats",
149
+ displayName: "No stats",
150
+ configVersion: 1,
151
+ configSchema: memoryConfigSchema,
152
+ createProvider: (): CacheProvider => ({
153
+ get: async () => undefined,
154
+ set: async () => {},
155
+ delete: async () => {},
156
+ deleteByPrefix: async () => 0,
157
+ has: async () => false,
158
+ }),
159
+ };
160
+ registry.register(minimalPlugin);
161
+
162
+ await manager.setActiveBackend("noStats", {});
163
+ const stats = await manager.getProvider().getStats!();
164
+ expect(stats).toEqual({
165
+ keyCount: null,
166
+ sizeBytes: null,
167
+ hits: null,
168
+ misses: null,
169
+ scope: "instance",
170
+ });
171
+ });
172
+ });
@@ -1,4 +1,10 @@
1
- import type { CacheManager, CacheProvider } from "@checkstack/cache-api";
1
+ import type {
2
+ CacheManager,
3
+ CacheProvider,
4
+ CacheStats,
5
+ ListEntriesOptions,
6
+ ListEntriesResult,
7
+ } from "@checkstack/cache-api";
2
8
  import type { CachePluginRegistryImpl } from "./cache-plugin-registry";
3
9
  import type { Logger, ConfigService } from "@checkstack/backend-api";
4
10
  import { z } from "zod";
@@ -18,6 +24,10 @@ type ActiveCachePointer = z.infer<typeof activeCachePointerSchema>;
18
24
  /**
19
25
  * A no-op CacheProvider used before any backend is configured.
20
26
  * All operations are safe to call but behave as if the cache is empty.
27
+ *
28
+ * Held only as the initial value of `activeProvider` until
29
+ * `loadConfiguration()` runs; consumers always interact with the stable
30
+ * proxy below, so they pick up the real provider as soon as it's ready.
21
31
  */
22
32
  const nullProvider: CacheProvider = {
23
33
  // eslint-disable-next-line unicorn/no-useless-undefined
@@ -31,8 +41,18 @@ const nullProvider: CacheProvider = {
31
41
  /**
32
42
  * CacheManagerImpl handles cache provider lifecycle and backend switching.
33
43
  *
34
- * Simpler than QueueManagerImpl no proxy pattern needed since cache is
35
- * stateless key/value. The active provider is replaced atomically on backend switch.
44
+ * `getProvider()` returns a single stable proxy whose methods delegate to
45
+ * whichever provider is currently active. This means:
46
+ *
47
+ * - Plugins that capture the proxy at init time (e.g. via
48
+ * `createCachedScope`) keep functioning across backend switches and
49
+ * don't write into orphaned providers.
50
+ * - The Infrastructure runtime panel reads stats from the same instance
51
+ * plugins write to — no split-brain.
52
+ *
53
+ * The earlier "no proxy pattern needed since cache is stateless key/value"
54
+ * comment was wrong: in-memory backends are stateful and replacing the
55
+ * active reference would orphan their state.
36
56
  */
37
57
  export class CacheManagerImpl implements CacheManager {
38
58
  private activePluginId: string = "memory";
@@ -42,12 +62,49 @@ export class CacheManagerImpl implements CacheManager {
42
62
  };
43
63
  private configVersion: number = 0;
44
64
  private activeProvider: CacheProvider = nullProvider;
65
+ private readonly providerProxy: CacheProvider;
45
66
 
46
67
  constructor(
47
68
  private registry: CachePluginRegistryImpl,
48
69
  private configService: ConfigService,
49
70
  private logger: Logger,
50
- ) {}
71
+ ) {
72
+ this.providerProxy = this.createProviderProxy();
73
+ }
74
+
75
+ private createProviderProxy(): CacheProvider {
76
+ return {
77
+ get: <T>(key: string) => this.activeProvider.get<T>(key),
78
+ set: <T>(key: string, value: T, ttlMs?: number) =>
79
+ this.activeProvider.set<T>(key, value, ttlMs),
80
+ delete: (key: string) => this.activeProvider.delete(key),
81
+ deleteByPrefix: (prefix: string) =>
82
+ this.activeProvider.deleteByPrefix(prefix),
83
+ has: (key: string) => this.activeProvider.has(key),
84
+ getStats: async (): Promise<CacheStats> => {
85
+ const provider = this.activeProvider;
86
+ if (provider.getStats) {
87
+ return provider.getStats();
88
+ }
89
+ return {
90
+ keyCount: null,
91
+ sizeBytes: null,
92
+ hits: null,
93
+ misses: null,
94
+ scope: "instance",
95
+ };
96
+ },
97
+ listEntries: async (
98
+ opts: ListEntriesOptions,
99
+ ): Promise<ListEntriesResult> => {
100
+ const provider = this.activeProvider;
101
+ if (!provider.listEntries) {
102
+ return { items: [], total: 0, hasMore: false };
103
+ }
104
+ return provider.listEntries(opts);
105
+ },
106
+ };
107
+ }
51
108
 
52
109
  async loadConfiguration(): Promise<void> {
53
110
  try {
@@ -83,11 +140,10 @@ export class CacheManagerImpl implements CacheManager {
83
140
  );
84
141
  }
85
142
 
86
- // Initialize the active provider
87
143
  this.initializeProvider();
88
144
  } catch (error) {
89
145
  this.logger.error("Failed to load cache configuration", error);
90
- // Continue with defaults — nullProvider is already set
146
+ // Continue with defaults — nullProvider is already set behind the proxy.
91
147
  }
92
148
  }
93
149
 
@@ -113,8 +169,12 @@ export class CacheManagerImpl implements CacheManager {
113
169
  }
114
170
  }
115
171
 
172
+ /**
173
+ * Returns the stable proxy. The same instance is returned for the
174
+ * lifetime of the manager, so callers can safely capture it once.
175
+ */
116
176
  getProvider(): CacheProvider {
117
- return this.activeProvider;
177
+ return this.providerProxy;
118
178
  }
119
179
 
120
180
  getActivePlugin(): string {
@@ -126,21 +186,17 @@ export class CacheManagerImpl implements CacheManager {
126
186
  }
127
187
 
128
188
  async setActiveBackend(pluginId: string, config: unknown): Promise<void> {
129
- // 1. Validate plugin exists
130
189
  const newPlugin = this.registry.getPlugin(pluginId);
131
190
  if (!newPlugin) {
132
191
  throw new Error(`Cache plugin '${pluginId}' not found`);
133
192
  }
134
193
 
135
- // 2. Validate config against schema
136
194
  newPlugin.configSchema.parse(config);
137
195
 
138
- // 3. Create new provider (acts as connection test)
139
196
  this.logger.info("🔍 Testing new cache provider...");
140
197
  let newProvider: CacheProvider;
141
198
  try {
142
199
  newProvider = newPlugin.createProvider(config, this.logger);
143
- // Quick smoke test
144
200
  await newProvider.set("__test__", true, 1000);
145
201
  await newProvider.delete("__test__");
146
202
  this.logger.info("✅ Cache provider test successful");
@@ -150,20 +206,17 @@ export class CacheManagerImpl implements CacheManager {
150
206
  throw new Error(`Failed to create cache provider: ${message}`);
151
207
  }
152
208
 
153
- // 4. Stop old provider if it has a stop method
154
209
  const oldProvider = this.activeProvider;
155
210
  if ("stop" in oldProvider && typeof oldProvider.stop === "function") {
156
211
  await (oldProvider as { stop: () => Promise<void> }).stop();
157
212
  }
158
213
 
159
- // 5. Switch to new provider
160
214
  const oldPluginId = this.activePluginId;
161
215
  this.activePluginId = pluginId;
162
216
  this.activeConfig = config;
163
217
  this.configVersion++;
164
218
  this.activeProvider = newProvider;
165
219
 
166
- // 6. Persist configuration
167
220
  await this.configService.set(
168
221
  pluginId,
169
222
  newPlugin.configSchema,
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { checkCompatibility } from "./compatibility-checker";
3
+ import type { InstallPackageMetadata } from "@checkstack/common";
4
+
5
+ const minimalPkg = (
6
+ override: Partial<InstallPackageMetadata>,
7
+ ): InstallPackageMetadata => ({
8
+ name: "@scope/example",
9
+ version: "1.0.0",
10
+ description: "example",
11
+ author: "test",
12
+ license: "Elastic-2.0",
13
+ checkstack: { type: "backend", pluginId: "example" },
14
+ ...override,
15
+ });
16
+
17
+ describe("checkCompatibility", () => {
18
+ it("passes when every @checkstack/* dep is satisfied by loaded versions", () => {
19
+ const pkg = minimalPkg({
20
+ dependencies: {
21
+ "@checkstack/backend-api": "^1.0.0",
22
+ "@checkstack/common": "^1.0.0",
23
+ },
24
+ });
25
+ const issues = checkCompatibility({
26
+ packages: [pkg],
27
+ loadedVersions: new Map([
28
+ ["@checkstack/backend-api", "1.2.3"],
29
+ ["@checkstack/common", "1.5.0"],
30
+ ]),
31
+ });
32
+ expect(issues).toEqual([]);
33
+ });
34
+
35
+ it("ignores non-@checkstack deps", () => {
36
+ const pkg = minimalPkg({
37
+ dependencies: { lodash: "^4.0.0", react: "^18.0.0" },
38
+ });
39
+ const issues = checkCompatibility({
40
+ packages: [pkg],
41
+ loadedVersions: new Map(),
42
+ });
43
+ expect(issues).toEqual([]);
44
+ });
45
+
46
+ it("flags missing @checkstack/* dependency not in bundle", () => {
47
+ const pkg = minimalPkg({
48
+ dependencies: { "@checkstack/missing-pkg": "^1.0.0" },
49
+ });
50
+ const issues = checkCompatibility({
51
+ packages: [pkg],
52
+ loadedVersions: new Map(),
53
+ });
54
+ expect(issues).toHaveLength(1);
55
+ expect(issues[0].kind).toBe("missing-dependency");
56
+ expect(issues[0].dependency).toBe("@checkstack/missing-pkg");
57
+ });
58
+
59
+ it("flags semver mismatch against loaded platform version", () => {
60
+ const pkg = minimalPkg({
61
+ dependencies: { "@checkstack/backend-api": "^2.0.0" },
62
+ });
63
+ const issues = checkCompatibility({
64
+ packages: [pkg],
65
+ loadedVersions: new Map([["@checkstack/backend-api", "1.0.0"]]),
66
+ });
67
+ expect(issues).toHaveLength(1);
68
+ expect(issues[0].kind).toBe("version-mismatch");
69
+ expect(issues[0].declared).toBe("^2.0.0");
70
+ expect(issues[0].actual).toBe("1.0.0");
71
+ });
72
+
73
+ it("rejects unresolved workspace:* ranges (must be resolved at pack time)", () => {
74
+ const pkg = minimalPkg({
75
+ dependencies: { "@checkstack/backend-api": "workspace:*" },
76
+ });
77
+ const issues = checkCompatibility({
78
+ packages: [pkg],
79
+ loadedVersions: new Map([["@checkstack/backend-api", "1.0.0"]]),
80
+ });
81
+ expect(issues).toHaveLength(1);
82
+ expect(issues[0].kind).toBe("version-mismatch");
83
+ expect(issues[0].declared).toBe("workspace:*");
84
+ });
85
+
86
+ it("satisfies bundle-internal deps from sibling packages, not platform versions", () => {
87
+ const primary = minimalPkg({
88
+ name: "@scope/foo-backend",
89
+ version: "1.0.0",
90
+ dependencies: { "@scope/foo-common": "^1.0.0" },
91
+ });
92
+ const sibling = minimalPkg({
93
+ name: "@scope/foo-common",
94
+ version: "1.0.0",
95
+ checkstack: { type: "common", pluginId: "foo" },
96
+ });
97
+
98
+ const issues = checkCompatibility({
99
+ packages: [primary, sibling],
100
+ loadedVersions: new Map(), // platform doesn't have @scope/foo-common
101
+ });
102
+ // The dep is `@scope/foo-common`, not `@checkstack/*`, so it isn't
103
+ // checked at all. But verify the check would also pass for the
104
+ // (more interesting) `@checkstack/*` case:
105
+ });
106
+
107
+ it("satisfies bundle-internal @checkstack/* dep from a sibling package", () => {
108
+ const primary = minimalPkg({
109
+ name: "@checkstack/foo-backend",
110
+ version: "1.0.0",
111
+ dependencies: { "@checkstack/foo-common": "^1.0.0" },
112
+ });
113
+ const sibling = minimalPkg({
114
+ name: "@checkstack/foo-common",
115
+ version: "1.0.0",
116
+ checkstack: { type: "common", pluginId: "foo" },
117
+ });
118
+
119
+ const issues = checkCompatibility({
120
+ packages: [primary, sibling],
121
+ loadedVersions: new Map(),
122
+ });
123
+ expect(issues).toEqual([]);
124
+ });
125
+
126
+ it("flags bundle-internal dep when version doesn't match", () => {
127
+ const primary = minimalPkg({
128
+ name: "@checkstack/foo-backend",
129
+ version: "1.0.0",
130
+ dependencies: { "@checkstack/foo-common": "^2.0.0" },
131
+ });
132
+ const sibling = minimalPkg({
133
+ name: "@checkstack/foo-common",
134
+ version: "1.0.0",
135
+ checkstack: { type: "common", pluginId: "foo" },
136
+ });
137
+
138
+ const issues = checkCompatibility({
139
+ packages: [primary, sibling],
140
+ loadedVersions: new Map(),
141
+ });
142
+ expect(issues).toHaveLength(1);
143
+ expect(issues[0].kind).toBe("version-mismatch");
144
+ expect(issues[0].dependency).toBe("@checkstack/foo-common");
145
+ });
146
+ });
@@ -0,0 +1,137 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import semver from "semver";
4
+ import type { InstallPackageMetadata } from "@checkstack/common";
5
+ import type { CompatibilityIssue } from "@checkstack/pluginmanager-common";
6
+ import { rootLogger } from "../logger";
7
+
8
+ /**
9
+ * Builds a snapshot of every `@checkstack/*` package the current process
10
+ * can resolve, with its installed `version`. Used as the source of truth
11
+ * for compatibility checks.
12
+ *
13
+ * We don't need a separate "core version" — every package version is its
14
+ * own contract.
15
+ */
16
+ export function loadCheckstackPackageVersions({
17
+ workspaceRoot,
18
+ runtimeDir,
19
+ }: {
20
+ workspaceRoot: string;
21
+ runtimeDir: string;
22
+ }): Map<string, string> {
23
+ const versions = new Map<string, string>();
24
+
25
+ const walk = (dir: string) => {
26
+ if (!fs.existsSync(dir)) return;
27
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
28
+ if (!entry.isDirectory()) continue;
29
+ const pkgJsonPath = path.join(dir, entry.name, "package.json");
30
+ if (!fs.existsSync(pkgJsonPath)) continue;
31
+ try {
32
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
33
+ if (
34
+ typeof pkg.name === "string" &&
35
+ pkg.name.startsWith("@checkstack/") &&
36
+ typeof pkg.version === "string"
37
+ && // First-write wins: monorepo source wins over runtime_plugins
38
+ !versions.has(pkg.name)) {
39
+ versions.set(pkg.name, pkg.version);
40
+ }
41
+ } catch (error) {
42
+ rootLogger.debug(`Failed to read ${pkgJsonPath}`, error);
43
+ }
44
+ }
45
+ };
46
+
47
+ walk(path.join(workspaceRoot, "core"));
48
+ walk(path.join(workspaceRoot, "plugins"));
49
+ walk(path.join(runtimeDir, "node_modules"));
50
+ walk(path.join(runtimeDir, "node_modules", "@checkstack"));
51
+
52
+ return versions;
53
+ }
54
+
55
+ /**
56
+ * Check that every `@checkstack/*` dep declared by a plugin (and its bundle
57
+ * siblings) is satisfiable from the loaded package set OR from another
58
+ * package in the same install bundle.
59
+ *
60
+ * Returns an empty array when compatible, a populated array of issues otherwise.
61
+ */
62
+ export function checkCompatibility({
63
+ packages,
64
+ loadedVersions,
65
+ }: {
66
+ packages: InstallPackageMetadata[];
67
+ loadedVersions: Map<string, string>;
68
+ }): CompatibilityIssue[] {
69
+ const issues: CompatibilityIssue[] = [];
70
+
71
+ // Bundle-internal deps: plugins in the same install set satisfy each other.
72
+ const bundleVersions = new Map<string, string>();
73
+ for (const pkg of packages) {
74
+ bundleVersions.set(pkg.name, pkg.version);
75
+ }
76
+
77
+ for (const pkg of packages) {
78
+ const deps = pkg.dependencies ?? {};
79
+ for (const [depName, declaredRange] of Object.entries(deps)) {
80
+ if (!depName.startsWith("@checkstack/")) continue;
81
+
82
+ // Skip workspace ranges — these only appear in monorepo source files,
83
+ // never in published tarballs (the pack CLI rewrites them). If we see
84
+ // one here it's a sign the plugin wasn't packed via our CLI.
85
+ if (declaredRange.startsWith("workspace:")) {
86
+ issues.push({
87
+ pluginName: pkg.name,
88
+ kind: "version-mismatch",
89
+ dependency: depName,
90
+ declared: declaredRange,
91
+ message: `Plugin '${pkg.name}' depends on '${depName}' with unresolved 'workspace:*' range. Re-pack with '@checkstack/scripts plugin-pack' which resolves workspace ranges to concrete versions.`,
92
+ });
93
+ continue;
94
+ }
95
+
96
+ const bundleVersion = bundleVersions.get(depName);
97
+ if (bundleVersion !== undefined) {
98
+ if (!semver.satisfies(bundleVersion, declaredRange)) {
99
+ issues.push({
100
+ pluginName: pkg.name,
101
+ kind: "version-mismatch",
102
+ dependency: depName,
103
+ declared: declaredRange,
104
+ actual: bundleVersion,
105
+ message: `Plugin '${pkg.name}' requires ${depName}@${declaredRange} but the bundle ships ${bundleVersion}.`,
106
+ });
107
+ }
108
+ continue;
109
+ }
110
+
111
+ const loadedVersion = loadedVersions.get(depName);
112
+ if (loadedVersion === undefined) {
113
+ issues.push({
114
+ pluginName: pkg.name,
115
+ kind: "missing-dependency",
116
+ dependency: depName,
117
+ declared: declaredRange,
118
+ message: `Plugin '${pkg.name}' depends on '${depName}' which is not loaded by this platform and is not part of the install bundle.`,
119
+ });
120
+ continue;
121
+ }
122
+
123
+ if (!semver.satisfies(loadedVersion, declaredRange)) {
124
+ issues.push({
125
+ pluginName: pkg.name,
126
+ kind: "version-mismatch",
127
+ dependency: depName,
128
+ declared: declaredRange,
129
+ actual: loadedVersion,
130
+ message: `Plugin '${pkg.name}' requires ${depName}@${declaredRange} but this platform has ${loadedVersion}.`,
131
+ });
132
+ }
133
+ }
134
+ }
135
+
136
+ return issues;
137
+ }