diff --git a/packages/core/src/commands/index.ts b/packages/core/src/commands/index.ts index 0003571..4e1f1ca 100644 --- a/packages/core/src/commands/index.ts +++ b/packages/core/src/commands/index.ts @@ -4,4 +4,5 @@ export { isValidCommandId, type CommandRegistry, type CommandRegistryDeps, + type RegisterLazyOptions, } from "./registry"; diff --git a/packages/core/src/commands/registry.test.ts b/packages/core/src/commands/registry.test.ts index 3360c43..ff25629 100644 --- a/packages/core/src/commands/registry.test.ts +++ b/packages/core/src/commands/registry.test.ts @@ -297,3 +297,88 @@ test("HostLog.append clones the incoming error, isolating later caller mutations expect(log.entries()[0]?.error.message).toBe("original"); }); + +// --- registerLazy / lazy commands (design.md §4.1, §5) --------------------- + +test("registerLazy adds the command to list() with its meta, but no handler runs it yet", async () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const registry = createCommandRegistry({ log, sink }); + + registry.registerLazy("demo.run", { + extensionId: "demo.ext", + meta: { title: "Run Demo", category: "Demo" }, + }); + + expect(registry.list()).toEqual([ + { id: "demo.run", title: "Run Demo", category: "Demo", when: undefined }, + ]); +}); + +test("executing a lazy, not-yet-activated command reports a HostError and does not throw", async () => { + const log = createHostLog(); + const { errors, sink } = createRecordingSink(); + const registry = createCommandRegistry({ log, sink }); + + registry.registerLazy("demo.run", { extensionId: "demo.ext" }); + + const result = await registry.execute("demo.run", "arg"); + + expect(result).toBeUndefined(); + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toContain("demo.ext"); + expect(errors[0]?.message.toLowerCase()).toContain("not activated yet"); + expect(errors[0]?.extensionId).toBe("demo.ext"); + + const logged = log.entries(); + expect(logged).toHaveLength(1); + expect(logged[0]?.level).toBe("warning"); +}); + +test("register() over a lazy entry replaces it with a real handler that execute() then runs", async () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const registry = createCommandRegistry({ log, sink }); + + registry.registerLazy("demo.run", { extensionId: "demo.ext" }); + registry.register("demo.run", () => "activated!"); + + expect(await registry.execute("demo.run")).toBe("activated!"); +}); + +test("registerLazy rejects command IDs that are not namespace.verb form", () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const registry = createCommandRegistry({ log, sink }); + + expect(() => registry.registerLazy("save", { extensionId: "demo.ext" })).toThrow(TypeError); +}); + +test("registerLazy's Disposable removes the command, matching register()'s dispose semantics", async () => { + const log = createHostLog(); + const { errors, sink } = createRecordingSink(); + const registry = createCommandRegistry({ log, sink }); + + const registration = registry.registerLazy("demo.run", { extensionId: "demo.ext" }); + registration.dispose(); + + const result = await registry.execute("demo.run"); + expect(result).toBeUndefined(); + expect(errors.at(-1)?.message).toBe("Command not found: demo.run"); +}); + +test("registerLazy twice for the same ID logs a re-registration warning (last-wins)", async () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const registry = createCommandRegistry({ log, sink }); + + registry.registerLazy("demo.run", { extensionId: "first.ext" }); + registry.registerLazy("demo.run", { extensionId: "second.ext" }); + + const result = await registry.execute("demo.run"); + expect(result).toBeUndefined(); + + const warnings = log.entries().filter((e) => e.level === "warning"); + // One for the re-registration, one for the not-activated-yet report. + expect(warnings.some((w) => w.error.message.includes("re-registered"))).toBe(true); +}); diff --git a/packages/core/src/commands/registry.ts b/packages/core/src/commands/registry.ts index 006a217..5a76569 100644 --- a/packages/core/src/commands/registry.ts +++ b/packages/core/src/commands/registry.ts @@ -4,9 +4,14 @@ * bindings, the palette, UI callbacks, extension-to-extension calls — goes * through `execute` rather than a direct function call (Req 1.5). * - * Lazy (manifest-declared, not-yet-activated) commands are out of scope - * here — they arrive with the extension host task (design.md §4.1's - * "lazy commands"); `CommandEntry` therefore carries no `lazy` flag. + * Lazy (manifest-declared, not-yet-activated) commands (design.md §4.1's + * "lazy commands", §5's `CommandEntry = { handler?, meta, extensionId?, + * lazy }`) are registered via {@link CommandRegistry.registerLazy} by the + * extension host (`host/registration.ts`) with no `handler` yet — real + * activation (calling the owning extension's `activate(ctx)` on first + * execution) is Task 1.12; until then, executing a lazy command reports a + * "not activated yet" `HostError` through `log`/`sink` rather than + * throwing or silently no-op'ing. */ import type { @@ -17,10 +22,23 @@ import type { } from "@tecode/api"; import type { HostError, HostLog, StatusSink } from "../host/errors"; -/** Internal registry state for one registered command. */ +/** Internal registry state for one registered command (design.md §5). + * `handler` is absent for a lazy (manifest-declared, not-yet-activated) + * command; `extensionId` is set only for lazy entries — a plain + * `register()` call has no extension attribution. */ interface CommandEntry { - handler: CommandHandler; + handler?: CommandHandler; meta: CommandMeta; + extensionId?: string; + lazy: boolean; +} + +/** Options for {@link CommandRegistry.registerLazy}. */ +export interface RegisterLazyOptions { + /** The extension whose `index.ts` owns this command, activated on first + * `execute()` once Task 1.12 wires real activation. */ + extensionId: string; + meta?: CommandMeta; } /** Dependencies a {@link createCommandRegistry} instance reports through @@ -34,9 +52,21 @@ export interface CommandRegistryDeps { } /** The public shape of the command registry — the implementation behind - * `tecode.commands` (Req 10.1). */ + * `tecode.commands` (Req 10.1), plus `registerLazy` (design.md §4.1), + * which is host-internal (extensions never call it directly; the `tecode` + * API surface handed to extensions exposes only `register`). */ export interface CommandRegistry { register(id: string, handler: CommandHandler, meta?: CommandMeta): Disposable; + /** + * Register a command declared in a manifest's `contributes.commands` + * without a handler yet (design.md §4.1, §5): the command appears in + * {@link list} and can be looked up by keybindings/the palette + * immediately, but `execute`-ing it before the owning extension has + * activated reports a "not activated yet" error rather than running + * anything. Same last-wins/duplicate-warning/`Disposable` semantics as + * {@link register}. + */ + registerLazy(id: string, options: RegisterLazyOptions): Disposable; execute(id: string, ...args: unknown[]): Promise; list(): CommandDescriptor[]; } @@ -91,22 +121,18 @@ export function createCommandRegistry(deps: CommandRegistryDeps): CommandRegistr } } - function register( - id: string, - handler: CommandHandler, - meta: CommandMeta = {}, - ): Disposable { - if (!isValidCommandId(id)) { - throw new TypeError( - `Invalid command ID "${id}": expected namespace.verb form (Req 3.2)`, - ); - } + /** Shared last-wins storage behind both {@link register} and + * {@link registerLazy}: warns on an existing entry under `id`, stores + * `entry`, and returns the identity-checked `Disposable` common to both + * (mirrors the entry-identity comparison design.md §5 relies on so a + * stale handle from a superseded registration never removes a newer + * one). */ + function storeEntry(id: string, entry: CommandEntry): Disposable { if (commands.has(id)) { logSafely("warning", { message: `Command re-registered, replacing previous handler: ${id}`, }); } - const entry: CommandEntry = { handler, meta }; commands.set(id, entry); let disposed = false; @@ -124,6 +150,32 @@ export function createCommandRegistry(deps: CommandRegistryDeps): CommandRegistr }; } + function register( + id: string, + handler: CommandHandler, + meta: CommandMeta = {}, + ): Disposable { + if (!isValidCommandId(id)) { + throw new TypeError( + `Invalid command ID "${id}": expected namespace.verb form (Req 3.2)`, + ); + } + return storeEntry(id, { handler, meta, lazy: false }); + } + + function registerLazy(id: string, options: RegisterLazyOptions): Disposable { + if (!isValidCommandId(id)) { + throw new TypeError( + `Invalid command ID "${id}": expected namespace.verb form (Req 3.2)`, + ); + } + return storeEntry(id, { + meta: options.meta ?? {}, + extensionId: options.extensionId, + lazy: true, + }); + } + async function execute(id: string, ...args: unknown[]): Promise { const entry = commands.get(id); if (!entry) { @@ -131,6 +183,19 @@ export function createCommandRegistry(deps: CommandRegistryDeps): CommandRegistr notifySafely(err); return undefined; } + if (!entry.handler) { + // Lazy, not-yet-activated command (design.md §4.1) — real activation + // is Task 1.12; for now report and stop, never throw. + const err: HostError = { + message: + `Command "${id}" belongs to extension "${entry.extensionId ?? "unknown"}", ` + + `which has not activated yet`, + extensionId: entry.extensionId, + }; + logSafely("warning", err); + notifySafely(err); + return undefined; + } try { return await entry.handler(...args); } catch (cause: unknown) { @@ -152,5 +217,5 @@ export function createCommandRegistry(deps: CommandRegistryDeps): CommandRegistr })); } - return { register, execute, list }; + return { register, registerLazy, execute, list }; } diff --git a/packages/core/src/host/discovery.test.ts b/packages/core/src/host/discovery.test.ts new file mode 100644 index 0000000..376994a --- /dev/null +++ b/packages/core/src/host/discovery.test.ts @@ -0,0 +1,446 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir as nodeReaddir, + rm, + stat as nodeStat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { Manifest } from "@tecode/api"; +import type { HostError, HostLogEntry } from "./errors"; +import { createHostLog } from "./errors"; +import { discover, type DiscoveryFs } from "./discovery"; +import { getUserExtensionsDir } from "./paths"; + +/** Every temp dir created by a test, cleaned up afterward regardless of + * pass/fail (matches `documentManager.test.ts`'s `dir`/`afterEach` + * pattern, extended to a list since several tests need more than one + * root). */ +let tempDirs: string[] = []; + +async function makeTempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))); + tempDirs = []; +}); + +/** + * A {@link DiscoveryFs} backed by the real filesystem, EXCEPT the real + * user extensions directory (`getUserExtensionsDir()`), which is always + * reported as missing (ENOENT) unless remapped — so every test here is + * hermetic regardless of what (if anything) actually lives under the host + * machine's real `~/.config/tecode/extensions`, and independent of Bun's + * `os.homedir()`, which — unlike Node's — does not honor a runtime + * `$HOME` mutation (verified: `process.env.HOME = x; homedir()` still + * returns the process's original home directory under Bun), so the + * env-var-redirection trick `config/service.test.ts` uses for a similar + * purpose does not actually work here. + * + * `remap` lets one test substitute a real temp directory in place of one + * blocked/real path (e.g. standing in for the user extensions dir with a + * fixture the test controls), while every other path still hits the real + * filesystem untouched — needed because manifest loading (by default, and + * genuinely even under an injected `importModule`) performs a real dynamic + * `import()`, so a fully in-memory fake `fs` cannot exercise a successful + * load. + */ +function createHermeticFs(remap: ReadonlyMap = new Map()): DiscoveryFs { + const blockedUserDir = getUserExtensionsDir(); + + function resolve(path: string): string { + for (const [from, to] of remap) { + if (path === from) return to; + if (path.startsWith(from + sep)) return to + path.slice(from.length); + } + return path; + } + + return { + async readdir(path) { + if (path === blockedUserDir && !remap.has(blockedUserDir)) { + throw Object.assign(new Error("ENOENT (blocked for test hermeticity)"), { + code: "ENOENT", + }); + } + return nodeReaddir(resolve(path)); + }, + async stat(path) { + const stats = await nodeStat(resolve(path)); + return { isDirectory: () => stats.isDirectory() }; + }, + }; +} + +/** Write a real `//` fixture. Real files + * are required because manifest loading defaults to a real dynamic + * `import()` (Req 2.2, design.md §4.1) — the `DiscoveryFs` seam only + * covers directory scanning, and even a test that injects `importModule` + * still performs a genuine import of a real fixture file. */ +async function writeManifestFixture( + extensionsDir: string, + name: string, + manifestSource: string, + filename = "manifest.ts", +): Promise { + const extensionDir = join(extensionsDir, name); + await mkdir(extensionDir, { recursive: true }); + const path = join(extensionDir, filename); + await writeFile(path, manifestSource, "utf8"); + return extensionDir; +} + +/** A manifest literal as TypeScript source (`as const` only parses when + * Bun transpiles a `.ts` file — see {@link manifestLiteralJs} for the + * `.js`-safe form). */ +function manifestLiteral(id: string, overrides: Partial = {}): string { + const manifest: Manifest = { + id, + version: "1.0.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: {}, + ...overrides, + }; + return `export default ${JSON.stringify(manifest)} as const;\n`; +} + +/** A manifest literal as plain JS source (no `as const` — that is TS-only + * syntax and a real `.js` file is parsed as plain JS, not transpiled). */ +function manifestLiteralJs(id: string, overrides: Partial = {}): string { + const manifest: Manifest = { + id, + version: "1.0.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: {}, + ...overrides, + }; + return `export default ${JSON.stringify(manifest)};\n`; +} + +function warnings(entries: readonly HostLogEntry[]): HostError[] { + return entries.filter((e) => e.level === "warning").map((e) => e.error); +} + +function errorEntries(entries: readonly HostLogEntry[]): HostError[] { + return entries.filter((e) => e.level === "error").map((e) => e.error); +} + +describe("discover — no sources", () => { + test("returns [] when there are no builtins, no user extensions, and no workspace", async () => { + const log = createHostLog(); + + const result = await discover({ log, fs: createHermeticFs() }); + + expect(result).toEqual([]); + expect(log.entries()).toEqual([]); + }); +}); + +describe("discover — scan order and path wiring (Req 2.1)", () => { + test("scans builtin, then user, then workspace, using the documented directory helpers", async () => { + const workspace = await makeTempDir("tecode-discover-ws-"); + const calls: string[] = []; + const fs: DiscoveryFs = { + async readdir(path) { + calls.push(path); + return []; + }, + async stat() { + return { isDirectory: () => true }; + }, + }; + const log = createHostLog(); + + await discover({ log, fs, workspaceRoot: workspace, builtins: [] }); + + // Built-ins need no directory scan at all (they're passed in + // directly), so the only two readdir calls are user then workspace, + // in that order. + expect(calls).toEqual([ + getUserExtensionsDir(), + join(workspace, ".tecode", "extensions"), + ]); + }); +}); + +describe("discover — builtins", () => { + test("includes builtins passed in, with source 'builtin'", async () => { + const log = createHostLog(); + const builtin: Manifest = { + id: "builtin.demo", + version: "0.1.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: {}, + }; + + const result = await discover({ log, fs: createHermeticFs(), builtins: [builtin] }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ extensionId: "builtin.demo", source: "builtin" }); + expect(result[0]?.manifest).toEqual(builtin); + }); +}); + +describe("discover — workspace extensions", () => { + test("finds a real manifest.ts under /.tecode/extensions", async () => { + const workspace = await makeTempDir("tecode-discover-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + await writeManifestFixture(extensionsDir, "demo", manifestLiteral("workspace.demo")); + const log = createHostLog(); + + const result = await discover({ log, fs: createHermeticFs(), workspaceRoot: workspace }); + + expect(result).toHaveLength(1); + const found = result[0]; + expect(found?.extensionId).toBe("workspace.demo"); + expect(found?.source).toBe("workspace"); + expect(found?.sourcePath).toBe(join(extensionsDir, "demo", "manifest.ts")); + expect((found?.manifest as Manifest).version).toBe("1.0.0"); + }); + + test("accepts a named 'manifest' export as a fallback to 'export default'", async () => { + const workspace = await makeTempDir("tecode-discover-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + const manifest: Manifest = { + id: "named.export", + version: "1.0.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: {}, + }; + await writeManifestFixture( + extensionsDir, + "demo", + `export const manifest = ${JSON.stringify(manifest)} as const;\n`, + ); + const log = createHostLog(); + + const result = await discover({ log, fs: createHermeticFs(), workspaceRoot: workspace }); + + expect(result).toHaveLength(1); + expect(result[0]?.extensionId).toBe("named.export"); + }); + + test("falls back to manifest.js when manifest.ts is absent", async () => { + const workspace = await makeTempDir("tecode-discover-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + await writeManifestFixture(extensionsDir, "demo", manifestLiteralJs("js.demo"), "manifest.js"); + const log = createHostLog(); + + const result = await discover({ log, fs: createHermeticFs(), workspaceRoot: workspace }); + + expect(result).toHaveLength(1); + expect(result[0]?.extensionId).toBe("js.demo"); + expect(result[0]?.sourcePath.endsWith("manifest.js")).toBe(true); + }); + + test("skips a non-directory entry in the extensions dir without crashing", async () => { + const workspace = await makeTempDir("tecode-discover-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + await mkdir(extensionsDir, { recursive: true }); + await writeFile(join(extensionsDir, "README.md"), "not an extension", "utf8"); + const log = createHostLog(); + + const result = await discover({ log, fs: createHermeticFs(), workspaceRoot: workspace }); + + expect(result).toEqual([]); + }); + + test("skips (and logs) an extension directory with no manifest.ts or manifest.js", async () => { + const workspace = await makeTempDir("tecode-discover-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions", "empty"); + await mkdir(extensionsDir, { recursive: true }); + const log = createHostLog(); + + const result = await discover({ + log, + fs: createHermeticFs(), + workspaceRoot: join(workspace), + }); + + expect(result).toEqual([]); + const warned = warnings(log.entries()); + expect(warned.some((e) => e.message.includes("no manifest.ts or manifest.js"))).toBe(true); + }); + + test("skips (and logs) a manifest module that throws on import, but keeps scanning", async () => { + const workspace = await makeTempDir("tecode-discover-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + await writeManifestFixture(extensionsDir, "broken", 'throw new Error("boom");\n'); + await writeManifestFixture(extensionsDir, "fine", manifestLiteral("still.fine")); + const log = createHostLog(); + + const result = await discover({ log, fs: createHermeticFs(), workspaceRoot: workspace }); + + expect(result).toHaveLength(1); + expect(result[0]?.extensionId).toBe("still.fine"); + const errs = errorEntries(log.entries()); + expect(errs.some((e) => e.message.includes("boom") && e.path?.includes("broken"))).toBe(true); + }); + + test("skips (and logs) a manifest module with no default or named 'manifest' export", async () => { + const workspace = await makeTempDir("tecode-discover-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + await writeManifestFixture(extensionsDir, "nothing", "export const somethingElse = 1;\n"); + const log = createHostLog(); + + const result = await discover({ log, fs: createHermeticFs(), workspaceRoot: workspace }); + + expect(result).toEqual([]); + const errs = errorEntries(log.entries()); + expect(errs.some((e) => e.message.includes("no usable export"))).toBe(true); + }); + + test("never imports the extension's index.ts", async () => { + const workspace = await makeTempDir("tecode-discover-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + const extensionDir = await writeManifestFixture( + extensionsDir, + "demo", + manifestLiteral("proof.demo"), + ); + const markerPath = join(extensionDir, "MARKER"); + await writeFile( + join(extensionDir, "index.ts"), + `import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(markerPath)}, "imported");\n`, + "utf8", + ); + const log = createHostLog(); + + const result = await discover({ log, fs: createHermeticFs(), workspaceRoot: workspace }); + + expect(result).toHaveLength(1); + expect(await Bun.file(markerPath).exists()).toBe(false); + }); +}); + +describe("discover — duplicate IDs across sources", () => { + test("workspace shadows user shadows builtin (later wins), and every shadow logs a warning", async () => { + const workspace = await makeTempDir("tecode-discover-ws-"); + + // The "user" layer needs a manifest that scanning finds at the real + // `getUserExtensionsDir()` path but that actually lives in a temp + // fixture, so the test never writes to (or deletes from) the real + // user configuration directory. Scanning is redirected with + // `createHermeticFs`'s remap; loading is redirected with the + // `importModule` seam, whose injected loader applies the same + // path translation and then performs a genuine dynamic `import()` of + // the temp fixture — so a real "user"-source module load is still + // exercised end to end. + const realUserExtensionsDir = getUserExtensionsDir(); + const fakeUserExtensionsDir = await makeTempDir("tecode-discover-user-"); + await writeManifestFixture( + fakeUserExtensionsDir, + "dup", + manifestLiteral("dup.ext", { version: "2.0.0" }), + ); + + const workspaceExtensionsDir = join(workspace, ".tecode", "extensions"); + await writeManifestFixture( + workspaceExtensionsDir, + "dup", + manifestLiteral("dup.ext", { version: "3.0.0" }), + ); + + const builtin: Manifest = { + id: "dup.ext", + version: "1.0.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: {}, + }; + const log = createHostLog(); + + const remap = new Map([[realUserExtensionsDir, fakeUserExtensionsDir]]); + const realUserUrlPrefix = pathToFileURL(realUserExtensionsDir).href; + const fakeUserUrlPrefix = pathToFileURL(fakeUserExtensionsDir).href; + const result = await discover({ + log, + builtins: [builtin], + workspaceRoot: workspace, + fs: createHermeticFs(remap), + importModule: (fileUrl) => + import( + fileUrl.startsWith(realUserUrlPrefix) + ? fakeUserUrlPrefix + fileUrl.slice(realUserUrlPrefix.length) + : fileUrl + ), + }); + + expect(result).toHaveLength(1); + expect(result[0]?.source).toBe("workspace"); + expect((result[0]?.manifest as Manifest).version).toBe("3.0.0"); + + const warned = warnings(log.entries()); + const dupWarnings = warned.filter((e) => e.message.includes("dup.ext")); + expect(dupWarnings).toHaveLength(2); + expect(warned.some((e) => e.message.includes("shadows the version from builtin"))).toBe(true); + expect(warned.some((e) => e.message.includes("shadows the version from user"))).toBe(true); + }); +}); + +describe("discover — DiscoveryFs error handling (fake seam, no real dynamic import)", () => { + function fakeFs(overrides: Partial): DiscoveryFs { + return { + readdir: async () => [], + stat: async () => ({ isDirectory: () => true }), + ...overrides, + }; + } + + test("a readdir failure that isn't ENOENT is logged as a warning and yields no extensions from that source", async () => { + const log = createHostLog(); + const fs = fakeFs({ + readdir: async () => { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + }, + }); + + const result = await discover({ log, fs, workspaceRoot: "/does/not/matter" }); + + expect(result).toEqual([]); + const warned = warnings(log.entries()); + expect(warned.some((e) => e.message.includes("Failed to scan"))).toBe(true); + }); + + test("an ENOENT readdir failure is treated as an empty source, with no log entry", async () => { + const log = createHostLog(); + const fs = fakeFs({ + readdir: async () => { + throw Object.assign(new Error("no such file"), { code: "ENOENT" }); + }, + }); + + const result = await discover({ log, fs, workspaceRoot: "/does/not/matter" }); + + expect(result).toEqual([]); + expect(log.entries()).toEqual([]); + }); + + test("a stat failure on one entry is logged and that entry is skipped, without crashing", async () => { + const log = createHostLog(); + const fs = fakeFs({ + readdir: async () => ["broken-entry"], + stat: async () => { + throw new Error("stat exploded"); + }, + }); + + const result = await discover({ log, fs, workspaceRoot: "/does/not/matter" }); + + expect(result).toEqual([]); + const warned = warnings(log.entries()); + expect(warned.some((e) => e.message.includes("Could not inspect"))).toBe(true); + }); +}); diff --git a/packages/core/src/host/discovery.ts b/packages/core/src/host/discovery.ts new file mode 100644 index 0000000..3f5fc5a --- /dev/null +++ b/packages/core/src/host/discovery.ts @@ -0,0 +1,379 @@ +/** + * Extension discovery (Req 2.1, 2.2, design.md §4.1): scans, in order, the + * embedded built-ins, the user extensions directory, and (when a workspace + * is open) the workspace extensions directory, loading each discovered + * extension's `manifest.ts`/`manifest.js` without ever executing its + * `index.ts`. + * + * This module owns the ONE sanctioned dynamic-import call site in the + * codebase — see {@link importManifestModule}'s TSDoc — required because an + * extension's manifest path is only known once the filesystem has been + * scanned at runtime. + * + * **Trust boundary**: importing a `manifest.ts`/`manifest.js` module + * evaluates that file's top-level code in the host process — for the + * `workspace` source that means code committed to whatever repository the + * user opened, before any validation has run. The manifest convention + * (pure declarative data, enforced by `validate.ts` only *after* the + * import) constrains what a well-behaved manifest contains, not what a + * malicious one can execute. A workspace-trust gate (prompting before the + * workspace layer is scanned at all) is the intended mitigation and is + * deliberately out of this task's scope — it belongs to the CLI assembly + * layer, which decides whether to pass `workspaceRoot` to {@link discover} + * at all: omitting it skips the workspace layer entirely, so callers that + * cannot yet establish trust already have the lever to withhold it. + */ + +import { readdir as nodeReaddir, stat as nodeStat } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { Manifest } from "@tecode/api"; +import type { HostError, HostLog } from "./errors"; +import { getUserExtensionsDir, getWorkspaceExtensionsDir } from "./paths"; + +/** Where a discovered extension came from (Req 2.1, design.md §4.1): + * built-ins are compiled into the binary; `user`/`workspace` are scanned + * off disk. Duplicate IDs resolve later-wins in this same order — + * `workspace` shadows `user` shadows `builtin`. */ +export type ExtensionSource = "builtin" | "user" | "workspace"; + +/** + * The narrow filesystem seam {@link discover} needs: enumerating a + * directory's entries and telling directories from files. Exists as an + * injectable seam (defaulting to `node:fs/promises`) so tests can simulate + * scan failures deterministically, matching `DocumentManagerFs`/ + * `ConfigServiceFs`'s precedent. Not part of the public API surface. + * + * Note: this seam covers directory *scanning* only. Loading a manifest's + * module contents goes through {@link DiscoveryDeps.importModule} (by + * default the real dynamic `import()` — see {@link importManifestModule}). + */ +export interface DiscoveryFs { + readdir(path: string): Promise; + stat(path: string): Promise<{ isDirectory(): boolean }>; +} + +function createNodeDiscoveryFs(): DiscoveryFs { + return { + readdir: (path) => nodeReaddir(path), + stat: async (path) => { + const stats = await nodeStat(path); + return { isDirectory: () => stats.isDirectory() }; + }, + }; +} + +/** One extension found by {@link discover}, before manifest validation + * (Phase 2, `validate.ts`) — `manifest` is the raw, untyped default (or + * named `manifest`) export of its manifest module. */ +export interface DiscoveredExtension { + /** A best-effort ID used for shadowing/logging before validation: the + * raw manifest's `id` field when it is a non-empty string, otherwise the + * extension's directory name (or a synthetic `builtin-` for a + * built-in whose supplied `Manifest.id` is somehow not a string). The + * authoritative `id` comes from `validate.ts` once the manifest is + * confirmed well-formed. */ + extensionId: string; + /** The manifest module's raw export — not yet validated. */ + manifest: unknown; + /** Where this extension's manifest came from: the manifest file path for + * `user`/`workspace` extensions, or a synthetic `/` label + * for built-ins (which have no filesystem path — Req 2.1, design.md + * §4.4). Used only for error messages/attribution. */ + sourcePath: string; + source: ExtensionSource; +} + +/** Dependencies for {@link discover}. */ +export interface DiscoveryDeps { + /** Built-in extensions' manifests, compiled into the binary as ordinary + * imports (design.md §4.1, §4.4) rather than discovered off disk. + * Defaults to `[]` — `packages/builtin/*` are placeholders with no + * `manifest.ts` yet (deviation noted in this task's plan), so callers + * pass whatever static registry exists once built-ins gain manifests. */ + builtins?: Manifest[]; + /** The open workspace's root directory. The workspace extensions layer + * is only scanned when this is provided — a single-file session with no + * workspace has no third source. */ + workspaceRoot?: string; + /** Filesystem seam — see {@link DiscoveryFs}. Defaults to + * `node:fs/promises`. */ + fs?: DiscoveryFs; + /** Manifest-module loading seam. Defaults to the sanctioned real + * dynamic `import()` ({@link importManifestModule}); tests inject a + * loader so manifest loading can be exercised against fixture + * directories without ever writing to the real user extensions dir + * (`~/.config/tecode/extensions`). Production callers never pass this. */ + importModule?: (fileUrl: string) => Promise; + /** Structured log for scan failures, missing manifests, load failures, + * and duplicate-ID shadowing (design.md §14). */ + log: HostLog; +} + +/** Extract an errno-style `code` (e.g. `"ENOENT"`) from a caught unknown + * (matches `documentManager.ts`'s/`service.ts`'s `errorCode`). */ +function errorCode(err: unknown): string | undefined { + if (typeof err === "object" && err !== null && "code" in err) { + const code = (err as { code?: unknown }).code; + if (typeof code === "string") return code; + } + return undefined; +} + +/** Render a caught `unknown` value as a message string without risking a + * second throw (matches `registry.ts`'s/`documentManager.ts`'s + * `describeError`). */ +function describeError(err: unknown): string { + try { + if (err instanceof Error) return err.message; + return String(err); + } catch { + return "Unknown error"; + } +} + +/** Guarded `log.append` — an injected log must not be able to break + * discovery (matches `registry.ts`'s `logSafely`). */ +function logSafely(log: HostLog, level: "error" | "warning", err: HostError): void { + try { + log.append(level, err); + } catch { + // Swallowed: reporting a reporting failure has nowhere left to go. + } +} + +/** + * Best-effort ID for a not-yet-validated manifest export — see + * {@link DiscoveredExtension.extensionId}'s TSDoc. + */ +function extractTentativeId(raw: unknown, fallback: string): string { + if (raw && typeof raw === "object" && "id" in raw) { + const id = (raw as Record).id; + if (typeof id === "string" && id.length > 0) return id; + } + return fallback; +} + +/** + * Pull the manifest data out of a loaded manifest module. The documented + * convention (manifest.ts's own TSDoc, design.md §4.1) is `export default + * {...} satisfies Manifest`; a named `manifest` export is also accepted as + * a fallback so authors who prefer that form are not blocked, but `export + * default` remains the one true convention emitted by any future + * scaffolding/docs. + */ +function extractManifestExport(mod: unknown): unknown | undefined { + if (!mod || typeof mod !== "object") return undefined; + const record = mod as Record; + if ("default" in record && record.default !== undefined) return record.default; + if ("manifest" in record && record.manifest !== undefined) return record.manifest; + return undefined; +} + +async function pathExists(path: string, fs: DiscoveryFs): Promise { + try { + await fs.stat(path); + return true; + } catch { + return false; + } +} + +/** Resolve `manifest.ts` (preferred) or `manifest.js` inside an extension + * directory (design.md §4.1); `undefined` if neither exists. */ +async function resolveManifestPath( + extensionDir: string, + fs: DiscoveryFs, +): Promise { + const tsPath = join(extensionDir, "manifest.ts"); + if (await pathExists(tsPath, fs)) return tsPath; + const jsPath = join(extensionDir, "manifest.js"); + if (await pathExists(jsPath, fs)) return jsPath; + return undefined; +} + +/** + * Dynamically import a discovered extension's manifest module. + * + * **This is the ONE sanctioned exception to the repository-wide ban on + * dynamic `import()`** (Req 2.2, design.md §4.1). Loading `manifest.ts`/ + * `manifest.js` for a third-party extension requires it: the path is only + * known after scanning the filesystem at runtime, so no static `import` + * can name it ahead of time. This is narrowly confined to this single + * function: + * + * - Nothing else in `discovery.ts` (or anywhere else in `core`) performs a + * dynamic import. + * - The extension's `index.ts` (activation code) is never imported this + * way, or at all, by discovery/registration — only `manifest.ts`/`.js`, + * which is constrained by convention and validation (`validate.ts`) to + * be pure declarative data. + * - `fileUrl` always comes from {@link resolveManifestPath} inside this + * module — a `file://` URL built from a real path found on disk during + * this same scan, never external/untrusted input passed straight + * through from a caller. + */ +async function importManifestModule(fileUrl: string): Promise { + // NOTE on the repo's dynamic-import ban: eslint.config.mjs's + // `no-restricted-syntax` rule only matches a dynamic `import()` of the + // literal "@tecode/core" specifier (crossing the extension/core + // boundary) — `fileUrl` here is a runtime-computed `file://` URL to a + // manifest on disk, which that selector does not (and must not) match, + // so no `eslint-disable` is required or added. This call site remains + // the sole sanctioned dynamic import in the codebase by convention and + // code review, not by a lint rule carve-out: nowhere else in `core` + // dynamically imports anything, and this function is never called with + // an `index.ts` path (Req 2.2, design.md §4.1). + return import(fileUrl); +} + +/** Scan one extensions directory (`user` or `workspace`): each immediate + * subdirectory is one candidate extension (Req 2.1). A missing directory + * is not an error — an extensions dir that was never created yields no + * extensions from that source. Every other failure (a bad manifest, an + * unreadable subdirectory, a directory that fails to enumerate) is + * reported to `log` and that one extension (or the whole source) is + * skipped; `scanExtensionsDir` itself never throws. */ +async function scanExtensionsDir( + dir: string, + source: Exclude, + fs: DiscoveryFs, + log: HostLog, + importModule: (fileUrl: string) => Promise, +): Promise { + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch (cause) { + if (errorCode(cause) === "ENOENT") return []; + logSafely(log, "warning", { + message: `Failed to scan ${source} extensions directory (${dir}): ${describeError(cause)}`, + path: dir, + }); + return []; + } + + const results: DiscoveredExtension[] = []; + for (const name of entries) { + const extensionDir = join(dir, name); + + let stats: { isDirectory(): boolean }; + try { + stats = await fs.stat(extensionDir); + } catch (cause) { + logSafely(log, "warning", { + message: `Could not inspect ${extensionDir}: ${describeError(cause)}`, + path: extensionDir, + }); + continue; + } + if (!stats.isDirectory()) continue; + + const manifestPath = await resolveManifestPath(extensionDir, fs); + if (!manifestPath) { + logSafely(log, "warning", { + message: `Extension directory ${extensionDir} has no manifest.ts or manifest.js — skipped`, + path: extensionDir, + }); + continue; + } + + let mod: unknown; + try { + mod = await importModule(pathToFileURL(manifestPath).href); + } catch (cause) { + logSafely(log, "error", { + message: `Failed to load manifest at ${manifestPath}: ${describeError(cause)}`, + path: manifestPath, + }); + continue; + } + + const raw = extractManifestExport(mod); + if (raw === undefined) { + logSafely(log, "error", { + message: + `Manifest at ${manifestPath} has no usable export (expected ` + + `"export default {...} satisfies Manifest")`, + path: manifestPath, + }); + continue; + } + + results.push({ + extensionId: extractTentativeId(raw, name), + manifest: raw, + sourcePath: manifestPath, + source, + }); + } + return results; +} + +/** + * Discover extensions from all three sources, in precedence order — + * built-in, then user, then workspace (Req 2.1) — and resolve duplicate + * IDs later-wins, logging a warning for each one shadowed (design.md + * §4.1: "Duplicate extension IDs resolve by discovery order — later wins + * ... the shadowed one is reported as a warning"). + * + * Never throws: every failure along the way (an unreadable extensions + * directory, a subdirectory with no manifest, a manifest module that + * throws on import, a manifest with no usable export) is reported to + * `deps.log` and that one extension is skipped, so one bad extension can + * never block the rest of startup (Req 2.4). + */ +export async function discover(deps: DiscoveryDeps): Promise { + const { log } = deps; + const fs = deps.fs ?? createNodeDiscoveryFs(); + const importModule = deps.importModule ?? importManifestModule; + const byId = new Map(); + + function addAll(discovered: DiscoveredExtension[]): void { + for (const extension of discovered) { + const existing = byId.get(extension.extensionId); + if (existing) { + logSafely(log, "warning", { + extensionId: extension.extensionId, + message: + `Extension "${extension.extensionId}" from ${extension.source} ` + + `(${extension.sourcePath}) shadows the version from ${existing.source} ` + + `(${existing.sourcePath})`, + }); + } + byId.set(extension.extensionId, extension); + } + } + + const builtins = deps.builtins ?? []; + addAll( + builtins.map((manifest, index) => { + const extensionId = extractTentativeId(manifest, `builtin-${index}`); + return { + extensionId, + manifest, + sourcePath: `/${extensionId}`, + source: "builtin" as const, + }; + }), + ); + + addAll(await scanExtensionsDir(getUserExtensionsDir(), "user", fs, log, importModule)); + + if (deps.workspaceRoot) { + // Trust boundary (see the module TSDoc): scanning the workspace layer + // imports manifest modules committed to the opened repository. Callers + // that cannot establish workspace trust must omit `workspaceRoot`. + addAll( + await scanExtensionsDir( + getWorkspaceExtensionsDir(deps.workspaceRoot), + "workspace", + fs, + log, + importModule, + ), + ); + } + + return Array.from(byId.values()); +} diff --git a/packages/core/src/host/index.ts b/packages/core/src/host/index.ts index b04c20e..7efbea0 100644 --- a/packages/core/src/host/index.ts +++ b/packages/core/src/host/index.ts @@ -1,7 +1,8 @@ -// Extension host (discovery, manifest validation, activation) — the rest of -// design.md §4 lands in later tasks. For now this module exposes the shared +// Extension host (discovery, manifest validation, registration) — activation +// (design.md §4.2) lands in Task 1.12. This module exposes the shared // error/log infrastructure (§4.1) that both host loading and the command -// registry (§5) depend on. +// registry (§5) depend on, plus discovery/validation/registration +// themselves. export { createHostLog, createNoopStatusSink, @@ -14,11 +15,45 @@ export { export { getUserConfigDir, + getUserExtensionsDir, getUserKeybindingsPath, getUserSettingsPath, + getWorkspaceExtensionsDir, getWorkspaceSettingsPath, } from "./paths"; -/** Placeholder for the remaining extension-host behavior (discovery, - * manifest validation, activation) — see design.md §4. */ +export { + discover, + type DiscoveredExtension, + type DiscoveryDeps, + type DiscoveryFs, + type ExtensionSource, +} from "./discovery"; + +export { + checkApiVersionCompatibility, + validateManifest, + type ApiVersionCompatibility, + type ManifestValidationResult, +} from "./validate"; + +export { + loadExtensions, + registerExtension, + type ConfigRegistrar, + type LoadedExtension, + type LoadExtensionsDeps, + type LoadExtensionsResult, + type PendingLanguageContribution, + type PendingThemeContribution, + type PendingViewContribution, + type RegisterExtensionDeps, + type RegisterExtensionResult, + type SkippedExtension, +} from "./registration"; + +/** Kept for source compatibility with `core/index.test.ts`'s existing + * placeholder assertion; discovery/validation/registration above are the + * real Task 1.11 surface. Activation (design.md §4.2) is still Task + * 1.12 — remove this once that task's own exports make it redundant. */ export const HOST_PLACEHOLDER = true; diff --git a/packages/core/src/host/paths.test.ts b/packages/core/src/host/paths.test.ts index 4530a64..f7ffeb2 100644 --- a/packages/core/src/host/paths.test.ts +++ b/packages/core/src/host/paths.test.ts @@ -3,8 +3,10 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { getUserConfigDir, + getUserExtensionsDir, getUserKeybindingsPath, getUserSettingsPath, + getWorkspaceExtensionsDir, getWorkspaceSettingsPath, } from "./paths"; @@ -70,4 +72,14 @@ describe("derived file paths", () => { join("/home/user/project", ".tecode", "settings.json"), ); }); + + test("getUserExtensionsDir appends extensions to the config dir", () => { + expect(getUserExtensionsDir()).toBe(join(getUserConfigDir(), "extensions")); + }); + + test("getWorkspaceExtensionsDir appends .tecode/extensions to the workspace root", () => { + expect(getWorkspaceExtensionsDir("/home/user/project")).toBe( + join("/home/user/project", ".tecode", "extensions"), + ); + }); }); diff --git a/packages/core/src/host/paths.ts b/packages/core/src/host/paths.ts index 0d9136d..f6a80ab 100644 --- a/packages/core/src/host/paths.ts +++ b/packages/core/src/host/paths.ts @@ -41,3 +41,27 @@ export function getUserKeybindingsPath(): string { export function getWorkspaceSettingsPath(workspaceRoot: string): string { return join(workspaceRoot, ".tecode", "settings.json"); } + +/** + * The user-level extensions directory, scanned second (after built-ins) + * during discovery (Req 2.1, design.md §4.1): `~/.config/tecode/extensions` + * (or the Windows equivalent under {@link getUserConfigDir}). Each + * immediate subdirectory is one extension. + * + * **Deviation from the original plan**: the plan mentioned `XDG_CONFIG_HOME` + * for this path, but `paths.ts` deliberately does not branch on that env + * var anywhere else (see the module TSDoc) — this helper stays consistent + * with the existing homedir-based resolution rather than introducing new + * OS-conventions handling for extensions alone. + */ +export function getUserExtensionsDir(): string { + return join(getUserConfigDir(), "extensions"); +} + +/** The workspace-level extensions directory, scanned last (highest + * precedence) during discovery (Req 2.1, design.md §4.1): + * `/.tecode/extensions`. `workspaceRoot` is the workspace's + * root directory (an absolute path). */ +export function getWorkspaceExtensionsDir(workspaceRoot: string): string { + return join(workspaceRoot, ".tecode", "extensions"); +} diff --git a/packages/core/src/host/registration.test.ts b/packages/core/src/host/registration.test.ts new file mode 100644 index 0000000..58f5be6 --- /dev/null +++ b/packages/core/src/host/registration.test.ts @@ -0,0 +1,429 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir as nodeReaddir, + rm, + stat as nodeStat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ConfigurationContribution, Disposable, Manifest } from "@tecode/api"; +import { createCommandRegistry } from "../commands/registry"; +import type { DiscoveryFs } from "./discovery"; +import { createHostLog, type HostError } from "./errors"; +import { getUserExtensionsDir } from "./paths"; +import { + loadExtensions, + registerExtension, + type ConfigRegistrar, +} from "./registration"; + +/** A `StatusSink` stub that records every error it receives (matches + * `commands/registry.test.ts`'s `createRecordingSink`). */ +function createRecordingSink() { + const errors: HostError[] = []; + return { + errors, + sink: { + error(err: HostError) { + errors.push(err); + }, + }, + }; +} + +/** A minimal, real, in-memory {@link ConfigRegistrar}: just enough of + * `ConfigService.registerConfiguration`'s contract (Task 1.10) to prove + * `registerExtension`/`loadExtensions` call it correctly — records every + * property registered and every `dispose()`. */ +function createRecordingConfigRegistrar(): ConfigRegistrar & { + registered: ConfigurationContribution[]; + disposedCount: number; +} { + const registered: ConfigurationContribution[] = []; + let disposedCount = 0; + return { + registered, + get disposedCount() { + return disposedCount; + }, + registerConfiguration(contribution) { + registered.push(contribution); + let disposed = false; + return { + dispose() { + if (disposed) return; + disposed = true; + disposedCount += 1; + }, + }; + }, + }; +} + +function fullManifest(overrides: Partial = {}): Manifest { + return { + id: "demo.ext", + version: "1.0.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: { + commands: [{ id: "demo.run", title: "Run Demo" }], + keybindings: [{ key: "ctrl+shift+r", command: "demo.run" }], + views: [{ id: "demo.view", title: "Demo", slot: "sidebar" }], + languages: [ + { id: "demo-lang", extensions: [".demo"], grammar: "g.wasm", highlights: "h.scm" }, + ], + themes: [{ id: "demo-theme", label: "Demo Theme", path: "theme.json" }], + configuration: { properties: { "demo.enabled": { type: "boolean", default: true } } }, + }, + ...overrides, + }; +} + +describe("registerExtension", () => { + test("registers each contributed command as lazy, attributed to the extension", async () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + + registerExtension("demo.ext", fullManifest(), { commands, log }); + + expect(commands.list()).toEqual([ + { id: "demo.run", title: "Run Demo", category: undefined, when: undefined }, + ]); + const result = await commands.execute("demo.run"); + expect(result).toBeUndefined(); + const warnings = log.entries().filter((e) => e.level === "warning"); + expect(warnings.some((w) => w.error.message.includes("demo.ext"))).toBe(true); + }); + + test("returns keybindings unchanged, for the caller to accumulate into KeymapLayers.extension", () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + + const result = registerExtension("demo.ext", fullManifest(), { commands, log }); + + expect(result.keybindings).toEqual([{ key: "ctrl+shift+r", command: "demo.run" }]); + }); + + test("collects views/languages/themes attributed to the extension", () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + + const result = registerExtension("demo.ext", fullManifest(), { commands, log }); + + expect(result.views).toEqual([ + { extensionId: "demo.ext", view: { id: "demo.view", title: "Demo", slot: "sidebar" } }, + ]); + expect(result.languages).toEqual([ + { + extensionId: "demo.ext", + language: { id: "demo-lang", extensions: [".demo"], grammar: "g.wasm", highlights: "h.scm" }, + }, + ]); + expect(result.themes).toEqual([ + { extensionId: "demo.ext", theme: { id: "demo-theme", label: "Demo Theme", path: "theme.json" } }, + ]); + }); + + test("calls the injected configRegistrar for contributes.configuration", () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + const configRegistrar = createRecordingConfigRegistrar(); + + const result = registerExtension("demo.ext", fullManifest(), { + commands, + log, + configRegistrar, + }); + + expect(configRegistrar.registered).toEqual([ + { properties: { "demo.enabled": { type: "boolean", default: true } } }, + ]); + expect(result.disposables.length).toBeGreaterThanOrEqual(2); // 1 command + 1 config + }); + + test("omitting configRegistrar simply skips configuration registration (not an error)", () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + + const result = registerExtension("demo.ext", fullManifest(), { commands, log }); + + expect(log.entries()).toEqual([]); + expect(result.disposables).toHaveLength(1); // just the command + }); + + test("a throwing configRegistrar is caught and logged; commands still register", () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + const configRegistrar: ConfigRegistrar = { + registerConfiguration(): Disposable { + throw new Error("registrar exploded"); + }, + }; + + registerExtension("demo.ext", fullManifest(), { + commands, + log, + configRegistrar, + }); + + expect(commands.list()).toHaveLength(1); + const warnings = log.entries().filter((e) => e.level === "warning"); + expect(warnings.some((w) => w.error.message.includes("registrar exploded"))).toBe(true); + }); + + test("a manifest with no contributions registers nothing and returns empty collections", () => { + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + const manifest: Manifest = { + id: "empty.ext", + version: "1.0.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: {}, + }; + + const result = registerExtension("empty.ext", manifest, { commands, log }); + + expect(result).toEqual({ + disposables: [], + keybindings: [], + views: [], + languages: [], + themes: [], + }); + }); +}); + +// --- loadExtensions: full discover -> validate -> register pipeline -------- + +let tempDirs: string[] = []; + +async function makeTempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))); + tempDirs = []; +}); + +/** Same hermetic-fs technique as `discovery.test.ts` — see its TSDoc for + * why a real `$HOME` mutation does not work under Bun here. Blocks the + * real user extensions directory (ENOENT) so every `loadExtensions` test + * is independent of whatever (if anything) is really there. */ +function createHermeticFs(): DiscoveryFs { + const blockedUserDir = getUserExtensionsDir(); + return { + async readdir(path) { + if (path === blockedUserDir) { + throw Object.assign(new Error("ENOENT (blocked for test hermeticity)"), { + code: "ENOENT", + }); + } + return nodeReaddir(path); + }, + async stat(path) { + const stats = await nodeStat(path); + return { isDirectory: () => stats.isDirectory() }; + }, + }; +} + +async function writeManifestFixture( + extensionsDir: string, + name: string, + manifestSource: string, +): Promise { + const extensionDir = join(extensionsDir, name); + await mkdir(extensionDir, { recursive: true }); + await writeFile(join(extensionDir, "manifest.ts"), manifestSource, "utf8"); + return extensionDir; +} + +function manifestLiteral(manifest: Manifest): string { + return `export default ${JSON.stringify(manifest)} as const;\n`; +} + +describe("loadExtensions", () => { + test("loads a real, valid workspace extension end to end: registered command, keybindings, config, pending contributions", async () => { + const workspace = await makeTempDir("tecode-load-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + await writeManifestFixture(extensionsDir, "demo", manifestLiteral(fullManifest())); + + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + const configRegistrar = createRecordingConfigRegistrar(); + + const result = await loadExtensions({ + log, + sink, + commands, + configRegistrar, + workspaceRoot: workspace, + fs: createHermeticFs(), + }); + + expect(result.loaded).toHaveLength(1); + expect(result.loaded[0]?.extensionId).toBe("demo.ext"); + expect(result.loaded[0]?.source).toBe("workspace"); + expect(result.skipped).toEqual([]); + + expect(commands.list().map((c) => c.id)).toEqual(["demo.run"]); + expect(result.extensionKeybindings).toEqual([{ key: "ctrl+shift+r", command: "demo.run" }]); + expect(result.pendingViews).toHaveLength(1); + expect(result.pendingLanguages).toHaveLength(1); + expect(result.pendingThemes).toHaveLength(1); + expect(configRegistrar.registered).toHaveLength(1); + expect(result.disposables.length).toBeGreaterThanOrEqual(2); + }); + + test("an invalid manifest is skipped with a reason, and does not block other extensions from loading (Req 2.4)", async () => { + const workspace = await makeTempDir("tecode-load-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + await writeManifestFixture( + extensionsDir, + "broken", + "export default { id: \"\" } as const;\n", + ); + await writeManifestFixture( + extensionsDir, + "fine", + manifestLiteral({ + id: "still.fine", + version: "1.0.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: {}, + }), + ); + + const log = createHostLog(); + const { sink, errors } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + + const result = await loadExtensions({ + log, + sink, + commands, + workspaceRoot: workspace, + fs: createHermeticFs(), + }); + + expect(result.loaded.map((e) => e.extensionId)).toEqual(["still.fine"]); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]?.sourcePath).toContain("broken"); + expect(result.skipped[0]?.reason).toContain("id:"); + expect(errors.some((e) => e.path?.includes("broken"))).toBe(true); + }); + + test("an API-version-incompatible manifest is skipped with a reason, not a crash (Req 2.7)", async () => { + const workspace = await makeTempDir("tecode-load-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + await writeManifestFixture( + extensionsDir, + "future", + manifestLiteral({ + id: "future.ext", + version: "1.0.0", + apiVersion: "99.0", + activationEvents: ["onStartup"], + contributes: {}, + }), + ); + + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + + const result = await loadExtensions({ + log, + sink, + commands, + workspaceRoot: workspace, + fs: createHermeticFs(), + }); + + expect(result.loaded).toEqual([]); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]?.extensionId).toBe("future.ext"); + expect(result.skipped[0]?.reason).toContain("major version mismatch"); + }); + + test("never imports any extension's index.ts across a full loadExtensions run", async () => { + const workspace = await makeTempDir("tecode-load-ws-"); + const extensionsDir = join(workspace, ".tecode", "extensions"); + const extensionDir = await writeManifestFixture( + extensionsDir, + "demo", + manifestLiteral({ + id: "proof.demo", + version: "1.0.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: {}, + }), + ); + const markerPath = join(extensionDir, "MARKER"); + await writeFile( + join(extensionDir, "index.ts"), + `import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(markerPath)}, "imported");\n`, + "utf8", + ); + + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + + const result = await loadExtensions({ + log, + sink, + commands, + workspaceRoot: workspace, + fs: createHermeticFs(), + }); + + expect(result.loaded).toHaveLength(1); + expect(await Bun.file(markerPath).exists()).toBe(false); + }); + + test("builtins are loaded the same way as user/workspace extensions", async () => { + const workspace = await makeTempDir("tecode-load-ws-"); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + const builtin: Manifest = { + id: "builtin.demo", + version: "0.1.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: { commands: [{ id: "builtin.run", title: "Run" }] }, + }; + + const result = await loadExtensions({ + log, + sink, + commands, + builtins: [builtin], + workspaceRoot: workspace, + fs: createHermeticFs(), + }); + + expect(result.loaded).toHaveLength(1); + expect(result.loaded[0]?.source).toBe("builtin"); + expect(commands.list().map((c) => c.id)).toEqual(["builtin.run"]); + }); +}); diff --git a/packages/core/src/host/registration.ts b/packages/core/src/host/registration.ts new file mode 100644 index 0000000..1288b2a --- /dev/null +++ b/packages/core/src/host/registration.ts @@ -0,0 +1,329 @@ +/** + * Extension registration (Req 2.1-2.4, 2.7, design.md §4.1): walks one + * validated manifest's `contributes` and pushes its declarations into the + * command registry (as *lazy* commands), a keybindings accumulator, the + * config schema registry, and per-extension collections of raw + * views/languages/themes declarations for later tasks (1.14, 2.8, 2.6) to + * consume — all without ever touching `index.ts`. + * + * {@link loadExtensions} is the orchestration entry point: discover + * (`discovery.ts`) → validate + check API-version compatibility + * (`validate.ts`) → register, for every source in one call. Like every + * other service boundary in `core`, it never throws — a bad extension is + * skipped and reported, startup continues (Req 2.4). + */ + +import type { + ConfigurationContribution, + Disposable, + KeybindingContribution, + LanguageContribution, + Manifest, + ThemeContribution, + ViewContribution, +} from "@tecode/api"; +import type { CommandRegistry } from "../commands/registry"; +import type { DiscoveredExtension, DiscoveryFs, ExtensionSource } from "./discovery"; +import { discover } from "./discovery"; +import type { HostError, HostLog, StatusSink } from "./errors"; +import { checkApiVersionCompatibility, validateManifest } from "./validate"; + +/** The narrow slice of {@link ConfigService} registration needs — "the + * config schema registry" from Task 1.10, satisfied by `ConfigService` + * itself without registration.ts depending on the whole service. */ +export interface ConfigRegistrar { + registerConfiguration(contribution: ConfigurationContribution): Disposable; +} + +/** A `contributes.views` entry, attributed to the extension that declared + * it — collected here for the slot registry (Task 1.14) to consume. */ +export interface PendingViewContribution { + extensionId: string; + view: ViewContribution; +} + +/** A `contributes.languages` entry, attributed to the extension that + * declared it — collected here for the language registry (Task 2.8). */ +export interface PendingLanguageContribution { + extensionId: string; + language: LanguageContribution; +} + +/** A `contributes.themes` entry, attributed to the extension that declared + * it — collected here for the theme registry (Task 2.6). */ +export interface PendingThemeContribution { + extensionId: string; + theme: ThemeContribution; +} + +/** What registering one extension's `contributes` block produced. */ +export interface RegisterExtensionResult { + /** Disposables for every lazy command registered (commands) and every + * configuration schema registered — the caller owns disposing these on + * extension unload/reload (a later task). */ + disposables: Disposable[]; + /** This extension's `contributes.keybindings`, unchanged — the caller + * accumulates these across extensions into `KeymapLayers.extension` + * (`bindingTable.ts` has no incremental API, so building the full + * 4-layer table is the caller's job, not registration.ts's). */ + keybindings: KeybindingContribution[]; + views: PendingViewContribution[]; + languages: PendingLanguageContribution[]; + themes: PendingThemeContribution[]; +} + +/** Dependencies {@link registerExtension} needs to push contributions into + * the right places. */ +export interface RegisterExtensionDeps { + commands: CommandRegistry; + /** The config schema registry (Task 1.10's `ConfigService`). Omitted + * when no config service is wired yet — a manifest's + * `contributes.configuration` is then simply not registered (and not + * reported as an error; that's a caller wiring choice, not a manifest + * problem). */ + configRegistrar?: ConfigRegistrar; + log: HostLog; +} + +/** Render a caught `unknown` value as a message string without risking a + * second throw (matches `discovery.ts`'s/`registry.ts`'s + * `describeError`). */ +function describeError(err: unknown): string { + try { + if (err instanceof Error) return err.message; + return String(err); + } catch { + return "Unknown error"; + } +} + +/** Guarded `log.append` (matches `discovery.ts`'s/`registry.ts`'s + * `logSafely`). */ +function logSafely(log: HostLog, level: "error" | "warning", err: HostError): void { + try { + log.append(level, err); + } catch { + // Swallowed: reporting a reporting failure has nowhere left to go. + } +} + +/** + * Register one already-validated manifest's `contributes` block + * (design.md §4.1). Never throws: a single bad contribution (e.g. a + * `configRegistrar` that throws) is logged and skipped, the rest of the + * manifest's contributions still register. + */ +export function registerExtension( + extensionId: string, + manifest: Manifest, + deps: RegisterExtensionDeps, +): RegisterExtensionResult { + const { commands, configRegistrar, log } = deps; + const disposables: Disposable[] = []; + const contributes = manifest.contributes; + + for (const command of contributes.commands ?? []) { + try { + disposables.push( + commands.registerLazy(command.id, { + extensionId, + meta: { title: command.title, category: command.category, when: command.when }, + }), + ); + } catch (cause) { + logSafely(log, "warning", { + extensionId, + message: `Failed to register command "${command.id}": ${describeError(cause)}`, + }); + } + } + + if (contributes.configuration && configRegistrar) { + try { + disposables.push(configRegistrar.registerConfiguration(contributes.configuration)); + } catch (cause) { + logSafely(log, "warning", { + extensionId, + message: `Failed to register configuration: ${describeError(cause)}`, + }); + } + } + + const views: PendingViewContribution[] = (contributes.views ?? []).map((view) => ({ + extensionId, + view, + })); + const languages: PendingLanguageContribution[] = (contributes.languages ?? []).map( + (language) => ({ extensionId, language }), + ); + const themes: PendingThemeContribution[] = (contributes.themes ?? []).map((theme) => ({ + extensionId, + theme, + })); + + return { + disposables, + keybindings: contributes.keybindings ?? [], + views, + languages, + themes, + }; +} + +/** One extension successfully discovered, validated, version-checked, and + * registered. */ +export interface LoadedExtension { + extensionId: string; + manifest: Manifest; + source: ExtensionSource; + sourcePath: string; +} + +/** One extension that was discovered but not loaded, and why — manifest + * validation failure or API-version incompatibility (Req 2.4, 2.7). */ +export interface SkippedExtension { + extensionId: string; + sourcePath: string; + source: ExtensionSource; + reason: string; +} + +/** Dependencies for {@link loadExtensions}. */ +export interface LoadExtensionsDeps { + log: HostLog; + sink: StatusSink; + commands: CommandRegistry; + configRegistrar?: ConfigRegistrar; + /** Built-in extensions' manifests, passed straight through to + * `discover()` — see `discovery.ts`'s `DiscoveryDeps.builtins`. */ + builtins?: Manifest[]; + workspaceRoot?: string; + fs?: DiscoveryFs; +} + +/** What {@link loadExtensions} produced across every discovered + * extension. */ +export interface LoadExtensionsResult { + loaded: LoadedExtension[]; + skipped: SkippedExtension[]; + /** Every loaded extension's `contributes.keybindings`, concatenated in + * discovery order — feed this straight in as `KeymapLayers.extension`. */ + extensionKeybindings: KeybindingContribution[]; + pendingViews: PendingViewContribution[]; + pendingLanguages: PendingLanguageContribution[]; + pendingThemes: PendingThemeContribution[]; + /** Every command/configuration `Disposable` produced across every loaded + * extension, for the caller to dispose on unload/reload. */ + disposables: Disposable[]; +} + +/** Guarded `sink.error` (matches `discovery.ts`'s/`registry.ts`'s + * `notifySafely`). */ +function notifySafely(sink: StatusSink, err: HostError): void { + try { + sink.error(err); + } catch { + // Swallowed — see logSafely. + } +} + +function reportSkip( + deps: Pick, + extension: DiscoveredExtension, + reason: string, +): SkippedExtension { + const err: HostError = { + extensionId: extension.extensionId, + path: extension.sourcePath, + message: `Extension "${extension.extensionId}" (${extension.sourcePath}) skipped: ${reason}`, + }; + logSafely(deps.log, "error", err); + notifySafely(deps.sink, err); + return { + extensionId: extension.extensionId, + sourcePath: extension.sourcePath, + source: extension.source, + reason, + }; +} + +/** + * Discover, validate, version-check, and register every extension from + * every source (Req 2.1-2.4, 2.7, design.md §4.1, §4.3). Never throws: any + * failure at any stage for any one extension is reported through + * `deps.log`/`deps.sink` and that extension is skipped — the rest of + * startup, and every other extension, proceeds regardless. + */ +export async function loadExtensions(deps: LoadExtensionsDeps): Promise { + const discovered = await discover({ + builtins: deps.builtins, + workspaceRoot: deps.workspaceRoot, + fs: deps.fs, + log: deps.log, + }); + + const loaded: LoadedExtension[] = []; + const skipped: SkippedExtension[] = []; + const extensionKeybindings: KeybindingContribution[] = []; + const pendingViews: PendingViewContribution[] = []; + const pendingLanguages: PendingLanguageContribution[] = []; + const pendingThemes: PendingThemeContribution[] = []; + const disposables: Disposable[] = []; + + for (const extension of discovered) { + let validation: ReturnType; + try { + validation = validateManifest(extension.manifest); + } catch (cause) { + skipped.push(reportSkip(deps, extension, `validator threw: ${describeError(cause)}`)); + continue; + } + if (!validation.valid) { + skipped.push(reportSkip(deps, extension, validation.errors.join("; "))); + continue; + } + + const { manifest } = validation; + const compatibility = checkApiVersionCompatibility(manifest.apiVersion); + if (!compatibility.compatible) { + skipped.push( + reportSkip(deps, extension, compatibility.reason ?? "incompatible apiVersion"), + ); + continue; + } + + let result: RegisterExtensionResult; + try { + result = registerExtension(manifest.id, manifest, { + commands: deps.commands, + configRegistrar: deps.configRegistrar, + log: deps.log, + }); + } catch (cause) { + skipped.push(reportSkip(deps, extension, `registration threw: ${describeError(cause)}`)); + continue; + } + + disposables.push(...result.disposables); + extensionKeybindings.push(...result.keybindings); + pendingViews.push(...result.views); + pendingLanguages.push(...result.languages); + pendingThemes.push(...result.themes); + loaded.push({ + extensionId: manifest.id, + manifest, + source: extension.source, + sourcePath: extension.sourcePath, + }); + } + + return { + loaded, + skipped, + extensionKeybindings, + pendingViews, + pendingLanguages, + pendingThemes, + disposables, + }; +} diff --git a/packages/core/src/host/validate.test.ts b/packages/core/src/host/validate.test.ts new file mode 100644 index 0000000..de8295e --- /dev/null +++ b/packages/core/src/host/validate.test.ts @@ -0,0 +1,548 @@ +import { describe, expect, test } from "bun:test"; +import type { Manifest } from "@tecode/api"; +import { checkApiVersionCompatibility, validateManifest } from "./validate"; + +/** A minimal, fully valid manifest to mutate/extend per test. */ +function validManifest(overrides: Record = {}): Record { + return { + id: "demo.ext", + version: "1.0.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: {}, + ...overrides, + }; +} + +describe("validateManifest — top-level shape", () => { + test("accepts a minimal valid manifest", () => { + const result = validateManifest(validManifest()); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.manifest).toEqual({ + id: "demo.ext", + version: "1.0.0", + apiVersion: "1.0", + activationEvents: ["onStartup"], + contributes: {}, + }); + } + }); + + test("rejects a non-object export", () => { + for (const raw of [undefined, null, "a string", 42, ["array"]]) { + const result = validateManifest(raw); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors[0]).toContain("must export a default object"); + } + } + }); + + test("reports missing id, version, apiVersion, activationEvents, and contributes together", () => { + const result = validateManifest({}); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors).toContain("id: required non-empty string"); + expect(result.errors).toContain("version: required non-empty string"); + expect( + result.errors.some((e) => e.startsWith("apiVersion:")), + ).toBe(true); + expect(result.errors).toContain("activationEvents: required array"); + expect(result.errors).toContain("contributes: required object (may be empty {})"); + } + }); + + test("rejects a non-string id", () => { + const result = validateManifest(validManifest({ id: 42 })); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.errors).toContain("id: required non-empty string"); + }); + + test('rejects a non-SemVer version ("not-semver")', () => { + const result = validateManifest(validManifest({ version: "not-semver" })); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.startsWith("version: must be a SemVer"))).toBe(true); + } + }); + + test('rejects an incomplete version ("1.0" — SemVer needs all three parts)', () => { + const result = validateManifest(validManifest({ version: "1.0" })); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.startsWith("version: must be a SemVer"))).toBe(true); + } + }); + + test("accepts SemVer versions with pre-release and build parts", () => { + expect(validateManifest(validManifest({ version: "1.2.3-beta.1" })).valid).toBe(true); + expect(validateManifest(validManifest({ version: "1.2.3+build.5" })).valid).toBe(true); + }); + + test("rejects a sparse activationEvents array (holes are invalid entries, not skipped)", () => { + const sparse: unknown[] = ["onStartup"]; + sparse.length = 3; + sparse[2] = "onCommand:demo.run"; + const result = validateManifest(validManifest({ activationEvents: sparse })); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.startsWith("activationEvents[1]:"))).toBe(true); + } + }); + + test("rejects a sparse contributes.commands array (holes reported by index)", () => { + const sparse: unknown[] = []; + sparse.length = 1; + const result = validateManifest(validManifest({ contributes: { commands: sparse } })); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.startsWith("contributes.commands[0]:"))).toBe(true); + } + }); + + test("an activation event whose toJSON and string conversion both throw is still reported, never thrown", () => { + const hostile = { + toJSON() { + throw new Error("json failure"); + }, + [Symbol.toPrimitive]() { + throw new Error("string failure"); + }, + }; + let result: ReturnType | undefined; + expect(() => { + result = validateManifest(validManifest({ activationEvents: [hostile] })); + }).not.toThrow(); + expect(result?.valid).toBe(false); + if (result && !result.valid) { + expect( + result.errors.some( + (e) => e.startsWith("activationEvents[0]:") && e.includes(""), + ), + ).toBe(true); + } + }); + + test("a BigInt activation event is reported as an error, never thrown", () => { + let result: ReturnType | undefined; + expect(() => { + result = validateManifest(validManifest({ activationEvents: [1n] })); + }).not.toThrow(); + expect(result?.valid).toBe(false); + if (result && !result.valid) { + expect(result.errors.some((e) => e.startsWith("activationEvents[0]:"))).toBe(true); + } + }); + + test("rejects a sparse languages[].extensions array instead of passing holes through", () => { + const sparseExtensions: unknown[] = [".ts"]; + sparseExtensions.length = 2; + const result = validateManifest( + validManifest({ + contributes: { + languages: [ + { + id: "demo", + extensions: sparseExtensions, + grammar: "g", + highlights: "h", + }, + ], + }, + }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect( + result.errors.some((e) => e.startsWith("contributes.languages[0].extensions:")), + ).toBe(true); + } + }); + + test("rejects an empty-string id", () => { + const result = validateManifest(validManifest({ id: "" })); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.errors).toContain("id: required non-empty string"); + }); + + test("rejects a malformed apiVersion", () => { + for (const apiVersion of ["v1", "1.0.0", "one", ""]) { + const result = validateManifest(validManifest({ apiVersion })); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.startsWith("apiVersion:"))).toBe(true); + } + } + }); + + test("accepts apiVersion as '' or '.'", () => { + for (const apiVersion of ["1", "1.0", "2.10"]) { + const result = validateManifest(validManifest({ apiVersion })); + expect(result.valid).toBe(true); + } + }); + + test("rejects activationEvents entries that don't match onStartup/onCommand:/onLanguage:", () => { + const result = validateManifest( + validManifest({ activationEvents: ["onStartup", "onFoo:bar", 42] }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e === 'activationEvents[1]: must be "onStartup", "onCommand:", or "onLanguage:" (got "onFoo:bar")')).toBe(true); + expect(result.errors.some((e) => e.startsWith("activationEvents[2]:"))).toBe(true); + } + }); + + test("accepts every documented activationEvent form", () => { + const result = validateManifest( + validManifest({ + activationEvents: ["onStartup", "onCommand:editor.action.save", "onLanguage:typescript"], + }), + ); + expect(result.valid).toBe(true); + }); + + test("rejects a non-object contributes", () => { + const result = validateManifest(validManifest({ contributes: "nope" })); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.errors).toContain("contributes: must be an object"); + }); +}); + +describe("validateManifest — contributes.commands", () => { + test("requires id (namespace.verb) and title, with exact field-path messages", () => { + const result = validateManifest( + validManifest({ + contributes: { commands: [{ title: "" }, { id: "not-namespaced", title: "Ok" }] }, + }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors).toContain("contributes.commands[0].id: required non-empty string"); + expect(result.errors).toContain("contributes.commands[0].title: required non-empty string"); + expect(result.errors).toContain( + 'contributes.commands[1].id: must be namespace.verb form (e.g. "editor.action.deleteLine")', + ); + } + }); + + test("accepts a well-formed command contribution, with optional category/when", () => { + const result = validateManifest( + validManifest({ + contributes: { + commands: [ + { id: "editor.action.deleteLine", title: "Delete Line", category: "Editor", when: "editorTextFocus" }, + ], + }, + }), + ); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.manifest.contributes.commands).toEqual([ + { id: "editor.action.deleteLine", title: "Delete Line", category: "Editor", when: "editorTextFocus" }, + ]); + } + }); + + test("rejects a non-array contributes.commands", () => { + const result = validateManifest(validManifest({ contributes: { commands: {} } })); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.errors).toContain("contributes.commands: must be an array"); + }); +}); + +describe("validateManifest — contributes.keybindings", () => { + test("requires key and command", () => { + const result = validateManifest( + validManifest({ contributes: { keybindings: [{ key: "" }] } }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors).toContain("contributes.keybindings[0].key: required non-empty string"); + expect(result.errors).toContain( + "contributes.keybindings[0].command: required non-empty string", + ); + } + }); + + test("accepts a removal binding ('-command') — no namespace.verb requirement on keybindings.command", () => { + const result = validateManifest( + validManifest({ + contributes: { keybindings: [{ key: "ctrl+k ctrl+s", command: "-editor.action.save" }] }, + }), + ); + expect(result.valid).toBe(true); + }); +}); + +describe("validateManifest — contributes.views", () => { + test("requires id, title, and a valid slot", () => { + const result = validateManifest( + validManifest({ contributes: { views: [{ id: "v", title: "V", slot: "toolbar" }] } }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors).toContain('contributes.views[0].slot: must be "sidebar" or "panel"'); + } + }); + + test("accepts sidebar and panel slots", () => { + const result = validateManifest( + validManifest({ + contributes: { + views: [ + { id: "a", title: "A", slot: "sidebar" }, + { id: "b", title: "B", slot: "panel", icon: "circle" }, + ], + }, + }), + ); + expect(result.valid).toBe(true); + }); +}); + +describe("validateManifest — contributes.languages", () => { + test("requires id, non-empty dot-prefixed extensions, grammar, and highlights", () => { + const result = validateManifest( + validManifest({ + contributes: { + languages: [{ id: "ts", extensions: ["ts"], grammar: "", highlights: "" }], + }, + }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect( + result.errors.some((e) => e.startsWith("contributes.languages[0].extensions:")), + ).toBe(true); + expect(result.errors).toContain( + "contributes.languages[0].grammar: required non-empty string", + ); + expect(result.errors).toContain( + "contributes.languages[0].highlights: required non-empty string", + ); + } + }); + + test("accepts comments and brackets", () => { + const result = validateManifest( + validManifest({ + contributes: { + languages: [ + { + id: "ts", + extensions: [".ts", ".tsx"], + grammar: "tree-sitter-typescript.wasm", + highlights: "highlights.scm", + comments: { line: "//", block: ["/*", "*/"] }, + brackets: [{ open: "{", close: "}" }], + }, + ], + }, + }), + ); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.manifest.contributes.languages?.[0]?.comments).toEqual({ + line: "//", + block: ["/*", "*/"], + }); + } + }); + + test("rejects a malformed comments.block pair", () => { + const result = validateManifest( + validManifest({ + contributes: { + languages: [ + { + id: "ts", + extensions: [".ts"], + grammar: "g", + highlights: "h", + comments: { block: ["only-one"] }, + }, + ], + }, + }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect( + result.errors.some((e) => e === "contributes.languages[0].comments.block: must be a [start, end] string pair"), + ).toBe(true); + } + }); +}); + +describe("validateManifest — contributes.themes", () => { + test("requires id, label, and path", () => { + const result = validateManifest( + validManifest({ contributes: { themes: [{ id: "t" }] } }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors).toContain("contributes.themes[0].label: required non-empty string"); + expect(result.errors).toContain("contributes.themes[0].path: required non-empty string"); + } + }); + + test("accepts a well-formed theme contribution", () => { + const result = validateManifest( + validManifest({ + contributes: { themes: [{ id: "dark", label: "Dark", path: "./themes/dark.json" }] }, + }), + ); + expect(result.valid).toBe(true); + }); +}); + +describe("validateManifest — contributes.configuration", () => { + test("requires properties, and each property's type", () => { + const result = validateManifest( + validManifest({ + contributes: { + configuration: { properties: { "editor.tabSize": { type: "not-a-type" } } }, + }, + }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect( + result.errors.some((e) => + e.startsWith('contributes.configuration.properties["editor.tabSize"].type:'), + ), + ).toBe(true); + } + }); + + test("accepts a well-formed configuration contribution with defaults/enum/description", () => { + const result = validateManifest( + validManifest({ + contributes: { + configuration: { + title: "Demo", + properties: { + "demo.mode": { + type: "string", + default: "fast", + description: "The mode", + enum: ["fast", "slow"], + }, + }, + }, + }, + }), + ); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.manifest.contributes.configuration).toEqual({ + title: "Demo", + properties: { + "demo.mode": { + type: "string", + default: "fast", + description: "The mode", + enum: ["fast", "slow"], + }, + }, + }); + } + }); + + test("requires contributes.configuration.properties itself", () => { + const result = validateManifest( + validManifest({ contributes: { configuration: { title: "Demo" } } }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors).toContain("contributes.configuration.properties: required object"); + } + }); +}); + +describe("validateManifest — multiple problems reported together", () => { + test("does not fail fast: every problem across every contribution surfaces in one pass", () => { + const result = validateManifest({ + // no id, no version + apiVersion: "not-a-version", + activationEvents: "not-an-array", + contributes: { + commands: [{}], + views: [{}], + }, + }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.length).toBeGreaterThanOrEqual(6); + } + }); +}); + +describe("checkApiVersionCompatibility — matrix (Req 2.7, design.md §4.3)", () => { + test("same major and minor is compatible", () => { + expect(checkApiVersionCompatibility("1.0", "1.0").compatible).toBe(true); + }); + + test("same major, older requested minor is compatible (host newer)", () => { + expect(checkApiVersionCompatibility("1.0", "1.5").compatible).toBe(true); + }); + + test("same major, newer requested minor than host is incompatible", () => { + const result = checkApiVersionCompatibility("1.5", "1.0"); + expect(result.compatible).toBe(false); + expect(result.reason).toBeDefined(); + }); + + test("different major is incompatible regardless of minor", () => { + expect(checkApiVersionCompatibility("2.0", "1.9").compatible).toBe(false); + expect(checkApiVersionCompatibility("0.9", "1.0").compatible).toBe(false); + }); + + test("an omitted minor is treated as 0", () => { + expect(checkApiVersionCompatibility("1", "1.0").compatible).toBe(true); + expect(checkApiVersionCompatibility("1", "0.9").compatible).toBe(false); + }); + + test("an unparsable version is incompatible, not thrown", () => { + const result = checkApiVersionCompatibility("not-a-version", "1.0"); + expect(result.compatible).toBe(false); + expect(result.reason).toContain("could not parse"); + }); + + test("defaults hostVersion to the real API_VERSION when omitted", () => { + // @tecode/api's API_VERSION is "1.0" today; a manifest requesting "1.0" + // must be compatible against whatever the real host actually exports, + // not a hardcoded copy of the version string. + const result = checkApiVersionCompatibility("1.0"); + expect(result.compatible).toBe(true); + }); +}); + +describe("validateManifest — a realistic full manifest round-trips", () => { + test("validates and reconstructs a manifest with every contribution kind", () => { + const raw: Manifest = { + id: "demo.everything", + version: "2.3.1", + apiVersion: "1.0", + activationEvents: ["onStartup", "onCommand:demo.run", "onLanguage:typescript"], + contributes: { + commands: [{ id: "demo.run", title: "Run Demo" }], + keybindings: [{ key: "ctrl+shift+r", command: "demo.run" }], + views: [{ id: "demo.view", title: "Demo", slot: "sidebar" }], + languages: [ + { id: "demo-lang", extensions: [".demo"], grammar: "g.wasm", highlights: "h.scm" }, + ], + themes: [{ id: "demo-theme", label: "Demo Theme", path: "theme.json" }], + configuration: { properties: { "demo.enabled": { type: "boolean", default: true } } }, + }, + }; + + const result = validateManifest(raw); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.manifest).toEqual(raw); + } + }); +}); diff --git a/packages/core/src/host/validate.ts b/packages/core/src/host/validate.ts new file mode 100644 index 0000000..4650d99 --- /dev/null +++ b/packages/core/src/host/validate.ts @@ -0,0 +1,572 @@ +/** + * Hand-written manifest validation (Req 2.3, 2.7, design.md §4.1, §4.3) — + * no runtime schema library (house convention: keeps the compiled binary + * lean). Validates a raw, untyped manifest module export against the + * `@tecode/api` `Manifest` shape and reports every problem it finds, each + * carrying a field path (e.g. `contributes.commands[0].id`) rather than + * failing fast on the first — a manifest author sees every mistake in one + * pass. `validateManifest` never throws, and returns a discriminated + * result rather than throwing on failure. + * + * API-version compatibility (design.md §4.3) is a separate, later check + * ({@link checkApiVersionCompatibility}) — a manifest can be *structurally* + * valid (a well-formed `apiVersion` string) while still being + * *incompatible* with the running host, which is not a validation failure + * in the same sense. + */ + +import { API_VERSION } from "@tecode/api"; +import type { + ActivationEvent, + BracketPair, + CommandContribution, + ConfigurationContribution, + ConfigurationPropertySchema, + Contributes, + KeybindingContribution, + LanguageComments, + LanguageContribution, + Manifest, + ThemeContribution, + ViewContribution, +} from "@tecode/api"; +import { isValidCommandId } from "../commands/registry"; + +/** The result of {@link validateManifest}: either a fully-typed `Manifest`, + * or the list of every problem found (field-path-qualified). */ +export type ManifestValidationResult = + | { valid: true; manifest: Manifest } + | { valid: false; errors: string[] }; + +/** Whether an {@link checkApiVersionCompatibility} check passed, and why + * not when it didn't. */ +export interface ApiVersionCompatibility { + compatible: boolean; + /** Human-readable reason, present only when `compatible` is `false`. */ + reason?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** Render an invalid manifest value for an error message without ever + * throwing — `JSON.stringify` raises `TypeError` on `BigInt` (and returns + * `undefined` for some inputs), which would break {@link validateManifest}'s + * never-throw contract mid-report. */ +function describeValue(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + // String(value) can itself throw (a hostile Symbol.toPrimitive or + // toString), so the fallback needs its own guard with a fixed, + // conversion-free last resort. + try { + return String(value); + } catch { + return ""; + } + } +} + +const ACTIVATION_EVENT_PATTERN = /^(?:onStartup|onCommand:.+|onLanguage:.+)$/; + +function isActivationEvent(value: unknown): value is ActivationEvent { + return typeof value === "string" && ACTIVATION_EVENT_PATTERN.test(value); +} + +/** `""` or `"."` (design.md §4.3). */ +const VERSION_PATTERN = /^(\d+)(?:\.(\d+))?$/; + +/** The SemVer 2.0.0 grammar (semver.org's published pattern) for + * `Manifest.version`, which — unlike the two-part {@link VERSION_PATTERN} + * used for `apiVersion` — is a full `major.minor.patch` version with + * optional pre-release/build parts. `"1.0"` and `"not-semver"` are + * rejected. */ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +function parseVersion(value: string): { major: number; minor: number } | undefined { + const match = VERSION_PATTERN.exec(value); + if (!match) return undefined; + const major = Number(match[1]); + const minor = match[2] !== undefined ? Number(match[2]) : 0; + return { major, minor }; +} + +const VIEW_SLOTS = new Set(["sidebar", "panel"]); +const CONFIG_PROPERTY_TYPES = new Set(["string", "number", "boolean", "array", "object"]); + +/** + * Validate one manifest module's raw default export against the `Manifest` + * shape (Req 2.3). Never throws. + */ +export function validateManifest(raw: unknown): ManifestValidationResult { + const errors: string[] = []; + + if (!isRecord(raw)) { + return { + valid: false, + errors: [ + 'manifest: must export a default object ("export default {...} satisfies Manifest")', + ], + }; + } + + if (!isNonEmptyString(raw.id)) { + errors.push("id: required non-empty string"); + } + if (!isNonEmptyString(raw.version)) { + errors.push("version: required non-empty string"); + } else if (!SEMVER_PATTERN.test(raw.version)) { + errors.push('version: must be a SemVer version ("..", e.g. "1.0.0")'); + } + if (!isNonEmptyString(raw.apiVersion) || !VERSION_PATTERN.test(raw.apiVersion)) { + errors.push( + 'apiVersion: required string in "" or "." form (e.g. "1", "1.0")', + ); + } + + let activationEvents: ActivationEvent[] = []; + if (!Array.isArray(raw.activationEvents)) { + errors.push("activationEvents: required array"); + } else { + // Indexed loop, not forEach: forEach skips sparse-array holes, which + // would let a hole (an `undefined` element) through unvalidated and + // into the returned manifest. Visiting every index reports holes as + // the invalid entries they are. + for (let i = 0; i < raw.activationEvents.length; i++) { + const event: unknown = raw.activationEvents[i]; + if (!isActivationEvent(event)) { + errors.push( + `activationEvents[${i}]: must be "onStartup", "onCommand:", or ` + + `"onLanguage:" (got ${describeValue(event)})`, + ); + } + } + activationEvents = raw.activationEvents as ActivationEvent[]; + } + + let contributes: Contributes = {}; + if (raw.contributes === undefined) { + errors.push("contributes: required object (may be empty {})"); + } else if (!isRecord(raw.contributes)) { + errors.push("contributes: must be an object"); + } else { + contributes = validateContributes(raw.contributes, errors); + } + + if (errors.length > 0) return { valid: false, errors }; + + return { + valid: true, + manifest: { + id: raw.id as string, + version: raw.version as string, + apiVersion: raw.apiVersion as string, + activationEvents, + contributes, + }, + }; +} + +function validateContributes(raw: Record, errors: string[]): Contributes { + const result: Contributes = {}; + + if (raw.commands !== undefined) { + result.commands = validateArray( + raw.commands, + "contributes.commands", + errors, + validateCommandContribution, + ); + } + if (raw.keybindings !== undefined) { + result.keybindings = validateArray( + raw.keybindings, + "contributes.keybindings", + errors, + validateKeybindingContribution, + ); + } + if (raw.views !== undefined) { + result.views = validateArray(raw.views, "contributes.views", errors, validateViewContribution); + } + if (raw.languages !== undefined) { + result.languages = validateArray( + raw.languages, + "contributes.languages", + errors, + validateLanguageContribution, + ); + } + if (raw.themes !== undefined) { + result.themes = validateArray( + raw.themes, + "contributes.themes", + errors, + validateThemeContribution, + ); + } + if (raw.configuration !== undefined) { + const configuration = validateConfigurationContribution( + raw.configuration, + "contributes.configuration", + errors, + ); + if (configuration) result.configuration = configuration; + } + + return result; +} + +/** Validate a `raw` value as an array of entries, each checked by `check`; + * a non-array `raw` reports once at `path` and yields `[]`. Invalid + * individual entries are dropped from the returned array (their errors are + * already recorded by `check`) — the caller only reaches this when the + * whole manifest is about to be rejected anyway (non-empty `errors`), so + * the returned array's exact contents don't matter in that case. */ +function validateArray( + raw: unknown, + path: string, + errors: string[], + check: (entry: unknown, path: string, errors: string[]) => T | undefined, +): T[] { + if (!Array.isArray(raw)) { + errors.push(`${path}: must be an array`); + return []; + } + const result: T[] = []; + // Indexed loop, not forEach: forEach skips sparse-array holes, which + // would silently drop an entry instead of reporting it as invalid. + for (let i = 0; i < raw.length; i++) { + const validated = check(raw[i], `${path}[${i}]`, errors); + if (validated !== undefined) result.push(validated); + } + return result; +} + +function validateCommandContribution( + entry: unknown, + path: string, + errors: string[], +): CommandContribution | undefined { + const before = errors.length; + if (!isRecord(entry)) { + errors.push(`${path}: must be an object`); + return undefined; + } + if (!isNonEmptyString(entry.id)) { + errors.push(`${path}.id: required non-empty string`); + } else if (!isValidCommandId(entry.id)) { + errors.push(`${path}.id: must be namespace.verb form (e.g. "editor.action.deleteLine")`); + } + if (!isNonEmptyString(entry.title)) { + errors.push(`${path}.title: required non-empty string`); + } + if (entry.category !== undefined && typeof entry.category !== "string") { + errors.push(`${path}.category: must be a string`); + } + if (entry.when !== undefined && typeof entry.when !== "string") { + errors.push(`${path}.when: must be a string`); + } + if (errors.length !== before) return undefined; + return { + id: entry.id as string, + title: entry.title as string, + category: entry.category as string | undefined, + when: entry.when as string | undefined, + }; +} + +function validateKeybindingContribution( + entry: unknown, + path: string, + errors: string[], +): KeybindingContribution | undefined { + const before = errors.length; + if (!isRecord(entry)) { + errors.push(`${path}: must be an object`); + return undefined; + } + if (!isNonEmptyString(entry.key)) { + errors.push(`${path}.key: required non-empty string`); + } + if (!isNonEmptyString(entry.command)) { + errors.push(`${path}.command: required non-empty string`); + } + if (entry.when !== undefined && typeof entry.when !== "string") { + errors.push(`${path}.when: must be a string`); + } + if (errors.length !== before) return undefined; + return { + key: entry.key as string, + command: entry.command as string, + when: entry.when as string | undefined, + }; +} + +function validateViewContribution( + entry: unknown, + path: string, + errors: string[], +): ViewContribution | undefined { + const before = errors.length; + if (!isRecord(entry)) { + errors.push(`${path}: must be an object`); + return undefined; + } + if (!isNonEmptyString(entry.id)) { + errors.push(`${path}.id: required non-empty string`); + } + if (!isNonEmptyString(entry.title)) { + errors.push(`${path}.title: required non-empty string`); + } + if (typeof entry.slot !== "string" || !VIEW_SLOTS.has(entry.slot)) { + errors.push(`${path}.slot: must be "sidebar" or "panel"`); + } + if (entry.icon !== undefined && typeof entry.icon !== "string") { + errors.push(`${path}.icon: must be a string`); + } + if (errors.length !== before) return undefined; + return { + id: entry.id as string, + title: entry.title as string, + slot: entry.slot as ViewContribution["slot"], + icon: entry.icon as string | undefined, + }; +} + +function validateLanguageComments( + entry: unknown, + path: string, + errors: string[], +): LanguageComments | undefined { + const before = errors.length; + if (!isRecord(entry)) { + errors.push(`${path}: must be an object`); + return undefined; + } + if (entry.line !== undefined && typeof entry.line !== "string") { + errors.push(`${path}.line: must be a string`); + } + let block: [string, string] | undefined; + if (entry.block !== undefined) { + if ( + !Array.isArray(entry.block) || + entry.block.length !== 2 || + typeof entry.block[0] !== "string" || + typeof entry.block[1] !== "string" + ) { + errors.push(`${path}.block: must be a [start, end] string pair`); + } else { + block = [entry.block[0], entry.block[1]]; + } + } + if (errors.length !== before) return undefined; + return { line: entry.line as string | undefined, block }; +} + +function validateBracketPair( + entry: unknown, + path: string, + errors: string[], +): BracketPair | undefined { + const before = errors.length; + if (!isRecord(entry)) { + errors.push(`${path}: must be an object`); + return undefined; + } + if (!isNonEmptyString(entry.open)) { + errors.push(`${path}.open: required non-empty string`); + } + if (!isNonEmptyString(entry.close)) { + errors.push(`${path}.close: required non-empty string`); + } + if (errors.length !== before) return undefined; + return { open: entry.open as string, close: entry.close as string }; +} + +function validateLanguageContribution( + entry: unknown, + path: string, + errors: string[], +): LanguageContribution | undefined { + const before = errors.length; + if (!isRecord(entry)) { + errors.push(`${path}: must be an object`); + return undefined; + } + if (!isNonEmptyString(entry.id)) { + errors.push(`${path}.id: required non-empty string`); + } + let extensions: string[] = []; + // Array.from before every: every skips sparse-array holes, which would + // let an array with holes validate and then carry the holes into the + // manifest. Array.from materializes holes as `undefined`, so they fail + // the element check like any other bad value. + const extensionList = Array.isArray(entry.extensions) ? Array.from(entry.extensions) : undefined; + if ( + extensionList === undefined || + extensionList.length === 0 || + !extensionList.every((ext: unknown) => typeof ext === "string" && ext.startsWith(".")) + ) { + errors.push(`${path}.extensions: required non-empty array of dot-prefixed extensions (e.g. ".ts")`); + } else { + extensions = extensionList as string[]; + } + if (!isNonEmptyString(entry.grammar)) { + errors.push(`${path}.grammar: required non-empty string`); + } + if (!isNonEmptyString(entry.highlights)) { + errors.push(`${path}.highlights: required non-empty string`); + } + let comments: LanguageComments | undefined; + if (entry.comments !== undefined) { + comments = validateLanguageComments(entry.comments, `${path}.comments`, errors); + } + let brackets: BracketPair[] | undefined; + if (entry.brackets !== undefined) { + brackets = validateArray(entry.brackets, `${path}.brackets`, errors, validateBracketPair); + } + if (errors.length !== before) return undefined; + return { + id: entry.id as string, + extensions, + grammar: entry.grammar as string, + highlights: entry.highlights as string, + comments, + brackets, + }; +} + +function validateThemeContribution( + entry: unknown, + path: string, + errors: string[], +): ThemeContribution | undefined { + const before = errors.length; + if (!isRecord(entry)) { + errors.push(`${path}: must be an object`); + return undefined; + } + if (!isNonEmptyString(entry.id)) { + errors.push(`${path}.id: required non-empty string`); + } + if (!isNonEmptyString(entry.label)) { + errors.push(`${path}.label: required non-empty string`); + } + if (!isNonEmptyString(entry.path)) { + errors.push(`${path}.path: required non-empty string`); + } + if (errors.length !== before) return undefined; + return { + id: entry.id as string, + label: entry.label as string, + path: entry.path as string, + }; +} + +function validateConfigurationPropertySchema( + entry: unknown, + path: string, + errors: string[], +): ConfigurationPropertySchema | undefined { + const before = errors.length; + if (!isRecord(entry)) { + errors.push(`${path}: must be an object`); + return undefined; + } + if (typeof entry.type !== "string" || !CONFIG_PROPERTY_TYPES.has(entry.type)) { + errors.push(`${path}.type: must be one of "string", "number", "boolean", "array", "object"`); + } + if (entry.description !== undefined && typeof entry.description !== "string") { + errors.push(`${path}.description: must be a string`); + } + if (entry.enum !== undefined && !Array.isArray(entry.enum)) { + errors.push(`${path}.enum: must be an array`); + } + if (errors.length !== before) return undefined; + return { + type: entry.type as ConfigurationPropertySchema["type"], + default: entry.default, + description: entry.description as string | undefined, + enum: entry.enum as unknown[] | undefined, + }; +} + +function validateConfigurationContribution( + entry: unknown, + path: string, + errors: string[], +): ConfigurationContribution | undefined { + const before = errors.length; + if (!isRecord(entry)) { + errors.push(`${path}: must be an object`); + return undefined; + } + if (entry.title !== undefined && typeof entry.title !== "string") { + errors.push(`${path}.title: must be a string`); + } + const properties: Record = {}; + if (!isRecord(entry.properties)) { + errors.push(`${path}.properties: required object`); + } else { + for (const [key, value] of Object.entries(entry.properties)) { + // Bracket notation: configuration keys routinely contain dots + // ("editor.fontSize"), which a plain `.${key}` path would make + // ambiguous with genuine nesting. + const schema = validateConfigurationPropertySchema( + value, + `${path}.properties[${describeValue(key)}]`, + errors, + ); + if (schema) properties[key] = schema; + } + } + if (errors.length !== before) return undefined; + return { + title: entry.title as string | undefined, + properties, + }; +} + +/** + * Whether a manifest's declared `apiVersion` is compatible with the running + * host's `API_VERSION` (Req 2.7, design.md §4.3): same major version, and + * the host's minor version is greater than or equal to the requested one. + * An unparsable version (should not happen for a manifest that already + * passed {@link validateManifest}, but checked independently here so this + * function is safe to call on its own) is reported incompatible rather + * than thrown. + */ +export function checkApiVersionCompatibility( + requested: string, + hostVersion: string = API_VERSION, +): ApiVersionCompatibility { + const req = parseVersion(requested); + const host = parseVersion(hostVersion); + if (!req || !host) { + return { + compatible: false, + reason: `could not parse apiVersion "${requested}" against host "${hostVersion}"`, + }; + } + if (req.major !== host.major) { + return { + compatible: false, + reason: `major version mismatch: extension requires ${req.major}.x, host is ${host.major}.${host.minor}`, + }; + } + if (host.minor < req.minor) { + return { + compatible: false, + reason: `extension requires minor version >= ${req.minor}, host is ${host.major}.${host.minor}`, + }; + } + return { compatible: true }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 60cfbe3..9d15e38 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,10 +1,36 @@ // Placeholder entry point for @tecode/core. Real wiring lands in later tasks. -export { HOST_PLACEHOLDER } from "./host/index"; +export { + checkApiVersionCompatibility, + discover, + getUserExtensionsDir, + getWorkspaceExtensionsDir, + HOST_PLACEHOLDER, + loadExtensions, + registerExtension, + validateManifest, + type ApiVersionCompatibility, + type ConfigRegistrar, + type DiscoveredExtension, + type DiscoveryDeps, + type DiscoveryFs, + type ExtensionSource, + type LoadedExtension, + type LoadExtensionsDeps, + type LoadExtensionsResult, + type ManifestValidationResult, + type PendingLanguageContribution, + type PendingThemeContribution, + type PendingViewContribution, + type RegisterExtensionDeps, + type RegisterExtensionResult, + type SkippedExtension, +} from "./host/index"; export { createCommandRegistry, isValidCommandId, type CommandRegistry, type CommandRegistryDeps, + type RegisterLazyOptions, } from "./commands/index"; export { CHORD_TIMEOUT_MS,