@nmnmcc/ability 1.0.1 → 2.0.1
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 +112 -69
- package/dist/Authorizer.d.ts +43 -47
- package/dist/Authorizer.d.ts.map +1 -1
- package/dist/Authorizer.js +5 -290
- package/dist/Authorizer.js.map +1 -1
- package/dist/Condition.d.ts +164 -332
- package/dist/Condition.d.ts.map +1 -1
- package/dist/Condition.js +75 -890
- package/dist/Condition.js.map +1 -1
- package/dist/Decision.d.ts +13 -13
- package/dist/Decision.d.ts.map +1 -1
- package/dist/Decision.js.map +1 -1
- package/dist/Domain.d.ts +10 -21
- package/dist/Domain.d.ts.map +1 -1
- package/dist/Domain.js +4 -62
- package/dist/Domain.js.map +1 -1
- package/dist/HttpApi.d.ts +31 -7
- package/dist/HttpApi.d.ts.map +1 -1
- package/dist/HttpApi.js +25 -8
- package/dist/HttpApi.js.map +1 -1
- package/dist/Limits.d.ts +114 -0
- package/dist/Limits.d.ts.map +1 -0
- package/dist/Limits.js +132 -0
- package/dist/Limits.js.map +1 -0
- package/dist/Policy.d.ts +276 -269
- package/dist/Policy.d.ts.map +1 -1
- package/dist/Policy.js +73 -1177
- package/dist/Policy.js.map +1 -1
- package/dist/Resource.d.ts +4 -177
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +4 -803
- package/dist/Resource.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +4 -5
- package/dist/internal/algebra.js +267 -0
- package/dist/internal/algebra.js.map +1 -0
- package/dist/internal/analysis.js +245 -0
- package/dist/internal/analysis.js.map +1 -0
- package/dist/internal/authorizer.d.ts +30 -0
- package/dist/internal/authorizer.d.ts.map +1 -0
- package/dist/internal/authorizer.js +221 -0
- package/dist/internal/authorizer.js.map +1 -0
- package/dist/internal/bind.js +311 -0
- package/dist/internal/bind.js.map +1 -0
- package/dist/internal/catalog.d.ts +56 -0
- package/dist/internal/catalog.d.ts.map +1 -0
- package/dist/internal/catalog.js +292 -0
- package/dist/internal/catalog.js.map +1 -0
- package/dist/internal/decide.js +166 -0
- package/dist/internal/decide.js.map +1 -0
- package/dist/internal/domain.js +32 -0
- package/dist/internal/domain.js.map +1 -0
- package/dist/internal/json.js +134 -0
- package/dist/internal/json.js.map +1 -0
- package/dist/internal/limits.d.ts +14 -0
- package/dist/internal/limits.d.ts.map +1 -0
- package/dist/internal/limits.js +43 -0
- package/dist/internal/limits.js.map +1 -0
- package/dist/internal/nodes.d.ts +121 -0
- package/dist/internal/nodes.d.ts.map +1 -0
- package/dist/internal/nodes.js +277 -0
- package/dist/internal/nodes.js.map +1 -0
- package/dist/internal/patterns.js +110 -0
- package/dist/internal/patterns.js.map +1 -0
- package/dist/internal/policy-json.js +265 -0
- package/dist/internal/policy-json.js.map +1 -0
- package/dist/internal/policy-nodes.d.ts +61 -0
- package/dist/internal/policy-nodes.d.ts.map +1 -0
- package/dist/internal/policy-nodes.js +146 -0
- package/dist/internal/policy-nodes.js.map +1 -0
- package/dist/internal/policy.d.ts +42 -0
- package/dist/internal/policy.d.ts.map +1 -0
- package/dist/internal/policy.js +124 -0
- package/dist/internal/policy.js.map +1 -0
- package/dist/internal/read.js +39 -0
- package/dist/internal/read.js.map +1 -0
- package/dist/internal/resource.js +115 -0
- package/dist/internal/resource.js.map +1 -0
- package/dist/internal/rule.d.ts +26 -0
- package/dist/internal/rule.d.ts.map +1 -0
- package/dist/internal/rule.js +103 -0
- package/dist/internal/rule.js.map +1 -0
- package/dist/internal/search.d.ts +22 -0
- package/dist/internal/search.d.ts.map +1 -0
- package/dist/internal/search.js +274 -0
- package/dist/internal/search.js.map +1 -0
- package/dist/internal/suggest.js +46 -0
- package/dist/internal/suggest.js.map +1 -0
- package/package.json +3 -4
- package/src/Authorizer.ts +67 -583
- package/src/Condition.ts +339 -2095
- package/src/Decision.ts +6 -5
- package/src/Domain.ts +12 -69
- package/src/HttpApi.ts +72 -10
- package/src/Limits.ts +132 -0
- package/src/Policy.ts +391 -2772
- package/src/Resource.ts +7 -1548
- package/src/index.ts +3 -9
- package/src/internal/algebra.ts +482 -0
- package/src/internal/analysis.ts +478 -0
- package/src/internal/authorizer.ts +418 -0
- package/src/internal/bind.ts +648 -0
- package/src/internal/catalog.ts +534 -0
- package/src/internal/decide.ts +402 -0
- package/src/internal/domain.ts +66 -0
- package/src/internal/json.ts +260 -0
- package/src/internal/limits.ts +78 -0
- package/src/internal/nodes.ts +404 -0
- package/src/internal/patterns.ts +211 -0
- package/src/internal/policy-json.ts +487 -0
- package/src/internal/policy-nodes.ts +207 -0
- package/src/internal/policy.ts +264 -0
- package/src/internal/read.ts +55 -0
- package/src/internal/resource.ts +180 -0
- package/src/internal/rule.ts +196 -0
- package/src/internal/search.ts +541 -0
- package/src/internal/suggest.ts +104 -0
- package/dist/Principal.d.ts +0 -35
- package/dist/Principal.d.ts.map +0 -1
- package/dist/Principal.js +0 -37
- package/dist/Principal.js.map +0 -1
- package/dist/Rule.d.ts +0 -275
- package/dist/Rule.d.ts.map +0 -1
- package/dist/Rule.js +0 -257
- package/dist/Rule.js.map +0 -1
- package/src/Principal.ts +0 -41
- package/src/Rule.ts +0 -683
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The search for a value that makes a condition true. Each test of a
|
|
3
|
+
* condition compares one field with constants, so the constants cut the
|
|
4
|
+
* values of each field into a few cells, and every test gives the same answer
|
|
5
|
+
* for all the values of one cell. The search tries one value from each cell,
|
|
6
|
+
* one field at a time.
|
|
7
|
+
*
|
|
8
|
+
* The values of a field come from its kinds in a catalog. The kinds hold every
|
|
9
|
+
* real value, so a "no" holds for the real values too. A "yes" holds for the
|
|
10
|
+
* kinds: a check of the Schema may still stop the example.
|
|
11
|
+
*/
|
|
12
|
+
import * as Arr from "effect/Array"
|
|
13
|
+
import * as Data from "effect/Data"
|
|
14
|
+
import * as HashMap from "effect/HashMap"
|
|
15
|
+
import * as HashSet from "effect/HashSet"
|
|
16
|
+
import * as MutableHashMap from "effect/MutableHashMap"
|
|
17
|
+
import * as Option from "effect/Option"
|
|
18
|
+
import { isObject } from "effect/Predicate"
|
|
19
|
+
import * as Algebra from "./algebra.ts"
|
|
20
|
+
import * as Catalog from "./catalog.ts"
|
|
21
|
+
import * as Limits from "./limits.ts"
|
|
22
|
+
import * as Nodes from "./nodes.ts"
|
|
23
|
+
|
|
24
|
+
type Node = Nodes.Node
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Values of fields, as a nested object. A field that is not in it may have any
|
|
28
|
+
* value. A value with parts that the search does not look into, such as a
|
|
29
|
+
* date, is an empty object.
|
|
30
|
+
*/
|
|
31
|
+
export interface Example {
|
|
32
|
+
readonly [field: string]: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The answer of a search. */
|
|
36
|
+
export type Satisfiability = Data.TaggedEnum<{
|
|
37
|
+
Satisfiable: { readonly example: Example }
|
|
38
|
+
Unsatisfiable: {}
|
|
39
|
+
Unknown: { readonly reason: "predicate" | "budget" }
|
|
40
|
+
}>
|
|
41
|
+
|
|
42
|
+
const satisfiability = Data.taggedEnum<Satisfiability>()
|
|
43
|
+
|
|
44
|
+
/** Stands for a value with parts, such as a struct or a date. */
|
|
45
|
+
const opaque: object = Object.freeze({})
|
|
46
|
+
|
|
47
|
+
/** Marks a field that a step of the search did not fix. */
|
|
48
|
+
const free: unique symbol = Symbol("free")
|
|
49
|
+
|
|
50
|
+
/** The least number that is greater than `value`. */
|
|
51
|
+
const nextUp = (value: number): number => {
|
|
52
|
+
if (Number.isNaN(value) || value === Infinity) {
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
if (value === 0) {
|
|
56
|
+
return Number.MIN_VALUE
|
|
57
|
+
}
|
|
58
|
+
const view = new DataView(new ArrayBuffer(8))
|
|
59
|
+
view.setFloat64(0, value)
|
|
60
|
+
const bits = view.getBigUint64(0)
|
|
61
|
+
view.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n)
|
|
62
|
+
return view.getFloat64(0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Gives one number from each part of the number line that the constants
|
|
67
|
+
* make: each constant, and one number between each two neighbours when there
|
|
68
|
+
* is one. The ends of the domain are in the list too.
|
|
69
|
+
*/
|
|
70
|
+
const numberCells = (
|
|
71
|
+
kind: "integer" | "finite" | "any",
|
|
72
|
+
constants: ReadonlyArray<number>,
|
|
73
|
+
): ReadonlyArray<number> => {
|
|
74
|
+
const integer = kind === "integer"
|
|
75
|
+
const low = integer
|
|
76
|
+
? Number.MIN_SAFE_INTEGER
|
|
77
|
+
: kind === "finite"
|
|
78
|
+
? -Number.MAX_VALUE
|
|
79
|
+
: -Infinity
|
|
80
|
+
const high = integer
|
|
81
|
+
? Number.MAX_SAFE_INTEGER
|
|
82
|
+
: kind === "finite"
|
|
83
|
+
? Number.MAX_VALUE
|
|
84
|
+
: Infinity
|
|
85
|
+
const known: ReadonlyArray<number> = Arr.filter(
|
|
86
|
+
constants,
|
|
87
|
+
(value: number) => !Number.isNaN(value),
|
|
88
|
+
)
|
|
89
|
+
const points = Arr.filter(
|
|
90
|
+
Arr.flatMap(known, (value: number) =>
|
|
91
|
+
integer && Number.isFinite(value)
|
|
92
|
+
? [Math.floor(value), Math.ceil(value)]
|
|
93
|
+
: [value],
|
|
94
|
+
),
|
|
95
|
+
(value: number) => value >= low && value <= high,
|
|
96
|
+
)
|
|
97
|
+
// `0` and `-0` are one point: every test gives the same answer for both,
|
|
98
|
+
// and `===` calls them equal.
|
|
99
|
+
const ordered: Array<number> = [low, high, ...points].sort((a, b) => a - b)
|
|
100
|
+
const sorted = Arr.dedupeAdjacentWith(
|
|
101
|
+
ordered,
|
|
102
|
+
(a: number, b: number) => a === b,
|
|
103
|
+
)
|
|
104
|
+
const cells = Arr.flatMap(sorted, (value, index) => {
|
|
105
|
+
const after = sorted[index + 1]
|
|
106
|
+
const between = integer ? value + 1 : nextUp(value)
|
|
107
|
+
return after !== undefined && between < after ? [value, between] : [value]
|
|
108
|
+
})
|
|
109
|
+
return kind === "any" ? Arr.append(cells, Number.NaN) : cells
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Gives one string from each part that the constants make. Strings are in the
|
|
114
|
+
* order of their UTF-16 code units, as `<` sees them. The least string after
|
|
115
|
+
* `a` is `a + "\0"`, so there is a string between `a` and `b` exactly when
|
|
116
|
+
* `b` is not `a + "\0"`.
|
|
117
|
+
*/
|
|
118
|
+
const stringCells = (
|
|
119
|
+
constants: ReadonlyArray<string>,
|
|
120
|
+
): ReadonlyArray<string> => {
|
|
121
|
+
const ordered: Array<string> = [...constants].sort()
|
|
122
|
+
const sorted = Arr.dedupeAdjacentWith(
|
|
123
|
+
ordered,
|
|
124
|
+
(a: string, b: string) => a === b,
|
|
125
|
+
)
|
|
126
|
+
if (sorted.length === 0) {
|
|
127
|
+
return [""]
|
|
128
|
+
}
|
|
129
|
+
return [
|
|
130
|
+
...(sorted[0] === "" ? [] : [""]),
|
|
131
|
+
...Arr.flatMap(sorted, (value, index) => {
|
|
132
|
+
const next = `${value}\0`
|
|
133
|
+
return next === sorted[index + 1] ? [value] : [value, next]
|
|
134
|
+
}),
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A text for a cell. Two cells get one text when every test gives the same
|
|
140
|
+
* answer for them: `0` and `-0` are one, and `NaN` is one.
|
|
141
|
+
*/
|
|
142
|
+
const cellText = (cell: unknown): string =>
|
|
143
|
+
cell === opaque
|
|
144
|
+
? "opaque"
|
|
145
|
+
: typeof cell === "number"
|
|
146
|
+
? `number:${Object.is(cell, -0) ? "0" : String(cell)}`
|
|
147
|
+
: `${typeof cell}:${String(cell)}`
|
|
148
|
+
|
|
149
|
+
/** Keeps the first cell of each text, in order. */
|
|
150
|
+
const dedupeCells = (cells: ReadonlyArray<unknown>): ReadonlyArray<unknown> =>
|
|
151
|
+
Arr.flatten(
|
|
152
|
+
Arr.mapAccum(cells, HashSet.empty<string>(), (seen, cell) => {
|
|
153
|
+
const text = cellText(cell)
|
|
154
|
+
return [HashSet.add(seen, text), HashSet.has(seen, text) ? [] : [cell]]
|
|
155
|
+
})[1],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
/** Gives one value from each cell of a field. */
|
|
159
|
+
const cellsOf = (
|
|
160
|
+
kinds: Catalog.Kinds,
|
|
161
|
+
constants: ReadonlyArray<Nodes.Scalar>,
|
|
162
|
+
): ReadonlyArray<unknown> =>
|
|
163
|
+
dedupeCells([
|
|
164
|
+
...(kinds.null ? [null] : []),
|
|
165
|
+
...(kinds.object ? [opaque] : []),
|
|
166
|
+
...(kinds.boolean ? [true, false] : []),
|
|
167
|
+
...(kinds.string
|
|
168
|
+
? stringCells(Arr.filter(constants, (value) => typeof value === "string"))
|
|
169
|
+
: []),
|
|
170
|
+
...(kinds.number === "none"
|
|
171
|
+
? []
|
|
172
|
+
: numberCells(
|
|
173
|
+
kinds.number,
|
|
174
|
+
Arr.filter(constants, (value) => typeof value === "number"),
|
|
175
|
+
)),
|
|
176
|
+
...kinds.literals,
|
|
177
|
+
])
|
|
178
|
+
|
|
179
|
+
class OutOfBudget extends Data.Error {}
|
|
180
|
+
|
|
181
|
+
/** What one search keeps for all its steps. It lives only as long as the search. */
|
|
182
|
+
interface Search {
|
|
183
|
+
readonly cells: (field: string) => ReadonlyArray<unknown>
|
|
184
|
+
/**
|
|
185
|
+
* The texts of the values of each `In` test, so one test of a value does
|
|
186
|
+
* not read every value of the test. It is a native `Map`, because it must
|
|
187
|
+
* find a node by the node itself: a hash of a node reads all its values.
|
|
188
|
+
*/
|
|
189
|
+
readonly members: Map<Nodes.In, HashSet.HashSet<string>>
|
|
190
|
+
readonly budget: number
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** What a search has done up to now. Each step gives a new one. */
|
|
194
|
+
interface SearchState {
|
|
195
|
+
readonly steps: number
|
|
196
|
+
/** The conditions that the search found to be unsatisfiable. */
|
|
197
|
+
readonly failed: HashSet.HashSet<Node>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface Found {
|
|
201
|
+
readonly assignment: Option.Option<ReadonlyArray<readonly [string, unknown]>>
|
|
202
|
+
readonly state: SearchState
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const literalOf = (operand: Nodes.Operand): Nodes.Scalar => {
|
|
206
|
+
if (operand._tag !== "Literal") {
|
|
207
|
+
throw new Error(
|
|
208
|
+
"Condition.satisfiable needs a condition with no principal fields. Call Condition.partial first.",
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
return operand.value
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The value of the field `name` after `field` gets `value`. A field below
|
|
216
|
+
* `field` is `null` when `value` is not a value with parts.
|
|
217
|
+
*/
|
|
218
|
+
const fixedValue = (
|
|
219
|
+
name: string,
|
|
220
|
+
field: string,
|
|
221
|
+
value: unknown,
|
|
222
|
+
): unknown | typeof free =>
|
|
223
|
+
name === field
|
|
224
|
+
? value
|
|
225
|
+
: name.startsWith(`${field}.`)
|
|
226
|
+
? value === opaque
|
|
227
|
+
? free
|
|
228
|
+
: null
|
|
229
|
+
: free
|
|
230
|
+
|
|
231
|
+
const membersOf = (node: Nodes.In, search: Search): HashSet.HashSet<string> => {
|
|
232
|
+
const known = search.members.get(node)
|
|
233
|
+
if (known !== undefined) {
|
|
234
|
+
return known
|
|
235
|
+
}
|
|
236
|
+
// `===` finds no `NaN`, so the set has none, and `NaN` is in no test.
|
|
237
|
+
const members = HashSet.fromIterable(
|
|
238
|
+
Arr.map(
|
|
239
|
+
Arr.filter(
|
|
240
|
+
Arr.map(node.values, literalOf),
|
|
241
|
+
(literal) => !Number.isNaN(literal),
|
|
242
|
+
),
|
|
243
|
+
cellText,
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
search.members.set(node, members)
|
|
247
|
+
return members
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const testFixed = (
|
|
251
|
+
node: Nodes.Compare | Nodes.In | Nodes.Exists,
|
|
252
|
+
value: unknown,
|
|
253
|
+
search: Search,
|
|
254
|
+
): boolean => {
|
|
255
|
+
switch (node._tag) {
|
|
256
|
+
case "Compare":
|
|
257
|
+
return Algebra.testCompare(node.op, value, literalOf(node.value))
|
|
258
|
+
case "In":
|
|
259
|
+
return (
|
|
260
|
+
!Number.isNaN(value) &&
|
|
261
|
+
HashSet.has(membersOf(node, search), cellText(value))
|
|
262
|
+
)
|
|
263
|
+
case "Exists":
|
|
264
|
+
return value !== null
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Puts one value into a field, and into the fields below it. */
|
|
269
|
+
const fix = (
|
|
270
|
+
node: Node,
|
|
271
|
+
field: string,
|
|
272
|
+
value: unknown,
|
|
273
|
+
search: Search,
|
|
274
|
+
): Node => {
|
|
275
|
+
const fixChild = (child: Node) => fix(child, field, value, search)
|
|
276
|
+
switch (node._tag) {
|
|
277
|
+
case "Compare":
|
|
278
|
+
case "In":
|
|
279
|
+
case "Exists": {
|
|
280
|
+
const known = fixedValue(node.field, field, value)
|
|
281
|
+
return known === free
|
|
282
|
+
? node
|
|
283
|
+
: testFixed(node, known, search)
|
|
284
|
+
? Nodes.always
|
|
285
|
+
: Nodes.never
|
|
286
|
+
}
|
|
287
|
+
case "And":
|
|
288
|
+
return Algebra.and(Arr.map(node.conditions, fixChild))
|
|
289
|
+
case "Or":
|
|
290
|
+
return Algebra.or(Arr.map(node.conditions, fixChild))
|
|
291
|
+
case "Not":
|
|
292
|
+
return node.condition.pipe(fixChild, Algebra.not)
|
|
293
|
+
default:
|
|
294
|
+
return node
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The fields of the tests of a condition, once for each test. */
|
|
299
|
+
const testedFields = (node: Node): ReadonlyArray<string> => {
|
|
300
|
+
switch (node._tag) {
|
|
301
|
+
case "Compare":
|
|
302
|
+
case "In":
|
|
303
|
+
case "Exists":
|
|
304
|
+
return [node.field]
|
|
305
|
+
case "And":
|
|
306
|
+
case "Or":
|
|
307
|
+
return Arr.flatMap(node.conditions, testedFields)
|
|
308
|
+
case "Not":
|
|
309
|
+
return testedFields(node.condition)
|
|
310
|
+
default:
|
|
311
|
+
return []
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Picks the next field to split on. A field comes before the fields below it,
|
|
317
|
+
* so a value of a field can fix them. Then a field in more tests comes first,
|
|
318
|
+
* and then the first name in order, so the choice is the same each time.
|
|
319
|
+
*/
|
|
320
|
+
const nextField = (node: Node): string => {
|
|
321
|
+
const counts = MutableHashMap.empty<string, number>()
|
|
322
|
+
Arr.forEach(testedFields(node), (field) =>
|
|
323
|
+
MutableHashMap.set(
|
|
324
|
+
counts,
|
|
325
|
+
field,
|
|
326
|
+
Option.getOrElse(MutableHashMap.get(counts, field), () => 0) + 1,
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
const depth = (field: string) => field.split(".").length
|
|
330
|
+
const [first, ...rest] = Array.from(counts)
|
|
331
|
+
return Arr.reduce(rest, first!, (best, next) =>
|
|
332
|
+
depth(next[0]) < depth(best[0]) ||
|
|
333
|
+
(depth(next[0]) === depth(best[0]) &&
|
|
334
|
+
(next[1] > best[1] || (next[1] === best[1] && next[0] < best[0])))
|
|
335
|
+
? next
|
|
336
|
+
: best,
|
|
337
|
+
)[0]
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Finds values of the fields that make a simple condition true. It splits on
|
|
342
|
+
* one field at a time, and tries one value from each cell of the field. Each
|
|
343
|
+
* step reads the whole condition, so the budget counts its size.
|
|
344
|
+
*/
|
|
345
|
+
const searchNode = (
|
|
346
|
+
node: Node,
|
|
347
|
+
search: Search,
|
|
348
|
+
state: SearchState,
|
|
349
|
+
assignment: ReadonlyArray<readonly [string, unknown]>,
|
|
350
|
+
): Found => {
|
|
351
|
+
if (node._tag === "True") {
|
|
352
|
+
return { assignment: Option.some(assignment), state }
|
|
353
|
+
}
|
|
354
|
+
if (node._tag === "False" || HashSet.has(state.failed, node)) {
|
|
355
|
+
return { assignment: Option.none(), state }
|
|
356
|
+
}
|
|
357
|
+
if (state.steps + node.size > search.budget) {
|
|
358
|
+
throw new OutOfBudget()
|
|
359
|
+
}
|
|
360
|
+
const field = nextField(node)
|
|
361
|
+
// The cells are tried in order, until one gives values. A fold over the
|
|
362
|
+
// cells does no work after a found answer, and it does not grow the stack
|
|
363
|
+
// with the number of cells.
|
|
364
|
+
const result = Arr.reduce(
|
|
365
|
+
search.cells(field),
|
|
366
|
+
{
|
|
367
|
+
assignment: Option.none(),
|
|
368
|
+
state: { ...state, steps: state.steps + node.size },
|
|
369
|
+
} as Found,
|
|
370
|
+
(before, cell): Found => {
|
|
371
|
+
if (Option.isSome(before.assignment)) {
|
|
372
|
+
return before
|
|
373
|
+
}
|
|
374
|
+
const steps = before.state.steps + node.size
|
|
375
|
+
if (steps > search.budget) {
|
|
376
|
+
throw new OutOfBudget()
|
|
377
|
+
}
|
|
378
|
+
return searchNode(
|
|
379
|
+
fix(node, field, cell, search),
|
|
380
|
+
search,
|
|
381
|
+
{ ...before.state, steps },
|
|
382
|
+
[...assignment, [field, cell]],
|
|
383
|
+
)
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
return Option.isSome(result.assignment)
|
|
387
|
+
? result
|
|
388
|
+
: {
|
|
389
|
+
assignment: Option.none(),
|
|
390
|
+
state: {
|
|
391
|
+
...result.state,
|
|
392
|
+
// A condition with the same structure has the same free fields,
|
|
393
|
+
// so its answer is the same.
|
|
394
|
+
failed: HashSet.add(result.state.failed, node),
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Puts a value at a path in an object, and gives a new object. A value with
|
|
401
|
+
* parts (`opaque`) becomes an empty object, and it keeps an object that is
|
|
402
|
+
* there.
|
|
403
|
+
*/
|
|
404
|
+
const setPath = (
|
|
405
|
+
root: Readonly<Record<string, unknown>>,
|
|
406
|
+
names: ReadonlyArray<string>,
|
|
407
|
+
value: unknown,
|
|
408
|
+
): Readonly<Record<string, unknown>> => {
|
|
409
|
+
const [name, ...rest] = names
|
|
410
|
+
if (name === undefined) {
|
|
411
|
+
return root
|
|
412
|
+
}
|
|
413
|
+
// Only an own field counts, as a condition reads it. An inherited one,
|
|
414
|
+
// such as `__proto__`, is not a field of the example.
|
|
415
|
+
const inner = Object.hasOwn(root, name) ? root[name] : undefined
|
|
416
|
+
if (rest.length === 0) {
|
|
417
|
+
return value === opaque && isObject(inner)
|
|
418
|
+
? root
|
|
419
|
+
: { ...root, [name]: value === opaque ? {} : value }
|
|
420
|
+
}
|
|
421
|
+
const base =
|
|
422
|
+
isObject(inner) && inner !== opaque
|
|
423
|
+
? (inner as Readonly<Record<string, unknown>>)
|
|
424
|
+
: {}
|
|
425
|
+
return { ...root, [name]: setPath(base, rest, value) }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const exampleOf = (
|
|
429
|
+
assignment: ReadonlyArray<readonly [string, unknown]>,
|
|
430
|
+
): Example =>
|
|
431
|
+
Arr.reduce(
|
|
432
|
+
assignment,
|
|
433
|
+
{} as Readonly<Record<string, unknown>>,
|
|
434
|
+
(root, [field, value]) => setPath(root, field.split("."), value),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
/** The constants that the tests of each field use. */
|
|
438
|
+
const constantsOf = (
|
|
439
|
+
node: Node,
|
|
440
|
+
): HashMap.HashMap<string, HashSet.HashSet<Nodes.Scalar>> => {
|
|
441
|
+
const pairs = (
|
|
442
|
+
current: Node,
|
|
443
|
+
): ReadonlyArray<readonly [string, ReadonlyArray<Nodes.Scalar>]> => {
|
|
444
|
+
switch (current._tag) {
|
|
445
|
+
case "Compare":
|
|
446
|
+
return [[current.field, [literalOf(current.value)]]]
|
|
447
|
+
case "In":
|
|
448
|
+
return [[current.field, Arr.map(current.values, literalOf)]]
|
|
449
|
+
case "Exists":
|
|
450
|
+
return [[current.field, []]]
|
|
451
|
+
case "Under":
|
|
452
|
+
throw new Error(
|
|
453
|
+
"Condition.satisfiable needs a condition with no Under nodes. Call Condition.partial first.",
|
|
454
|
+
)
|
|
455
|
+
case "And":
|
|
456
|
+
case "Or":
|
|
457
|
+
return Arr.flatMap(current.conditions, pairs)
|
|
458
|
+
case "Not":
|
|
459
|
+
return pairs(current.condition)
|
|
460
|
+
default:
|
|
461
|
+
return []
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return Arr.reduce(pairs(node), HashMap.empty(), (all, [field, values]) =>
|
|
465
|
+
HashMap.set(
|
|
466
|
+
all,
|
|
467
|
+
field,
|
|
468
|
+
HashSet.union(
|
|
469
|
+
Option.getOrElse(HashMap.get(all, field), HashSet.empty),
|
|
470
|
+
HashSet.fromIterable(values),
|
|
471
|
+
),
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Gives the kinds of each field of a catalog. A field that it does not have may hold any value. */
|
|
477
|
+
export const kindsIn =
|
|
478
|
+
(catalog: Catalog.Catalog | undefined) =>
|
|
479
|
+
(field: string): Catalog.Kinds =>
|
|
480
|
+
(catalog === undefined ? undefined : Catalog.lookup(catalog, field))
|
|
481
|
+
?.kinds ?? Catalog.anyKinds
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Searches for values that make a condition true. `kindsOf` gives the kinds
|
|
485
|
+
* of values of each field. The condition must have no principal fields and no
|
|
486
|
+
* `Under` nodes.
|
|
487
|
+
*/
|
|
488
|
+
export const satisfiable = (
|
|
489
|
+
node: Node,
|
|
490
|
+
kindsOf: (field: string) => Catalog.Kinds,
|
|
491
|
+
budget: number = Limits.searchSteps,
|
|
492
|
+
): Satisfiability => {
|
|
493
|
+
if (node.opaque) {
|
|
494
|
+
return satisfiability.Unknown({ reason: "predicate" })
|
|
495
|
+
}
|
|
496
|
+
const constants = constantsOf(node)
|
|
497
|
+
// The search calls itself one time for each field that it fixes.
|
|
498
|
+
if (HashMap.size(constants) > Limits.searchFields) {
|
|
499
|
+
return satisfiability.Unknown({ reason: "budget" })
|
|
500
|
+
}
|
|
501
|
+
// The cells of each field. A field name is a string, so an Effect map
|
|
502
|
+
// finds it.
|
|
503
|
+
const cells = MutableHashMap.empty<string, ReadonlyArray<unknown>>()
|
|
504
|
+
const search: Search = {
|
|
505
|
+
members: new Map(),
|
|
506
|
+
cells: (field) =>
|
|
507
|
+
Option.getOrElse(MutableHashMap.get(cells, field), () => {
|
|
508
|
+
// The cells come from the constants, so many constants are work
|
|
509
|
+
// that the budget must count before the cells are made.
|
|
510
|
+
const known = Option.getOrElse(
|
|
511
|
+
HashMap.get(constants, field),
|
|
512
|
+
HashSet.empty,
|
|
513
|
+
)
|
|
514
|
+
if (HashSet.size(known) > budget) {
|
|
515
|
+
throw new OutOfBudget()
|
|
516
|
+
}
|
|
517
|
+
const found = cellsOf(kindsOf(field), Array.from(known))
|
|
518
|
+
MutableHashMap.set(cells, field, found)
|
|
519
|
+
return found
|
|
520
|
+
}),
|
|
521
|
+
budget,
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
const found = searchNode(
|
|
525
|
+
Algebra.simplify(node),
|
|
526
|
+
search,
|
|
527
|
+
{ steps: 0, failed: HashSet.empty() },
|
|
528
|
+
[],
|
|
529
|
+
)
|
|
530
|
+
return Option.match(found.assignment, {
|
|
531
|
+
onNone: () => satisfiability.Unsatisfiable(),
|
|
532
|
+
onSome: (assignment) =>
|
|
533
|
+
satisfiability.Satisfiable({ example: exampleOf(assignment) }),
|
|
534
|
+
})
|
|
535
|
+
} catch (error) {
|
|
536
|
+
if (error instanceof OutOfBudget) {
|
|
537
|
+
return satisfiability.Unknown({ reason: "budget" })
|
|
538
|
+
}
|
|
539
|
+
throw error
|
|
540
|
+
}
|
|
541
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggestions for a name with a mistake, as in "There is no field "titel".
|
|
3
|
+
* Did you mean "title"?".
|
|
4
|
+
*/
|
|
5
|
+
import * as Arr from "effect/Array"
|
|
6
|
+
import * as Option from "effect/Option"
|
|
7
|
+
import * as Order from "effect/Order"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The fewest changes that make one text into the other. A change adds, takes
|
|
11
|
+
* away or changes one character, or swaps two characters next to each other.
|
|
12
|
+
*/
|
|
13
|
+
const distance = (left: string, right: string): number => {
|
|
14
|
+
const from = Array.from(left)
|
|
15
|
+
const to = Array.from(right)
|
|
16
|
+
// Each step keeps the row before the last one too, for a swap.
|
|
17
|
+
const [, last] = Arr.reduce(
|
|
18
|
+
from,
|
|
19
|
+
[[], Arr.range(0, to.length)] as readonly [
|
|
20
|
+
ReadonlyArray<number>,
|
|
21
|
+
ReadonlyArray<number>,
|
|
22
|
+
],
|
|
23
|
+
([before, row], char, index) => {
|
|
24
|
+
const next = Arr.reduce(
|
|
25
|
+
to,
|
|
26
|
+
[index + 1] as ReadonlyArray<number>,
|
|
27
|
+
(cells, other, place) => {
|
|
28
|
+
const change = Math.min(
|
|
29
|
+
cells[place]! + 1,
|
|
30
|
+
row[place + 1]! + 1,
|
|
31
|
+
row[place]! + (char === other ? 0 : 1),
|
|
32
|
+
)
|
|
33
|
+
const swap =
|
|
34
|
+
index > 0 &&
|
|
35
|
+
place > 0 &&
|
|
36
|
+
char === to[place - 1] &&
|
|
37
|
+
from[index - 1] === other
|
|
38
|
+
? before[place - 1]! + 1
|
|
39
|
+
: Number.POSITIVE_INFINITY
|
|
40
|
+
return [...cells, Math.min(change, swap)]
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
return [row, next] as const
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
return last[to.length]!
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The longest name that gets a suggestion. */
|
|
50
|
+
const maxNameLength = 64
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Gives the name in `names` that is nearest to `name`, when it is near
|
|
54
|
+
* enough: at most one change for a short name, and two for a longer one.
|
|
55
|
+
*/
|
|
56
|
+
export const suggest = (
|
|
57
|
+
name: string,
|
|
58
|
+
names: ReadonlyArray<string>,
|
|
59
|
+
): string | undefined => {
|
|
60
|
+
// The work of one distance grows with the length of both names, so a very
|
|
61
|
+
// long name gets no suggestion.
|
|
62
|
+
if (name.length > maxNameLength) {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
const most = Math.min(2, Math.max(1, Math.floor(name.length / 2)))
|
|
66
|
+
// Two names whose lengths differ by more than `most` are not near, so
|
|
67
|
+
// only the others need a distance.
|
|
68
|
+
const close = Arr.filter(
|
|
69
|
+
names,
|
|
70
|
+
(other) => Math.abs(other.length - name.length) <= most,
|
|
71
|
+
)
|
|
72
|
+
const scored = Arr.map(
|
|
73
|
+
close,
|
|
74
|
+
(other) => [other, distance(name, other)] as const,
|
|
75
|
+
)
|
|
76
|
+
const near = Arr.filter(
|
|
77
|
+
scored,
|
|
78
|
+
([other, score]) => other !== name && score <= most,
|
|
79
|
+
)
|
|
80
|
+
return Option.getOrUndefined(
|
|
81
|
+
Option.map(
|
|
82
|
+
Arr.head(
|
|
83
|
+
Arr.sort(
|
|
84
|
+
near,
|
|
85
|
+
Order.mapInput(
|
|
86
|
+
Order.Number,
|
|
87
|
+
([, score]: readonly [string, number]) => score,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
([other]) => other,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Adds a suggestion to a message, when there is one. */
|
|
97
|
+
export const withSuggestion = (
|
|
98
|
+
message: string,
|
|
99
|
+
name: string,
|
|
100
|
+
names: ReadonlyArray<string>,
|
|
101
|
+
): string => {
|
|
102
|
+
const near = suggest(name, names)
|
|
103
|
+
return near === undefined ? message : `${message}. Did you mean "${near}"?`
|
|
104
|
+
}
|
package/dist/Principal.d.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import * as Schema from "effect/Schema";
|
|
2
|
-
declare namespace Principal_d_exports {
|
|
3
|
-
export { Any, Class };
|
|
4
|
-
}
|
|
5
|
-
/**
|
|
6
|
-
* Any Schema that can be a principal. Its type must be an object.
|
|
7
|
-
*
|
|
8
|
-
* @stability stable
|
|
9
|
-
* @category models
|
|
10
|
-
* @since 0.3.0
|
|
11
|
-
*/
|
|
12
|
-
export type Any = Schema.Top & {
|
|
13
|
-
readonly Type: object;
|
|
14
|
-
};
|
|
15
|
-
/**
|
|
16
|
-
* Makes a principal as a Schema class. It is the same as `Schema.Class`.
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* ```ts
|
|
20
|
-
* import { Schema } from "effect"
|
|
21
|
-
* import { Principal } from "@nmnmcc/ability"
|
|
22
|
-
*
|
|
23
|
-
* class Caller extends Principal.Class<Caller>("Caller")({
|
|
24
|
-
* userId: Schema.String,
|
|
25
|
-
* organizationId: Schema.String,
|
|
26
|
-
* }) {}
|
|
27
|
-
* ```
|
|
28
|
-
*
|
|
29
|
-
* @stability stable
|
|
30
|
-
* @category constructors
|
|
31
|
-
* @since 0.3.0
|
|
32
|
-
*/
|
|
33
|
-
export declare const Class: typeof Schema.Class;
|
|
34
|
-
export { Principal_d_exports };
|
|
35
|
-
//# sourceMappingURL=Principal.d.ts.map
|
package/dist/Principal.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Principal.d.ts","names":[],"sources":["../src/Principal.ts"],"mappings":";;;;;;;;;;;YAoBY,MAAM,OAAO;WAAiB;;;;;;;;;;;;;;;;;;;;qBAoB7B,cAAc,OAAO"}
|