diff --git a/docs/adr/native/typed-operation-facts.md b/docs/adr/native/typed-operation-facts.md index 1bbff10d72..7772b9fd77 100644 --- a/docs/adr/native/typed-operation-facts.md +++ b/docs/adr/native/typed-operation-facts.md @@ -41,7 +41,8 @@ consumers would still need the typed value. four readers today. `NativeDispatchMetadataInspection`'s `.strict()` row check is removed. 3. **The port parses once: `executeFact`.** The application port module - exports `executeFact(port, contract, parameters, options)`. It runs the + `src/application/AnalysisOperationFacts.ts` exports + `executeFact(port, contract, parameters, options)`. It runs the operation through the given port's `execute` and parses the result with the contract once, returning `{ execution: AnalysisExecution; fact: Fact }`. A mismatch is an diff --git a/src/application/AnalysisOperationFacts.test.ts b/src/application/AnalysisOperationFacts.test.ts new file mode 100644 index 0000000000..861daf54de --- /dev/null +++ b/src/application/AnalysisOperationFacts.test.ts @@ -0,0 +1,97 @@ +import { expect, it } from "vitest"; + +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { + listNamesFact, + listProceduresFact, + listStringsFact, + searchProceduresFact, + searchStringsFact, +} from "../domain/native/nativeOperationFacts.js"; +import { ok } from "../domain/result.js"; +import { executeFact } from "./AnalysisOperationFacts.js"; +import { + createAnalysisExecution, + type AnalysisOperationPort, +} from "./AnalysisProvider.js"; + +const provider = { id: "fixture", name: "Fixture", version: "1" }; + +const portReturning = ( + result: JsonValue, + calls: string[] = [], +): AnalysisOperationPort => ({ + execute: async (operation) => { + calls.push(operation); + return ok(createAnalysisExecution(result, provider)); + }, +}); + +const nameRow = { address: "0x1000", value: "main", provider_extra: 7 }; +const stringRow = { address: "0x2000", value: "hello", provider_extra: 7 }; + +it.each([ + [listNamesFact, nameRow], + [listProceduresFact, nameRow], + [searchProceduresFact, nameRow], + [listStringsFact, stringRow], + [searchStringsFact, stringRow], +] as const)( + "parses %s once, dropping unknown row fields from the fact but not the execution", + async (contract, row) => { + const calls: string[] = []; + const executed = await executeFact( + portReturning([row], calls), + contract, + {}, + ); + expect(calls).toEqual([contract.operation]); + if (!executed.ok) throw executed.error; + expect(executed.value.fact).toEqual([ + { address: row.address, value: row.value }, + ]); + // Evidence is built from the execution, which keeps the source value. + expect(executed.value.execution.result).toEqual([row]); + }, +); + +it("refuses a malformed result with field diagnostics and the observed execution", async () => { + const executed = await executeFact( + portReturning([{ address: 4096, value: "main" }]), + listNamesFact, + { limit: 1 }, + ); + expect(executed.ok).toBe(false); + if (executed.ok) return; + expect(executed.error).toBeInstanceOf(AnalysisOutputError); + expect(executed.error).toMatchObject({ + operation: "list_names", + reason: "Provider returned an invalid name inventory", + diagnostics: { + contract_issues: [ + { + path: [0, "address"], + code: "invalid_type", + expected: "string", + received: "number", + }, + ], + }, + }); + expect(executed.error.partialObservation).toMatchObject({ + operation: "list_names", + parameters: { limit: 1 }, + normalized_result: [{ address: 4096, value: "main" }], + }); +}); + +it("returns the port's own failure unchanged", async () => { + const failure = new AnalysisOutputError("list_names", "unavailable"); + const executed = await executeFact( + { execute: async () => ({ ok: false, error: failure }) }, + listNamesFact, + {}, + ); + expect(executed).toEqual({ ok: false, error: failure }); +}); diff --git a/src/application/AnalysisOperationFacts.ts b/src/application/AnalysisOperationFacts.ts new file mode 100644 index 0000000000..1054cfd147 --- /dev/null +++ b/src/application/AnalysisOperationFacts.ts @@ -0,0 +1,60 @@ +import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import { outputContractDiagnostics } from "../domain/native/nativeProviderOutput.js"; +import type { NativeOperationFactContract } from "../domain/native/nativeOperationFacts.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { + AnalysisExecution, + AnalysisOperation, + AnalysisOperationPort, + ExecutionOptions, +} from "./AnalysisProvider.js"; + +/** A completed execution and its result parsed once against its named contract. */ +export interface AnalysisFactExecution { + /** The provider's execution; its result stays the source value. */ + readonly execution: AnalysisExecution; + readonly fact: Fact; +} + +/** + * Run one native operation through `port` and parse its result once against + * the named contract. The call goes through the port's own `execute`, so a + * wrapping port observes it as any other call. A mismatch is an + * `AnalysisOutputError` naming the failed fields, keeping the execution as + * partial observed Evidence. + */ +export const executeFact = async ( + port: AnalysisOperationPort, + contract: NativeOperationFactContract, + parameters: Readonly>, + options?: ExecutionOptions, +): Promise, AnalysisError>> => { + const executed = await port.execute(contract.operation, parameters, options); + if (!executed.ok) return executed; + const parsed = contract.schema.safeParse(executed.value.result); + if (parsed.success) + return ok({ execution: executed.value, fact: parsed.data }); + return err( + new AnalysisOutputError( + contract.operation, + `Provider returned an invalid ${contract.subject}`, + { + cause: parsed.error, + diagnostics: outputContractDiagnostics( + parsed.error, + executed.value.result, + ), + }, + ).retainPartialObservation( + createAnalysisExecutionEvidence( + contract.operation, + parameters, + executed.value, + "observed", + ), + ), + ); +}; diff --git a/src/domain/native/nativeOperationFacts.ts b/src/domain/native/nativeOperationFacts.ts new file mode 100644 index 0000000000..1f35165f6c --- /dev/null +++ b/src/domain/native/nativeOperationFacts.ts @@ -0,0 +1,48 @@ +import type { z } from "zod"; +import { nativeInventoryRowsSchemas } from "./nativeInventory.js"; + +/** + * One native operation's provider-neutral result contract with its exact fact + * type. Readers name the contract they need; nothing selects one by catalog + * order. Row schemas are lenient: fields they do not name are dropped from + * the typed fact, while Evidence keeps the provider's source value. + */ +export interface NativeOperationFactContract { + readonly operation: Operation; + /** What the result holds, for failure messages. */ + readonly subject: string; + readonly schema: z.ZodType; +} + +const factContract = ( + operation: Operation, + subject: string, + schema: z.ZodType, +): NativeOperationFactContract => + Object.freeze({ operation, subject, schema }); + +export const listNamesFact = factContract( + "list_names", + "name inventory", + nativeInventoryRowsSchemas.names, +); +export const listProceduresFact = factContract( + "list_procedures", + "procedure inventory", + nativeInventoryRowsSchemas.names, +); +export const searchProceduresFact = factContract( + "search_procedures", + "procedure search result", + nativeInventoryRowsSchemas.names, +); +export const listStringsFact = factContract( + "list_strings", + "string inventory", + nativeInventoryRowsSchemas.strings, +); +export const searchStringsFact = factContract( + "search_strings", + "string search result", + nativeInventoryRowsSchemas.strings, +);