Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions eslint.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,11 +9,55 @@ 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,
...tseslint.configs.recommended,
{
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.",
},
],
},
],
// 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.",
},
],
},
},
);
69 changes: 69 additions & 0 deletions packages/api/src/document.ts
Original file line numberDiff line numberDiff line change
@@ -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<DocumentChangeEvent>;
}
10 changes: 7 additions & 3 deletions packages/api/src/index.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
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");
});

test("API_VERSION uses <major>.<minor> form", () => {
expect(API_VERSION).toMatch(/^\d+\.\d+$/);
});
95 changes: 91 additions & 4 deletions packages/api/src/index.ts
Original file line numberDiff line numberDiff line change
@@ -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,
ThemeContribution,
} 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,
ThemesNamespace,
Tecode,
} from "./namespaces";

/**
* The `@tecode/api` version, as `"<major>.<minor>"` (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: "<major>"` or `"<major>.<minor>"`. 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";
109 changes: 109 additions & 0 deletions packages/api/src/layering.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
/**
* 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, 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");
const OK_FILE = path.join(REPO_ROOT, "packages/cli/src/__lint-fixture__.ts");
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function cleanupFixtures(): Promise<void> {
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);
Comment thread
goofmint marked this conversation as resolved.

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",
});
// 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,
]);
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 };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 mkdir(path.dirname(OK_FILE), { recursive: true });
await writeFile(OK_FILE, 'import "@tecode/core";\n');

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();
}
});
Loading