@nmnmcc/ability 2.0.7 → 2.1.2

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 (87) hide show
  1. package/README.md +52 -29
  2. package/dist/Authorizer.d.ts +21 -12
  3. package/dist/Authorizer.d.ts.map +1 -1
  4. package/dist/Authorizer.js.map +1 -1
  5. package/dist/Condition.d.ts +121 -19
  6. package/dist/Condition.d.ts.map +1 -1
  7. package/dist/Condition.js +32 -21
  8. package/dist/Condition.js.map +1 -1
  9. package/dist/Domain.d.ts +9 -0
  10. package/dist/Domain.d.ts.map +1 -1
  11. package/dist/Domain.js +5 -0
  12. package/dist/Domain.js.map +1 -1
  13. package/dist/Limits.d.ts +44 -13
  14. package/dist/Limits.d.ts.map +1 -1
  15. package/dist/Limits.js +52 -22
  16. package/dist/Limits.js.map +1 -1
  17. package/dist/Policy.d.ts +102 -18
  18. package/dist/Policy.d.ts.map +1 -1
  19. package/dist/Policy.js +7 -6
  20. package/dist/Policy.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/internal/algebra.js +2 -2
  23. package/dist/internal/analysis.js +81 -75
  24. package/dist/internal/analysis.js.map +1 -1
  25. package/dist/internal/authorizer.d.ts.map +1 -1
  26. package/dist/internal/authorizer.js +11 -7
  27. package/dist/internal/authorizer.js.map +1 -1
  28. package/dist/internal/bind.js +86 -64
  29. package/dist/internal/bind.js.map +1 -1
  30. package/dist/internal/catalog.d.ts +33 -10
  31. package/dist/internal/catalog.d.ts.map +1 -1
  32. package/dist/internal/catalog.js +170 -77
  33. package/dist/internal/catalog.js.map +1 -1
  34. package/dist/internal/decide.js +27 -31
  35. package/dist/internal/decide.js.map +1 -1
  36. package/dist/internal/domain.js +19 -5
  37. package/dist/internal/domain.js.map +1 -1
  38. package/dist/internal/json.js +120 -76
  39. package/dist/internal/json.js.map +1 -1
  40. package/dist/internal/limits.d.ts +28 -0
  41. package/dist/internal/limits.d.ts.map +1 -1
  42. package/dist/internal/limits.js +75 -16
  43. package/dist/internal/limits.js.map +1 -1
  44. package/dist/internal/nodes.d.ts.map +1 -1
  45. package/dist/internal/nodes.js +53 -44
  46. package/dist/internal/nodes.js.map +1 -1
  47. package/dist/internal/patterns.js +50 -15
  48. package/dist/internal/patterns.js.map +1 -1
  49. package/dist/internal/policy-json.js +169 -127
  50. package/dist/internal/policy-json.js.map +1 -1
  51. package/dist/internal/policy-nodes.d.ts.map +1 -1
  52. package/dist/internal/policy-nodes.js +16 -13
  53. package/dist/internal/policy-nodes.js.map +1 -1
  54. package/dist/internal/policy.d.ts.map +1 -1
  55. package/dist/internal/policy.js +38 -22
  56. package/dist/internal/policy.js.map +1 -1
  57. package/dist/internal/resource.js +1 -1
  58. package/dist/internal/rule.d.ts.map +1 -1
  59. package/dist/internal/rule.js +6 -5
  60. package/dist/internal/rule.js.map +1 -1
  61. package/dist/internal/search.d.ts.map +1 -1
  62. package/dist/internal/search.js +106 -92
  63. package/dist/internal/search.js.map +1 -1
  64. package/dist/internal/trampoline.js +19 -5
  65. package/dist/internal/trampoline.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/Authorizer.ts +30 -20
  68. package/src/Condition.ts +317 -86
  69. package/src/Domain.ts +9 -0
  70. package/src/Limits.ts +69 -29
  71. package/src/Policy.ts +143 -21
  72. package/src/internal/analysis.ts +154 -124
  73. package/src/internal/authorizer.ts +29 -14
  74. package/src/internal/bind.ts +193 -136
  75. package/src/internal/catalog.ts +320 -118
  76. package/src/internal/decide.ts +61 -50
  77. package/src/internal/domain.ts +39 -3
  78. package/src/internal/json.ts +212 -117
  79. package/src/internal/limits.ts +101 -31
  80. package/src/internal/nodes.ts +65 -47
  81. package/src/internal/patterns.ts +79 -25
  82. package/src/internal/policy-json.ts +266 -197
  83. package/src/internal/policy-nodes.ts +31 -23
  84. package/src/internal/policy.ts +63 -23
  85. package/src/internal/rule.ts +16 -3
  86. package/src/internal/search.ts +186 -136
  87. package/src/internal/trampoline.ts +51 -4
@@ -15,10 +15,12 @@ import * as Condition from "../Condition.ts"
15
15
  import type * as Domain from "../Domain.ts"
16
16
  import type * as Policy from "../Policy.ts"
17
17
  import * as Bind from "./bind.ts"
18
+ import * as Limits from "./limits.ts"
18
19
  import * as Nodes from "./nodes.ts"
19
20
  import * as PolicyJson from "./policy-json.ts"
20
21
  import * as PolicyNodes from "./policy-nodes.ts"
21
22
  import * as RuleData from "./rule.ts"
23
+ import * as Trampoline from "./trampoline.ts"
22
24
 
23
25
  type Any = Policy.Any
24
26
  type Node = Policy.Node
@@ -60,8 +62,24 @@ export class PolicyData<
60
62
  /** The node of a policy, with no types, for the code below. */
61
63
  export const nodeOf = (policy: Any): PolicyNodes.Node => policy.node as never
62
64
 
63
- export const fromNode = (domain: Domain.Any, node: PolicyNodes.Node): any =>
64
- new PolicyData({ domain, node })
65
+ /**
66
+ * Makes a policy of a domain from its tree. The constructors of the nodes
67
+ * check the ceiling of `policyNodes`, since a node does not know its domain,
68
+ * so this checks the limit of the domain.
69
+ */
70
+ export const fromNode = (domain: Domain.Any, node: PolicyNodes.Node): any => {
71
+ const most = domain.limits.policyNodes
72
+ if (node.size > most) {
73
+ throw Limits.exceeded(
74
+ "A policy tree",
75
+ "policyNodes",
76
+ "nodes",
77
+ node.size,
78
+ most,
79
+ )
80
+ }
81
+ return new PolicyData({ domain, node })
82
+ }
65
83
 
66
84
  /**
67
85
  * The error when a rule does not fit its domain. It has one issue for each
@@ -191,28 +209,50 @@ const provideNode = (
191
209
  }),
192
210
  ]),
193
211
  )
194
- const provide = (node: PolicyNodes.Node): PolicyNodes.Node => {
195
- if (!node.opaque) {
196
- return node
197
- }
198
- switch (node._tag) {
199
- case "Rules":
200
- return new PolicyNodes.Rules({
201
- algorithm: node.algorithm,
202
- rules: Arr.map(node.rules, (value) => provided.get(value) ?? value),
203
- })
204
- case "All":
205
- case "Any":
206
- case "FirstOf":
207
- return new PolicyNodes[node._tag](Arr.map(node.policies, provide))
208
- case "Ceiling":
209
- return new PolicyNodes.Ceiling({
210
- cap: provide(node.cap),
211
- policy: provide(node.policy),
212
- })
212
+ // The walk of the tree takes the steps of a trampoline, so a deep tree
213
+ // does not fill the stack.
214
+ return Trampoline.run(root.depth, (R) => {
215
+ const provide = (
216
+ node: PolicyNodes.Node,
217
+ ): Trampoline.Step<PolicyNodes.Node> => {
218
+ if (!node.opaque) {
219
+ return R.succeed(node)
220
+ }
221
+ switch (node._tag) {
222
+ case "Rules":
223
+ return R.succeed(
224
+ new PolicyNodes.Rules({
225
+ algorithm: node.algorithm,
226
+ rules: Arr.map(
227
+ node.rules,
228
+ (value) => provided.get(value) ?? value,
229
+ ),
230
+ }),
231
+ )
232
+ case "All":
233
+ case "Any":
234
+ case "FirstOf": {
235
+ const tag = node._tag
236
+ return R.map(
237
+ R.forEach(node.policies, (child) =>
238
+ R.suspend(() => provide(child)),
239
+ ),
240
+ (policies) => new PolicyNodes[tag](policies),
241
+ )
242
+ }
243
+ case "Ceiling":
244
+ return R.flatMap(
245
+ R.suspend(() => provide(node.cap)),
246
+ (cap) =>
247
+ R.map(
248
+ R.suspend(() => provide(node.policy)),
249
+ (policy) => new PolicyNodes.Ceiling({ cap, policy }),
250
+ ),
251
+ )
252
+ }
213
253
  }
214
- }
215
- return provide(root)
254
+ return provide(root)
255
+ })
216
256
  }
217
257
 
218
258
  /** Gives the services in `context` to every predicate of a policy. */
@@ -12,6 +12,7 @@ import type * as Domain from "../Domain.ts"
12
12
  import type * as Policy from "../Policy.ts"
13
13
  import type * as Resource from "../Resource.ts"
14
14
  import * as Catalog from "./catalog.ts"
15
+ import * as Limits from "./limits.ts"
15
16
  import * as Nodes from "./nodes.ts"
16
17
  import * as ResourceData from "./resource.ts"
17
18
  import * as Patterns from "./patterns.ts"
@@ -167,6 +168,7 @@ export const matchesInside = (pattern: string, field: string): boolean =>
167
168
  */
168
169
  export const fitsOf = (
169
170
  catalog: Catalog.Catalog,
171
+ parts: number,
170
172
  ): ((pattern: string) => boolean) => {
171
173
  // A native `Map`: the hash of a string in Effect has no random seed, and a
172
174
  // policy could hold very many patterns with one hash. The engine hashes a
@@ -177,7 +179,11 @@ export const fitsOf = (
177
179
  if (known !== undefined) {
178
180
  return known
179
181
  }
180
- const found = Patterns.fitsCatalog(Patterns.parseBound(pattern), catalog)
182
+ const found = Patterns.fitsCatalog(
183
+ Patterns.parseBound(pattern),
184
+ catalog,
185
+ parts,
186
+ )
181
187
  memo.set(pattern, found)
182
188
  return found
183
189
  }
@@ -205,6 +211,8 @@ export const matchesTarget = (
205
211
  readonly descriptor?: Resource.Descriptor | undefined
206
212
  /** The test of {@link fitsOf} for the catalog of the descriptor. */
207
213
  readonly fits?: ((pattern: string) => boolean) | undefined
214
+ /** The most parts of a field path in the domain. */
215
+ readonly parts?: number | undefined
208
216
  },
209
217
  ): boolean => {
210
218
  if (!rule.actions.includes("*") && !rule.actions.includes(target.action)) {
@@ -226,7 +234,11 @@ export const matchesTarget = (
226
234
  return (
227
235
  rule._tag === "Allow" &&
228
236
  (descriptor === undefined ||
229
- Arr.some(rule.fields, target.fits ?? fitsOf(descriptor.catalog)))
237
+ Arr.some(
238
+ rule.fields,
239
+ target.fits ??
240
+ fitsOf(descriptor.catalog, target.parts ?? Limits.fieldPathParts),
241
+ ))
230
242
  )
231
243
  }
232
244
  const field = target.field
@@ -236,7 +248,8 @@ export const matchesTarget = (
236
248
  const open =
237
249
  rule._tag === "Deny" &&
238
250
  target.descriptor !== undefined &&
239
- (Catalog.lookup(target.descriptor.catalog, field)?.open ?? false)
251
+ (Catalog.lookup(target.descriptor.catalog, field, target.parts)?.open ??
252
+ false)
240
253
  return rule.fields.some(
241
254
  (pattern) =>
242
255
  matchesField(pattern, field) || (open && matchesInside(pattern, field)),
@@ -17,6 +17,7 @@ import * as Algebra from "./algebra.ts"
17
17
  import * as Catalog from "./catalog.ts"
18
18
  import * as Limits from "./limits.ts"
19
19
  import * as Nodes from "./nodes.ts"
20
+ import * as Trampoline from "./trampoline.ts"
20
21
 
21
22
  type Node = Nodes.Node
22
23
 
@@ -279,51 +280,65 @@ const testFixed = (
279
280
  }
280
281
 
281
282
  /** Puts one value into a field, and into the fields below it. */
282
- const fix = (
283
- node: Node,
284
- field: string,
285
- value: unknown,
286
- search: Search,
287
- ): Node => {
288
- const fixChild = (child: Node) => fix(child, field, value, search)
289
- switch (node._tag) {
290
- case "Compare":
291
- case "In":
292
- case "Exists": {
293
- const known = fixedValue(node.field, field, value)
294
- return known === free
295
- ? node
296
- : testFixed(node, known, search)
297
- ? Nodes.always
298
- : Nodes.never
283
+ const fix = (root: Node, field: string, value: unknown, search: Search): Node =>
284
+ // The walks of a condition take the steps of a trampoline, so a deep
285
+ // condition does not fill the stack.
286
+ Trampoline.run(root.depth, (R) => {
287
+ const go = (node: Node): Trampoline.Step<Node> => {
288
+ const children = (nodes: ReadonlyArray<Node>) =>
289
+ R.forEach(nodes, (child) => R.suspend(() => go(child)))
290
+ switch (node._tag) {
291
+ case "Compare":
292
+ case "In":
293
+ case "Exists": {
294
+ const known = fixedValue(node.field, field, value)
295
+ return R.succeed(
296
+ known === free
297
+ ? node
298
+ : testFixed(node, known, search)
299
+ ? Nodes.always
300
+ : Nodes.never,
301
+ )
302
+ }
303
+ case "And":
304
+ return R.map(children(node.conditions), Algebra.and)
305
+ case "Or":
306
+ return R.map(children(node.conditions), Algebra.or)
307
+ case "Not":
308
+ return R.map(
309
+ R.suspend(() => go(node.condition)),
310
+ Algebra.not,
311
+ )
312
+ default:
313
+ return R.succeed(node)
314
+ }
299
315
  }
300
- case "And":
301
- return Algebra.and(Arr.map(node.conditions, fixChild))
302
- case "Or":
303
- return Algebra.or(Arr.map(node.conditions, fixChild))
304
- case "Not":
305
- return node.condition.pipe(fixChild, Algebra.not)
306
- default:
307
- return node
308
- }
309
- }
316
+ return go(root)
317
+ })
310
318
 
311
319
  /** The fields of the tests of a condition, once for each test. */
312
- const testedFields = (node: Node): ReadonlyArray<string> => {
313
- switch (node._tag) {
314
- case "Compare":
315
- case "In":
316
- case "Exists":
317
- return [node.field]
318
- case "And":
319
- case "Or":
320
- return Arr.flatMap(node.conditions, testedFields)
321
- case "Not":
322
- return testedFields(node.condition)
323
- default:
324
- return []
325
- }
326
- }
320
+ const testedFields = (root: Node): ReadonlyArray<string> =>
321
+ Trampoline.run(root.depth, (R) => {
322
+ const go = (node: Node): Trampoline.Step<ReadonlyArray<string>> => {
323
+ switch (node._tag) {
324
+ case "Compare":
325
+ case "In":
326
+ case "Exists":
327
+ return R.succeed([node.field])
328
+ case "And":
329
+ case "Or":
330
+ return R.map(
331
+ R.forEach(node.conditions, (child) => R.suspend(() => go(child))),
332
+ Arr.flatten,
333
+ )
334
+ case "Not":
335
+ return R.suspend(() => go(node.condition))
336
+ default:
337
+ return R.succeed([])
338
+ }
339
+ }
340
+ return go(root)
341
+ })
327
342
 
328
343
  /**
329
344
  * Picks the next field to split on. A field comes before the fields below it,
@@ -353,59 +368,66 @@ const nextField = (node: Node): string => {
353
368
  * one field at a time, and tries one value from each cell of the field. Each
354
369
  * step reads the whole condition, so the budget counts its size.
355
370
  */
356
- const searchNode = (
357
- node: Node,
358
- search: Search,
359
- state: SearchState,
360
- assignment: ReadonlyArray<readonly [string, unknown]>,
361
- ): Found => {
362
- if (node._tag === "True") {
363
- return { assignment: Option.some(assignment), state }
364
- }
365
- if (node._tag === "False") {
366
- return { assignment: Option.none(), state }
367
- }
368
- // The key reads at most a small part of the node: a node with very many
369
- // nodes, or with very many values in its `In` tests, is its own key. A
370
- // step reads the nodes but not the values, which `membersOf` keeps, so the
371
- // budget counts the size.
372
- const key = Algebra.keyOf(node)
373
- if (search.failed.has(key)) {
374
- return { assignment: Option.none(), state }
375
- }
376
- if (state.steps + node.size > search.budget) {
377
- throw new OutOfBudget()
378
- }
379
- const field = nextField(node)
380
- // The cells are tried in order, until one gives values. A fold over the
381
- // cells does no work after a found answer, and it does not grow the stack
382
- // with the number of cells.
383
- const result = Arr.reduce(
384
- search.cells(field),
385
- {
386
- assignment: Option.none(),
387
- state: { ...state, steps: state.steps + node.size },
388
- } as Found,
389
- (before, cell): Found => {
390
- if (Option.isSome(before.assignment)) {
391
- return before
392
- }
393
- const steps = before.state.steps + node.size
394
- if (steps > search.budget) {
395
- throw new OutOfBudget()
396
- }
397
- return searchNode(
398
- fix(node, field, cell, search),
399
- search,
400
- { ...before.state, steps },
401
- [...assignment, [field, cell]],
402
- )
403
- },
404
- )
405
- if (Option.isNone(result.assignment)) {
406
- search.failed.add(key)
371
+ const searchNode = (search: Search) => (R: Trampoline.Runner) => {
372
+ const go = (
373
+ node: Node,
374
+ state: SearchState,
375
+ assignment: ReadonlyArray<readonly [string, unknown]>,
376
+ ): Trampoline.Step<Found> => {
377
+ if (node._tag === "True") {
378
+ return R.succeed({ assignment: Option.some(assignment), state })
379
+ }
380
+ if (node._tag === "False") {
381
+ return R.succeed({ assignment: Option.none(), state })
382
+ }
383
+ // The key reads at most a small part of the node: a node with very
384
+ // many nodes, or with very many values in its `In` tests, is its own
385
+ // key. A step reads the nodes but not the values, which `membersOf`
386
+ // keeps, so the budget counts the size.
387
+ const key = Algebra.keyOf(node)
388
+ if (search.failed.has(key)) {
389
+ return R.succeed({ assignment: Option.none(), state })
390
+ }
391
+ if (state.steps + node.size > search.budget) {
392
+ throw new OutOfBudget()
393
+ }
394
+ const field = nextField(node)
395
+ // The cells are tried in order, until one gives values. A fold over
396
+ // the cells does no work after a found answer, and it does not grow
397
+ // the stack with the number of cells. Each call for the next field
398
+ // is a step of the runner, so very many fields do not fill the stack.
399
+ return R.map(
400
+ R.reduce(
401
+ search.cells(field),
402
+ {
403
+ assignment: Option.none(),
404
+ state: { ...state, steps: state.steps + node.size },
405
+ } as Found,
406
+ (before, cell): Trampoline.Step<Found> => {
407
+ if (Option.isSome(before.assignment)) {
408
+ return R.succeed(before)
409
+ }
410
+ const steps = before.state.steps + node.size
411
+ if (steps > search.budget) {
412
+ throw new OutOfBudget()
413
+ }
414
+ return R.suspend(() =>
415
+ go(fix(node, field, cell, search), { ...before.state, steps }, [
416
+ ...assignment,
417
+ [field, cell],
418
+ ]),
419
+ )
420
+ },
421
+ ),
422
+ (result) => {
423
+ if (Option.isNone(result.assignment)) {
424
+ search.failed.add(key)
425
+ }
426
+ return result
427
+ },
428
+ )
407
429
  }
408
- return result
430
+ return go
409
431
  }
410
432
 
411
433
  /**
@@ -413,28 +435,40 @@ const searchNode = (
413
435
  * parts (`opaque`) becomes an empty object, and it keeps an object that is
414
436
  * there.
415
437
  */
438
+ type Fields = Readonly<Record<string, unknown>>
439
+
416
440
  const setPath = (
417
- root: Readonly<Record<string, unknown>>,
441
+ root: Fields,
418
442
  names: ReadonlyArray<string>,
419
443
  value: unknown,
420
- ): Readonly<Record<string, unknown>> => {
421
- const [name, ...rest] = names
422
- if (name === undefined) {
444
+ ): Fields => {
445
+ const last = names[names.length - 1]
446
+ if (last === undefined) {
423
447
  return root
424
448
  }
425
449
  // Only an own field counts, as a condition reads it. An inherited one,
426
450
  // such as `__proto__`, is not a field of the example.
427
- const inner = Object.hasOwn(root, name) ? root[name] : undefined
428
- if (rest.length === 0) {
429
- return value === opaque && isObject(inner)
430
- ? root
431
- : { ...root, [name]: value === opaque ? {} : value }
432
- }
433
- const base =
434
- isObject(inner) && inner !== opaque
435
- ? (inner as Readonly<Record<string, unknown>>)
436
- : {}
437
- return { ...root, [name]: setPath(base, rest, value) }
451
+ const own = (base: Fields, name: string): unknown =>
452
+ Object.hasOwn(base, name) ? base[name] : undefined
453
+ // The object at each part of the path, from the root down to the object
454
+ // that gets the value. A part that is not an object becomes empty. The
455
+ // path is walked in a loop, so a long path does not fill the stack.
456
+ const bases = Arr.scan(names.slice(0, -1), root, (base, name): Fields => {
457
+ const inner = own(base, name)
458
+ return isObject(inner) && inner !== opaque ? (inner as Fields) : {}
459
+ })
460
+ const target = bases[bases.length - 1]!
461
+ const inner = own(target, last)
462
+ const placed: Fields =
463
+ value === opaque && isObject(inner)
464
+ ? target
465
+ : { ...target, [last]: value === opaque ? {} : value }
466
+ // Each object above gets the new object below it, from the bottom up.
467
+ return Arr.reduceRight(
468
+ names.slice(0, -1),
469
+ placed,
470
+ (below, name, index): Fields => ({ ...bases[index]!, [name]: below }),
471
+ )
438
472
  }
439
473
 
440
474
  const exampleOf = (
@@ -452,31 +486,42 @@ const exampleOf = (
452
486
  * belongs to one search.
453
487
  */
454
488
  const constantsOf = (node: Node): Map<string, Set<Nodes.Scalar>> => {
455
- const pairs = (
456
- current: Node,
457
- ): ReadonlyArray<readonly [string, ReadonlyArray<Nodes.Scalar>]> => {
458
- switch (current._tag) {
459
- case "Compare":
460
- return [[current.field, [literalOf(current.value)]]]
461
- case "In":
462
- return [[current.field, Arr.map(current.values, literalOf)]]
463
- case "Exists":
464
- return [[current.field, []]]
465
- case "Under":
466
- throw new Error(
467
- "Condition.satisfiable needs a condition with no Under nodes. Call Condition.partial first.",
468
- )
469
- case "And":
470
- case "Or":
471
- return Arr.flatMap(current.conditions, pairs)
472
- case "Not":
473
- return pairs(current.condition)
474
- default:
475
- return []
489
+ type Pair = readonly [string, ReadonlyArray<Nodes.Scalar>]
490
+ // The walk takes the steps of a trampoline, so a deep condition does not
491
+ // fill the stack.
492
+ const pairs = Trampoline.run(node.depth, (R) => {
493
+ const go = (current: Node): Trampoline.Step<ReadonlyArray<Pair>> => {
494
+ switch (current._tag) {
495
+ case "Compare":
496
+ return R.succeed([[current.field, [literalOf(current.value)]]])
497
+ case "In":
498
+ return R.succeed([
499
+ [current.field, Arr.map(current.values, literalOf)],
500
+ ])
501
+ case "Exists":
502
+ return R.succeed([[current.field, []]])
503
+ case "Under":
504
+ throw new Error(
505
+ "Condition.satisfiable needs a condition with no Under nodes. Call Condition.partial first.",
506
+ )
507
+ case "And":
508
+ case "Or":
509
+ return R.map(
510
+ R.forEach(current.conditions, (child) =>
511
+ R.suspend(() => go(child)),
512
+ ),
513
+ Arr.flatten,
514
+ )
515
+ case "Not":
516
+ return R.suspend(() => go(current.condition))
517
+ default:
518
+ return R.succeed([])
519
+ }
476
520
  }
477
- }
521
+ return go(node)
522
+ })
478
523
  const all = new Map<string, Set<Nodes.Scalar>>()
479
- Arr.forEach(pairs(node), ([field, values]) => {
524
+ Arr.forEach(pairs, ([field, values]) => {
480
525
  const known = all.get(field) ?? new Set<Nodes.Scalar>()
481
526
  Arr.forEach(values, (value) => known.add(value))
482
527
  all.set(field, known)
@@ -486,9 +531,9 @@ const constantsOf = (node: Node): Map<string, Set<Nodes.Scalar>> => {
486
531
 
487
532
  /** Gives the kinds of each field of a catalog. A field that it does not have may hold any value. */
488
533
  export const kindsIn =
489
- (catalog: Catalog.Catalog | undefined) =>
534
+ (catalog: Catalog.Catalog | undefined, parts?: number) =>
490
535
  (field: string): Catalog.Kinds =>
491
- (catalog === undefined ? undefined : Catalog.lookup(catalog, field))
536
+ (catalog === undefined ? undefined : Catalog.lookup(catalog, field, parts))
492
537
  ?.kinds ?? Catalog.anyKinds
493
538
 
494
539
  /**
@@ -500,13 +545,14 @@ export const satisfiable = (
500
545
  node: Node,
501
546
  kindsOf: (field: string) => Catalog.Kinds,
502
547
  budget: number = Limits.searchSteps,
548
+ fields: number = Limits.searchFields,
503
549
  ): Satisfiability => {
504
550
  if (node.opaque) {
505
551
  return satisfiability.Unknown({ reason: "predicate" })
506
552
  }
507
553
  const constants = constantsOf(node)
508
554
  // The search calls itself one time for each field that it fixes.
509
- if (constants.size > Limits.searchFields) {
555
+ if (constants.size > fields) {
510
556
  return satisfiability.Unknown({ reason: "budget" })
511
557
  }
512
558
  // The cells of each field, in a native `Map` as the constants are.
@@ -532,7 +578,11 @@ export const satisfiable = (
532
578
  budget,
533
579
  }
534
580
  try {
535
- const found = searchNode(Algebra.simplify(node), search, { steps: 0 }, [])
581
+ // The search goes one call deeper for each field, so the runner is the
582
+ // one for that many levels.
583
+ const found = Trampoline.run(constants.size, (R) =>
584
+ searchNode(search)(R)(Algebra.simplify(node), { steps: 0 }, []),
585
+ )
536
586
  return Option.match(found.assignment, {
537
587
  onNone: () => satisfiability.Unsatisfiable(),
538
588
  onSome: (assignment) =>
@@ -41,6 +41,15 @@ export interface Runner {
41
41
  items: ReadonlyArray<A>,
42
42
  f: (item: A, index: number) => Step<B>,
43
43
  ) => Step<ReadonlyArray<B>>
44
+ /**
45
+ * Runs a step for each item, in order, with the value of the step before.
46
+ * It does not grow the stack with the number of items.
47
+ */
48
+ readonly reduce: <A, B>(
49
+ items: ReadonlyArray<A>,
50
+ initial: B,
51
+ f: (before: B, item: A) => Step<B>,
52
+ ) => Step<B>
44
53
  /** Runs a step for each item, in order, up to the first `true`. */
45
54
  readonly some: <A>(
46
55
  items: ReadonlyArray<A>,
@@ -58,15 +67,18 @@ const value = <A>(step: Step<A>): A => step as unknown as A
58
67
  const step = <A>(value: A): Step<A> => value as unknown as Step<A>
59
68
 
60
69
  /** Runs each step at once, on the stack. */
70
+ // A step of `direct` is its value, so a function that gives a step gives a
71
+ // value too. The runner passes such a function on with no wrap: a wrap for
72
+ // each item would be a call more for each node of a walk.
61
73
  export const direct: Runner = {
62
74
  succeed: step,
63
75
  suspend: (make) => make(),
64
76
  map: (from, f) => step(f(value(from))),
65
77
  flatMap: (from, f) => f(value(from)),
66
- forEach: (items, f) =>
67
- step(Arr.map(items, (item, index) => value(f(item, index)))),
68
- some: (items, f) => step(Arr.some(items, (item) => value(f(item)))),
69
- every: (items, f) => step(Arr.every(items, (item) => value(f(item)))),
78
+ forEach: (items, f) => step(Arr.map(items, f as never)),
79
+ reduce: (items, initial, f) => step(Arr.reduce(items, initial, f as never)),
80
+ some: (items, f) => step(Arr.some(items, f as never)),
81
+ every: (items, f) => step(Arr.every(items, f as never)),
70
82
  }
71
83
 
72
84
  // A step of `loop` is an Effect with no error and no services. A failure of a
@@ -84,6 +96,14 @@ export const loop: Runner = {
84
96
  fromEffect(Effect.flatMap(effect(from), (value) => effect(f(value)))),
85
97
  forEach: (items, f) =>
86
98
  fromEffect(Effect.forEach(items, (item, index) => effect(f(item, index)))),
99
+ reduce: (items, initial, f) =>
100
+ fromEffect(
101
+ Effect.reduce(
102
+ items,
103
+ () => initial,
104
+ (before, item) => effect(f(before, item)),
105
+ ),
106
+ ),
87
107
  some: (items, f) =>
88
108
  fromEffect(
89
109
  Effect.map(
@@ -140,6 +160,33 @@ export const runWith = <A>(
140
160
  })
141
161
  }
142
162
 
163
+ /**
164
+ * Makes a walk of an input with an unknown depth, as the input of a decoder.
165
+ * It runs with `direct` while the input is low. A part more than
166
+ * `directDepth` levels down runs with `loop`, and the walk stays in the loop
167
+ * below it, so it goes into the loop one time on each path. `walk` gets the
168
+ * runner, and the call for a child at the next level: `child(item, depth)`.
169
+ */
170
+ export const adaptive = <I, A>(
171
+ walk: (
172
+ R: Runner,
173
+ child: (item: I, depth: number) => Step<A>,
174
+ ) => (item: I, depth: number) => Step<A>,
175
+ ): ((item: I) => A) => {
176
+ const inLoop: (item: I, depth: number) => Step<A> = walk(
177
+ loop,
178
+ (item, depth) => loop.suspend(() => inLoop(item, depth)),
179
+ )
180
+ const onStack: (item: I, depth: number) => Step<A> = walk(
181
+ direct,
182
+ (item, depth) =>
183
+ depth > directDepth
184
+ ? step(runWith(loop, () => inLoop(item, depth)))
185
+ : onStack(item, depth),
186
+ )
187
+ return (item) => value(onStack(item, 1))
188
+ }
189
+
143
190
  /**
144
191
  * Gives the Effect of a step of `loop`, so a walk can go on in an Effect
145
192
  * that runs a predicate.