/** * Testing primitives for rule and plugin authors. * * Subpath export: `@cad0p/pi-steering/testing`. Also re-exported * at the package root for discoverability. * * Phase 5a ships four low-level primitives that wrap the engine's * internals without forcing authors to stand up a pi runtime stub: * * - {@link loadHarness} — build an evaluator + dispatcher * pair from a static * {@link SteeringConfig}. No * filesystem discovery — tests * pass explicit config. * - {@link mockContext} — build a {@link PredicateContext} * for unit-testing predicate * handlers in isolation. * - {@link mockObserverContext} — same for {@link ObserverContext}. * - {@link getAppendedEntries} — read back `appendEntry` writes * captured by either mock context. * * The convenience wrappers (`testPredicate`, `expectBlocks`, * `runMatrix`, …) that build on these live in a Phase 5b follow-up. * * Design notes: * * - `exec` is deliberately stub-required. The defaults reject with a * clear error so a test forgetting to stub fails loudly instead of * silently evaluating predicates against an always-empty exec * result. * - `appendEntry` captures are tracked in a module-level WeakMap * keyed by the context object, so {@link getAppendedEntries} * accesses cleanly without leaking state across tests and without * requiring users to pass capture buffers around. * - `findEntries` draws from an entries array passed in at context * build time; it does NOT pick up entries written via the * context's own `appendEntry`. That mirrors the production * evaluator's per-call snapshot semantics — appends are visible * on the NEXT evaluation, not the current one. */ import type { ExtensionContext, ExecResult as PiExecResult, ToolCallEvent, ToolCallEventResult } from "@earendil-works/pi-coding-agent"; import { type EvaluatorHost, type EvaluatorRuntime } from "../evaluator.ts"; import type { SyntheticEntry } from "../evaluator-internals/speculative-synthesis.ts"; import { type ObserverDispatcher } from "../observer-dispatcher.ts"; import type { ResolvedPluginState } from "../plugin-merger.ts"; import type { ExecOpts, ExecResult, Observer, ObserverContext, PredicateContext, PredicateHandler, PredicateToolInput, PredicateVerdict, ToolResultEvent as SchemaToolResultEvent, SteeringConfig, SteeringDiagnostic, WhenWalkerState } from "../schema.ts"; /** * Build-once, invoke-many handle a test uses to drive the engine * against a scenario. `evaluate` and `dispatch` have identical * signatures to {@link EvaluatorRuntime.evaluate} and * {@link ObserverDispatcher.dispatch} so production call sites can be * ported verbatim. */ export interface Harness { /** See {@link EvaluatorRuntime.evaluate}. */ evaluate: EvaluatorRuntime["evaluate"]; /** See {@link ObserverDispatcher.dispatch}. */ dispatch: ObserverDispatcher["dispatch"]; /** * The effective config the harness was built from (after default * injection and `disable` filtering). * * No-op short-circuit caveat: when `harness.diagnostics` has an * error-class entry, treat `harness.config` as the post-merge, * post-disabledRules-filter snapshot pi-steering would have * handed to `buildEvaluator` if the diagnostics had been clean — * provided as a debugging artifact, NOT an executable config * production would accept (production would have thrown). * Inspect `harness.diagnostics` first to learn which surface * flagged a problem, then fix the source config and re-load * before reading `config` for any "production would have run on * this shape" interpretation. */ readonly config: SteeringConfig; /** * The plugin merger's resolved state, for introspection / * assertions. * * No-op short-circuit caveat: when an error-class diagnostic fires * and the harness returns the no-op evaluator/dispatcher pair, * `resolved.diagnostics` carries the FULL diagnostic list (merge- * side + user-config-name + resolve-side) to mirror * `harness.diagnostics`. In the regular path, `resolved.diagnostics` * carries only the resolve-side stream — see * {@link SteeringDiagnosticKind} for the per-kind * loader-vs-merger split. Consumers should prefer * `harness.diagnostics` for the canonical full list. */ readonly resolved: ResolvedPluginState; /** * Every {@link SteeringDiagnostic} produced while building the * harness — merge-side (within-layer collisions, cross-config * collisions when {@link LoadHarnessOptions.includeDefaults} is * `true`) and plugin-merger-side (predicate / observer / rule / * extension-orphan / reserved-name / invalid-name diagnostics, * plus user-config rule and observer name validation). * * Unlike production, `loadHarness` does NOT throw on error-class * diagnostics. Plugin-author tests assert directly on this array * (e.g. `harness.diagnostics.some(d => d.kind === "reserved-tracker-name")`) * so the failure surface is observable in test output rather than * a thrown error that hides which other diagnostics fired. Both * plugin-shipped and user-config malformed names route through the * same `kind: "invalid-name"` diagnostic stream — plugin-author * tests can use a uniform matrix of malformed-name cases without * worrying about which surface raised the issue. * * Production-strictness divergence: `loadHarness` does NOT honor * the merged config's `failOnWarnings` flag. Production's * `buildSessionRuntime` throws when `failOnWarnings !== false` * (default `true`) AND any warning-class diagnostic is present; * the harness ignores `failOnWarnings` and returns a real * evaluator/dispatcher running on the post-collision merged * state. Tests intending to use the harness as a "production * prediction" should check this array against the strict-mode * rule themselves, e.g. * `harness.diagnostics.some(d => d.type === "error" || (config.failOnWarnings !== false && d.type === "warning"))` * before treating the harness's verdict as production-faithful. */ readonly diagnostics: readonly SteeringDiagnostic[]; } /** * Options for {@link loadHarness}. */ export interface LoadHarnessOptions { /** The config under test. */ readonly config: SteeringConfig; /** * Prepend {@link DEFAULT_PLUGINS} to `config.plugins` and * {@link DEFAULT_RULES} to `config.rules` at the innermost * position. Mirrors the production flag via * `!config.disableDefaults`, but kept explicit here so tests can * exercise default rules without editing the config under test. * * Default: `false`. */ readonly includeDefaults?: boolean; /** * Host to drive `exec` / `appendEntry` off. Defaults to an * in-memory stub whose `exec` rejects with a clear error (tests * needing exec must stub it explicitly) and whose `appendEntry` * is a silent sink. */ readonly host?: EvaluatorHost; } /** * Build an evaluator + observer dispatcher pair from a static * {@link SteeringConfig}. Tests drive rules through the same pipeline * production uses, without filesystem discovery. */ export declare function loadHarness(options: LoadHarnessOptions): Harness; /** * Shape of an entry fed into {@link mockContext} / {@link * mockObserverContext} to back `findEntries`. Mirrors the subset of * pi's `CustomEntry` the evaluator + dispatcher actually read. */ export interface MockEntry { readonly type: "custom"; readonly customType: string; readonly data: unknown; readonly timestamp: string; } /** * Options for {@link priorEntry}. */ export interface PriorEntryOptions { /** * Agent-loop index to stamp on the payload. The engine's live * `appendEntry` wrapper stamps this automatically on every write; * fixture entries must reproduce the same shape so `when.happened: * { in: "agent_loop" }` scope filtering works identically whether * the entry was written at runtime or seeded into the mock. * * Defaults to `0`. Set to the value of {@link MockContextOptions.agentLoopIndex} * to place the entry in the current agent loop; set to a lower * value (or 0 with `agentLoopIndex: 1+` on the context) to place * it in a prior agent loop. */ readonly agentLoopIndex?: number; /** * ISO-8601 timestamp string. Defaults to `"2026-01-01T00:00:00.000Z"`. * Use distinct, monotonically-increasing timestamps when seeding * multiple entries the `since` invalidation sentinel needs to * order. */ readonly timestamp?: string; } /** * Build a {@link MockEntry} for {@link MockContextOptions.entries} * (and the observer-context equivalent) with the reserved * `_agentLoopIndex` tag stamped on the payload exactly as the live * engine's `appendEntry` wrapper would. * * The reserved-key name is kept as an internal detail of the engine * so plugin / fixture authors don't have to remember the underscore * prefix. A typo on the `agentLoopIndex` field of {@link PriorEntryOptions} * is a TypeScript compile error; the equivalent typo on a hand-rolled * `data: { agentLoopIndex: 5 }` literal is silent — the entry passes * through `findEntries` but then fails to match the current * agent-loop scope, and the rule under test appears to misbehave. * * Payload shaping mirrors the live `createAppendEntry`: * - Plain-object `data`: merged as `{ ...data, _agentLoopIndex }`. * - Anything else (arrays, Date, Map, Set, Error, primitives, * null, undefined): wrapped as `{ value: data, _agentLoopIndex }`. * * @example * const ctx = mockContext({ * agentLoopIndex: 5, * entries: [ * priorEntry("ws-sync-done", {}, { agentLoopIndex: 5 }), * ], * }); * // `when.happened: { event: "ws-sync-done", in: "agent_loop" }` * // now sees the entry as "happened in the current loop". */ export declare function priorEntry(customType: string, data?: unknown, opts?: PriorEntryOptions): MockEntry; /** * Re-exported for plugin authors constructing `toolCallEvents` * fixtures on {@link MockContextOptions}. Structurally `{ data, * timestamp, speculative: true }` — the same shape the walker-level * speculative-entry synthesis pass produces in production. Plugin * predicates that filter out speculative entries check * `entry.speculative === true`. */ export type { SyntheticEntry } from "../evaluator-internals/speculative-synthesis.ts"; /** * Options for {@link mockContext}. */ export interface MockContextOptions { /** Defaults to `"/tmp/test"`. */ readonly cwd?: string; /** Engine agent-loop counter. Defaults to `0`. */ readonly agentLoopIndex?: number; /** * Which tool this predicate is evaluating under. Defaults to * `"bash"`. Drives the default shape of {@link input} when the * caller doesn't supply one. */ readonly tool?: "bash" | "write" | "edit"; /** * Tool input. Omitted: derived from {@link tool} as the empty * shape for that tool (bash: `{ command: "" }`, write: * `{ path: "", content: "" }`, edit: `{ path: "", edits: [] }`). */ readonly input?: PredicateToolInput; /** * Walker-state snapshot the predicate sees via * {@link PredicateContext.walkerState}. * * Defaults to `{ cwd: options.cwd, env: new Map() }` so the * built-in `when.cwd` predicate and any plugin reading * `walkerState.env` work without wiring up a full walker. Callers * who want a specific env map or branch tracker state pass it in * via this option. * * The typed shape is {@link WhenWalkerState} with every field * optional (partial) so tests that only care about one dimension * don't have to fill in the others. The engine's production path * always populates `cwd` + `env`; the mock's default matches. * * Partial override: fields you pass merge over the defaults, so * `mockContext({ walkerState: { cwd: "/x" } })` keeps the default * empty env Map (same shape as production). Pass `env` explicitly * only when you need a seeded map. */ readonly walkerState?: Partial & Record; /** * Stub for `ctx.exec`. Defaults to rejecting with a clear error * message — tests that call out to exec must stub explicitly * (silent `undefined` would make predicate logic hard to reason * about). */ readonly exec?: (cmd: string, args: readonly string[], opts?: ExecOpts) => ExecResult | Promise; /** * Prior session entries `findEntries` reads from. Filtered by * customType; timestamps parsed from the ISO string to epoch-ms, * matching the production shape. * * For rules that use `when.happened: { in: "agent_loop" }` (or * `in: "session"` with the same-loop filter), construct entries * via {@link priorEntry} so the engine's reserved * `_agentLoopIndex` tag is stamped correctly — hand-rolled * literals with a typo (`agentLoopIndex` instead of the underscore * form) silently fail to match the current agent-loop scope and * the rule appears to misbehave. */ readonly entries?: ReadonlyArray; /** * Per-ref speculative events the built-in `when.happened` predicate * reads from `ctx.walkerState.events` (see * {@link PredicateContext.walkerState}'s reserved `events` key). * Keys are the `customType` event literals; values are the * synthetic entries for that type. When provided, overwrites any * `events` entry on the caller's {@link walkerState}. * * Use this to drive `when.happened` with `in: "tool_call"` (or any plugin * predicate that introspects `walkerState.events`) in isolation * without wiring up `loadHarness` + a full bash event. The shape * matches what the walker-level synthesis pass produces in * production — `{ data, timestamp, speculative: true }` per entry. */ readonly toolCallEvents?: Readonly>; } /** * Build a {@link PredicateContext} for unit-testing predicates in * isolation. See {@link MockContextOptions} for defaults. The returned * context's `appendEntry` captures into a buffer accessible via * {@link getAppendedEntries}. */ export declare function mockContext(options?: MockContextOptions): PredicateContext; /** * Options for {@link mockObserverContext}. Observers don't see * `tool`, `input`, or `walkerState` — those are predicate-side * concepts — so those fields are omitted here. */ export type MockObserverContextOptions = Omit; /** * Build an {@link ObserverContext} for unit-testing observer * `onResult` handlers. Same capture + `findEntries` pattern as * {@link mockContext}. * * Note: production `ObserverContext` does NOT expose `exec` — but the * mock does (as an `exec`-like stub on a different property name is * more confusing than forbidding it outright). Observer authors that * reach for `exec` are probably using the wrong hook; rules / plugins * carrying that logic belong in a predicate. The mock still accepts * the stub so tests composing an observer + predicate through a shared * options object don't have to strip the field. * * We DO NOT attach `exec` to the returned ObserverContext — the * schema doesn't expose it. The stub is accepted but silently unused * at this phase; the follow-up `testObserver` wrapper (Phase 5b) will * surface a warning when the stub is set but can never fire. */ export declare function mockObserverContext(options?: MockObserverContextOptions): ObserverContext; /** * Shape of a session-entry produced by {@link createRecordingHost}'s * `appendEntry`, readable by an {@link ExtensionContext} built from * {@link mockExtensionContext}. Mirrors the subset of pi's * `CustomEntry` the engine reads — `id` and `parentId` exist on real * pi entries, so we populate them too to keep type-shape drift from * masking silent divergence. */ export interface RecordedSessionEntry { readonly type: "custom"; readonly customType: string; readonly data: unknown; readonly timestamp: string; readonly id: string; readonly parentId: string | null; } /** * Exec-call record captured by {@link createRecordingHost}. One entry * per invocation, in registration order. `args` is defensively copied * so later mutation of the caller's argv array doesn't corrupt the * record. */ export interface RecordedExecCall { readonly cmd: string; readonly args: readonly string[]; readonly cwd: string; } /** * Options for {@link createRecordingHost}. */ export interface CreateRecordingHostOptions { /** * Stub for `host.exec`. Receives the normalized `cwd` (either the * caller's `opts.cwd` or `"/"`). Defaults to resolving with an * empty successful result — override when tests need to assert * behavior against a specific stdout / exit code. */ readonly exec?: (cmd: string, args: readonly string[], cwd: string) => Promise; } /** * Recording {@link EvaluatorHost}. Every `exec` and `appendEntry` * call is captured into readable accumulators; `entries` mirrors the * `appendEntry` writes in the shape pi's `sessionManager.getEntries()` * returns, so feeding `host.entries` into {@link mockExtensionContext} * makes the engine's writes visible to its subsequent reads in the * same test. * * Note: `entries` and `execCalls` / `appendedEntries` are returned as * mutable arrays so asserts can use `.some`, `.find`, etc. directly * without a copy. They are owned by the host; don't splice or reassign * them out from under it. */ export interface RecordingHost extends EvaluatorHost { /** * Session-entry log backing {@link mockExtensionContext}. Mutated * in-place on every `appendEntry` call. Pass this array to * `mockExtensionContext(cwd, host.entries)` so the host and the * ctx share the same store. */ readonly entries: RecordedSessionEntry[]; /** Every `exec` invocation, in call order. */ readonly execCalls: RecordedExecCall[]; /** * Every `appendEntry(type, data)` invocation, in call order. The * `data` field is stored verbatim — NOT the auto-tagged shape the * engine produces (that's reflected in {@link entries} instead). * This buffer is the raw host-level log; use it for assertions that * care about exactly which calls the engine made. */ readonly appendedEntries: Array<{ type: string; data: unknown; }>; } /** * Build a {@link RecordingHost}. Every `exec` call is recorded, and * every `appendEntry` call appends both to {@link RecordingHost. * appendedEntries} (raw host-level log) and to {@link RecordingHost. * entries} (session-entry shape used by {@link mockExtensionContext}). * * Timestamps on the session-entry log are monotonically-incrementing * ISO strings starting at `2026-01-01T00:00:00Z` (+ 1s per entry) so * chronological-order asserts stay stable across test runs without a * live clock dependency. Override with a wrapping host if your test * needs real timestamps. * * The default `exec` stub resolves with an empty successful result — * safer than rejecting by default because most tests don't exercise * exec at all and a loud reject would swamp the signal. Opt in to * rejection via `options.exec` when a test must assert "exec was NOT * called". */ export declare function createRecordingHost(options?: CreateRecordingHostOptions): RecordingHost; /** * Build a minimal {@link ExtensionContext} stub backed by a * {@link RecordedSessionEntry} array. Used with {@link loadHarness}'s * `harness.evaluate` / `harness.dispatch` when a test needs the engine * to see entries a {@link RecordingHost} previously recorded. * * Only `cwd` and `sessionManager.getEntries()` are populated — the * two fields the engine actually reads. Everything else on * `ExtensionContext` throws on access (via an `unknown` cast) so an * accidental reliance on unsupported surface surfaces as a clear * `TypeError` rather than silently passing. * * Pass `host.entries` from {@link createRecordingHost} to share the * backing store between the engine's writes and its subsequent reads. * * Choosing between this and {@link loadHarness} alone: * * - Use {@link loadHarness} + {@link expectBlocks}/{@link expectAllows} * when the test only asserts block vs allow on a single event. * - Use {@link createRecordingHost} + `mockExtensionContext` when the * test drives a multi-call sequence, asserts on session-entry * shape, or inspects exec calls. */ export declare function mockExtensionContext(cwd: string, entries?: ReadonlyArray): ExtensionContext; /** * Read the `appendEntry` capture buffer for a mock context. * * Returns an empty array when: * - nothing has been appended yet, OR * - the context wasn't built by {@link mockContext} / * {@link mockObserverContext} (safe lookup — no throw). * * The returned array is a snapshot (copy) so callers can iterate * without worrying about concurrent appends racing the assertion. */ export declare function getAppendedEntries(ctx: PredicateContext | ObserverContext): ReadonlyArray<{ customType: string; data?: unknown; }>; /** * Convenience shape for a bash tool-call event. Accepted by * {@link expectBlocks}, {@link expectAllows}, {@link expectRuleFires}, * and {@link runMatrix} in place of a full {@link ToolCallEvent}. */ export interface BashShorthand { readonly command: string; readonly cwd?: string; } /** Convenience shape for a write tool-call event. */ export interface WriteShorthand { readonly write: { readonly path: string; readonly content: string; }; readonly cwd?: string; } /** Convenience shape for an edit tool-call event. */ export interface EditShorthand { readonly edit: { readonly path: string; readonly edits: ReadonlyArray<{ readonly oldText: string; readonly newText: string; }>; }; readonly cwd?: string; } /** Union of the bash/write/edit shorthands. */ export type ToolCallShorthand = BashShorthand | WriteShorthand | EditShorthand; /** * Convenience shape for a tool-result event, accepted by * {@link testObserver}. Mirrors the minimal {@link SchemaToolResultEvent} * fields observers actually read. */ export interface ToolResultShorthand { readonly toolName: string; readonly input?: unknown; readonly output?: unknown; readonly exitCode?: number; } /** * Drive a single {@link PredicateHandler} against a {@link mockContext}. * Returns the boolean verdict. * * Usage: * ```ts * const fires = await testPredicate(branch, /^main$/, { * walkerState: { branch: "main" }, * }); * ``` * * Chain-aware predicates (e.g. the built-in `happened` with its * `&&`-chain speculative allow) read per-ref synthetic events from * `ctx.walkerState.events`. Populate `toolCallEvents` (or set * `walkerState` directly) in {@link MockContextOptions} to simulate * that surface in isolation without wiring up `loadHarness` + a * full bash event. */ export declare function testPredicate(predicate: PredicateHandler, args: A, options?: MockContextOptions): Promise; /** * Fire an {@link Observer} at an event, returning the captured * `appendEntry` writes plus whether the observer's `watch` filter * accepted the event. Use the `entries` field to assert what the * observer recorded; use `watchMatched` to assert the filter gated * firing correctly. * * If the observer's `watch` did NOT match, `onResult` is NOT called * (mirrors production dispatch). * * If `options.exec` is supplied, emits a `console.warn` — observers * don't see `exec`, so the stub can never fire. Exists on the options * shape only because {@link MockObserverContextOptions} is derived * from {@link MockContextOptions} for ergonomic test composition. */ export declare function testObserver(observer: Observer, event: SchemaToolResultEvent | ToolResultShorthand, options?: MockObserverContextOptions): Promise<{ entries: ReadonlyArray<{ customType: string; data?: unknown; }>; watchMatched: boolean; }>; /** Options for {@link expectBlocks}. */ export interface ExpectBlocksOptions { /** * Expected rule name — matched against the `[steering:@]` * prefix (source-tagged format per ADR §11). The source suffix is * ignored for matching; pass the bare rule name. */ readonly rule?: string; /** Expected reason — exact string match (string) or pattern match (RegExp). */ readonly reason?: string | RegExp; } /** * Assert that the harness blocks the given event. Returns the block * payload for further inspection. Throws on allow. * * Optional `expected.rule` / `expected.reason` narrow the assertion: * - `rule: "no-force-push"` — the fired rule's name must match. * - `reason: /force-push/` — the reason string must match (exact * string or regex). */ export declare function expectBlocks(harness: Harness, event: ToolCallEvent | ToolCallShorthand, expected?: ExpectBlocksOptions): Promise; /** * Assert that the harness allows the given event (no rule fires). * Throws with a rich message on block. */ export declare function expectAllows(harness: Harness, event: ToolCallEvent | ToolCallShorthand): Promise; /** * Assert that a specific rule fires on the given event. Thin alias * over {@link expectBlocks}; kept as a distinct helper for tests whose * intent is "which rule fired" rather than "the tool was blocked". */ export declare function expectRuleFires(harness: Harness, event: ToolCallEvent | ToolCallShorthand, ruleName: string): Promise; /** One row of a {@link runMatrix} input. */ export interface MatrixCase { readonly name: string; readonly event: ToolCallEvent | ToolCallShorthand; readonly expect: "block" | "allow" | { readonly block: true; readonly rule?: string; }; readonly cwd?: string; } /** Per-case outcome. */ export interface MatrixCaseResult { readonly case: MatrixCase; readonly passed: boolean; readonly actual: "block" | "allow"; readonly reason?: string; readonly errorMessage?: string; } /** Aggregate outcome of {@link runMatrix}. */ export interface MatrixResult { readonly total: number; readonly passed: number; readonly failed: number; readonly cases: ReadonlyArray; } /** * Batch-evaluate a list of cases against a harness. Never throws — * failures surface in `result.cases`. Pair with {@link formatMatrix} * to render a human-readable report. */ export declare function runMatrix(harness: Harness, cases: readonly MatrixCase[]): Promise; /** * Pretty-print a {@link MatrixResult}. ASCII-friendly for CI log * aggregators; structure mirrors the adversarial-matrix report style. */ export declare function formatMatrix(result: MatrixResult): string; //# sourceMappingURL=index.d.ts.map