Conformance kits
The port contracts as runnable checks, to run against your own adapter in any test runner.
Conformance kits
A port says what its methods take and return; a conformance kit says what an
implementation must do. @xandreed/core ships each contract as a list of
checks, so a host that writes its own session store, sessions backend, task
runner, tasks backend, tool registry, memory strategy, step loop, turn
composition, file system, shell or model credential and settings store can
prove it behaves like the first-party ones (@xandreed/plugin-workspace’s
local file system and shell pass the file-system and shell kits on Bun and
Node; @xandreed/plugin-sessions, @xandreed/plugin-tasks and
@xandreed/plugin-tool-discovery pass the sessions, task and registry kits).
@xandreed/plugin-render does the same for render surfaces, feeds and
transports (see render kits).
The kits
| Kit | Checks | Run against |
|---|---|---|
sessionLogConformance(log) | dense sequence numbers, one revision per commit, compare-and-swap, the notAfter clock check, JSON kept by value, an event as large as a message’s images at their limit (MEDIA_LIMITS.requestChars base64 characters), reads by position and kind, listing order, children and their removal | a SessionLog |
sessionLogRecoveryConformance(subject) | reopening, several open handles, interrupted commits, a handle whose scope never closed, the exact notAfter boundary | a way to open a SessionLog |
sessionsConformance(subject) | create, get, list and fork; one open turn per session on every instance and one winner of a race; key retries (TurnDuplicate, whatever the meta) and KeyConflict; meta stored and admitted; every NotResumable reason; AttachmentRejected and NothingPending opening nothing; the writer’s order and endings; transact and the reserved kinds; cancel and removal; the inbox; a write renewing a lease, a lease that ran out and a dead process holder reaped; TurnActivity, when the instance has one, agreeing with what is open | a way to open Sessions instances over one store, and to move its clock |
taskRunnerConformance(make, { interruptsOnClose? }) | run hands the work over and returns, the work runs once, outlives its caller, is told a deadline not in the past and runs without the caller’s services; TaskActivity counts it from the hand-over; closing the runner’s scope interrupts it | a way to make a TaskRunner (and its TaskActivity) |
tasksConformance(make) | a task runs as one turn of a child session (spawn empty, fork with the parent’s closed turns), its result reaches the parent’s inbox once and the parent reacts once, after the turn in flight; task.started once per task, a second start runs nothing; a failed task says why; cancel; no task starts tasks; reconcile starting a task that never ran and delivering a result whose delivery was lost | a way to build Sessions and Tasks over the kit’s runner and executor |
toolRegistryConformance(make) | the catalog, activation (always-on, on demand, unknown, ungranted, only growing), one invocation id per call minted before the checks, CurrentToolInvocation and the opener’s services in the handler, events published on the run’s bus only (never written to the journal by the registry), PermissionGrants, a tool.refused for each reason (inactive, forbidden, denied, call-limit, tool-call-limit) | a way to build a ToolRegistry over the kit’s capabilities |
memoryConformance(memory, services) | own entry ids, a log that reads back exactly after a reopen, views and artifacts kept, a pure build, append-only maintenance, a message’s images through a reopen (the current turn’s built as image parts, an earlier turn’s as image parts or label lines), a tool result’s images stored once and the current turn’s built as the result’s images | a memory strategy |
stepLoopConformance(loop) | per-step event order, validation before dispatch, planned batches, forced tool choices, no call after completion, the run’s usage (the sum of its assistant.message usage), the step cap, the request’s captureTraceContent reaching its model calls as CurrentTraceContent, a request refused over an image planned again and sent once more when onMediaRejected answers true (failing when it answers false) | a StepLoop |
turnConformance(runner, services) | the writer first, the message before the matcher’s history, the reply and the end exactly once, tasks joined before the reply, a model-driven turn’s usage equal to its stored step.usage records (turnUsageOf), the user’s attachments stored with the turn and reaching the first request as image parts, the host layer seeing only earlier turns | a turn composition |
fileSystemConformance(subject) | round trips, createParents, byte ranges, append, rename, stat and links, sorted listings, glob, mkdir, realPath, the remove rules, the reason of every failure | a way to open a FileSystem over an empty directory |
bashConformance(subject) | exit codes as results, separate streams, output before exit, stdin, the inherited-environment allowlist, timeouts and interrupts that kill the process group, cwd, signals, UTF-8 | a way to open a Bash adapter |
authStoreConformance(store) | an absent provider, API key, local and OAuth credentials read back and resolve as they should (the key Redacted), an expired token the store cannot refresh fails with an AuthError naming the provider and never returns the stale token, set replaces, remove deletes everywhere and is a no-op for an absent provider, providers are isolated | an AuthStore |
settingsStoreConformance(store, { writable }) | load is stable; writable: each role and key reads back typed, None clears back to the value before the set, a value that does not parse (through set or setRole) fails with a SettingsError and changes nothing; read-only: every write is refused and changes nothing | a SettingsStore |
modelCatalogConformance(catalog, { includes? }) | entries decode, each selection is "<provider>:<modelId>" of its own provider, no selection twice, two lists agree, the expected selections are listed | a ModelCatalog |
Every core kit returns ReadonlyArray<ConformanceCheck>. A check has a name and
a run effect that succeeds, or fails with a ConformanceFailure whose
check names the broken part of the contract and whose message says what
happened. The session log, Sessions and Tasks kits use fresh session ids and
owners in every check, so they can run against a shared database; the
TaskRunner and ToolRegistry kits build what they check afresh for each
check. The AuthStore kit uses fresh
provider ids (conformance-<uuid>) and removes them when a check ends. The
SettingsStore kit writes the store’s own keys, since settings have no ids: it
puts each key it touches back as it found it, but run it against settings
nothing else is using. A key holding a value the store must refuse (a
selection that is not "<provider>:<modelId>", a count below 1, left by an
older unchecked write) could not be put back, so the check that would touch it
fails before writing anything. @xandreed/plugin-models runs these three kits against
its file and in-memory stores, on Bun and on Node.
The image checks of
memoryConformance and turnConformance need a memory whose budget admits
one small image, as the memory plugins’ defaults do: one set to send no
images (maxImages: 0 or maxMediaChars: 0) fails them.
Running a kit
Map the checks to tests in whatever runner you use. With Bun’s test runner
(Vitest and node:test look the same):
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { sessionLogConformance } from "@xandreed/core"
import { log } from "./my-session-log.js" // your SessionLog implementation
describe("my session log conforms to the SessionLog contract", () => {
sessionLogConformance(log).map((check) => test(check.name, async () => {
const result = await Effect.runPromise(Effect.result(check.run))
expect(result._tag === "Failure" ? result.failure.message : "ok").toBe("ok")
}))
})
To fail a deploy check or a script instead, run them all and collect the failures:
const failures = await Effect.runPromise(Effect.forEach(sessionLogConformance(log), (check) =>
Effect.result(check.run).pipe(Effect.map((result) => result._tag === "Failure" ? [`${check.name}: ${result.failure.message}`] : []))))
if (failures.flat().length > 0) throw new Error(failures.flat().join("\n"))
The SessionLog recovery kit
sessionLogConformance checks one handle on a store. A real store is opened
by several processes, restarted, and sometimes left behind by a process that
was killed. sessionLogRecoveryConformance checks, inside one process, what
must hold across handles, reopening and interruption, and takes a subject
rather than a log:
interface SessionLogSubject {
/** A new handle on the same stored data each time it runs, released with its scope. */
readonly open: Effect.Effect<SessionLog["Service"], SessionLogError, Scope.Scope>
/** The backend stamps time with the caller's Effect Clock: also check the notAfter boundary. */
readonly callerClock?: boolean
}
open should do what your host does on start: open a connection to the
database or file and close it when the scope closes. The checks:
- reopen: what a handle committed (events, state, times, the listing) is all there after it closes and the store is opened again.
- reopen-twice: opening an existing store again is idempotent; nothing is lost and commits continue from the stored revision.
- cross-handle: two open handles see each other’s commits at once, and a stale revision through either is refused.
- cross-handle-race: compare-and-swap commits racing through two handles have exactly one winner per revision; no increment is lost or stored twice.
- interrupted-commit: a commit interrupted at any point leaves all of its events or none, and the session takes the next commit. The commit must have settled by the time the interrupt returns: a backend whose write can land after its caller stops waiting (a request already sent to a server) makes that write uninterruptible, or the check fails now and then.
- unclosed-handle: a handle whose scope never closed (leaked, or held by a process that hangs) blocks nothing; a new handle reads all it committed and commits after it.
- expiry-boundary, only with
callerClock: true: with the clock stopped, a commit at exactly itsnotAfteris stored at that time, and one a millisecond later is refused withLeaseExpired.
Each check must finish within 30 seconds, so a handle that waits on another fails the check rather than hanging it.
The SQLite log is the reference subject. makeSessionLogSqlite(path) is the
scoped log SessionLogSqliteLive builds:
import { sessionLogRecoveryConformance } from "@xandreed/core"
import { makeSessionLogSqlite } from "@xandreed/plugin-session-sqlite"
const checks = sessionLogRecoveryConformance({ open: makeSessionLogSqlite("/tmp/recovery.db"), callerClock: true })
An in-memory log passes only when every open returns the same store, the
way two connections reach the same database. Build it once and return it;
a fresh SessionLogMemoryLive per open starts empty and fails every check
that opens the store again:
const log = await Effect.runPromise(Effect.service(SessionLog).pipe(Effect.provide(SessionLogMemoryLive)))
const checks = sessionLogRecoveryConformance({ open: Effect.succeed(log), callerClock: true })
The kit’s own tests run it against careless backends, which is the point: one
that buffers writes until a handle closes passes reopening and fails
cross-handle, cross-handle-race and unclosed-handle; one that writes a
commit’s events one at a time, with no transaction around them, passes
everything but interrupted-commit.
A process that crashes is not a handle left open: the operating system
releases its files and locks. The SQLite package tests crashes and other
processes with real ones: several processes opening one new file at once, a
commit that waited behind another process’s write lock past its notAfter
is refused, a process killed in the middle of committing leaves whole
commits, two processes making 200 compare-and-swap increments each end at
400 after conflicting, and a turn held by a killed process is reaped under
process and lease ownership.
See SDK and sessions for how sessions use the log.
The Sessions kit
sessionsConformance checks a Sessions backend the way hosts and turns
use it: over one store, opened by several instances at once (one per
server), with leases that run out and processes that stop. It takes a
subject that opens instances and moves the clock:
interface SessionsSubject {
/** Another instance over the same store each time, its turns leased for ttlMs, renewed only by their writes. */
readonly leased: (options: { ttlMs: number; admission: TurnAdmission["Service"] }) => Effect.Effect<SessionsInstance, unknown, Scope.Scope>
/** The same, its turns held by the process `self`; of its host's pids only `running` run. Optional. */
readonly processOwned?: (options: { self: SessionsProcess; running: ReadonlyArray<number>; admission: TurnAdmission["Service"] }) => Effect.Effect<SessionsInstance, unknown, Scope.Scope>
/** Move the clock the store and the instances read: TestClock.adjust, or a sleep. */
readonly advance: (millis: number) => Effect.Effect<void>
}
interface SessionsInstance {
readonly sessions: Sessions["Service"]
/** Its TurnActivity, when it has one; without one, the activity check passes unchecked. */
readonly activity: Option.Option<TurnActivity["Service"]>
}
interface SessionsProcess { readonly host: string; readonly pid: number; readonly startedAt: number }
The kit passes its own TurnAdmission to every instance (it admits all,
records what it is shown, holds a begin, or refuses one), so an instance
must use the admission it is given. @xandreed/plugin-sessions runs the kit
over SessionsLive and the in-memory log, each check under a TestClock of
its own; @xandreed/plugin-session-sqlite runs it over the SQLite log the
same way, and verify:packages on Node:
import { Context, Effect, Layer, Option } from "effect"
import { TestClock } from "effect/testing"
import { SessionLog, Sessions, sessionsConformance, TurnActivity, TurnAdmission } from "@xandreed/core"
import type { SessionsSubject } from "@xandreed/core"
import { SessionsLive, sessionsDefaults } from "@xandreed/plugin-sessions"
import type { ProcessLiveness, SessionsConfig } from "@xandreed/plugin-sessions"
declare const log: SessionLog["Service"] // one store for every instance and check
const instance = (config: SessionsConfig, admission: TurnAdmission["Service"], liveness?: ProcessLiveness) =>
Layer.build(SessionsLive(config, liveness === undefined ? undefined : { liveness }).pipe(
Layer.provide(Layer.merge(Layer.succeed(SessionLog, log), Layer.succeed(TurnAdmission, admission))))).pipe(
Effect.map((context) => ({ sessions: Context.get(context, Sessions), activity: Context.getOption(context, TurnActivity) })))
const subject: SessionsSubject = {
leased: ({ ttlMs, admission }) => instance({ ...sessionsDefaults, ownership: { mode: "lease", ttlMs, renew: "on-commit" } }, admission),
processOwned: ({ self, running, admission }) => instance({ ...sessionsDefaults, ownership: { mode: "process" } }, admission,
{ current: Effect.succeed(Option.some(self)), alive: (pid) => Effect.succeed(running.includes(pid)) }),
advance: (millis) => TestClock.adjust(millis),
}
// Run each check under the clock `advance` moves.
const checks = sessionsConformance(subject).map((check) => ({ ...check, run: check.run.pipe(Effect.provide(TestClock.layer())) }))
The checks:
- create: a new session is empty and read back by its owner; another
owner’s address and an unknown id are
SessionMissing; an id is created once (SessionExists). - list: an owner’s sessions newest first, in pages that continue from
next; a session’s children only underparent. - fork: a fork’s lineage and history are its parent’s up to the last
closed turn (a turn still open is left out) and its turns count on from
there; a spawn starts empty; forking a missing session is
SessionMissing. - busy: the admitted turn is what was asked for; every instance shows it
open and answers a second begin with
SessionBusy; the next turn opens after the end. - race: of begins racing on one session from three instances, exactly one opens a turn.
- duplicate: a key’s retry is
TurnDuplicate(open, then not) whatever itsmeta; another message or command under the key isKeyConflict;lookupfinds the key’s turn. - meta:
metais stored inturn.started, admitted and shown to the host’s admission;{}when none is given; the inbox’s fromdrain. - resume: a cancelled turn is resumed (
resumesadmitted and stored), a retried resume isTurnDuplicate, and every other case isNotResumablewith its reason (finished,missing,superseded,inbox), orSessionBusywhile a turn is open. - attachments: an image its bytes do not support is
AttachmentRejectedand opens nothing; a good one is admitted and stored. - nothing-pending: an inbox turn with nothing waiting is
NothingPendingand a drain runs none. - writer: writes are stored in order, stamped with the turn; the end
stores
completed, a failure’s code,turn.unendedfor a scope closed without an end andturn.interruptedfor an interrupted one; a write after the end isturn.closed. - transact: records outside a turn;
turn.*kinds areReservedKind. - cancel: ends exactly the open turn as cancelled; the holder’s writer is closed at once on its own instance, and at its next write when the cancel came from another.
- remove: removing a session closes its open turn (
removed) and removes its children. - inbox: an item is stored once per id (even after it was taken), taken by one inbox turn whose message frames it, waits for an open turn (whose end reports it), comes back when its turn is interrupted from outside, and is done when its turn is cancelled (a later drain takes nothing).
- lease: a turn within its lease is open and busy; past it, it is shown
closed, a drain with nothing waiting stores nothing (only a turn that
opens reaps), the next begin reaps it (
interrupted,turn.lease) and the old holder’s writes are refused. - lease-renewal: a write renews the lease from that write: a turn written to within its lease is still open and busy past its first lease, and reaped once a lease has passed since the write.
- process, only with
processOwned: a turn whose process stopped, or whose pid now belongs to a process that started later, is reaped by the next begin; one whose process runs is held. - activity, for an instance with a
TurnActivity(one without passes it unchecked): it counts a begin from the moment it is asked for (while the host’s admission holds it), then its turn per session and turn, oldest first, until its writer’s scope closes, an ended turn included; another instance’s turns and a refused begin do not count;idlecompletes only when nothing is open or beginning.
The kit’s own tests run it against careless wrappers of SessionsLive: a
begin that cancels the open turn instead of refusing it fails busy,
duplicate, lease, lease-renewal, process, race and resume; a
TurnActivity that always reads idle fails activity; a begin that drops
meta fails meta; leases that writes do not renew (renew: "none") fail
lease-renewal.
The task kits
taskRunnerConformance(make, options?) checks a TaskRunner, the place
background work runs. make builds one for each check, with its
TaskActivity when it has one:
interface TaskRunnerFixture {
readonly runner: TaskRunner["Service"]
readonly activity: Option.Option<TaskActivity["Service"]>
}
- hand-over:
runreturns once the work is handed over, before it ends, and the work runs once. - outlives-caller: the work goes on when the caller that handed it over is interrupted and its scope closed.
- deadline: the work is told a deadline that is none or not in the past.
- isolation: the work runs without the caller’s services (the turn that starts a task ends before it).
- activity: with a
TaskActivity, the work counts from the momentrunreturns until it ends, andidlewaits for all of it. - scope-close: closing the scope the runner was made in interrupts the
work still running, so a task’s turn ends interrupted rather than lost. A
runner that hands work to the platform (a serverless
waitUntil) cannot, and passes{ interruptsOnClose: false }to leave it out.
The checks wait up to two seconds on the live clock for work they expect,
so run the kit without a TestClock. InProcessTaskRunnerLive passes them
all, with and without a deadline (maxMs); core’s tests show a runner that
runs the work in the caller failing hand-over, outlives-caller,
isolation and scope-close, and one that forks it loose failing
isolation and scope-close.
import { Context, Effect, Layer } from "effect"
import { TaskActivity, TaskRunner, taskRunnerConformance } from "@xandreed/core"
import { InProcessTaskRunnerLive } from "@xandreed/plugin-tasks"
const checks = taskRunnerConformance(Layer.build(InProcessTaskRunnerLive()).pipe(Effect.map((context) => ({
runner: Context.get(context, TaskRunner),
activity: Context.getOption(context, TaskActivity),
}))))
tasksConformance(make) checks a Tasks backend. make builds it, and the
Sessions it runs over, with the kit’s own runner (each piece of work on a
fiber the kit waits for, with the services there were when the check built
the runner and none of its caller’s, or dropped, as by a runner that died)
and executor (a child turn answers found: <instructions> unless the check
says otherwise; a reaction records the turn it ran on). Like the runner
kit, it waits up to two seconds on the live clock for background work, so
run it without a TestClock:
import { Context, Effect, Layer } from "effect"
import { SessionLog, Sessions, TaskExecutor, TaskRunner, Tasks, tasksConformance, TurnAdmissionOpen } from "@xandreed/core"
import { SessionsLive, sessionsDefaults } from "@xandreed/plugin-sessions"
import { TasksLive, tasksDefaults } from "@xandreed/plugin-tasks"
declare const log: SessionLog["Service"]
const checks = tasksConformance(({ runner, executor }) => Layer.build(TasksLive(tasksDefaults).pipe(Layer.provideMerge(Layer.mergeAll(
SessionsLive(sessionsDefaults).pipe(Layer.provide(Layer.merge(Layer.succeed(SessionLog, log), TurnAdmissionOpen))),
Layer.succeed(TaskRunner, runner),
Layer.succeed(TaskExecutor, executor),
)))).pipe(Effect.map((context) => ({ sessions: Context.get(context, Sessions), tasks: Context.get(context, Tasks) }))))
- run: a task runs as one turn of a child session of the parent (a
spawn’s history is empty); its result reaches the parent’s inbox once, and
the parent reacts once, to an inbox turn whose item carries the reply; the
parent records one
task.started, and lists the task. - start-once: three starts of one task id, while it runs and after, run it once.
- after-turn: a result that arrives while the parent’s turn is open waits for that turn, whose end reports it; a fork’s history is the parent’s up to its last closed turn.
- failed: a failed child turn is a
failedtask whose failure carries the error’s code, and the parent still hears of it. - cancel: cancelling a running task ends its child’s turn cancelled and
delivers nothing; a cancelled task is not cancelled again, and an unknown
one is
TaskMissing. - depth: a task’s session cannot start tasks (
TaskRefused,depth). - reconcile: a task whose runner dropped it stays
pendinguntilreconcilestarts it, and then delivers; a secondreconcilefinds nothing. - reconcile-delivery: a task that finished while the parent’s inbox was
full (
InboxFull) readscompletedand not delivered; once the inbox is drained,reconciledelivers it (once), the parent reacts to it after the caller drains, and a secondreconcilefinds nothing. The kit fills the inbox itself, so this check relies on the backend keeping a result the full inbox refused for a laterreconcile, and runs only over aSessionswhose inbox refuses an item within 256 deliveries; over one that takes them all it passes unchecked.
TasksLive passes them; a cancel that stops nothing fails cancel, a
start that ignores the task id fails start-once, a list that returns
nothing fails run and start-once, a reconcile that does nothing fails
reconcile and reconcile-delivery, and one that leaves a result whose
delivery was lost undelivered fails reconcile-delivery.
The ToolRegistry kit
toolRegistryConformance(make) checks a ToolRegistry. make builds one
over the kit’s capabilities (four tools and three skills: an always-on one,
one needing the kit.vault permission, one loaded on demand) with at most
maxCallsPerRun calls per turn and no permission granted by default. Each
check runs a turn over core’s TurnLive, with a writer that keeps the
turn’s events in memory, and calls the tools through the open toolkit’s
handlers:
import { Context, Effect, Layer } from "effect"
import { Capabilities, ToolRegistry, toolRegistryConformance } from "@xandreed/core"
import { toolDiscoveryDefaults, ToolDiscoveryLive } from "@xandreed/plugin-tool-discovery"
const checks = toolRegistryConformance((capabilities, limits) =>
Layer.build(ToolDiscoveryLive({ ...toolDiscoveryDefaults, ...limits }).pipe(Layer.provide(Layer.succeed(Capabilities, capabilities)))).pipe(
Effect.map((context) => Context.get(context, ToolRegistry))))
- catalog: the catalog and the run’s tools hold every skill and tool, with the permissions a tool needs.
- activation: always-on skills are active once the matcher’s choice is
applied, others only once activated (publishing
skills.activatedonce); an unknown skill fails withskills.unknown; a skill whose permission is not granted stays inactive; the active set only grows. - invocation: a handled call publishes
tool.started, its handler’stool.progressandtool.completedunder one invocation id, taken after the refused call before it (<runId>:call:<n>); the handler sees that call asCurrentToolInvocationand the services where the tools were opened, not the caller’s; the events are published on the run’s bus only, sotool.progressnever reaches the journal. - grants: the turn’s
PermissionGrantslet a skill that needs them be activated and its tools run. - refused-inactive, refused-forbidden, refused-denied,
refused-call-limit, refused-tool-call-limit: a call refused before
its handler runs (a tool not loaded, a permission the turn lacks, an
ActionPolicydenial, the turn’s call limit, the tool’s own) takes its invocation id first, publishes onetool.refusedwith its input and notool.startedon the run’s bus only, never runs the handler, and returns the refusal’s message as its failed result.
A registry publishes on the run’s bus (RunContext.events) and never
writes the journal itself: the turn journals what it publishes, and core’s
TurnLive is what keeps tool.progress out of the journal and stores a
tool.refused without its input. The kit reads the journal to catch a
registry that writes it directly, so those checks guard the registry, not
the turn’s own journaling.
The plugin’s registry passes them; its tests show a registry that ignores
the turn’s call limit failing refused-call-limit, one that activates every
skill when the tools open failing activation, invocation and
refused-inactive, and one whose catalog drops the permissions failing
catalog.
The FileSystem kit
fileSystemConformance opens a fixture for each check: the port, an empty
absolute directory root to work in, and a way to make symbolic links (the
port has no call for that):
interface FileSystemSubject {
/** A fresh empty root each time, released (and removed) with its scope. */
readonly open: Effect.Effect<FileSystemFixture, unknown, Scope.Scope>
}
interface FileSystemFixture {
readonly fs: FileSystem["Service"]
readonly root: string
readonly symlink: (target: string, path: string) => Effect.Effect<void, unknown>
}
@xandreed/core runs the kit over its in-memory backend, giving it its own
tree for each check:
import { Effect } from "effect"
import { fileSystemConformance, makeMemoryFileSystem } from "@xandreed/core"
const checks = fileSystemConformance({
open: makeMemoryFileSystem().pipe(
Effect.tap((fs) => fs.mkdir("/work")),
Effect.map((fs) => ({ fs, root: "/work", symlink: fs.symlink })),
),
})
For a backend on disk, open makes a temporary directory and removes it
when the scope closes, with fs.symlink as symlink. That is how
@xandreed/plugin-workspace runs it over FileSystemLive, the local disk
through node:fs/promises, on Bun and on Node:
import { mkdtemp, rm, symlink } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Effect } from "effect"
import { fileSystemConformance } from "@xandreed/core"
import { localFileSystem } from "@xandreed/plugin-workspace"
const temporary = Effect.acquireRelease(
Effect.promise(() => mkdtemp(join(tmpdir(), "fs-kit-"))),
(dir) => Effect.promise(() => rm(dir, { recursive: true, force: true })),
)
const checks = fileSystemConformance({
open: temporary.pipe(Effect.map((root) => ({
fs: localFileSystem,
root,
symlink: (target: string, path: string) => Effect.tryPromise(() => symlink(target, path)),
}))),
})
The checks:
- read-write: text (UTF-8 included) and bytes read back exactly, a
write replaces the whole file, and reading a missing file, reading or
writing a directory and writing below a file fail with
NotFound,IsDirectoryandNotDirectory. - create-parents: without
createParentsa missing directory fails withNotFound; with it,writeandappendcreate every directory on the way. - read-bytes:
readBytesreturns the range asked for, what there is of a range past the end, and refuses a negative offset withInvalid. - append: creates a missing file and adds text and bytes in order.
- rename: moves a file, replaces an existing file at the target, moves a
directory with what it holds, moves a link itself (not its target), and
names the side a failure came from: the source when it is missing, the
target for a missing parent there or what is in the way (
IsDirectory,NotDirectory,NotEmpty). - stat: kind, size in bytes and time; a link is followed unless
followSymlinks: false, which reportssymlink. - exists: true for files and directories; false, never an error, for a missing path and a link whose target is missing.
- list: entries sorted by name (code-unit order, so
C.txtbeforea.txt) with their kinds, links not followed. - glob, glob-recursive, glob-dot, glob-only-files,
glob-limit, glob-errors:
*,?, classes and braces within a segment;**across directories but never into a linked one;excludeby name or by path; dot entries only withdotor a literal dot in the pattern;onlyFileskeeping regular files only;limitkeeping the first paths in walk order andtruncatedonly when more matched; a missingcwd, an absolute pattern and a..segment refused. - mkdir: one directory, or with
recursiveevery missing one, idempotently; otherwise an existing path fails withExistsand a missing parent withNotFound. - real-path: absolute and relative links on the way are resolved.
- remove: a missing path is a no-op, an empty directory goes without
recursive, one with entries fails withNotEmptyuntilrecursive, and removing a link leaves its target. - remove-paths:
removeacts on the last name as written. A link named with a trailing slash (link/) is removed and its target kept, with or withoutrecursive; a link in the parent (via/sub) is followed; a path whose last segment is.or..fails withInvalid. A disk backend that hands the path tormdirorrmas given fails here: the kernel follows the trailing slash and resolves the.., so<link>/..could reach the root. Every path the check removes is under the fixture’s root, even on such a backend.
Every backend should implement glob as walkGlob(list, pattern, options)
over its own list. That is what makes the glob checks pass on Bun and on
Node: fs.glob cannot list dot entries on Node and follows links on Bun. The
kit’s own tests show what it catches. A backend that lists in reverse order,
refuses to remove a missing path and always follows links in stat fails
list, remove, remove-paths, stat and rename (rename and
remove-paths read a link without following it); a rename that moves a link’s target instead of
the link, or that names the source for every failure, fails rename; a
remove that resolves the whole path first fails remove and
remove-paths; a glob that follows links fails glob, glob-recursive,
glob-dot and glob-limit; one that lists dot entries whatever dot says
fails every glob check with a dot entry in reach.
The Bash kit
bashConformance opens a Bash adapter and an empty directory for each
check. The adapter’s parent environment must be the process’s own plus
planted, so the kit can check what a command inherits:
interface BashSubject {
readonly open: (planted: Readonly<Record<string, string>>) => Effect.Effect<BashFixture, unknown, Scope.Scope>
}
interface BashFixture {
readonly bash: Bash["Service"]
readonly root: string
}
The checks:
- exit-code: a non-zero exit is a result with its code, not an error.
- streams: stdout and stderr stay apart, chunks carry their stream, and
exactly one
BashExitcomes last. - streaming: output arrives while the command still runs. The command waits for the kit to see its first output before it exits, so an adapter that holds output until exit fails here.
- stdin:
stdin, text or bytes, is delivered and then closed; without it the command reads end-of-file at once. - stdin-ignored: a command that exits without reading a megabyte of stdin still yields its exit (the adapter must not die of the broken pipe).
- env:
envis set over the parent environment. - inherit-env: by default only the allowlist passes (
PATHandLC_*do, a planted secret does not);inheritEnv: true,falseand an allowlist with a*prefix do what they say. - timeout: a command past
timeoutMsends withtermination: "timeout", and a child it left in the background is gone too. - cwd: the command runs in
cwd; a missing one, or a file, fails withCwdMissing. - signal: a command killed by a signal reports
termination: "signal"and the signal’s name. - utf8: a character written in two pieces arrives whole.
- interrupt: interrupting the stream returns, and kills the command and the child it started.
Core has no Bash adapter: the kit’s own tests run it against fakes that
run nothing, which fail every check. BashLive from
@xandreed/plugin-workspace (commands through node:child_process) passes
it on Bun and on Node. Its parentEnv option is the environment commands
inherit from, which is how a test plants variables:
import { Effect } from "effect"
import type { Scope } from "effect"
import { bashConformance } from "@xandreed/core"
import { makeBash } from "@xandreed/plugin-workspace"
declare const temporary: Effect.Effect<string, never, Scope.Scope> // as in the FileSystem kit above
const checks = bashConformance({
open: (planted) => Effect.all({ root: temporary, bash: makeBash({ parentEnv: { ...process.env, ...planted } }) }),
})
Render kits
@xandreed/plugin-render exports three kits for its ports. Their checks have
the same shape, name and run, and fail with that package’s own
ConformanceFailure (check, message) or a RenderError:
| Kit | Checks | Run against |
|---|---|---|
renderSurfaceConformance(subject) | generations opened only by changed content, completion once per version, freezing and placeholder fills, preparations one at a time, reopening, rejected components never committed, an idempotent store | a SurfaceSubject, whose make(admission) builds a fresh Render and RenderStore for each check |
renderFeedConformance(makeFeed) | resuming after the cursor with one ready frame, heartbeats while idle, records in order, polling at once on a change signal | a function that builds a RenderFeed over a JournalTail (makeMemoryJournal gives one to append to) |
renderTransportConformance({ sse, socket }) | SSE and WebSocket carry identical frames | the two RenderTransports |