diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 22:03:19 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 22:23:39 +0900 |
| commit | 727c98c9dae516a2070eb950410314380a20c974 (patch) | |
| tree | 52aa1022c54f11770be7e4e2a324f0a8b8b8deec /packages/kernel/src/host | |
| parent | e59dc11f63b1df51142259bb2c406af8c9c8c2bb (diff) | |
| download | dispatch-727c98c9dae516a2070eb950410314380a20c974.tar.gz dispatch-727c98c9dae516a2070eb950410314380a20c974.zip | |
style: switch from tabs to 2-space indentation
Diffstat (limited to 'packages/kernel/src/host')
| -rw-r--r-- | packages/kernel/src/host/dag.test.ts | 196 | ||||
| -rw-r--r-- | packages/kernel/src/host/dag.ts | 104 | ||||
| -rw-r--r-- | packages/kernel/src/host/host.test.ts | 2438 | ||||
| -rw-r--r-- | packages/kernel/src/host/host.ts | 412 | ||||
| -rw-r--r-- | packages/kernel/src/host/version.test.ts | 190 | ||||
| -rw-r--r-- | packages/kernel/src/host/version.ts | 82 |
6 files changed, 1711 insertions, 1711 deletions
diff --git a/packages/kernel/src/host/dag.test.ts b/packages/kernel/src/host/dag.test.ts index 1a0431f..352965c 100644 --- a/packages/kernel/src/host/dag.test.ts +++ b/packages/kernel/src/host/dag.test.ts @@ -3,105 +3,105 @@ import type { Manifest } from "../contracts/extension.js"; import { resolveActivationOrder } from "./dag.js"; function manifest(id: string, deps?: readonly string[]): Manifest { - const base: Manifest = { - id, - name: id, - version: "1.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - }; - if (deps !== undefined) { - return { ...base, dependsOn: deps }; - } - return base; + const base: Manifest = { + id, + name: id, + version: "1.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + }; + if (deps !== undefined) { + return { ...base, dependsOn: deps }; + } + return base; } describe("resolveActivationOrder", () => { - it("returns empty array for no extensions", () => { - expect(resolveActivationOrder([])).toEqual([]); - }); - - it("returns a single extension with no deps", () => { - const result = resolveActivationOrder([manifest("a")]); - expect(result.map((m) => m.id)).toEqual(["a"]); - }); - - it("orders a linear chain (A → B → C)", () => { - const a = manifest("a"); - const b = manifest("b", ["a"]); - const c = manifest("c", ["b"]); - - const result = resolveActivationOrder([c, b, a]); - const ids = result.map((m) => m.id); - - expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); - expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("c")); - }); - - it("orders a diamond (A → B, A → C, B → D, C → D)", () => { - const a = manifest("a"); - const b = manifest("b", ["a"]); - const c = manifest("c", ["a"]); - const d = manifest("d", ["b", "c"]); - - const result = resolveActivationOrder([d, c, b, a]); - const ids = result.map((m) => m.id); - - expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); - expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("c")); - expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("d")); - expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d")); - }); - - it("handles independent sets (no deps between them)", () => { - const a = manifest("a"); - const b = manifest("b"); - const c = manifest("c"); - - const result = resolveActivationOrder([a, b, c]); - expect(result).toHaveLength(3); - expect(result.map((m) => m.id).sort()).toEqual(["a", "b", "c"]); - }); - - it("throws on a cycle (A → B → A)", () => { - const a = manifest("a", ["b"]); - const b = manifest("b", ["a"]); - - expect(() => resolveActivationOrder([a, b])).toThrow(/cycle/i); - }); - - it("throws on a larger cycle (A → B → C → A)", () => { - const a = manifest("a", ["c"]); - const b = manifest("b", ["a"]); - const c = manifest("c", ["b"]); - - expect(() => resolveActivationOrder([a, b, c])).toThrow(/cycle/i); - }); - - it("throws on a missing dependency", () => { - const a = manifest("a", ["nonexistent"]); - - expect(() => resolveActivationOrder([a])).toThrow(/not available/); - }); - - it("throws on duplicate extension ids", () => { - const a1 = manifest("a"); - const a2 = manifest("a"); - - expect(() => resolveActivationOrder([a1, a2])).toThrow(/duplicate/i); - }); - - it("handles mixed independent and dependent extensions", () => { - const a = manifest("a"); - const b = manifest("b", ["a"]); - const c = manifest("c"); - const d = manifest("d", ["c"]); - - const result = resolveActivationOrder([a, b, c, d]); - const ids = result.map((m) => m.id); - - expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); - expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d")); - expect(result).toHaveLength(4); - }); + it("returns empty array for no extensions", () => { + expect(resolveActivationOrder([])).toEqual([]); + }); + + it("returns a single extension with no deps", () => { + const result = resolveActivationOrder([manifest("a")]); + expect(result.map((m) => m.id)).toEqual(["a"]); + }); + + it("orders a linear chain (A → B → C)", () => { + const a = manifest("a"); + const b = manifest("b", ["a"]); + const c = manifest("c", ["b"]); + + const result = resolveActivationOrder([c, b, a]); + const ids = result.map((m) => m.id); + + expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); + expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("c")); + }); + + it("orders a diamond (A → B, A → C, B → D, C → D)", () => { + const a = manifest("a"); + const b = manifest("b", ["a"]); + const c = manifest("c", ["a"]); + const d = manifest("d", ["b", "c"]); + + const result = resolveActivationOrder([d, c, b, a]); + const ids = result.map((m) => m.id); + + expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); + expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("c")); + expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("d")); + expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d")); + }); + + it("handles independent sets (no deps between them)", () => { + const a = manifest("a"); + const b = manifest("b"); + const c = manifest("c"); + + const result = resolveActivationOrder([a, b, c]); + expect(result).toHaveLength(3); + expect(result.map((m) => m.id).sort()).toEqual(["a", "b", "c"]); + }); + + it("throws on a cycle (A → B → A)", () => { + const a = manifest("a", ["b"]); + const b = manifest("b", ["a"]); + + expect(() => resolveActivationOrder([a, b])).toThrow(/cycle/i); + }); + + it("throws on a larger cycle (A → B → C → A)", () => { + const a = manifest("a", ["c"]); + const b = manifest("b", ["a"]); + const c = manifest("c", ["b"]); + + expect(() => resolveActivationOrder([a, b, c])).toThrow(/cycle/i); + }); + + it("throws on a missing dependency", () => { + const a = manifest("a", ["nonexistent"]); + + expect(() => resolveActivationOrder([a])).toThrow(/not available/); + }); + + it("throws on duplicate extension ids", () => { + const a1 = manifest("a"); + const a2 = manifest("a"); + + expect(() => resolveActivationOrder([a1, a2])).toThrow(/duplicate/i); + }); + + it("handles mixed independent and dependent extensions", () => { + const a = manifest("a"); + const b = manifest("b", ["a"]); + const c = manifest("c"); + const d = manifest("d", ["c"]); + + const result = resolveActivationOrder([a, b, c, d]); + const ids = result.map((m) => m.id); + + expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); + expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d")); + expect(result).toHaveLength(4); + }); }); diff --git a/packages/kernel/src/host/dag.ts b/packages/kernel/src/host/dag.ts index 5cde2f5..1ce35e6 100644 --- a/packages/kernel/src/host/dag.ts +++ b/packages/kernel/src/host/dag.ts @@ -1,64 +1,64 @@ import type { Manifest } from "../contracts/extension.js"; export function resolveActivationOrder(manifests: readonly Manifest[]): Manifest[] { - const byId = new Map<string, Manifest>(); - for (const m of manifests) { - if (byId.has(m.id)) { - throw new Error(`Duplicate extension id: "${m.id}"`); - } - byId.set(m.id, m); - } + const byId = new Map<string, Manifest>(); + for (const m of manifests) { + if (byId.has(m.id)) { + throw new Error(`Duplicate extension id: "${m.id}"`); + } + byId.set(m.id, m); + } - for (const m of manifests) { - for (const dep of m.dependsOn ?? []) { - if (!byId.has(dep)) { - throw new Error(`Extension "${m.id}" depends on "${dep}", which is not available.`); - } - } - } + for (const m of manifests) { + for (const dep of m.dependsOn ?? []) { + if (!byId.has(dep)) { + throw new Error(`Extension "${m.id}" depends on "${dep}", which is not available.`); + } + } + } - const inDegree = new Map<string, number>(); - const dependents = new Map<string, string[]>(); + const inDegree = new Map<string, number>(); + const dependents = new Map<string, string[]>(); - for (const m of manifests) { - inDegree.set(m.id, 0); - dependents.set(m.id, []); - } + for (const m of manifests) { + inDegree.set(m.id, 0); + dependents.set(m.id, []); + } - for (const m of manifests) { - for (const dep of m.dependsOn ?? []) { - const list = dependents.get(dep); - if (list !== undefined) list.push(m.id); - inDegree.set(m.id, (inDegree.get(m.id) ?? 0) + 1); - } - } + for (const m of manifests) { + for (const dep of m.dependsOn ?? []) { + const list = dependents.get(dep); + if (list !== undefined) list.push(m.id); + inDegree.set(m.id, (inDegree.get(m.id) ?? 0) + 1); + } + } - const queue: string[] = []; - for (const [id, deg] of inDegree) { - if (deg === 0) queue.push(id); - } + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } - const result: Manifest[] = []; - let idx = 0; - while (idx < queue.length) { - const id = queue[idx]; - if (id === undefined) break; - idx++; - const m = byId.get(id); - if (m === undefined) continue; - result.push(m); - for (const dep of dependents.get(id) ?? []) { - const newDeg = (inDegree.get(dep) ?? 1) - 1; - inDegree.set(dep, newDeg); - if (newDeg === 0) queue.push(dep); - } - } + const result: Manifest[] = []; + let idx = 0; + while (idx < queue.length) { + const id = queue[idx]; + if (id === undefined) break; + idx++; + const m = byId.get(id); + if (m === undefined) continue; + result.push(m); + for (const dep of dependents.get(id) ?? []) { + const newDeg = (inDegree.get(dep) ?? 1) - 1; + inDegree.set(dep, newDeg); + if (newDeg === 0) queue.push(dep); + } + } - if (result.length !== manifests.length) { - const remaining = manifests.filter((m) => !result.some((r) => r.id === m.id)); - const ids = remaining.map((m) => m.id).join(", "); - throw new Error(`Dependency cycle detected among extensions: ${ids}`); - } + if (result.length !== manifests.length) { + const remaining = manifests.filter((m) => !result.some((r) => r.id === m.id)); + const ids = remaining.map((m) => m.id).join(", "); + throw new Error(`Dependency cycle detected among extensions: ${ids}`); + } - return result; + return result; } diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts index 0067091..88e2836 100644 --- a/packages/kernel/src/host/host.test.ts +++ b/packages/kernel/src/host/host.test.ts @@ -2,1276 +2,1276 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createBus } from "../bus/bus.js"; import type { AuthContract } from "../contracts/auth.js"; import type { - ConfigAccess, - EventsEmitter, - Extension, - HostAPI, - Manifest, - ManifestContributions, - PermissionDecision, - PermissionGate, - PermissionRequest, - ScheduledJob, - SecretsAccess, - StorageNamespace, + ConfigAccess, + EventsEmitter, + Extension, + HostAPI, + Manifest, + ManifestContributions, + PermissionDecision, + PermissionGate, + PermissionRequest, + ScheduledJob, + SecretsAccess, + StorageNamespace, } from "../contracts/extension.js"; import { defineEventHook, defineFilter, defineService } from "../contracts/hooks.js"; import type { - Attributes, - ErrorAttributes, - LogDeps, - Logger, - LogRecord, - LogSink, + Attributes, + ErrorAttributes, + LogDeps, + Logger, + LogRecord, + LogSink, } from "../contracts/logging.js"; import type { ProviderContract } from "../contracts/provider.js"; import type { ToolContract } from "../contracts/tool.js"; import { createHost, type HostDeps, KERNEL_API_VERSION } from "./host.js"; interface FakeLogger extends Logger { - readonly logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }>; + readonly logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }>; } function createFakeLogger(): FakeLogger { - const logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }> = []; - return { - logs, - debug: (message: string, attrs?: Attributes) => { - if (attrs !== undefined) { - logs.push({ level: "debug", message, attrs }); - } else { - logs.push({ level: "debug", message }); - } - }, - info: (message: string, attrs?: Attributes) => { - if (attrs !== undefined) { - logs.push({ level: "info", message, attrs }); - } else { - logs.push({ level: "info", message }); - } - }, - warn: (message: string, attrs?: Attributes) => { - if (attrs !== undefined) { - logs.push({ level: "warn", message, attrs }); - } else { - logs.push({ level: "warn", message }); - } - }, - error: (message: string, attrs?: ErrorAttributes) => { - if (attrs !== undefined) { - logs.push({ level: "error", message, attrs }); - } else { - logs.push({ level: "error", message }); - } - }, - child( - _ctx: Partial<import("../contracts/logging.js").LogContext> & { readonly attrs?: Attributes }, - ): Logger { - return createFakeLogger(); - }, - span(_name: string, _attrs?: Attributes): import("../contracts/logging.js").Span { - return { - id: "fake-span", - log: createFakeLogger(), - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, - }; + const logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }> = []; + return { + logs, + debug: (message: string, attrs?: Attributes) => { + if (attrs !== undefined) { + logs.push({ level: "debug", message, attrs }); + } else { + logs.push({ level: "debug", message }); + } + }, + info: (message: string, attrs?: Attributes) => { + if (attrs !== undefined) { + logs.push({ level: "info", message, attrs }); + } else { + logs.push({ level: "info", message }); + } + }, + warn: (message: string, attrs?: Attributes) => { + if (attrs !== undefined) { + logs.push({ level: "warn", message, attrs }); + } else { + logs.push({ level: "warn", message }); + } + }, + error: (message: string, attrs?: ErrorAttributes) => { + if (attrs !== undefined) { + logs.push({ level: "error", message, attrs }); + } else { + logs.push({ level: "error", message }); + } + }, + child( + _ctx: Partial<import("../contracts/logging.js").LogContext> & { readonly attrs?: Attributes }, + ): Logger { + return createFakeLogger(); + }, + span(_name: string, _attrs?: Attributes): import("../contracts/logging.js").Span { + return { + id: "fake-span", + log: createFakeLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; } function createFakeLogSink(): LogSink & { readonly records: LogRecord[] } { - const records: LogRecord[] = []; - return { - records, - emit: (record: LogRecord) => { - records.push(record); - }, - }; + const records: LogRecord[] = []; + return { + records, + emit: (record: LogRecord) => { + records.push(record); + }, + }; } function createFakeLogDeps(): LogDeps { - let idCounter = 0; - return { - now: () => 1000 + idCounter * 100, - newId: () => `span-${++idCounter}`, - }; + let idCounter = 0; + return { + now: () => 1000 + idCounter * 100, + newId: () => `span-${++idCounter}`, + }; } function createFakeConfig(): ConfigAccess { - return { - get: () => undefined, - getAll: () => ({}), - }; + return { + get: () => undefined, + getAll: () => ({}), + }; } function createFakeStorageFactory(): (ns: string) => StorageNamespace { - const stores = new Map<string, Map<string, string>>(); - return (ns: string) => { - let store = stores.get(ns); - if (!store) { - store = new Map(); - stores.set(ns, store); - } - const s = store; - return { - get: async (key: string) => s.get(key) ?? null, - set: async (key: string, value: string) => { - s.set(key, value); - }, - delete: async (key: string) => { - s.delete(key); - }, - has: async (key: string) => s.has(key), - keys: async () => [...s.keys()], - }; - }; + const stores = new Map<string, Map<string, string>>(); + return (ns: string) => { + let store = stores.get(ns); + if (!store) { + store = new Map(); + stores.set(ns, store); + } + const s = store; + return { + get: async (key: string) => s.get(key) ?? null, + set: async (key: string, value: string) => { + s.set(key, value); + }, + delete: async (key: string) => { + s.delete(key); + }, + has: async (key: string) => s.has(key), + keys: async () => [...s.keys()], + }; + }; } function createFakeSecrets(): SecretsAccess { - const store = new Map<string, string>(); - return { - get: async (key: string) => store.get(key) ?? null, - set: async (key: string, value: string) => { - store.set(key, value); - }, - delete: async (key: string) => { - store.delete(key); - }, - }; + const store = new Map<string, string>(); + return { + get: async (key: string) => store.get(key) ?? null, + set: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + }; } function createFakePermissions(): PermissionGate { - return { - check: async (_request: PermissionRequest): Promise<PermissionDecision> => ({ - allowed: true, - }), - }; + return { + check: async (_request: PermissionRequest): Promise<PermissionDecision> => ({ + allowed: true, + }), + }; } function createFakeScheduler(): { - readonly register: (job: ScheduledJob) => void; - readonly jobs: ScheduledJob[]; + readonly register: (job: ScheduledJob) => void; + readonly jobs: ScheduledJob[]; } { - const jobs: ScheduledJob[] = []; - return { - register: (job: ScheduledJob) => { - jobs.push(job); - }, - jobs, - }; + const jobs: ScheduledJob[] = []; + return { + register: (job: ScheduledJob) => { + jobs.push(job); + }, + jobs, + }; } function createFakeEvents(): EventsEmitter & { readonly emitted: unknown[] } { - const emitted: unknown[] = []; - return { - emitted, - emit: (event) => { - emitted.push(event); - }, - }; + const emitted: unknown[] = []; + return { + emitted, + emit: (event) => { + emitted.push(event); + }, + }; } function createExtension( - id: string, - opts: { - readonly dependsOn?: readonly string[]; - readonly apiVersion?: string; - readonly activate?: (host: HostAPI) => void | Promise<void>; - readonly deactivate?: () => void | Promise<void>; - readonly contributes?: ManifestContributions; - } = {}, + id: string, + opts: { + readonly dependsOn?: readonly string[]; + readonly apiVersion?: string; + readonly activate?: (host: HostAPI) => void | Promise<void>; + readonly deactivate?: () => void | Promise<void>; + readonly contributes?: ManifestContributions; + } = {}, ): Extension { - const base: Manifest = { - id, - name: id, - version: "1.0.0", - apiVersion: opts.apiVersion ?? `^${KERNEL_API_VERSION}`, - trust: "bundled", - }; - const manifest: Manifest = - opts.dependsOn !== undefined - ? { ...base, dependsOn: opts.dependsOn } - : opts.contributes !== undefined - ? { ...base, contributes: opts.contributes } - : base; - const ext: Extension = { - manifest, - activate: opts.activate ?? (() => {}), - }; - if (opts.deactivate !== undefined) { - return { ...ext, deactivate: opts.deactivate }; - } - return ext; + const base: Manifest = { + id, + name: id, + version: "1.0.0", + apiVersion: opts.apiVersion ?? `^${KERNEL_API_VERSION}`, + trust: "bundled", + }; + const manifest: Manifest = + opts.dependsOn !== undefined + ? { ...base, dependsOn: opts.dependsOn } + : opts.contributes !== undefined + ? { ...base, contributes: opts.contributes } + : base; + const ext: Extension = { + manifest, + activate: opts.activate ?? (() => {}), + }; + if (opts.deactivate !== undefined) { + return { ...ext, deactivate: opts.deactivate }; + } + return ext; } function createFakeTool(name: string): ToolContract { - return { - name, - description: `Tool ${name}`, - parameters: { type: "object" }, - execute: async () => ({ content: "ok" }), - }; + return { + name, + description: `Tool ${name}`, + parameters: { type: "object" }, + execute: async () => ({ content: "ok" }), + }; } function createFakeProvider(id: string): ProviderContract { - return { - id, - stream: async function* () {}, - }; + return { + id, + stream: async function* () {}, + }; } function createFakeAuth(id: string): AuthContract { - return { - id, - resolve: async () => null, - }; + return { + id, + resolve: async () => null, + }; } describe("createHost", () => { - let logger: FakeLogger; - let logSink: ReturnType<typeof createFakeLogSink>; - let logDeps: LogDeps; - let deps: HostDeps; - let scheduler: ReturnType<typeof createFakeScheduler>; - let events: ReturnType<typeof createFakeEvents>; - - beforeEach(() => { - logger = createFakeLogger(); - logSink = createFakeLogSink(); - logDeps = createFakeLogDeps(); - scheduler = createFakeScheduler(); - events = createFakeEvents(); - deps = { - logger, - config: createFakeConfig(), - storageFactory: createFakeStorageFactory(), - secrets: createFakeSecrets(), - permissions: createFakePermissions(), - scheduler, - bus: createBus(logger), - events, - logSink, - logDeps, - }; - }); - - describe("activation order", () => { - it("activates extensions in topological order", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("a"); - }, - }); - const b = createExtension("b", { - dependsOn: ["a"], - activate: () => { - order.push("b"); - }, - }); - const c = createExtension("c", { - dependsOn: ["b"], - activate: () => { - order.push("c"); - }, - }); - - const host = createHost([c, b, a], deps); - await host.activate(); - - expect(order).toEqual(["a", "b", "c"]); - }); - - it("activates independent extensions", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("a"); - }, - }); - const b = createExtension("b", { - activate: () => { - order.push("b"); - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - expect(order).toHaveLength(2); - expect(order).toContain("a"); - expect(order).toContain("b"); - }); - }); - - describe("fault isolation", () => { - it("a throwing extension is isolated — others still activate", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("a"); - }, - }); - const b = createExtension("b", { - activate: () => { - throw new Error("boom"); - }, - }); - const c = createExtension("c", { - activate: () => { - order.push("c"); - }, - }); - - const host = createHost([a, b, c], deps); - await host.activate(); - - expect(order).toEqual(["a", "c"]); - expect(host.getDisabled()).toHaveLength(1); - expect(host.getDisabled()[0]?.manifest.id).toBe("b"); - expect(host.getDisabled()[0]?.reason).toContain("boom"); - }); - - it("an async-rejecting extension is isolated", async () => { - const a = createExtension("a", { - activate: async () => { - throw new Error("async fail"); - }, - }); - const b = createExtension("b", { - activate: () => {}, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(1); - expect(host.getDisabled()[0]?.manifest.id).toBe("a"); - }); - }); - - describe("apiVersion compatibility", () => { - it("activates compatible extensions", async () => { - const ext = createExtension("good", { - apiVersion: `^${KERNEL_API_VERSION}`, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(0); - }); - - it("disables incompatible extensions without crashing", async () => { - const ext = createExtension("bad", { - apiVersion: "^99.0.0", - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(1); - expect(host.getDisabled()[0]?.manifest.id).toBe("bad"); - expect(host.getDisabled()[0]?.reason).toContain("incompatible"); - }); - - it("logs a warning for disabled extensions", async () => { - const ext = createExtension("bad", { - apiVersion: "^99.0.0", - }); - - const host = createHost([ext], deps); - await host.activate(); - - const warnings = logger.logs.filter((l) => l.level === "warn"); - expect(warnings).toHaveLength(1); - expect(warnings[0]?.message).toContain("bad"); - }); - }); - - describe("registries", () => { - it("defineTool populates the tool registry", async () => { - const tool = createFakeTool("read-file"); - const ext = createExtension("tools-fs", { - activate: (host) => { - host.defineTool(tool); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getTools().size).toBe(1); - expect(host.getTool("read-file")).toBe(tool); - }); - - it("defineProvider populates the provider registry", async () => { - const provider = createFakeProvider("anthropic"); - const ext = createExtension("provider-anthropic", { - activate: (host) => { - host.defineProvider(provider); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getProviders().size).toBe(1); - expect(host.getProvider("anthropic")).toBe(provider); - }); - - it("defineAuth populates the auth registry", async () => { - const auth = createFakeAuth("apikey"); - const ext = createExtension("auth-apikey", { - activate: (host) => { - host.defineAuth(auth); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getAuthProviders().size).toBe(1); - expect(host.getAuthProvider("apikey")).toBe(auth); - }); - - it("getService returns what an extension provided via provideService", async () => { - const handle = defineService<{ value: number }>("test/svc"); - const ext = createExtension("svc-provider", { - activate: (host) => { - host.provideService(handle, { value: 42 }); - }, - }); - const consumer = createExtension("svc-consumer", { - dependsOn: ["svc-provider"], - activate: (host) => { - const svc = host.getService(handle); - expect(svc.value).toBe(42); - }, - }); - - const host = createHost([ext, consumer], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(0); - }); - - it("multiple extensions contribute to the same registry", async () => { - const ext1 = createExtension("tools-a", { - activate: (host) => { - host.defineTool(createFakeTool("tool-a")); - }, - }); - const ext2 = createExtension("tools-b", { - activate: (host) => { - host.defineTool(createFakeTool("tool-b")); - }, - }); - - const host = createHost([ext1, ext2], deps); - await host.activate(); - - expect(host.getTools().size).toBe(2); - expect(host.getTool("tool-a")).toBeDefined(); - expect(host.getTool("tool-b")).toBeDefined(); - }); - }); - - describe("scheduler", () => { - it("collects scheduled jobs and forwards to sink", async () => { - const job: ScheduledJob = { - id: "cache-warm", - cron: "*/5 * * * *", - execute: () => {}, - }; - const ext = createExtension("scheduler-ext", { - activate: (host) => { - host.scheduler.register(job); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getScheduledJobs()).toHaveLength(1); - expect(host.getScheduledJobs()[0]).toBe(job); - expect(scheduler.jobs).toHaveLength(1); - expect(scheduler.jobs[0]).toBe(job); - }); - }); - - describe("migrations", () => { - it("collects migrations from manifests", async () => { - const ext = createExtension("store-ext", { - contributes: { migrations: ["001-init", "002-add-index"] }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getMigrations()).toEqual(["001-init", "002-add-index"]); - }); - }); - - describe("deactivation", () => { - it("deactivates in reverse activation order", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("activate-a"); - }, - deactivate: () => { - order.push("deactivate-a"); - }, - }); - const b = createExtension("b", { - activate: () => { - order.push("activate-b"); - }, - deactivate: () => { - order.push("deactivate-b"); - }, - }); - const c = createExtension("c", { - activate: () => { - order.push("activate-c"); - }, - deactivate: () => { - order.push("deactivate-c"); - }, - }); - - const host = createHost([a, b, c], deps); - await host.activate(); - await host.deactivate(); - - expect(order).toEqual([ - "activate-a", - "activate-b", - "activate-c", - "deactivate-c", - "deactivate-b", - "deactivate-a", - ]); - }); - - it("a failing deactivate does not prevent others", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => {}, - deactivate: () => { - order.push("deactivate-a"); - }, - }); - const b = createExtension("b", { - activate: () => {}, - deactivate: () => { - throw new Error("deactivate boom"); - }, - }); - const c = createExtension("c", { - activate: () => {}, - deactivate: () => { - order.push("deactivate-c"); - }, - }); - - const host = createHost([a, b, c], deps); - await host.activate(); - await host.deactivate(); - - expect(order).toEqual(["deactivate-c", "deactivate-a"]); - const errors = logger.logs.filter((l) => l.level === "error"); - expect(errors.some((e) => e.message.includes("deactivate"))).toBe(true); - expect(errors.some((e) => (e.attrs as { err?: unknown })?.err instanceof Error)).toBe(true); - }); - }); - - describe("HostAPI delegation", () => { - it("on/addFilter delegate to the bus", async () => { - const hook = defineEventHook<string>("test/host-event"); - const received: string[] = []; - - const ext = createExtension("hook-ext", { - activate: (host) => { - host.on(hook, (payload) => { - received.push(payload); - }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - deps.bus.emit(hook, "hello"); - expect(received).toEqual(["hello"]); - }); - - it("emit dispatches to handlers registered via on", async () => { - const hook = defineEventHook<string>("test/emit-dispatch"); - const received: string[] = []; - - const ext = createExtension("emit-ext", { - activate: (host) => { - host.on(hook, (payload) => { - received.push(payload); - }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - api.emit(hook, "world"); - expect(received).toEqual(["world"]); - }); - - it("emit isolates a throwing handler (does not propagate)", async () => { - const hook = defineEventHook<string>("test/emit-isolation"); - const received: string[] = []; - - const ext = createExtension("emit-isolation-ext", { - activate: (host) => { - host.on(hook, () => { - throw new Error("handler boom"); - }); - host.on(hook, (payload) => { - received.push(payload); - }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.emit(hook, "safe")).not.toThrow(); - expect(received).toEqual(["safe"]); - }); - - it("applyFilters threads a value through registered filters in order", async () => { - const hook = defineFilter<string>("test/text-transform"); - - const ext = createExtension("filter-ext", { - activate: (host) => { - host.addFilter(hook, (value) => `${value}-first`); - host.addFilter(hook, (value) => `${value}-second`); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - const result = await api.applyFilters(hook, "start"); - expect(result).toBe("start-first-second"); - }); - - it("applyFilters returns the input unchanged when no filters are registered", async () => { - const hook = defineFilter<string>("test/unused-filter"); - - const ext = createExtension("no-filter-ext", { - activate: () => {}, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - const result = await api.applyFilters(hook, "unchanged"); - expect(result).toBe("unchanged"); - }); - - it("storage delegates to the factory", async () => { - let storageResult: StorageNamespace | undefined; - - const ext = createExtension("storage-ext", { - activate: (host) => { - storageResult = host.storage("my-ns"); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(storageResult).toBeDefined(); - await storageResult?.set("key", "value"); - expect(await storageResult?.get("key")).toBe("value"); - }); - - it("events delegates to the emitter", async () => { - const ext = createExtension("event-ext", { - activate: (host) => { - host.events.emit({ type: "custom", data: 42 }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(events.emitted).toHaveLength(1); - expect(events.emitted[0]).toEqual({ type: "custom", data: 42 }); - }); - }); - - describe("HostAPI registry access", () => { - it("getTools returns registered tools via HostAPI", async () => { - const tool = createFakeTool("read-file"); - let capturedTools: ReadonlyMap<string, ToolContract> | undefined; - - const producer = createExtension("tools-fs", { - activate: (host) => { - host.defineTool(tool); - }, - }); - const consumer = createExtension("consumer", { - dependsOn: ["tools-fs"], - activate: (host) => { - capturedTools = host.getTools(); - }, - }); - - const host = createHost([producer, consumer], deps); - await host.activate(); - - expect(capturedTools).toBeDefined(); - expect(capturedTools?.size).toBe(1); - expect(capturedTools?.get("read-file")).toBe(tool); - }); - - it("getProviders returns registered providers via HostAPI", async () => { - const provider = createFakeProvider("anthropic"); - let capturedProviders: ReadonlyMap<string, ProviderContract> | undefined; - - const producer = createExtension("provider-anthropic", { - activate: (host) => { - host.defineProvider(provider); - }, - }); - const consumer = createExtension("consumer", { - dependsOn: ["provider-anthropic"], - activate: (host) => { - capturedProviders = host.getProviders(); - }, - }); - - const host = createHost([producer, consumer], deps); - await host.activate(); - - expect(capturedProviders).toBeDefined(); - expect(capturedProviders?.size).toBe(1); - expect(capturedProviders?.get("anthropic")).toBe(provider); - }); - - it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => { - const auth = createFakeAuth("apikey"); - let capturedAuth: ReadonlyMap<string, AuthContract> | undefined; - let capturedSingle: AuthContract | undefined; - - const producer = createExtension("auth-apikey", { - activate: (host) => { - host.defineAuth(auth); - }, - }); - const consumer = createExtension("consumer", { - dependsOn: ["auth-apikey"], - activate: (host) => { - capturedAuth = host.getAuthProviders(); - capturedSingle = host.getAuthProvider("apikey"); - }, - }); - - const host = createHost([producer, consumer], deps); - await host.activate(); - - expect(capturedAuth).toBeDefined(); - expect(capturedAuth?.size).toBe(1); - expect(capturedAuth?.get("apikey")).toBe(auth); - expect(capturedSingle).toBe(auth); - }); - }); - - describe("getExtensions", () => { - it("returns empty array when no extensions are activated", async () => { - const host = createHost([], deps); - await host.activate(); - - expect(host.getExtensions()).toEqual([]); - }); - - it("returns manifests of all activated extensions", async () => { - const a = createExtension("ext-a"); - const b = createExtension("ext-b"); - - const host = createHost([a, b], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts).toHaveLength(2); - expect(exts.map((e) => e.id)).toContain("ext-a"); - expect(exts.map((e) => e.id)).toContain("ext-b"); - }); - - it("returns manifests in activation order", async () => { - const a = createExtension("a"); - const b = createExtension("b", { dependsOn: ["a"] }); - const c = createExtension("c", { dependsOn: ["b"] }); - - const host = createHost([c, b, a], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]); - }); - - it("excludes extensions that failed to activate", async () => { - const a = createExtension("good"); - const b = createExtension("bad", { - activate: () => { - throw new Error("boom"); - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts).toHaveLength(1); - expect(exts[0]?.id).toBe("good"); - }); - - it("excludes extensions disabled by apiVersion incompatibility", async () => { - const good = createExtension("good"); - const bad = createExtension("bad", { apiVersion: "^99.0.0" }); - - const host = createHost([good, bad], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts).toHaveLength(1); - expect(exts[0]?.id).toBe("good"); - }); - - it("returns a frozen array", async () => { - const ext = createExtension("ext"); - const host = createHost([ext], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(Object.isFrozen(exts)).toBe(true); - }); - - it("HostAPI getExtensions reflects activated extensions after full activation", async () => { - const a = createExtension("ext-a"); - const b = createExtension("ext-b", { - dependsOn: ["ext-a"], - activate: () => {}, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - // Use getHostAPI() to verify the post-activation view - const api = host.getHostAPI(); - const capturedExtsAfter = api.getExtensions(); - - expect(capturedExtsAfter).toHaveLength(2); - expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]); - }); - - it("HostAPI getExtensions during activation sees only previously activated", async () => { - const seenDuringActivation: string[][] = []; - - const a = createExtension("a", { - activate: (host) => { - seenDuringActivation.push(host.getExtensions().map((e) => e.id)); - }, - }); - const b = createExtension("b", { - activate: (host) => { - seenDuringActivation.push(host.getExtensions().map((e) => e.id)); - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - // When a activates, activated[] is empty (a hasn't been pushed yet) - // When b activates, activated[] has [a] (b hasn't been pushed yet) - expect(seenDuringActivation).toEqual([[], ["a"]]); - }); - }); - - describe("DAG errors", () => { - it("throws on missing dependency", () => { - const ext = createExtension("a", { dependsOn: ["missing"] }); - expect(() => createHost([ext], deps)).toThrow(/not available/); - }); - - it("throws on dependency cycle", () => { - const a = createExtension("a", { dependsOn: ["b"] }); - const b = createExtension("b", { dependsOn: ["a"] }); - expect(() => createHost([a, b], deps)).toThrow(/cycle/i); - }); - }); - - describe("empty host", () => { - it("works with no extensions", async () => { - const host = createHost([], deps); - await host.activate(); - - expect(host.getTools().size).toBe(0); - expect(host.getProviders().size).toBe(0); - expect(host.getAuthProviders().size).toBe(0); - expect(host.getScheduledJobs()).toHaveLength(0); - expect(host.getMigrations()).toHaveLength(0); - expect(host.getDisabled()).toHaveLength(0); - }); - }); - - describe("getHostAPI", () => { - it("returns a HostAPI whose read-views reflect registrations from activation", async () => { - const tool = createFakeTool("read-file"); - const provider = createFakeProvider("anthropic"); - const auth = createFakeAuth("apikey"); - - const ext = createExtension("multi-ext", { - activate: (host) => { - host.defineTool(tool); - host.defineProvider(provider); - host.defineAuth(auth); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - - expect(api.getTools().size).toBe(1); - expect(api.getTools().get("read-file")).toBe(tool); - - expect(api.getProviders().size).toBe(1); - expect(api.getProviders().get("anthropic")).toBe(provider); - - expect(api.getAuthProviders().size).toBe(1); - expect(api.getAuthProvider("apikey")).toBe(auth); - }); - - it("throws on defineTool after activation", async () => { - const ext = createExtension("ext", { activate: () => {} }); - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.defineTool(createFakeTool("late"))).toThrow( - "Registration not available after activation", - ); - }); - - it("throws on defineProvider after activation", async () => { - const ext = createExtension("ext", { activate: () => {} }); - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.defineProvider(createFakeProvider("late"))).toThrow( - "Registration not available after activation", - ); - }); - - it("throws on defineAuth after activation", async () => { - const ext = createExtension("ext", { activate: () => {} }); - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.defineAuth(createFakeAuth("late"))).toThrow( - "Registration not available after activation", - ); - }); - - it("applyFilters is available on registration-closed HostAPI", async () => { - const hook = defineFilter<string>("test/closed-filter"); - - const ext = createExtension("filter-ext", { - activate: (host) => { - host.addFilter(hook, (value) => `${value}-filtered`); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - const result = await api.applyFilters(hook, "input"); - expect(result).toBe("input-filtered"); - }); - }); - - describe("auto-scoped logger (D6)", () => { - it("each extension's logger stamps its own manifest.id as extensionId", async () => { - let extALogger: Logger | undefined; - let extBLogger: Logger | undefined; - - const a = createExtension("ext-a", { - activate: (host) => { - extALogger = host.logger; - }, - }); - const b = createExtension("ext-b", { - activate: (host) => { - extBLogger = host.logger; - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - extALogger?.info("from-a"); - extBLogger?.info("from-b"); - - const logRecords = logSink.records.filter((r) => r.kind === "log"); - expect(logRecords).toHaveLength(2); - if (logRecords[0]?.kind === "log") { - expect(logRecords[0].extensionId).toBe("ext-a"); - expect(logRecords[0].msg).toBe("from-a"); - } - if (logRecords[1]?.kind === "log") { - expect(logRecords[1].extensionId).toBe("ext-b"); - expect(logRecords[1].msg).toBe("from-b"); - } - }); - - it("an extension cannot spoof extensionId — it is auto-stamped", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("real-id", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - // child() cannot override extensionId - const child = extLogger?.child({ extensionId: "spoofed" }); - child?.info("msg"); - - const logRecords = logSink.records.filter((r) => r.kind === "log"); - expect(logRecords).toHaveLength(1); - if (logRecords[0]?.kind === "log") { - expect(logRecords[0].extensionId).toBe("real-id"); - } - }); - - it("host.logger.error uses structured { err } shape", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - extLogger?.error("something broke", { err: new Error("boom") }); - - const logRecords = logSink.records.filter((r) => r.kind === "log"); - expect(logRecords).toHaveLength(1); - if (logRecords[0]?.kind === "log") { - expect(logRecords[0].level).toBe("error"); - expect(logRecords[0].msg).toBe("something broke"); - expect(logRecords[0].attributes?.["error.message"]).toBe("boom"); - } - }); - - it("a throwing sink does NOT break the caller", async () => { - const brokenSink: LogSink = { - emit() { - throw new Error("sink down"); - }, - }; - const brokenDeps: HostDeps = { - ...deps, - logSink: brokenSink, - }; - - let extLogger: Logger | undefined; - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], brokenDeps); - await host.activate(); - - // Should not throw - expect(() => extLogger?.info("msg")).not.toThrow(); - }); - - it("span() + end() emit incremental span-open and span-close records", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("my-span", { key: "value" }); - span?.setAttributes({ extra: "attr" }); - span?.end({ attrs: { result: "ok" } }); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); - - expect(spanOpens).toHaveLength(1); - expect(spanCloses).toHaveLength(1); - - if (spanOpens[0]?.kind === "span-open") { - expect(spanOpens[0].name).toBe("my-span"); - expect(spanOpens[0].extensionId).toBe("ext"); - expect(spanOpens[0].attributes?.key).toBe("value"); - } - if (spanCloses[0]?.kind === "span-close") { - expect(spanCloses[0].name).toBe("my-span"); - expect(spanCloses[0].status).toBe("ok"); - expect(spanCloses[0].durationMs).toBeGreaterThanOrEqual(0); - expect(spanCloses[0].attributes?.extra).toBe("attr"); - expect(spanCloses[0].attributes?.result).toBe("ok"); - } - }); - - it("span() with body emits body on span-open record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("with-body", { key: "value" }, '{"payload":"hello"}'); - span?.end(); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - expect(spanOpens).toHaveLength(1); - if (spanOpens[0]?.kind === "span-open") { - expect(spanOpens[0].body).toBe('{"payload":"hello"}'); - } - }); - - it("span() without body omits body field on span-open record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("no-body"); - span?.end(); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - expect(spanOpens).toHaveLength(1); - if (spanOpens[0]?.kind === "span-open") { - expect(spanOpens[0].body).toBeUndefined(); - } - }); - - it("child() with body emits body on child span-open record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("parent"); - const child = span?.child("child-name", { k: "v" }, '{"child":"body"}'); - child?.end(); - span?.end(); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - const childOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "child-name"); - expect(childOpen).toBeDefined(); - if (childOpen?.kind === "span-open") { - expect(childOpen.body).toBe('{"child":"body"}'); - } - }); - - it("end() with body emits body on span-close record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("close-body"); - span?.end({ body: '{"result":"data"}' }); - - const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); - expect(spanCloses).toHaveLength(1); - if (spanCloses[0]?.kind === "span-close") { - expect(spanCloses[0].body).toBe('{"result":"data"}'); - } - }); - - it("end() without body omits body field on span-close record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("no-close-body"); - span?.end(); - - const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); - expect(spanCloses).toHaveLength(1); - if (spanCloses[0]?.kind === "span-close") { - expect(spanCloses[0].body).toBeUndefined(); - } - }); - }); + let logger: FakeLogger; + let logSink: ReturnType<typeof createFakeLogSink>; + let logDeps: LogDeps; + let deps: HostDeps; + let scheduler: ReturnType<typeof createFakeScheduler>; + let events: ReturnType<typeof createFakeEvents>; + + beforeEach(() => { + logger = createFakeLogger(); + logSink = createFakeLogSink(); + logDeps = createFakeLogDeps(); + scheduler = createFakeScheduler(); + events = createFakeEvents(); + deps = { + logger, + config: createFakeConfig(), + storageFactory: createFakeStorageFactory(), + secrets: createFakeSecrets(), + permissions: createFakePermissions(), + scheduler, + bus: createBus(logger), + events, + logSink, + logDeps, + }; + }); + + describe("activation order", () => { + it("activates extensions in topological order", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("a"); + }, + }); + const b = createExtension("b", { + dependsOn: ["a"], + activate: () => { + order.push("b"); + }, + }); + const c = createExtension("c", { + dependsOn: ["b"], + activate: () => { + order.push("c"); + }, + }); + + const host = createHost([c, b, a], deps); + await host.activate(); + + expect(order).toEqual(["a", "b", "c"]); + }); + + it("activates independent extensions", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("a"); + }, + }); + const b = createExtension("b", { + activate: () => { + order.push("b"); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + expect(order).toHaveLength(2); + expect(order).toContain("a"); + expect(order).toContain("b"); + }); + }); + + describe("fault isolation", () => { + it("a throwing extension is isolated — others still activate", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("a"); + }, + }); + const b = createExtension("b", { + activate: () => { + throw new Error("boom"); + }, + }); + const c = createExtension("c", { + activate: () => { + order.push("c"); + }, + }); + + const host = createHost([a, b, c], deps); + await host.activate(); + + expect(order).toEqual(["a", "c"]); + expect(host.getDisabled()).toHaveLength(1); + expect(host.getDisabled()[0]?.manifest.id).toBe("b"); + expect(host.getDisabled()[0]?.reason).toContain("boom"); + }); + + it("an async-rejecting extension is isolated", async () => { + const a = createExtension("a", { + activate: async () => { + throw new Error("async fail"); + }, + }); + const b = createExtension("b", { + activate: () => {}, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(1); + expect(host.getDisabled()[0]?.manifest.id).toBe("a"); + }); + }); + + describe("apiVersion compatibility", () => { + it("activates compatible extensions", async () => { + const ext = createExtension("good", { + apiVersion: `^${KERNEL_API_VERSION}`, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(0); + }); + + it("disables incompatible extensions without crashing", async () => { + const ext = createExtension("bad", { + apiVersion: "^99.0.0", + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(1); + expect(host.getDisabled()[0]?.manifest.id).toBe("bad"); + expect(host.getDisabled()[0]?.reason).toContain("incompatible"); + }); + + it("logs a warning for disabled extensions", async () => { + const ext = createExtension("bad", { + apiVersion: "^99.0.0", + }); + + const host = createHost([ext], deps); + await host.activate(); + + const warnings = logger.logs.filter((l) => l.level === "warn"); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.message).toContain("bad"); + }); + }); + + describe("registries", () => { + it("defineTool populates the tool registry", async () => { + const tool = createFakeTool("read-file"); + const ext = createExtension("tools-fs", { + activate: (host) => { + host.defineTool(tool); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getTools().size).toBe(1); + expect(host.getTool("read-file")).toBe(tool); + }); + + it("defineProvider populates the provider registry", async () => { + const provider = createFakeProvider("anthropic"); + const ext = createExtension("provider-anthropic", { + activate: (host) => { + host.defineProvider(provider); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getProviders().size).toBe(1); + expect(host.getProvider("anthropic")).toBe(provider); + }); + + it("defineAuth populates the auth registry", async () => { + const auth = createFakeAuth("apikey"); + const ext = createExtension("auth-apikey", { + activate: (host) => { + host.defineAuth(auth); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getAuthProviders().size).toBe(1); + expect(host.getAuthProvider("apikey")).toBe(auth); + }); + + it("getService returns what an extension provided via provideService", async () => { + const handle = defineService<{ value: number }>("test/svc"); + const ext = createExtension("svc-provider", { + activate: (host) => { + host.provideService(handle, { value: 42 }); + }, + }); + const consumer = createExtension("svc-consumer", { + dependsOn: ["svc-provider"], + activate: (host) => { + const svc = host.getService(handle); + expect(svc.value).toBe(42); + }, + }); + + const host = createHost([ext, consumer], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(0); + }); + + it("multiple extensions contribute to the same registry", async () => { + const ext1 = createExtension("tools-a", { + activate: (host) => { + host.defineTool(createFakeTool("tool-a")); + }, + }); + const ext2 = createExtension("tools-b", { + activate: (host) => { + host.defineTool(createFakeTool("tool-b")); + }, + }); + + const host = createHost([ext1, ext2], deps); + await host.activate(); + + expect(host.getTools().size).toBe(2); + expect(host.getTool("tool-a")).toBeDefined(); + expect(host.getTool("tool-b")).toBeDefined(); + }); + }); + + describe("scheduler", () => { + it("collects scheduled jobs and forwards to sink", async () => { + const job: ScheduledJob = { + id: "cache-warm", + cron: "*/5 * * * *", + execute: () => {}, + }; + const ext = createExtension("scheduler-ext", { + activate: (host) => { + host.scheduler.register(job); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getScheduledJobs()).toHaveLength(1); + expect(host.getScheduledJobs()[0]).toBe(job); + expect(scheduler.jobs).toHaveLength(1); + expect(scheduler.jobs[0]).toBe(job); + }); + }); + + describe("migrations", () => { + it("collects migrations from manifests", async () => { + const ext = createExtension("store-ext", { + contributes: { migrations: ["001-init", "002-add-index"] }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getMigrations()).toEqual(["001-init", "002-add-index"]); + }); + }); + + describe("deactivation", () => { + it("deactivates in reverse activation order", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("activate-a"); + }, + deactivate: () => { + order.push("deactivate-a"); + }, + }); + const b = createExtension("b", { + activate: () => { + order.push("activate-b"); + }, + deactivate: () => { + order.push("deactivate-b"); + }, + }); + const c = createExtension("c", { + activate: () => { + order.push("activate-c"); + }, + deactivate: () => { + order.push("deactivate-c"); + }, + }); + + const host = createHost([a, b, c], deps); + await host.activate(); + await host.deactivate(); + + expect(order).toEqual([ + "activate-a", + "activate-b", + "activate-c", + "deactivate-c", + "deactivate-b", + "deactivate-a", + ]); + }); + + it("a failing deactivate does not prevent others", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => {}, + deactivate: () => { + order.push("deactivate-a"); + }, + }); + const b = createExtension("b", { + activate: () => {}, + deactivate: () => { + throw new Error("deactivate boom"); + }, + }); + const c = createExtension("c", { + activate: () => {}, + deactivate: () => { + order.push("deactivate-c"); + }, + }); + + const host = createHost([a, b, c], deps); + await host.activate(); + await host.deactivate(); + + expect(order).toEqual(["deactivate-c", "deactivate-a"]); + const errors = logger.logs.filter((l) => l.level === "error"); + expect(errors.some((e) => e.message.includes("deactivate"))).toBe(true); + expect(errors.some((e) => (e.attrs as { err?: unknown })?.err instanceof Error)).toBe(true); + }); + }); + + describe("HostAPI delegation", () => { + it("on/addFilter delegate to the bus", async () => { + const hook = defineEventHook<string>("test/host-event"); + const received: string[] = []; + + const ext = createExtension("hook-ext", { + activate: (host) => { + host.on(hook, (payload) => { + received.push(payload); + }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + deps.bus.emit(hook, "hello"); + expect(received).toEqual(["hello"]); + }); + + it("emit dispatches to handlers registered via on", async () => { + const hook = defineEventHook<string>("test/emit-dispatch"); + const received: string[] = []; + + const ext = createExtension("emit-ext", { + activate: (host) => { + host.on(hook, (payload) => { + received.push(payload); + }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + api.emit(hook, "world"); + expect(received).toEqual(["world"]); + }); + + it("emit isolates a throwing handler (does not propagate)", async () => { + const hook = defineEventHook<string>("test/emit-isolation"); + const received: string[] = []; + + const ext = createExtension("emit-isolation-ext", { + activate: (host) => { + host.on(hook, () => { + throw new Error("handler boom"); + }); + host.on(hook, (payload) => { + received.push(payload); + }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.emit(hook, "safe")).not.toThrow(); + expect(received).toEqual(["safe"]); + }); + + it("applyFilters threads a value through registered filters in order", async () => { + const hook = defineFilter<string>("test/text-transform"); + + const ext = createExtension("filter-ext", { + activate: (host) => { + host.addFilter(hook, (value) => `${value}-first`); + host.addFilter(hook, (value) => `${value}-second`); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "start"); + expect(result).toBe("start-first-second"); + }); + + it("applyFilters returns the input unchanged when no filters are registered", async () => { + const hook = defineFilter<string>("test/unused-filter"); + + const ext = createExtension("no-filter-ext", { + activate: () => {}, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "unchanged"); + expect(result).toBe("unchanged"); + }); + + it("storage delegates to the factory", async () => { + let storageResult: StorageNamespace | undefined; + + const ext = createExtension("storage-ext", { + activate: (host) => { + storageResult = host.storage("my-ns"); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(storageResult).toBeDefined(); + await storageResult?.set("key", "value"); + expect(await storageResult?.get("key")).toBe("value"); + }); + + it("events delegates to the emitter", async () => { + const ext = createExtension("event-ext", { + activate: (host) => { + host.events.emit({ type: "custom", data: 42 }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(events.emitted).toHaveLength(1); + expect(events.emitted[0]).toEqual({ type: "custom", data: 42 }); + }); + }); + + describe("HostAPI registry access", () => { + it("getTools returns registered tools via HostAPI", async () => { + const tool = createFakeTool("read-file"); + let capturedTools: ReadonlyMap<string, ToolContract> | undefined; + + const producer = createExtension("tools-fs", { + activate: (host) => { + host.defineTool(tool); + }, + }); + const consumer = createExtension("consumer", { + dependsOn: ["tools-fs"], + activate: (host) => { + capturedTools = host.getTools(); + }, + }); + + const host = createHost([producer, consumer], deps); + await host.activate(); + + expect(capturedTools).toBeDefined(); + expect(capturedTools?.size).toBe(1); + expect(capturedTools?.get("read-file")).toBe(tool); + }); + + it("getProviders returns registered providers via HostAPI", async () => { + const provider = createFakeProvider("anthropic"); + let capturedProviders: ReadonlyMap<string, ProviderContract> | undefined; + + const producer = createExtension("provider-anthropic", { + activate: (host) => { + host.defineProvider(provider); + }, + }); + const consumer = createExtension("consumer", { + dependsOn: ["provider-anthropic"], + activate: (host) => { + capturedProviders = host.getProviders(); + }, + }); + + const host = createHost([producer, consumer], deps); + await host.activate(); + + expect(capturedProviders).toBeDefined(); + expect(capturedProviders?.size).toBe(1); + expect(capturedProviders?.get("anthropic")).toBe(provider); + }); + + it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => { + const auth = createFakeAuth("apikey"); + let capturedAuth: ReadonlyMap<string, AuthContract> | undefined; + let capturedSingle: AuthContract | undefined; + + const producer = createExtension("auth-apikey", { + activate: (host) => { + host.defineAuth(auth); + }, + }); + const consumer = createExtension("consumer", { + dependsOn: ["auth-apikey"], + activate: (host) => { + capturedAuth = host.getAuthProviders(); + capturedSingle = host.getAuthProvider("apikey"); + }, + }); + + const host = createHost([producer, consumer], deps); + await host.activate(); + + expect(capturedAuth).toBeDefined(); + expect(capturedAuth?.size).toBe(1); + expect(capturedAuth?.get("apikey")).toBe(auth); + expect(capturedSingle).toBe(auth); + }); + }); + + describe("getExtensions", () => { + it("returns empty array when no extensions are activated", async () => { + const host = createHost([], deps); + await host.activate(); + + expect(host.getExtensions()).toEqual([]); + }); + + it("returns manifests of all activated extensions", async () => { + const a = createExtension("ext-a"); + const b = createExtension("ext-b"); + + const host = createHost([a, b], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(2); + expect(exts.map((e) => e.id)).toContain("ext-a"); + expect(exts.map((e) => e.id)).toContain("ext-b"); + }); + + it("returns manifests in activation order", async () => { + const a = createExtension("a"); + const b = createExtension("b", { dependsOn: ["a"] }); + const c = createExtension("c", { dependsOn: ["b"] }); + + const host = createHost([c, b, a], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]); + }); + + it("excludes extensions that failed to activate", async () => { + const a = createExtension("good"); + const b = createExtension("bad", { + activate: () => { + throw new Error("boom"); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(1); + expect(exts[0]?.id).toBe("good"); + }); + + it("excludes extensions disabled by apiVersion incompatibility", async () => { + const good = createExtension("good"); + const bad = createExtension("bad", { apiVersion: "^99.0.0" }); + + const host = createHost([good, bad], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(1); + expect(exts[0]?.id).toBe("good"); + }); + + it("returns a frozen array", async () => { + const ext = createExtension("ext"); + const host = createHost([ext], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(Object.isFrozen(exts)).toBe(true); + }); + + it("HostAPI getExtensions reflects activated extensions after full activation", async () => { + const a = createExtension("ext-a"); + const b = createExtension("ext-b", { + dependsOn: ["ext-a"], + activate: () => {}, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + // Use getHostAPI() to verify the post-activation view + const api = host.getHostAPI(); + const capturedExtsAfter = api.getExtensions(); + + expect(capturedExtsAfter).toHaveLength(2); + expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]); + }); + + it("HostAPI getExtensions during activation sees only previously activated", async () => { + const seenDuringActivation: string[][] = []; + + const a = createExtension("a", { + activate: (host) => { + seenDuringActivation.push(host.getExtensions().map((e) => e.id)); + }, + }); + const b = createExtension("b", { + activate: (host) => { + seenDuringActivation.push(host.getExtensions().map((e) => e.id)); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + // When a activates, activated[] is empty (a hasn't been pushed yet) + // When b activates, activated[] has [a] (b hasn't been pushed yet) + expect(seenDuringActivation).toEqual([[], ["a"]]); + }); + }); + + describe("DAG errors", () => { + it("throws on missing dependency", () => { + const ext = createExtension("a", { dependsOn: ["missing"] }); + expect(() => createHost([ext], deps)).toThrow(/not available/); + }); + + it("throws on dependency cycle", () => { + const a = createExtension("a", { dependsOn: ["b"] }); + const b = createExtension("b", { dependsOn: ["a"] }); + expect(() => createHost([a, b], deps)).toThrow(/cycle/i); + }); + }); + + describe("empty host", () => { + it("works with no extensions", async () => { + const host = createHost([], deps); + await host.activate(); + + expect(host.getTools().size).toBe(0); + expect(host.getProviders().size).toBe(0); + expect(host.getAuthProviders().size).toBe(0); + expect(host.getScheduledJobs()).toHaveLength(0); + expect(host.getMigrations()).toHaveLength(0); + expect(host.getDisabled()).toHaveLength(0); + }); + }); + + describe("getHostAPI", () => { + it("returns a HostAPI whose read-views reflect registrations from activation", async () => { + const tool = createFakeTool("read-file"); + const provider = createFakeProvider("anthropic"); + const auth = createFakeAuth("apikey"); + + const ext = createExtension("multi-ext", { + activate: (host) => { + host.defineTool(tool); + host.defineProvider(provider); + host.defineAuth(auth); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + + expect(api.getTools().size).toBe(1); + expect(api.getTools().get("read-file")).toBe(tool); + + expect(api.getProviders().size).toBe(1); + expect(api.getProviders().get("anthropic")).toBe(provider); + + expect(api.getAuthProviders().size).toBe(1); + expect(api.getAuthProvider("apikey")).toBe(auth); + }); + + it("throws on defineTool after activation", async () => { + const ext = createExtension("ext", { activate: () => {} }); + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.defineTool(createFakeTool("late"))).toThrow( + "Registration not available after activation", + ); + }); + + it("throws on defineProvider after activation", async () => { + const ext = createExtension("ext", { activate: () => {} }); + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.defineProvider(createFakeProvider("late"))).toThrow( + "Registration not available after activation", + ); + }); + + it("throws on defineAuth after activation", async () => { + const ext = createExtension("ext", { activate: () => {} }); + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.defineAuth(createFakeAuth("late"))).toThrow( + "Registration not available after activation", + ); + }); + + it("applyFilters is available on registration-closed HostAPI", async () => { + const hook = defineFilter<string>("test/closed-filter"); + + const ext = createExtension("filter-ext", { + activate: (host) => { + host.addFilter(hook, (value) => `${value}-filtered`); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "input"); + expect(result).toBe("input-filtered"); + }); + }); + + describe("auto-scoped logger (D6)", () => { + it("each extension's logger stamps its own manifest.id as extensionId", async () => { + let extALogger: Logger | undefined; + let extBLogger: Logger | undefined; + + const a = createExtension("ext-a", { + activate: (host) => { + extALogger = host.logger; + }, + }); + const b = createExtension("ext-b", { + activate: (host) => { + extBLogger = host.logger; + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + extALogger?.info("from-a"); + extBLogger?.info("from-b"); + + const logRecords = logSink.records.filter((r) => r.kind === "log"); + expect(logRecords).toHaveLength(2); + if (logRecords[0]?.kind === "log") { + expect(logRecords[0].extensionId).toBe("ext-a"); + expect(logRecords[0].msg).toBe("from-a"); + } + if (logRecords[1]?.kind === "log") { + expect(logRecords[1].extensionId).toBe("ext-b"); + expect(logRecords[1].msg).toBe("from-b"); + } + }); + + it("an extension cannot spoof extensionId — it is auto-stamped", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("real-id", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + // child() cannot override extensionId + const child = extLogger?.child({ extensionId: "spoofed" }); + child?.info("msg"); + + const logRecords = logSink.records.filter((r) => r.kind === "log"); + expect(logRecords).toHaveLength(1); + if (logRecords[0]?.kind === "log") { + expect(logRecords[0].extensionId).toBe("real-id"); + } + }); + + it("host.logger.error uses structured { err } shape", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + extLogger?.error("something broke", { err: new Error("boom") }); + + const logRecords = logSink.records.filter((r) => r.kind === "log"); + expect(logRecords).toHaveLength(1); + if (logRecords[0]?.kind === "log") { + expect(logRecords[0].level).toBe("error"); + expect(logRecords[0].msg).toBe("something broke"); + expect(logRecords[0].attributes?.["error.message"]).toBe("boom"); + } + }); + + it("a throwing sink does NOT break the caller", async () => { + const brokenSink: LogSink = { + emit() { + throw new Error("sink down"); + }, + }; + const brokenDeps: HostDeps = { + ...deps, + logSink: brokenSink, + }; + + let extLogger: Logger | undefined; + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], brokenDeps); + await host.activate(); + + // Should not throw + expect(() => extLogger?.info("msg")).not.toThrow(); + }); + + it("span() + end() emit incremental span-open and span-close records", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("my-span", { key: "value" }); + span?.setAttributes({ extra: "attr" }); + span?.end({ attrs: { result: "ok" } }); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); + + expect(spanOpens).toHaveLength(1); + expect(spanCloses).toHaveLength(1); + + if (spanOpens[0]?.kind === "span-open") { + expect(spanOpens[0].name).toBe("my-span"); + expect(spanOpens[0].extensionId).toBe("ext"); + expect(spanOpens[0].attributes?.key).toBe("value"); + } + if (spanCloses[0]?.kind === "span-close") { + expect(spanCloses[0].name).toBe("my-span"); + expect(spanCloses[0].status).toBe("ok"); + expect(spanCloses[0].durationMs).toBeGreaterThanOrEqual(0); + expect(spanCloses[0].attributes?.extra).toBe("attr"); + expect(spanCloses[0].attributes?.result).toBe("ok"); + } + }); + + it("span() with body emits body on span-open record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("with-body", { key: "value" }, '{"payload":"hello"}'); + span?.end(); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + expect(spanOpens).toHaveLength(1); + if (spanOpens[0]?.kind === "span-open") { + expect(spanOpens[0].body).toBe('{"payload":"hello"}'); + } + }); + + it("span() without body omits body field on span-open record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("no-body"); + span?.end(); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + expect(spanOpens).toHaveLength(1); + if (spanOpens[0]?.kind === "span-open") { + expect(spanOpens[0].body).toBeUndefined(); + } + }); + + it("child() with body emits body on child span-open record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("parent"); + const child = span?.child("child-name", { k: "v" }, '{"child":"body"}'); + child?.end(); + span?.end(); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + const childOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "child-name"); + expect(childOpen).toBeDefined(); + if (childOpen?.kind === "span-open") { + expect(childOpen.body).toBe('{"child":"body"}'); + } + }); + + it("end() with body emits body on span-close record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("close-body"); + span?.end({ body: '{"result":"data"}' }); + + const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); + expect(spanCloses).toHaveLength(1); + if (spanCloses[0]?.kind === "span-close") { + expect(spanCloses[0].body).toBe('{"result":"data"}'); + } + }); + + it("end() without body omits body field on span-close record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("no-close-body"); + span?.end(); + + const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); + expect(spanCloses).toHaveLength(1); + if (spanCloses[0]?.kind === "span-close") { + expect(spanCloses[0].body).toBeUndefined(); + } + }); + }); }); diff --git a/packages/kernel/src/host/host.ts b/packages/kernel/src/host/host.ts index 2a262be..f74881f 100644 --- a/packages/kernel/src/host/host.ts +++ b/packages/kernel/src/host/host.ts @@ -1,22 +1,22 @@ import type { Bus } from "../bus/bus.js"; import type { AuthContract } from "../contracts/auth.js"; import type { - ConfigAccess, - EventsEmitter, - Extension, - HostAPI, - Manifest, - PermissionGate, - ScheduledJob, - SecretsAccess, - StorageNamespace, + ConfigAccess, + EventsEmitter, + Extension, + HostAPI, + Manifest, + PermissionGate, + ScheduledJob, + SecretsAccess, + StorageNamespace, } from "../contracts/extension.js"; import type { - EventHandler, - EventHookDescriptor, - FilterDescriptor, - FilterHandler, - ServiceHandle, + EventHandler, + EventHookDescriptor, + FilterDescriptor, + FilterHandler, + ServiceHandle, } from "../contracts/hooks.js"; import type { LogDeps, Logger, LogSink } from "../contracts/logging.js"; import type { ProviderContract } from "../contracts/provider.js"; @@ -28,210 +28,210 @@ import { isApiVersionCompatible } from "./version.js"; export const KERNEL_API_VERSION = "0.1.0"; export interface DisabledExtension { - readonly manifest: Manifest; - readonly reason: string; + readonly manifest: Manifest; + readonly reason: string; } export interface HostDeps { - readonly logger: Logger; - readonly config: ConfigAccess; - readonly storageFactory: (namespace: string) => StorageNamespace; - readonly secrets: SecretsAccess; - readonly permissions: PermissionGate; - readonly scheduler: { readonly register: (job: ScheduledJob) => void }; - readonly bus: Bus; - readonly events: EventsEmitter; - readonly logSink: LogSink; - readonly logDeps: LogDeps; + readonly logger: Logger; + readonly config: ConfigAccess; + readonly storageFactory: (namespace: string) => StorageNamespace; + readonly secrets: SecretsAccess; + readonly permissions: PermissionGate; + readonly scheduler: { readonly register: (job: ScheduledJob) => void }; + readonly bus: Bus; + readonly events: EventsEmitter; + readonly logSink: LogSink; + readonly logDeps: LogDeps; } export interface Host { - readonly activate: () => Promise<void>; - readonly deactivate: () => Promise<void>; - readonly getTools: () => ReadonlyMap<string, ToolContract>; - readonly getTool: (name: string) => ToolContract | undefined; - readonly getProviders: () => ReadonlyMap<string, ProviderContract>; - readonly getProvider: (id: string) => ProviderContract | undefined; - readonly getAuthProviders: () => ReadonlyMap<string, AuthContract>; - readonly getAuthProvider: (id: string) => AuthContract | undefined; - readonly getScheduledJobs: () => readonly ScheduledJob[]; - readonly getMigrations: () => readonly string[]; - readonly getDisabled: () => readonly DisabledExtension[]; - readonly getExtensions: () => readonly Manifest[]; - readonly getHostAPI: () => HostAPI; + readonly activate: () => Promise<void>; + readonly deactivate: () => Promise<void>; + readonly getTools: () => ReadonlyMap<string, ToolContract>; + readonly getTool: (name: string) => ToolContract | undefined; + readonly getProviders: () => ReadonlyMap<string, ProviderContract>; + readonly getProvider: (id: string) => ProviderContract | undefined; + readonly getAuthProviders: () => ReadonlyMap<string, AuthContract>; + readonly getAuthProvider: (id: string) => AuthContract | undefined; + readonly getScheduledJobs: () => readonly ScheduledJob[]; + readonly getMigrations: () => readonly string[]; + readonly getDisabled: () => readonly DisabledExtension[]; + readonly getExtensions: () => readonly Manifest[]; + readonly getHostAPI: () => HostAPI; } export function createHost(extensions: readonly Extension[], deps: HostDeps): Host { - const tools = new Map<string, ToolContract>(); - const providers = new Map<string, ProviderContract>(); - const authProviders = new Map<string, AuthContract>(); - const scheduledJobs: ScheduledJob[] = []; - const migrations: string[] = []; - const disabled: DisabledExtension[] = []; - const activated: Extension[] = []; + const tools = new Map<string, ToolContract>(); + const providers = new Map<string, ProviderContract>(); + const authProviders = new Map<string, AuthContract>(); + const scheduledJobs: ScheduledJob[] = []; + const migrations: string[] = []; + const disabled: DisabledExtension[] = []; + const activated: Extension[] = []; - const ordered = resolveActivationOrder(extensions.map((e) => e.manifest)); - const extById = new Map<string, Extension>(); - for (const ext of extensions) { - extById.set(ext.manifest.id, ext); - } + const ordered = resolveActivationOrder(extensions.map((e) => e.manifest)); + const extById = new Map<string, Extension>(); + for (const ext of extensions) { + extById.set(ext.manifest.id, ext); + } - const compatible: Extension[] = []; - for (const m of ordered) { - const ext = extById.get(m.id); - if (ext === undefined) continue; - if (isApiVersionCompatible(m.apiVersion, KERNEL_API_VERSION)) { - compatible.push(ext); - } else { - disabled.push({ - manifest: m, - reason: `apiVersion "${m.apiVersion}" is incompatible with kernel API ${KERNEL_API_VERSION}`, - }); - deps.logger.warn(`Extension "${m.id}" disabled: apiVersion incompatible`); - } - } + const compatible: Extension[] = []; + for (const m of ordered) { + const ext = extById.get(m.id); + if (ext === undefined) continue; + if (isApiVersionCompatible(m.apiVersion, KERNEL_API_VERSION)) { + compatible.push(ext); + } else { + disabled.push({ + manifest: m, + reason: `apiVersion "${m.apiVersion}" is incompatible with kernel API ${KERNEL_API_VERSION}`, + }); + deps.logger.warn(`Extension "${m.id}" disabled: apiVersion incompatible`); + } + } - for (const ext of compatible) { - const extMigrations = ext.manifest.contributes?.migrations; - if (extMigrations) { - for (const migration of extMigrations) { - migrations.push(migration); - } - } - } + for (const ext of compatible) { + const extMigrations = ext.manifest.contributes?.migrations; + if (extMigrations) { + for (const migration of extMigrations) { + migrations.push(migration); + } + } + } - function buildHostAPI( - extensionId: string, - opts?: { readonly registrationClosed?: boolean }, - ): HostAPI { - const closed = opts?.registrationClosed ?? false; - const extLogger = createLogger({ extensionId }, deps.logSink, deps.logDeps); - return { - defineTool(tool: ToolContract) { - if (closed) throw new Error("Registration not available after activation"); - tools.set(tool.name, tool); - }, - defineProvider(provider: ProviderContract) { - if (closed) throw new Error("Registration not available after activation"); - providers.set(provider.id, provider); - }, - defineAuth(auth: AuthContract) { - if (closed) throw new Error("Registration not available after activation"); - authProviders.set(auth.id, auth); - }, - on<TPayload>(hook: EventHookDescriptor<TPayload>, handler: EventHandler<TPayload>) { - return deps.bus.on(hook, handler); - }, - emit<TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) { - deps.bus.emit(hook, payload); - }, - addFilter<TValue>(hook: FilterDescriptor<TValue>, fn: FilterHandler<TValue>) { - return deps.bus.addFilter(hook, fn); - }, - async applyFilters<TValue>( - hook: FilterDescriptor<TValue>, - value: TValue, - opts?: { readonly failClosed?: boolean }, - ): Promise<TValue> { - return deps.bus.applyFilters(hook, value, opts); - }, - provideService<T>(handle: ServiceHandle<T>, impl: T) { - deps.bus.provideService(handle, impl); - }, - getService<T>(handle: ServiceHandle<T>): T { - return deps.bus.getService(handle); - }, - storage(namespace: string): StorageNamespace { - return deps.storageFactory(namespace); - }, - config: deps.config, - secrets: deps.secrets, - permissions: deps.permissions, - events: deps.events, - logger: extLogger, - getProviders() { - return providers; - }, - getTools() { - return tools; - }, - getAuthProviders() { - return authProviders; - }, - getAuthProvider(id: string) { - return authProviders.get(id); - }, - getExtensions() { - return Object.freeze(activated.map((e) => e.manifest)); - }, - scheduler: { - register(job: ScheduledJob) { - scheduledJobs.push(job); - deps.scheduler.register(job); - }, - }, - }; - } + function buildHostAPI( + extensionId: string, + opts?: { readonly registrationClosed?: boolean }, + ): HostAPI { + const closed = opts?.registrationClosed ?? false; + const extLogger = createLogger({ extensionId }, deps.logSink, deps.logDeps); + return { + defineTool(tool: ToolContract) { + if (closed) throw new Error("Registration not available after activation"); + tools.set(tool.name, tool); + }, + defineProvider(provider: ProviderContract) { + if (closed) throw new Error("Registration not available after activation"); + providers.set(provider.id, provider); + }, + defineAuth(auth: AuthContract) { + if (closed) throw new Error("Registration not available after activation"); + authProviders.set(auth.id, auth); + }, + on<TPayload>(hook: EventHookDescriptor<TPayload>, handler: EventHandler<TPayload>) { + return deps.bus.on(hook, handler); + }, + emit<TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) { + deps.bus.emit(hook, payload); + }, + addFilter<TValue>(hook: FilterDescriptor<TValue>, fn: FilterHandler<TValue>) { + return deps.bus.addFilter(hook, fn); + }, + async applyFilters<TValue>( + hook: FilterDescriptor<TValue>, + value: TValue, + opts?: { readonly failClosed?: boolean }, + ): Promise<TValue> { + return deps.bus.applyFilters(hook, value, opts); + }, + provideService<T>(handle: ServiceHandle<T>, impl: T) { + deps.bus.provideService(handle, impl); + }, + getService<T>(handle: ServiceHandle<T>): T { + return deps.bus.getService(handle); + }, + storage(namespace: string): StorageNamespace { + return deps.storageFactory(namespace); + }, + config: deps.config, + secrets: deps.secrets, + permissions: deps.permissions, + events: deps.events, + logger: extLogger, + getProviders() { + return providers; + }, + getTools() { + return tools; + }, + getAuthProviders() { + return authProviders; + }, + getAuthProvider(id: string) { + return authProviders.get(id); + }, + getExtensions() { + return Object.freeze(activated.map((e) => e.manifest)); + }, + scheduler: { + register(job: ScheduledJob) { + scheduledJobs.push(job); + deps.scheduler.register(job); + }, + }, + }; + } - return { - async activate() { - for (const ext of compatible) { - try { - await ext.activate(buildHostAPI(ext.manifest.id)); - activated.push(ext); - deps.logger.info(`Extension "${ext.manifest.id}" activated`); - } catch (err) { - disabled.push({ - manifest: ext.manifest, - reason: `Activation failed: ${err instanceof Error ? err.message : String(err)}`, - }); - deps.logger.error(`Extension "${ext.manifest.id}" failed to activate`, { err }); - } - } - }, - async deactivate() { - for (let i = activated.length - 1; i >= 0; i--) { - const ext = activated[i]; - if (ext === undefined || ext.deactivate === undefined) continue; - try { - await ext.deactivate(); - } catch (err) { - deps.logger.error(`Extension "${ext.manifest.id}" failed to deactivate`, { err }); - } - } - }, - getTools() { - return tools; - }, - getTool(name: string) { - return tools.get(name); - }, - getProviders() { - return providers; - }, - getProvider(id: string) { - return providers.get(id); - }, - getAuthProviders() { - return authProviders; - }, - getAuthProvider(id: string) { - return authProviders.get(id); - }, - getScheduledJobs() { - return scheduledJobs; - }, - getMigrations() { - return migrations; - }, - getDisabled() { - return disabled; - }, - getExtensions() { - return Object.freeze(activated.map((e) => e.manifest)); - }, - getHostAPI() { - return buildHostAPI("__host__", { registrationClosed: true }); - }, - }; + return { + async activate() { + for (const ext of compatible) { + try { + await ext.activate(buildHostAPI(ext.manifest.id)); + activated.push(ext); + deps.logger.info(`Extension "${ext.manifest.id}" activated`); + } catch (err) { + disabled.push({ + manifest: ext.manifest, + reason: `Activation failed: ${err instanceof Error ? err.message : String(err)}`, + }); + deps.logger.error(`Extension "${ext.manifest.id}" failed to activate`, { err }); + } + } + }, + async deactivate() { + for (let i = activated.length - 1; i >= 0; i--) { + const ext = activated[i]; + if (ext === undefined || ext.deactivate === undefined) continue; + try { + await ext.deactivate(); + } catch (err) { + deps.logger.error(`Extension "${ext.manifest.id}" failed to deactivate`, { err }); + } + } + }, + getTools() { + return tools; + }, + getTool(name: string) { + return tools.get(name); + }, + getProviders() { + return providers; + }, + getProvider(id: string) { + return providers.get(id); + }, + getAuthProviders() { + return authProviders; + }, + getAuthProvider(id: string) { + return authProviders.get(id); + }, + getScheduledJobs() { + return scheduledJobs; + }, + getMigrations() { + return migrations; + }, + getDisabled() { + return disabled; + }, + getExtensions() { + return Object.freeze(activated.map((e) => e.manifest)); + }, + getHostAPI() { + return buildHostAPI("__host__", { registrationClosed: true }); + }, + }; } diff --git a/packages/kernel/src/host/version.test.ts b/packages/kernel/src/host/version.test.ts index 85002b6..8325c5e 100644 --- a/packages/kernel/src/host/version.test.ts +++ b/packages/kernel/src/host/version.test.ts @@ -2,99 +2,99 @@ import { describe, expect, it } from "vitest"; import { isApiVersionCompatible } from "./version.js"; describe("isApiVersionCompatible", () => { - describe("wildcard", () => { - it("matches any version", () => { - expect(isApiVersionCompatible("*", "0.1.0")).toBe(true); - expect(isApiVersionCompatible("*", "1.0.0")).toBe(true); - expect(isApiVersionCompatible("*", "99.99.99")).toBe(true); - }); - }); - - describe("exact match", () => { - it("matches identical version", () => { - expect(isApiVersionCompatible("0.1.0", "0.1.0")).toBe(true); - }); - - it("rejects different patch", () => { - expect(isApiVersionCompatible("0.1.0", "0.1.1")).toBe(false); - }); - - it("rejects different minor", () => { - expect(isApiVersionCompatible("0.1.0", "0.2.0")).toBe(false); - }); - - it("rejects different major", () => { - expect(isApiVersionCompatible("1.0.0", "2.0.0")).toBe(false); - }); - }); - - describe("caret range (^)", () => { - it("0.x: allows same minor, higher patch", () => { - expect(isApiVersionCompatible("^0.1.0", "0.1.0")).toBe(true); - expect(isApiVersionCompatible("^0.1.0", "0.1.5")).toBe(true); - expect(isApiVersionCompatible("^0.1.0", "0.1.99")).toBe(true); - }); - - it("0.x: rejects different minor", () => { - expect(isApiVersionCompatible("^0.1.0", "0.2.0")).toBe(false); - expect(isApiVersionCompatible("^0.1.0", "0.0.9")).toBe(false); - }); - - it("0.x: rejects different major", () => { - expect(isApiVersionCompatible("^0.1.0", "1.0.0")).toBe(false); - }); - - it("1.x+: allows same major, higher minor/patch", () => { - expect(isApiVersionCompatible("^1.2.0", "1.2.0")).toBe(true); - expect(isApiVersionCompatible("^1.2.0", "1.3.0")).toBe(true); - expect(isApiVersionCompatible("^1.2.0", "1.99.0")).toBe(true); - }); - - it("1.x+: rejects next major", () => { - expect(isApiVersionCompatible("^1.2.0", "2.0.0")).toBe(false); - }); - - it("rejects below minimum", () => { - expect(isApiVersionCompatible("^1.2.3", "1.2.2")).toBe(false); - expect(isApiVersionCompatible("^1.2.3", "1.1.9")).toBe(false); - }); - }); - - describe("tilde range (~)", () => { - it("allows same major.minor, higher patch", () => { - expect(isApiVersionCompatible("~0.1.0", "0.1.0")).toBe(true); - expect(isApiVersionCompatible("~0.1.0", "0.1.5")).toBe(true); - }); - - it("rejects different minor", () => { - expect(isApiVersionCompatible("~0.1.0", "0.2.0")).toBe(false); - }); - - it("rejects below minimum", () => { - expect(isApiVersionCompatible("~1.2.3", "1.2.2")).toBe(false); - }); - }); - - describe(">= range", () => { - it("allows equal or higher", () => { - expect(isApiVersionCompatible(">=0.1.0", "0.1.0")).toBe(true); - expect(isApiVersionCompatible(">=0.1.0", "0.2.0")).toBe(true); - expect(isApiVersionCompatible(">=0.1.0", "1.0.0")).toBe(true); - }); - - it("rejects below minimum", () => { - expect(isApiVersionCompatible(">=0.2.0", "0.1.0")).toBe(false); - expect(isApiVersionCompatible(">=1.0.0", "0.9.9")).toBe(false); - }); - }); - - describe("invalid input", () => { - it("throws on invalid kernel version", () => { - expect(() => isApiVersionCompatible("^0.1.0", "abc")).toThrow(/invalid semver/i); - }); - - it("throws on invalid range version", () => { - expect(() => isApiVersionCompatible("not-a-range", "0.1.0")).toThrow(/invalid semver/i); - }); - }); + describe("wildcard", () => { + it("matches any version", () => { + expect(isApiVersionCompatible("*", "0.1.0")).toBe(true); + expect(isApiVersionCompatible("*", "1.0.0")).toBe(true); + expect(isApiVersionCompatible("*", "99.99.99")).toBe(true); + }); + }); + + describe("exact match", () => { + it("matches identical version", () => { + expect(isApiVersionCompatible("0.1.0", "0.1.0")).toBe(true); + }); + + it("rejects different patch", () => { + expect(isApiVersionCompatible("0.1.0", "0.1.1")).toBe(false); + }); + + it("rejects different minor", () => { + expect(isApiVersionCompatible("0.1.0", "0.2.0")).toBe(false); + }); + + it("rejects different major", () => { + expect(isApiVersionCompatible("1.0.0", "2.0.0")).toBe(false); + }); + }); + + describe("caret range (^)", () => { + it("0.x: allows same minor, higher patch", () => { + expect(isApiVersionCompatible("^0.1.0", "0.1.0")).toBe(true); + expect(isApiVersionCompatible("^0.1.0", "0.1.5")).toBe(true); + expect(isApiVersionCompatible("^0.1.0", "0.1.99")).toBe(true); + }); + + it("0.x: rejects different minor", () => { + expect(isApiVersionCompatible("^0.1.0", "0.2.0")).toBe(false); + expect(isApiVersionCompatible("^0.1.0", "0.0.9")).toBe(false); + }); + + it("0.x: rejects different major", () => { + expect(isApiVersionCompatible("^0.1.0", "1.0.0")).toBe(false); + }); + + it("1.x+: allows same major, higher minor/patch", () => { + expect(isApiVersionCompatible("^1.2.0", "1.2.0")).toBe(true); + expect(isApiVersionCompatible("^1.2.0", "1.3.0")).toBe(true); + expect(isApiVersionCompatible("^1.2.0", "1.99.0")).toBe(true); + }); + + it("1.x+: rejects next major", () => { + expect(isApiVersionCompatible("^1.2.0", "2.0.0")).toBe(false); + }); + + it("rejects below minimum", () => { + expect(isApiVersionCompatible("^1.2.3", "1.2.2")).toBe(false); + expect(isApiVersionCompatible("^1.2.3", "1.1.9")).toBe(false); + }); + }); + + describe("tilde range (~)", () => { + it("allows same major.minor, higher patch", () => { + expect(isApiVersionCompatible("~0.1.0", "0.1.0")).toBe(true); + expect(isApiVersionCompatible("~0.1.0", "0.1.5")).toBe(true); + }); + + it("rejects different minor", () => { + expect(isApiVersionCompatible("~0.1.0", "0.2.0")).toBe(false); + }); + + it("rejects below minimum", () => { + expect(isApiVersionCompatible("~1.2.3", "1.2.2")).toBe(false); + }); + }); + + describe(">= range", () => { + it("allows equal or higher", () => { + expect(isApiVersionCompatible(">=0.1.0", "0.1.0")).toBe(true); + expect(isApiVersionCompatible(">=0.1.0", "0.2.0")).toBe(true); + expect(isApiVersionCompatible(">=0.1.0", "1.0.0")).toBe(true); + }); + + it("rejects below minimum", () => { + expect(isApiVersionCompatible(">=0.2.0", "0.1.0")).toBe(false); + expect(isApiVersionCompatible(">=1.0.0", "0.9.9")).toBe(false); + }); + }); + + describe("invalid input", () => { + it("throws on invalid kernel version", () => { + expect(() => isApiVersionCompatible("^0.1.0", "abc")).toThrow(/invalid semver/i); + }); + + it("throws on invalid range version", () => { + expect(() => isApiVersionCompatible("not-a-range", "0.1.0")).toThrow(/invalid semver/i); + }); + }); }); diff --git a/packages/kernel/src/host/version.ts b/packages/kernel/src/host/version.ts index 0d62a3b..eb9ac16 100644 --- a/packages/kernel/src/host/version.ts +++ b/packages/kernel/src/host/version.ts @@ -1,52 +1,52 @@ interface SemVer { - readonly major: number; - readonly minor: number; - readonly patch: number; + readonly major: number; + readonly minor: number; + readonly patch: number; } function parseSemVer(version: string): SemVer { - const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/); - if (!match) throw new Error(`Invalid semver: "${version}"`); - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - }; + const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!match) throw new Error(`Invalid semver: "${version}"`); + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; } function gte(a: SemVer, b: SemVer): boolean { - if (a.major !== b.major) return a.major > b.major; - if (a.minor !== b.minor) return a.minor > b.minor; - return a.patch >= b.patch; + if (a.major !== b.major) return a.major > b.major; + if (a.minor !== b.minor) return a.minor > b.minor; + return a.patch >= b.patch; } export function isApiVersionCompatible(range: string, kernelVersion: string): boolean { - if (range === "*") return true; - - const kernel = parseSemVer(kernelVersion); - - if (range.startsWith("^")) { - const min = parseSemVer(range.slice(1)); - if (!gte(kernel, min)) return false; - if (min.major === 0) { - return kernel.major === 0 && kernel.minor === min.minor; - } - return kernel.major === min.major; - } - - if (range.startsWith("~")) { - const min = parseSemVer(range.slice(1)); - if (!gte(kernel, min)) return false; - return kernel.major === min.major && kernel.minor === min.minor; - } - - if (range.startsWith(">=")) { - const min = parseSemVer(range.slice(2)); - return gte(kernel, min); - } - - const exact = parseSemVer(range); - return ( - kernel.major === exact.major && kernel.minor === exact.minor && kernel.patch === exact.patch - ); + if (range === "*") return true; + + const kernel = parseSemVer(kernelVersion); + + if (range.startsWith("^")) { + const min = parseSemVer(range.slice(1)); + if (!gte(kernel, min)) return false; + if (min.major === 0) { + return kernel.major === 0 && kernel.minor === min.minor; + } + return kernel.major === min.major; + } + + if (range.startsWith("~")) { + const min = parseSemVer(range.slice(1)); + if (!gte(kernel, min)) return false; + return kernel.major === min.major && kernel.minor === min.minor; + } + + if (range.startsWith(">=")) { + const min = parseSemVer(range.slice(2)); + return gte(kernel, min); + } + + const exact = parseSemVer(range); + return ( + kernel.major === exact.major && kernel.minor === exact.minor && kernel.patch === exact.patch + ); } |
