From 47d9f48340133647f4465899d851fd7b260d5cb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 11:06:02 +0000 Subject: [PATCH 1/3] Define the complete @tecode/api type surface and layering lint rule All types per Req 10.1 and design.md: primitives (Position/Range/ TextEdit/Selection/Uri/Disposable/Event), Document model, Manifest and contribution schemas, theme types (UiColorKey ~54 keys, CaptureName with dotted refinements), the nine namespace interfaces plus the aggregate Tecode contract, and API_VERSION with its compat rule. Adds the no-restricted-imports rule forbidding @tecode/core outside packages/cli, verified by a temp-fixture lint test. Fixes #3 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- eslint.config.mjs | 22 ++ packages/api/src/document.ts | 69 ++++++ packages/api/src/index.test.ts | 6 +- packages/api/src/index.ts | 95 ++++++++- packages/api/src/layering.test.ts | 87 ++++++++ packages/api/src/manifest.ts | 177 ++++++++++++++++ packages/api/src/namespaces.ts | 338 ++++++++++++++++++++++++++++++ packages/api/src/primitives.ts | 79 +++++++ packages/api/src/theme.ts | 126 +++++++++++ 9 files changed, 992 insertions(+), 7 deletions(-) create mode 100644 packages/api/src/document.ts create mode 100644 packages/api/src/layering.test.ts create mode 100644 packages/api/src/manifest.ts create mode 100644 packages/api/src/namespaces.ts create mode 100644 packages/api/src/primitives.ts create mode 100644 packages/api/src/theme.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index e316b6c..30db4f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,4 +16,26 @@ export default tseslint.config( { files: ["**/*.ts", "**/*.tsx"], }, + { + // Layering rule (Req 1.3, design.md §2): `builtin` extensions must + // import only from `@tecode/api`, never reach across the + // extension/core boundary by importing `@tecode/core` directly. `cli` + // is exempt — it is core's sole privileged wiring point. + files: ["packages/**/*.{ts,tsx}"], + ignores: ["packages/cli/**"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@tecode/core", "@tecode/core/*"], + message: + "Calls across the extension/core boundary must go through the command registry (tecode.commands), not a direct import of @tecode/core. Only packages/cli may import @tecode/core.", + }, + ], + }, + ], + }, + }, ); diff --git a/packages/api/src/document.ts b/packages/api/src/document.ts new file mode 100644 index 0000000..fa6ff3b --- /dev/null +++ b/packages/api/src/document.ts @@ -0,0 +1,69 @@ +/** + * The document/text-buffer surface (Req 5, design.md §7). + */ + +import type { Event, TextEdit, Uri } from "./primitives"; + +/** Line-ending style. Detected on load (first occurrence wins, default + * `"\n"`) and preserved on save (Req 5.1). */ +export type Eol = "\n" | "\r\n"; + +/** + * Fired on `Document.onDidChange` after a call to `applyEdits` completes. + * Carries the edits that were applied (in the form actually committed, i.e. + * already validated) and the document's new version, so listeners such as + * the syntax-highlight service can apply incremental updates instead of + * re-scanning the whole buffer (design.md §10). + */ +export interface DocumentChangeEvent { + document: Document; + edits: TextEdit[]; + version: number; +} + +/** + * A single open text document. Every open file (and unsaved/untitled + * buffer) is represented as one `Document`, synchronized to the renderer by + * the core (Req 5.1). + * + * `applyEdits` is the *only* mutation path (Req 5.2) — there is no + * `setText`, no direct buffer access, and no other way to change a + * document's content. This keeps undo/redo and change notification + * centralized in the core. + */ +export interface Document { + /** The document's resource identifier. */ + uri: Uri; + /** The language ID resolved for this document (e.g. `"typescript"`, + * `"plaintext"` when no language matches — Req 8.3). */ + languageId: string; + /** Monotonically increasing version number, bumped on every applied + * edit. */ + version: number; + /** Whether the document has unsaved changes. */ + dirty: boolean; + /** Whether the document rejects edits (e.g. files over 10 MB are opened + * read-only — Req 5.5). */ + readonly: boolean; + /** Line-ending style used when saving this document. */ + eol: Eol; + + /** + * Apply one or more edits atomically. This is the only way to modify a + * document's content (Req 5.2). Edits are validated, applied bottom-up, + * and recorded on the undo stack; the call bumps `version` and fires + * exactly one `onDidChange` event. On a `readonly` document this + * surfaces a status-bar error and does nothing (design.md §14). + */ + applyEdits(edits: TextEdit[]): void; + + /** + * Group every `applyEdits` call made inside `fn` into a single undo step + * (Req 5.4), so extensions can perform multi-step edits (e.g. "toggle + * comment on N lines") that undo/redo as one operation. + */ + transaction(fn: () => void): void; + + /** Fired after each `applyEdits` call completes (Req 5.3). */ + onDidChange: Event; +} diff --git a/packages/api/src/index.test.ts b/packages/api/src/index.test.ts index 8516b19..dcf8d20 100644 --- a/packages/api/src/index.test.ts +++ b/packages/api/src/index.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { API_PLACEHOLDER } from "./index"; +import { API_VERSION } from "./index"; -test("placeholder", () => { - expect(API_PLACEHOLDER).toBe(true); +test("API_VERSION is the current major.minor version", () => { + expect(API_VERSION).toBe("1.0"); }); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index cde9953..1fef952 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,4 +1,91 @@ -// Placeholder public API surface for @tecode/api. -// The real type declarations (Manifest, ExtensionContext, namespace -// interfaces, API_VERSION, etc.) are added in a later task. -export const API_PLACEHOLDER = true; +/** + * `@tecode/api` — the complete public type surface extensions are written + * against. This package has no dependencies and contains no runtime + * behavior beyond {@link API_VERSION} (Req 1.3, design.md §2): `core` + * implements these types, `builtin` and third-party extensions import only + * from here, and neither imports from `core` directly. + */ + +export type { + Position, + Range, + Selection, + TextEdit, + Uri, + Disposable, + Listener, + Event, +} from "./primitives"; + +export type { Eol, Document, DocumentChangeEvent } from "./document"; + +export type { + UiColorKey, + BaseCaptureName, + CaptureName, + RGB, + Style, + ResolvedTheme, +} from "./theme"; + +export type { + ActivationEvent, + CommandMeta, + CommandContribution, + KeybindingContribution, + ViewSlot, + ViewContribution, + LanguageComments, + BracketPair, + LanguageContribution, + ConfigurationPropertySchema, + ConfigurationContribution, + Contributes, + Manifest, + ExtensionContext, +} from "./manifest"; + +export type { + CommandHandler, + CommandDescriptor, + CommandsNamespace, + FileType, + FileStat, + DirEntry, + FileChangeType, + FileChangeEvent, + FileSystem, + WorkspaceNamespace, + MessageKind, + QuickPickItem, + QuickPickOptions, + InputBoxOptions, + StatusBarItem, + Editor, + WindowNamespace, + EditorNamespace, + SlotId, + ComponentType, + UiNamespace, + ConfigChangeEvent, + ConfigNamespace, + ContextNamespace, + LanguagesNamespace, + ThemeContribution, + ThemesNamespace, + Tecode, +} from "./namespaces"; + +/** + * The `@tecode/api` version, as `"."` (design.md §4.3). This + * is the package's only runtime code — everything else is type-only. + * + * **Compatibility rule** (Req 2.7): a manifest declares the API version it + * targets as `apiVersion: ""` or `"."`. An extension + * is compatible with the running host when the major versions match and + * the host's minor version is greater than or equal to the extension's + * requested minor version (an omitted minor is treated as `0`). An + * incompatible extension is skipped at registration with a surfaced error + * rather than crashing the host. + */ +export const API_VERSION = "1.0"; diff --git a/packages/api/src/layering.test.ts b/packages/api/src/layering.test.ts new file mode 100644 index 0000000..ed6e162 --- /dev/null +++ b/packages/api/src/layering.test.ts @@ -0,0 +1,87 @@ +/** + * Verifies the `no-restricted-imports` layering rule in the root + * `eslint.config.mjs` (Req 1.3, design.md §2): `packages/builtin/**` may + * not import `@tecode/core` directly, while `packages/cli/**` — the one + * place wiring core together is legitimate — is exempt. + * + * These fixtures are written to temp locations under the real packages + * (ESLint's flat-config `files`/`ignores` globs match on path, so the + * fixture has to actually live under `packages/builtin` or + * `packages/cli/src`) and are always removed again in a `finally`, so a + * failed assertion never leaves a permanently-lint-breaking file behind. + */ + +import { afterEach, expect, test } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const REPO_ROOT = path.resolve(import.meta.dir, "../../.."); +const BAD_DIR = path.join(REPO_ROOT, "packages/builtin/__lint-fixture__"); +const BAD_FILE = path.join(BAD_DIR, "bad.ts"); +const OK_FILE = path.join(REPO_ROOT, "packages/cli/src/__lint-fixture__.ts"); + +async function cleanupFixtures(): Promise { + await rm(BAD_DIR, { recursive: true, force: true }); + await rm(OK_FILE, { force: true }); +} + +// Belt-and-suspenders: also clean up after the suite in case a test is +// interrupted between its own try/finally and completion. +afterEach(cleanupFixtures); + +interface EslintMessage { + ruleId: string | null; +} + +interface EslintFileResult { + messages: EslintMessage[]; +} + +async function runEslint( + relFile: string, +): Promise<{ exitCode: number; results: EslintFileResult[] }> { + const proc = Bun.spawn({ + cmd: ["bunx", "eslint", "--no-ignore", "-f", "json", relFile], + cwd: REPO_ROOT, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + const results = stdout.trim().length > 0 ? (JSON.parse(stdout) as EslintFileResult[]) : []; + return { exitCode, results }; +} + +test("blocks @tecode/core imports from packages/builtin", async () => { + await cleanupFixtures(); + try { + await mkdir(BAD_DIR, { recursive: true }); + await writeFile(BAD_FILE, 'import "@tecode/core";\n'); + + const { exitCode, results } = await runEslint( + "packages/builtin/__lint-fixture__/bad.ts", + ); + + const ruleIds = results.flatMap((r) => r.messages.map((m) => m.ruleId)); + expect(exitCode).not.toBe(0); + expect(ruleIds).toContain("no-restricted-imports"); + } finally { + await cleanupFixtures(); + } +}); + +test("allows @tecode/core imports from packages/cli", async () => { + await cleanupFixtures(); + try { + await writeFile(OK_FILE, 'import "@tecode/core";\n'); + + const { results } = await runEslint("packages/cli/src/__lint-fixture__.ts"); + + const ruleIds = results.flatMap((r) => r.messages.map((m) => m.ruleId)); + expect(ruleIds).not.toContain("no-restricted-imports"); + } finally { + await cleanupFixtures(); + } +}); diff --git a/packages/api/src/manifest.ts b/packages/api/src/manifest.ts new file mode 100644 index 0000000..7ad72b6 --- /dev/null +++ b/packages/api/src/manifest.ts @@ -0,0 +1,177 @@ +/** + * Extension manifest types (Req 2.3, 2.5, 2.7, 3.2, 3.3, 8.2, design.md + * §4.1-4.3). `manifest.ts` is read and validated by the host *without* + * executing the extension's `index.ts` (Req 2.2) — everything here is pure + * data, constrained by convention to `export default {...} satisfies + * Manifest`. + */ + +import type { Disposable, Uri } from "./primitives"; +import type { Tecode } from "./namespaces"; + +/** + * When an extension activates (Req 2.5). The MVP supports: + * - `"onStartup"` — activated after the UI shell's first frame. + * - `` `onCommand:${string}` `` — activated when the named command is + * first executed (the command registry re-dispatches after activation). + * - `` `onLanguage:${string}` `` — activated when a document with the + * named language ID is opened. + */ +export type ActivationEvent = + | "onStartup" + | `onCommand:${string}` + | `onLanguage:${string}`; + +/** + * Metadata attached to a command: what the palette shows (`title`), how it + * is grouped there (`category`), and when it should be visible/enabled + * (`when`, a boolean context expression — Req 3.3, design.md §6.4). + */ +export interface CommandMeta { + title?: string; + category?: string; + when?: string; +} + +/** + * A command declared in a manifest's `contributes.commands`. + * + * Command IDs follow the `namespace.verb` convention (e.g. + * `"editor.action.deleteLine"`, `"explorer.reveal"` — Req 3.2). Declaring a + * command here registers it (lazily) without activating the extension; + * activation happens on first execution via the `onCommand:` + * activation event. + */ +export interface CommandContribution { + id: string; + /** Palette display name. */ + title: string; + category?: string; + when?: string; +} + +/** A keybinding declared in a manifest's `contributes.keybindings`, in the + * same shape as an entry in the user's `keybindings.json` (Req 4.2). */ +export interface KeybindingContribution { + /** A canonical key or two-stroke chord string (e.g. `"ctrl+shift+p"`, + * `"ctrl+k ctrl+s"` — Req 4.4). */ + key: string; + /** The command to run, or `"-"` to remove a default binding for + * `` on this key (Req 4.3). */ + command: string; + when?: string; +} + +/** The UI slot a contributed view is rendered into (Req 6.2). */ +export type ViewSlot = "sidebar" | "panel"; + +/** + * A view declared in a manifest's `contributes.views`. A `"sidebar"` view + * is paired 1:1 with an activity bar item of the same `id` (Req 6.2) — + * `icon` is that activity bar item's glyph. + */ +export interface ViewContribution { + id: string; + title: string; + slot: ViewSlot; + icon?: string; +} + +/** Line/block comment markers for a language (Req 8.2). */ +export interface LanguageComments { + line?: string; + block?: [start: string, end: string]; +} + +/** A matching pair of auto-closed/matched brackets (Req 8.2). */ +export interface BracketPair { + open: string; + close: string; +} + +/** + * A language declared in a manifest's `contributes.languages` (Req 8.2). + * When a file's extension matches no declared language, it is treated as + * `"plaintext"` with no highlighting (Req 8.3). + */ +export interface LanguageContribution { + id: string; + /** File extensions this language applies to, including the leading dot + * (e.g. `[".ts", ".tsx"]`). */ + extensions: string[]; + /** Path to the tree-sitter WASM grammar. */ + grammar: string; + /** Path to the tree-sitter highlight query (`.scm`) file. */ + highlights: string; + comments?: LanguageComments; + brackets?: BracketPair[]; +} + +/** One configuration property's JSON-schema-like description (Req 9.3). */ +export interface ConfigurationPropertySchema { + type: "string" | "number" | "boolean" | "array" | "object"; + default?: unknown; + description?: string; + enum?: unknown[]; +} + +/** A settings schema declared in a manifest's `contributes.configuration` + * (Req 9.3), read back at runtime via `tecode.config.get(key)`. */ +export interface ConfigurationContribution { + title?: string; + properties: Record; +} + +/** + * Everything a manifest can contribute to the running editor (Req 2.3). + * All fields are optional — a manifest contributes only what it declares. + */ +export interface Contributes { + commands?: CommandContribution[]; + keybindings?: KeybindingContribution[]; + views?: ViewContribution[]; + languages?: LanguageContribution[]; + /** Theme display name → path to its VS Code-subset color theme JSON + * (design.md §9). */ + themes?: Record; + configuration?: ConfigurationContribution; +} + +/** + * The declarative shape of `manifest.ts`'s default export (Req 2.3). The + * host reads and validates this without executing `index.ts` (Req 2.2). + */ +export interface Manifest { + /** Globally unique extension ID. */ + id: string; + /** The extension's own version (semver). */ + version: string; + /** + * The `@tecode/api` version this extension targets, as `""` or + * `"."` (e.g. `"1"`, `"1.0"`). The host refuses to + * activate an extension whose required API version is incompatible with + * `API_VERSION` (Req 2.7) — see {@link API_VERSION}'s TSDoc for the + * compatibility rule. + */ + apiVersion: string; + activationEvents: ActivationEvent[]; + contributes: Contributes; +} + +/** + * Passed to an extension's exported `activate(ctx)` (Req 2.6, design.md + * §4.2). + */ +export interface ExtensionContext { + /** The live `tecode` API object — identical to what every other + * extension (built-in or third-party) receives (Req 1.4). */ + api: Tecode; + /** The URI of the extension's own directory, for resolving bundled + * assets. */ + extensionUri: Uri; + /** Push disposables here to have them disposed automatically, in + * reverse order, on deactivation. */ + subscriptions: Disposable[]; + /** A per-extension directory for persisting extension state. */ + storagePath: string; +} diff --git a/packages/api/src/namespaces.ts b/packages/api/src/namespaces.ts new file mode 100644 index 0000000..beab8f9 --- /dev/null +++ b/packages/api/src/namespaces.ts @@ -0,0 +1,338 @@ +/** + * The nine `tecode.*` namespaces (Req 10.1, design.md §12) and the + * aggregate {@link Tecode} interface that bundles them into the single + * frozen object handed to every extension. + */ + +import type { + Disposable, + Event, + Listener, + Position, + Selection, + TextEdit, + Uri, +} from "./primitives"; +import type { Document } from "./document"; +import type { ResolvedTheme } from "./theme"; +import type { CommandMeta, LanguageContribution } from "./manifest"; + +/* ------------------------------------------------------------------ */ +/* tecode.commands */ +/* ------------------------------------------------------------------ */ + +/** A command handler, invoked with whatever arguments `execute` was + * called with. May return a value synchronously or a `Promise`. */ +export type CommandHandler = (...args: unknown[]) => unknown; + +/** One entry of `commands.list()` — the palette's view of a registered + * command (design.md §5). */ +export interface CommandDescriptor { + id: string; + title?: string; + category?: string; + when?: string; +} + +/** + * The command registry (Req 3, Req 10.1). All cross-module behavior in + * tecode — keybindings, the palette, UI callbacks, extension-to-extension + * calls — goes through here rather than direct function calls (Req 1.5). + */ +export interface CommandsNamespace { + /** + * Register a command handler under `id` (`namespace.verb` form — Req + * 3.2). Re-registering an existing ID replaces its handler + * (design.md §5). Returns a {@link Disposable} that unregisters it. + */ + register(id: string, handler: CommandHandler, meta?: CommandMeta): Disposable; + /** + * Execute a command by ID. Never throws to the caller (Req 3.4, 3.5): + * an unknown ID or a handler exception is caught, surfaced in the + * status bar, and resolves the returned promise to `undefined`. + */ + execute(id: string, ...args: unknown[]): Promise; + /** List every registered command, for the palette to filter by + * `when` and fuzzy-match. */ + list(): CommandDescriptor[]; +} + +/* ------------------------------------------------------------------ */ +/* tecode.workspace */ +/* ------------------------------------------------------------------ */ + +/** The kind of filesystem entry a {@link FileStat} or {@link DirEntry} + * describes. */ +export type FileType = "file" | "directory" | "symlink" | "unknown"; + +/** Metadata about a filesystem entry, as returned by `fs.stat`. */ +export interface FileStat { + type: FileType; + /** Size in bytes. */ + size: number; + /** Last-modified time, in milliseconds since the Unix epoch. */ + mtime: number; + /** Creation/status-change time, in milliseconds since the Unix + * epoch. */ + ctime: number; +} + +/** One entry returned by `fs.readdir`. */ +export interface DirEntry { + name: string; + type: FileType; +} + +/** The kind of change reported by `fs.watch`. */ +export type FileChangeType = "created" | "changed" | "deleted"; + +/** An event fired by an `fs.watch` subscription. */ +export interface FileChangeEvent { + type: FileChangeType; + uri: Uri; +} + +/** + * Filesystem access, wrapping `node:fs/promises` and `fs.watch` behind the + * API so a future virtual filesystem stays possible; the MVP imposes no + * sandboxing (Req 10.2, design.md §12). + */ +export interface FileSystem { + read(uri: Uri): Promise; + write(uri: Uri, content: Uint8Array): Promise; + stat(uri: Uri): Promise; + readdir(uri: Uri): Promise; + /** Watch a file or directory for changes. Returns a {@link Disposable} + * that stops the watch. */ + watch(uri: Uri, listener: Listener): Disposable; +} + +/** + * The open workspace (a single root directory in the MVP) and its open + * documents (Req 10.1). + */ +export interface WorkspaceNamespace { + /** The workspace root, or `undefined` when tecode was opened on a + * single file with no enclosing workspace. */ + readonly rootUri: Uri | undefined; + /** Open (or return the already-open) document for `uri`. */ + openDocument(uri: Uri): Promise; + /** All currently open documents. */ + readonly documents: readonly Document[]; + readonly fs: FileSystem; + onDidOpen: Event; + onDidClose: Event; + onDidSave: Event; +} + +/* ------------------------------------------------------------------ */ +/* tecode.window */ +/* ------------------------------------------------------------------ */ + +/** Severity of a `window.showMessage` notification. */ +export type MessageKind = "info" | "warning" | "error"; + +/** One selectable item in `window.showQuickPick`. */ +export interface QuickPickItem { + label: string; + description?: string; + detail?: string; +} + +export interface QuickPickOptions { + placeHolder?: string; + canPickMany?: boolean; +} + +export interface InputBoxOptions { + prompt?: string; + value?: string; + placeHolder?: string; + /** Mask the input, for secrets. */ + password?: boolean; +} + +/** Which side of the status bar an item renders on, and its sort + * priority within that side (higher first — Req 6.2). */ +export interface StatusBarItem { + id: string; + text: string; + tooltip?: string; + side: "left" | "right"; + priority: number; +} + +/** The active editor's document and cursor/selection state. */ +export interface Editor { + document: Document; + selections: Selection[]; +} + +/** + * Window-level UI: notifications, pickers, and the status bar (Req 10.1). + */ +export interface WindowNamespace { + /** The editor currently in focus, or `undefined` if none. */ + readonly activeEditor: Editor | undefined; + showMessage(message: string, kind?: MessageKind): void; + showQuickPick( + items: QuickPickItem[], + options?: QuickPickOptions, + ): Promise; + showInputBox(options?: InputBoxOptions): Promise; + /** Create or update a status bar item. Returns a {@link Disposable} + * that removes it. */ + setStatusBarItem(item: StatusBarItem): Disposable; +} + +/* ------------------------------------------------------------------ */ +/* tecode.editor */ +/* ------------------------------------------------------------------ */ + +/** + * Operations on the active editor (Req 10.1). Calls made with no active + * editor no-op with a status-bar notice (design.md §12). + */ +export interface EditorNamespace { + /** The active editor's selections/cursors (first-class array — Req + * 6.6, 11.1). */ + readonly selections: Selection[]; + /** The primary cursor position (the active end of `selections[0]`). */ + readonly cursor: Position; + /** Scroll so `line` is visible, driven by the primary cursor. */ + revealLine(line: number): void; + /** Insert a snippet at each cursor (tab-stop syntax is host-defined; + * `@tecode/api` only fixes the entry point). */ + insertSnippet(snippet: string): void; + /** Apply edits to the active document (see `Document.applyEdits`). */ + applyEdits(edits: TextEdit[]): void; +} + +/* ------------------------------------------------------------------ */ +/* tecode.ui */ +/* ------------------------------------------------------------------ */ + +/** The UI slot a view can be registered into (Req 6.2). */ +export type SlotId = + | "activityBar.item" + | "sidebar.view" + | "panel.tab" + | "statusBar.item" + | "editor.viewType"; + +/** + * A UI component type. `@tecode/api` has no dependency on React (or any UI + * framework), so this is modeled loosely as a props-in/element-out + * function rather than `React.ComponentType`; `@tecode/core` substitutes + * the real React component type at the integration boundary + * (design.md §12). + */ +export type ComponentType

> = (props: P) => unknown; + +/** + * View registration and the common component library (Req 10.1, 6.3). + */ +export interface UiNamespace { + /** Register `Component` as the content for view `id` in `slot` (Req + * 6.3). Returns a {@link Disposable} that unregisters it. */ + registerView(slot: SlotId, id: string, component: ComponentType): Disposable; + /** Read the active theme; components must obtain all colors from here + * rather than hard-coding literals (Req 7.3). */ + useTheme(): ResolvedTheme; + List: ComponentType; + Tree: ComponentType; + Input: ComponentType; + Tabs: ComponentType; +} + +/* ------------------------------------------------------------------ */ +/* tecode.config */ +/* ------------------------------------------------------------------ */ + +/** Fired by `config.onDidChange`; `affectsConfiguration` reports whether a + * given key (or one of its children) changed (Req 9.4). */ +export interface ConfigChangeEvent { + affectsConfiguration(key: string): boolean; +} + +/** + * Read access to the merged (defaults ← user ← workspace) settings tree + * (Req 9, 10.1). + */ +export interface ConfigNamespace { + get(key: string): T | undefined; + onDidChange: Event; +} + +/* ------------------------------------------------------------------ */ +/* tecode.context */ +/* ------------------------------------------------------------------ */ + +/** + * The flat context-key store `when` clauses evaluate against (Req 4.6, + * 10.1). + */ +export interface ContextNamespace { + set(key: string, value: unknown): void; + get(key: string): T | undefined; +} + +/* ------------------------------------------------------------------ */ +/* tecode.languages */ +/* ------------------------------------------------------------------ */ + +/** + * Programmatic language registration (a runtime-equivalent of + * `contributes.languages` — Req 8.2, 10.1) and language-ID lookup. + */ +export interface LanguagesNamespace { + register(contribution: LanguageContribution): Disposable; + /** The language ID resolved for `uri` (`"plaintext"` if none match — + * Req 8.3). */ + getLanguageId(uri: Uri): string; +} + +/* ------------------------------------------------------------------ */ +/* tecode.themes */ +/* ------------------------------------------------------------------ */ + +/** A theme registered at runtime (a runtime-equivalent of one + * `contributes.themes` entry). */ +export interface ThemeContribution { + id: string; + label: string; + /** Path to the theme's VS Code-subset color theme JSON. */ + path: string; +} + +/** + * Theme registration and the active theme (Req 7, 10.1). + */ +export interface ThemesNamespace { + register(contribution: ThemeContribution): Disposable; + /** The currently active, fully resolved theme. */ + readonly current: ResolvedTheme; +} + +/* ------------------------------------------------------------------ */ +/* Tecode — the aggregate namespace object */ +/* ------------------------------------------------------------------ */ + +/** + * The complete `tecode` API object handed to every extension (built-in or + * third-party) via `ExtensionContext.api`, and available as the `"tecode"` + * module alias at runtime (design.md §2). Frozen shallowly per namespace + * by the host so extensions cannot monkey-patch across each other + * (design.md §12). + */ +export interface Tecode { + commands: CommandsNamespace; + workspace: WorkspaceNamespace; + window: WindowNamespace; + editor: EditorNamespace; + ui: UiNamespace; + config: ConfigNamespace; + context: ContextNamespace; + languages: LanguagesNamespace; + themes: ThemesNamespace; +} diff --git a/packages/api/src/primitives.ts b/packages/api/src/primitives.ts new file mode 100644 index 0000000..83de045 --- /dev/null +++ b/packages/api/src/primitives.ts @@ -0,0 +1,79 @@ +/** + * Core, dependency-free primitive types shared across the `@tecode/api` + * surface. These are intentionally LSP-compatible (0-based line/character + * positions) so that `offsetAt`/`positionAt`-style mapping and a future + * language server integration require no shape changes (design.md §7, + * §18 "Deferred Design Concerns"). + */ + +/** + * A zero-based position in a document, expressed as a line number and a + * character offset (in UTF-16 code units) within that line. Matches the + * LSP `Position` shape. + */ +export interface Position { + /** Zero-based line number. */ + line: number; + /** Zero-based character offset within the line. */ + character: number; +} + +/** + * A half-open range between two positions: `[start, end)`. Matches the LSP + * `Range` shape. + */ +export interface Range { + start: Position; + end: Position; +} + +/** + * A text selection: a {@link Range} plus the anchor/active endpoints needed + * to render carets and support multiple cursors (Req 6.6). `anchor` is + * where the selection began; `active` is where the caret currently sits + * (they are equal for a collapsed selection/cursor). + */ +export interface Selection extends Range { + anchor: Position; + active: Position; +} + +/** + * A single text replacement over a {@link Range}. `applyEdits(edits: + * TextEdit[])` is the *only* document mutation path (Req 5.2) — every + * insert, delete, and replace in the editor is expressed as one or more + * `TextEdit`s. + */ +export interface TextEdit { + range: Range; + newText: string; +} + +/** + * Opaque identifier for a resource, represented as a URI string (typically + * `file://...`). Kept as a plain string alias — rather than a branded or + * structured type — to stay LSP-compatible and dependency-free. + */ +export type Uri = string; + +/** + * A handle returned by any registration or subscription method in the API. + * Calling `dispose()` undoes the registration (unregisters a command, + * removes an event listener, closes a file watcher, ...). Extensions + * typically push these into `ExtensionContext.subscriptions` so the host + * can dispose them all on deactivation (design.md §4.2). + */ +export interface Disposable { + dispose(): void; +} + +/** A callback subscribed to an {@link Event}. */ +export type Listener = (e: T) => void; + +/** + * A subscribable event. Calling the event with a {@link Listener} registers + * it and returns a {@link Disposable} that removes it again — the same + * pattern used throughout the API for `onDidChange`, `onDidOpen`, and + * friends. + */ +export type Event = (listener: Listener) => Disposable; diff --git a/packages/api/src/theme.ts b/packages/api/src/theme.ts new file mode 100644 index 0000000..ceee62d --- /dev/null +++ b/packages/api/src/theme.ts @@ -0,0 +1,126 @@ +/** + * Theming types (Req 7, design.md §9). + */ + +/** + * The UI color keys a theme can supply, reusing VS Code's color IDs so + * existing VS Code theme knowledge transfers directly (Req 7.2). This is + * the "approximately 40 keys" set called out in Req 7.2; the six keys + * named explicitly there (`editor.background`, `editor.foreground`, + * `sideBar.background`, `statusBar.background`, `tab.activeBackground`, + * `list.activeSelectionBackground`) are included below. A theme that omits + * a key falls back to the built-in base palette for it (design.md §9). + */ +export type UiColorKey = + | "focusBorder" + | "foreground" + | "editor.background" + | "editor.foreground" + | "editor.lineHighlightBackground" + | "editor.selectionBackground" + | "editor.selectionForeground" + | "editor.inactiveSelectionBackground" + | "editorLineNumber.foreground" + | "editorLineNumber.activeForeground" + | "editorCursor.foreground" + | "editorIndentGuide.background" + | "editorIndentGuide.activeBackground" + | "editorWhitespace.foreground" + | "activityBar.background" + | "activityBar.foreground" + | "activityBar.inactiveForeground" + | "activityBar.border" + | "activityBarBadge.background" + | "activityBarBadge.foreground" + | "sideBar.background" + | "sideBar.foreground" + | "sideBar.border" + | "sideBarTitle.foreground" + | "sideBarSectionHeader.background" + | "statusBar.background" + | "statusBar.foreground" + | "statusBar.border" + | "statusBar.debuggingBackground" + | "statusBarItem.hoverBackground" + | "tab.activeBackground" + | "tab.activeForeground" + | "tab.inactiveBackground" + | "tab.inactiveForeground" + | "tab.border" + | "tab.activeBorder" + | "panel.background" + | "panel.border" + | "panelTitle.activeForeground" + | "panelTitle.inactiveForeground" + | "input.background" + | "input.foreground" + | "input.border" + | "input.placeholderForeground" + | "list.activeSelectionBackground" + | "list.activeSelectionForeground" + | "list.inactiveSelectionBackground" + | "list.hoverBackground" + | "list.focusBackground" + | "scrollbarSlider.background" + | "scrollbarSlider.hoverBackground" + | "badge.background" + | "badge.foreground" + | "button.background" + | "button.foreground"; + +/** + * Base tree-sitter capture names for syntax highlighting (decision #3 in + * requirements.md; Req 7.2, 8.1). A compatibility mapping from VS Code + * TextMate scopes is out of scope for the MVP (design.md §18). + */ +export type BaseCaptureName = + | "keyword" + | "string" + | "comment" + | "function" + | "type" + | "variable" + | "number" + | "operator" + | "punctuation"; + +/** + * A tree-sitter capture name, either a base name or a dotted refinement of + * one (e.g. `"function.builtin"`, `"string.escape"`). Refinements that a + * theme does not style explicitly fall back to their base capture by + * longest-prefix match (design.md §9). + */ +export type CaptureName = BaseCaptureName | `${BaseCaptureName}.${string}`; + +/** A resolved, quantization-ready RGB color (0-255 per channel). */ +export interface RGB { + r: number; + g: number; + b: number; +} + +/** A resolved text style for one syntax capture. */ +export interface Style { + foreground?: RGB; + background?: RGB; + bold?: boolean; + italic?: boolean; + underline?: boolean; +} + +/** + * A fully resolved theme, ready to render: every {@link UiColorKey} has + * been filled in (from the theme's JSON, falling back to the base palette) + * and colors are already quantized for the terminal's detected color depth + * (Req 7.4). + * + * `tokens` is keyed by {@link CaptureName}, which includes an infinite + * template-literal union (`` `${BaseCaptureName}.${string}` ``) — no + * concrete value can ever supply every possible key, so this is a + * `Partial` index rather than a full `Record`. Consumers should resolve a + * capture by exact match first, then fall back to its base capture name. + */ +export interface ResolvedTheme { + colors: Record; + tokens: Partial>; +} From a81a5413e72a423a7b0b46d991ac3c2c555d7754 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 11:13:25 +0000 Subject: [PATCH 2/3] Address CodeRabbit review on API types PR - editor selections typed as readonly arrays so extensions cannot mutate host-managed state - layering test: consume stderr, fail loudly on empty eslint output, assert exit code 0 on the allowed side, create OK_FILE's parent dir, and raise the per-file test timeout for the eslint spawns - globally ignore __lint-fixture__ paths in eslint.config.mjs so a concurrent 'eslint .' never lints temp fixtures (the test passes --no-ignore) - correct the UiColorKey count in the theme comment (55 keys) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- eslint.config.mjs | 5 +++++ packages/api/src/layering.test.ts | 30 ++++++++++++++++++++++++++---- packages/api/src/namespaces.ts | 4 ++-- packages/api/src/theme.ts | 2 +- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 30db4f9..a9521fb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,11 @@ export default tseslint.config( "**/dist/**", "**/build/**", "**/*.d.ts", + // Temp fixtures written by layering.test.ts. Ignored here so a + // concurrent `eslint .` never lints them; the test itself passes + // --no-ignore to lint them deliberately. + "**/__lint-fixture__*/**", + "**/__lint-fixture__*", ], }, eslint.configs.recommended, diff --git a/packages/api/src/layering.test.ts b/packages/api/src/layering.test.ts index ed6e162..d87133f 100644 --- a/packages/api/src/layering.test.ts +++ b/packages/api/src/layering.test.ts @@ -11,10 +11,14 @@ * failed assertion never leaves a permanently-lint-breaking file behind. */ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, setDefaultTimeout, test } from "bun:test"; import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; +// Each test spawns a full `bunx eslint` run, which can exceed bun's 5 s +// default test timeout on a cold cache. +setDefaultTimeout(60_000); + const REPO_ROOT = path.resolve(import.meta.dir, "../../.."); const BAD_DIR = path.join(REPO_ROOT, "packages/builtin/__lint-fixture__"); const BAD_FILE = path.join(BAD_DIR, "bad.ts"); @@ -46,11 +50,23 @@ async function runEslint( stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([ + // Consume both pipes: an unread stderr pipe can fill up and stall the + // child process. + const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), + new Response(proc.stderr).text(), proc.exited, ]); - const results = stdout.trim().length > 0 ? (JSON.parse(stdout) as EslintFileResult[]) : []; + if (stdout.trim().length === 0) { + // ESLint always emits a JSON array on a successful run (even with no + // findings), so an empty stdout means the process itself failed — + // e.g. a broken config (exit code 2). Fail loudly instead of letting + // callers mistake it for "no lint errors". + throw new Error( + `eslint produced no output for ${relFile} (exit ${exitCode}): ${stderr.trim()}`, + ); + } + const results = JSON.parse(stdout) as EslintFileResult[]; return { exitCode, results }; } @@ -75,11 +91,17 @@ test("blocks @tecode/core imports from packages/builtin", async () => { test("allows @tecode/core imports from packages/cli", async () => { await cleanupFixtures(); try { + await mkdir(path.dirname(OK_FILE), { recursive: true }); await writeFile(OK_FILE, 'import "@tecode/core";\n'); - const { results } = await runEslint("packages/cli/src/__lint-fixture__.ts"); + const { exitCode, results } = await runEslint( + "packages/cli/src/__lint-fixture__.ts", + ); const ruleIds = results.flatMap((r) => r.messages.map((m) => m.ruleId)); + // Exit 0 also guards against a broken ESLint config (exit 2) being + // mistaken for "no findings". + expect(exitCode).toBe(0); expect(ruleIds).not.toContain("no-restricted-imports"); } finally { await cleanupFixtures(); diff --git a/packages/api/src/namespaces.ts b/packages/api/src/namespaces.ts index beab8f9..a883090 100644 --- a/packages/api/src/namespaces.ts +++ b/packages/api/src/namespaces.ts @@ -165,7 +165,7 @@ export interface StatusBarItem { /** The active editor's document and cursor/selection state. */ export interface Editor { document: Document; - selections: Selection[]; + readonly selections: readonly Selection[]; } /** @@ -196,7 +196,7 @@ export interface WindowNamespace { export interface EditorNamespace { /** The active editor's selections/cursors (first-class array — Req * 6.6, 11.1). */ - readonly selections: Selection[]; + readonly selections: readonly Selection[]; /** The primary cursor position (the active end of `selections[0]`). */ readonly cursor: Position; /** Scroll so `line` is visible, driven by the primary cursor. */ diff --git a/packages/api/src/theme.ts b/packages/api/src/theme.ts index ceee62d..32d89d1 100644 --- a/packages/api/src/theme.ts +++ b/packages/api/src/theme.ts @@ -5,7 +5,7 @@ /** * The UI color keys a theme can supply, reusing VS Code's color IDs so * existing VS Code theme knowledge transfers directly (Req 7.2). This is - * the "approximately 40 keys" set called out in Req 7.2; the six keys + * the ~40-key set called out in Req 7.2, realized here as 55 keys; the six keys * named explicitly there (`editor.background`, `editor.foreground`, * `sideBar.background`, `statusBar.background`, `tab.activeBackground`, * `list.activeSelectionBackground`) are included below. A theme that omits From 1271be256ded6222b4f305d340b51835063700bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 11:16:35 +0000 Subject: [PATCH 3/3] Apply CodeRabbit nitpicks on API types PR - CommandContribution and CommandDescriptor now extend CommandMeta so command metadata fields live in one place - unify theme registration: Contributes.themes is ThemeContribution[] (moved to theme.ts), the same shape tecode.themes.register accepts - add an API_VERSION major.minor format test - extend the layering rule to dynamic import() and require() of @tecode/core via no-restricted-syntax Skipped per requirements: making contributes/activationEvents optional (Req 2.3 requires manifests to declare them); eslint-plugin-import zones for relative-path enforcement (new dependency, out of MVP scope). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- eslint.config.mjs | 17 +++++++++++++++++ packages/api/src/index.test.ts | 4 ++++ packages/api/src/index.ts | 2 +- packages/api/src/manifest.ts | 13 ++++++------- packages/api/src/namespaces.ts | 16 ++-------------- packages/api/src/theme.ts | 13 +++++++++++++ 6 files changed, 43 insertions(+), 22 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index a9521fb..412f264 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -41,6 +41,23 @@ export default tseslint.config( ], }, ], + // no-restricted-imports only sees static imports; also block dynamic + // import() and require() of @tecode/core by string literal. + "no-restricted-syntax": [ + "error", + { + selector: + "ImportExpression > Literal[value=/^@tecode\\u002Fcore(\\u002F.*)?$/]", + message: + "Dynamic import of @tecode/core crosses the extension/core boundary; go through the command registry (tecode.commands) instead.", + }, + { + selector: + "CallExpression[callee.name='require'] > Literal[value=/^@tecode\\u002Fcore(\\u002F.*)?$/]", + message: + "require() of @tecode/core crosses the extension/core boundary; go through the command registry (tecode.commands) instead.", + }, + ], }, }, ); diff --git a/packages/api/src/index.test.ts b/packages/api/src/index.test.ts index dcf8d20..84d4f66 100644 --- a/packages/api/src/index.test.ts +++ b/packages/api/src/index.test.ts @@ -4,3 +4,7 @@ import { API_VERSION } from "./index"; test("API_VERSION is the current major.minor version", () => { expect(API_VERSION).toBe("1.0"); }); + +test("API_VERSION uses . form", () => { + expect(API_VERSION).toMatch(/^\d+\.\d+$/); +}); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 1fef952..686c023 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -26,6 +26,7 @@ export type { RGB, Style, ResolvedTheme, + ThemeContribution, } from "./theme"; export type { @@ -71,7 +72,6 @@ export type { ConfigNamespace, ContextNamespace, LanguagesNamespace, - ThemeContribution, ThemesNamespace, Tecode, } from "./namespaces"; diff --git a/packages/api/src/manifest.ts b/packages/api/src/manifest.ts index 7ad72b6..040bbc4 100644 --- a/packages/api/src/manifest.ts +++ b/packages/api/src/manifest.ts @@ -8,6 +8,7 @@ import type { Disposable, Uri } from "./primitives"; import type { Tecode } from "./namespaces"; +import type { ThemeContribution } from "./theme"; /** * When an extension activates (Req 2.5). The MVP supports: @@ -42,12 +43,10 @@ export interface CommandMeta { * activation happens on first execution via the `onCommand:` * activation event. */ -export interface CommandContribution { +export interface CommandContribution extends CommandMeta { id: string; - /** Palette display name. */ + /** Palette display name (required for contributed commands). */ title: string; - category?: string; - when?: string; } /** A keybinding declared in a manifest's `contributes.keybindings`, in the @@ -131,9 +130,9 @@ export interface Contributes { keybindings?: KeybindingContribution[]; views?: ViewContribution[]; languages?: LanguageContribution[]; - /** Theme display name → path to its VS Code-subset color theme JSON - * (design.md §9). */ - themes?: Record; + /** Themes this extension ships, in the same shape + * `tecode.themes.register` accepts (design.md §9). */ + themes?: ThemeContribution[]; configuration?: ConfigurationContribution; } diff --git a/packages/api/src/namespaces.ts b/packages/api/src/namespaces.ts index a883090..1aeba20 100644 --- a/packages/api/src/namespaces.ts +++ b/packages/api/src/namespaces.ts @@ -14,7 +14,7 @@ import type { Uri, } from "./primitives"; import type { Document } from "./document"; -import type { ResolvedTheme } from "./theme"; +import type { ResolvedTheme, ThemeContribution } from "./theme"; import type { CommandMeta, LanguageContribution } from "./manifest"; /* ------------------------------------------------------------------ */ @@ -27,11 +27,8 @@ export type CommandHandler = (...args: unknown[]) => unknown; /** One entry of `commands.list()` — the palette's view of a registered * command (design.md §5). */ -export interface CommandDescriptor { +export interface CommandDescriptor extends CommandMeta { id: string; - title?: string; - category?: string; - when?: string; } /** @@ -296,15 +293,6 @@ export interface LanguagesNamespace { /* tecode.themes */ /* ------------------------------------------------------------------ */ -/** A theme registered at runtime (a runtime-equivalent of one - * `contributes.themes` entry). */ -export interface ThemeContribution { - id: string; - label: string; - /** Path to the theme's VS Code-subset color theme JSON. */ - path: string; -} - /** * Theme registration and the active theme (Req 7, 10.1). */ diff --git a/packages/api/src/theme.ts b/packages/api/src/theme.ts index 32d89d1..b0a284a 100644 --- a/packages/api/src/theme.ts +++ b/packages/api/src/theme.ts @@ -124,3 +124,16 @@ export interface ResolvedTheme { colors: Record; tokens: Partial>; } + +/** + * One theme a manifest's `contributes.themes` declares — and the same + * shape `tecode.themes.register` accepts at runtime, so both paths + * normalize to a single internal representation (Req 7.1). + */ +export interface ThemeContribution { + id: string; + /** Display name shown in the theme picker. */ + label: string; + /** Path to the theme's VS Code-subset color theme JSON. */ + path: string; +}