@glubean/sdk 0.1.38 → 0.2.0

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.
Files changed (47) hide show
  1. package/dist/contract-core.d.ts +96 -0
  2. package/dist/contract-core.d.ts.map +1 -0
  3. package/dist/contract-core.js +747 -0
  4. package/dist/contract-core.js.map +1 -0
  5. package/dist/contract-http/adapter.d.ts +52 -0
  6. package/dist/contract-http/adapter.d.ts.map +1 -0
  7. package/dist/contract-http/adapter.js +650 -0
  8. package/dist/contract-http/adapter.js.map +1 -0
  9. package/dist/contract-http/factory.d.ts +32 -0
  10. package/dist/contract-http/factory.d.ts.map +1 -0
  11. package/dist/contract-http/factory.js +83 -0
  12. package/dist/contract-http/factory.js.map +1 -0
  13. package/dist/contract-http/flow-helpers.d.ts +12 -0
  14. package/dist/contract-http/flow-helpers.d.ts.map +1 -0
  15. package/dist/contract-http/flow-helpers.js +34 -0
  16. package/dist/contract-http/flow-helpers.js.map +1 -0
  17. package/dist/contract-http/index.d.ts +15 -0
  18. package/dist/contract-http/index.d.ts.map +1 -0
  19. package/dist/contract-http/index.js +14 -0
  20. package/dist/contract-http/index.js.map +1 -0
  21. package/dist/contract-http/markdown.d.ts +10 -0
  22. package/dist/contract-http/markdown.d.ts.map +1 -0
  23. package/dist/contract-http/markdown.js +21 -0
  24. package/dist/contract-http/markdown.js.map +1 -0
  25. package/dist/contract-http/openapi.d.ts +15 -0
  26. package/dist/contract-http/openapi.d.ts.map +1 -0
  27. package/dist/contract-http/openapi.js +38 -0
  28. package/dist/contract-http/openapi.js.map +1 -0
  29. package/dist/contract-http/types.d.ts +252 -0
  30. package/dist/contract-http/types.d.ts.map +1 -0
  31. package/dist/contract-http/types.js +13 -0
  32. package/dist/contract-http/types.js.map +1 -0
  33. package/dist/contract-types.d.ts +420 -461
  34. package/dist/contract-types.d.ts.map +1 -1
  35. package/dist/contract-types.js +16 -4
  36. package/dist/contract-types.js.map +1 -1
  37. package/dist/index.d.ts +21 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +24 -2
  40. package/dist/index.js.map +1 -1
  41. package/dist/types.d.ts +22 -10
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/dist/contract.d.ts +0 -64
  45. package/dist/contract.d.ts.map +0 -1
  46. package/dist/contract.js +0 -781
  47. package/dist/contract.js.map +0 -1
@@ -0,0 +1,747 @@
1
+ /**
2
+ * Protocol-agnostic contract core.
3
+ *
4
+ * Provides:
5
+ * - Adapter registry (`_adapters`)
6
+ * - `contract.register(protocol, adapter)` — plugin extension point
7
+ * - `contract[protocol](id, spec)` dispatcher — validates 1:1 case keys,
8
+ * invokes adapter.execute per case, registers tests + ContractRegistryMeta
9
+ * - `contract.flow(id)` — protocol-agnostic FlowBuilder
10
+ * - `runFlow(flow, ctx)` — core flow execution helper (Rule 1/2 teardown)
11
+ * - `normalizeFlow(runtime)` — Runtime → ExtractedFlowProjection
12
+ * - Permissive Proxy tracer for `compute` nodes (best-effort reads/writes)
13
+ *
14
+ * HTTP is NOT handled here — it registers itself in `./contract-http/`.
15
+ * This file has zero HTTP-specific code.
16
+ *
17
+ * See:
18
+ * - `internal/40-discovery/proposals/contract-generics-complete.md` v5
19
+ * - `internal/40-discovery/proposals/contract-flow.md` v9
20
+ */
21
+ import { registerTest } from "./internal.js";
22
+ // =============================================================================
23
+ // Adapter registry
24
+ // =============================================================================
25
+ const _adapters = new Map();
26
+ /** Internal accessor for plugins / downstream (scanner, runner). */
27
+ export function getAdapter(protocol) {
28
+ return _adapters.get(protocol);
29
+ }
30
+ // =============================================================================
31
+ // contract.register + dispatcher
32
+ // =============================================================================
33
+ const RESERVED_PROTOCOL_NAMES = new Set([
34
+ "register",
35
+ "flow",
36
+ "getAdapter",
37
+ ]);
38
+ /**
39
+ * Register an adapter. Called by built-in HTTP adapter on SDK load, and by
40
+ * external adapter plugins (`@glubean/contract-grpc` etc.) on their import.
41
+ */
42
+ function register(protocol, adapter) {
43
+ if (RESERVED_PROTOCOL_NAMES.has(protocol)) {
44
+ throw new Error(`Cannot register reserved protocol name "${protocol}"`);
45
+ }
46
+ _adapters.set(protocol, adapter);
47
+ // Attach contract[protocol] dispatcher dynamically.
48
+ contract[protocol] = (id, spec) => {
49
+ return dispatchContract(protocol, adapter, id, spec);
50
+ };
51
+ }
52
+ /**
53
+ * Generic contract dispatcher — shared across all protocols.
54
+ * Called by `contract[protocol](id, spec)` above.
55
+ */
56
+ function dispatchContract(protocol, adapter, id, spec) {
57
+ const rawProjection = adapter.project(spec);
58
+ // Registered protocol name is source of truth — adapters may report a
59
+ // canonical name that drifts from the registration name.
60
+ const projection = {
61
+ ...rawProjection,
62
+ protocol,
63
+ };
64
+ // 1:1 key invariant between spec.cases and projection.cases
65
+ validateCaseKeys(protocol, spec.cases ?? {}, projection.cases);
66
+ const cases = spec.cases ?? {};
67
+ const contractTagsRaw = spec.tags;
68
+ const contractTags = Array.isArray(contractTagsRaw)
69
+ ? contractTagsRaw
70
+ : contractTagsRaw
71
+ ? [contractTagsRaw]
72
+ : [];
73
+ const projCaseMap = new Map(projection.cases.map((c) => [c.key, c]));
74
+ const tests = Object.entries(cases).map(([caseKey, caseSpec]) => {
75
+ const testId = `${id}.${caseKey}`;
76
+ const testName = `${id} — ${caseKey}`;
77
+ const projCase = projCaseMap.get(caseKey);
78
+ const caseTags = caseSpec.tags ?? [];
79
+ const allTags = [...contractTags, ...caseTags];
80
+ // Projection lifecycle/severity are authoritative
81
+ const requires = projCase.requires ?? caseSpec.requires ?? "headless";
82
+ const defaultRun = projCase.defaultRun ?? caseSpec.defaultRun ??
83
+ (requires !== "headless" ? "opt-in" : "always");
84
+ const runtimeTags = [];
85
+ if (requires !== "headless")
86
+ runtimeTags.push(`requires:${requires}`);
87
+ if (defaultRun === "opt-in")
88
+ runtimeTags.push("default-run:opt-in");
89
+ const finalTags = [...allTags, ...runtimeTags];
90
+ const skipDeprecated = projCase.lifecycle === "deprecated"
91
+ ? `deprecated: ${projCase.deprecatedReason ?? caseSpec.deprecated ?? "deprecated"}`
92
+ : caseSpec.deprecated
93
+ ? `deprecated: ${caseSpec.deprecated}`
94
+ : undefined;
95
+ const skipDeferred = projCase.lifecycle === "deferred"
96
+ ? (projCase.deferredReason ?? caseSpec.deferred ?? "deferred")
97
+ : caseSpec.deferred;
98
+ const testDef = {
99
+ meta: {
100
+ id: testId,
101
+ name: testName,
102
+ description: projCase.description,
103
+ tags: finalTags.length > 0 ? finalTags : undefined,
104
+ deferred: skipDeferred,
105
+ deprecated: skipDeprecated
106
+ ? (projCase.deprecatedReason ?? caseSpec.deprecated ?? "deprecated")
107
+ : undefined,
108
+ requires,
109
+ defaultRun,
110
+ },
111
+ type: "simple",
112
+ fn: async (ctx) => {
113
+ if (skipDeprecated)
114
+ ctx.skip(skipDeprecated);
115
+ if (skipDeferred)
116
+ ctx.skip(skipDeferred);
117
+ await adapter.execute(ctx, caseSpec, spec);
118
+ },
119
+ };
120
+ // Build ContractRegistryMeta
121
+ const payloadSummary = adapter.describePayload
122
+ ? (() => {
123
+ // describePayload expects SafeSchemas — we don't have safe form yet,
124
+ // so pass projCase.schemas as a best-effort (adapters using this in
125
+ // runtime meta should be tolerant of live objects).
126
+ try {
127
+ return adapter.describePayload((projCase.schemas ?? undefined));
128
+ }
129
+ catch {
130
+ return undefined;
131
+ }
132
+ })()
133
+ : undefined;
134
+ const registryMeta = {
135
+ target: projection.target,
136
+ protocol: projection.protocol,
137
+ caseKey,
138
+ lifecycle: projCase.lifecycle,
139
+ severity: projCase.severity,
140
+ instanceName: projection.instanceName,
141
+ payloadSummary,
142
+ meta: projCase.meta,
143
+ };
144
+ registerTest({
145
+ id: testId,
146
+ name: testName,
147
+ type: "simple",
148
+ tags: finalTags.length > 0 ? finalTags : undefined,
149
+ description: projCase.description,
150
+ groupId: id,
151
+ requires,
152
+ defaultRun,
153
+ contract: registryMeta,
154
+ });
155
+ return testDef;
156
+ });
157
+ // Core injects id into both _projection and _spec carrier
158
+ const enrichedProjection = { ...projection, id };
159
+ const arr = [...tests];
160
+ const contractObj = Object.assign(arr, {
161
+ _projection: enrichedProjection,
162
+ _spec: spec,
163
+ case(key) {
164
+ // Delegate fail-fast validation to adapter (e.g. HTTP rejects
165
+ // function-valued body/params/query/headers).
166
+ adapter.validateCaseForFlow?.(spec, key, id);
167
+ return makeContractCaseRef(protocol, id, projection.target, key, contractObj, spec);
168
+ },
169
+ });
170
+ return contractObj;
171
+ }
172
+ function validateCaseKeys(protocol, specCases, projectionCases) {
173
+ // Duplicate check
174
+ const keys = projectionCases.map((c) => c.key);
175
+ const keySet = new Set(keys);
176
+ if (keySet.size !== keys.length) {
177
+ const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
178
+ throw new Error(`contract.register("${protocol}"): project() returned duplicate case key(s): ${[...new Set(dupes)].join(", ")}. ` +
179
+ `Each projected case key must be unique.`);
180
+ }
181
+ const specKeys = new Set(Object.keys(specCases));
182
+ for (const key of keySet) {
183
+ if (!specKeys.has(key)) {
184
+ throw new Error(`contract.register("${protocol}"): project() returned case "${key}" not present in spec.cases. ` +
185
+ `Projected cases must 1:1 match spec.cases keys.`);
186
+ }
187
+ }
188
+ for (const key of specKeys) {
189
+ if (!keySet.has(key)) {
190
+ throw new Error(`contract.register("${protocol}"): spec.cases has "${key}" but project() did not return it. ` +
191
+ `Projected cases must 1:1 match spec.cases keys.`);
192
+ }
193
+ }
194
+ }
195
+ /**
196
+ * Construct a ContractCaseRef. Adapters may extend this via module
197
+ * augmentation if they want stricter typing for their specific I/O shapes.
198
+ *
199
+ * This function performs the `function-valued field fail-fast` check
200
+ * required by contract-flow §5.1.1 — but since the check is
201
+ * protocol-specific (what counts as an "input slot" varies), adapters
202
+ * should enforce it in their own `.case()` wrapper if needed. For
203
+ * built-in HTTP adapter, see `./contract-http/adapter.ts`.
204
+ */
205
+ function makeContractCaseRef(protocol, contractId, target, caseKey, contract, _spec) {
206
+ return {
207
+ __glubean_type: "contract-case-ref",
208
+ contractId,
209
+ caseKey,
210
+ protocol,
211
+ target,
212
+ contract,
213
+ };
214
+ }
215
+ export const contract = {
216
+ register,
217
+ flow,
218
+ };
219
+ // =============================================================================
220
+ // Flow: FlowBuilder + runFlow + normalizeFlow + tracePureFn
221
+ // =============================================================================
222
+ /**
223
+ * Protocol-agnostic flow builder. See contract-flow.md v9 §4.1.
224
+ */
225
+ export function flow(idOrMeta) {
226
+ const meta = typeof idOrMeta === "string"
227
+ ? { id: idOrMeta }
228
+ : idOrMeta;
229
+ const steps = [];
230
+ let setupFn;
231
+ let teardownFn;
232
+ let built = false;
233
+ let extraMeta = {};
234
+ const builder = {
235
+ __glubean_type: "flow-builder",
236
+ meta(m) {
237
+ extraMeta = { ...extraMeta, ...m };
238
+ return builder;
239
+ },
240
+ setup(fn) {
241
+ setupFn = fn;
242
+ return builder;
243
+ },
244
+ teardown(fn) {
245
+ teardownFn = fn;
246
+ return builder;
247
+ },
248
+ step(ref, bindings) {
249
+ const adapter = _adapters.get(ref.protocol);
250
+ if (!adapter) {
251
+ throw new Error(`contract.flow(${JSON.stringify(meta.id)}).step: unknown protocol "${ref.protocol}". ` +
252
+ `Did you forget to import a contract plugin package (e.g. "@glubean/contract-grpc")?`);
253
+ }
254
+ if (!adapter.executeCaseInFlow) {
255
+ throw new Error(`contract.flow(${JSON.stringify(meta.id)}).step: adapter for "${ref.protocol}" ` +
256
+ `does not implement executeCaseInFlow — this protocol cannot appear in a flow.`);
257
+ }
258
+ const step = {
259
+ kind: "contract-call",
260
+ name: bindings?.name,
261
+ ref,
262
+ caseKey: ref.caseKey,
263
+ contract: ref.contract,
264
+ bindings: bindings
265
+ ? { in: bindings.in, out: bindings.out }
266
+ : undefined,
267
+ };
268
+ steps.push(step);
269
+ return builder;
270
+ },
271
+ compute(fn) {
272
+ const step = {
273
+ kind: "compute",
274
+ fn: fn,
275
+ };
276
+ steps.push(step);
277
+ return builder;
278
+ },
279
+ build() {
280
+ return finalize();
281
+ },
282
+ };
283
+ // Auto-finalize via microtask (mirrors TestBuilder pattern)
284
+ queueMicrotask(() => {
285
+ if (!built)
286
+ finalize();
287
+ });
288
+ function finalize() {
289
+ if (built)
290
+ return builtResult;
291
+ built = true;
292
+ const runtime = {
293
+ protocol: "flow",
294
+ description: extraMeta.description,
295
+ tags: extraMeta.tags,
296
+ extensions: extraMeta.extensions,
297
+ setup: setupFn,
298
+ teardown: teardownFn,
299
+ steps,
300
+ };
301
+ // Build the single Test that runFlow orchestrates.
302
+ const flowTest = {
303
+ meta: {
304
+ id: meta.id,
305
+ name: extraMeta.name ?? meta.id,
306
+ tags: extraMeta.tags,
307
+ description: extraMeta.description,
308
+ // `FlowMeta.skip: string` (the reason) → `TestMeta.deferred: string`
309
+ // mirrors the ContractCase.deferred convention so downstream
310
+ // reporters render the skip reason consistently.
311
+ ...(extraMeta.skip !== undefined ? { deferred: extraMeta.skip } : {}),
312
+ ...(extraMeta.only !== undefined ? { only: extraMeta.only } : {}),
313
+ },
314
+ type: "simple",
315
+ fn: async (ctx) => {
316
+ // Belt-and-suspenders: runtime ctx.skip in case the runner didn't
317
+ // filter on meta.deferred (e.g. the user ran with an explicit
318
+ // target that bypasses skip filters).
319
+ if (extraMeta.skip)
320
+ ctx.skip(extraMeta.skip);
321
+ await runFlow(resultHandle, ctx);
322
+ },
323
+ };
324
+ const arr = [flowTest];
325
+ const runtimeWithId = { ...runtime, id: meta.id };
326
+ // Pre-compute extracted projection so downstream consumers (scanner,
327
+ // CLI, MCP, Cloud) get full field mappings + compute reads/writes
328
+ // without having to import the SDK to call normalizeFlow themselves.
329
+ const extracted = normalizeFlow(runtimeWithId);
330
+ const resultHandle = Object.assign(arr, {
331
+ _flow: runtimeWithId,
332
+ _extracted: extracted,
333
+ });
334
+ // Register flow in the registry
335
+ const flowRegistryMeta = {
336
+ id: extracted.id,
337
+ description: extracted.description,
338
+ tags: extracted.tags,
339
+ steps: extracted.steps.map(stepProjectionToRegistry),
340
+ setupDynamic: extracted.setupDynamic,
341
+ };
342
+ registerTest({
343
+ id: meta.id,
344
+ name: extraMeta.name ?? meta.id,
345
+ type: "simple",
346
+ tags: extraMeta.tags,
347
+ description: extraMeta.description,
348
+ flow: flowRegistryMeta,
349
+ });
350
+ builtResult = resultHandle;
351
+ return resultHandle;
352
+ }
353
+ let builtResult;
354
+ return builder;
355
+ }
356
+ function stepProjectionToRegistry(step) {
357
+ if (step.kind === "compute") {
358
+ return {
359
+ kind: "compute",
360
+ name: step.name,
361
+ reads: step.reads,
362
+ writes: step.writes,
363
+ };
364
+ }
365
+ return {
366
+ kind: "contract-call",
367
+ name: step.name,
368
+ contractId: step.contractId,
369
+ caseKey: step.caseKey,
370
+ protocol: step.protocol,
371
+ target: step.target,
372
+ inputs: step.inputs,
373
+ outputs: step.outputs,
374
+ };
375
+ }
376
+ /**
377
+ * Core flow execution helper. Implements the Rule 1 / Rule 2 teardown
378
+ * semantics from contract-flow.md §7.
379
+ */
380
+ export async function runFlow(flowContract, ctx) {
381
+ const runtime = flowContract._flow;
382
+ // If flow.setup throws → flow.teardown does NOT run (Rule 2).
383
+ let state = runtime.setup
384
+ ? await runtime.setup(ctx)
385
+ : undefined;
386
+ try {
387
+ for (const step of runtime.steps) {
388
+ if (step.kind === "compute") {
389
+ // Compute: synchronous pure function. Enforce both syntactic and
390
+ // value-level async rejection.
391
+ if (step.fn.constructor?.name === "AsyncFunction") {
392
+ throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
393
+ `async functions are not allowed — compute must be synchronous and I/O-free`);
394
+ }
395
+ const result = step.fn(state);
396
+ if (result && typeof result.then === "function") {
397
+ throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
398
+ `returned a thenable (Promise or Promise-like) — compute must not return async values. ` +
399
+ `If you need async initialization, use flow.setup() instead.`);
400
+ }
401
+ state = result;
402
+ continue;
403
+ }
404
+ // contract-call branch
405
+ const adapter = _adapters.get(step.ref.protocol);
406
+ if (!adapter) {
407
+ throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
408
+ `no registered adapter for protocol "${step.ref.protocol}". ` +
409
+ `Did you forget to import a contract plugin package?`);
410
+ }
411
+ if (!adapter.executeCaseInFlow) {
412
+ throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
413
+ `adapter for "${step.ref.protocol}" does not implement executeCaseInFlow`);
414
+ }
415
+ const resolvedInputs = step.bindings?.in?.(state);
416
+ const response = await adapter.executeCaseInFlow({
417
+ ctx,
418
+ contract: step.contract,
419
+ caseKey: step.caseKey,
420
+ resolvedInputs,
421
+ });
422
+ if (step.bindings?.out) {
423
+ state = step.bindings.out(state, response);
424
+ }
425
+ }
426
+ }
427
+ finally {
428
+ if (runtime.teardown) {
429
+ // flow.teardown runs in Rule 2 outer-finally. Its errors are logged
430
+ // but must not mask the primary exception.
431
+ try {
432
+ await runtime.teardown(ctx, state);
433
+ }
434
+ catch (teardownErr) {
435
+ ctx.log?.(`flow.teardown failed: ${String(teardownErr)}`);
436
+ }
437
+ }
438
+ }
439
+ }
440
+ // =============================================================================
441
+ // normalizeFlow + permissive Proxy tracer
442
+ // =============================================================================
443
+ /**
444
+ * Normalize a RuntimeFlowProjection to JSON-safe ExtractedFlowProjection.
445
+ * Runs Proxy dry-run of lens functions to extract FieldMappings.
446
+ *
447
+ * Lens purity is enforced here: if a `step.bindings.in` or `.out` lens
448
+ * violates purity (method call, `new`, etc.), `LensPurityError` is thrown
449
+ * with step context so the author sees which step needs fixing.
450
+ */
451
+ export function normalizeFlow(runtime) {
452
+ return {
453
+ id: runtime.id,
454
+ protocol: "flow",
455
+ description: runtime.description,
456
+ tags: runtime.tags,
457
+ extensions: runtime.extensions,
458
+ setupDynamic: runtime.setup ? true : undefined,
459
+ steps: runtime.steps.map((s, idx) => {
460
+ const stepLabel = s.name ??
461
+ (s.kind === "contract-call" ? `${s.contract._projection.id}#${s.caseKey}` : `step-${idx + 1}`);
462
+ if (s.kind === "compute") {
463
+ try {
464
+ const { reads, writes } = traceComputeFn(s.fn);
465
+ return { kind: "compute", name: s.name, reads, writes };
466
+ }
467
+ catch (err) {
468
+ // Wrap compute-tracer failures with the same step-context format
469
+ // used for lens errors, so authoring mistakes are equally easy
470
+ // to localize regardless of which tracer caught them.
471
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (compute): ${err instanceof Error ? err.message : String(err)}`);
472
+ }
473
+ }
474
+ let inputs;
475
+ if (s.bindings?.in) {
476
+ try {
477
+ inputs = extractMappings(s.bindings.in);
478
+ }
479
+ catch (err) {
480
+ if (err instanceof LensPurityError) {
481
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (in lens): ${err.message}`);
482
+ }
483
+ throw err;
484
+ }
485
+ }
486
+ let outputs;
487
+ if (s.bindings?.out) {
488
+ try {
489
+ outputs = extractMappingsOut(s.bindings.out);
490
+ }
491
+ catch (err) {
492
+ if (err instanceof LensPurityError) {
493
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (out lens): ${err.message}`);
494
+ }
495
+ throw err;
496
+ }
497
+ }
498
+ return {
499
+ kind: "contract-call",
500
+ name: s.name,
501
+ contractId: s.contract._projection.id,
502
+ caseKey: s.caseKey,
503
+ protocol: s.ref.protocol,
504
+ target: s.ref.target,
505
+ inputs,
506
+ outputs,
507
+ };
508
+ }),
509
+ };
510
+ }
511
+ /**
512
+ * Run a pure lens `fn: (state) => output` with a tracing Proxy, extracting
513
+ * FieldMappings from state paths to output paths.
514
+ *
515
+ * Pure-lens enforcement: method calls, `new`, and coercion on the state
516
+ * proxy raise `LensPurityError`. Errors are **not** swallowed — the caller
517
+ * (typically `normalizeFlow`) wraps them with step context and re-throws,
518
+ * so authors see the failure at flow build time rather than silently
519
+ * losing projection data.
520
+ */
521
+ export function extractMappings(fn) {
522
+ const proxy = makeLensProxy("state");
523
+ const result = fn(proxy);
524
+ return collectMappings(result, []);
525
+ }
526
+ /**
527
+ * Extract FieldMappings from `out: (state, response) => newState`.
528
+ * Same purity contract as `extractMappings`.
529
+ */
530
+ export function extractMappingsOut(fn) {
531
+ const stateProxy = makeLensProxy("state");
532
+ const resProxy = makeLensProxy("response");
533
+ const result = fn(stateProxy, resProxy);
534
+ // outputs target is always state.X
535
+ return collectMappings(result, ["state"]);
536
+ }
537
+ /**
538
+ * Recursively descend into an object produced by a traced lens, collecting
539
+ * paths. Lens outputs are plain objects whose leaf values are TracedValue
540
+ * markers (recording their source path).
541
+ */
542
+ function collectMappings(out, targetPath) {
543
+ const mappings = [];
544
+ walk(out, targetPath);
545
+ return mappings;
546
+ function walk(val, path) {
547
+ if (isTracedValue(val)) {
548
+ mappings.push({
549
+ target: path.join("."),
550
+ source: { kind: "path", path: val[TRACE_MARKER] },
551
+ });
552
+ return;
553
+ }
554
+ if (val === undefined || val === null)
555
+ return;
556
+ if (typeof val === "object" && !Array.isArray(val)) {
557
+ for (const [k, v] of Object.entries(val)) {
558
+ walk(v, [...path, k]);
559
+ }
560
+ return;
561
+ }
562
+ if (Array.isArray(val)) {
563
+ val.forEach((v, i) => walk(v, [...path, String(i)]));
564
+ return;
565
+ }
566
+ // Primitive literal
567
+ mappings.push({
568
+ target: path.join("."),
569
+ source: { kind: "literal", value: val },
570
+ });
571
+ }
572
+ }
573
+ // --- Traced values (returned by lens proxy on property access) ---------------
574
+ //
575
+ // The trace marker is a Symbol (not a string key) so it is invisible to
576
+ // `Object.keys` / spread / JSON.stringify. Without this, `{ ...s }` in a lens
577
+ // would copy the tracer sentinel into the result and trip the traced-value
578
+ // check against the entire spread object.
579
+ const TRACE_MARKER = Symbol.for("@glubean/lens-trace");
580
+ function isTracedValue(v) {
581
+ // Lens proxy uses a callable function target (for apply-trap purity
582
+ // enforcement), so the proxy itself reports `typeof === "function"` in
583
+ // V8. Accept either object or function.
584
+ return ((typeof v === "object" || typeof v === "function") &&
585
+ v !== null &&
586
+ v[TRACE_MARKER] !== undefined);
587
+ }
588
+ /**
589
+ * Raised by the strict lens Proxy when a user lens fn attempts an operation
590
+ * that breaks the "pure field access + repack" contract. Caught and re-
591
+ * thrown with step context by `normalizeFlow`.
592
+ */
593
+ export class LensPurityError extends Error {
594
+ path;
595
+ operation;
596
+ constructor(path, operation) {
597
+ super(`Flow step in/out lens must be a pure select/repack function ` +
598
+ `(no method calls, branches, or arithmetic). ` +
599
+ `Illegal operation: "${operation}" on path "${path}". ` +
600
+ `If you need computation, move it to a .compute(s => ...) step between ` +
601
+ `this .step() and the next one.`);
602
+ this.name = "LensPurityError";
603
+ this.path = path;
604
+ this.operation = operation;
605
+ }
606
+ }
607
+ /**
608
+ * Strict lens proxy — records top-level property access path as a TracedValue
609
+ * on read. Used for `.step()` in/out lenses where we want precise field
610
+ * mappings. Trace marker is a Symbol so it doesn't leak through spread.
611
+ *
612
+ * Enforces the lens purity invariant (contract-flow v9 §3.1 + §3.3):
613
+ * method calls / coercion / method dispatch on state or response throw
614
+ * `LensPurityError`. Authors who need computation must move it to a
615
+ * `.compute(s => ...)` step.
616
+ */
617
+ function makeLensProxy(rootPath) {
618
+ function childProxy(path) {
619
+ // Use a callable **arrow** function target so `apply` trap fires on
620
+ // method calls. Arrow functions don't own `prototype`, so ownKeys can
621
+ // return [] without violating Proxy invariants (regular functions own
622
+ // a non-configurable `prototype` that would have to be surfaced).
623
+ const target = (() => { });
624
+ return new Proxy(target, {
625
+ get(_target, prop) {
626
+ if (prop === TRACE_MARKER)
627
+ return path;
628
+ if (typeof prop === "symbol")
629
+ return undefined;
630
+ return childProxy(`${path}.${String(prop)}`);
631
+ },
632
+ apply(_target, _thisArg, _args) {
633
+ // Method call — forbidden. Extract the leaf segment as the offending op.
634
+ const dot = path.lastIndexOf(".");
635
+ const op = dot >= 0 ? path.slice(dot + 1) : path;
636
+ const owner = dot >= 0 ? path.slice(0, dot) : path;
637
+ throw new LensPurityError(owner, `${op}()`);
638
+ },
639
+ construct(_target, _args) {
640
+ throw new LensPurityError(path, "new");
641
+ },
642
+ // ownKeys returns nothing so spread copies no fields.
643
+ ownKeys() {
644
+ return [];
645
+ },
646
+ getOwnPropertyDescriptor() {
647
+ return undefined;
648
+ },
649
+ });
650
+ }
651
+ return childProxy(rootPath);
652
+ }
653
+ // --- Permissive compute proxy (different role, different trap rules) --------
654
+ /**
655
+ * Trace a `compute` fn to extract top-level reads + writes.
656
+ *
657
+ * Compute allows arbitrary sync TS (template literals, method calls, .map()),
658
+ * so the Proxy must be permissive: respond to primitive coercion, swallow
659
+ * method calls, etc. We only record top-level property access paths.
660
+ *
661
+ * See contract-flow.md §4.1.1 for design + known limitations.
662
+ */
663
+ export function traceComputeFn(fn) {
664
+ const reads = new Set();
665
+ const proxy = makeComputeProxy("state", reads);
666
+ // compute fn is permitted to do arbitrary synchronous TS — the proxy is
667
+ // tolerant of method calls / coercion / iteration via its permissive
668
+ // trap rules, so any exception here is genuine (e.g. fn threw explicitly).
669
+ // Don't silently swallow: let the error propagate so callers surface it.
670
+ const result = fn(proxy);
671
+ const writes = result && typeof result === "object" && !Array.isArray(result)
672
+ ? Object.keys(result).filter((k) => typeof k === "string")
673
+ : [];
674
+ return { reads: [...reads].sort(), writes };
675
+ }
676
+ function makeComputeProxy(rootPath, reads) {
677
+ // Arrow-function target: callable (for `apply` trap on method calls) but
678
+ // does NOT own `prototype`, so `ownKeys` returning [] is valid and spread
679
+ // (`{ ...s }`) works without triggering a Proxy invariant violation.
680
+ return new Proxy((() => { }), {
681
+ get(_target, prop) {
682
+ if (prop === Symbol.toPrimitive)
683
+ return () => "";
684
+ if (prop === "toString")
685
+ return () => "";
686
+ if (prop === "valueOf")
687
+ return () => 0;
688
+ if (prop === Symbol.iterator)
689
+ return function* () { };
690
+ if (prop === "length")
691
+ return 0;
692
+ if (prop === Symbol.isConcatSpreadable)
693
+ return false;
694
+ if (typeof prop !== "string")
695
+ return undefined;
696
+ reads.add(`${rootPath}.${prop}`);
697
+ return makePermissiveChildProxy();
698
+ },
699
+ apply() {
700
+ return makePermissiveChildProxy();
701
+ },
702
+ has() {
703
+ return true;
704
+ },
705
+ ownKeys() {
706
+ return [];
707
+ },
708
+ getOwnPropertyDescriptor() {
709
+ return { configurable: true, enumerable: true, value: undefined };
710
+ },
711
+ });
712
+ }
713
+ /**
714
+ * Totally-permissive child proxy — swallows every access / invocation,
715
+ * returns itself. Used for depth-2+ of compute tracing where we don't track.
716
+ */
717
+ function makePermissiveChildProxy() {
718
+ const self = new Proxy((() => { }), {
719
+ get(_target, prop) {
720
+ if (prop === Symbol.toPrimitive)
721
+ return () => "";
722
+ if (prop === "toString")
723
+ return () => "";
724
+ if (prop === "valueOf")
725
+ return () => 0;
726
+ if (prop === Symbol.iterator)
727
+ return function* () { };
728
+ if (prop === "length")
729
+ return 0;
730
+ return self;
731
+ },
732
+ apply() {
733
+ return self;
734
+ },
735
+ has() {
736
+ return true;
737
+ },
738
+ ownKeys() {
739
+ return [];
740
+ },
741
+ getOwnPropertyDescriptor() {
742
+ return { configurable: true, enumerable: true, value: undefined };
743
+ },
744
+ });
745
+ return self;
746
+ }
747
+ //# sourceMappingURL=contract-core.js.map