diff --git a/.claude/skills/effect-program-design/PREDICATE-MATCH.md b/.claude/skills/effect-program-design/PREDICATE-MATCH.md new file mode 100644 index 0000000..8354a4c --- /dev/null +++ b/.claude/skills/effect-program-design/PREDICATE-MATCH.md @@ -0,0 +1,97 @@ +# Predicate and Match for Fold + +Use this reference when working with ordinary tagged values such as log entries, model descriptors, provider +settings, and in-process decisions. It does not replace typed Effect error handling. + +## Choose the right construct + +| Situation | Use | Why | +| -------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------- | +| Recover from a tagged failure in `Effect` | `Effect.catchTag` / `Effect.catchTags` | The error stays in the error channel. | +| Test one tag or create a reusable narrowing filter | `Predicate.isTagged` | Produces a guard that works with `find`, `filter`, and guard clauses. | +| Reuse a multi-tag narrowing filter | Compose `Predicate.isTagged` guards | Keeps the narrowed union in one named predicate. | +| Transform every member of a closed tagged union | `Match.type()` with `Match.tagsExhaustive` | Makes added variants a type error at the transformation. | +| Dispatch a closed union at one call site | `Match.value(value)` | Keeps the cases and their result together. | +| Handle only selected tags intentionally | `Match.tag` / `Match.tags` plus an explicit fallback | Documents that unmatched values are expected. | + +`Match` selects a branch for an ordinary value. A branch may return an `Effect`, but `Match` neither runs it nor +recovers its failures. Use `catchTag` / `catchTags` after an Effect has failed with a typed error. + +## Predicate: narrow, do not decode + +Use `Predicate.isTagged` when a caller needs one narrow branch or a named refinement for an array operation. The +value must already be trusted as the union type. `Predicate.isTagged` proves only the `_tag` value; it does not check +the remaining fields. + +```ts +import { Predicate } from 'effect' + +type MessageEntry = Extract + +const isMessageEntry: Predicate.Refinement = Predicate.or( + Predicate.isTagged('user-message'), + Predicate.isTagged('assistant-message'), +) + +const transcriptEntries = entries.filter(isMessageEntry) +``` + +For an untrusted value from JSON, a provider, a file, or a host boundary, decode it with the owning `Schema` first. +Do not use `Predicate.isTagged` or a hand-written `typeof`/property ladder as a substitute for decoding. + +Use a direct tag check when it is a single local guard in a loop and extracting a predicate would obscure the control +flow. Prefer a named predicate when the condition recurs, combines tags, or expresses domain vocabulary. + +## Match: transform complete unions + +Use `Match.type()` to define a reusable transformation. `Match.tagsExhaustive` is the default for a closed union: +it must cover every tag, so a new schema variant makes the compiler identify every transformation that needs a case. + +```ts +import { Match } from 'effect' + +const sessionIdFromIndexRecord = Match.type().pipe( + Match.tagsExhaustive({ + summary: ({ summary }) => summary.sessionId, + deleted: ({ sessionId }) => sessionId, + }), +) +``` + +Use `Match.value(value)` for a one-off dispatch. It is especially useful when each case has a distinct output and a +conditional chain would repeat the discriminant. + +```ts +const status = Match.value(lastFinished.outcome).pipe( + Match.when('completed', () => 'ready' as const), + Match.when('error', () => 'error' as const), + Match.orElse(() => 'stopped' as const), +) +``` + +An explicit `Match.orElse` or `Match.option` is appropriate only when partial handling is intentional. Do not use a +fallback merely to suppress exhaustiveness for a union whose variants Fold owns. + +## Boundaries and errors remain separate + +```ts +const decodeEntry = Schema.decodeUnknownEffect(LogEntry) + +const recoverProviderError = providerCall.pipe( + Effect.catchTags({ + ProviderUnavailable: () => Effect.succeed(fallback), + ProviderUnauthenticated: () => Effect.succeed(fallback), + }), +) +``` + +The first line decodes an unknown boundary value. The second block recovers typed failures. Neither should be +rewritten as an ordinary-value `Match`. + +## Review checklist + +- Is this a normal tagged value or a typed failure? Use `Predicate`/`Match` only for the former. +- Has untrusted data already been decoded by the owning schema? +- Does a reusable tag condition deserve a named `Predicate` refinement? +- Does a Fold-owned closed union deserve `Match.tagsExhaustive`? +- Would a direct local `_tag` guard communicate a simple loop condition more clearly? Keep it when yes. diff --git a/.claude/skills/effect-program-design/REFERENCE.md b/.claude/skills/effect-program-design/REFERENCE.md index 3b8292f..73d2695 100644 --- a/.claude/skills/effect-program-design/REFERENCE.md +++ b/.claude/skills/effect-program-design/REFERENCE.md @@ -1,315 +1,102 @@ -# Effect Program Design — Reference +# Fold Effect Program Design Reference -Annotated skeleton, copy-paste patterns, and the anti-pattern catalog. Canonical real implementation: -`apps/riptide-api/src/effects/services/slack/`. Good runner-up: `access-request/` (note: it also contains -anti-patterns — see the catalog). Foils to avoid: `workos/`, `resend/`, `stripe/`, `s3/`. +These are Fold-native references. Follow the closest existing pattern instead of importing application-specific +conventions from another repository. ---- +| Concern | Canonical Fold reference | What it demonstrates | +| ---------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Public API and descriptor lowering | `README.md`; `packages/fold-core/src/Api/Provisioning.ts` | Hosts define data descriptors; provisioning owns internal service/layer wiring. | +| Service interface | `packages/fold-core/src/EventLog/EventLogService.ts` | A small `Context.Service` surface for a session-scoped capability. | +| Durable tagged data | `packages/fold-core/src/EventLog/Schemas.ts` | `Schema.TaggedStruct`, `Schema.Union`, versioning, and decode-time compatibility. | +| Provider/runtime ownership | `packages/fold-core/src/Api/Provisioning.ts` | Scope ownership, fresh memo maps, provider layers, and resource lifetime. | +| Complete tagged dispatch | `packages/fold-agent/src/Session/SessionLayout.ts` | An exhaustive `Match` over a Fold-owned tagged union. | +| Focused tag guard | `packages/fold-core/src/HookRunner/Errors.ts` | A reusable `Predicate.isTagged` guard. | +| Pure projection policy | `packages/fold-core/src/Compaction/CompactionEngine.ts` | Pure projections and the distinction between simple loop guards and transformations. | -## File skeleton +## Public Fold shape -### `widget.errors.ts` — tagged errors, two-tier unions +Fold has two intentionally different interfaces: -```ts -import { Schema } from 'effect' +1. A host-facing descriptor interface, where consumers describe an agent, model, toolset, hooks, and event-log + backend without learning Fold's internal runtime graph. +2. An internal Effect interface, where `Context.Service` tags and `Layer` implementations express capabilities, + resource requirements, and replaceable test seams. -import { OrganizationId } from './widget.ids' +Keep the conversion in one direction at the facade/provisioning seam: -// Internal vocabulary: everything that can break at the boundary. -export class WidgetTokenRevokedError extends Schema.TaggedErrorClass()( - 'WidgetTokenRevokedError', - { cause: Schema.optional(Schema.Defect()) }, -) {} -export class WidgetRateLimitError extends Schema.TaggedErrorClass()('WidgetRateLimitError', { - retryAfterMs: Schema.optional(Schema.Number), - cause: Schema.optional(Schema.Defect()), -}) {} -export class WidgetApiUnavailableError extends Schema.TaggedErrorClass()( - 'WidgetApiUnavailableError', - { cause: Schema.optional(Schema.Defect()) }, -) {} -export class WidgetUnexpectedResponseError extends Schema.TaggedErrorClass()( - 'WidgetUnexpectedResponseError', - { code: Schema.optional(Schema.String), cause: Schema.optional(Schema.Defect()) }, -) {} +```text +host descriptors -> provisioning -> Effect services/layers -> running session +``` -// Public vocabulary: small, caller-actionable. Models the decision, not the HTTP status. -export class WidgetNotConnectedError extends Schema.TaggedErrorClass()( - 'WidgetNotConnectedError', - { organizationId: OrganizationId }, -) {} -export class WidgetNeedsReauthError extends Schema.TaggedErrorClass()( - 'WidgetNeedsReauthError', - { organizationId: OrganizationId, lastErrorCode: Schema.optional(Schema.String) }, -) {} -export class WidgetUnavailableError extends Schema.TaggedErrorClass()( - 'WidgetUnavailableError', - { cause: Schema.optional(Schema.Defect()) }, -) {} +Do not expose layer construction to a host just because the implementation needs a provider client, tool runtime, or +event log. Conversely, do not turn a capability that varies per runtime into a global singleton or a closure-captured +ambient dependency. -export type WidgetProviderError = - WidgetTokenRevokedError | WidgetRateLimitError | WidgetApiUnavailableError | WidgetUnexpectedResponseError -``` +## A small service seam -### `widget.types.ts` — domain types + the service shape +Use a service when callers need a capability rather than a data structure. The service describes what callers can do; +its layer owns how it does it. ```ts -import type { Effect } from 'effect' -import type { WidgetNeedsReauthError, WidgetNotConnectedError, WidgetUnavailableError } from './widget.errors' +import { Context } from 'effect' +import type { Effect, Stream } from 'effect' -export type Widget = { id: string; name: string } // domain type — never the SDK/row shape - -export type WidgetServiceShape = { - // Narrow input (named object), narrow per-method public error union. - listWidgets: (input: { - organizationId: string - }) => Effect.Effect - // Best-effort fan-out: structurally cannot fail. - notify: (input: { organizationId: string; widgetId: string }) => Effect.Effect +export type EventLogService = { + readonly append: (entry: LogEntryInput) => Effect.Effect + readonly entries: (fromSeq?: LogSeq) => Stream.Stream } + +export class EventLog extends Context.Service()('fold/EventLog') {} ``` -### `widget.service.ts` — shape + live layer (capture-then-narrow lives here) +The layer may coordinate storage, validation, sequence allocation, subscriptions, and resource cleanup. None of +those details become arguments to `append` or `entries`. -```ts -import { Config, Context, Effect, Layer, Redacted } from 'effect' -import * as HttpClient from 'effect/unstable/http/HttpClient' +## Provisioning owns layers and scopes -import { Sentry } from '../../../instrument' -import { PostgresDb } from '../postgres/postgres-db.service' -import { makeWidgetClient } from './widget.client' -import { WidgetNeedsReauthError, WidgetUnavailableError } from './widget.errors' -import { requireWidgetConnection, markNeedsReauth } from './widget.persistence' -import type { WidgetServiceShape } from './widget.types' +Provisioning is a deep module: it takes a small model/tool descriptor and returns a ready `AgentRuntime`. It owns the +details that must remain coordinated: -export class WidgetService extends Context.Service()('WidgetService') {} +- a fresh `Layer.makeMemoMap` for an isolated provision; +- the caller's ambient `Scope`, so runtime resources release at the correct lifetime; +- the shared session services versus per-agent tool/runtime layers; +- provider-specific `LanguageModel` realization. -export const WidgetServiceLiveBase = Layer.effect( - WidgetService, - Effect.gen(function* () { - const apiKey = yield* Config.redacted('WIDGET_API_KEY') // secrets via Config + Redacted - const baseUrl = yield* Config.string('WIDGET_API_BASE_URL').pipe(Config.withDefault('https://api.widget.com')) - const httpClient = yield* HttpClient.HttpClient // injected — substitutable in tests - const postgres = yield* PostgresDb // dep declared in R +When adding a new descriptor field or provider, ask which side of this seam owns it. Host-visible policy belongs in +the descriptor. Runtime clients, layer composition, and release behavior belong in provisioning. - const client = makeWidgetClient({ httpClient, config: { apiKey, baseUrl } }) +## Errors are not normal tagged values - return { - listWidgets: ({ organizationId }) => - Effect.gen(function* () { - const conn = yield* requireWidgetConnection(organizationId) // module resolves its own internal (token) - return yield* client.list(Redacted.make(conn.access_token)) - }).pipe( - Effect.withSpan('widget.list_widgets', { attributes: { organization_id: organizationId } }), - // CAPTURE (raw) BEFORE NARROW: - Effect.tapError((error) => Effect.logError(error)), - Effect.tapError((error) => Effect.sync(() => Sentry.captureException(error))), - // NARROW internal → public, and self-heal: - Effect.catchTag(['WidgetTokenRevokedError'], (error) => - markNeedsReauth(organizationId, error._tag).pipe( - Effect.flatMap(() => - Effect.fail(new WidgetNeedsReauthError({ organizationId, lastErrorCode: error._tag })), - ), - ), - ), - Effect.catchTags({ - WidgetRateLimitError: (cause) => Effect.fail(new WidgetUnavailableError({ cause })), - WidgetApiUnavailableError: (cause) => Effect.fail(new WidgetUnavailableError({ cause })), - WidgetUnexpectedResponseError: (cause) => Effect.fail(new WidgetUnavailableError({ cause })), - }), - Effect.provideService(PostgresDb, postgres), - ), +For a typed provider or service failure, preserve the error channel and recover in it: - notify: (input) => - notifyWidget(input).pipe( - // see widget.notify.ts — best-effort, returns Effect - Effect.provideService(PostgresDb, postgres), - ), - } +```ts +operation.pipe( + Effect.catchTags({ + ProviderUnavailable: () => Effect.succeed(fallback), + ProviderUnauthenticated: () => Effect.succeed(fallback), }), ) - -// Expose the transport unprovided as the Base; provide it for production. -export const WidgetServiceLive = WidgetServiceLiveBase.pipe(Layer.provide(HttpClient.layer)) ``` -### `widget.client.ts` — external adapter, the ONE SDK-error mapper +For a decoded event/descriptor union, dispatch it as a normal value with `Predicate` or `Match`. Do not move an +Effect error into a normal union merely to match it, and do not use `Match` as a substitute for `catchTag`. -```ts -import { Effect, Redacted } from 'effect' -import * as HttpClient from 'effect/unstable/http/HttpClient' - -import { Sentry } from '../../../instrument' -import { - WidgetApiUnavailableError, - WidgetRateLimitError, - WidgetTokenRevokedError, - WidgetUnexpectedResponseError, -} from './widget.errors' -import type { WidgetProviderError } from './widget.types' - -// The single per-adapter mapper. The ONLY place that inspects a thrown cause. -// Prefer Schema.decodeUnknown / safe property access over instanceof. -const mapSdkErrorToEffectError = (cause: unknown): WidgetProviderError => { - const status = typeof cause === 'object' && cause !== null ? (cause as { status?: number }).status : undefined - if (status === 401) return new WidgetTokenRevokedError({ cause }) - if (status === 429) return new WidgetRateLimitError({ cause }) - if (status !== undefined && status >= 500) return new WidgetApiUnavailableError({ cause }) - return new WidgetUnexpectedResponseError({ cause }) -} - -export const makeWidgetClient = (deps: { - httpClient: HttpClient.HttpClient - config: { apiKey: Redacted.Redacted; baseUrl: string } -}) => ({ - list: (token: Redacted.Redacted) => - Effect.tryPromise({ - try: () => callWidgetApi(deps, token), // throwing SDK - catch: mapSdkErrorToEffectError, // throwing boundary: ok, but only here, only this fn - }).pipe( - Effect.tapError((e) => Effect.logError('Widget API failed', e)), - Effect.tapError((e) => Effect.sync(() => Sentry.captureException(e))), - Effect.withSpan('widget.api.list'), // child span on the I/O sub-effect - ), -}) -``` - -### `widget.persistence.ts` — map driver errors at the seam - -```ts -export const requireWidgetConnection = (organizationId: string) => - Effect.gen(function* () { - const pg = yield* PostgresDb - return yield* pg - .query( - pg.client - .select() - .from(widgetConnections) - .where(eq(widgetConnections.organization_id, organizationId)) - .limit(1), - ) - .pipe( - Effect.mapError((cause) => new WidgetUnavailableError({ cause })), - Effect.flatMap( - ([row]) => - !row - ? Effect.fail(new WidgetNotConnectedError({ organizationId })) - : row.needs_reauth - ? Effect.fail( - new WidgetNeedsReauthError({ - organizationId, - lastErrorCode: row.last_error_code ?? undefined, - }), - ) - : Effect.succeed(row), // trust scalar columns; parse jsonb columns with the Zod schema before returning - ), - ) - }) -``` +## Testing at a real seam -### Best-effort fan-out (`Effect`) +Write an `it.effect` program that acquires the public service and provides its dependencies as layers. Replace only +true externals such as an HTTP provider, filesystem, clock, or event-log adapter. Use a real implementation when the +behavior under test depends on durable storage or its constraints. ```ts -const notifyWidget = (input: { organizationId: string; widgetId: string }): Effect.Effect => +it.effect('records an entry through the public service', () => Effect.gen(function* () { - /* … fan out … */ - }).pipe( - Effect.catchCause( - ( - cause, // catches defects too - ) => - Effect.logError('Widget notify failed', cause).pipe( - Effect.andThen( - Effect.sync(() => - Sentry.captureException(cause, { - tags: { error_type: 'widget_notify_failure' }, - extra: input, - }), - ), - ), - ), - ), - ) -``` - ---- - -## Test skeleton — `@effect/vitest` - -```ts -import { describe, it } from '@effect/vitest' -import { ConfigProvider, Effect, Layer } from 'effect' -import { beforeAll, afterAll, expect } from 'vitest' - -import { WidgetService, WidgetServiceLiveBase } from '../../src/effects/services/widget/widget.service' -import { PostgresDbLive } from '../../src/effects/services/postgres/postgres-db.service' -import { createTestDb } from '../utils/test-db' -import { requireExternalTestServices } from '../utils/external-test-services' - -await requireExternalTestServices() - -describe('WidgetService.listWidgets', () => { - let db: Awaited>['db'] - let cleanup: (() => Promise) | undefined - beforeAll(async () => { - const t = await createTestDb('widget') - db = t.db - cleanup = t.cleanup - }, 60000) - afterAll(async () => { - await cleanup?.() - }, 30000) - - const configLayer = ConfigProvider.layer( - ConfigProvider.fromUnknown({ WIDGET_API_KEY: 'k', WIDGET_API_BASE_URL: 'https://widget.test' }), - ) - const fakeHttp = Layer.succeed( - /* HttpClient.HttpClient */ undefined as never, - /* canned responses */ undefined as never, - ) - const makeLayer = () => - Layer.provideMerge(WidgetServiceLiveBase, Layer.mergeAll(PostgresDbLive(db), configLayer, fakeHttp)) - - it.effect('returns widgets through the public interface', () => - Effect.gen(function* () { - const svc = yield* WidgetService - const widgets = yield* svc.listWidgets({ organizationId: 'org-1' }) - expect(widgets).toEqual([{ id: 'w1', name: 'one' }]) - // also assert real DB end-state via Effect.promise(() => db.select()…) - }).pipe(Effect.provide(makeLayer())), - ) -}) + const eventLog = yield* EventLog + const entry = yield* eventLog.append(input) + expect(entry._tag).toBe('user-message') + // Assert the observable adapter end state when the behavior promises one. + }).pipe(Effect.provide(testLayer)), +) ``` -Dependency category → seam: real ephemeral DB (`PostgresDbLive(db)`) for persistence behavior; hand-fake -`PostgresDb` layer for pure-logic; `Layer.succeed(Service, {…})` recording-store fakes for true externals -(unused methods `Effect.die('… not used')`); fake `HttpClient`/loopback server for transport. - ---- - -## Anti-pattern catalog (in-repo line refs) - -| Anti-pattern | Where (foil) | Fix | -| ---------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------- | -| `instanceof` in Effect code | workos.service.ts:62; resend.service.ts:89; access-request.service.ts:139,335 | `catchTag` / `Schema` | -| classify-then-silent (no capture) | resend.service.ts; workos.service.ts (whole) | `tapError` → log + Sentry | -| one wide error union for every method | workos `WorkosError`×all; resend `ResendError`×all | narrow per method | -| status-bucket errors vs caller-action | workos.service.ts:61-84; resend.service.ts:76-114 | model the decision | -| caller string-matches a message | access-request.service.ts:335 (`'already invited'`) | model it as a tag | -| `unknown`/`any` across the seam | resend.service.ts:53 (`getAutomation: …unknown`); `(client as any)` | parse to domain type | -| errors-as-values | access-request-internal.ts:38 (`NotifyResult.error: string`) | keep typed in the channel | -| dep/layer/effect passed as arg | workos-config.service.ts:11 (`(workos) => Layer.succeed`) | declare in `R` | -| no spans | slack/\*, workos, resend, stripe, s3 | `withSpan` + `annotateCurrentSpan` | -| `Effect.orDie` hiding init failure | s3.service.ts:45 | typed error + capture | -| module mocks / method spies | (any test) | swap layers at real seams | -| `ManagedRuntime` + plain vitest for a new test | slack-\*.vi.test.ts (legacy) | `@effect/vitest` `it.effect` | - -## Specimens - -- **GOLD — `slack/`**: deep module; internal `SlackProviderError` (8) → public `SlackConnectionError` (4); - capture-then-narrow at the boundary; `dispatch*` is `Effect` with `absorbDeliveryFailure` + - `catchCause`; `Config`/`Redacted`; decomposed by responsibility. (Tests are legacy `ManagedRuntime`.) -- **MIXED — `access-request/`**: good `it.effect` tests, retryable SQLSTATE classification, boundary narrowing — - but also `instanceof`, errors-as-values, message string-matching. Not an exemplar. -- **FOILS — `workos/` `resend/` `stripe/`**: shallow SDK wrappers, classify-then-silent, wide unions, no spans. - **`s3/`**: worst — no tagged errors, raw `Error`, `Effect.orDie`, no observability. +No `vi.mock`, `vi.spyOn`, module patching, or sleep-based timing. If the behavior cannot be tested by providing a +layer, move the seam rather than patching the module under test. diff --git a/.claude/skills/effect-program-design/SCHEMA-DOMAIN-PATTERNS.md b/.claude/skills/effect-program-design/SCHEMA-DOMAIN-PATTERNS.md index c04c91b..4c155d6 100644 --- a/.claude/skills/effect-program-design/SCHEMA-DOMAIN-PATTERNS.md +++ b/.claude/skills/effect-program-design/SCHEMA-DOMAIN-PATTERNS.md @@ -1,267 +1,98 @@ -# Schema-First Domain Patterns +# Fold Schema and Domain Patterns -Use this when modeling Effect service inputs, outputs, durable events, command records, tagged errors, and IDs. -The rule: make the schema the source of truth, then derive the TypeScript type from it. Do not hand-write a -parallel object type that can drift from the schema. +Schemas define Fold's encoded boundaries: durable log records, host/provider input, and values that must survive a +process or package boundary. Use the smallest model that preserves the contract; do not add class or schema ceremony +to trusted local control flow with no encoded representation. -## Defaults +## Schema chooser -- Model identity values as branded schemas, not raw strings. -- Model tagged domain variants with `Schema.TaggedClass`, not ad-hoc object unions. -- Model expected failures with `Schema.TaggedErrorClass`, not `Data.TaggedError`. -- Export both the schema/class value and the derived type: `export type X = typeof X.Type`. -- Use raw `string` for freeform text, provider-owned opaque strings, display labels, and external values that are - not identity-bearing inside the domain. +| Value role | Default representation | +| ------------------------------------------------- | ------------------------------------------------------------------------------- | +| Ordinary encoded record | `Schema.Struct` | +| Closed scalar vocabulary | `Schema.Literals` | +| One encoded `_tag` variant | `Schema.TaggedStruct` | +| Encoded tagged union | `Schema.Union` of `Schema.TaggedStruct` variants | +| Internal-only tagged decision | A precise TypeScript union or `Data.TaggedEnum` when constructors/matchers help | +| Internal-only expected failure | `Data.TaggedError` | +| Error that crosses an encoded boundary | A schema-backed tagged error, when its codec is required | +| Identity with a concrete cross-domain mix-up risk | A constrained branded schema | -## Branded IDs +`Schema.TaggedClass` and `Schema.TaggedErrorClass` are not defaults in Fold. Use them only when class identity or +behavior has a real requirement. `Schema.TaggedStruct` and `Data.TaggedError` normally preserve a smaller, clearer +surface. -```ts -import { makeBrandedId } from '@humanlayer/effect-branded-id' - -/** ID for an organization in this domain. */ -export const OrganizationId = makeBrandedId('org', { brand: 'OrganizationId' }) -export type OrganizationId = typeof OrganizationId.Type - -/** ID for a session-scoped agent. */ -export const AgentId = makeBrandedId('agent', { brand: 'AgentId' }) -export type AgentId = typeof AgentId.Type - -/** ID for a persisted message. */ -export const MessageId = makeBrandedId('msg', { brand: 'MessageId' }) -export type MessageId = typeof MessageId.Type -``` +## Durable tagged events -Prefer branded IDs whenever two values could both be strings but must not be interchangeable. Branded IDs belong -in public service inputs and persisted schemas; callers should not pass positional bare strings. - -```ts -export type WidgetService = { - readonly listWidgets: (input: { readonly organizationId: OrganizationId }) => Effect.Effect -} -``` - -## Domain Scalars - -Small constrained values should also start as schemas. +Fold event-log data is schema-first and versioned. Define each persisted variant with `Schema.TaggedStruct`, then +compose the public union and derive its type from the schema. ```ts import { Schema } from 'effect' -export const LogSeq = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).annotate({ - identifier: 'LogSeq', -}) -export type LogSeq = typeof LogSeq.Type - -export const EpochMillis = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)).annotate({ - identifier: 'EpochMillis', -}) -export type EpochMillis = typeof EpochMillis.Type - -export const ProviderKind = Schema.Literals(['anthropic', 'openai-compatible', 'codex']).annotate({ - identifier: 'ProviderKind', -}) -export type ProviderKind = typeof ProviderKind.Type -``` - -Use literal schemas for closed vocabularies. Use branded IDs for identities. Use plain `Schema.String` only when -the value is truly freeform or provider-owned. - -## Tagged Domain Classes - -Use `Schema.TaggedClass` for persisted events, commands, state transitions, and other discriminated records. -This gives you constructors, schemas, encoders/decoders, and the `_tag` discriminator from one definition. - -```ts -import { Schema } from 'effect' -import { makeBrandedId } from '@humanlayer/effect-branded-id' - -export const AgentId = makeBrandedId('agent', { brand: 'AgentId' }) -export type AgentId = typeof AgentId.Type - -export const MessageId = makeBrandedId('msg', { brand: 'MessageId' }) -export type MessageId = typeof MessageId.Type - -export const LogSeq = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).annotate({ identifier: 'LogSeq' }) -export type LogSeq = typeof LogSeq.Type - -export const EpochMillis = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)).annotate({ - identifier: 'EpochMillis', -}) -export type EpochMillis = typeof EpochMillis.Type - -const StoredEnvelopeFields = { - seq: LogSeq, - ts: EpochMillis, -} as const - -const AgentScopedFields = { - agentId: AgentId, - parentAgentId: Schema.NullOr(AgentId), -} as const - -export class UserMessageInput extends Schema.TaggedClass()('user-message', { - ...AgentScopedFields, - messageId: MessageId, - text: Schema.String, -}) {} -export type UserMessageInput = typeof UserMessageInput.Type - -export class UserMessageEntry extends Schema.TaggedClass()('user-message', { - ...StoredEnvelopeFields, - ...AgentScopedFields, - messageId: MessageId, - text: Schema.String, -}) {} -export type UserMessageEntry = typeof UserMessageEntry.Type - -export class ToolResultInput extends Schema.TaggedClass()('tool-result', { - ...AgentScopedFields, - messageId: MessageId, - output: Schema.String, -}) {} -export type ToolResultInput = typeof ToolResultInput.Type - -export class ToolResultEntry extends Schema.TaggedClass()('tool-result', { - ...StoredEnvelopeFields, - ...AgentScopedFields, +const UserMessage = Schema.TaggedStruct('user-message', { messageId: MessageId, - output: Schema.String, -}) {} -export type ToolResultEntry = typeof ToolResultEntry.Type + message: UserMessageEncoded, +}) -export const LogEntryInput = Schema.Union([UserMessageInput, ToolResultInput]).annotate({ - identifier: 'LogEntryInput', - discriminator: '_tag', +const Compaction = Schema.TaggedStruct('compaction', { + compactionId: CompactionId, + summary: Schema.String, }) -export type LogEntryInput = typeof LogEntryInput.Type -export const LogEntry = Schema.Union([UserMessageEntry, ToolResultEntry]).annotate({ +export const LogEntry = Schema.Union([UserMessage, Compaction]).annotate({ identifier: 'LogEntry', discriminator: '_tag', }) export type LogEntry = typeof LogEntry.Type ``` -Keep repeated field groups as schema field constants (`StoredEnvelopeFields`, `AgentScopedFields`) instead of -duplicating TypeScript object types. If a variant has invariants, pass a checked `Schema.Struct` to -`Schema.TaggedClass` and derive the type from the class. - -```ts -type AgentRunContext = { - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId | null -} +When a durable format changes incompatibly, add a new versioned schema and upcast at the decode boundary. Do not +silently change the meaning of a persisted v1 field or use a type assertion to reinterpret historical data. -const AgentRunContextFilter = Schema.makeFilter( - ({ parentAgentId, toolCallId }) => { - const bothNull = parentAgentId === null && toolCallId === null - const bothSet = parentAgentId !== null && toolCallId !== null +## Optionality and unknown fields - return bothNull || bothSet ? undefined : 'parentAgentId and toolCallId must both be null or both be set' - }, - { identifier: 'AgentRunContext' }, -) +Choose the exact wire contract: -export class AgentStartedInput extends Schema.TaggedClass()( - 'agent-started', - Schema.Struct({ - agentId: AgentId, - parentAgentId: Schema.NullOr(AgentId), - toolCallId: Schema.NullOr(ToolCallId), - model: Schema.String, - }).check(AgentRunContextFilter), -) {} -export type AgentStartedInput = typeof AgentStartedInput.Type -``` +- `Schema.optionalKey(S)` means a key may be absent. +- `Schema.optional(S)` permits an absent key or an explicit `undefined` value. +- `Schema.NullOr(S)` means the key is present and its value is either `null` or `S`. +- `Schema.Unknown` and `Schema.Json` are valid inside an explicit extensibility/payload boundary. Keep that unknown + data contained, decoded, or narrowed before it becomes domain behavior. -The helper `type AgentRunContext` is acceptable because it exists only to type the filter callback. It is not a -public domain type and does not duplicate an exported schema contract. +Do not flatten absent, `undefined`, and `null` merely to make a caller easier to write. Provider and persisted data +often assign different meanings to them. -## Tagged Errors +## Decode before domain behavior -Use `Schema.TaggedErrorClass` for typed expected failures. Error fields should be safe to log and structured for -recovery. Model the caller action, not the transport status. +Decode at a file, JSONL, provider, host, or network boundary. Decoding is distinct from constructing a value Fold +already trusts. ```ts -import { Schema } from 'effect' - -import { OrganizationId } from './ids' +const decodeLogEntry = Schema.decodeUnknownEffect(LogEntry) -export const WidgetOperation = Schema.Literals(['list', 'sync', 'notify']).annotate({ - identifier: 'WidgetOperation', -}) -export type WidgetOperation = typeof WidgetOperation.Type - -export class WidgetUnavailableError extends Schema.TaggedErrorClass()( - 'WidgetUnavailableError', - { - operation: WidgetOperation, - retryable: Schema.Boolean, - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} - -export class WidgetNeedsReauthError extends Schema.TaggedErrorClass()( - 'WidgetNeedsReauthError', - { - operation: WidgetOperation, - organizationId: OrganizationId, - message: Schema.String, - }, -) {} - -export type WidgetError = WidgetUnavailableError | WidgetNeedsReauthError -``` - -Avoid `cause: unknown` in the schema. Prefer `cause: Schema.optional(Schema.Defect())`, and keep raw inspection at -the adapter boundary before mapping into a tagged error. - -## Boundary Parsing - -Decode at trust boundaries and keep service internals typed. - -```ts -const decodeLogEntry = (input: unknown): Effect.Effect => - Schema.decodeUnknownEffect(LogEntry)(input) - -const encodeLogEntry = (entry: LogEntry): Effect.Effect => - Schema.encodeUnknownEffect(LogEntry)(entry) +const decoded = yield * decodeLogEntry(unknownInput) +const trusted = LogEntry.make(trustedInput) ``` -Use `Schema.decodeUnknownEffect` for inbound JSON, webhooks, CLI input, provider responses, and JSONL/database -JSON blobs. Use `Schema.encodeUnknownEffect` before persisting or sending schema-modeled values across a wire. +Use `Schema.decodeUnknownEffect` for encoded/untrusted input. Use `make` only when the input is already the schema's +type-side construction input; use `makeEffect` when trusted construction can still legitimately fail in a workflow. +Do not use `as T` to skip decoding. -## Anti-Patterns +## Tagged values and errors -```ts -// Wrong: schema and type can drift. -export const UserMessage = Schema.Struct({ - messageId: MessageId, - text: Schema.String, -}) -export type UserMessage = { - readonly messageId: string - readonly text: string -} +After decoding, normal tagged data uses `Predicate` and `Match` according to `PREDICATE-MATCH.md`. Schema decoding +establishes the whole variant shape; `Predicate.isTagged` establishes only a tag and must not replace decoding. -// Wrong: identity values are interchangeable strings. -export type AppendInput = { - readonly agentId: string - readonly messageId: string -} +Expected errors retain Effect's error channel. Use `Data.TaggedError` for internal errors, then recover with +`Effect.catchTag` or `Effect.catchTags`. Choose a schema-backed error only when an adapter must encode/decode that +error as part of its public boundary. -// Wrong: tagged union exists only in TypeScript and cannot decode/encode itself. -export type LogEntry = - | { readonly _tag: 'user-message'; readonly messageId: MessageId; readonly text: string } - | { readonly _tag: 'tool-result'; readonly messageId: MessageId; readonly output: string } +## IDs and records -// Wrong: expected error is not schema-modeled. -export class WidgetUnavailableError extends Data.TaggedError('WidgetUnavailableError')<{ - readonly message: string - readonly cause?: unknown -}> {} -``` +Fold already uses schema-backed IDs where identity matters. Introduce another brand only when it prevents a realistic +cross-domain mix-up or protects a persisted/public contract. A raw string remains correct for freeform text, +provider-owned opaque identifiers, paths, and display values. -Prefer the schema/class value as the contract. The type alias is derived documentation for TypeScript, not a -second source of truth. +Keep repeated durable event fields as schema field constants when it improves consistency. A local helper type is +acceptable for typing a schema filter; do not maintain an exported hand-written object type that duplicates an +exported schema's fields. diff --git a/.claude/skills/effect-program-design/SKILL.md b/.claude/skills/effect-program-design/SKILL.md index 3c4ed51..c1ba7c7 100644 --- a/.claude/skills/effect-program-design/SKILL.md +++ b/.claude/skills/effect-program-design/SKILL.md @@ -1,191 +1,142 @@ --- name: effect-program-design description: >- - Design, write, and review Effect service modules (Context.Service + Layer) using the riptide-api - "deep module" pattern. Use when creating or changing an Effect service, external adapter, tagged - error, layer, or its @effect/vitest tests; or when reviewing Effect code for module depth, typed-error - capture-then-narrow, observability (spans/logs/Sentry), Config/Redacted secrets, and real-seam layer - tests. The Slack service (apps/riptide-api/src/effects/services/slack) is the canonical reference. + Design, write, review, and test Fold's Effect v4 programs: descriptor-facing public APIs, deep services and + layers, schemas, tagged failures, resource ownership, concurrency, and real-seam Effect tests. Use for Fold + services, adapters, providers, event logs, session workflows, and Effect-based code review. --- -# Effect Program Design - -Build **deep** Effect service modules: a small, domain-shaped public surface hiding substantial behavior, -with typed success **and** error channels, declared dependencies, capture-then-narrow error handling, spans, -and tests that swap real layers at real seams. The Slack service is the gold standard. `workos`, `resend`, -`stripe`, and `s3` are shallow foils — do not copy them. - -CANONICAL references on Effect v4 (effect-smol): - -- https://effect-ts-effect-smol.mintlify.app/introduction -- ~/projects/effect-smol - -## The creed (non-negotiables) - -1. **Deep modules.** The interface is the _cost_, the implementation is the _benefit_. Hide a lot behind a - small, simple shape. The caller never learns the module's internals. -2. **Everything stays in Effect.** Typed success **and** error channels; dependencies declared in `R` (Effect - services), never passed as arguments. No plain functions that receive/inspect errors or carry errors as values. -3. **Two-tier errors, capture-then-narrow.** Classify raw failures into tagged errors → capture (log + Sentry) - **before** narrowing → map to a small, caller-actionable public union. `catchTag`/`catchTags` only — never - `instanceof`, never `error._tag === '…'`. -4. **Observe everything.** A span per public method, structured logs, Sentry for the actionable/unexpected — - all with safe fields, secrets `Redacted`. -5. **Testable by layer substitution.** Every service is exercised through its public interface with deps - swapped as layers (`@effect/vitest` + `Effect.provide`). No module mocks, no method spies. - -> See `REFERENCE.md` for the annotated file skeleton, copy-paste stubs, and the anti-pattern catalog with -> in-repo line references. -> See `SCHEMA-DOMAIN-PATTERNS.md` for schema-first domain modeling: `Schema.TaggedClass`, branded IDs, -> `Schema.TaggedErrorClass`, and deriving TypeScript types from schemas instead of hand-writing parallel types. - -## 1. Anatomy — the file set - -Split by responsibility. Names like `client`/`dispatch`/`persistence`/`oauth` are Slack-specific, not required. - -| File | Owns | -| -------------------------------------------- | --------------------------------------------------------------------------------------- | -| `x.service.ts` | the `Context.Service` **shape** + the **live layer** (`Layer.effect`) | -| `x.errors.ts` | all `Schema.TaggedErrorClass` classes + the internal/public error unions | -| `x.types.ts` | domain types (and optionally the service shape — both fine) | -| `x.client.ts` | the external adapter: wraps the SDK/HTTP, emits typed errors, owns the SDK-error mapper | -| `x.persistence.ts` | DB operations (Drizzle), each mapping driver errors to a tagged error | -| `x..ts` | further sub-effects (dispatch/oauth/…) by responsibility | -| pure helpers (`format.ts`, `classify.ts`, …) | functional core — no I/O, no deps | - -A small service may stay in one file. Split when it spans external-call + persistence + orchestration. - -## 2. Deep modules - -- **Small, domain-shaped surface.** Few methods; each input/output a domain type; **each method's error union - is small, caller-actionable, and distinct from (smaller than) the internal vocabulary.** Slack: 8 internal - `SlackProviderError` tags → public `SlackConnectionError` (4); `dispatch*` advertises `never`. -- **The caller must not know the module's internals.** A service resolves the data that is its _own_ concern - rather than making the caller fetch and pass it. `listChannels({ organizationId })` resolves the bot token - internally; a shallow `listChannels({ botToken })` leaks an internal. -- **But accept the domain inputs the caller legitimately holds, as named types.** Not "pass a `taskId` and - look it up" — if callers have the `Task`, take a `Task`. Don't destructure its fields into the signature. -- **IDs:** branded IDs are the default for identity values. Use raw `string` only for non-identity text, - provider-owned names, opaque external strings, or display values. Always pass IDs in named input objects - (`{ organizationId }`) — never positional bare strings or same-typed positional args. -- **Decision rule:** _to produce this argument, would the caller have to know how the module works inside?_ - Yes → the module resolves it. No, it's a value they already hold → accept it as its domain type. -- **Deletion test:** removing the module must _spread_ complexity to callers, not erase it. Shallow tells: a - 1:1 SDK/table mirror; an interface that makes callers supply internals. - -## 3. Errors - -- **`Schema.TaggedErrorClass`** for every expected failure — stable tag, structured safe fields, optional - `cause: Schema.Defect()`. Derive operation/status/id field types from schemas, not duplicate TypeScript types. -- **Two tiers.** Internal: the rich vocabulary of everything that can break (transport + API-body + persistence). - Public: a small union of caller-actionable outcomes per method. -- **Model the decision, not the status.** `SlackNeedsReauthError`, `retryable`, `…Unavailable`, `AlreadyRequested` - — not `ServerError`/`RateLimitError` status buckets. Never make a caller string-match a message to recover a - distinction (the workos `'already invited'` smell). -- **Classify in the channel.** Transform the **error channel** with `catchTags`/`mapError` on typed errors — - not a plain `(cause: unknown) => Error`. Prefer SDKs that already emit typed errors (`@humanlayer/effect-slack`). -- **Throwing SDKs** (the one exception): `Effect.tryPromise({ try, catch })` where `catch` delegates to **one** - per-adapter `mapSdkErrorToEffectError(cause) => TaggedError`. Centralized — no scattered cause-inspection, - no `instanceof`, no `(x as any).status` sprinkled around. -- **Banned:** `instanceof` in Effect code; `error._tag === '…'` equality; raw/`unknown` errors leaked to callers; - errors carried as values (`{ ok: false, error: string }`). Aggregate with `Effect.result` → `Result`. - -## 4. Effect purity & dependencies - -- **Declare service deps in `R`** (`yield* PostgresDb`, `yield* WorkosService`). Never pass a service/layer as a - function argument (`(workos) => Layer.succeed(...)` is wrong). -- **Effect-needs-effect → compose with the pipe pattern** (`.pipe` / `yield*` / `flatMap`), not by passing - effects around. Passing an effect/capability is a rare, justified exception. -- The functional core (parsers, formatting, decisions) is pure — no I/O, no logger, no ambient time/randomness. - The imperative shell (the layer + adapters) sequences effects, does I/O, classifies failures, observes. - -## 5. Observability — three channels - -- **Spans (required).** One `Effect.withSpan('service_name.operation', { attributes })` per **public method**, - plus **child spans for sub-effects that do I/O or are expensive**, **none for pure helpers**. Add safe context - with `Effect.annotateCurrentSpan({...})`. Naming: `snake_case` `domain.operation` (`slack.list_channels`). - Spans live **inside** the service, not only at the orpc handler. -- **Logs.** `Effect.logError` (+ `logDebug`/`logInfo` for notable events) with `Effect.annotateLogs({...})`. - Reuse the **same attribute object** for logs and spans. -- **Sentry — actionable or unexpected only.** `Sentry.captureException(error, { tags: { error_type }, extra })` - for integration/transport breakage, defects (via `catchCause`), and anything degrading a capability a human - should see. **Not** pure control-flow / input validation (`SlackNotConnectedError`, `…ValidationError`). -- **Capture before you narrow or swallow.** `tapError(log)` + `tapError(Sentry)` on the **raw** error _before_ - `catchTags`. Best-effort work captures, then swallows — never swallows silently. Top-level nets use - `catchCause` (catches defects too), not just `catchTag`. -- **Safe fields only.** Domain IDs, operation, provider, tags, `has_access_token: Boolean(...)`. Secrets are - `Redacted` and never logged/spanned. - -## 6. Config & secrets - -- Load **all env vars and secrets via Effect `Config`** (`Config.string` / `Config.redacted` / `Config.url`, - `Config.withDefault(...)`) inside the layer's `Effect.gen`. No `process.env` in service logic. -- Secrets are `Redacted` end-to-end; `Redacted.value(...)` **only at the adapter edge** making the call. -- `Config`-based loading is what makes the service testable without env (tests inject `ConfigProvider`). - -## 7. Boundaries & DB rows - -- **Parse untrusted boundaries into domain types** at the adapter edge (HTTP/SDK/JSON/webhooks/user input). - Slack maps raw `conversations.list` objects → `SlackChannel`. Parsing of inbound request bodies happens at - the oRPC/webhook entrypoint; the service receives already-parsed domain inputs. -- **Schema-first domain modeling.** Domain records, commands, durable events, and discriminated unions should be - modeled as schemas first (`Schema.TaggedClass` for tagged variants; branded schemas for IDs), then exported as - `type X = typeof X.Type`. Avoid hand-written object types that duplicate schema fields. -- **DB rows: scalar trust, jsonb parse.** Trust Drizzle `$inferSelect` types for straightforward scalar columns - (Postgres enforces them). **Parse `jsonb`** — Postgres does not enforce jsonb shape — with the existing **Zod** - schemas (`@codelayer/db/zodschemas/*`, `drizzle-zod` select schemas). No Effect Schema bridging. -- **Never return a raw `$inferSelect` row across the public interface** — project to a domain type - (Slack returns `SlackConnectionStatus`, not the integration row). -- `parseX` / `makeX` / `isX` naming (avoid `validateX`); no generic `isRecord`/`isObject` guards; no `as T` on - decoded JSON or rows. - -## 8. Async & workflows - -- **Bounded concurrency** for unbounded/fan-out work (`Effect.forEach(xs, f, { concurrency })`); start independent - work together rather than awaiting in a loop. -- **Idempotency** on retried creates (idempotency keys; `onConflictDoNothing` claims). **Atomic transition guards** - for lifecycle writes (the Slack thread-claim via `acquireUseRelease`). Do **not** hold a DB transaction open - across a network call. -- **Best-effort / `Effect`** is the right shape for fan-out/notification side-effects where one - failure must not fail the caller: capture (log + Sentry) then swallow; surface a typed error only when the - caller can act on it. Codify Slack's `absorbDeliveryFailure` + `catchCause`. -- No floating/unsupervised effects. - -## 9. Testing — real-seam layers with `@effect/vitest` - -- **Always `@effect/vitest`.** `import { describe, it } from '@effect/vitest'`; keep `expect`/`beforeAll`/`afterAll` - from `vitest`. The Slack `ManagedRuntime` + plain-vitest tests are **legacy** — write new tests with `it.effect`. -- **`it.effect('…', () => Effect.gen(function*(){ … }).pipe(Effect.provide(layer)))`.** No `ManagedRuntime`, no - manual `runPromise`/`dispose`. -- **Build the layer** behind a `makeLayer(opts)` factory: - `Layer.provideMerge(ServiceLive, Layer.mergeAll(PostgresDbLive(db), …fakes, ConfigProvider…))`. -- **Substitute each dep by category:** real ephemeral DB (`PostgresDbLive(db)` + `createTestDb`) for persistence - behavior; a hand-fake `PostgresDb` layer for pure-logic-over-DB; `Layer.succeed(Service, {...})` recording-store - fakes for true externals (unused methods `Effect.die('… not used')`); a fake `HttpClient` layer or loopback - server for transport; `ConfigProvider.fromUnknown({...})` for config. -- **Expose a `…Base` layer** that leaves the external transport unprovided, so tests can inject a fake `HttpClient`. -- **Assert on both** the returned value / narrowed error **and** real side-effect end-state (DB rows via - `Effect.promise(() => db.select()…)`, recording-store contents). -- **Banned:** `vi.mock`, `vi.spyOn`, module patching, method spies. If a dep can't be swapped via a layer, the - module is wrong (hidden/ambient/arg-passed) — fix the module, not the test. - -## 10. TypeScript contracts (must-haves) - -No `any`, no `!`, no unjustified `as` (escape hatches are local, behind precise interfaces, with a `SAFETY:` -comment + lint-disable reason). `readonly` by default. `??` not `||` for "absent" defaults; no `filter(Boolean)`. -`import type` for type-only imports; no barrels; JSDoc on exports. Guard clauses (no `else` after `return`). -`Map`/`Set` for dynamic keyed collections. Precise file names — no `utils.ts`/`helpers.ts` dumping grounds. +# Fold Effect Program Design + +Build deep Fold modules: a small, domain-shaped public interface hides descriptor lowering, service wiring, provider +details, persistence, resource ownership, and workflow coordination. Keep expected failures in Effect's error channel, +dependencies in `R`, resources in scopes, and external values at explicit schema boundaries. + +Fold is an Effect v4 (`4.0.0-rc.109`) Bun monorepo. Read the installed declarations first; when they do not settle an +API, read `~/projects/effect`, not Effect v3 documentation or examples. + +## Fold's architecture + +- **Public Fold APIs are descriptor-facing.** Hosts use `defineAgent`, model descriptors, tool descriptors, and + event-log descriptors without learning `Layer`, `Toolkit`, or runtime wiring. See `README.md` and + `packages/fold-core/src/Api/Provisioning.ts`. +- **Provisioning owns lowering.** The provisioner turns descriptors into the required services and layers once per + runtime/session. Do not make callers build or pass Fold's internal clients, layers, toolsets, or runtime services. +- **Services are internal capabilities.** Use `Context.Service` and `Layer` where a capability varies by runtime, + implementation, or test seam. One implementation is enough when the seam is valuable for real tests. +- **Event schemas are durable contracts.** Model persisted and wire-visible log entries with `Schema.TaggedStruct` and + `Schema.Union`; decode before projecting or dispatching. See `packages/fold-core/src/EventLog/Schemas.ts`. + +## The defaults + +1. **Deep modules.** A module's interface is the cost; hidden behavior is the benefit. A caller supplies the domain + values it holds, not credentials, provider clients, decoded rows, layers, or other internals. +2. **Effects retain their channels.** Expected failure stays in `E`; dependencies stay in `R`; resources have an + owning scope. Do not pass errors, services, layers, or effects around as ordinary data just to compose them later. +3. **Typed failures recover in-channel.** Classify an untrusted/throwing boundary once, then use `Effect.catchTag` or + `Effect.catchTags` to recover or narrow. Do not recover typed failures with `instanceof` or manual `_tag` checks. +4. **Normal tagged data dispatches explicitly.** For decoded or otherwise trusted union values, use `Predicate` for + reusable narrowing and `Match` for complete transformations. See `PREDICATE-MATCH.md`. +5. **Schemas parse boundaries.** Decode JSON, files, provider payloads, host input, and durable records at their edge. + Do not cast or ad hoc-narrow unknown data through the core. +6. **Tests cross real seams.** Test services through their public interface with `@effect/vitest` and substitute layers + or real test adapters. Do not use `vi.mock`, module patching, or method spies. + +## Module depth and seams + +- Give public operations named domain inputs and outputs. A descriptor API should hide the provider/layer graph it + needs to realize the descriptor. +- Use the deletion test: deleting a useful module should spread its orchestration and invariants across callers, not + simply delete a pass-through. +- Keep pure domain decisions, projections, and formatting separate from the I/O shell. Pure helpers accept and return + domain values; layers/adapters sequence Effects and own I/O. +- Do not add a wrapper solely to mirror an SDK or data structure. A module earns its seam by concentrating meaningful + lifecycle, policy, parsing, or orchestration. +- Do not make all dependencies ordinary parameters. Runtime-varying capabilities belong in Effect's environment; pure + values legitimately held by the caller stay explicit inputs. + +## Services, layers, and resources + +- Prefer `Context.Service` for an application capability and `Layer.effect` or `Layer.scoped` for its implementation. + Small capabilities may collocate shape, tag, and live layer; split an external adapter, persistence, or workflow + only when that separation improves locality. +- Dependencies normally remain ambient in `R`. Yield them where the operation needs them rather than forwarding them + through public signatures. +- Build runtime-specific layer graphs at the provisioner/facade seam. Fold's agent provisioner owns memo-map and + scope semantics; callers do not recreate that graph. +- Acquire resources in a scope and make background work supervised/owned. Bound concurrency for unbounded fan-out and + keep external calls outside authoritative transactions. +- Use `Effect.fn` or `Effect.withSpan` for meaningful public or I/O operation boundaries when observability is + configured. Add safe context only; never log secrets or unrestricted provider payloads. + +## Errors and boundaries + +- Model expected failures as tagged errors with fields a caller can use. `Data.TaggedError` is appropriate for an + internal failure; use a schema-backed tagged error when the error itself crosses an encoded boundary. +- Wrap a throwing SDK or native API once at its adapter edge with `Effect.try` or `Effect.tryPromise`. Its `catch` + maps the unknown cause into the module's typed error vocabulary. Do not repeat provider-specific inspection in + callers. +- Preserve rich internal failures until the module boundary, then narrow to the small set of outcomes callers can + actually act on. A fallback is only correct when it is part of the operation's contract. +- Capture actionable or unexpected failures with the repository's configured logging/observability before swallowing + or narrowing them. Do not add Sentry or other product-specific dependencies unless Fold provides and configures one. +- Use `catchCause` only for a deliberate top-level safety net that must include defects, such as a best-effort + operation. It must make the failure observable before it is swallowed. + +## Schema and domain data + +- Use `Schema.Struct` for ordinary records and `Schema.TaggedStruct` plus `Schema.Union` for encoded tagged variants. + Derive the TypeScript type from the schema with `typeof X.Type`. +- Use `Schema.Literals` for closed scalar vocabularies. Branded IDs are valuable where Fold must prevent + same-typed identity mix-ups, but do not introduce brands by default without a concrete boundary or misuse risk. +- Use `Schema.optionalKey`, `Schema.optional`, and `Schema.NullOr` to preserve the distinction between absent, + `undefined`, and `null` values. +- Decode untrusted encoded values with `Schema.decodeUnknownEffect`; use `make`/`makeEffect` only for trusted + type-side construction. Do not cast decoded JSON. +- Keep schemas that define durable log data backward compatible. Add an entry schema/version and upcast at the decode + boundary instead of mutating an incompatible persisted format. + +## Predicate and Match + +The error channel and ordinary tagged values require different tools: + +- Use `Effect.catchTag` / `Effect.catchTags` for typed errors in `Effect`. +- Use `Predicate.isTagged` for a reusable one-tag guard or a named multi-tag refinement used by `find`, `filter`, or + a guard clause. +- Use `Match.type()` and `Match.tagsExhaustive` for a reusable transformation over a Fold-owned closed union. + Use `Match.value(value)` for a one-off dispatch. +- Keep a simple direct `_tag` guard in a local stateful loop when a matcher or extracted predicate would add ceremony. + Do not mechanically replace every conditional. Repeated comparisons and complete domain transformations should not + drift across ad hoc conditionals. +- Neither `Predicate` nor `Match` validates an unknown provider/file/JSON value. Decode it first. + +Read `PREDICATE-MATCH.md` before writing or reviewing tagged normal-value control flow. + +## Tests + +- Use `@effect/vitest` and `it.effect`; provide the service/layer graph to the program under test. +- Substitute an external provider, filesystem, clock, or other true boundary with a narrow fake Effect layer. Use real + ephemeral infrastructure when behavior depends on its constraints or persistence semantics. +- Prefer deterministic `TestClock`, `Deferred`, `Queue`, `Latch`, and `Ref` coordination over sleeps and timing + races. +- Assert both the returned value/error and the relevant observable end state: durable entries, emitted events, files, + requests recorded by a fake, or released resources. +- A fake should fail loudly for unexpected methods. If a dependency cannot be replaced at a layer seam, improve the + module boundary rather than reaching for a module mock. + +## References + +- `PREDICATE-MATCH.md` for tagged normal-value dispatch. +- `SCHEMA-DOMAIN-PATTERNS.md` for Fold schema, durable-data, and error-model choices. +- `REFERENCE.md` for canonical Fold modules and the expected service/layer shapes. +- `codebase-design` for shared vocabulary on depth, interface, seam, adapter, leverage, and locality. ## Review checklist -- [ ] Public surface small, domain-shaped; per-method error union narrow + distinct from internal. -- [ ] No `$inferSelect` row, `any`, or `unknown` across the public seam. -- [ ] Errors are `Schema.TaggedErrorClass`, model caller actions; classified in-channel; **one** SDK-error mapper. -- [ ] Capture (log + Sentry) happens on the raw error **before** narrowing/swallowing; `catchCause` at top nets. -- [ ] `Effect.withSpan` on every public method; safe annotations; secrets `Redacted`. -- [ ] All deps in `R`; no service/layer/effect passed as an argument; no errors-as-values. -- [ ] Config via `Config.*`; secrets `Redacted`, unwrapped only at the edge. -- [ ] jsonb parsed with Zod; scalar columns trusted. -- [ ] Tests use `@effect/vitest` `it.effect` + `Effect.provide(layer)`; real seams; no `vi.mock`/`vi.spyOn`; - assert value **and** persisted end-state. -- [ ] No `instanceof`, no `error._tag === '…'`, no plain `(cause) => Error` classifiers. +- [ ] The public interface is small, domain-shaped, and hides Fold's descriptor-lowering and runtime wiring. +- [ ] Expected failures remain typed in `E`; dependencies remain declared in `R`; resources have an owner. +- [ ] Unknown data is decoded at the edge and raw provider/file data does not leak through a public contract. +- [ ] Typed errors use `catchTag` / `catchTags`; ordinary trusted tags use `Predicate` / `Match` when appropriate. +- [ ] Fold-owned complete unions use an exhaustive transformation where a new variant must force a review. +- [ ] Provider calls, concurrency, retries, and resource lifetime are bounded and owned by the module that needs them. +- [ ] Tests use `@effect/vitest`, real seams, deterministic coordination, and no module mocks or spies. diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 27ece33..b676af2 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -3,8 +3,19 @@ // The `correctness` category is enabled by default. Keep the broader Effect Channels // plugin set and Vitest's test-specific rules. "plugins": ["typescript", "import", "oxc", "eslint", "unicorn", "node", "vitest"], - "ignorePatterns": ["**/node_modules/**", "**/dist/**", ".release/**"], + "jsPlugins": [ + { "name": "anti-slop", "specifier": "./tools/oxlint/anti-slop/index.ts" }, + { "name": "automation", "specifier": "./tools/oxlint/automation/index.ts" }, + ], + "ignorePatterns": ["**/node_modules/**", "**/dist/**", ".release/**", "tools/oxlint/**"], "rules": { + "anti-slop/no-module-mocking": "error", + "anti-slop/no-object-parameters": "error", + "automation/no-shadowed-standard-array-static": "error", + "automation/no-disable-validation": "error", + "automation/no-silent-error-swallow": "error", + "automation/prefer-effect-match": "error", + "anti-slop/no-reflect-apply": "error", "typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }], "typescript/no-import-type-side-effects": "error", "import/no-duplicates": "error", @@ -45,6 +56,25 @@ "import/extensions": ["error", "never"], }, "overrides": [ + { + // TUI code intentionally permits exploratory implementation patterns. + "files": [ + "packages/fold-tui-theme/**", + "packages/fold-cli/src/tui/**", + "packages/fold-cli/test/tui/**", + "packages/fold-cli/test/Tui*.vi.test.ts", + "packages/fold-cli/test/fixtures/Tui*.tsx", + ], + "rules": { + "anti-slop/no-module-mocking": "off", + "anti-slop/no-object-parameters": "off", + "anti-slop/no-reflect-apply": "off", + "automation/no-disable-validation": "off", + "automation/no-shadowed-standard-array-static": "off", + "automation/no-silent-error-swallow": "off", + "automation/prefer-effect-match": "off", + }, + }, { // The vitest-config package needs explicit `.ts` extensions for Node's strict // ESM resolution when Vite externalizes the workspace package. diff --git a/bun.lock b/bun.lock index e6d6cfe..f372f98 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "fold", "devDependencies": { "@effect/tsgo": "0.36.4", + "@oxlint/plugins": "1.77.0", "@types/bun": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", @@ -495,6 +496,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.77.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw=="], + "@oxlint/plugins": ["@oxlint/plugins@1.77.0", "", {}, "sha512-6KtPXskPgpt+0Kyl2nNSxNnYXt7lbkxW1C7vi2L7xwtq/AisZlsmV+hnzOTEyM5tU1TxPv1GpTqpUBbZfPzBLg=="], + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@3.3.0", "", { "dependencies": { "@noble/hashes": "^2.0.1", "bignumber.js": "^9.3.1", "error-causes": "^3.0.2" }, "bin": { "cuid2": "bin/cuid2.js" } }, "sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw=="], "@redis/bloom": ["@redis/bloom@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag=="], diff --git a/package.json b/package.json index 73fbb29..0a61008 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ }, "devDependencies": { "@effect/tsgo": "0.36.4", + "@oxlint/plugins": "1.77.0", "@types/bun": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", diff --git a/packages/fold-agent/src/Bin/ManagedBinaries.ts b/packages/fold-agent/src/Bin/ManagedBinaries.ts index 8a2d662..2bbe97e 100644 --- a/packages/fold-agent/src/Bin/ManagedBinaries.ts +++ b/packages/fold-agent/src/Bin/ManagedBinaries.ts @@ -332,11 +332,7 @@ const installFromAsset = ( yield* context.fs.rename(extractedPath, installPath) yield* context.fs.chmod(installPath, 0o755) - }).pipe( - Effect.ensuring( - context.fs.remove(extractDir, { recursive: true, force: true }).pipe(Effect.catch(() => Effect.void)), - ), - ) + }).pipe(Effect.ensuring(context.fs.remove(extractDir, { recursive: true, force: true }).pipe(Effect.ignore))) }) /** Resolve one binary through the system -> managed -> download ladder. Failures propagate typed (inferred). */ diff --git a/packages/fold-agent/src/Catalog/LoadCatalog.ts b/packages/fold-agent/src/Catalog/LoadCatalog.ts index 5a53ac4..f8d3c20 100644 --- a/packages/fold-agent/src/Catalog/LoadCatalog.ts +++ b/packages/fold-agent/src/Catalog/LoadCatalog.ts @@ -122,14 +122,12 @@ const writeCache = (fs: FileSystem.FileSystem, path: string, cache: ModelCatalog const json = JSON.stringify(cache) const tmpPath = `${path}.tmp` - yield* fs.makeDirectory(dirname(path), { recursive: true }).pipe(Effect.catch(() => Effect.void)) + yield* fs.makeDirectory(dirname(path), { recursive: true }) yield* fs.writeFileString(tmpPath, json).pipe( Effect.andThen(fs.rename(tmpPath, path)), // Seams without rename (the in-memory test FileSystem) fall back to a plain write. Effect.catch(() => - fs - .writeFileString(path, json) - .pipe(Effect.andThen(fs.remove(tmpPath).pipe(Effect.catch(() => Effect.void)))), + fs.writeFileString(path, json).pipe(Effect.andThen(fs.remove(tmpPath).pipe(Effect.ignore))), ), ) }).pipe( diff --git a/packages/fold-agent/src/Config/AgentModels.ts b/packages/fold-agent/src/Config/AgentModels.ts index c65e4a8..14e05b6 100644 --- a/packages/fold-agent/src/Config/AgentModels.ts +++ b/packages/fold-agent/src/Config/AgentModels.ts @@ -18,7 +18,7 @@ import { anthropicModel, DEFAULT_ANTHROPIC_MODEL_ID, lookupCatalogEntry, openaiM import type { ActiveModel, ModelCatalogEntry, ReasoningLevel, FoldModel } from '@humanlayer/fold-core' import { DEFAULT_OPENCODE_MODEL_ID, openCodeModel } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID, xaiModel } from '@humanlayer/fold-xai' -import { Effect, Redacted, Schema } from 'effect' +import { Effect, Match, Redacted, Schema } from 'effect' import { ConfigRole, type ProviderConnection, type RoleBinding, type FoldConfig } from './ConfigSchema' @@ -62,12 +62,12 @@ export const agentModelsFromConfig = (config: FoldConfig, options?: AgentModelsO const catalog = options?.catalog /** The binding for a role; `orchestrator` falls back to `smart`. */ - const bindingFor = (role: ConfigRole): RoleBinding => - role === 'fast' - ? config.roles.fast - : role === 'orchestrator' - ? (config.roles.orchestrator ?? config.roles.smart) - : config.roles.smart + const bindingFor = Match.type().pipe( + Match.when('fast', () => config.roles.fast), + Match.when('orchestrator', () => config.roles.orchestrator ?? config.roles.smart), + Match.when('smart', () => config.roles.smart), + Match.exhaustive, + ) /** Resolve the API key for a keyed provider (anthropic/openai-compat). */ const resolveApiKey = ( diff --git a/packages/fold-agent/src/Config/ConfigSchemaJson.ts b/packages/fold-agent/src/Config/ConfigSchemaJson.ts index 0dffeaf..62956b2 100644 --- a/packages/fold-agent/src/Config/ConfigSchemaJson.ts +++ b/packages/fold-agent/src/Config/ConfigSchemaJson.ts @@ -176,7 +176,13 @@ export const bootstrapFoldHome = (options?: ConfigInitOptions): Effect.Effect Effect.void)) + yield* fs + .chmod(authPath, 0o600) + .pipe( + Effect.catch((error) => + Effect.logWarning(`could not set auth file permissions at ${authPath}: ${error.message}`), + ), + ) } return { configPath, schemaPath, infoPath, authPath, createdConfig: !configExists, createdAuth: !authExists } diff --git a/packages/fold-agent/src/Config/ModelSelections.ts b/packages/fold-agent/src/Config/ModelSelections.ts index cbf856e..89302b0 100644 --- a/packages/fold-agent/src/Config/ModelSelections.ts +++ b/packages/fold-agent/src/Config/ModelSelections.ts @@ -2,7 +2,7 @@ import { DEFAULT_CODEX_MODEL_ID } from '@humanlayer/fold-codex' import { DEFAULT_ANTHROPIC_MODEL_ID, type ModelCatalogEntry, type FoldModel } from '@humanlayer/fold-core' import { DEFAULT_OPENCODE_MODEL_ID, GROK_BUILD_MODEL_ID } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID } from '@humanlayer/fold-xai' -import { Effect } from 'effect' +import { Effect, Match } from 'effect' import { agentModelsFromConfig, type AgentModelsOptions, RoleResolutionError } from './AgentModels' import type { ConfigRole, ProfileConfig, ProfileModeName, RoleBinding, FoldConfig } from './ConfigSchema' @@ -74,14 +74,12 @@ export const describeModelConfiguration = ( const catalogModels = catalog .filter((entry) => catalogProviderIds.includes(entry.providerId)) .map((entry) => entry.modelId) - const defaultModels = - provider.kind === 'codex' - ? [DEFAULT_OPENCODE_MODEL_ID] - : provider.kind === 'opencode' - ? [DEFAULT_OPENCODE_MODEL_ID, GROK_BUILD_MODEL_ID] - : provider.kind === 'xai' - ? [DEFAULT_XAI_MODEL_ID] - : [] + const defaultModels = Match.value(provider.kind).pipe( + Match.when('codex', () => [DEFAULT_OPENCODE_MODEL_ID]), + Match.when('opencode', () => [DEFAULT_OPENCODE_MODEL_ID, GROK_BUILD_MODEL_ID]), + Match.when('xai', () => [DEFAULT_XAI_MODEL_ID]), + Match.orElse((): ReadonlyArray => []), + ) const models = provider.kind === 'xai' ? [DEFAULT_XAI_MODEL_ID] diff --git a/packages/fold-agent/src/Config/ProviderConfig.ts b/packages/fold-agent/src/Config/ProviderConfig.ts index f143d5b..4b8df65 100644 --- a/packages/fold-agent/src/Config/ProviderConfig.ts +++ b/packages/fold-agent/src/Config/ProviderConfig.ts @@ -8,7 +8,7 @@ import { dirname } from 'node:path' import { DEFAULT_CODEX_MODEL_ID } from '@humanlayer/fold-codex' import { DEFAULT_OPENCODE_MODEL_ID } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID } from '@humanlayer/fold-xai' -import { Effect, Schema } from 'effect' +import { Effect, Match, Schema } from 'effect' import { fileSystemFor } from '../Fs/DefaultFileSystem' import type { FoldConfig, ProviderKind } from './ConfigSchema' @@ -102,9 +102,7 @@ const writeConfig = ( Effect.andThen(fs.chmod(path, 0o600)), // Some injected/sandbox filesystems do not implement rename. A mode-restricted direct write is // still reasonable there; clean up the temporary file on either fallback outcome. - Effect.catch(() => - writeDirect.pipe(Effect.ensuring(fs.remove(temporaryPath).pipe(Effect.catch(() => Effect.void)))), - ), + Effect.catch(() => writeDirect.pipe(Effect.ensuring(fs.remove(temporaryPath).pipe(Effect.ignore)))), Effect.mapError( (error) => new ProviderConfigurationWriteError({ path, message: `could not write config: ${error.message}` }), @@ -138,14 +136,12 @@ export const configureProvider = ( }) const apiKey = !oauth && hasApiKey ? yield* required(input.apiKey ?? '', 'apiKey') : undefined const apiKeyEnv = !oauth && hasApiKeyEnv ? yield* required(input.apiKeyEnv ?? '', 'apiKeyEnv') : undefined - const defaultModel = - input.kind === 'codex' - ? DEFAULT_CODEX_MODEL_ID - : input.kind === 'opencode' - ? DEFAULT_OPENCODE_MODEL_ID - : input.kind === 'xai' - ? DEFAULT_XAI_MODEL_ID - : undefined + const defaultModel = Match.value(input.kind).pipe( + Match.when('codex', () => DEFAULT_CODEX_MODEL_ID), + Match.when('opencode', () => DEFAULT_OPENCODE_MODEL_ID), + Match.when('xai', () => DEFAULT_XAI_MODEL_ID), + Match.orElse(() => undefined), + ) const model = input.model === undefined ? defaultModel : yield* required(input.model, 'model') const config = yield* loadFoldConfig(options) const previousModels = config.providers[name]?.configuredModels ?? [] diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index 5fc3493..5782f28 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -34,7 +34,7 @@ import { type FoldSession, type FoldTool, } from '@humanlayer/fold-core' -import { Effect, Schema, Semaphore, type Scope } from 'effect' +import { Effect, Match, Schema, Semaphore, type Scope } from 'effect' import { loadModelCatalog } from '../Catalog/LoadCatalog' import { agentModelsFromConfig, type EnvLookup, type RoleResolutionError } from '../Config/AgentModels' @@ -221,11 +221,12 @@ const modeFor = (opts: LaunchSessionOptions, profileMode: ProfileModeName | null opts.mode ?? (profileMode === null ? defaultCodingMode : modeForName(profileMode)) const roleBindingFor = (config: FoldConfig, role: ConfigRole): RoleBinding => - role === 'fast' - ? config.roles.fast - : role === 'orchestrator' - ? (config.roles.orchestrator ?? config.roles.smart) - : config.roles.smart + Match.value(role).pipe( + Match.when('fast', () => config.roles.fast), + Match.when('orchestrator', () => config.roles.orchestrator ?? config.roles.smart), + Match.when('smart', () => config.roles.smart), + Match.exhaustive, + ) /** * Merge a CLI/OpenTUI model selection over a role's configured binding. Field-wise the selection wins, @@ -253,11 +254,12 @@ const withSelectedRoleBinding = (config: FoldConfig, role: ConfigRole, binding: ...config, roles: { ...config.roles, - ...(role === 'fast' - ? { fast: binding } - : role === 'orchestrator' - ? { orchestrator: binding } - : { smart: binding }), + ...Match.value(role).pipe( + Match.when('fast', () => ({ fast: binding })), + Match.when('orchestrator', () => ({ orchestrator: binding })), + Match.when('smart', () => ({ smart: binding })), + Match.exhaustive, + ), }, }) diff --git a/packages/fold-agent/src/OutputStore/OutputStore.ts b/packages/fold-agent/src/OutputStore/OutputStore.ts index 1fed169..4555841 100644 --- a/packages/fold-agent/src/OutputStore/OutputStore.ts +++ b/packages/fold-agent/src/OutputStore/OutputStore.ts @@ -182,7 +182,7 @@ export const makeOutputStore = (options: MakeOutputStoreOptions): OutputStoreSer if (info === null || info.type !== 'File') continue const mtime = Option.match(info.mtime, { onNone: () => 0, onSome: (date) => date.getTime() }) - if (now - mtime > retentionMs) yield* fs.remove(path).pipe(Effect.catch(() => Effect.void)) + if (now - mtime > retentionMs) yield* fs.remove(path).pipe(Effect.ignore) } } }).pipe( diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index 1220c6c..b8b0542 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -114,7 +114,11 @@ const appendSessionIndexRecord = (record: SessionIndexRecord, options?: SessionL Effect.andThen( fs.writeFileString(join(directory, 'index.jsonl'), `${JSON.stringify(record)}\n`, { flag: 'a' }), ), - Effect.catch(() => Effect.void), + Effect.catch((error) => + Effect.logWarning( + `could not append session index record at ${join(directory, 'index.jsonl')}: ${error.message}`, + ), + ), ) } diff --git a/packages/fold-agent/src/Session/ViewedChanges.ts b/packages/fold-agent/src/Session/ViewedChanges.ts index bf051ae..96b7e92 100644 --- a/packages/fold-agent/src/Session/ViewedChanges.ts +++ b/packages/fold-agent/src/Session/ViewedChanges.ts @@ -56,6 +56,8 @@ export const saveViewedPatchHash = ( const record = { sessionId, changeKey, patchHash, ts: Date.now() } return fs.makeDirectory(directory, { recursive: true }).pipe( Effect.andThen(fs.writeFileString(viewedChangesPath(options), `${JSON.stringify(record)}\n`, { flag: 'a' })), - Effect.catch(() => Effect.void), + Effect.catch((error) => + Effect.logWarning(`could not save viewed change for session ${sessionId}: ${error.message}`), + ), ) } diff --git a/packages/fold-agent/src/Tools/BashTool.ts b/packages/fold-agent/src/Tools/BashTool.ts index f1e5a21..743d1f2 100644 --- a/packages/fold-agent/src/Tools/BashTool.ts +++ b/packages/fold-agent/src/Tools/BashTool.ts @@ -293,10 +293,24 @@ export const bashTool = (options?: BashToolOptions): FoldTool => spillPath, writeSpill: (path, chunk) => outputStore === undefined - ? fs.writeFileString(path, chunk, { flag: 'a' }).pipe(Effect.catch(() => Effect.void)) + ? fs + .writeFileString(path, chunk, { flag: 'a' }) + .pipe( + Effect.catch((error) => + Effect.logWarning( + `could not persist bash output at ${path}: ${error.message}`, + ), + ), + ) : outputStore .append(currentToolCall.toolCallId, chunk) - .pipe(Effect.catch(() => Effect.void)), + .pipe( + Effect.catch((error) => + Effect.logWarning( + `could not persist bash output at ${path}: ${error.message}`, + ), + ), + ), }) // If this call is interrupted, the synthetic tool result points the model at the partial diff --git a/packages/fold-core/src/Model/ModelCatalog.ts b/packages/fold-core/src/Model/ModelCatalog.ts index 89f8342..02b4a7b 100644 --- a/packages/fold-core/src/Model/ModelCatalog.ts +++ b/packages/fold-core/src/Model/ModelCatalog.ts @@ -9,7 +9,7 @@ * low-level composition roots never mention it and every consumer degrades gracefully: compaction * falls back to its interim pattern table, cost rendering falls back to `--`. */ -import { Context, Effect, Schema } from 'effect' +import { Context, Effect, Match, Schema } from 'effect' import type { ActiveModel } from '../EventLog/Schemas' @@ -69,8 +69,11 @@ const candidateProviderIds = (model: ActiveModel): ReadonlyArray => { } /** Deterministic preference for bare-model-id matches: anthropic, then openai, then first-seen. */ -const bareMatchPriority = (providerId: string): number => - providerId === 'anthropic' ? 2 : providerId === 'openai' ? 1 : 0 +const bareMatchPriority = Match.type().pipe( + Match.when('anthropic', () => 2), + Match.when('openai', () => 1), + Match.orElse(() => 0), +) type CatalogIndex = { readonly byProvider: ReadonlyMap> diff --git a/packages/fold-core/src/Session/Profiles.ts b/packages/fold-core/src/Session/Profiles.ts index f3e63be..2faf189 100644 --- a/packages/fold-core/src/Session/Profiles.ts +++ b/packages/fold-core/src/Session/Profiles.ts @@ -8,7 +8,7 @@ * at resolve time is an engine invariant violation - session-start validation rejects any roster whose * role bindings the initial profiles do not cover - and dies like other configuration invariants. */ -import { Context, Effect, Ref, Schema } from 'effect' +import { Context, Effect, Match, Ref, Schema } from 'effect' import type { FoldModel } from '../Api/ModelDescriptor' @@ -78,11 +78,12 @@ export const makeProfiles = (initial: SessionProfiles): Effect.Effect => Ref.update(state, (profiles) => - role === 'smart' - ? { ...profiles, smart: model } - : role === 'fast' - ? { ...profiles, fast: model } - : { ...profiles, orchestrator: model }, + Match.value(role).pipe( + Match.when('smart', () => ({ ...profiles, smart: model })), + Match.when('fast', () => ({ ...profiles, fast: model })), + Match.when('orchestrator', () => ({ ...profiles, orchestrator: model })), + Match.exhaustive, + ), ) return { resolve, set, replace: (profiles) => Ref.set(state, profiles), snapshot: Ref.get(state) } diff --git a/scripts/release/prepare.ts b/scripts/release/prepare.ts index 6fd029d..c59a010 100644 --- a/scripts/release/prepare.ts +++ b/scripts/release/prepare.ts @@ -28,13 +28,8 @@ const repository = { type: 'git', url: 'git+https://github.com/humanlayer/fold.g await rm(stage, { recursive: true, force: true }) function dependencies(manifest: PackageManifest) { - for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies']) { - const dependencyMap = - field === 'dependencies' - ? manifest.dependencies - : field === 'peerDependencies' - ? manifest.peerDependencies - : manifest.optionalDependencies + for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const) { + const dependencyMap = manifest[field] if (dependencyMap === undefined) continue for (const [name, range] of Object.entries(dependencyMap)) { if (range === 'catalog:') diff --git a/tools/oxlint/README.md b/tools/oxlint/README.md new file mode 100644 index 0000000..f9f461f --- /dev/null +++ b/tools/oxlint/README.md @@ -0,0 +1,9 @@ +# Vendored OXLint rules + +This directory contains the selected rules that Fold owns and maintains: + +- `anti-slop` rules are adapted from . +- `automation` rules are adapted from . + +Only rules enabled in `.oxlintrc.jsonc` are vendored. Keep `@oxlint/plugins` aligned with the repository's +`oxlint` version when upgrading either package. diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 0000000..8035521 --- /dev/null +++ b/tools/oxlint/anti-slop/index.ts @@ -0,0 +1,14 @@ +import { eslintCompatPlugin } from '@oxlint/plugins' + +import { noModuleMockingRule } from './rules/no-module-mocking.ts' +import { noObjectParametersRule } from './rules/no-object-parameters.ts' +import { noReflectApplyRule } from './rules/no-reflect-apply.ts' + +export default eslintCompatPlugin({ + meta: { name: 'anti-slop' }, + rules: { + 'no-module-mocking': noModuleMockingRule, + 'no-object-parameters': noObjectParametersRule, + 'no-reflect-apply': noReflectApplyRule, + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts new file mode 100644 index 0000000..1e2951f --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -0,0 +1,81 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins' + +const moduleMockMethods = new Set(['doMock', 'mock', 'unstable_mockModule']) + +function resolveVariable(sourceCode: SourceCode, identifier: ESTree.IdentifierReference): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier) + while (scope !== null) { + const variable = scope.set.get(identifier.name) + if (variable !== undefined) return variable + scope = scope.upper + } + return null +} + +function importedName(node: ESTree.Node): string | null { + if (node.type !== 'ImportSpecifier') return null + return node.imported.type === 'Identifier' ? node.imported.name : node.imported.value +} + +function isTestFrameworkObject( + sourceCode: SourceCode, + expression: ESTree.Expression, +): expression is ESTree.IdentifierReference { + if (expression.type !== 'Identifier') return false + if ((expression.name === 'vi' || expression.name === 'jest') && sourceCode.isGlobalReference(expression)) { + return true + } + + const variable = resolveVariable(sourceCode, expression) + if (variable === null || variable.defs.length === 0) { + return expression.name === 'vi' || expression.name === 'jest' + } + return variable.defs.some((definition) => { + if (definition.type !== 'ImportBinding' || definition.parent?.type !== 'ImportDeclaration') { + return false + } + const source = definition.parent.source.value + const name = importedName(definition.node) + return (source === 'vitest' && name === 'vi') || (source === '@jest/globals' && name === 'jest') + }) +} + +function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { + if (!('property' in callee) || !('object' in callee) || !('computed' in callee)) return false + if (!isTestFrameworkObject(sourceCode, callee.object)) return false + const property = callee.property + const method = callee.computed + ? property.type === 'Literal' && + (property.value === 'doMock' || property.value === 'mock' || property.value === 'unstable_mockModule') + ? property.value + : null + : property.type === 'Identifier' + ? property.name + : null + return method !== null && moduleMockMethods.has(method) +} + +export const noModuleMockingRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.', + }, + messages: { + moduleMock: + 'Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.', + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === 'Super' || node.callee.type === 'V8IntrinsicExpression') return + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: 'moduleMock' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 0000000..e774b4e --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,111 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree, SourceCode } from '@oxlint/plugins' + +import { lexicalTypeParameterNames } from '../shared/lexical-type-parameters.ts' + +type Parameter = ESTree.ParamPattern +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === 'TSParameterProperty') return parameterAnnotation(parameter.parameter) + if (parameter.type === 'RestElement') return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument) + if (parameter.type === 'AssignmentPattern') return parameter.typeAnnotation ?? parameter.left.typeAnnotation + return parameter.typeAnnotation +} + +function parameterName(parameter: Parameter, sourceCode: SourceCode): string { + return parameter.type === 'Identifier' + ? parameter.name + : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, '') +} + +export const noObjectParametersRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow object function parameters; inputs must use an owner-provided type.', + }, + messages: { + objectParameter: + 'Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.', + }, + }, + createOnce(context) { + const aliases = new Map() + + const resolvesToObject = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === 'TSObjectKeyword') return true + if (type.type === 'TSParenthesizedType') + return resolvesToObject(type.typeAnnotation, shadowedAliases, visited) + if (type.type === 'TSUnionType') { + return type.types.some((member) => resolvesToObject(member, shadowedAliases, visited)) + } + if ( + type.type !== 'TSTypeReference' || + type.typeName.type !== 'Identifier' || + (type.typeArguments !== null && + type.typeArguments !== undefined && + type.typeArguments.params.length > 0) || + visited.has(type.typeName.name) || + shadowedAliases.has(type.typeName.name) + ) { + return false + } + const alias = aliases.get(type.typeName.name) + if (alias === undefined) return false + const nextVisited = new Set(visited) + nextVisited.add(type.typeName.name) + return resolvesToObject(alias, shadowedAliases, nextVisited) + } + + const checkParameters = (node: ParameterOwner) => { + const shadowedAliases = lexicalTypeParameterNames(node, context.sourceCode.visitorKeys) + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter) + if (annotation === null || annotation === undefined) continue + if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue + context.report({ + node: annotation.typeAnnotation, + messageId: 'objectParameter', + data: { parameter: parameterName(parameter, context.sourceCode) }, + }) + } + } + + return { + Program(node) { + aliases.clear() + for (const statement of node.body) { + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement + if ( + declaration?.type === 'TSTypeAliasDeclaration' && + (declaration.typeParameters === null || declaration.typeParameters === undefined) + ) { + aliases.set(declaration.id.name, declaration.typeAnnotation) + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts new file mode 100644 index 0000000..8bcba03 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -0,0 +1,27 @@ +import { defineRule } from '@oxlint/plugins' + +import { isGlobalReflectMethodCall } from '../shared/reflect-method.ts' + +export const noReflectApplyRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.', + }, + messages: { + reflectApply: + 'Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.', + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === 'Super' || node.callee.type === 'V8IntrinsicExpression') return + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, 'apply')) { + context.report({ node, messageId: 'reflectApply' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts new file mode 100644 index 0000000..e430f64 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -0,0 +1,48 @@ +import type { ESTree } from '@oxlint/plugins' + +type VisitorKeys = Readonly>> + +function isNode(value: unknown): value is ESTree.Node { + return typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' +} + +function collectInferTypeParameterNames(node: ESTree.Node, visitorKeys: VisitorKeys, names: Set): void { + if (node.type === 'TSInferType') names.add(node.typeParameter.name.name) + const record = node as unknown as Readonly> + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key] + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names) + continue + } + if (!Array.isArray(value)) continue + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names) + } + } +} + +export function lexicalTypeParameterNames(node: ESTree.Node, visitorKeys: VisitorKeys): ReadonlySet { + const names = new Set() + let descendant: ESTree.Node = node + let current: ESTree.Node | null = node + while (current !== null && current.type !== 'Program') { + if ('typeParameters' in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name) + } + } + if ( + current.type === 'TSMappedType' && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name) + } + if (current.type === 'TSConditionalType' && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names) + } + descendant = current + current = current.parent + } + return names +} diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts new file mode 100644 index 0000000..2dda097 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/reflect-method.ts @@ -0,0 +1,31 @@ +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins' + +function resolveVariable(sourceCode: SourceCode, identifier: ESTree.IdentifierReference): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier) + while (scope !== null) { + const variable = scope.set.get(identifier.name) + if (variable !== undefined) return variable + scope = scope.upper + } + return null +} + +function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { + if (expression.type !== 'Identifier' || expression.name !== 'Reflect') return false + if (sourceCode.isGlobalReference(expression)) return true + const variable = resolveVariable(sourceCode, expression) + return variable === null || variable.defs.length === 0 +} + +export function isGlobalReflectMethodCall( + sourceCode: SourceCode, + callee: ESTree.Expression, + methodName: string, +): boolean { + if (!('property' in callee) || !('object' in callee) || !('computed' in callee)) return false + if (!isGlobalReflect(sourceCode, callee.object)) return false + const property = callee.property + return callee.computed + ? property.type === 'Literal' && property.value === methodName + : property.type === 'Identifier' && property.name === methodName +} diff --git a/tools/oxlint/automation/index.ts b/tools/oxlint/automation/index.ts new file mode 100644 index 0000000..d81b991 --- /dev/null +++ b/tools/oxlint/automation/index.ts @@ -0,0 +1,16 @@ +import { eslintCompatPlugin } from '@oxlint/plugins' + +import noDisableValidation from './rules/no-disable-validation.ts' +import noShadowedStandardArrayStatic from './rules/no-shadowed-standard-array-static.ts' +import noSilentErrorSwallow from './rules/no-silent-error-swallow.ts' +import preferEffectMatch from './rules/prefer-effect-match.ts' + +export default eslintCompatPlugin({ + meta: { name: 'automation' }, + rules: { + 'no-disable-validation': noDisableValidation, + 'no-shadowed-standard-array-static': noShadowedStandardArrayStatic, + 'no-silent-error-swallow': noSilentErrorSwallow, + 'prefer-effect-match': preferEffectMatch, + }, +}) diff --git a/tools/oxlint/automation/rules/no-disable-validation.ts b/tools/oxlint/automation/rules/no-disable-validation.ts new file mode 100644 index 0000000..7769876 --- /dev/null +++ b/tools/oxlint/automation/rules/no-disable-validation.ts @@ -0,0 +1,30 @@ +import { defineRule } from '@oxlint/plugins' + +export default defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Keep Effect Schema validation enabled; fix the data or schema instead.', + }, + messages: { + disabledValidation: + 'Do not use disableValidation: true. Fix the data or schema and keep validation enabled.', + }, + }, + createOnce(context) { + return { + Property(node) { + const keyName = + node.key.type === 'Identifier' || node.key.type === 'PrivateIdentifier' + ? node.key.name + : node.key.type === 'Literal' + ? node.key.value + : undefined + + if (keyName === 'disableValidation' && node.value.type === 'Literal' && node.value.value === true) { + context.report({ node, messageId: 'disabledValidation' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/automation/rules/no-shadowed-standard-array-static.ts b/tools/oxlint/automation/rules/no-shadowed-standard-array-static.ts new file mode 100644 index 0000000..4fc1217 --- /dev/null +++ b/tools/oxlint/automation/rules/no-shadowed-standard-array-static.ts @@ -0,0 +1,47 @@ +import { defineRule } from '@oxlint/plugins' + +const standardArrayMethods = new Set(['from', 'isArray', 'of']) + +export default defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Require globalThis.Array when Array is imported from effect.', + }, + messages: { + shadowedArray: + 'Array is imported from effect in this file. Use globalThis.Array for standard Array static APIs.', + }, + }, + createOnce(context) { + let arrayImportedFromEffect = false + + return { + ImportDeclaration(node) { + if ( + node.source.value === 'effect' && + node.specifiers.some( + (specifier) => + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === 'Array' && + specifier.local.name === 'Array', + ) + ) { + arrayImportedFromEffect = true + } + }, + MemberExpression(node) { + if (!arrayImportedFromEffect) return + if ( + node.object.type === 'Identifier' && + node.object.name === 'Array' && + node.property.type === 'Identifier' && + standardArrayMethods.has(node.property.name) + ) { + context.report({ node, messageId: 'shadowedArray' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/automation/rules/no-silent-error-swallow.ts b/tools/oxlint/automation/rules/no-silent-error-swallow.ts new file mode 100644 index 0000000..c1e82d8 --- /dev/null +++ b/tools/oxlint/automation/rules/no-silent-error-swallow.ts @@ -0,0 +1,50 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +const catchMethods = new Set(['catch', 'catchTag', 'catchTags', 'catchReason', 'catchReasons']) +const voidMethods = new Set(['void', 'unit']) + +const isEffectMember = (node: ESTree.Node | null | undefined, names: ReadonlySet): boolean => + node?.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'Effect' && + node.property.type === 'Identifier' && + names.has(node.property.name) + +const returnsOnlyVoid = (node: ESTree.Node): boolean => { + if (node.type !== 'ArrowFunctionExpression' && node.type !== 'FunctionExpression') return false + if (isEffectMember(node.body, voidMethods)) return true + if (node.body.type !== 'BlockStatement' || node.body.body.length !== 1) return false + const statement = node.body.body[0] + return statement?.type === 'ReturnStatement' && isEffectMember(statement.argument, voidMethods) +} + +export default defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Do not silently swallow Effect errors; recover, transform, or propagate them.', + }, + messages: { + silentSwallow: + 'Do not silently swallow Effect errors with Effect.void or Effect.unit. Recover meaningfully, transform the error, or let it propagate.', + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (!isEffectMember(node.callee, catchMethods)) return + for (const argument of node.arguments) { + if (argument.type === 'SpreadElement') continue + if (returnsOnlyVoid(argument)) context.report({ node, messageId: 'silentSwallow' }) + if (argument.type !== 'ObjectExpression') continue + for (const property of argument.properties) { + if (property.type === 'Property' && returnsOnlyVoid(property.value)) { + context.report({ node, messageId: 'silentSwallow' }) + } + } + } + }, + } + }, +}) diff --git a/tools/oxlint/automation/rules/prefer-effect-match.ts b/tools/oxlint/automation/rules/prefer-effect-match.ts new file mode 100644 index 0000000..f0834f0 --- /dev/null +++ b/tools/oxlint/automation/rules/prefer-effect-match.ts @@ -0,0 +1,44 @@ +import { defineRule } from '@oxlint/plugins' + +const equalityOperators = new Set(['==', '===', '!=', '!==']) + +export default defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Use Match from effect for chained literal ternaries over the same value.', + }, + messages: { + preferMatch: 'Use Match from effect instead of a chained literal ternary.', + }, + }, + createOnce(context) { + const isLiteral = (node: Parameters[0]): boolean => + node.type === 'Literal' || (node.type === 'TemplateLiteral' && node.expressions.length === 0) + + const comparedValue = (node: Parameters[0]): string | undefined => { + if (node.type !== 'BinaryExpression' || !equalityOperators.has(node.operator)) return undefined + if (isLiteral(node.left)) return context.sourceCode.getText(node.right) + if (isLiteral(node.right)) return context.sourceCode.getText(node.left) + return undefined + } + + return { + ConditionalExpression(node) { + if (node.parent?.type === 'ConditionalExpression') return + const value = comparedValue(node.test) + if (value === undefined) return + + let alternate = node.alternate + let literalChecks = 1 + while (alternate.type === 'ConditionalExpression') { + if (comparedValue(alternate.test) !== value) return + literalChecks += 1 + alternate = alternate.alternate + } + + if (literalChecks > 1) context.report({ node, messageId: 'preferMatch' }) + }, + } + }, +})