@nmnmcc/ability 2.0.2 → 2.0.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/README.md +20 -14
- package/dist/Condition.d.ts +18 -8
- package/dist/Condition.d.ts.map +1 -1
- package/dist/Condition.js +2 -1
- package/dist/Condition.js.map +1 -1
- package/dist/Limits.d.ts +3 -1
- package/dist/Limits.d.ts.map +1 -1
- package/dist/Limits.js +3 -1
- package/dist/Limits.js.map +1 -1
- package/dist/Policy.d.ts +2 -6
- package/dist/Policy.d.ts.map +1 -1
- package/dist/Policy.js.map +1 -1
- package/dist/internal/algebra.js +84 -21
- package/dist/internal/algebra.js.map +1 -1
- package/dist/internal/analysis.js +4 -2
- package/dist/internal/analysis.js.map +1 -1
- package/dist/internal/bind.js +36 -23
- package/dist/internal/bind.js.map +1 -1
- package/dist/internal/catalog.d.ts +9 -0
- package/dist/internal/catalog.d.ts.map +1 -1
- package/dist/internal/catalog.js +38 -23
- package/dist/internal/catalog.js.map +1 -1
- package/dist/internal/decide.js +2 -1
- package/dist/internal/decide.js.map +1 -1
- package/dist/internal/place.js +19 -0
- package/dist/internal/place.js.map +1 -0
- package/dist/internal/policy-json.js +39 -39
- package/dist/internal/policy-json.js.map +1 -1
- package/dist/internal/rule.d.ts +20 -22
- package/dist/internal/rule.d.ts.map +1 -1
- package/dist/internal/rule.js +32 -4
- package/dist/internal/rule.js.map +1 -1
- package/dist/internal/search.d.ts.map +1 -1
- package/dist/internal/search.js +51 -34
- package/dist/internal/search.js.map +1 -1
- package/package.json +1 -1
- package/src/Condition.ts +46 -25
- package/src/Limits.ts +3 -1
- package/src/Policy.ts +1 -7
- package/src/internal/algebra.ts +110 -32
- package/src/internal/analysis.ts +5 -0
- package/src/internal/bind.ts +88 -32
- package/src/internal/catalog.ts +78 -30
- package/src/internal/decide.ts +3 -0
- package/src/internal/place.ts +32 -0
- package/src/internal/policy-json.ts +49 -29
- package/src/internal/rule.ts +59 -11
- package/src/internal/search.ts +78 -75
package/src/internal/algebra.ts
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
import * as Arr from "effect/Array"
|
|
13
13
|
import type * as Context from "effect/Context"
|
|
14
14
|
import * as Effect from "effect/Effect"
|
|
15
|
-
import * as HashSet from "effect/HashSet"
|
|
16
15
|
import * as Option from "effect/Option"
|
|
17
16
|
import type * as Resource from "../Resource.ts"
|
|
18
17
|
import * as Nodes from "./nodes.ts"
|
|
@@ -22,19 +21,81 @@ type Node = Nodes.Node
|
|
|
22
21
|
|
|
23
22
|
const { always, never } = Nodes
|
|
24
23
|
|
|
25
|
-
//
|
|
24
|
+
// Keys of structure
|
|
25
|
+
|
|
26
|
+
/** The text of an operand. The type is in it, so `"1"` is not `1`. */
|
|
27
|
+
const operandText = (operand: Nodes.Operand): ReadonlyArray<string> =>
|
|
28
|
+
operand._tag === "Literal"
|
|
29
|
+
? [typeof operand.value, String(operand.value)]
|
|
30
|
+
: ["principal", operand.path]
|
|
31
|
+
|
|
32
|
+
/** The parts of the text of a condition with no predicate. */
|
|
33
|
+
const shapeOf = (node: Node): unknown => {
|
|
34
|
+
switch (node._tag) {
|
|
35
|
+
case "True":
|
|
36
|
+
case "False":
|
|
37
|
+
return [node._tag]
|
|
38
|
+
case "Compare":
|
|
39
|
+
return [node._tag, node.op, node.field, operandText(node.value)]
|
|
40
|
+
case "In":
|
|
41
|
+
return [node._tag, node.field, Arr.map(node.values, operandText)]
|
|
42
|
+
case "Exists":
|
|
43
|
+
return [node._tag, node.field]
|
|
44
|
+
case "Under":
|
|
45
|
+
return [node._tag, Arr.map(node.prefix, operandText)]
|
|
46
|
+
case "And":
|
|
47
|
+
case "Or":
|
|
48
|
+
return [node._tag, Arr.map(node.conditions, shapeOf)]
|
|
49
|
+
case "Not":
|
|
50
|
+
return [node._tag, shapeOf(node.condition)]
|
|
51
|
+
case "Predicate":
|
|
52
|
+
// A smart constructor and a search do not ask for the text of a
|
|
53
|
+
// predicate: it is equal only to itself.
|
|
54
|
+
throw new Error("A predicate has no text of structure")
|
|
55
|
+
}
|
|
56
|
+
}
|
|
26
57
|
|
|
27
58
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
59
|
+
* The text of the structure of a condition with no predicate. Two conditions
|
|
60
|
+
* with one text give the same answer for every value. It reads all of the
|
|
61
|
+
* condition, so a search counts its size in the budget.
|
|
30
62
|
*/
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
63
|
+
export const structureText = (node: Node): string =>
|
|
64
|
+
JSON.stringify(shapeOf(node))
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The most nodes of a condition that a smart constructor reads for its key.
|
|
68
|
+
* A larger child is its own key, so the work of a constructor stays small.
|
|
69
|
+
*/
|
|
70
|
+
const keyNodes = 64
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The key of a child in a smart constructor: the text of its structure, or
|
|
74
|
+
* the node itself when it is large. The keys go into a native `Set`, not a
|
|
75
|
+
* `HashSet` of Effect. The hash of a string in Effect has no random seed, so
|
|
76
|
+
* a policy could hold very many strings with one hash, and each test of the
|
|
77
|
+
* set would read all of them. A native `Set` finds a node by the node itself,
|
|
78
|
+
* and it hashes a string with the random seed of the engine.
|
|
79
|
+
*/
|
|
80
|
+
const keyOf = (node: Node): string | Node =>
|
|
81
|
+
node.size > keyNodes ? node : structureText(node)
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Keeps the first node of each structure, in order. A large node is a copy
|
|
85
|
+
* only of itself.
|
|
86
|
+
*/
|
|
87
|
+
const dedupe = (nodes: ReadonlyArray<Node>): ReadonlyArray<Node> => {
|
|
88
|
+
// The set belongs to this call.
|
|
89
|
+
const seen = new Set<string | Node>()
|
|
90
|
+
return Arr.filter(nodes, (node) => {
|
|
91
|
+
const key = keyOf(node)
|
|
92
|
+
if (seen.has(key)) {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
seen.add(key)
|
|
96
|
+
return true
|
|
97
|
+
})
|
|
98
|
+
}
|
|
38
99
|
|
|
39
100
|
/**
|
|
40
101
|
* Removes the children that the other children make useless. In an `Or`,
|
|
@@ -44,15 +105,18 @@ const absorb = (
|
|
|
44
105
|
inner: "And" | "Or",
|
|
45
106
|
nodes: ReadonlyArray<Node>,
|
|
46
107
|
): ReadonlyArray<Node> => {
|
|
47
|
-
|
|
108
|
+
// A native `Set`, as in `dedupe`.
|
|
109
|
+
const all = new Set(Arr.map(nodes, keyOf))
|
|
48
110
|
return Arr.filter(
|
|
49
111
|
nodes,
|
|
50
112
|
(node) =>
|
|
51
113
|
node._tag !== inner ||
|
|
52
|
-
!Arr.some(node.conditions, (part) =>
|
|
114
|
+
!Arr.some(node.conditions, (part) => all.has(keyOf(part))),
|
|
53
115
|
)
|
|
54
116
|
}
|
|
55
117
|
|
|
118
|
+
// Smart constructors
|
|
119
|
+
|
|
56
120
|
/**
|
|
57
121
|
* Gives the result of an `And` with a `False` child, or of an `Or` with a
|
|
58
122
|
* `True` child. The children are tested in order, so a predicate before the
|
|
@@ -259,8 +323,8 @@ const testUnder = (
|
|
|
259
323
|
})
|
|
260
324
|
}
|
|
261
325
|
|
|
262
|
-
/** Tests a condition with no predicate on a value. */
|
|
263
|
-
|
|
326
|
+
/** Tests a simple condition with no predicate on a value. */
|
|
327
|
+
const testSync = (node: Node, input: Input): boolean => {
|
|
264
328
|
switch (node._tag) {
|
|
265
329
|
case "True":
|
|
266
330
|
return true
|
|
@@ -283,11 +347,11 @@ export const evaluateSync = (node: Node, input: Input): boolean => {
|
|
|
283
347
|
case "Under":
|
|
284
348
|
return testUnder(node.prefix, input)
|
|
285
349
|
case "And":
|
|
286
|
-
return Arr.every(node.conditions, (child) =>
|
|
350
|
+
return Arr.every(node.conditions, (child) => testSync(child, input))
|
|
287
351
|
case "Or":
|
|
288
|
-
return Arr.some(node.conditions, (child) =>
|
|
352
|
+
return Arr.some(node.conditions, (child) => testSync(child, input))
|
|
289
353
|
case "Not":
|
|
290
|
-
return !
|
|
354
|
+
return !testSync(node.condition, input)
|
|
291
355
|
case "Predicate":
|
|
292
356
|
// The types stop this. A predicate needs `evaluate`.
|
|
293
357
|
throw new Error(
|
|
@@ -297,38 +361,55 @@ export const evaluateSync = (node: Node, input: Input): boolean => {
|
|
|
297
361
|
}
|
|
298
362
|
|
|
299
363
|
/**
|
|
300
|
-
* Tests a condition on a value. The children of `And` and `Or` run in
|
|
301
|
-
* and they stop at the first child that gives the answer.
|
|
364
|
+
* Tests a simple condition on a value. The children of `And` and `Or` run in
|
|
365
|
+
* order, and they stop at the first child that gives the answer.
|
|
302
366
|
*/
|
|
303
|
-
|
|
367
|
+
const test = (
|
|
304
368
|
node: Node,
|
|
305
369
|
input: Input,
|
|
306
370
|
): Effect.Effect<boolean, unknown, unknown> => {
|
|
307
371
|
if (!node.opaque) {
|
|
308
|
-
return Effect.sync(() =>
|
|
372
|
+
return Effect.sync(() => testSync(node, input))
|
|
309
373
|
}
|
|
310
374
|
switch (node._tag) {
|
|
311
375
|
case "Predicate":
|
|
312
376
|
return Nodes.runOf(node)(input.value, input.principal)
|
|
313
377
|
case "Not":
|
|
314
|
-
return Effect.map(
|
|
378
|
+
return Effect.map(test(node.condition, input), (value) => !value)
|
|
315
379
|
case "And":
|
|
316
380
|
return Effect.map(
|
|
317
381
|
Effect.findFirst(node.conditions, (child) =>
|
|
318
|
-
Effect.map(
|
|
382
|
+
Effect.map(test(child, input), (value) => !value),
|
|
319
383
|
),
|
|
320
384
|
Option.isNone,
|
|
321
385
|
)
|
|
322
386
|
case "Or":
|
|
323
387
|
return Effect.map(
|
|
324
|
-
Effect.findFirst(node.conditions, (child) =>
|
|
388
|
+
Effect.findFirst(node.conditions, (child) => test(child, input)),
|
|
325
389
|
Option.isSome,
|
|
326
390
|
)
|
|
327
391
|
default:
|
|
328
|
-
return Effect.sync(() =>
|
|
392
|
+
return Effect.sync(() => testSync(node, input))
|
|
329
393
|
}
|
|
330
394
|
}
|
|
331
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Tests a condition with no predicate on a value. The test runs on the
|
|
398
|
+
* simple form of the condition with the principal in it, as a residual does.
|
|
399
|
+
* So a test reads only the fields that the residual reads: a read of a field
|
|
400
|
+
* that is not plain data is a defect, and a field that the answer does not
|
|
401
|
+
* need, as in `x AND False`, is not read.
|
|
402
|
+
*/
|
|
403
|
+
export const evaluateSync = (node: Node, input: Input): boolean =>
|
|
404
|
+
testSync(partial(node, input), input)
|
|
405
|
+
|
|
406
|
+
/** Tests a condition on a value, as {@link evaluateSync} does. */
|
|
407
|
+
export const evaluate = (
|
|
408
|
+
node: Node,
|
|
409
|
+
input: Input,
|
|
410
|
+
): Effect.Effect<boolean, unknown, unknown> =>
|
|
411
|
+
Effect.suspend(() => test(partial(node, input), input))
|
|
412
|
+
|
|
332
413
|
// The principal and the path
|
|
333
414
|
|
|
334
415
|
/**
|
|
@@ -473,10 +554,7 @@ export const fields = (node: Node): ReadonlyArray<string> => {
|
|
|
473
554
|
return []
|
|
474
555
|
}
|
|
475
556
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
HashSet.has(seen, field) ? [] : [field],
|
|
480
|
-
])[1],
|
|
481
|
-
)
|
|
557
|
+
// A native `Set`, as in `dedupe`: a field of a condition that is not bound
|
|
558
|
+
// to a domain may be any string.
|
|
559
|
+
return Array.from(new Set(all(node)))
|
|
482
560
|
}
|
package/src/internal/analysis.ts
CHANGED
|
@@ -85,6 +85,8 @@ interface ResidualContext {
|
|
|
85
85
|
}
|
|
86
86
|
/** The resource of the kind, for the fields that a rule may grant. */
|
|
87
87
|
readonly descriptor: Resource.Descriptor
|
|
88
|
+
/** The test of `RuleData.fitsOf` for the catalog, for this request. */
|
|
89
|
+
readonly fits: (pattern: string) => boolean
|
|
88
90
|
/**
|
|
89
91
|
* The condition of each rule, with the principal in it. They are made
|
|
90
92
|
* first, from a flat list of the rules, so the walk does not go down the
|
|
@@ -241,6 +243,7 @@ interface PredicateContext {
|
|
|
241
243
|
readonly kind: string
|
|
242
244
|
readonly field: string | undefined
|
|
243
245
|
readonly descriptor: Resource.Descriptor
|
|
246
|
+
readonly fits: (pattern: string) => boolean
|
|
244
247
|
}
|
|
245
248
|
|
|
246
249
|
/** Tests if a rule that the request may use has a predicate. */
|
|
@@ -345,6 +348,7 @@ const scopedResiduals = (policy: Any, request: ScopedRequest): Scoped => {
|
|
|
345
348
|
field: request.field,
|
|
346
349
|
input,
|
|
347
350
|
descriptor,
|
|
351
|
+
fits: RuleData.fitsOf(descriptor.catalog),
|
|
348
352
|
}
|
|
349
353
|
const context: ResidualContext = {
|
|
350
354
|
...target,
|
|
@@ -378,6 +382,7 @@ const scopedResiduals = (policy: Any, request: ScopedRequest): Scoped => {
|
|
|
378
382
|
path: descriptor.path,
|
|
379
383
|
},
|
|
380
384
|
descriptor,
|
|
385
|
+
fits: RuleData.fitsOf(descriptor.catalog),
|
|
381
386
|
trace: undefined,
|
|
382
387
|
},
|
|
383
388
|
[],
|
package/src/internal/bind.ts
CHANGED
|
@@ -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. */
|
|
@@ -40,8 +41,13 @@ export interface RuleData {
|
|
|
40
41
|
interface Memo {
|
|
41
42
|
/** The suggestion for each name, by the place where it was looked for. */
|
|
42
43
|
readonly suggestions: MutableHashMap.MutableHashMap<string, string>
|
|
43
|
-
/**
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* If a pattern matches a field of a kind, by the kind and the pattern. The
|
|
46
|
+
* patterns come from a policy, so the map is native: the hash of a string
|
|
47
|
+
* in Effect has no random seed, and a policy could hold very many
|
|
48
|
+
* patterns with one hash. The engine hashes a string with a random seed.
|
|
49
|
+
*/
|
|
50
|
+
readonly fits: Map<string, boolean>
|
|
45
51
|
/** The count of the issues of conditions so far. It belongs to one binding. */
|
|
46
52
|
readonly issues: { count: number; noted: boolean }
|
|
47
53
|
/** The steps of the checks of patterns so far. It belongs to one binding. */
|
|
@@ -50,7 +56,7 @@ interface Memo {
|
|
|
50
56
|
|
|
51
57
|
const makeMemo = (): Memo => ({
|
|
52
58
|
suggestions: MutableHashMap.empty(),
|
|
53
|
-
fits:
|
|
59
|
+
fits: new Map(),
|
|
54
60
|
issues: { count: 0, noted: false },
|
|
55
61
|
work: { steps: 0, noted: false },
|
|
56
62
|
})
|
|
@@ -176,6 +182,36 @@ const fieldIssue = (
|
|
|
176
182
|
]
|
|
177
183
|
}
|
|
178
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The issues of a field that a condition reads: a field that is not a field
|
|
187
|
+
* of every resource of the rule, or a field that is not plain data in one of
|
|
188
|
+
* them. A read of a value that is not plain data, as a getter of a class, is
|
|
189
|
+
* a defect in a decision. A residual may leave the read out, so the residual
|
|
190
|
+
* would say "yes" where the decision fails. So the binding does not take
|
|
191
|
+
* such a read: see `Catalog.Field.plain`.
|
|
192
|
+
*/
|
|
193
|
+
const readIssues = (
|
|
194
|
+
context: Context,
|
|
195
|
+
field: string,
|
|
196
|
+
at: ReadonlyArray<PropertyKey>,
|
|
197
|
+
): ReadonlyArray<Issue> => {
|
|
198
|
+
const exists = fieldIssue(context, field, at)
|
|
199
|
+
if (exists.length > 0) {
|
|
200
|
+
return exists
|
|
201
|
+
}
|
|
202
|
+
return Arr.some(
|
|
203
|
+
context.named,
|
|
204
|
+
(descriptor) => !(Catalog.lookup(descriptor.catalog, field)?.plain ?? true),
|
|
205
|
+
)
|
|
206
|
+
? [
|
|
207
|
+
{
|
|
208
|
+
path: at,
|
|
209
|
+
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.`,
|
|
210
|
+
},
|
|
211
|
+
]
|
|
212
|
+
: []
|
|
213
|
+
}
|
|
214
|
+
|
|
179
215
|
/**
|
|
180
216
|
* The issues of a field pattern: a pattern that does not parse, or that
|
|
181
217
|
* matches no field of the resources of the rule, as a pattern with a mistake
|
|
@@ -190,24 +226,29 @@ const patternIssues = (
|
|
|
190
226
|
Result.match(Patterns.parse(pattern), {
|
|
191
227
|
onFailure: (issue) => [{ path: at, issue }],
|
|
192
228
|
onSuccess: (parsed) => {
|
|
193
|
-
// A rule with no known resource has an issue for that already.
|
|
229
|
+
// A rule with no known resource has an issue for that already. The
|
|
230
|
+
// pattern is checked on every kind, not only up to the first that it
|
|
231
|
+
// fits: a decision on a kind does this check, so the limit of the
|
|
232
|
+
// work below is a limit of the work of a decision too.
|
|
194
233
|
const fits =
|
|
195
234
|
known.length === 0 ||
|
|
196
|
-
Arr.
|
|
235
|
+
Arr.reduce(known, false, (before, descriptor) => {
|
|
197
236
|
const key = `${descriptor.kind}\u0000${pattern}`
|
|
198
|
-
return
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
237
|
+
return (
|
|
238
|
+
Option.getOrElse(Option.fromUndefinedOr(memo.fits.get(key)), () => {
|
|
239
|
+
// Each check of a pattern reads the fields of a catalog. The
|
|
240
|
+
// work of one binding has a limit, so very many patterns stop
|
|
241
|
+
// with one issue, and the policy does not load.
|
|
242
|
+
memo.work.steps +=
|
|
243
|
+
descriptor.catalog.fields.length * Math.max(parsed.length, 1)
|
|
244
|
+
if (memo.work.steps > Limits.searchSteps) {
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
const found = Patterns.fitsCatalog(parsed, descriptor.catalog)
|
|
248
|
+
memo.fits.set(key, found)
|
|
249
|
+
return found
|
|
250
|
+
}) || before
|
|
251
|
+
)
|
|
211
252
|
})
|
|
212
253
|
if (memo.work.steps > Limits.searchSteps && !memo.work.noted) {
|
|
213
254
|
memo.work.noted = true
|
|
@@ -246,6 +287,14 @@ const principalField = (
|
|
|
246
287
|
at: ReadonlyArray<PropertyKey>,
|
|
247
288
|
): Catalog.Field | Issue => {
|
|
248
289
|
const found = Catalog.lookup(context.domain.principalCatalog, ref)
|
|
290
|
+
// A read of the principal that is not plain data is a defect, as a read
|
|
291
|
+
// of a resource is: see `readIssues`.
|
|
292
|
+
if (found !== undefined && !found.plain) {
|
|
293
|
+
return {
|
|
294
|
+
path: at,
|
|
295
|
+
issue: `A condition may not read the field "${ref}" of the principal. 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.`,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
249
298
|
return found !== undefined
|
|
250
299
|
? found
|
|
251
300
|
: {
|
|
@@ -471,7 +520,7 @@ const conditionIssues = (
|
|
|
471
520
|
const here = (issues: ReadonlyArray<Issue>) => under(context.memo, at, issues)
|
|
472
521
|
switch (node._tag) {
|
|
473
522
|
case "Compare": {
|
|
474
|
-
const exists =
|
|
523
|
+
const exists = readIssues(context, node.field, ["field"])
|
|
475
524
|
return here(
|
|
476
525
|
exists.length > 0
|
|
477
526
|
? exists
|
|
@@ -485,7 +534,7 @@ const conditionIssues = (
|
|
|
485
534
|
)
|
|
486
535
|
}
|
|
487
536
|
case "In": {
|
|
488
|
-
const exists =
|
|
537
|
+
const exists = readIssues(context, node.field, ["field"])
|
|
489
538
|
return here(
|
|
490
539
|
exists.length > 0
|
|
491
540
|
? exists
|
|
@@ -498,7 +547,7 @@ const conditionIssues = (
|
|
|
498
547
|
)
|
|
499
548
|
}
|
|
500
549
|
case "Exists":
|
|
501
|
-
return here(
|
|
550
|
+
return here(readIssues(context, node.field, ["field"]))
|
|
502
551
|
case "Under":
|
|
503
552
|
return here(underIssues(context, node.prefix, []))
|
|
504
553
|
case "And":
|
|
@@ -541,8 +590,12 @@ export const ruleIssues = (
|
|
|
541
590
|
memo: Memo = makeMemo(),
|
|
542
591
|
): ReadonlyArray<Issue> => {
|
|
543
592
|
const all = rule.resources.includes("*")
|
|
544
|
-
// Each kind is checked one time, also when the list names it again.
|
|
545
|
-
|
|
593
|
+
// Each kind is checked one time, also when the list names it again. A
|
|
594
|
+
// native `Set` finds the copies in one pass: `Arr.dedupe` compares each
|
|
595
|
+
// name with each name before it, and a stored rule may name very many.
|
|
596
|
+
const names = Array.from(
|
|
597
|
+
new Set(Arr.filter(rule.resources, (kind) => kind !== "*")),
|
|
598
|
+
)
|
|
546
599
|
const kinds = Array.from(HashMap.keys(domain.resources))
|
|
547
600
|
const named = Arr.flatMap(names, (kind) =>
|
|
548
601
|
Option.toArray(HashMap.get(domain.resources, kind)),
|
|
@@ -630,15 +683,18 @@ export const treeIssues = (
|
|
|
630
683
|
// The rules come out of the tree first, and each one is checked after.
|
|
631
684
|
// A check of a condition goes down its levels, so a check inside the walk
|
|
632
685
|
// of the tree would go down the levels of both.
|
|
686
|
+
// The place of each rule shares the path of its parent, and the full path
|
|
687
|
+
// is made only when its rule is checked. A deep tree with very many rules
|
|
688
|
+
// would need very much memory for a full copy of the path of each rule.
|
|
633
689
|
const rulesOf = (
|
|
634
690
|
node: TreeNode,
|
|
635
|
-
|
|
691
|
+
place: Place.Place | undefined,
|
|
636
692
|
level: number,
|
|
637
|
-
): ReadonlyArray<Issue | readonly [RuleData,
|
|
693
|
+
): ReadonlyArray<Issue | readonly [RuleData, Place.Place]> => {
|
|
638
694
|
if (level > Limits.policyDepth) {
|
|
639
695
|
return [
|
|
640
696
|
{
|
|
641
|
-
path,
|
|
697
|
+
path: Place.pathOf(place),
|
|
642
698
|
issue: `A policy tree has more than ${String(Limits.policyDepth)} levels. The most is ${String(Limits.policyDepth)}.`,
|
|
643
699
|
},
|
|
644
700
|
]
|
|
@@ -647,25 +703,25 @@ export const treeIssues = (
|
|
|
647
703
|
case "Rules":
|
|
648
704
|
return Arr.map(
|
|
649
705
|
node.rules,
|
|
650
|
-
(rule, index) => [rule,
|
|
706
|
+
(rule, index) => [rule, Place.at(place, "rules", index)] as const,
|
|
651
707
|
)
|
|
652
708
|
case "All":
|
|
653
709
|
case "Any":
|
|
654
710
|
case "FirstOf":
|
|
655
711
|
return Arr.flatMap(node.policies, (child, index) =>
|
|
656
|
-
rulesOf(child,
|
|
712
|
+
rulesOf(child, Place.at(place, "policies", index), level + 1),
|
|
657
713
|
)
|
|
658
714
|
case "Ceiling":
|
|
659
715
|
return [
|
|
660
|
-
...rulesOf(node.cap,
|
|
661
|
-
...rulesOf(node.policy,
|
|
716
|
+
...rulesOf(node.cap, Place.at(place, "cap"), level + 1),
|
|
717
|
+
...rulesOf(node.policy, Place.at(place, "policy"), level + 1),
|
|
662
718
|
]
|
|
663
719
|
}
|
|
664
720
|
}
|
|
665
721
|
const memo = makeMemo()
|
|
666
|
-
return Arr.flatMap(rulesOf(root,
|
|
722
|
+
return Arr.flatMap(rulesOf(root, undefined, 1), (found) =>
|
|
667
723
|
Array.isArray(found)
|
|
668
|
-
? ruleIssues(domain, found[0], found[1], memo)
|
|
724
|
+
? ruleIssues(domain, found[0], Place.pathOf(found[1]), memo)
|
|
669
725
|
: [found as Issue],
|
|
670
726
|
)
|
|
671
727
|
}
|
package/src/internal/catalog.ts
CHANGED
|
@@ -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
|
|
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 = (
|
|
227
|
-
ast.
|
|
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
|
-
/**
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
356
|
-
if (
|
|
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
|
-
|
|
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(
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
)
|