From 80bec7abf7d61cf8a5d1026203175c7bbb255e01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 23:02:16 +0000 Subject: [PATCH 1/2] Assemble the tecode API object and wire the "tecode" module alias Implements Task 1.13: createTecodeApi builds the frozen, nine-namespace tecode object handed to every extension, delegating commands/workspace/ config/context to the real core services and backing window/editor/ui/ languages/themes with documented no-op stubs ahead of the UI-shell and theming tasks that give them real behavior. A new FileSystem wrapper over node:fs/promises + fs.watch backs workspace.fs with no sandboxing (Req 10.2). registerTecodeAlias uses Bun.plugin to make `import ... from "tecode"` resolve at runtime, with an ambient module declaration keeping `bunx tsc --noEmit` clean; cli/main.ts wires createTecodeApi then registerTecodeAlias behind an import.meta.main guard so Task 1.15 can build on it. A contract-test suite exercises every namespace through a fixture extension and through the "tecode" alias, checks freeze-ness and register/dispose symmetry, and stands as the compatibility gate for future API_VERSION bumps. Adds a minimal CI workflow running tests and lint. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- .github/workflows/ci.yml | 20 + bun.lock | 1 + packages/cli/package.json | 1 + packages/cli/src/main.test.ts | 57 +++ packages/cli/src/main.ts | 102 ++++- packages/core/src/api/alias.ts | 72 ++++ packages/core/src/api/create.contract.test.ts | 372 ++++++++++++++++++ packages/core/src/api/create.ts | 174 ++++++++ packages/core/src/api/index.ts | 26 +- packages/core/src/api/stubs.test.ts | 171 ++++++++ packages/core/src/api/stubs.ts | 365 +++++++++++++++++ packages/core/src/api/tecode-module.d.ts | 55 +++ packages/core/src/buffer/fileSystem.test.ts | 177 +++++++++ packages/core/src/buffer/fileSystem.ts | 247 ++++++++++++ packages/core/src/buffer/index.ts | 1 + packages/core/src/index.ts | 19 +- 16 files changed, 1855 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 packages/core/src/api/alias.ts create mode 100644 packages/core/src/api/create.contract.test.ts create mode 100644 packages/core/src/api/create.ts create mode 100644 packages/core/src/api/stubs.test.ts create mode 100644 packages/core/src/api/stubs.ts create mode 100644 packages/core/src/api/tecode-module.d.ts create mode 100644 packages/core/src/buffer/fileSystem.test.ts create mode 100644 packages/core/src/buffer/fileSystem.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8f187c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - name: contract-tests + run: bun test + - name: lint + run: bun run lint diff --git a/bun.lock b/bun.lock index 94be193..3a88ef3 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "tecode": "src/main.ts", }, "dependencies": { + "@tecode/api": "workspace:*", "@tecode/core": "workspace:*", }, }, diff --git a/packages/cli/package.json b/packages/cli/package.json index b2d9c98..819d667 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,6 +8,7 @@ "tecode": "src/main.ts" }, "dependencies": { + "@tecode/api": "workspace:*", "@tecode/core": "workspace:*" } } diff --git a/packages/cli/src/main.test.ts b/packages/cli/src/main.test.ts index 5dfb649..ff6219f 100644 --- a/packages/cli/src/main.test.ts +++ b/packages/cli/src/main.test.ts @@ -1,5 +1,10 @@ import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToUri } from "@tecode/core"; import pkg from "../package.json"; +import { buildAssemblyRoot } from "./main"; test("--version prints the package version and exits 0", async () => { const proc = Bun.spawn(["bun", "run", `${import.meta.dir}/main.ts`, "--version"], { @@ -12,3 +17,55 @@ test("--version prints the package version and exits 0", async () => { expect(stdout.trim()).toBe(pkg.version); expect(exitCode).toBe(0); }); + +test("buildAssemblyRoot wires every core service and registers the 'tecode' module alias", async () => { + // Importing main.ts (above) does not itself run `main()` — see main.ts's + // `import.meta.main` guard — so calling buildAssemblyRoot() directly + // here is safe and does not depend on this test file's own argv. + const dir = await mkdtemp(join(tmpdir(), "tecode-cli-root-")); + // Redirect the user-level config directory into this test's temp dir + // (matches config/service.test.ts's real-filesystem test) so this never + // reads or watches the real user's ~/.config/tecode files. + const savedHome = process.env["HOME"]; + const savedAppData = process.env["APPDATA"]; + process.env["HOME"] = dir; + process.env["APPDATA"] = dir; + let root: ReturnType; + try { + root = buildAssemblyRoot(dir); + } finally { + if (savedHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = savedHome; + if (savedAppData === undefined) delete process.env["APPDATA"]; + else process.env["APPDATA"] = savedAppData; + } + + try { + await root.config.ready; + + // Every namespace reachable via the assembled api. + expect(Object.keys(root.api)).toEqual([ + "commands", + "workspace", + "window", + "editor", + "ui", + "config", + "context", + "languages", + "themes", + ]); + + expect(root.api.workspace.rootUri).toBe(pathToUri(dir)); + expect(Object.isFrozen(root.api)).toBe(true); + + // buildAssemblyRoot's own TSDoc documents that registerTecodeAlias runs + // as its last step; `create.contract.test.ts` is where the resulting + // `"tecode"` module-alias resolution is exercised end-to-end (the one + // sanctioned dynamic `import("tecode")` test call site) — this test + // stays focused on cli's composition wiring itself. + } finally { + root.config.dispose(); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 4bef331..c7a623d 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -1,10 +1,110 @@ import pkg from "../package.json"; +import type { FileSystem, Tecode } from "@tecode/api"; +import { + createCommandRegistry, + createConfigService, + createContextService, + createDocumentManager, + createFileSystem, + createHostLog, + createNoopStatusSink, + createTecodeApi, + pathToUri, + registerTecodeAlias, + type CommandRegistry, + type ConfigService, + type ContextService, + type DocumentManager, + type HostLog, + type StatusSink, +} from "@tecode/core"; + +/** + * Every core service {@link buildAssemblyRoot} wires together, plus the + * assembled `tecode` object itself — returned so a caller (currently just + * this module's own `main`; Task 1.15's startup sequence next) can hold + * onto `config` for `ready`/`dispose()` without reaching back into the + * module's internals. + */ +export interface AssemblyRoot { + log: HostLog; + sink: StatusSink; + commands: CommandRegistry; + documents: DocumentManager; + fs: FileSystem; + config: ConfigService; + context: ContextService; + api: Tecode; +} + +/** + * Build the `tecode` composition root and register the `"tecode"` module + * alias (Req 10.1, 10.2; design.md §12, §17; Task 1.13's "Bun module alias + * registration" note). `packages/cli` is the one place allowed to import + * `@tecode/core` directly (`eslint.config.mjs`'s layering rule) — this + * function is that wiring. + * + * **This is deliberately a small slice of design.md §17's full startup + * sequence**, not that sequence itself: argv parsing (file vs. directory), + * the sync-before-first-frame phase, rendering the UI shell, deferred + * extension discovery/activation, the initial file open, and startup-timing + * instrumentation are all Task 1.15's job. That task should *call* this + * function (or extend it) rather than duplicate its ordering — the one + * invariant it establishes and Task 1.15 must preserve is + * {@link registerTecodeAlias} running immediately after + * {@link createTecodeApi} and strictly before any extension module is + * imported (Req 1.4, design.md §2): an extension's `import ... from + * "tecode"` resolves only once the alias is registered. + * + * `workspaceRoot` defaults to `process.cwd()` as a placeholder for Task + * 1.15's real argv-driven file/directory resolution (design.md §17's + * "Argv parsing (file/directory)" step) — nothing here interprets `argv` + * yet. + */ +export function buildAssemblyRoot(workspaceRoot: string = process.cwd()): AssemblyRoot { + const log = createHostLog(); + // No UI shell exists yet (Task 1.14) to back a real StatusSink — matches + // every other core composition point that hasn't reached its UI task. + const sink = createNoopStatusSink(); + + const commands = createCommandRegistry({ log, sink }); + const documents = createDocumentManager({ log, sink }); + const fs = createFileSystem({ log }); + const config = createConfigService({ log, sink, workspaceRoot }); + const context = createContextService(); + + const api = createTecodeApi({ + commands, + documents, + fs, + rootUri: pathToUri(workspaceRoot), + config, + context, + sink, + }); + + // Must run before any extension module is imported (see this function's + // TSDoc) — no extension loading exists yet (Task 1.15/2.x), so this is + // simply the last step here today. + registerTecodeAlias(api); + + return { log, sink, commands, documents, fs, config, context, api }; +} function main(argv: string[]): void { if (argv.includes("--version")) { console.log(pkg.version); process.exit(0); } + buildAssemblyRoot(); } -main(process.argv.slice(2)); +// `import.meta.main` is Bun's "am I the entry point" check (true only when +// this file itself was executed, e.g. `bun run main.ts`; false when another +// module — such as this file's own test — imports it). Without this guard, +// importing `main.ts` for testing `buildAssemblyRoot` would also run +// `main(process.argv.slice(2))` as an unwanted side effect, against the +// *importing* process's real argv and real `HOME`. +if (import.meta.main) { + main(process.argv.slice(2)); +} diff --git a/packages/core/src/api/alias.ts b/packages/core/src/api/alias.ts new file mode 100644 index 0000000..5dad5dd --- /dev/null +++ b/packages/core/src/api/alias.ts @@ -0,0 +1,72 @@ +/** + * `registerTecodeAlias`: makes `import ... from "tecode"` resolve at + * runtime (Req 10.1, design.md §2, §12; Task 1.13) using `Bun.plugin`'s + * virtual-module hook. Every extension is written against `@tecode/api`'s + * *types* but reaches the live implementation through the `"tecode"` + * module specifier (design.md §2) — this is the one place that binding is + * actually wired up, and it must run once, after {@link createTecodeApi} + * has built the object and *before* any extension module is imported + * (`cli/main.ts`'s startup wiring, Task 1.15, is the intended call site; + * `discovery.ts`'s manifest-only dynamic import runs before this and never + * touches `index.ts`, so ordering there is unaffected). + * + * **Static typing for `"tecode"`**: `Bun.plugin`'s `builder.module(...)` is + * a runtime-only hook — TypeScript has no way to see that the specifier + * `"tecode"` will resolve to anything without help. `api/tecode-module.d.ts` + * supplies that help with an ambient `declare module "tecode"` re-exporting + * each namespace's type from `@tecode/api`; that file's own TSDoc explains + * why it works across every package in one `bunx tsc --noEmit` run despite + * living in `core`. + * + * **Compiled-mode (`bun build --compile`) note**: `Bun.plugin` registration + * must still run before any extension module import inside the compiled + * binary's own entry point — nothing about this changes for a compiled + * build (`Bun.plugin` is a runtime call, not a bundler transform), but the + * *build entry file* (design.md §17's `scripts/release.ts`-driven build, + * not yet written) must be the one that calls + * {@link createTecodeApi}/{@link registerTecodeAlias}, exactly like + * `cli/main.ts` does in dev. No build script changes are needed for this + * task; this note exists so Task whichever-wires-`--compile` doesn't have + * to rediscover the constraint. + */ + +import type { Tecode } from "@tecode/api"; + +/** The `api` object most recently registered via {@link registerTecodeAlias} + * — tracked so a repeat call with the exact same object is a cheap no-op + * (idempotent) while a call with a genuinely different object (e.g. a test + * building a fresh composition root) still takes effect: `Bun.plugin` + * itself is fine with re-registering the same module specifier (last + * registration wins, verified empirically — it does not throw or warn), so + * there is no correctness reason to refuse that case, only a cheap + * optimization for the common one. */ +let registeredApi: Tecode | undefined; + +/** + * Register the `"tecode"` virtual module so `import ... from "tecode"` + * resolves to `api`'s namespaces as named exports (`commands`, `workspace`, + * `window`, `editor`, `ui`, `config`, `context`, `languages`, `themes` — + * matching `Tecode`'s own shape, since `Bun.plugin`'s `loader: "object"` + * projects an object's own enumerable properties onto the module's named + * exports). Call this exactly once per `api` object, after + * {@link createTecodeApi} and before any extension module loads. + */ +export function registerTecodeAlias(api: Tecode): void { + if (registeredApi === api) return; + registeredApi = api; + Bun.plugin({ + name: "tecode-module-alias", + setup(builder) { + builder.module("tecode", () => ({ + // `OnLoadResultObject.exports` is typed `Record` + // (an index signature `Tecode` deliberately does not declare — its + // nine namespaces are named, not open-ended). The cast is safe: + // `api`'s own enumerable properties genuinely are exactly what + // `tecode-module.d.ts`'s ambient declaration promises callers of + // `import ... from "tecode"`. + exports: api as unknown as Record, + loader: "object", + })); + }, + }); +} diff --git a/packages/core/src/api/create.contract.test.ts b/packages/core/src/api/create.contract.test.ts new file mode 100644 index 0000000..c704ec7 --- /dev/null +++ b/packages/core/src/api/create.contract.test.ts @@ -0,0 +1,372 @@ +/** + * Contract tests for `createTecodeApi` (Req 10.1, 10.2; design.md §12, §16; + * Task 1.13) — design.md §16's "compatibility gate for `API_VERSION` + * bumps": "a test harness activates a fixture extension against the real + * core and asserts every `tecode.*` namespace behaves per its documented + * contract (register/dispose symmetry, event firing order, freeze-ness)." + * A future `API_VERSION` bump (`host/validate.ts`'s + * `checkApiVersionCompatibility`) that changes what a namespace guarantees + * should break a test in this file first, before it ever reaches a real + * extension. + * + * The composition root below (`buildRoot`) is assembled by hand from the + * real services — `createCommandRegistry`, `createDocumentManager`, + * `createConfigService`, `createContextService`, `createFileSystem` — the + * same shape `cli/main.ts`'s startup wiring (Task 1.15) will eventually + * build, rather than fakes: this suite exists specifically to catch + * integration-level wiring mistakes a per-service unit test cannot see. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ExtensionContext, Tecode } from "@tecode/api"; +import { createCommandRegistry } from "../commands/registry"; +import { createDocumentManager, type DocumentManager } from "../buffer/documentManager"; +import { createFileSystem } from "../buffer/fileSystem"; +import { pathToUri } from "../buffer/uri"; +import { createConfigService, type ConfigService } from "../config/service"; +import { createContextService } from "../keymap/context"; +import { createHostLog, type HostError } from "../host/errors"; +import type { ExtensionModule } from "../host/activation"; +import { createTecodeApi } from "./create"; +import { registerTecodeAlias } from "./alias"; + +/** 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); + }, + }, + }; +} + +interface Root { + api: Tecode; + errors: HostError[]; + documents: DocumentManager; + config: ConfigService; +} + +/** + * Build a full composition root from the real services (no fakes). + * `workspaceRoot` doubles as the redirected user-config directory + * (`HOME`/`APPDATA`) for the duration of `createConfigService`'s + * construction, so this suite never reads or watches the real user's + * `~/.config/tecode` files (matches `config/service.test.ts`'s + * real-filesystem integration test). + */ +async function buildRoot(workspaceRoot: string): Promise { + const log = createHostLog(); + const { errors, sink } = createRecordingSink(); + const commands = createCommandRegistry({ log, sink }); + const documents = createDocumentManager({ log, sink }); + const fs = createFileSystem({ log }); + + const savedHome = process.env["HOME"]; + const savedAppData = process.env["APPDATA"]; + process.env["HOME"] = workspaceRoot; + process.env["APPDATA"] = workspaceRoot; + let config: ConfigService; + try { + config = createConfigService({ log, sink, workspaceRoot }); + } finally { + if (savedHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = savedHome; + if (savedAppData === undefined) delete process.env["APPDATA"]; + else process.env["APPDATA"] = savedAppData; + } + await config.ready; + + const context = createContextService(); + + const api = createTecodeApi({ + commands, + documents, + fs, + rootUri: pathToUri(workspaceRoot), + config, + context, + sink, + }); + + return { api, errors, documents, config }; +} + +/** A fixture extension module (Req 2.6's `activate(ctx)`/`deactivate()` + * shape) that touches all nine `tecode.*` namespaces through `ctx.api`, + * pushing every subscription it creates onto `ctx.subscriptions` — the + * same disposal contract `host/activation.ts`'s `disposeSubscriptions` + * relies on — and records a breadcrumb per namespace into `events` so the + * test can assert every one was actually reached. */ +function createFixtureExtensionModule(events: string[]): ExtensionModule { + return { + async activate(ctx: ExtensionContext) { + const { api } = ctx; + + // commands + let executed = 0; + const commandSub = api.commands.register("fixture.contract.activate", () => { + executed += 1; + }); + ctx.subscriptions.push(commandSub); + await api.commands.execute("fixture.contract.activate"); + events.push(`commands:executed=${executed},list=${api.commands.list().length}`); + + // workspace + const openSub = api.workspace.onDidOpen((doc) => events.push(`workspace.onDidOpen:${doc.uri}`)); + ctx.subscriptions.push(openSub); + events.push(`workspace.rootUri:${api.workspace.rootUri ?? "none"}`); + events.push(`workspace.documents:${api.workspace.documents.length}`); + events.push(`workspace.fs:${typeof api.workspace.fs.read}`); + + // window + api.window.showMessage("hello from the fixture extension"); + events.push(`window.activeEditor:${String(api.window.activeEditor)}`); + const statusBarSub = api.window.setStatusBarItem({ + id: "fixture.contract.status", + text: "fixture", + side: "left", + priority: 0, + }); + ctx.subscriptions.push(statusBarSub); + + // editor + events.push(`editor.selections:${api.editor.selections.length}`); + events.push(`editor.cursor:${api.editor.cursor.line},${api.editor.cursor.character}`); + api.editor.revealLine(1); + api.editor.insertSnippet("fixture-snippet"); + api.editor.applyEdits([]); + + // ui + const viewSub = api.ui.registerView("sidebar.view", "fixture.contract.view", () => undefined); + ctx.subscriptions.push(viewSub); + events.push(`ui.useTheme:${typeof api.ui.useTheme().colors}`); + + // config + events.push(`config.get:${String(api.config.get("fixture.contract.key"))}`); + const configSub = api.config.onDidChange(() => events.push("config.onDidChange")); + ctx.subscriptions.push(configSub); + + // context + api.context.set("fixture.contract.flag", true); + events.push(`context.get:${String(api.context.get("fixture.contract.flag"))}`); + + // languages + const languageSub = api.languages.register({ + id: "fixture-contract-lang", + extensions: [".fxc"], + grammar: "g.wasm", + highlights: "h.scm", + }); + ctx.subscriptions.push(languageSub); + events.push(`languages.getLanguageId:${api.languages.getLanguageId("file:///a.fxc")}`); + + // themes + const themeSub = api.themes.register({ + id: "fixture-contract-theme", + label: "Fixture Contract Theme", + path: "theme.json", + }); + ctx.subscriptions.push(themeSub); + events.push(`themes.current:${typeof api.themes.current.colors}`); + + events.push("activate:done"); + }, + deactivate() { + events.push("deactivate:done"); + }, + }; +} + +describe("createTecodeApi — contract tests (design.md §16 compatibility gate)", () => { + let dir: string; + let root: Root | undefined; + + afterEach(async () => { + root?.config.dispose(); + root = undefined; + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + test("every tecode.* namespace is reachable via ctx.api and identical via the 'tecode' module alias", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-api-contract-")); + root = await buildRoot(dir); + const { api } = root; + + registerTecodeAlias(api); + // A dynamic import is required here, not a static one: a static + // `import ... from "tecode"` at the top of this file would resolve + // before `registerTecodeAlias` above ever runs, since ES module + // imports are hoisted and resolved at module-load time — Bun's + // virtual-module binding would not exist yet. This is the one + // sanctioned dynamic `import()` in a test file; the specifier is + // `"tecode"`, not the `"@tecode/core"` literal `eslint.config.mjs`'s + // `no-restricted-syntax` rule bans, so the layering rule does not + // apply to it. + const tecodeModule = await import("tecode"); + + expect(tecodeModule.commands).toBe(api.commands); + expect(tecodeModule.workspace).toBe(api.workspace); + expect(tecodeModule.window).toBe(api.window); + expect(tecodeModule.editor).toBe(api.editor); + expect(tecodeModule.ui).toBe(api.ui); + expect(tecodeModule.config).toBe(api.config); + expect(tecodeModule.context).toBe(api.context); + expect(tecodeModule.languages).toBe(api.languages); + expect(tecodeModule.themes).toBe(api.themes); + }); + + test("the aggregate object and every namespace are frozen (mutation throws in strict mode)", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-api-contract-")); + root = await buildRoot(dir); + const { api } = root; + + expect(Object.isFrozen(api)).toBe(true); + for (const namespace of Object.values(api)) { + expect(Object.isFrozen(namespace)).toBe(true); + } + + // This is a runtime behavior (`Object.freeze` + strict-mode assignment + // throwing `TypeError`), not something the type system is meant to + // catch on its own — every mutation attempt below is cast through + // `Record` deliberately, so the type checker's + // ordinary readonly/void-return leniency (e.g. any function is + // assignable where a `void`-returning one is expected) can't mask + // whether the *runtime* freeze actually held. + const mutableApi = api as unknown as Record; + expect(() => { + mutableApi["commands"] = {}; + }).toThrow(TypeError); + + const mutableCommands = api.commands as unknown as Record; + expect(() => { + mutableCommands["register"] = () => {}; + }).toThrow(TypeError); + + const mutableEditor = api.editor as unknown as Record; + expect(() => { + mutableEditor["revealLine"] = () => {}; + }).toThrow(TypeError); + }); + + test("commands: register/dispose symmetry — a disposed command reports 'not found' on execute", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-api-contract-")); + root = await buildRoot(dir); + const { api, errors } = root; + + let calls = 0; + const sub = api.commands.register("fixture.contract.symmetry", () => { + calls += 1; + }); + + await api.commands.execute("fixture.contract.symmetry"); + expect(calls).toBe(1); + + sub.dispose(); + const result = await api.commands.execute("fixture.contract.symmetry"); + + expect(result).toBeUndefined(); + expect(calls).toBe(1); // did not fire again + expect(errors.some((e) => e.message.includes("fixture.contract.symmetry"))).toBe(true); + expect(() => sub.dispose()).not.toThrow(); // idempotent + }); + + test("workspace.onDidOpen -> onDidSave -> onDidClose fire in that order for a real document", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-api-contract-")); + root = await buildRoot(dir); + const { api, documents } = root; + const filePath = join(dir, "doc.txt"); + await writeFile(filePath, "hello", "utf8"); + const uri = pathToUri(filePath); + + const events: string[] = []; + const openSub = api.workspace.onDidOpen((doc) => events.push(`open:${doc.uri}`)); + const saveSub = api.workspace.onDidSave((doc) => events.push(`save:${doc.uri}`)); + const closeSub = api.workspace.onDidClose((doc) => events.push(`close:${doc.uri}`)); + + const doc = await api.workspace.openDocument(uri); + expect(api.workspace.documents).toContain(doc); + + // `workspace.save`/`close` are not part of the public `WorkspaceNamespace` + // (Req 10.1 lists no such method — saving is a future built-in + // extension's command, design.md §13's editor-core), so this test + // drives the underlying `DocumentManager` directly to prove the + // *events* are correctly wired through `tecode.workspace`. + await documents.save(uri); + documents.close(uri); + + expect(events).toEqual([`open:${uri}`, `save:${uri}`, `close:${uri}`]); + + openSub.dispose(); + saveSub.dispose(); + closeSub.dispose(); + + // Register/dispose symmetry: none of the disposed listeners fire again. + await api.workspace.openDocument(uri); + expect(events).toHaveLength(3); + }); + + test("editor calls with no active editor no-op and deliver a HostError to the injected sink", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-api-contract-")); + root = await buildRoot(dir); + const { api, errors } = root; + + expect(api.editor.selections).toEqual([]); + expect(api.editor.cursor).toEqual({ line: 0, character: 0 }); + + const before = errors.length; + api.editor.revealLine(2); + api.editor.insertSnippet("snippet"); + api.editor.applyEdits([]); + + const newErrors = errors.slice(before); + expect(newErrors).toHaveLength(3); + expect(newErrors.every((e) => e.message.startsWith("No active editor"))).toBe(true); + }); + + test("a fixture extension touches every ctx.api namespace without throwing, then deactivates cleanly", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-api-contract-")); + root = await buildRoot(dir); + const { api } = root; + + const events: string[] = []; + const extensionModule = createFixtureExtensionModule(events); + const ctx: ExtensionContext = { + api, + extensionUri: pathToUri(join(dir, "fixture-ext")), + subscriptions: [], + storagePath: join(dir, ".tecode-storage", "fixture-ext"), + }; + + await extensionModule.activate?.(ctx); + + expect(events).toContain("activate:done"); + expect(events).toContain("commands:executed=1,list=1"); + expect(events.some((e) => e.startsWith("workspace.rootUri:"))).toBe(true); + expect(events).toContain("editor.selections:0"); + expect(events.some((e) => e.startsWith("ui.useTheme:object"))).toBe(true); + expect(events).toContain("languages.getLanguageId:plaintext"); + expect(events.some((e) => e.startsWith("themes.current:object"))).toBe(true); + expect(events).toContain("context.get:true"); + + // Deactivation (matches host/activation.ts's disposeSubscriptions: + // reverse push order), then the module's deactivate(). + for (let i = ctx.subscriptions.length - 1; i >= 0; i--) { + ctx.subscriptions[i]?.dispose(); + } + await extensionModule.deactivate?.(); + + expect(events).toContain("deactivate:done"); + + // Register/dispose symmetry: the fixture's command is gone post-teardown. + const result = await api.commands.execute("fixture.contract.activate"); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/core/src/api/create.ts b/packages/core/src/api/create.ts new file mode 100644 index 0000000..7275dea --- /dev/null +++ b/packages/core/src/api/create.ts @@ -0,0 +1,174 @@ +/** + * `createTecodeApi`: assembles the single frozen `tecode` object handed to + * every extension (Req 10.1, 10.2; design.md §12; Task 1.13). Each + * `tecode.*` namespace is either a thin, deliberately narrowed projection of + * an already-built core service (`commands`, `workspace`, `config`, + * `context`) or a documented no-op/placeholder stub (`window`, `editor`, + * `ui`, `languages`, `themes` — see `stubs.ts`'s TSDoc for why each is a + * stub today). + * + * **Narrowing, not re-implementing** (design.md §12's "prevent accidental + * monkey-patching across extensions" extends to the host itself): several + * services expose more than their `tecode.*` projection — + * `CommandRegistry.registerLazy` is host-internal (extensions never + * manifest-declare lazy commands directly), and `ContextService.onDidChange` + * is consumed by focus tracking/the keymap service, not by + * `tecode.context`. Building each namespace object explicitly, naming only + * the methods `@tecode/api` declares, is what keeps those extra surfaces + * off the object extensions actually receive — a wildcard spread would leak + * them. + * + * **Freezing**: every namespace object, and the aggregate object itself, is + * `Object.freeze`d (shallowly — design.md §12) so no extension can + * monkey-patch `tecode.commands.register` out from under another. Event/ + * method values are still the *same function references* the underlying + * service owns, so delegation needs no wrapper closures beyond narrowing. + */ + +import type { + CommandsNamespace, + ConfigNamespace, + ContextNamespace, + EditorNamespace, + FileSystem, + LanguagesNamespace, + Tecode, + ThemesNamespace, + UiNamespace, + Uri, + WindowNamespace, + WorkspaceNamespace, +} from "@tecode/api"; +import type { CommandRegistry } from "../commands/registry"; +import type { DocumentManager } from "../buffer/documentManager"; +import type { ConfigService } from "../config/service"; +import type { ContextService } from "../keymap/context"; +import type { StatusSink } from "../host/errors"; +import { + createEditorStub, + createLanguagesStub, + createThemesStub, + createUiStub, + createWindowStub, +} from "./stubs"; + +/** Dependencies {@link createTecodeApi} wires into the `tecode` object — + * one already-built instance of each core service (design.md §12). */ +export interface CreateTecodeApiDeps { + /** Backs `tecode.commands`. Only `register`/`execute`/`list` are + * exposed — `registerLazy` stays host-internal (see this module's + * TSDoc). */ + commands: CommandRegistry; + /** Backs `tecode.workspace.openDocument`/`documents`/`onDidOpen`/ + * `onDidClose`/`onDidSave`. */ + documents: DocumentManager; + /** Backs `tecode.workspace.fs`. */ + fs: FileSystem; + /** The open workspace's root, or `undefined` for a single-file session + * with no enclosing workspace (`tecode.workspace.rootUri`, Req 10.1). */ + rootUri?: Uri; + /** Backs `tecode.config.get`/`onDidChange` — `registerConfiguration` and + * `getKeybindingEntries` stay host-internal. */ + config: ConfigService; + /** Backs `tecode.context.set`/`get` — `onDidChange` stays host-internal + * (consumed by focus tracking and the keymap service, not extensions). */ + context: ContextService; + /** Where the `window`/`editor` stubs report user-facing errors (Req + * 10.1, design.md §12's "no-active-editor no-ops with a status-bar + * notice"). */ + sink: StatusSink; +} + +/** + * Build the complete `tecode` API object (Req 10.1, 10.2; design.md §12). + * The result — and each of its nine namespace objects — is shallowly + * frozen; assigning to (or deleting) any property on either throws in + * strict mode and is a silent no-op otherwise. + */ +export function createTecodeApi(deps: CreateTecodeApiDeps): Tecode { + const commandsNamespace: CommandsNamespace = Object.freeze({ + register: deps.commands.register, + execute: deps.commands.execute, + list: deps.commands.list, + }); + + const workspaceNamespace: WorkspaceNamespace = Object.freeze({ + get rootUri() { + return deps.rootUri; + }, + openDocument: deps.documents.openDocument, + get documents() { + return deps.documents.documents; + }, + fs: deps.fs, + onDidOpen: deps.documents.onDidOpen, + onDidClose: deps.documents.onDidClose, + onDidSave: deps.documents.onDidSave, + }); + + const configNamespace: ConfigNamespace = Object.freeze({ + get: deps.config.get, + onDidChange: deps.config.onDidChange, + }); + + const contextNamespace: ContextNamespace = Object.freeze({ + set: deps.context.set, + get: deps.context.get, + }); + + // The window/editor/ui/languages/themes stubs each return more than + // their `@tecode/api` namespace shape — a test-only introspection method + // proving register/dispose symmetry with nothing yet consuming the + // registration (`stubs.ts`'s `WindowStub`/`UiStub`/`LanguagesStub`/ + // `ThemesStub` TSDoc) — so, as with the delegated namespaces above, only + // the namespace's own declared members are copied into the frozen object + // extensions actually receive. + const themesStub = createThemesStub(); + const themesNamespace: ThemesNamespace = Object.freeze({ + register: themesStub.register, + get current() { + return themesStub.current; + }, + }); + + const windowStub = createWindowStub(); + const windowNamespace: WindowNamespace = Object.freeze({ + get activeEditor() { + return windowStub.activeEditor; + }, + showMessage: windowStub.showMessage, + showQuickPick: windowStub.showQuickPick, + showInputBox: windowStub.showInputBox, + setStatusBarItem: windowStub.setStatusBarItem, + }); + + const editorNamespace: EditorNamespace = Object.freeze(createEditorStub({ sink: deps.sink })); + + const uiStub = createUiStub({ getTheme: () => themesNamespace.current }); + const uiNamespace: UiNamespace = Object.freeze({ + registerView: uiStub.registerView, + useTheme: uiStub.useTheme, + List: uiStub.List, + Tree: uiStub.Tree, + Input: uiStub.Input, + Tabs: uiStub.Tabs, + }); + + const languagesStub = createLanguagesStub(); + const languagesNamespace: LanguagesNamespace = Object.freeze({ + register: languagesStub.register, + getLanguageId: languagesStub.getLanguageId, + }); + + return Object.freeze({ + commands: commandsNamespace, + workspace: workspaceNamespace, + window: windowNamespace, + editor: editorNamespace, + ui: uiNamespace, + config: configNamespace, + context: contextNamespace, + languages: languagesNamespace, + themes: themesNamespace, + }); +} diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 8cc5024..5cdad34 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -1,3 +1,23 @@ -// Placeholder for building the concrete `tecode` namespace object handed to -// extensions. -export const API_BUILDER_PLACEHOLDER = true; +// The `tecode` API object assembly (Req 10.1, 10.2, design.md §12): builds +// the frozen `Tecode` object handed to every extension, the no-op/ +// placeholder namespaces it delegates to ahead of later tasks giving them +// real backing, and the `"tecode"` module-alias registration extensions +// import against. +export { + createTecodeApi, + type CreateTecodeApiDeps, +} from "./create"; +export { + createBaseTheme, + createEditorStub, + createLanguagesStub, + createThemesStub, + createUiStub, + createWindowStub, + type LanguagesStub, + type RegisteredView, + type ThemesStub, + type UiStub, + type WindowStub, +} from "./stubs"; +export { registerTecodeAlias } from "./alias"; diff --git a/packages/core/src/api/stubs.test.ts b/packages/core/src/api/stubs.test.ts new file mode 100644 index 0000000..2b09b98 --- /dev/null +++ b/packages/core/src/api/stubs.test.ts @@ -0,0 +1,171 @@ +import { expect, test } from "bun:test"; +import type { HostError } from "../host/errors"; +import { + createBaseTheme, + createEditorStub, + createLanguagesStub, + createThemesStub, + createUiStub, + createWindowStub, +} from "./stubs"; + +/** A `StatusSink` stub that records every error it receives (matches + * `registry.test.ts`'s `createRecordingSink`). */ +function createRecordingSink() { + const errors: HostError[] = []; + return { + errors, + sink: { + error(err: HostError) { + errors.push(err); + }, + }, + }; +} + +test("createBaseTheme fills in every UiColorKey and is frozen", () => { + const theme = createBaseTheme(); + + // A representative sample of Req 7.2's six explicitly named keys, plus + // the count check below for the full 55-key set (theme.ts's TSDoc). + expect(theme.colors["editor.background"]).toBeDefined(); + expect(theme.colors["editor.foreground"]).toBeDefined(); + expect(theme.colors["sideBar.background"]).toBeDefined(); + expect(theme.colors["statusBar.background"]).toBeDefined(); + expect(theme.colors["tab.activeBackground"]).toBeDefined(); + expect(theme.colors["list.activeSelectionBackground"]).toBeDefined(); + expect(Object.keys(theme.colors)).toHaveLength(55); + + expect(Object.isFrozen(theme)).toBe(true); + expect(Object.isFrozen(theme.colors)).toBe(true); +}); + +test("createBaseTheme returns a fresh, independently-frozen object each call", () => { + const a = createBaseTheme(); + const b = createBaseTheme(); + + expect(a).not.toBe(b); + expect(a).toEqual(b); +}); + +test("window.setStatusBarItem: register/dispose symmetry", () => { + const window = createWindowStub(); + const item = { id: "test.item", text: "hello", side: "left" as const, priority: 0 }; + + const sub = window.setStatusBarItem(item); + expect(window.registeredStatusBarItems()).toEqual([item]); + + sub.dispose(); + expect(window.registeredStatusBarItems()).toEqual([]); + + // Idempotent: a second dispose() must not throw or double-remove. + expect(() => sub.dispose()).not.toThrow(); +}); + +test("window stub: no active editor, inert actions, resolved-undefined pickers", async () => { + const window = createWindowStub(); + + expect(window.activeEditor).toBeUndefined(); + expect(() => window.showMessage("hi")).not.toThrow(); + await expect(window.showQuickPick([])).resolves.toBeUndefined(); + await expect(window.showInputBox()).resolves.toBeUndefined(); +}); + +test("editor stub: no-active-editor reads and guarded sink notifications", () => { + const { errors, sink } = createRecordingSink(); + const editor = createEditorStub({ sink }); + + expect(editor.selections).toEqual([]); + expect(editor.cursor).toEqual({ line: 0, character: 0 }); + + editor.revealLine(5); + editor.insertSnippet("foo"); + editor.applyEdits([]); + + expect(errors).toHaveLength(3); + expect(errors.every((e) => e.message.startsWith("No active editor"))).toBe(true); +}); + +test("editor stub: cursor returns a fresh object each call (no shared mutable singleton)", () => { + const { sink } = createRecordingSink(); + const editor = createEditorStub({ sink }); + + const first = editor.cursor; + first.line = 99; + + expect(editor.cursor).toEqual({ line: 0, character: 0 }); +}); + +test("editor stub: a throwing sink does not make revealLine/insertSnippet/applyEdits throw", () => { + const throwingSink = { + error() { + throw new Error("sink boom"); + }, + }; + const editor = createEditorStub({ sink: throwingSink }); + + expect(() => editor.revealLine(1)).not.toThrow(); + expect(() => editor.insertSnippet("x")).not.toThrow(); + expect(() => editor.applyEdits([])).not.toThrow(); +}); + +test("ui.registerView: register/dispose symmetry", () => { + const ui = createUiStub({ getTheme: createBaseTheme }); + const component = () => undefined; + + const sub = ui.registerView("sidebar.view", "test.view", component); + expect(ui.registeredViews()).toEqual([{ slot: "sidebar.view", id: "test.view", component }]); + + sub.dispose(); + expect(ui.registeredViews()).toEqual([]); + expect(() => sub.dispose()).not.toThrow(); +}); + +test("ui.useTheme delegates to the injected getTheme", () => { + const theme = createBaseTheme(); + const ui = createUiStub({ getTheme: () => theme }); + + expect(ui.useTheme()).toBe(theme); +}); + +test("ui stub's List/Tree/Input/Tabs are inert placeholder components", () => { + const ui = createUiStub({ getTheme: createBaseTheme }); + + expect(ui.List({})).toBeUndefined(); + expect(ui.Tree({})).toBeUndefined(); + expect(ui.Input({})).toBeUndefined(); + expect(ui.Tabs({})).toBeUndefined(); +}); + +test("languages.register: register/dispose symmetry, getLanguageId always 'plaintext'", () => { + const languages = createLanguagesStub(); + const contribution = { + id: "fixture-lang", + extensions: [".fx"], + grammar: "g.wasm", + highlights: "h.scm", + }; + + const sub = languages.register(contribution); + expect(languages.registeredContributions()).toEqual([contribution]); + + expect(languages.getLanguageId("file:///a.fx")).toBe("plaintext"); + + sub.dispose(); + expect(languages.registeredContributions()).toEqual([]); + expect(() => sub.dispose()).not.toThrow(); +}); + +test("themes.register: register/dispose symmetry; current is unaffected by registration", () => { + const themes = createThemesStub(); + const contribution = { id: "fixture-theme", label: "Fixture", path: "theme.json" }; + const beforeCurrent = themes.current; + + const sub = themes.register(contribution); + expect(themes.registeredContributions()).toEqual([contribution]); + expect(themes.current).toBe(beforeCurrent); + + sub.dispose(); + expect(themes.registeredContributions()).toEqual([]); + expect(() => sub.dispose()).not.toThrow(); +}); diff --git a/packages/core/src/api/stubs.ts b/packages/core/src/api/stubs.ts new file mode 100644 index 0000000..476fc21 --- /dev/null +++ b/packages/core/src/api/stubs.ts @@ -0,0 +1,365 @@ +/** + * Typed no-op/placeholder implementations of the `tecode.window`, + * `tecode.editor`, `tecode.ui`, `tecode.languages`, and `tecode.themes` + * namespaces (Req 10.1, design.md §12), for {@link createTecodeApi} + * (`create.ts`) to wire in ahead of the tasks that give them real backing: + * + * - `window`/`editor` depend on the UI shell (Task 1.14) and active-editor + * tracking (a later editor task) — design.md §12 says as much for + * `window.showQuickPick`/`showInputBox` ("implemented on the shell's + * modal layer... since the palette and pickers must exist before any + * extension UI"). Until then, every read reports "nothing is active" and + * every action reports through the injected {@link StatusSink} rather + * than silently doing nothing (Req 10.1's contract stays observable even + * before there is a UI to observe). + * - `languages`/`themes` registration is real (a `register` call returns a + * working, disposable registration extensions can rely on immediately), + * but nothing yet *consumes* the registry — grammar/theme resolution + * lands in later tasks (design.md §9, §8.2). `themes.current` returns a + * hardcoded base palette (design.md §12's own note that `ThemeProvider` + * starts with "a hardcoded base palette for now", Task 1.14) until a real + * theme loader can resolve one. + * - `ui.registerView` is likewise a real, disposable registration with no + * renderer behind it yet (the UI shell's slot registry, Task 1.14); + * `List`/`Tree`/`Input`/`Tabs` are inert placeholder components (no + * dependency on React here — `@tecode/api`'s `ComponentType` is + * deliberately framework-agnostic, design.md §12). + * + * None of this throws: every method here follows the same never-throw + * discipline as the rest of core (`registry.ts`, `documentManager.ts`, + * `service.ts`) so a third-party extension calling into an unimplemented + * corner of the API degrades gracefully instead of crashing the host. + */ + +import type { + ComponentType, + Disposable, + EditorNamespace, + LanguageContribution, + LanguagesNamespace, + Position, + ResolvedTheme, + RGB, + SlotId, + StatusBarItem, + ThemeContribution, + ThemesNamespace, + UiColorKey, + UiNamespace, + WindowNamespace, +} from "@tecode/api"; +import type { StatusSink } from "../host/errors"; + +/** + * The house "`Set` + guarded idempotent dispose" registration pattern + * (mirrors `commands/registry.ts`'s `storeEntry`, `documentManager.ts`'s + * `makeEvent`): `register` adds `entry` and returns a `Disposable` that + * removes exactly that entry, safe to call more than once. Shared by every + * stub registry below (`languages.register`, `themes.register`, + * `ui.registerView`, `window.setStatusBarItem`) so register/dispose + * symmetry is identical, and independently testable, across all of them. + */ +function createRegistrySet(): { + register(entry: T): Disposable; + entries(): readonly T[]; +} { + const set = new Set(); + return { + register(entry: T): Disposable { + set.add(entry); + let disposed = false; + return { + dispose() { + if (disposed) return; + disposed = true; + set.delete(entry); + }, + }; + }, + entries() { + return Array.from(set); + }, + }; +} + +/** The primary cursor's placeholder position when there is no active + * editor: the document origin (Req 10.1's `editor.cursor`). A fresh object + * every call (not a shared singleton) — `Position` has no protection + * against a caller mutating a returned instance, and a shared reference + * would let one extension's accidental write leak into every subsequent + * read across every extension. */ +function originPosition(): Position { + return { line: 0, character: 0 }; +} + +/** + * A hardcoded base color palette (design.md §12, §9's "a theme that omits a + * key falls back to the built-in base palette for it") — every + * {@link UiColorKey} filled in with a plain dark-neutral scheme, standing in + * until Task 1.14's `ThemeProvider`/a real theme loader (design.md §9) + * resolves an actual theme. Not exported: `themes.current` is the only + * sanctioned way to read it, so a future replacement of this value doesn't + * ripple through other modules' imports. + */ +const BG: RGB = { r: 30, g: 30, b: 30 }; +const FG: RGB = { r: 212, g: 212, b: 212 }; +const ACCENT: RGB = { r: 0, g: 122, b: 204 }; +const BORDER: RGB = { r: 60, g: 60, b: 60 }; +const SELECTION: RGB = { r: 38, g: 79, b: 120 }; +const MUTED: RGB = { r: 133, g: 133, b: 133 }; +const WHITE: RGB = { r: 255, g: 255, b: 255 }; + +const BASE_COLORS: Record = { + focusBorder: ACCENT, + foreground: FG, + "editor.background": BG, + "editor.foreground": FG, + "editor.lineHighlightBackground": { r: 40, g: 40, b: 40 }, + "editor.selectionBackground": SELECTION, + "editor.selectionForeground": FG, + "editor.inactiveSelectionBackground": { r: 38, g: 53, b: 71 }, + "editorLineNumber.foreground": MUTED, + "editorLineNumber.activeForeground": FG, + "editorCursor.foreground": FG, + "editorIndentGuide.background": BORDER, + "editorIndentGuide.activeBackground": { r: 99, g: 99, b: 99 }, + "editorWhitespace.foreground": { r: 99, g: 99, b: 99 }, + "activityBar.background": { r: 51, g: 51, b: 51 }, + "activityBar.foreground": FG, + "activityBar.inactiveForeground": MUTED, + "activityBar.border": BORDER, + "activityBarBadge.background": ACCENT, + "activityBarBadge.foreground": WHITE, + "sideBar.background": { r: 37, g: 37, b: 38 }, + "sideBar.foreground": FG, + "sideBar.border": BORDER, + "sideBarTitle.foreground": FG, + "sideBarSectionHeader.background": { r: 51, g: 51, b: 51 }, + "statusBar.background": ACCENT, + "statusBar.foreground": WHITE, + "statusBar.border": BORDER, + "statusBar.debuggingBackground": { r: 205, g: 98, b: 14 }, + "statusBarItem.hoverBackground": { r: 0, g: 99, b: 166 }, + "tab.activeBackground": BG, + "tab.activeForeground": FG, + "tab.inactiveBackground": { r: 45, g: 45, b: 45 }, + "tab.inactiveForeground": MUTED, + "tab.border": BORDER, + "tab.activeBorder": ACCENT, + "panel.background": BG, + "panel.border": BORDER, + "panelTitle.activeForeground": FG, + "panelTitle.inactiveForeground": MUTED, + "input.background": { r: 60, g: 60, b: 60 }, + "input.foreground": FG, + "input.border": BORDER, + "input.placeholderForeground": MUTED, + "list.activeSelectionBackground": SELECTION, + "list.activeSelectionForeground": FG, + "list.inactiveSelectionBackground": { r: 55, g: 55, b: 55 }, + "list.hoverBackground": { r: 44, g: 44, b: 44 }, + "list.focusBackground": SELECTION, + "scrollbarSlider.background": { r: 100, g: 100, b: 100 }, + "scrollbarSlider.hoverBackground": { r: 120, g: 120, b: 120 }, + "badge.background": ACCENT, + "badge.foreground": WHITE, + "button.background": ACCENT, + "button.foreground": WHITE, +}; + +/** The placeholder {@link ResolvedTheme} `themes.current` returns until a + * real theme loader lands (see this module's TSDoc). No syntax-highlight + * `tokens` are populated — `Partial` means an empty object already + * satisfies the type, and no consumer resolves capture styles yet. Frozen + * (both the theme object and its `colors` map) so a caller mutating the + * value it read back cannot corrupt every other extension's later read of + * the same singleton `themes.current` reference. */ +export function createBaseTheme(): ResolvedTheme { + return Object.freeze({ colors: Object.freeze({ ...BASE_COLORS }), tokens: {} }); +} + +/** + * {@link createWindowStub}'s return type: `WindowNamespace` plus + * `registeredStatusBarItems`, a test-only introspection hook proving + * `setStatusBarItem`'s register/dispose symmetry (there is no renderer yet + * to observe it through, design.md §12) — `create.ts` narrows this away + * when assembling the public, frozen `tecode.window` namespace, matching + * its own "narrowing, not re-implementing" design (`create.ts`'s TSDoc). + */ +export interface WindowStub extends WindowNamespace { + /** Every currently-registered status bar item; an item's entry is gone + * once its `Disposable` has been disposed. */ + registeredStatusBarItems(): readonly StatusBarItem[]; +} + +/** + * Build the `tecode.window` stub (Req 10.1). No UI shell exists yet (Task + * 1.14) so every read reports "nothing active/no picker" and every action + * is inert; `setStatusBarItem` is a real, disposable registration with no + * renderer behind it yet. + */ +export function createWindowStub(): WindowStub { + const statusBarItems = createRegistrySet(); + return { + get activeEditor() { + return undefined; + }, + showMessage() { + // No UI shell yet (Task 1.14) — inert until the shell's notification + // area exists. Never throws. + }, + showQuickPick() { + return Promise.resolve(undefined); + }, + showInputBox() { + return Promise.resolve(undefined); + }, + setStatusBarItem(item: StatusBarItem) { + return statusBarItems.register(item); + }, + registeredStatusBarItems: statusBarItems.entries, + }; +} + +/** + * Build the `tecode.editor` stub (Req 10.1, design.md §12: "calls made with + * no active editor no-op with a status-bar notice"). There is no + * active-editor tracking yet, so this is *always* the no-active-editor + * case — `selections` is empty, `cursor` is the document origin, and every + * mutating call reports through `sink` rather than doing anything. + */ +export function createEditorStub(deps: { sink: StatusSink }): EditorNamespace { + const { sink } = deps; + + function notifyNoActiveEditor(action: string): void { + // Guarded: a broken/throwing sink must not make an editor call throw + // (matches registry.ts's/documentManager.ts's notifySafely). + try { + sink.error({ message: `No active editor to ${action}.` }); + } catch { + // Swallowed — see this module's TSDoc on the never-throw discipline. + } + } + + return { + get selections() { + return []; + }, + get cursor() { + return originPosition(); + }, + revealLine(line: number) { + notifyNoActiveEditor(`reveal line ${line}`); + }, + insertSnippet() { + notifyNoActiveEditor("insert a snippet"); + }, + applyEdits() { + notifyNoActiveEditor("apply edits"); + }, + }; +} + +/** An inert placeholder `ComponentType` — `@tecode/api` has no dependency + * on React (or any UI framework, design.md §12), and no renderer exists + * yet to give `List`/`Tree`/`Input`/`Tabs` real behavior. */ +const notImplementedComponent: ComponentType = () => undefined; + +/** One registered `ui.registerView` call. */ +export interface RegisteredView { + slot: SlotId; + id: string; + component: ComponentType; +} + +/** {@link createUiStub}'s return type — see {@link WindowStub}'s TSDoc for + * why a stub factory returns more than its `@tecode/api` namespace type. */ +export interface UiStub extends UiNamespace { + /** Every currently-registered view; an entry is gone once its + * `Disposable` has been disposed. */ + registeredViews(): readonly RegisteredView[]; +} + +/** + * Build the `tecode.ui` stub (Req 10.1, 6.3). `registerView` is a real, + * disposable registration (the UI shell's slot registry, Task 1.14, is the + * eventual consumer); `useTheme` reads whatever `getTheme` currently + * returns, so it stays in sync with `tecode.themes.current` without this + * module depending on `themes.ts` directly (the two are wired together in + * `create.ts`). + */ +export function createUiStub(deps: { getTheme: () => ResolvedTheme }): UiStub { + const views = createRegistrySet(); + return { + registerView(slot: SlotId, id: string, component: ComponentType) { + return views.register({ slot, id, component }); + }, + useTheme() { + return deps.getTheme(); + }, + List: notImplementedComponent, + Tree: notImplementedComponent, + Input: notImplementedComponent, + Tabs: notImplementedComponent, + registeredViews: views.entries, + }; +} + +/** {@link createLanguagesStub}'s return type — see {@link WindowStub}'s + * TSDoc for why a stub factory returns more than its `@tecode/api` + * namespace type. */ +export interface LanguagesStub extends LanguagesNamespace { + /** Every currently-registered contribution; an entry is gone once its + * `Disposable` has been disposed. */ + registeredContributions(): readonly LanguageContribution[]; +} + +/** + * Build the `tecode.languages` stub (Req 8.2, 10.1). `register` is a real, + * disposable registration; `getLanguageId` always reports `"plaintext"` + * (Req 8.3's documented fallback) since matching a `Uri` against + * registered contributions is the real language registry's job (Task + * 2.8) — `DocumentManager.resolveLanguageId` already returns the same + * stub value independently (`buffer/documentManager.ts`) until that lands. + */ +export function createLanguagesStub(): LanguagesStub { + const registrations = createRegistrySet(); + return { + register(contribution: LanguageContribution) { + return registrations.register(contribution); + }, + getLanguageId() { + return "plaintext"; + }, + registeredContributions: registrations.entries, + }; +} + +/** {@link createThemesStub}'s return type — see {@link WindowStub}'s TSDoc + * for why a stub factory returns more than its `@tecode/api` namespace + * type. */ +export interface ThemesStub extends ThemesNamespace { + /** Every currently-registered contribution; an entry is gone once its + * `Disposable` has been disposed. */ + registeredContributions(): readonly ThemeContribution[]; +} + +/** + * Build the `tecode.themes` stub (Req 7, 10.1). `register` is a real, + * disposable registration; `current` always returns the hardcoded + * {@link createBaseTheme} palette until a real theme loader (design.md §9) + * can resolve a registered theme and track the active selection. + */ +export function createThemesStub(): ThemesStub { + const registrations = createRegistrySet(); + const baseTheme = createBaseTheme(); + return { + register(contribution: ThemeContribution) { + return registrations.register(contribution); + }, + get current() { + return baseTheme; + }, + registeredContributions: registrations.entries, + }; +} diff --git a/packages/core/src/api/tecode-module.d.ts b/packages/core/src/api/tecode-module.d.ts new file mode 100644 index 0000000..653c895 --- /dev/null +++ b/packages/core/src/api/tecode-module.d.ts @@ -0,0 +1,55 @@ +/** + * Ambient type declaration for the `"tecode"` module specifier (Req 10.1, + * design.md §2, §12). `alias.ts`'s `registerTecodeAlias` binds this + * specifier at *runtime* via `Bun.plugin`'s virtual-module hook — + * TypeScript has no static knowledge of that binding on its own, so this + * file supplies it: each `tecode.*` namespace, re-exported here as a named + * export of type only, mirrors exactly what `Bun.plugin`'s `loader: + * "object"` actually does at runtime (project the registered object's own + * enumerable properties onto the module's named exports — `alias.ts` + * registers the `Tecode` object itself, whose own properties are these + * nine namespaces). + * + * **Why this file, here, is enough**: this repo's root `tsconfig.json` sets + * no `"include"`, so a single `bunx tsc --noEmit` run from the repo root + * compiles one Program spanning every package's `src/` (verified: it lists + * files from `api`, `core`, `builtin`, and `cli` together) — and an ambient + * `declare module` is visible to every file in that one Program regardless + * of which package's `src/` it physically lives in. `packages/cli/src/ + * main.ts`'s `import ... from "tecode"` therefore type-checks even though + * this declaration sits under `packages/core/src/api/`. + * + * **Limitation**: a *per-package* `tsc` invocation scoped to just + * `packages/cli/tsconfig.json` (its `"include": ["src"]` only reaches + * `packages/cli/src/`) would NOT see this file and would fail to resolve + * `"tecode"`. No such per-package script exists in this repo today — the + * only typecheck this codebase runs is the root, whole-Program + * `bunx tsc --noEmit` — so this is a documented latent gap rather than a + * live break; if a per-package typecheck is ever introduced, either + * duplicate this declaration under `packages/cli/src/` or add a `"files"`/ + * `"types"` reference from `cli`'s `tsconfig.json` to this one. + */ + +declare module "tecode" { + import type { + CommandsNamespace, + ConfigNamespace, + ContextNamespace, + EditorNamespace, + LanguagesNamespace, + ThemesNamespace, + UiNamespace, + WindowNamespace, + WorkspaceNamespace, + } from "@tecode/api"; + + export const commands: CommandsNamespace; + export const workspace: WorkspaceNamespace; + export const window: WindowNamespace; + export const editor: EditorNamespace; + export const ui: UiNamespace; + export const config: ConfigNamespace; + export const context: ContextNamespace; + export const languages: LanguagesNamespace; + export const themes: ThemesNamespace; +} diff --git a/packages/core/src/buffer/fileSystem.test.ts b/packages/core/src/buffer/fileSystem.test.ts new file mode 100644 index 0000000..bb740e4 --- /dev/null +++ b/packages/core/src/buffer/fileSystem.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile as nodeWriteFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { FileChangeEvent } from "@tecode/api"; +import type { HostError } from "../host/errors"; +import { createHostLog } from "../host/errors"; +import { createFileSystem } from "./fileSystem"; +import { pathToUri } from "./uri"; + +/** Poll `predicate` until it is true or `timeoutMs` elapses (matches + * `config/service.test.ts`'s `waitFor` — real `fs.watch` delivery is not + * synchronous). */ +async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error("waitFor: timed out waiting for predicate"); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +describe("createFileSystem", () => { + let dir: string; + + afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + test("write then read round-trips bytes", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const fs = createFileSystem(); + const uri = pathToUri(join(dir, "hello.txt")); + + await fs.write(uri, new TextEncoder().encode("hello, tecode")); + const bytes = await fs.read(uri); + + expect(new TextDecoder().decode(bytes)).toBe("hello, tecode"); + }); + + test("stat reports type/size/mtime/ctime for a file", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const fs = createFileSystem(); + const filePath = join(dir, "file.txt"); + await nodeWriteFile(filePath, "0123456789", "utf8"); + + const stat = await fs.stat(pathToUri(filePath)); + + expect(stat.type).toBe("file"); + expect(stat.size).toBe(10); + expect(typeof stat.mtime).toBe("number"); + expect(typeof stat.ctime).toBe("number"); + }); + + test("stat reports type 'directory' for a directory", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const fs = createFileSystem(); + + const stat = await fs.stat(pathToUri(dir)); + + expect(stat.type).toBe("directory"); + }); + + test("readdir lists entries with name and type", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + await nodeWriteFile(join(dir, "a.txt"), "a", "utf8"); + await mkdir(join(dir, "sub")); + const fs = createFileSystem(); + + const entries = await fs.readdir(pathToUri(dir)); + + expect(entries).toHaveLength(2); + expect(entries).toContainEqual({ name: "a.txt", type: "file" }); + expect(entries).toContainEqual({ name: "sub", type: "directory" }); + }); + + test("read rejects for a missing file", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const fs = createFileSystem(); + + await expect(fs.read(pathToUri(join(dir, "missing.txt")))).rejects.toThrow(); + }); + + describe("watch — real fs.watch integration (design.md §16)", () => { + test("reports a 'changed' event when a watched file is modified", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-watch-")); + const filePath = join(dir, "watched.txt"); + await nodeWriteFile(filePath, "v1", "utf8"); + const fs = createFileSystem(); + + const events: FileChangeEvent[] = []; + const sub = fs.watch(pathToUri(filePath), (e) => events.push(e)); + try { + await nodeWriteFile(filePath, "v2", "utf8"); + await waitFor(() => events.length > 0); + expect(events.some((e) => e.type === "changed" || e.type === "created")).toBe(true); + } finally { + sub.dispose(); + } + }, 10_000); + + test("reports a 'created' event for a new file inside a watched directory", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-watch-")); + const fs = createFileSystem(); + const events: FileChangeEvent[] = []; + const sub = fs.watch(pathToUri(dir), (e) => events.push(e)); + try { + await nodeWriteFile(join(dir, "new.txt"), "hi", "utf8"); + await waitFor(() => events.some((e) => e.type === "created")); + expect(events.some((e) => e.uri === pathToUri(join(dir, "new.txt")))).toBe(true); + } finally { + sub.dispose(); + } + }, 10_000); + + test("reports a 'deleted' event for a removed file inside a watched directory", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-watch-")); + const filePath = join(dir, "doomed.txt"); + await nodeWriteFile(filePath, "bye", "utf8"); + const fs = createFileSystem(); + const events: FileChangeEvent[] = []; + const sub = fs.watch(pathToUri(dir), (e) => events.push(e)); + try { + await rm(filePath); + await waitFor(() => events.some((e) => e.type === "deleted")); + } finally { + sub.dispose(); + } + }, 10_000); + + test("dispose is idempotent and stops delivering events", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-watch-")); + const filePath = join(dir, "watched.txt"); + await nodeWriteFile(filePath, "v1", "utf8"); + const fs = createFileSystem(); + const events: FileChangeEvent[] = []; + const sub = fs.watch(pathToUri(filePath), (e) => events.push(e)); + + sub.dispose(); + sub.dispose(); // must not throw + + await nodeWriteFile(filePath, "v2", "utf8"); + // Give a real watcher a moment to (not) fire — there is nothing to + // poll for on the "it never happens" side, so a short fixed wait is + // the pragmatic choice here. + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(events).toHaveLength(0); + }); + + test("a throwing listener is caught and logged, not thrown", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-watch-")); + const filePath = join(dir, "watched.txt"); + await nodeWriteFile(filePath, "v1", "utf8"); + const log = createHostLog(); + const fs = createFileSystem({ log }); + + const sub = fs.watch(pathToUri(filePath), () => { + throw new Error("listener boom"); + }); + try { + await nodeWriteFile(filePath, "v2", "utf8"); + await waitFor(() => + log.entries().some((e: { error: HostError }) => e.error.message.includes("listener boom")), + ); + } finally { + sub.dispose(); + } + }, 10_000); + + test("watching a path that does not exist does not throw and returns a disposable no-op", () => { + const fs = createFileSystem(); + const sub = fs.watch(pathToUri("/nonexistent/path/for/tecode/tests"), () => {}); + expect(() => sub.dispose()).not.toThrow(); + }); + }); +}); diff --git a/packages/core/src/buffer/fileSystem.ts b/packages/core/src/buffer/fileSystem.ts new file mode 100644 index 0000000..ad98b04 --- /dev/null +++ b/packages/core/src/buffer/fileSystem.ts @@ -0,0 +1,247 @@ +/** + * `createFileSystem`: the implementation behind `tecode.workspace.fs` (Req + * 10.1, 10.2; design.md §12). Wraps `node:fs/promises` for + * read/write/stat/readdir and `node:fs`'s `watch` for change notification — + * a thin pass-through, not a virtual filesystem, but kept behind this one + * seam so a future virtual/sandboxed filesystem only has to replace this + * module (design.md §12's "wraps `node:fs/promises` + `fs.watch` behind the + * API so future virtual filesystems stay possible"). + * + * **No sandboxing** (Req 10.2, explicitly out of scope for the MVP): + * extensions get the same filesystem access as the host process. What this + * module *does* guarantee, matching every other core service, is that it + * never crashes the process — a synchronous `fs.watch` failure (e.g. the + * path does not exist yet) or an asynchronous watcher error is reported + * (when a {@link HostLog} is injected) and swallowed rather than thrown or + * left as an unhandled `"error"` event (mirrors `config/service.ts`'s + * `createNodeConfigFs`). + */ + +import * as nodeFs from "node:fs/promises"; +import { watch as nodeFsWatch, statSync, type Dirent } from "node:fs"; +import { join } from "node:path"; +import type { + DirEntry, + Disposable, + FileChangeEvent, + FileChangeType, + FileStat, + FileSystem, + FileType, + Listener, + Uri, +} from "@tecode/api"; +import type { HostError, HostLog } from "../host/errors"; +import { pathToUri, uriToPath } from "./uri"; + +/** Dependencies for {@link createFileSystem}. Every field is optional — + * `createFileSystem()` with no arguments is a complete, working + * filesystem; `log` only adds visibility into watch failures that would + * otherwise be silently swallowed. */ +export interface FileSystemDeps { + /** Structured log for watch-setup and asynchronous watcher failures + * (design.md §14). Omitted (the default) swallows these silently — + * `FileSystem.watch` still never throws or crashes either way. */ + log?: HostLog; +} + +/** Render a caught `unknown` value as a message string without risking a + * second throw (matches `documentManager.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"; + } +} + +/** Map a `node:fs` `Dirent`/`Stats`-shaped pair of directory/file/symlink + * checks onto {@link FileType}. */ +function classify(entry: { + isDirectory(): boolean; + isSymbolicLink(): boolean; + isFile(): boolean; +}): FileType { + if (entry.isDirectory()) return "directory"; + if (entry.isSymbolicLink()) return "symlink"; + if (entry.isFile()) return "file"; + return "unknown"; +} + +/** + * Build a `FileSystem` (Req 10.1's `workspace.fs`, Req 10.2). `deps.log` is + * optional — see {@link FileSystemDeps}. + */ +export function createFileSystem(deps: FileSystemDeps = {}): FileSystem { + function logSafely(err: HostError): void { + if (!deps.log) return; + try { + deps.log.append("warning", err); + } catch { + // Swallowed: reporting a reporting failure has nowhere left to go. + } + } + + async function read(uri: Uri): Promise { + return nodeFs.readFile(uriToPath(uri)); + } + + async function write(uri: Uri, content: Uint8Array): Promise { + await nodeFs.writeFile(uriToPath(uri), content); + } + + /** + * `stat` reports on the target a symlink points at (size/mtime/ctime of + * the resolved file) while still reporting `type: "symlink"` for it — the + * one case that needs both an `lstat` (to detect the symlink without + * following it) and a `stat` (to describe what it resolves to). A broken + * symlink (the `stat` follow-through fails) falls back to the link's own + * metadata rather than rejecting outright. + */ + async function stat(uri: Uri): Promise { + const path = uriToPath(uri); + const linkStat = await nodeFs.lstat(path); + if (linkStat.isSymbolicLink()) { + try { + const target = await nodeFs.stat(path); + return { + type: "symlink", + size: target.size, + mtime: target.mtimeMs, + ctime: target.ctimeMs, + }; + } catch { + return { + type: "symlink", + size: linkStat.size, + mtime: linkStat.mtimeMs, + ctime: linkStat.ctimeMs, + }; + } + } + return { + type: classify(linkStat), + size: linkStat.size, + mtime: linkStat.mtimeMs, + ctime: linkStat.ctimeMs, + }; + } + + async function readdir(uri: Uri): Promise { + const path = uriToPath(uri); + const entries = await nodeFs.readdir(path, { withFileTypes: true }); + return entries.map((entry: Dirent) => ({ + name: entry.name, + type: classify(entry), + })); + } + + /** + * `node:fs.watch`'s `(eventType, filename)` callback reports `filename` + * relative to the watched directory when watching a directory, but the + * watched file's own basename (or nothing, on some platforms) when + * watching a single file — determined once, synchronously, at watch + * setup via `statSync` (a failure here, e.g. the path does not exist yet, + * falls back to treating it as a single-file watch: an MVP limitation + * matching `config/service.ts`'s "watch attempted once at startup" note). + * + * `node:fs.watch`'s `"rename"` event is an umbrella for create, delete, + * and rename — there is no portable way to tell which without checking + * the filesystem, so a `"rename"` event triggers a best-effort + * existence check: the path still existing reports `"created"`, + * otherwise `"deleted"`. A rapid create-then-delete can race this check + * and land on `"deleted"`; acceptable for the MVP (no consumer needs + * exact create/delete disambiguation under that race yet). + */ + function watch(uri: Uri, listener: Listener): Disposable { + const path = uriToPath(uri); + + let isDirectory = false; + try { + isDirectory = statSync(path).isDirectory(); + } catch { + // Path does not exist (yet) or is inaccessible — see TSDoc above. + } + + let disposed = false; + + function notify(event: FileChangeEvent): void { + if (disposed) return; + try { + listener(event); + } catch (cause) { + logSafely({ + message: `FileSystem watch listener for "${uri}" threw: ${describeError(cause)}`, + path: uri, + }); + } + } + + function resolveAffected(filename: string | Buffer | null): { path: string; uri: Uri } { + const name = typeof filename === "string" ? filename : undefined; + const affectedPath = isDirectory && name ? join(path, name) : path; + return { path: affectedPath, uri: pathToUri(affectedPath) }; + } + + function handleEvent(eventType: string, filename: string | Buffer | null): void { + if (disposed) return; + const affected = resolveAffected(filename); + + if (eventType === "change") { + notify({ type: "changed", uri: affected.uri }); + return; + } + + // "rename" — resolve created vs. deleted (see TSDoc above). + void nodeFs.stat(affected.path).then( + () => notify({ type: "created" as FileChangeType, uri: affected.uri }), + () => notify({ type: "deleted" as FileChangeType, uri: affected.uri }), + ); + } + + let watcher: ReturnType; + try { + watcher = nodeFsWatch(path, (eventType, filename) => handleEvent(eventType, filename)); + } catch (cause) { + logSafely({ + message: `Could not watch "${uri}" for changes: ${describeError(cause)}`, + path: uri, + }); + return { dispose() {} }; + } + + // An FSWatcher is an EventEmitter: an "error" event with no listener is + // rethrown as an uncaught exception and kills the whole process. + // Absorb it, close the now-dead watcher, and report rather than crash + // (matches config/service.ts's createNodeConfigFs). + watcher.on("error", (cause) => { + try { + watcher.close(); + } catch { + // Already closed/broken — nothing more to release. + } + logSafely({ + message: + `Watcher for "${uri}" failed: ${describeError(cause)}. Live updates for this ` + + `path stop until a new watch() call.`, + path: uri, + }); + }); + + return { + dispose() { + if (disposed) return; + disposed = true; + try { + watcher.close(); + } catch { + // Best-effort — see documentManager.ts's/service.ts's dispose(). + } + }, + }; + } + + return { read, write, stat, readdir, watch }; +} diff --git a/packages/core/src/buffer/index.ts b/packages/core/src/buffer/index.ts index 1e11993..1f69b77 100644 --- a/packages/core/src/buffer/index.ts +++ b/packages/core/src/buffer/index.ts @@ -35,3 +35,4 @@ export { type DocumentManagerFs, } from "./documentManager"; export { pathToUri, uriToPath } from "./uri"; +export { createFileSystem, type FileSystemDeps } from "./fileSystem"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 76b813a..afc9a42 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -96,6 +96,8 @@ export { type UndoEntry, type UndoStack, type UndoStackDeps, + createFileSystem, + type FileSystemDeps, } from "./buffer/index"; export { UI_PLACEHOLDER } from "./ui/index"; export { @@ -108,4 +110,19 @@ export { type JsoncParseResult, type JsoncSuccess, } from "./config/index"; -export { API_BUILDER_PLACEHOLDER } from "./api/index"; +export { + createBaseTheme, + createEditorStub, + createLanguagesStub, + createTecodeApi, + createThemesStub, + createUiStub, + createWindowStub, + registerTecodeAlias, + type CreateTecodeApiDeps, + type LanguagesStub, + type RegisteredView, + type ThemesStub, + type UiStub, + type WindowStub, +} from "./api/index"; From a08921d242d8ed58f9696285005f26e845fb95e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 23:47:06 +0000 Subject: [PATCH 2/2] Deep-freeze base theme RGB values, harden CI token scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createBaseTheme now hands out frozen per-key COPIES of the palette's RGB values (freezing only the colors map left the shared BG/FG/... module constants mutable through a returned theme, corrupting every alias and later call); tokens is frozen too. Regression test asserts mutation throws and nothing leaks across keys or calls. - ci.yml: job permissions restricted to contents: read and checkout no longer persists credentials — no step performs authenticated writes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- .github/workflows/ci.yml | 7 +++++++ packages/core/src/api/stubs.test.ts | 17 +++++++++++++++++ packages/core/src/api/stubs.ts | 10 +++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f187c9..05353c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,18 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # CI only reads the tree — never persist the token into the local + # git config for later steps. + persist-credentials: false - uses: oven-sh/setup-bun@v2 with: bun-version: latest diff --git a/packages/core/src/api/stubs.test.ts b/packages/core/src/api/stubs.test.ts index 2b09b98..a53f336 100644 --- a/packages/core/src/api/stubs.test.ts +++ b/packages/core/src/api/stubs.test.ts @@ -38,6 +38,23 @@ test("createBaseTheme fills in every UiColorKey and is frozen", () => { expect(Object.isFrozen(theme)).toBe(true); expect(Object.isFrozen(theme.colors)).toBe(true); + expect(Object.isFrozen(theme.tokens)).toBe(true); +}); + +test("createBaseTheme's RGB values are frozen copies — mutating one never leaks anywhere", () => { + const theme = createBaseTheme(); + const bg = theme.colors["editor.background"]; + + expect(Object.isFrozen(bg)).toBe(true); + // Strict-mode assignment to a frozen object throws, so the shared base + // constants (editor/panel/tab all alias the same palette entry) can + // never be corrupted through a returned theme. + expect(() => { + "use strict"; + (bg as { r: number }).r = 0; + }).toThrow(); + expect(theme.colors["panel.background"].r).toBe(bg.r); + expect(createBaseTheme().colors["editor.background"].r).toBe(bg.r); }); test("createBaseTheme returns a fresh, independently-frozen object each call", () => { diff --git a/packages/core/src/api/stubs.ts b/packages/core/src/api/stubs.ts index 476fc21..f1ff089 100644 --- a/packages/core/src/api/stubs.ts +++ b/packages/core/src/api/stubs.ts @@ -175,7 +175,15 @@ const BASE_COLORS: Record = { * value it read back cannot corrupt every other extension's later read of * the same singleton `themes.current` reference. */ export function createBaseTheme(): ResolvedTheme { - return Object.freeze({ colors: Object.freeze({ ...BASE_COLORS }), tokens: {} }); + // Deep-freeze via per-key RGB COPIES: the spread above only copies the + // map, so freezing it alone would still hand out the mutable shared + // BG/FG/... module constants — `theme.colors["editor.background"].r = 0` + // would then corrupt every alias of that constant (panel.background, + // tab.activeBackground) and every later createBaseTheme() result. + const colors = Object.fromEntries( + Object.entries(BASE_COLORS).map(([key, rgb]) => [key, Object.freeze({ ...rgb })]), + ) as Record; + return Object.freeze({ colors: Object.freeze(colors), tokens: Object.freeze({}) }); } /**