@hraness/direct 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +436 -0
- package/dist/core/index.js +162 -0
- package/dist/index-1csg00w4.js +1167 -0
- package/dist/index-6mdfd2ey.js +464 -0
- package/dist/index-7n1h75n6.js +616 -0
- package/dist/index.js +232 -0
- package/dist/react.js +32 -0
- package/dist/testing/index.js +1069 -0
- package/dist/tooling/bombadil.js +2117 -0
- package/dist/tooling/browser-verification-entry.js +1499 -0
- package/dist/tooling/bundle-boundary.js +119 -0
- package/dist/web.js +605 -0
- package/package.json +179 -0
- package/skills/direct/AGENTS.md +13 -0
- package/skills/direct/SKILL.md +49 -0
- package/skills/direct/agents/openai.yaml +4 -0
- package/skills/direct/references/adoption.md +131 -0
- package/skills/direct/references/install.md +91 -0
- package/skills/direct/references/verification.md +247 -0
- package/src/core/coverage.ts +336 -0
- package/src/core/definition.ts +378 -0
- package/src/core/effects.ts +88 -0
- package/src/core/fixture.ts +185 -0
- package/src/core/ids.ts +77 -0
- package/src/core/index.ts +13 -0
- package/src/core/json-value.ts +7 -0
- package/src/core/json.ts +593 -0
- package/src/core/query.ts +230 -0
- package/src/core/reason.ts +16 -0
- package/src/core/resource.ts +10 -0
- package/src/core/result.ts +19 -0
- package/src/core/runtime.ts +229 -0
- package/src/core/scenario.ts +149 -0
- package/src/core/store.ts +784 -0
- package/src/index.ts +51 -0
- package/src/react.ts +54 -0
- package/src/testing/activity.ts +228 -0
- package/src/testing/coverage-binding.ts +99 -0
- package/src/testing/evidence.ts +59 -0
- package/src/testing/index.ts +22 -0
- package/src/testing/manifest.ts +559 -0
- package/src/testing/probe.ts +446 -0
- package/src/testing/scripted-transport.ts +775 -0
- package/src/testing/session.ts +525 -0
- package/src/tooling/bombadil-campaign.ts +288 -0
- package/src/tooling/bombadil-internal.d.ts +46 -0
- package/src/tooling/bombadil-runner.ts +1424 -0
- package/src/tooling/bombadil.ts +27 -0
- package/src/tooling/browser-verification-entry.ts +32 -0
- package/src/tooling/browser-verification.ts +916 -0
- package/src/tooling/bundle-boundary.ts +159 -0
- package/src/web/browser-bridge.ts +296 -0
- package/src/web/browser.ts +277 -0
- package/src/web/fetch-firewall.ts +251 -0
- package/src/web.ts +27 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DIRECT_COVERAGE_SCHEMA,
|
|
3
|
+
createCoverageCatalog,
|
|
4
|
+
parseCoverageCatalogSnapshot,
|
|
5
|
+
type CoverageCatalog,
|
|
6
|
+
type CoverageEntryInput,
|
|
7
|
+
type CoverageError,
|
|
8
|
+
} from "./coverage.js";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_MAX_FIXTURE_BYTES,
|
|
11
|
+
createFixtureEnvelope,
|
|
12
|
+
parseFixtureEnvelope,
|
|
13
|
+
parseFixtureJson,
|
|
14
|
+
serializeFixtureJson,
|
|
15
|
+
type FixtureCreateInput,
|
|
16
|
+
type FixtureEnvelope,
|
|
17
|
+
type FixtureError,
|
|
18
|
+
} from "./fixture.js";
|
|
19
|
+
import type { ScenarioId } from "./ids.js";
|
|
20
|
+
import { parseJsonValue, type WorldParser } from "./json.js";
|
|
21
|
+
import type { JsonValue } from "./json-value.js";
|
|
22
|
+
import { renderUnknownReason } from "./reason.js";
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_MAX_QUERY_BYTES,
|
|
25
|
+
activateDirectScenario,
|
|
26
|
+
maximumFixtureQueryBytes,
|
|
27
|
+
parseDirectQuery,
|
|
28
|
+
type ActiveDirect,
|
|
29
|
+
type QueryError,
|
|
30
|
+
} from "./query.js";
|
|
31
|
+
import { err, isRecord, ok, type Result } from "./result.js";
|
|
32
|
+
import { parseLogicalRuntimeSnapshot } from "./runtime.js";
|
|
33
|
+
import {
|
|
34
|
+
createScenarioCatalog,
|
|
35
|
+
type ScenarioCatalog,
|
|
36
|
+
type ScenarioCatalogError,
|
|
37
|
+
type ScenarioDefinition,
|
|
38
|
+
type ScenarioDefinitionInput,
|
|
39
|
+
} from "./scenario.js";
|
|
40
|
+
|
|
41
|
+
export interface DirectDefinitionInput<World extends JsonValue, Route extends string> {
|
|
42
|
+
readonly parseWorld: WorldParser<World>;
|
|
43
|
+
readonly defaultScenario: ScenarioId | string;
|
|
44
|
+
readonly scenarios: readonly ScenarioDefinitionInput<World, Route>[];
|
|
45
|
+
readonly coverage: readonly CoverageEntryInput[];
|
|
46
|
+
readonly maxFixtureBytes?: number;
|
|
47
|
+
readonly maxQueryBytes?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type DirectDefinitionError =
|
|
51
|
+
| {
|
|
52
|
+
readonly code: "invalid-options";
|
|
53
|
+
readonly message: string;
|
|
54
|
+
readonly scenarioError: null;
|
|
55
|
+
readonly coverageError: null;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
readonly code: "invalid-scenarios";
|
|
59
|
+
readonly message: string;
|
|
60
|
+
readonly scenarioError: ScenarioCatalogError;
|
|
61
|
+
readonly coverageError: null;
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
readonly code: "invalid-default-scenario";
|
|
65
|
+
readonly message: string;
|
|
66
|
+
readonly scenarioError: ScenarioCatalogError;
|
|
67
|
+
readonly coverageError: null;
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
readonly code: "invalid-coverage";
|
|
71
|
+
readonly message: string;
|
|
72
|
+
readonly scenarioError: null;
|
|
73
|
+
readonly coverageError: CoverageError;
|
|
74
|
+
}
|
|
75
|
+
| {
|
|
76
|
+
readonly code: "invalid-limits";
|
|
77
|
+
readonly message: string;
|
|
78
|
+
readonly scenarioError: null;
|
|
79
|
+
readonly coverageError: null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export interface DirectDefinitionLimits {
|
|
83
|
+
readonly maxFixtureBytes: number;
|
|
84
|
+
readonly maxQueryBytes: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DirectDefinition<World extends JsonValue, Route extends string> {
|
|
88
|
+
readonly defaultScenario: ScenarioDefinition<World, Route>;
|
|
89
|
+
readonly scenarios: ScenarioCatalog<World, Route>;
|
|
90
|
+
readonly coverage: CoverageCatalog;
|
|
91
|
+
readonly parseWorld: WorldParser<World>;
|
|
92
|
+
readonly limits: DirectDefinitionLimits;
|
|
93
|
+
/** Parse a foreign query. An empty query activates the validated default scenario. */
|
|
94
|
+
readonly activate: (source: unknown) => Result<ActiveDirect<World, Route>, QueryError>;
|
|
95
|
+
/** Activate a named catalog scenario without going through a browser URL. */
|
|
96
|
+
readonly activateScenario: (scenario: unknown) => Result<ActiveDirect<World, Route>, QueryError>;
|
|
97
|
+
/** Parse a foreign fixture under this definition's catalog, parser, and byte limit. */
|
|
98
|
+
readonly parseFixture: (input: unknown) => Result<FixtureEnvelope<World, Route>, FixtureError>;
|
|
99
|
+
/** Parse fixture JSON under this definition's catalog, parser, and byte limit. */
|
|
100
|
+
readonly parseFixtureJson: (source: unknown) => Result<FixtureEnvelope<World, Route>, FixtureError>;
|
|
101
|
+
/** Create a fixture under this definition's catalog, parser, and byte limit. */
|
|
102
|
+
readonly createFixture: (
|
|
103
|
+
input: FixtureCreateInput<World>,
|
|
104
|
+
) => Result<FixtureEnvelope<World, Route>, FixtureError>;
|
|
105
|
+
/** Serialize a fixture that this definition can activate. */
|
|
106
|
+
readonly serializeFixture: (input: FixtureCreateInput<World>) => Result<string, FixtureError>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function definitionError(
|
|
110
|
+
code: DirectDefinitionError["code"],
|
|
111
|
+
message: string,
|
|
112
|
+
causes: {
|
|
113
|
+
readonly scenarioError?: ScenarioCatalogError;
|
|
114
|
+
readonly coverageError?: CoverageError;
|
|
115
|
+
} = {},
|
|
116
|
+
): DirectDefinitionError {
|
|
117
|
+
if (code === "invalid-scenarios" || code === "invalid-default-scenario") {
|
|
118
|
+
const scenarioError = causes.scenarioError;
|
|
119
|
+
if (scenarioError === undefined) throw new Error(`${code} requires a scenario error`);
|
|
120
|
+
return Object.freeze({ code, message, scenarioError, coverageError: null });
|
|
121
|
+
}
|
|
122
|
+
if (code === "invalid-coverage") {
|
|
123
|
+
const coverageError = causes.coverageError;
|
|
124
|
+
if (coverageError === undefined) throw new Error("invalid-coverage requires a coverage error");
|
|
125
|
+
return Object.freeze({ code, message, scenarioError: null, coverageError });
|
|
126
|
+
}
|
|
127
|
+
return Object.freeze({ code, message, scenarioError: null, coverageError: null });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function validPositiveLimit(value: number): boolean {
|
|
131
|
+
return Number.isSafeInteger(value) && value >= 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function activeFromParsed<World extends JsonValue, Route extends string>(
|
|
135
|
+
parsed: ReturnType<typeof parseDirectQuery<World, Route>>,
|
|
136
|
+
): Result<ActiveDirect<World, Route>, QueryError> {
|
|
137
|
+
if (!parsed.ok) return parsed;
|
|
138
|
+
if (parsed.value.kind === "active") return ok(parsed.value);
|
|
139
|
+
return err({ code: "invalid-scenario", message: "Direct activation did not select a scenario" });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Validate the product-owned world parser, scenarios, default activation, and
|
|
144
|
+
* proof catalog once, then expose one fail-closed activation boundary.
|
|
145
|
+
*/
|
|
146
|
+
function tryDefineDirectUnchecked<World extends JsonValue, Route extends string>(
|
|
147
|
+
input: DirectDefinitionInput<World, Route>,
|
|
148
|
+
): Result<DirectDefinition<World, Route>, DirectDefinitionError> {
|
|
149
|
+
const parseWorld = input.parseWorld;
|
|
150
|
+
const defaultScenarioInput = input.defaultScenario;
|
|
151
|
+
const scenarioInputs = input.scenarios;
|
|
152
|
+
const coverageInputs = input.coverage;
|
|
153
|
+
const maxFixtureBytes = input.maxFixtureBytes ?? DEFAULT_MAX_FIXTURE_BYTES;
|
|
154
|
+
const maxQueryBytes = input.maxQueryBytes ?? DEFAULT_MAX_QUERY_BYTES;
|
|
155
|
+
if (!validPositiveLimit(maxFixtureBytes) || !validPositiveLimit(maxQueryBytes)) {
|
|
156
|
+
return err(definitionError(
|
|
157
|
+
"invalid-limits",
|
|
158
|
+
"Direct query and fixture limits must be positive safe integers",
|
|
159
|
+
));
|
|
160
|
+
}
|
|
161
|
+
const requiredQueryBytes = maximumFixtureQueryBytes(maxFixtureBytes);
|
|
162
|
+
if (!Number.isSafeInteger(requiredQueryBytes) || maxQueryBytes < requiredQueryBytes) {
|
|
163
|
+
return err(definitionError(
|
|
164
|
+
"invalid-limits",
|
|
165
|
+
`Direct maxQueryBytes must be at least ${String(requiredQueryBytes)} to carry every bounded fixture`,
|
|
166
|
+
));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const scenarios = createScenarioCatalog(scenarioInputs, parseWorld);
|
|
170
|
+
if (!scenarios.ok) {
|
|
171
|
+
return err(definitionError("invalid-scenarios", scenarios.error.message, {
|
|
172
|
+
scenarioError: scenarios.error,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
const defaultScenario = scenarios.value.resolve(defaultScenarioInput);
|
|
176
|
+
if (!defaultScenario.ok) {
|
|
177
|
+
return err(definitionError("invalid-default-scenario", defaultScenario.error.message, {
|
|
178
|
+
scenarioError: defaultScenario.error,
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
const coverage = createCoverageCatalog(coverageInputs, scenarios.value);
|
|
182
|
+
if (!coverage.ok) {
|
|
183
|
+
return err(definitionError("invalid-coverage", coverage.error.message, {
|
|
184
|
+
coverageError: coverage.error,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const limits = Object.freeze({ maxFixtureBytes, maxQueryBytes });
|
|
189
|
+
const fixtureOptions = Object.freeze({
|
|
190
|
+
scenarios: scenarios.value,
|
|
191
|
+
parseWorld,
|
|
192
|
+
maxBytes: maxFixtureBytes,
|
|
193
|
+
});
|
|
194
|
+
const queryOptions = Object.freeze({
|
|
195
|
+
...fixtureOptions,
|
|
196
|
+
maxQueryBytes,
|
|
197
|
+
});
|
|
198
|
+
const activateScenario = (
|
|
199
|
+
scenario: unknown,
|
|
200
|
+
): Result<ActiveDirect<World, Route>, QueryError> => activateDirectScenario(
|
|
201
|
+
scenario,
|
|
202
|
+
scenarios.value,
|
|
203
|
+
);
|
|
204
|
+
const activate = (source: unknown): Result<ActiveDirect<World, Route>, QueryError> => {
|
|
205
|
+
const parsed = parseDirectQuery(source, queryOptions);
|
|
206
|
+
if (!parsed.ok || parsed.value.kind === "active") return activeFromParsed(parsed);
|
|
207
|
+
return activateScenario(defaultScenario.value.id);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return ok(Object.freeze({
|
|
211
|
+
defaultScenario: defaultScenario.value,
|
|
212
|
+
scenarios: scenarios.value,
|
|
213
|
+
coverage: coverage.value,
|
|
214
|
+
parseWorld,
|
|
215
|
+
limits,
|
|
216
|
+
activate,
|
|
217
|
+
activateScenario,
|
|
218
|
+
parseFixture: (fixture: unknown) => parseFixtureEnvelope(fixture, fixtureOptions),
|
|
219
|
+
parseFixtureJson: (source: unknown) => parseFixtureJson(source, fixtureOptions),
|
|
220
|
+
createFixture: (fixture: FixtureCreateInput<World>) => createFixtureEnvelope(fixture, fixtureOptions),
|
|
221
|
+
serializeFixture: (fixture: FixtureCreateInput<World>) => serializeFixtureJson(fixture, fixtureOptions),
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Validate a typed dynamically assembled definition without throwing. */
|
|
226
|
+
export function tryDefineDirect<World extends JsonValue, Route extends string>(
|
|
227
|
+
input: DirectDefinitionInput<World, Route>,
|
|
228
|
+
): Result<DirectDefinition<World, Route>, DirectDefinitionError> {
|
|
229
|
+
try {
|
|
230
|
+
return tryDefineDirectUnchecked(input);
|
|
231
|
+
} catch (reason) {
|
|
232
|
+
return err(definitionError(
|
|
233
|
+
"invalid-options",
|
|
234
|
+
renderUnknownReason(reason, "Direct definition options could not be inspected"),
|
|
235
|
+
));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const DEFINITION_INPUT_KEYS = new Set([
|
|
240
|
+
"coverage",
|
|
241
|
+
"defaultScenario",
|
|
242
|
+
"maxFixtureBytes",
|
|
243
|
+
"maxQueryBytes",
|
|
244
|
+
"parseWorld",
|
|
245
|
+
"scenarios",
|
|
246
|
+
]);
|
|
247
|
+
const SCENARIO_INPUT_KEYS = new Set([
|
|
248
|
+
"description",
|
|
249
|
+
"id",
|
|
250
|
+
"route",
|
|
251
|
+
"runtime",
|
|
252
|
+
"title",
|
|
253
|
+
"world",
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Parse a genuinely foreign definition value. Its world and route types remain
|
|
258
|
+
* broad because unknown configuration cannot supply compile-time refinements.
|
|
259
|
+
*/
|
|
260
|
+
export function parseDirectDefinition(
|
|
261
|
+
input: unknown,
|
|
262
|
+
): Result<DirectDefinition<JsonValue, string>, DirectDefinitionError> {
|
|
263
|
+
try {
|
|
264
|
+
if (!isRecord(input)) throw new Error("Direct definition must be an object");
|
|
265
|
+
for (const key of Object.keys(input)) {
|
|
266
|
+
if (!DEFINITION_INPUT_KEYS.has(key)) throw new Error(`Unknown Direct definition key: ${key}`);
|
|
267
|
+
}
|
|
268
|
+
const rawParser = input.parseWorld;
|
|
269
|
+
if (typeof rawParser !== "function") throw new Error("Direct parseWorld must be a function");
|
|
270
|
+
const parseWorld: WorldParser<JsonValue> = (candidate) => {
|
|
271
|
+
const parsedCandidate: unknown = Reflect.apply(rawParser, undefined, [candidate]);
|
|
272
|
+
const parsedJson = parseJsonValue(parsedCandidate);
|
|
273
|
+
if (!parsedJson.ok) throw new Error(parsedJson.error.message);
|
|
274
|
+
return parsedJson.value;
|
|
275
|
+
};
|
|
276
|
+
if (typeof input.defaultScenario !== "string") {
|
|
277
|
+
throw new Error("Direct defaultScenario must be a string");
|
|
278
|
+
}
|
|
279
|
+
if (!Array.isArray(input.scenarios)) throw new Error("Direct scenarios must be an array");
|
|
280
|
+
const scenarios: ScenarioDefinitionInput<JsonValue, string>[] = [];
|
|
281
|
+
for (const [index, candidate] of input.scenarios.entries()) {
|
|
282
|
+
if (!isRecord(candidate)) throw new Error(`Direct scenario ${String(index)} must be an object`);
|
|
283
|
+
for (const key of Object.keys(candidate)) {
|
|
284
|
+
if (!SCENARIO_INPUT_KEYS.has(key)) {
|
|
285
|
+
throw new Error(`Unknown Direct scenario key at ${String(index)}: ${key}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (
|
|
289
|
+
typeof candidate.id !== "string"
|
|
290
|
+
|| typeof candidate.title !== "string"
|
|
291
|
+
|| typeof candidate.route !== "string"
|
|
292
|
+
|| (candidate.description !== undefined && typeof candidate.description !== "string")
|
|
293
|
+
) {
|
|
294
|
+
throw new Error(`Direct scenario ${String(index)} has an invalid shape`);
|
|
295
|
+
}
|
|
296
|
+
const world = parseJsonValue(candidate.world);
|
|
297
|
+
if (!world.ok) throw new Error(`Direct scenario ${String(index)} world is invalid: ${world.error.message}`);
|
|
298
|
+
const runtime = candidate.runtime === undefined
|
|
299
|
+
? undefined
|
|
300
|
+
: parseLogicalRuntimeSnapshot(candidate.runtime);
|
|
301
|
+
if (runtime !== undefined && !runtime.ok) {
|
|
302
|
+
throw new Error(`Direct scenario ${String(index)} runtime is invalid: ${runtime.error.message}`);
|
|
303
|
+
}
|
|
304
|
+
scenarios.push({
|
|
305
|
+
id: candidate.id,
|
|
306
|
+
title: candidate.title,
|
|
307
|
+
...(candidate.description === undefined ? {} : { description: candidate.description }),
|
|
308
|
+
route: candidate.route,
|
|
309
|
+
world: world.value,
|
|
310
|
+
...(runtime === undefined ? {} : { runtime: runtime.value }),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const coverage = parseCoverageCatalogSnapshot({
|
|
314
|
+
schema: DIRECT_COVERAGE_SCHEMA,
|
|
315
|
+
entries: input.coverage,
|
|
316
|
+
});
|
|
317
|
+
if (!coverage.ok) {
|
|
318
|
+
return err(definitionError("invalid-coverage", coverage.error.message, {
|
|
319
|
+
coverageError: coverage.error,
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
const maxFixtureBytes = input.maxFixtureBytes;
|
|
323
|
+
const maxQueryBytes = input.maxQueryBytes;
|
|
324
|
+
if (maxFixtureBytes !== undefined && typeof maxFixtureBytes !== "number") {
|
|
325
|
+
throw new Error("Direct maxFixtureBytes must be a number");
|
|
326
|
+
}
|
|
327
|
+
if (maxQueryBytes !== undefined && typeof maxQueryBytes !== "number") {
|
|
328
|
+
throw new Error("Direct maxQueryBytes must be a number");
|
|
329
|
+
}
|
|
330
|
+
return tryDefineDirectUnchecked({
|
|
331
|
+
parseWorld,
|
|
332
|
+
defaultScenario: input.defaultScenario,
|
|
333
|
+
scenarios,
|
|
334
|
+
coverage: coverage.value.entries,
|
|
335
|
+
...(maxFixtureBytes === undefined ? {} : { maxFixtureBytes }),
|
|
336
|
+
...(maxQueryBytes === undefined ? {} : { maxQueryBytes }),
|
|
337
|
+
});
|
|
338
|
+
} catch (reason) {
|
|
339
|
+
return err(definitionError(
|
|
340
|
+
"invalid-options",
|
|
341
|
+
renderUnknownReason(reason, "Direct definition options could not be inspected"),
|
|
342
|
+
));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Define an authored Direct composition.
|
|
348
|
+
*
|
|
349
|
+
* Configuration failures are programming errors at this boundary, so this
|
|
350
|
+
* concise path throws with the structured definition error as its cause. Use
|
|
351
|
+
* `tryDefineDirect` for typed dynamic assembly and
|
|
352
|
+
* `parseDirectDefinition` for a genuinely unknown value.
|
|
353
|
+
*/
|
|
354
|
+
export function defineDirect<
|
|
355
|
+
World extends JsonValue,
|
|
356
|
+
const Scenarios extends readonly {
|
|
357
|
+
readonly id: string;
|
|
358
|
+
readonly route: string;
|
|
359
|
+
}[],
|
|
360
|
+
>(
|
|
361
|
+
input: {
|
|
362
|
+
readonly parseWorld: WorldParser<World>;
|
|
363
|
+
readonly defaultScenario: NoInfer<Scenarios>[number]["id"];
|
|
364
|
+
readonly scenarios: Scenarios & readonly ScenarioDefinitionInput<
|
|
365
|
+
World,
|
|
366
|
+
Scenarios[number]["route"]
|
|
367
|
+
>[];
|
|
368
|
+
readonly coverage: readonly CoverageEntryInput<NoInfer<Scenarios>[number]["id"]>[];
|
|
369
|
+
readonly maxFixtureBytes?: number;
|
|
370
|
+
readonly maxQueryBytes?: number;
|
|
371
|
+
},
|
|
372
|
+
): DirectDefinition<World, Scenarios[number]["route"]> {
|
|
373
|
+
const defined = tryDefineDirect(input);
|
|
374
|
+
if (!defined.ok) {
|
|
375
|
+
throw new Error(defined.error.message, { cause: defined.error });
|
|
376
|
+
}
|
|
377
|
+
return defined.value;
|
|
378
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { parseOperationId, type OperationId } from "./ids.js";
|
|
2
|
+
import { cloneJson, freezeJson } from "./json.js";
|
|
3
|
+
import type { JsonValue } from "./json-value.js";
|
|
4
|
+
|
|
5
|
+
export interface QueuedEffect<Effect extends JsonValue> {
|
|
6
|
+
readonly id: OperationId;
|
|
7
|
+
readonly effect: Effect;
|
|
8
|
+
readonly remaining: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type QueuedFault<Fault extends JsonValue> = QueuedEffect<Fault>;
|
|
12
|
+
|
|
13
|
+
export type ConsumedEffect<Effect extends JsonValue> =
|
|
14
|
+
| {
|
|
15
|
+
readonly kind: "consumed";
|
|
16
|
+
readonly effect: Effect;
|
|
17
|
+
readonly queue: readonly QueuedEffect<Effect>[];
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: "empty";
|
|
21
|
+
readonly queue: readonly QueuedEffect<Effect>[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function ownQueuedEffect<Effect extends JsonValue>(
|
|
25
|
+
entry: QueuedEffect<Effect>,
|
|
26
|
+
): QueuedEffect<Effect> {
|
|
27
|
+
const id = parseOperationId(entry.id);
|
|
28
|
+
if (!id.ok) throw new Error(id.error.message);
|
|
29
|
+
if (!Number.isSafeInteger(entry.remaining) || entry.remaining < 1) {
|
|
30
|
+
throw new Error("Queued effect remaining uses must be a positive safe integer");
|
|
31
|
+
}
|
|
32
|
+
const cloned = cloneJson(entry.effect);
|
|
33
|
+
if (!cloned.ok) throw new Error(cloned.error.message);
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
id: id.value,
|
|
36
|
+
effect: freezeJson(cloned.value) as Effect,
|
|
37
|
+
remaining: entry.remaining,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ownEffectQueue<Effect extends JsonValue>(
|
|
42
|
+
queue: readonly QueuedEffect<Effect>[],
|
|
43
|
+
): readonly QueuedEffect<Effect>[] {
|
|
44
|
+
return Object.freeze(queue.map((entry) => ownQueuedEffect(entry)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function enqueueEffect<Effect extends JsonValue>(
|
|
48
|
+
queue: readonly QueuedEffect<Effect>[],
|
|
49
|
+
id: OperationId,
|
|
50
|
+
effect: Effect,
|
|
51
|
+
uses = 1,
|
|
52
|
+
): readonly QueuedEffect<Effect>[] {
|
|
53
|
+
if (!Number.isSafeInteger(uses) || uses < 1) {
|
|
54
|
+
throw new Error("Queued effect uses must be a positive safe integer");
|
|
55
|
+
}
|
|
56
|
+
const ownedQueue = ownEffectQueue(queue);
|
|
57
|
+
const appended = ownQueuedEffect({ id, effect, remaining: uses });
|
|
58
|
+
return Object.freeze([...ownedQueue, appended]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function consumeEffect<Effect extends JsonValue>(
|
|
62
|
+
queue: readonly QueuedEffect<Effect>[],
|
|
63
|
+
matches: (entry: QueuedEffect<Effect>) => boolean = () => true,
|
|
64
|
+
): ConsumedEffect<Effect> {
|
|
65
|
+
const ownedQueue = ownEffectQueue(queue);
|
|
66
|
+
const index = ownedQueue.findIndex(matches);
|
|
67
|
+
if (index < 0) {
|
|
68
|
+
return Object.freeze({ kind: "empty", queue: ownedQueue });
|
|
69
|
+
}
|
|
70
|
+
const matched = ownedQueue[index];
|
|
71
|
+
if (matched === undefined) {
|
|
72
|
+
return Object.freeze({ kind: "empty", queue: ownedQueue });
|
|
73
|
+
}
|
|
74
|
+
const next = [...ownedQueue];
|
|
75
|
+
if (matched.remaining === 1) {
|
|
76
|
+
next.splice(index, 1);
|
|
77
|
+
} else {
|
|
78
|
+
next[index] = Object.freeze({ ...matched, remaining: matched.remaining - 1 });
|
|
79
|
+
}
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
kind: "consumed",
|
|
82
|
+
effect: matched.effect,
|
|
83
|
+
queue: Object.freeze(next),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const enqueueFault = enqueueEffect;
|
|
88
|
+
export const consumeFault = consumeEffect;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { parseScenarioId, type ScenarioId } from "./ids.js";
|
|
2
|
+
import {
|
|
3
|
+
canonicalJson,
|
|
4
|
+
parseAndCloneWorld,
|
|
5
|
+
parseExactJsonSource,
|
|
6
|
+
utf8ByteLength,
|
|
7
|
+
type WorldParser,
|
|
8
|
+
} from "./json.js";
|
|
9
|
+
import type { JsonValue } from "./json-value.js";
|
|
10
|
+
import { err, isRecord, ok, type Result } from "./result.js";
|
|
11
|
+
import { parseLogicalRuntimeSnapshot, type LogicalRuntimeSnapshot } from "./runtime.js";
|
|
12
|
+
import type { ScenarioCatalog } from "./scenario.js";
|
|
13
|
+
|
|
14
|
+
export const FIXTURE_SCHEMA = "direct.fixture/v1" as const;
|
|
15
|
+
export const DEFAULT_MAX_FIXTURE_BYTES = 65_536;
|
|
16
|
+
|
|
17
|
+
export interface FixtureEnvelope<World extends JsonValue, Route extends string> {
|
|
18
|
+
readonly schema: typeof FIXTURE_SCHEMA;
|
|
19
|
+
readonly scenario: ScenarioId;
|
|
20
|
+
readonly route: Route;
|
|
21
|
+
readonly world: World;
|
|
22
|
+
readonly runtime: LogicalRuntimeSnapshot;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type FixtureErrorCode =
|
|
26
|
+
| "duplicate-key"
|
|
27
|
+
| "invalid-fixture"
|
|
28
|
+
| "invalid-json"
|
|
29
|
+
| "invalid-runtime"
|
|
30
|
+
| "invalid-scenario"
|
|
31
|
+
| "invalid-world"
|
|
32
|
+
| "mismatched-route"
|
|
33
|
+
| "oversized-fixture"
|
|
34
|
+
| "unknown-key"
|
|
35
|
+
| "unknown-scenario";
|
|
36
|
+
|
|
37
|
+
export interface FixtureError {
|
|
38
|
+
readonly code: FixtureErrorCode;
|
|
39
|
+
readonly message: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FixtureParseOptions<World extends JsonValue, Route extends string> {
|
|
43
|
+
readonly scenarios: ScenarioCatalog<World, Route>;
|
|
44
|
+
readonly parseWorld: WorldParser<World>;
|
|
45
|
+
readonly maxBytes?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FixtureCreateInput<World extends JsonValue> {
|
|
49
|
+
readonly scenario: ScenarioId | string;
|
|
50
|
+
readonly world: World;
|
|
51
|
+
readonly runtime?: LogicalRuntimeSnapshot;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const FIXTURE_KEYS = new Set(["schema", "scenario", "route", "world", "runtime"]);
|
|
55
|
+
|
|
56
|
+
function fixtureError(code: FixtureErrorCode, message: string): FixtureError {
|
|
57
|
+
return { code, message };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function maxFixtureBytes(value: number | undefined): number {
|
|
61
|
+
const maximum = value ?? DEFAULT_MAX_FIXTURE_BYTES;
|
|
62
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1) {
|
|
63
|
+
throw new Error("Fixture maxBytes must be a positive safe integer");
|
|
64
|
+
}
|
|
65
|
+
return maximum;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parseFixtureEnvelope<World extends JsonValue, Route extends string>(
|
|
69
|
+
input: unknown,
|
|
70
|
+
options: FixtureParseOptions<World, Route>,
|
|
71
|
+
): Result<FixtureEnvelope<World, Route>, FixtureError> {
|
|
72
|
+
const maximum = maxFixtureBytes(options.maxBytes);
|
|
73
|
+
const serialized = canonicalJson(input);
|
|
74
|
+
if (!serialized.ok) {
|
|
75
|
+
return err(fixtureError("invalid-fixture", serialized.error.message));
|
|
76
|
+
}
|
|
77
|
+
if (utf8ByteLength(serialized.value) > maximum) {
|
|
78
|
+
return err(fixtureError("oversized-fixture", "Fixture exceeds its byte limit"));
|
|
79
|
+
}
|
|
80
|
+
const foreign = JSON.parse(serialized.value) as unknown;
|
|
81
|
+
if (!isRecord(foreign)) {
|
|
82
|
+
return err(fixtureError("invalid-fixture", "Fixture must be an object"));
|
|
83
|
+
}
|
|
84
|
+
for (const key of Object.keys(foreign)) {
|
|
85
|
+
if (!FIXTURE_KEYS.has(key)) {
|
|
86
|
+
return err(fixtureError("unknown-key", `Unknown fixture key: ${key}`));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (foreign.schema !== FIXTURE_SCHEMA) {
|
|
90
|
+
return err(fixtureError("invalid-fixture", `Fixture schema must be ${FIXTURE_SCHEMA}`));
|
|
91
|
+
}
|
|
92
|
+
const id = parseScenarioId(foreign.scenario);
|
|
93
|
+
if (!id.ok) {
|
|
94
|
+
return err(fixtureError("invalid-scenario", id.error.message));
|
|
95
|
+
}
|
|
96
|
+
const scenario = options.scenarios.get(id.value);
|
|
97
|
+
if (scenario === undefined) {
|
|
98
|
+
return err(fixtureError("unknown-scenario", `Unknown fixture scenario: ${id.value}`));
|
|
99
|
+
}
|
|
100
|
+
if (typeof foreign.route !== "string" || foreign.route !== scenario.route) {
|
|
101
|
+
return err(fixtureError("mismatched-route", `Fixture route must match scenario ${id.value}`));
|
|
102
|
+
}
|
|
103
|
+
const runtime = foreign.runtime === undefined
|
|
104
|
+
? ok(scenario.runtime)
|
|
105
|
+
: parseLogicalRuntimeSnapshot(foreign.runtime);
|
|
106
|
+
if (!runtime.ok) {
|
|
107
|
+
return err(fixtureError("invalid-runtime", runtime.error.message));
|
|
108
|
+
}
|
|
109
|
+
const world = parseAndCloneWorld(foreign.world, options.parseWorld);
|
|
110
|
+
if (!world.ok) {
|
|
111
|
+
return err(fixtureError("invalid-world", world.error.message));
|
|
112
|
+
}
|
|
113
|
+
const envelope = Object.freeze({
|
|
114
|
+
schema: FIXTURE_SCHEMA,
|
|
115
|
+
scenario: id.value,
|
|
116
|
+
route: scenario.route,
|
|
117
|
+
world: world.value,
|
|
118
|
+
runtime: runtime.value,
|
|
119
|
+
});
|
|
120
|
+
const normalized = canonicalJson(envelope);
|
|
121
|
+
if (!normalized.ok) {
|
|
122
|
+
return err(fixtureError("invalid-fixture", normalized.error.message));
|
|
123
|
+
}
|
|
124
|
+
if (utf8ByteLength(normalized.value) > maximum) {
|
|
125
|
+
return err(fixtureError("oversized-fixture", "Normalized fixture exceeds its byte limit"));
|
|
126
|
+
}
|
|
127
|
+
return ok(envelope);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseFixtureJson<World extends JsonValue, Route extends string>(
|
|
131
|
+
source: unknown,
|
|
132
|
+
options: FixtureParseOptions<World, Route>,
|
|
133
|
+
): Result<FixtureEnvelope<World, Route>, FixtureError> {
|
|
134
|
+
if (typeof source !== "string") {
|
|
135
|
+
return err(fixtureError("invalid-json", "Fixture JSON source must be a string"));
|
|
136
|
+
}
|
|
137
|
+
if (utf8ByteLength(source) > maxFixtureBytes(options.maxBytes)) {
|
|
138
|
+
return err(fixtureError("oversized-fixture", "Fixture exceeds its byte limit"));
|
|
139
|
+
}
|
|
140
|
+
const input = parseExactJsonSource(source);
|
|
141
|
+
if (!input.ok) {
|
|
142
|
+
return err(fixtureError(
|
|
143
|
+
input.error.code === "duplicate-key" ? "duplicate-key" : "invalid-json",
|
|
144
|
+
input.error.code === "duplicate-key" ? input.error.message : "Fixture is not valid JSON",
|
|
145
|
+
));
|
|
146
|
+
}
|
|
147
|
+
return parseFixtureEnvelope(input.value, options);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Create an exact fixture envelope while deriving its route from the catalog. */
|
|
151
|
+
export function createFixtureEnvelope<World extends JsonValue, Route extends string>(
|
|
152
|
+
input: FixtureCreateInput<World>,
|
|
153
|
+
options: FixtureParseOptions<World, Route>,
|
|
154
|
+
): Result<FixtureEnvelope<World, Route>, FixtureError> {
|
|
155
|
+
const id = parseScenarioId(input.scenario);
|
|
156
|
+
if (!id.ok) return err(fixtureError("invalid-scenario", id.error.message));
|
|
157
|
+
const scenario = options.scenarios.get(id.value);
|
|
158
|
+
if (scenario === undefined) {
|
|
159
|
+
return err(fixtureError("unknown-scenario", `Unknown fixture scenario: ${id.value}`));
|
|
160
|
+
}
|
|
161
|
+
return parseFixtureEnvelope({
|
|
162
|
+
schema: FIXTURE_SCHEMA,
|
|
163
|
+
scenario: id.value,
|
|
164
|
+
route: scenario.route,
|
|
165
|
+
world: input.world,
|
|
166
|
+
runtime: input.runtime ?? scenario.runtime,
|
|
167
|
+
}, options);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Serialize a validated fixture with canonical key ordering. */
|
|
171
|
+
export function serializeFixtureJson<World extends JsonValue, Route extends string>(
|
|
172
|
+
input: FixtureCreateInput<World>,
|
|
173
|
+
options: FixtureParseOptions<World, Route>,
|
|
174
|
+
): Result<string, FixtureError> {
|
|
175
|
+
const fixture = createFixtureEnvelope(input, options);
|
|
176
|
+
if (!fixture.ok) return fixture;
|
|
177
|
+
const serialized = canonicalJson(fixture.value);
|
|
178
|
+
if (!serialized.ok) {
|
|
179
|
+
return err(fixtureError("invalid-fixture", serialized.error.message));
|
|
180
|
+
}
|
|
181
|
+
if (utf8ByteLength(serialized.value) > maxFixtureBytes(options.maxBytes)) {
|
|
182
|
+
return err(fixtureError("oversized-fixture", "Normalized fixture exceeds its byte limit"));
|
|
183
|
+
}
|
|
184
|
+
return ok(serialized.value);
|
|
185
|
+
}
|