@nmnmcc/ability 2.0.1 → 2.0.4

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 (48) hide show
  1. package/README.md +25 -17
  2. package/dist/Condition.d.ts +26 -16
  3. package/dist/Condition.d.ts.map +1 -1
  4. package/dist/Condition.js +2 -1
  5. package/dist/Condition.js.map +1 -1
  6. package/dist/Limits.d.ts +3 -1
  7. package/dist/Limits.d.ts.map +1 -1
  8. package/dist/Limits.js +3 -1
  9. package/dist/Limits.js.map +1 -1
  10. package/dist/Policy.d.ts +6 -8
  11. package/dist/Policy.d.ts.map +1 -1
  12. package/dist/Policy.js +4 -2
  13. package/dist/Policy.js.map +1 -1
  14. package/dist/internal/algebra.js +23 -13
  15. package/dist/internal/algebra.js.map +1 -1
  16. package/dist/internal/analysis.js +4 -2
  17. package/dist/internal/analysis.js.map +1 -1
  18. package/dist/internal/authorizer.js +13 -7
  19. package/dist/internal/authorizer.js.map +1 -1
  20. package/dist/internal/bind.js +53 -27
  21. package/dist/internal/bind.js.map +1 -1
  22. package/dist/internal/catalog.d.ts +9 -0
  23. package/dist/internal/catalog.d.ts.map +1 -1
  24. package/dist/internal/catalog.js +37 -22
  25. package/dist/internal/catalog.js.map +1 -1
  26. package/dist/internal/decide.js +2 -1
  27. package/dist/internal/decide.js.map +1 -1
  28. package/dist/internal/place.js +19 -0
  29. package/dist/internal/place.js.map +1 -0
  30. package/dist/internal/policy-json.js +39 -39
  31. package/dist/internal/policy-json.js.map +1 -1
  32. package/dist/internal/rule.d.ts +20 -22
  33. package/dist/internal/rule.d.ts.map +1 -1
  34. package/dist/internal/rule.js +32 -4
  35. package/dist/internal/rule.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/Condition.ts +64 -50
  38. package/src/Limits.ts +3 -1
  39. package/src/Policy.ts +5 -9
  40. package/src/internal/algebra.ts +30 -13
  41. package/src/internal/analysis.ts +5 -0
  42. package/src/internal/authorizer.ts +17 -7
  43. package/src/internal/bind.ts +85 -23
  44. package/src/internal/catalog.ts +78 -30
  45. package/src/internal/decide.ts +3 -0
  46. package/src/internal/place.ts +32 -0
  47. package/src/internal/policy-json.ts +49 -29
  48. package/src/internal/rule.ts +55 -11
@@ -16,6 +16,7 @@ import * as Catalog from "./catalog.ts"
16
16
  import * as Limits from "./limits.ts"
17
17
  import * as Nodes from "./nodes.ts"
18
18
  import * as Patterns from "./patterns.ts"
19
+ import * as Place from "./place.ts"
19
20
  import { withSuggestion } from "./suggest.ts"
20
21
 
21
22
  /** One problem of a rule or a policy, with its path. */
@@ -44,12 +45,15 @@ interface Memo {
44
45
  readonly fits: MutableHashMap.MutableHashMap<string, boolean>
45
46
  /** The count of the issues of conditions so far. It belongs to one binding. */
46
47
  readonly issues: { count: number; noted: boolean }
48
+ /** The steps of the checks of patterns so far. It belongs to one binding. */
49
+ readonly work: { steps: number; noted: boolean }
47
50
  }
48
51
 
49
52
  const makeMemo = (): Memo => ({
50
53
  suggestions: MutableHashMap.empty(),
51
54
  fits: MutableHashMap.empty(),
52
55
  issues: { count: 0, noted: false },
56
+ work: { steps: 0, noted: false },
53
57
  })
54
58
 
55
59
  /**
@@ -173,6 +177,36 @@ const fieldIssue = (
173
177
  ]
174
178
  }
175
179
 
180
+ /**
181
+ * The issues of a field that a condition reads: a field that is not a field
182
+ * of every resource of the rule, or a field that is not plain data in one of
183
+ * them. A read of a value that is not plain data, as a getter of a class, is
184
+ * a defect in a decision. A residual may leave the read out, so the residual
185
+ * would say "yes" where the decision fails. So the binding does not take
186
+ * such a read: see `Catalog.Field.plain`.
187
+ */
188
+ const readIssues = (
189
+ context: Context,
190
+ field: string,
191
+ at: ReadonlyArray<PropertyKey>,
192
+ ): ReadonlyArray<Issue> => {
193
+ const exists = fieldIssue(context, field, at)
194
+ if (exists.length > 0) {
195
+ return exists
196
+ }
197
+ return Arr.some(
198
+ context.named,
199
+ (descriptor) => !(Catalog.lookup(descriptor.catalog, field)?.plain ?? true),
200
+ )
201
+ ? [
202
+ {
203
+ path: at,
204
+ issue: `A condition may not read the field "${field}". A value above it may be an object that does not have it as a data field, as a class with a getter, a date or a list. Use a predicate for it.`,
205
+ },
206
+ ]
207
+ : []
208
+ }
209
+
176
210
  /**
177
211
  * The issues of a field pattern: a pattern that does not parse, or that
178
212
  * matches no field of the resources of the rule, as a pattern with a mistake
@@ -186,24 +220,49 @@ const patternIssues = (
186
220
  ): ReadonlyArray<Issue> =>
187
221
  Result.match(Patterns.parse(pattern), {
188
222
  onFailure: (issue) => [{ path: at, issue }],
189
- onSuccess: (parsed) =>
190
- // A rule with no known resource has an issue for that already.
191
- known.length === 0 ||
192
- Arr.some(known, (descriptor) => {
193
- const key = `${descriptor.kind}\u0000${pattern}`
194
- return Option.getOrElse(MutableHashMap.get(memo.fits, key), () => {
195
- const fits = Patterns.fitsCatalog(parsed, descriptor.catalog)
196
- MutableHashMap.set(memo.fits, key, fits)
197
- return fits
223
+ onSuccess: (parsed) => {
224
+ // A rule with no known resource has an issue for that already. The
225
+ // pattern is checked on every kind, not only up to the first that it
226
+ // fits: a decision on a kind does this check, so the limit of the
227
+ // work below is a limit of the work of a decision too.
228
+ const fits =
229
+ known.length === 0 ||
230
+ Arr.reduce(known, false, (before, descriptor) => {
231
+ const key = `${descriptor.kind}\u0000${pattern}`
232
+ return (
233
+ Option.getOrElse(MutableHashMap.get(memo.fits, key), () => {
234
+ // Each check of a pattern reads the fields of a catalog. The
235
+ // work of one binding has a limit, so very many patterns stop
236
+ // with one issue, and the policy does not load.
237
+ memo.work.steps +=
238
+ descriptor.catalog.fields.length * Math.max(parsed.length, 1)
239
+ if (memo.work.steps > Limits.searchSteps) {
240
+ return true
241
+ }
242
+ const found = Patterns.fitsCatalog(parsed, descriptor.catalog)
243
+ MutableHashMap.set(memo.fits, key, found)
244
+ return found
245
+ }) || before
246
+ )
198
247
  })
199
- })
248
+ if (memo.work.steps > Limits.searchSteps && !memo.work.noted) {
249
+ memo.work.noted = true
250
+ return [
251
+ {
252
+ path: at,
253
+ issue: `The field patterns of the policy take more than ${String(Limits.searchSteps)} steps to check. The most is ${String(Limits.searchSteps)}.`,
254
+ },
255
+ ]
256
+ }
257
+ return fits
200
258
  ? []
201
259
  : [
202
260
  {
203
261
  path: at,
204
262
  issue: `The field pattern "${pattern}" matches no field of the resources of the rule`,
205
263
  },
206
- ],
264
+ ]
265
+ },
207
266
  })
208
267
 
209
268
  /** The fields of the resources that a test of the rule may read. */
@@ -448,7 +507,7 @@ const conditionIssues = (
448
507
  const here = (issues: ReadonlyArray<Issue>) => under(context.memo, at, issues)
449
508
  switch (node._tag) {
450
509
  case "Compare": {
451
- const exists = fieldIssue(context, node.field, ["field"])
510
+ const exists = readIssues(context, node.field, ["field"])
452
511
  return here(
453
512
  exists.length > 0
454
513
  ? exists
@@ -462,7 +521,7 @@ const conditionIssues = (
462
521
  )
463
522
  }
464
523
  case "In": {
465
- const exists = fieldIssue(context, node.field, ["field"])
524
+ const exists = readIssues(context, node.field, ["field"])
466
525
  return here(
467
526
  exists.length > 0
468
527
  ? exists
@@ -475,7 +534,7 @@ const conditionIssues = (
475
534
  )
476
535
  }
477
536
  case "Exists":
478
- return here(fieldIssue(context, node.field, ["field"]))
537
+ return here(readIssues(context, node.field, ["field"]))
479
538
  case "Under":
480
539
  return here(underIssues(context, node.prefix, []))
481
540
  case "And":
@@ -607,15 +666,18 @@ export const treeIssues = (
607
666
  // The rules come out of the tree first, and each one is checked after.
608
667
  // A check of a condition goes down its levels, so a check inside the walk
609
668
  // of the tree would go down the levels of both.
669
+ // The place of each rule shares the path of its parent, and the full path
670
+ // is made only when its rule is checked. A deep tree with very many rules
671
+ // would need very much memory for a full copy of the path of each rule.
610
672
  const rulesOf = (
611
673
  node: TreeNode,
612
- path: ReadonlyArray<PropertyKey>,
674
+ place: Place.Place | undefined,
613
675
  level: number,
614
- ): ReadonlyArray<Issue | readonly [RuleData, ReadonlyArray<PropertyKey>]> => {
676
+ ): ReadonlyArray<Issue | readonly [RuleData, Place.Place]> => {
615
677
  if (level > Limits.policyDepth) {
616
678
  return [
617
679
  {
618
- path,
680
+ path: Place.pathOf(place),
619
681
  issue: `A policy tree has more than ${String(Limits.policyDepth)} levels. The most is ${String(Limits.policyDepth)}.`,
620
682
  },
621
683
  ]
@@ -624,25 +686,25 @@ export const treeIssues = (
624
686
  case "Rules":
625
687
  return Arr.map(
626
688
  node.rules,
627
- (rule, index) => [rule, [...path, "rules", index]] as const,
689
+ (rule, index) => [rule, Place.at(place, "rules", index)] as const,
628
690
  )
629
691
  case "All":
630
692
  case "Any":
631
693
  case "FirstOf":
632
694
  return Arr.flatMap(node.policies, (child, index) =>
633
- rulesOf(child, [...path, "policies", index], level + 1),
695
+ rulesOf(child, Place.at(place, "policies", index), level + 1),
634
696
  )
635
697
  case "Ceiling":
636
698
  return [
637
- ...rulesOf(node.cap, [...path, "cap"], level + 1),
638
- ...rulesOf(node.policy, [...path, "policy"], level + 1),
699
+ ...rulesOf(node.cap, Place.at(place, "cap"), level + 1),
700
+ ...rulesOf(node.policy, Place.at(place, "policy"), level + 1),
639
701
  ]
640
702
  }
641
703
  }
642
704
  const memo = makeMemo()
643
- return Arr.flatMap(rulesOf(root, [], 1), (found) =>
705
+ return Arr.flatMap(rulesOf(root, undefined, 1), (found) =>
644
706
  Array.isArray(found)
645
- ? ruleIssues(domain, found[0], found[1], memo)
707
+ ? ruleIssues(domain, found[0], Place.pathOf(found[1]), memo)
646
708
  : [found as Issue],
647
709
  )
648
710
  }
@@ -129,6 +129,15 @@ export interface Field {
129
129
  readonly listed: boolean
130
130
  /** The field may hold parts that the Schema does not name, as a record may. */
131
131
  readonly open: boolean
132
+ /**
133
+ * A read of the field finds only plain data. It is `false` when a value
134
+ * above the field may be an object that does not have it as a data field:
135
+ * a class without the field, which may have a getter of that name, a
136
+ * value such as a date or a list, or any object, as of `Schema.Unknown`.
137
+ * A decode or a `make` copies a struct or a record into a plain object,
138
+ * so a struct without the field gives `null` for it.
139
+ */
140
+ readonly plain: boolean
132
141
  }
133
142
 
134
143
  /** A place in the tree of a catalog, with the places below it. */
@@ -185,8 +194,8 @@ const classStruct = (
185
194
  }
186
195
 
187
196
  /**
188
- * Gives the Schemas that a value at one place may fit: it opens unions,
189
- * `Schema.suspend` and classes. The open suspend nodes are in a native `Set`,
197
+ * Gives the Schemas that a value at one place may fit: it opens unions and
198
+ * `Schema.suspend`. A class stays a class: see {@link structOf}. The open suspend nodes are in a native `Set`,
190
199
  * because the walk must find a node by the node itself: `Equal` compares
191
200
  * Schema nodes by structure, and a recursive Schema has no end. A suspend
192
201
  * node that gives itself again, with no step on the path, gives nothing.
@@ -202,15 +211,19 @@ const alternatives = (
202
211
  return open.has(ast)
203
212
  ? []
204
213
  : alternatives(ast.thunk(), new Set([...open, ast]))
205
- case "Declaration": {
206
- const inner = classStruct(ast)
207
- return inner === undefined ? [ast] : alternatives(inner, open)
208
- }
209
214
  default:
210
215
  return [ast]
211
216
  }
212
217
  }
213
218
 
219
+ /** The struct of a Schema: a struct, or the struct of a class. */
220
+ const structOf = (ast: SchemaAST.AST): SchemaAST.Objects | undefined =>
221
+ ast._tag === "Objects"
222
+ ? ast
223
+ : ast._tag === "Declaration"
224
+ ? classStruct(ast)
225
+ : undefined
226
+
214
227
  const hasCheck = (
215
228
  checks: ReadonlyArray<SchemaAST.Check<never>>,
216
229
  id: string,
@@ -223,8 +236,10 @@ const hasCheck = (
223
236
  )
224
237
 
225
238
  /** A struct with named fields, or with the keys of a record. */
226
- const isStruct = (ast: SchemaAST.AST): ast is SchemaAST.Objects =>
227
- ast._tag === "Objects" &&
239
+ const isStruct = (
240
+ ast: SchemaAST.Objects | undefined,
241
+ ): ast is SchemaAST.Objects =>
242
+ ast !== undefined &&
228
243
  (ast.propertySignatures.length > 0 || ast.indexSignatures.length > 0)
229
244
 
230
245
  /** The kinds of the values of a Schema that is not a struct. */
@@ -326,6 +341,8 @@ interface Place {
326
341
  readonly missing: boolean
327
342
  /** The value may be anything, as an extra field of a struct may be. */
328
343
  readonly top: boolean
344
+ /** A read here finds only plain data: see {@link Field.plain}. */
345
+ readonly plain: boolean
329
346
  }
330
347
 
331
348
  const textOf = (path: ReadonlyArray<Segment>): string =>
@@ -333,16 +350,27 @@ const textOf = (path: ReadonlyArray<Segment>): string =>
333
350
  typeof segment === "string" ? segment : "*",
334
351
  ).join(".")
335
352
 
336
- /** Gives the property of a struct with a name, if it has one. */
337
- const propertyOf = (
353
+ /**
354
+ * The name of a property as a part of a path. A key of an object is a string,
355
+ * so a number key, as from `Schema.Record(Schema.Literal(1), ...)`, has the
356
+ * name `"1"`. A symbol key is not a field.
357
+ */
358
+ const nameOf = (signature: SchemaAST.PropertySignature): string | undefined =>
359
+ typeof signature.name === "symbol" ? undefined : String(signature.name)
360
+
361
+ /**
362
+ * The properties of a struct by name. A walk makes it one time for each
363
+ * struct of a place, so a struct with very many fields does not take a
364
+ * search for each name.
365
+ */
366
+ const propertiesOf = (
338
367
  struct: SchemaAST.Objects,
339
- name: string,
340
- ): SchemaAST.PropertySignature | undefined =>
341
- Option.getOrUndefined(
342
- Arr.findFirst(
343
- struct.propertySignatures,
344
- (signature) => signature.name === name,
345
- ),
368
+ ): HashMap.HashMap<string, SchemaAST.PropertySignature> =>
369
+ HashMap.fromIterable(
370
+ Arr.flatMap(struct.propertySignatures, (signature) => {
371
+ const name = nameOf(signature)
372
+ return name === undefined ? [] : [[name, signature] as const]
373
+ }),
346
374
  )
347
375
 
348
376
  /** The names of the fields of the structs, in the order that they first come. */
@@ -352,8 +380,8 @@ const namesOf = (
352
380
  Arr.dedupe(
353
381
  Arr.flatMap(structs, (struct) =>
354
382
  Arr.flatMap(struct.propertySignatures, (signature) => {
355
- const name = signature.name
356
- if (typeof name !== "string") {
383
+ const name = nameOf(signature)
384
+ if (name === undefined) {
357
385
  return []
358
386
  }
359
387
  // A dot in a path is the step into a nested field. A field name with
@@ -377,10 +405,22 @@ const namesOf = (
377
405
  * very many fields before the walk is done.
378
406
  */
379
407
  const branchOf = (place: Place, count: { size: number }): Branch => {
380
- const structs = Arr.filter(place.schemas, isStruct)
381
- const values = Arr.filter(place.schemas, (schema) => !isStruct(schema))
408
+ const structs = Arr.filter(Arr.map(place.schemas, structOf), isStruct)
409
+ // The structs of classes. A value of a class is not copied into a plain
410
+ // object, so it keeps its getters and methods.
411
+ const classes = Arr.filter(
412
+ Arr.map(
413
+ Arr.filter(place.schemas, (schema) => schema._tag === "Declaration"),
414
+ structOf,
415
+ ),
416
+ isStruct,
417
+ )
418
+ const values = Arr.filter(
419
+ place.schemas,
420
+ (schema) => !isStruct(structOf(schema)),
421
+ )
382
422
  const valueKinds = Arr.reduce(values, noKinds, (kinds, schema) =>
383
- joinKinds(kinds, kindsOf(schema)),
423
+ joinKinds(kinds, kindsOf(structOf(schema) ?? schema)),
384
424
  )
385
425
  const kinds = place.top
386
426
  ? anyKinds
@@ -412,6 +452,7 @@ const branchOf = (place: Place, count: { size: number }): Branch => {
412
452
  structs.length > 0 ||
413
453
  valueKinds.object ||
414
454
  place.top,
455
+ plain: place.plain,
415
456
  }
416
457
  if (field !== undefined) {
417
458
  count.size += 1
@@ -434,11 +475,12 @@ const branchOf = (place: Place, count: { size: number }): Branch => {
434
475
  keyBranch: undefined,
435
476
  }
436
477
  }
478
+ const structProperties = Arr.map(structs, propertiesOf)
479
+ const classProperties = Arr.map(classes, propertiesOf)
437
480
  const children = Arr.map(namesOf(structs), (name) => {
438
- const properties = Arr.flatMap(structs, (struct) => {
439
- const property = propertyOf(struct, name)
440
- return property === undefined ? [] : [property]
441
- })
481
+ const properties = Arr.flatMap(structProperties, (named) =>
482
+ Option.toArray(HashMap.get(named, name)),
483
+ )
442
484
  const below: Place = {
443
485
  path: [...place.path, name],
444
486
  schemas: Arr.flatMap(properties, (property) =>
@@ -454,10 +496,11 @@ const branchOf = (place: Place, count: { size: number }): Branch => {
454
496
  // with any value: only a decode takes extra fields away. A value
455
497
  // that may be any object, as one of `Schema.Unknown` may, may have
456
498
  // the name with any value too.
457
- top:
458
- place.top ||
459
- valueKinds.object ||
460
- Arr.some(structs, (struct) => propertyOf(struct, name) === undefined),
499
+ top: place.top || valueKinds.object || properties.length < structs.length,
500
+ plain:
501
+ place.plain &&
502
+ !valueKinds.object &&
503
+ Arr.every(classProperties, (named) => HashMap.has(named, name)),
461
504
  }
462
505
  return [name, branchOf(below, count)] as const
463
506
  })
@@ -479,6 +522,10 @@ const branchOf = (place: Place, count: { size: number }): Branch => {
479
522
  // A record may not have a given key.
480
523
  missing: true,
481
524
  top: place.top || valueKinds.object || records.length < structs.length,
525
+ plain:
526
+ place.plain &&
527
+ !valueKinds.object &&
528
+ Arr.every(classes, (struct) => struct.indexSignatures.length > 0),
482
529
  },
483
530
  count,
484
531
  )
@@ -502,6 +549,7 @@ export const catalogOf = (ast: SchemaAST.AST): Catalog => {
502
549
  schemas: alternatives(ast, new Set()),
503
550
  missing: false,
504
551
  top: false,
552
+ plain: true,
505
553
  },
506
554
  { size: 0 },
507
555
  )
@@ -37,6 +37,8 @@ interface DecideContext {
37
37
  readonly input: Condition.Input
38
38
  /** The resource of the kind, for the fields that a rule may grant. */
39
39
  readonly descriptor: Resource.Descriptor
40
+ /** The test of `RuleData.fitsOf` for the catalog, for this decision. */
41
+ readonly fits: (pattern: string) => boolean
40
42
  /** The rules that were tested, for {@link explain}. */
41
43
  readonly trace: Ref.Ref<Chunk.Chunk<Step>> | undefined
42
44
  }
@@ -282,6 +284,7 @@ const makeContext = (
282
284
  path: descriptor.path,
283
285
  },
284
286
  descriptor,
287
+ fits: RuleData.fitsOf(descriptor.catalog),
285
288
  trace,
286
289
  }
287
290
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The place of a part of an input, as a path that shares the path of its
3
+ * parent. A deep tree with very many rules would need very much memory for a
4
+ * full copy of the path of each rule, so a place keeps only its own parts and
5
+ * its parent. The full path is made only for a problem, in one walk up.
6
+ */
7
+ import * as Arr from "effect/Array"
8
+ import * as Option from "effect/Option"
9
+
10
+ /** A place: its own parts of the path, after the path of its parent. */
11
+ export interface Place {
12
+ readonly parent: Place | undefined
13
+ readonly parts: ReadonlyArray<PropertyKey>
14
+ }
15
+
16
+ /** The place below `parent` with more parts. */
17
+ export const at = (
18
+ parent: Place | undefined,
19
+ ...parts: ReadonlyArray<PropertyKey>
20
+ ): Place => ({ parent, parts })
21
+
22
+ /** The full path of a place. The root, with no place, has the empty path. */
23
+ export const pathOf = (place: Place | undefined): ReadonlyArray<PropertyKey> =>
24
+ Arr.flatten(
25
+ Arr.reverse(
26
+ Arr.unfold(place, (current) =>
27
+ current === undefined
28
+ ? Option.none()
29
+ : Option.some([current.parts, current.parent] as const),
30
+ ),
31
+ ),
32
+ )
@@ -12,6 +12,7 @@ import type * as Domain from "../Domain.ts"
12
12
  import * as Rule from "./rule.ts"
13
13
  import * as Json from "./json.ts"
14
14
  import * as Limits from "./limits.ts"
15
+ import * as Place from "./place.ts"
15
16
  import * as PolicyNodes from "./policy-nodes.ts"
16
17
 
17
18
  type Node = PolicyNodes.Node
@@ -213,10 +214,13 @@ const spendValue = (
213
214
  )
214
215
  }
215
216
 
216
- /** A rule in the input, with its place, to decode after the tree. */
217
+ /**
218
+ * A rule in the input, with its place, to decode after the tree. The place
219
+ * shares the path of its parent: see `Place`.
220
+ */
217
221
  interface RuleInput {
218
222
  readonly input: unknown
219
- readonly path: ReadonlyArray<PropertyKey>
223
+ readonly place: Place.Place
220
224
  }
221
225
 
222
226
  /**
@@ -227,20 +231,20 @@ interface RuleInput {
227
231
  type Plan =
228
232
  | {
229
233
  readonly _tag: "Rules"
230
- readonly path: ReadonlyArray<PropertyKey>
234
+ readonly place: Place.Place | undefined
231
235
  readonly size: number
232
236
  readonly algorithm: PolicyNodes.Algorithm
233
237
  readonly rules: ReadonlyArray<number>
234
238
  }
235
239
  | {
236
240
  readonly _tag: "All" | "Any" | "FirstOf"
237
- readonly path: ReadonlyArray<PropertyKey>
241
+ readonly place: Place.Place | undefined
238
242
  readonly size: number
239
243
  readonly policies: ReadonlyArray<Plan>
240
244
  }
241
245
  | {
242
246
  readonly _tag: "Ceiling"
243
- readonly path: ReadonlyArray<PropertyKey>
247
+ readonly place: Place.Place | undefined
244
248
  readonly size: number
245
249
  readonly cap: Plan
246
250
  readonly policy: Plan
@@ -252,11 +256,13 @@ type Plan =
252
256
  * the value, so its size counts in the budget.
253
257
  */
254
258
  const decodeMeta = (
255
- { input, path }: RuleInput,
259
+ rule: RuleInput,
256
260
  domain: Domain.Any,
257
261
  budget: Json.Budget,
258
- ): Effect.Effect<unknown, Json.Problem> =>
259
- Effect.flatMap(
262
+ ): Effect.Effect<unknown, Json.Problem> => {
263
+ const { input } = rule
264
+ const path = Place.pathOf(rule.place)
265
+ return Effect.flatMap(
260
266
  attempt(() =>
261
267
  typeof input !== "object" || input === null
262
268
  ? undefined
@@ -285,13 +291,16 @@ const decodeMeta = (
285
291
  }),
286
292
  ),
287
293
  )
294
+ }
288
295
 
289
296
  const decodeRule = (
290
- { input, path }: RuleInput,
297
+ rule: RuleInput,
291
298
  meta: unknown,
292
299
  budget: Json.Budget,
293
300
  domain: Domain.Any,
294
301
  ): Rule.Rule => {
302
+ const { input } = rule
303
+ const path = Place.pathOf(rule.place)
295
304
  if (typeof input !== "object" || input === null) {
296
305
  throw Json.problem(path, "A rule must be an object")
297
306
  }
@@ -330,11 +339,13 @@ const decodeRule = (
330
339
  /**
331
340
  * Reads the shape of a tree, and puts each rule in `rules`. The rules are
332
341
  * decoded after the walk: a condition has levels of its own, and a decode
333
- * inside the walk would go down the levels of both.
342
+ * inside the walk would go down the levels of both. `path` is the path of the
343
+ * node for a problem here, and `place` keeps it for later.
334
344
  */
335
345
  const readTree = (
336
346
  input: unknown,
337
347
  path: ReadonlyArray<PropertyKey>,
348
+ place: Place.Place | undefined,
338
349
  depth: number,
339
350
  budget: Json.Budget,
340
351
  // The rules of one decode. The list belongs to that decode.
@@ -350,8 +361,15 @@ const readTree = (
350
361
  throw Json.problem(path, "A node of a policy must be an object")
351
362
  }
352
363
  Json.spend(budget, 1, path)
353
- const child = (value: unknown, at: ReadonlyArray<PropertyKey>) =>
354
- readTree(value, at, depth + 1, budget, rules)
364
+ const child = (value: unknown, ...parts: ReadonlyArray<PropertyKey>) =>
365
+ readTree(
366
+ value,
367
+ [...path, ...parts],
368
+ Place.at(place, ...parts),
369
+ depth + 1,
370
+ budget,
371
+ rules,
372
+ )
355
373
  const tag = Json.read(input, "_tag", path)
356
374
  switch (tag) {
357
375
  case "Rules": {
@@ -365,11 +383,14 @@ const readTree = (
365
383
  const found = Arr.map(
366
384
  Json.readList(input, "rules", path, budget),
367
385
  (rule, index) =>
368
- rules.push({ input: rule, path: [...path, "rules", index] }) - 1,
386
+ rules.push({
387
+ input: rule,
388
+ place: Place.at(place, "rules", index),
389
+ }) - 1,
369
390
  )
370
- return sized({
391
+ return sized(path, {
371
392
  _tag: "Rules",
372
- path,
393
+ place,
373
394
  size: 1 + found.length,
374
395
  algorithm: algorithm as PolicyNodes.Algorithm,
375
396
  rules: found,
@@ -380,24 +401,21 @@ const readTree = (
380
401
  case "FirstOf": {
381
402
  const policies = Arr.map(
382
403
  Json.readList(input, "policies", path, budget),
383
- (value, index) => child(value, [...path, "policies", index]),
404
+ (value, index) => child(value, "policies", index),
384
405
  )
385
- return sized({
406
+ return sized(path, {
386
407
  _tag: tag,
387
- path,
408
+ place,
388
409
  size: Arr.reduce(policies, 1, (total, plan) => total + plan.size),
389
410
  policies,
390
411
  })
391
412
  }
392
413
  case "Ceiling": {
393
- const cap = child(Json.read(input, "cap", path), [...path, "cap"])
394
- const policy = child(Json.read(input, "policy", path), [
395
- ...path,
396
- "policy",
397
- ])
398
- return sized({
414
+ const cap = child(Json.read(input, "cap", path), "cap")
415
+ const policy = child(Json.read(input, "policy", path), "policy")
416
+ return sized(path, {
399
417
  _tag: "Ceiling",
400
- path,
418
+ place,
401
419
  size: 1 + cap.size + policy.size,
402
420
  cap,
403
421
  policy,
@@ -409,10 +427,10 @@ const readTree = (
409
427
  }
410
428
 
411
429
  /** Stops a plan past `policyNodes`, with the message of the nodes. */
412
- const sized = (plan: Plan): Plan => {
430
+ const sized = (path: ReadonlyArray<PropertyKey>, plan: Plan): Plan => {
413
431
  if (plan.size > Limits.policyNodes) {
414
432
  throw Json.problem(
415
- plan.path,
433
+ path,
416
434
  `A policy tree has ${String(plan.size)} nodes. The most is ${String(Limits.policyNodes)}.`,
417
435
  )
418
436
  }
@@ -443,7 +461,7 @@ const build = (plan: Plan, rules: ReadonlyArray<Rule.Rule>): Node => {
443
461
  } catch (error) {
444
462
  // A node past a limit is a problem at its place.
445
463
  if (error instanceof Limits.LimitExceeded) {
446
- throw Json.problem(plan.path, error.message)
464
+ throw Json.problem(Place.pathOf(plan.place), error.message)
447
465
  }
448
466
  throw error
449
467
  }
@@ -474,7 +492,9 @@ export const decode = (
474
492
  const budget: Json.Budget = { used: 0 }
475
493
  // The rules of this decode. The list belongs to this decode.
476
494
  const inputs: Array<RuleInput> = []
477
- const plan = yield* attempt(() => readTree(input, [], 1, budget, inputs))
495
+ const plan = yield* attempt(() =>
496
+ readTree(input, [], undefined, 1, budget, inputs),
497
+ )
478
498
  const metas = yield* Effect.forEach(inputs, (rule) =>
479
499
  decodeMeta(rule, domain, budget),
480
500
  )