Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ api-docs
!packages/eventual-send/src/exports.d.ts
!packages/eventual-send/src/types.d.ts
!packages/exo/src/types.d.ts
!packages/exo/types-index.d.ts
!packages/far/src/exports.d.ts
!packages/lp32/types.d.ts
!packages/pass-style/src/types.d.ts
Expand Down
92 changes: 92 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Agent Instructions for endo

This file provides conventions and constraints for AI agents working in this repository.

## Repository structure

- Monorepo managed with Yarn workspaces
- Packages live in `packages/`
- Tests use `ava` (runtime) and `tsd` (types)
- Linting: `eslint` with project-specific rules; run `yarn lint` per-package

## TypeScript usage

Our TypeScript conventions accommodate `.js` development (this repo) and `.ts` consumers (e.g. agoric-sdk). See [agoric-sdk/docs/typescript.md](https://github.com/Agoric/agoric-sdk/blob/master/docs/typescript.md) for full background.

### No `.ts` in runtime bundles

Never use `.ts` files in modules that are transitively imported into an Endo bundle. The Endo bundler does not understand `.ts` syntax. We avoid build steps for runtime imports.

### `.ts` files are for type definitions only

Use `.ts` files to define exported types. These are never imported at runtime. They are made available to consumers through a `types-index` module.

When a `.ts` file contains runtime code (e.g. `type-from-pattern.ts` with `declare` statements), it still produces only `.d.ts` output — the `declare` keyword ensures no JS is emitted. Actual runtime code belongs in `.js` files.

### The `types-index` convention

Each package that exports types uses a pair of files:

- **`types-index.js`** — Runtime re-exports. Contains `export { ... } from './src/foo.js'` for values that need enhanced type signatures (e.g. `M`, `matches`, `mustMatch`).
- **`types-index.d.ts`** — **Pure re-export index.** Contains only `export type * from` and `export { ... } from` lines. **No type definitions belong here.**

Why: `.d.ts` files are not checked by `tsc` (we use `skipLibCheck: true`). Type definitions in `.d.ts` files silently pass even if they contain errors. Definitions in `.ts` files are checked.

The entrypoint (`index.js`) re-exports from `types-index.js`:
```js
// eslint-disable-next-line import/export
export * from './types-index.js';
```

### Where type definitions go

| What | Where | Why |
|------|-------|-----|
| Interface types, data types | `src/types.ts` | Canonical type definitions |
| Inferred/computed types | `src/type-from-pattern.ts` (or similar `.ts`) | Complex type logic, checked by tsc |
| Value + namespace merges | Same `.ts` file as the namespace | TS requires both in one module for merging |
| `declare function` overrides | `.ts` file alongside related types | Gets type-checked |
| Re-exports only | `types-index.d.ts` | Pure index, no definitions |

### `emitDeclarationOnly`

The repo-wide `tsconfig-build-options.json` sets `emitDeclarationOnly: true`. `tsc` only generates `.d.ts` files, not `.js`. This means `.ts` files with runtime code (not just types) would need `build-ts-to-js` or equivalent — which this repo does not currently have. Keep `.ts` files type-only.

### Imports in `.js` files

Use `/** @import */` JSDoc comments to import types without runtime module loading:
```js
/** @import { Pattern, MatcherNamespace } from './types.js' */
```

## Exo `this` context

Exo methods receive a `this` context (via `ThisType<>`) that differs between single-facet and multi-facet exos:

| API | `this.self` | `this.facets` | `this.state` |
|-----|-------------|---------------|--------------|
| `makeExo` | ✅ the exo instance | ❌ | ❌ (always `{}`) |
| `defineExoClass` | ✅ the exo instance | ❌ | ✅ from `init()` |
| `defineExoClassKit` | ❌ | ✅ all facets in cohort | ✅ from `init()` |

**Why no `self` on kits?** A kit has multiple facets (e.g. `public`, `admin`), each a separate remotable object. There is no single "self". Use `this.facets.facetName` to access any facet in the cohort.

When writing `ThisType<>` annotations in `types-index.d.ts`:
- Single-facet: `ThisType<{ self: Guarded<M>; state: S }>`
- Multi-facet: `ThisType<{ facets: GuardedKit<F>; state: S }>`

Never mix `self` and `facets` in the same context type.

## Testing

- Runtime tests: `yarn test` (uses `ava`)
- Type tests: `yarn lint:types` (uses `tsd` — test files are `test/types.test-d.ts`)
- Lint: `yarn lint` (runs both `lint:types` and `lint:eslint`)

Always run `yarn lint` in each package you've modified before committing.

## Commit conventions

- Use conventional commits: `feat(pkg):`, `fix(pkg):`, `refactor(pkg):`, `chore:`, `test(pkg):`
- Breaking changes: `feat(pkg)!:` or `fix(pkg)!:`
- File conversions (`.js` to `.ts`) get their own `refactor:` commit
8 changes: 7 additions & 1 deletion packages/exo/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export * from './src/exo-makers.js';
export { initEmpty } from './src/exo-makers.js';

// makeExo, defineExoClass, defineExoClassKit are re-exported from
// types-index so they get typed declarations that infer method types
// from InterfaceGuard (see types-index.d.ts).
// eslint-disable-next-line import/export
export * from './types-index.js';

// eslint-disable-next-line import/export -- ESLint not aware of type exports in types.d.ts
export * from './src/types.js';
Expand Down
26 changes: 26 additions & 0 deletions packages/exo/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,36 @@ export type MatchConfig = {
};
export type FacetName = string;
export type Methods = Record<RemotableMethodName, CallableFunction>;
/**
* The `this` context for methods of a single-facet exo (makeExo, defineExoClass).
*
* - `this.state` — the per-instance state record returned by `init()`
* (empty `{}` for `makeExo` which has no `init`).
* - `this.self` — the exo instance itself (the object whose methods you're
* implementing). Useful for passing "yourself" to other code.
*
* **Not available on kits.** Multi-facet exos use {@link KitContext} instead,
* which provides `this.facets` (the record of all facet instances in the
* cohort) rather than `this.self`.
*/
export type ClassContext<S = any, M extends Methods = any> = {
state: S;
self: M;
};

/**
* The `this` context for methods of a multi-facet exo kit (defineExoClassKit).
*
* - `this.state` — the per-instance state record returned by `init()`.
* - `this.facets` — the record of all facet instances in this cohort,
* keyed by facet name. Use `this.facets.myFacet` to access sibling
* facets.
*
* **No `this.self` on kits.** A kit method belongs to one facet, and
* there is no single "self" — instead, each facet is a separate remotable
* object. Use `this.facets.foo` to get the specific facet you need.
* For single-facet exos, see {@link ClassContext} which provides `this.self`.
*/
export type KitContext<S = any, F extends Record<string, Methods> = any> = {
state: S;
facets: F;
Expand Down
145 changes: 124 additions & 21 deletions packages/exo/test/heap-classes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test('what happens with extra arguments', t => {
t.is(x, undefined);
},
});
// TS sees foo(x: any) from the impl, so this is valid to TS. Runtime guard rejects it.
t.throws(() => exo.foo('an extra arg'), {
message:
'"In \\"foo\\" method of (NoExtraArgs)" accepts at most 0 arguments, not 1: ["an extra arg"]',
Expand All @@ -36,6 +37,7 @@ test('callWhen-guarded method called without optional array argument', async t =
t.is(arr, undefined);
},
});
// @ts-expect-error TS infers foo(arr) as required from the impl, but guard makes it optional at runtime
await t.notThrowsAsync(() => exo.foo());
});

Expand Down Expand Up @@ -139,8 +141,10 @@ test('test defineExoClassKit', t => {
message:
'In "decr" method of (Counter down): arg 0?: string "foo" - Must be a number',
});
// @ts-expect-error bad arg
t.throws(() => upCounter.decr(3), {
// TS limitation: Guarded<M> extends Methods which has an index signature
// (Record<PropertyKey, CallableFunction>), so upCounter.decr is not a
// type error even though 'decr' is only on the down facet.
t.throws(() => /** @type {any} */ (upCounter).decr(3), {
message: 'upCounter.decr is not a function',
});
t.deepEqual(upCounter[GET_INTERFACE_GUARD]?.(), UpCounterI);
Expand Down Expand Up @@ -206,7 +210,6 @@ test('sloppy option', t => {
() =>
makeExo(
'greeter',
// @ts-expect-error missing guard
EmptyGreeterI,
{
sayHello() {
Expand Down Expand Up @@ -290,7 +293,7 @@ test('raw guards', t => {
// Object is implicitly frozen by harden as a side-effect of passing to
// an M.any guard, or unfrozen because harden is fake, but isFrozen lies.
t.true(Object.isFrozen(obj));
return { ...obj };
return { .../** @type {Record<string, any>} */ (obj) };
},
passthrough(obj) {
// The object is not frozen, but isFrozen lies when hardenTaming is
Expand Down Expand Up @@ -324,9 +327,11 @@ test('raw guards', t => {
t.is(Object.isFrozen({}), Object.isFrozen(greeter2.passthrough({})));

t.true(Object.isFrozen(greeter2.tortuous({}, {}, {}, {}, {})));
// @ts-expect-error TS infers 4 required params from impl, guard makes last 2 optional at runtime
t.true(Object.isFrozen(greeter2.tortuous({}, {}, {})));

t.throws(
// @ts-expect-error same: 3 args but impl has 4 required
() => greeter2.tortuous(makeBehavior(), {}, {}),
{
message:
Expand All @@ -335,6 +340,7 @@ test('raw guards', t => {
'passable behavior not allowed',
);
t.notThrows(
// @ts-expect-error same: 3 args but impl has 4 required
() => greeter2.tortuous({}, makeBehavior(), {}),
'raw behavior allowed',
);
Expand Down Expand Up @@ -389,25 +395,25 @@ test.skip('types', () => {
return val;
},
});
// @ts-expect-error invalid args
// @ts-expect-error TS infers incr(val: number) as required from JSDoc, guard makes it optional at runtime
guarded.incr();
// @ts-expect-error not defined
// @ts-expect-error not defined on the guarded type
guarded.notInBehavior;

makeExo(
'upCounter',
// @ts-expect-error Property 'notInInterface' is missing from UpCounterI
UpCounterI,
{
/** @param {number} val */
incr(val) {
return val;
},
notInInterface() {
return 0;
},
// Runtime error: 'notInInterface' not in guard.
// TS limitation: excess property checking does not apply in generic
// contexts, so TS cannot reject extra methods here. If TS gains
// exact-type checking for object literals in generics, this could
// become a compile-time error.
makeExo('upCounter', UpCounterI, {
/** @param {number} val */
incr(val) {
return val;
},
);
notInInterface() {
return 0;
},
});

const sloppy = makeExo(
'upCounter',
Expand All @@ -429,10 +435,107 @@ test.skip('types', () => {
},
);
sloppy.incr(1);
// @ts-expect-error invalid args
// @ts-expect-error TS infers incr(val: number) as required from JSDoc, guard makes it optional at runtime
sloppy.incr();
// allowed because sloppy:true
sloppy.notInInterface() === 0;
// @ts-expect-error TS infers it's literally 0
sloppy.notInInterface() === 1;
});

// ===== defineExoClassKit with typed InterfaceGuardKit =====

const ReaderI = M.interface('Reader', {
read: M.call().returns(M.string()),
});

const WriterI = M.interface('Writer', {
write: M.call(M.string()).returns(M.undefined()),
});

test('defineExoClassKit infers facet types from guard kit', t => {
const makeRW = defineExoClassKit(
'ReadWriter',
{ reader: ReaderI, writer: WriterI },
/** @param {string} initial */
initial => ({ data: initial }),
{
reader: {
read() {
const { state } = this;
return state.data;
},
},
writer: {
write(text) {
const { state } = this;
state.data = text;
},
},
},
);

const rw = makeRW('hello');
// reader facet
t.is(rw.reader.read(), 'hello');
// writer facet
rw.writer.write('world');
t.is(rw.reader.read(), 'world');
});

const SelfRefI = M.interface('SelfRef', {
get: M.call().returns(M.string()),
getViaSelf: M.call().returns(M.string()),
});

test('this.self is typed correctly in exo methods', t => {
const selfRef = makeExo('SelfRef', SelfRefI, {
get() {
return 'direct';
},
getViaSelf() {
// this.self should have the same type as the exo object
return this.self.get();
},
});
t.is(selfRef.get(), 'direct');
t.is(selfRef.getViaSelf(), 'direct');
});

const KitReaderI = M.interface('KitReader', {
read: M.call().returns(M.string()),
readViaFacets: M.call().returns(M.string()),
});

const KitWriterI = M.interface('KitWriter', {
write: M.call(M.string()).returns(M.undefined()),
});

test('this.facets is typed correctly in kit methods', t => {
const makeKit = defineExoClassKit(
'Kit',
{ reader: KitReaderI, writer: KitWriterI },
/** @param {string} data */
data => ({ data }),
{
reader: {
read() {
return this.state.data;
},
readViaFacets() {
// this.facets.reader has the reader facet type
return this.facets.reader.read();
},
},
writer: {
write(text) {
this.state.data = text;
},
},
},
);
const kit = makeKit('hello');
t.is(kit.reader.read(), 'hello');
t.is(kit.reader.readViaFacets(), 'hello');
kit.writer.write('world');
t.is(kit.reader.read(), 'world');
});
Loading
Loading