@cplieger/deadset-ts 8.0.0-dev.6 → 8.0.0-dev.8

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 (193) hide show
  1. package/dist/bin/mapper-connection.js +2 -1
  2. package/dist/bin/node-host.js +24 -5
  3. package/dist/src/analysis.js +37 -56
  4. package/dist/src/automatic-types.js +10 -29
  5. package/dist/src/build-scripts.js +17 -29
  6. package/dist/src/component-layer.js +4 -2
  7. package/dist/src/components.js +2 -3
  8. package/dist/src/config.js +6 -82
  9. package/dist/src/configuration-files.js +27 -168
  10. package/dist/src/configured-declarations.js +15 -20
  11. package/dist/src/consumers.js +2 -2
  12. package/dist/src/contract/config.generated.js +544 -0
  13. package/dist/src/contract/contract.generated.js +13 -0
  14. package/dist/src/contract/report.generated.js +3 -2
  15. package/dist/src/conventions.js +20 -60
  16. package/dist/src/dependencies.js +25 -43
  17. package/dist/src/discover.js +14 -40
  18. package/dist/src/edges.js +38 -122
  19. package/dist/src/element-access.js +4 -6
  20. package/dist/src/entry-point-gaps.js +2 -8
  21. package/dist/src/entry-points.js +3 -10
  22. package/dist/src/enum-group.js +3 -10
  23. package/dist/src/erased-stores.js +4 -6
  24. package/dist/src/exempt-classes.js +0 -4
  25. package/dist/src/exempt.js +6 -22
  26. package/dist/src/file-facts.js +2 -25
  27. package/dist/src/findings/completion.js +5 -4
  28. package/dist/src/findings/emitters.js +1 -1
  29. package/dist/src/findings/intra-function.js +1 -1
  30. package/dist/src/findings/non-code-artifacts.js +2 -2
  31. package/dist/src/findings/reads-and-writes.js +2 -1
  32. package/dist/src/findings/self-check.js +3 -2
  33. package/dist/src/findings/unused-declarations.js +1 -1
  34. package/dist/src/findings/visibility-narrowing.js +2 -2
  35. package/dist/src/findings-pass.js +7 -12
  36. package/dist/src/framework-lifecycle.js +7 -7
  37. package/dist/src/fs/document.js +59 -0
  38. package/dist/src/fs/manifest.js +43 -0
  39. package/dist/src/fs/tree.js +88 -0
  40. package/dist/src/git-ignore.js +34 -25
  41. package/dist/src/html-entries.js +6 -33
  42. package/dist/src/in-operands.js +2 -1
  43. package/dist/src/injection-container.js +1 -1
  44. package/dist/src/intra-function-parts.js +10 -13
  45. package/dist/src/inventory.js +2 -21
  46. package/dist/src/json-document.js +198 -67
  47. package/dist/src/keyof-comparisons.js +4 -6
  48. package/dist/src/kinds.js +0 -7
  49. package/dist/src/manifest.js +5 -43
  50. package/dist/src/mdx-entries.js +3 -26
  51. package/dist/src/partial.js +2 -1
  52. package/dist/src/paths.js +26 -15
  53. package/dist/src/position.js +2 -1
  54. package/dist/src/print.js +11 -79
  55. package/dist/src/product.js +13 -0
  56. package/dist/src/references.js +14 -27
  57. package/dist/src/report-schema.js +3 -225
  58. package/dist/src/report.js +22 -31
  59. package/dist/src/reporters/sarif.js +4 -2
  60. package/dist/src/reporters/template.js +4 -19
  61. package/dist/src/resolve.js +245 -724
  62. package/dist/src/roots.js +7 -17
  63. package/dist/src/run.js +55 -61
  64. package/dist/src/schema.js +320 -343
  65. package/dist/src/scope.js +34 -103
  66. package/dist/src/serialization-contract.js +5 -9
  67. package/dist/src/session.js +2 -1
  68. package/dist/src/suppress-file.js +21 -25
  69. package/dist/src/suppress.js +3 -3
  70. package/dist/src/symbol-keys.js +3 -5
  71. package/dist/src/template-field.js +9 -19
  72. package/dist/src/test-globals.js +6 -28
  73. package/dist/src/test-joins.js +6 -6
  74. package/dist/src/text/compare.js +26 -0
  75. package/dist/src/text/glob.js +194 -0
  76. package/dist/src/text/shell.js +159 -0
  77. package/dist/src/{yaml-document.js → text/yaml.js} +38 -0
  78. package/dist/src/type-errors.js +14 -38
  79. package/dist/src/unseen-loads.js +2 -9
  80. package/dist/src/unwrap.js +23 -0
  81. package/dist/src/usage.js +10 -0
  82. package/dist/src/verbs/analyze.js +25 -26
  83. package/dist/src/verbs/describe.js +4 -4
  84. package/dist/src/verbs/explain.js +8 -6
  85. package/dist/src/verbs/print-projects.js +4 -3
  86. package/dist/src/verbs/print-retained.js +2 -1
  87. package/dist/src/verbs/print-roots.js +2 -1
  88. package/dist/src/verbs/version.js +2 -1
  89. package/dist/src/workflow-steps.js +9 -46
  90. package/dist/src/workspace-manifests.js +2 -2
  91. package/dist/src/workspace-programs.js +5 -3
  92. package/dist/src/workspace-resolution.js +13 -17
  93. package/dist/src/workspace.js +28 -116
  94. package/jsr.json +1 -1
  95. package/package.json +1 -1
  96. package/src/analysis.ts +55 -76
  97. package/src/automatic-types.ts +11 -29
  98. package/src/build-scripts.ts +17 -38
  99. package/src/component-layer.ts +4 -2
  100. package/src/components.ts +2 -4
  101. package/src/config.ts +31 -296
  102. package/src/configuration-files.ts +27 -170
  103. package/src/configured-declarations.ts +18 -23
  104. package/src/consumers.ts +4 -4
  105. package/src/contract/config.generated.ts +798 -0
  106. package/src/contract/contract.generated.ts +25 -0
  107. package/src/contract/report.generated.ts +3 -2
  108. package/src/conventions.ts +26 -74
  109. package/src/dependencies.ts +32 -50
  110. package/src/discover.ts +15 -40
  111. package/src/edges.ts +41 -140
  112. package/src/element-access.ts +4 -7
  113. package/src/entry-point-gaps.ts +3 -9
  114. package/src/entry-points.ts +2 -13
  115. package/src/enum-group.ts +2 -11
  116. package/src/erased-stores.ts +3 -7
  117. package/src/exempt-classes.ts +0 -5
  118. package/src/exempt.ts +8 -30
  119. package/src/file-facts.ts +6 -24
  120. package/src/findings/completion.ts +5 -6
  121. package/src/findings/emitter.ts +2 -2
  122. package/src/findings/emitters.ts +1 -1
  123. package/src/findings/intra-function.ts +1 -1
  124. package/src/findings/non-code-artifacts.ts +4 -4
  125. package/src/findings/reads-and-writes.ts +2 -1
  126. package/src/findings/self-check.ts +3 -2
  127. package/src/findings/unused-declarations.ts +1 -1
  128. package/src/findings/visibility-narrowing.ts +2 -2
  129. package/src/findings-pass.ts +9 -15
  130. package/src/framework-lifecycle.ts +7 -7
  131. package/src/fs/document.ts +74 -0
  132. package/src/fs/manifest.ts +52 -0
  133. package/src/fs/tree.ts +110 -0
  134. package/src/git-ignore.ts +38 -25
  135. package/src/host.ts +12 -5
  136. package/src/html-entries.ts +6 -33
  137. package/src/in-operands.ts +2 -1
  138. package/src/injection-container.ts +1 -1
  139. package/src/intra-function-parts.ts +10 -14
  140. package/src/inventory.ts +2 -20
  141. package/src/json-document.ts +241 -76
  142. package/src/keyof-comparisons.ts +3 -7
  143. package/src/kinds.ts +0 -8
  144. package/src/manifest.ts +5 -46
  145. package/src/mdx-entries.ts +3 -26
  146. package/src/partial.ts +2 -1
  147. package/src/paths.ts +27 -15
  148. package/src/position.ts +2 -1
  149. package/src/print.ts +12 -92
  150. package/src/product.ts +18 -0
  151. package/src/references.ts +14 -29
  152. package/src/report-schema.ts +3 -301
  153. package/src/report.ts +24 -34
  154. package/src/reporters/sarif.ts +4 -4
  155. package/src/reporters/template.ts +4 -20
  156. package/src/resolve.ts +283 -1114
  157. package/src/roots.ts +7 -20
  158. package/src/run.ts +57 -75
  159. package/src/schema.ts +391 -388
  160. package/src/scope.ts +40 -126
  161. package/src/serialization-contract.ts +5 -10
  162. package/src/session.ts +2 -1
  163. package/src/suppress-file.ts +21 -29
  164. package/src/suppress.ts +3 -3
  165. package/src/symbol-keys.ts +2 -5
  166. package/src/template-field.ts +15 -25
  167. package/src/test-globals.ts +9 -27
  168. package/src/test-joins.ts +6 -8
  169. package/src/text/compare.ts +27 -0
  170. package/src/text/glob.ts +210 -0
  171. package/src/text/shell.ts +161 -0
  172. package/src/{yaml-document.ts → text/yaml.ts} +46 -0
  173. package/src/type-errors.ts +25 -36
  174. package/src/unseen-loads.ts +1 -12
  175. package/src/unwrap.ts +34 -0
  176. package/src/usage.ts +10 -0
  177. package/src/verbs/analyze.ts +32 -42
  178. package/src/verbs/describe.ts +4 -4
  179. package/src/verbs/explain.ts +8 -10
  180. package/src/verbs/print-projects.ts +4 -3
  181. package/src/verbs/print-retained.ts +2 -1
  182. package/src/verbs/print-roots.ts +2 -1
  183. package/src/verbs/verb.ts +2 -2
  184. package/src/verbs/version.ts +2 -1
  185. package/src/workflow-steps.ts +9 -44
  186. package/src/workspace-manifests.ts +2 -2
  187. package/src/workspace-programs.ts +5 -3
  188. package/src/workspace-resolution.ts +21 -17
  189. package/src/workspace.ts +35 -121
  190. package/dist/src/glob.js +0 -65
  191. package/dist/src/pnpm-workspace.js +0 -120
  192. package/src/glob.ts +0 -67
  193. package/src/pnpm-workspace.ts +0 -135
package/src/schema.ts CHANGED
@@ -1,466 +1,469 @@
1
- import { ConfigError } from "./config.ts";
2
-
3
1
  /**
4
- * How one node of the closed key list is written: `leaf`, a scalar or an array of
5
- * scalars; `section`, an object of declared members and no value of its own; `map`, an
6
- * object of open member names and scalar values (severity, provenance); `list`, an
7
- * array of objects holding every admitted shape's members; `object`, one setting
8
- * written as an object of declared members and supplied whole. Which shape an entry or
9
- * a value takes, where the key list admits several, is the decoder's refusal.
2
+ * Holds a value to a JSON Schema the generator copies from the Contract. It evaluates the
3
+ * keywords the Contract's schemas use; the schemas' patterns are written to match alike in
4
+ * RE2 and in ECMAScript.
10
5
  */
11
- type KeyKind = "leaf" | "section" | "map" | "list" | "object";
6
+
7
+ import { isRecord, pointerOf, pointerSegments } from "./json-document.ts";
8
+ import { bytewise } from "./text/compare.ts";
12
9
 
13
10
  /**
14
- * One node of the closed key list. `members` holds a section's declared members,
15
- * or a list entry's, and is absent otherwise. `integer` marks a leaf of the
16
- * integer type, whose number the document writes as digits alone.
11
+ * One value a schema refuses: its JSON Pointer, the keyword, and why. `member` names the
12
+ * member a `required`, `additionalProperties` or `propertyNames` refusal is about, the
13
+ * object being at `pointer`, and is empty for every other keyword.
17
14
  */
18
- export interface KeyNode {
19
- readonly kind: KeyKind;
20
- readonly members?: Readonly<Record<string, KeyNode>>;
21
- readonly integer?: true;
15
+ export interface SchemaRefusal {
16
+ readonly pointer: string;
17
+ readonly keyword: string;
18
+ readonly reason: string;
19
+ readonly member: string;
22
20
  }
23
21
 
24
- const leaf: KeyNode = { kind: "leaf" };
25
- const integer: KeyNode = { kind: "leaf", integer: true };
26
-
27
- /** A list of declarations, each entry in one of the three shapes the `ts` section states. */
28
- const declarations: KeyNode = {
29
- kind: "list",
30
- members: { symbol: leaf, module: leaf, name: leaf, global: leaf },
31
- };
22
+ export type Schema = Readonly<Record<string, unknown>>;
23
+ type Documents = Readonly<Record<string, Schema>>;
32
24
 
33
25
  /**
34
- * The closed key list the Contract's configuration schema declares, derived from
35
- * that schema key by key. A test pins the two equal, so a Contract change that
36
- * adds a key fails there rather than being silently unimplemented.
26
+ * Every keyword the validator evaluates or knows to carry no assertion. A schema naming
27
+ * another fails every check rather than passing values the keyword would refuse.
37
28
  */
38
- export const SCHEMA_ROOT: KeyNode = {
39
- kind: "section",
40
- members: {
41
- contract_version: leaf,
42
- target: {
43
- kind: "section",
44
- members: { kind: { kind: "object", members: { go: leaf, ts: leaf } } },
45
- },
46
- analysis: {
47
- kind: "section",
48
- members: {
49
- languages: leaf,
50
- min_confidence: leaf,
51
- generated_files: leaf,
52
- consumer_tests: leaf,
53
- configurations: {
54
- kind: "list",
55
- members: { id: leaf, os: leaf, arch: leaf, tags: leaf, project: leaf },
56
- },
57
- matrix: { kind: "section", members: { complete: leaf } },
58
- template_dirs: leaf,
59
- template_delimiters: {
60
- kind: "object",
61
- members: { left: leaf, right: leaf },
62
- },
63
- },
64
- },
65
- consumers: { kind: "section", members: { complete: leaf } },
66
- roots: { kind: "section", members: { patterns: leaf } },
67
- severity: { kind: "map" },
68
- exemptions: { kind: "section", members: { disabled: leaf } },
69
- reporters: {
70
- kind: "section",
71
- members: {
72
- formats: leaf,
73
- sort: leaf,
74
- cascade: leaf,
75
- max_findings: integer,
76
- fail_on: leaf,
77
- },
78
- },
79
- providers: {
80
- kind: "section",
81
- members: {
82
- analyzers: {
83
- kind: "list",
84
- members: {
85
- name: leaf,
86
- languages: leaf,
87
- command: leaf,
88
- source: leaf,
89
- version: leaf,
90
- digest: leaf,
91
- },
92
- },
93
- },
94
- },
95
- go: { kind: "section", members: {} },
96
- ts: {
97
- kind: "section",
98
- members: {
99
- test_files: leaf,
100
- entry_files: leaf,
101
- component_extensions: leaf,
102
- disabled_conventions: leaf,
103
- injection_registrations: declarations,
104
- lifecycle_contracts: {
105
- kind: "list",
106
- members: { components: declarations, bases: declarations, members: leaf },
107
- },
108
- serializers: declarations,
109
- roots_complete: leaf,
110
- },
111
- },
112
- provenance: { kind: "map" },
113
- },
114
- };
115
-
116
- /** The name of the severity object, whose member names the key list leaves open. */
117
- export const SEVERITY_SECTION = "severity";
29
+ const KEYWORDS: ReadonlySet<string> = new Set([
30
+ ...["$schema", "$id", "$comment", "$defs", "title", "description", "default", "examples"],
31
+ ...["$ref", "type", "enum", "const", "pattern", "minLength", "minimum", "maximum"],
32
+ ...["properties", "additionalProperties", "propertyNames", "required", "minProperties"],
33
+ ...["items", "minItems", "maxItems", "uniqueItems"],
34
+ ...["allOf", "anyOf", "oneOf", "not", "if", "then", "else"],
35
+ ]);
118
36
 
119
- /**
120
- * The node one member name resolves to, and whether the key list declares it. A
121
- * member of an open object, and any member below a setting, resolves to a leaf,
122
- * so the walk descends far enough to find a repeated member at any depth.
123
- */
124
- function memberOf(node: KeyNode, name: string): KeyNode | undefined {
125
- if (node.kind === "section" || node.kind === "list" || node.kind === "object") {
126
- const members = node.members ?? {};
127
- return Object.hasOwn(members, name) ? members[name] : undefined;
128
- }
129
- return leaf;
130
- }
37
+ const patterns = new Map<string, RegExp>();
131
38
 
132
- function joinKey(at: string, name: string): string {
133
- return at === "" ? name : `${at}.${name}`;
39
+ function pattern(source: string): RegExp {
40
+ let held = patterns.get(source);
41
+ if (held === undefined) {
42
+ held = new RegExp(source, "u");
43
+ patterns.set(source, held);
44
+ }
45
+ return held;
134
46
  }
135
47
 
136
- function collect(node: KeyNode, at: string, keys: string[], settingsOnly: boolean): void {
137
- for (const [name, child] of Object.entries(node.members ?? {})) {
138
- const path = joinKey(at, name);
139
- if (!settingsOnly) {
140
- keys.push(path);
141
- collect(child, child.kind === "list" ? `${path}[]` : path, keys, settingsOnly);
142
- continue;
143
- }
144
- if (child.kind === "leaf" || child.kind === "list" || child.kind === "object") {
145
- keys.push(path);
146
- continue;
147
- }
148
- collect(child, path, keys, settingsOnly);
48
+ function typeOf(value: unknown): string {
49
+ if (value === null) {
50
+ return "null";
149
51
  }
52
+ if (Array.isArray(value)) {
53
+ return "array";
54
+ }
55
+ if (typeof value === "number") {
56
+ return Number.isInteger(value) ? "integer" : "number";
57
+ }
58
+ return typeof value;
150
59
  }
151
60
 
152
- /**
153
- * Every key the closed key list declares, as a dotted path, in ascending order:
154
- * the sections, the settings, and the two open objects. A list's entry members
155
- * are spelled under the list's own path followed by empty brackets.
156
- */
157
- export function declaredKeys(): string[] {
158
- const keys: string[] = [];
159
- collect(SCHEMA_ROOT, "", keys, false);
160
- return keys.sort();
61
+ function hasType(value: unknown, name: string): boolean {
62
+ const held = typeOf(value);
63
+ return held === name || (name === "number" && held === "integer");
161
64
  }
162
65
 
163
- /**
164
- * The dotted path of every setting that holds one value, in ascending order. A
165
- * list and a setting written as an object are each one setting, their members not
166
- * addressable on their own, and the severity object is not among them: it
167
- * resolves per code.
168
- */
169
- export function settingPaths(): string[] {
170
- const paths: string[] = [];
171
- collect(SCHEMA_ROOT, "", paths, true);
172
- return paths.sort();
66
+ /** The types a schema names, none where it names no type. */
67
+ function typesOf(schema: Schema): readonly string[] {
68
+ const types = schema["type"];
69
+ if (types === undefined) {
70
+ return [];
71
+ }
72
+ return (Array.isArray(types) ? types : [types]).filter((one) => typeof one === "string");
173
73
  }
174
74
 
175
- /**
176
- * Whether one dotted path names a setting a source may supply: a setting of the
177
- * closed key list, or one code of the severity object, whose member names the key
178
- * list leaves open. It is the predicate the command maps its own flag names
179
- * through, so a flag that would be refused as an unimplemented key is a failing
180
- * test rather than a flag nothing reads.
181
- *
182
- * A section holds no value of its own, so `analysis` is false. A setting written
183
- * as an object is supplied whole, so `analysis.template_delimiters` is true and
184
- * `analysis.template_delimiters.left` is false. A severity code is a declared
185
- * setting whatever it names, so `severity.DS9999` is true here and refused by
186
- * resolution: this answers the shape of a path, and resolution answers the
187
- * vocabulary.
188
- */
189
- export function declaresSetting(path: string): boolean {
190
- if (settingPaths().includes(path)) {
191
- return true;
75
+ /** One value's encoding with object members sorted, so two equal values encode alike. */
76
+ function canonical(value: unknown): string {
77
+ if (Array.isArray(value)) {
78
+ return `[${value.map(canonical).join(",")}]`;
192
79
  }
193
- const dot = path.indexOf(".");
194
- if (dot < 0) {
195
- return false;
80
+ if (isRecord(value)) {
81
+ return `{${Object.keys(value)
82
+ .sort(bytewise)
83
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
84
+ .join(",")}}`;
196
85
  }
197
- const code = path.slice(dot + 1);
198
- return path.slice(0, dot) === SEVERITY_SECTION && code !== "" && !code.includes(".");
86
+ return JSON.stringify(value);
199
87
  }
200
88
 
201
- /** The Levenshtein distance between two strings, counted in code points. */
202
- function editDistance(a: string, b: string): number {
203
- const from = Array.from(a);
204
- const to = Array.from(b);
205
- let previous = Array.from({ length: to.length + 1 }, (_unused, index) => index);
206
- let current = new Array<number>(to.length + 1).fill(0);
207
- for (let i = 1; i <= from.length; i += 1) {
208
- current[0] = i;
209
- for (let j = 1; j <= to.length; j += 1) {
210
- const substitution = (previous[j - 1] ?? 0) + (from[i - 1] === to[j - 1] ? 0 : 1);
211
- current[j] = Math.min(substitution, (previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1);
212
- }
213
- [previous, current] = [current, previous];
89
+ /** A string's length in code points, which JSON Schema's minLength counts. */
90
+ function codePoints(value: string): number {
91
+ let counted = 0;
92
+ for (const _point of value) {
93
+ counted += 1;
214
94
  }
215
- return previous[to.length] ?? 0;
95
+ return counted;
216
96
  }
217
97
 
218
- function sharedSegments(a: readonly string[], b: readonly string[]): number {
219
- let shared = 0;
220
- while (shared < a.length && shared < b.length && a[shared] === b[shared]) {
221
- shared += 1;
222
- }
223
- return shared;
98
+ /** The schemas of a list keyword, none where the schema does not write it. */
99
+ function listOf(schema: Schema, keyword: string): readonly Schema[] {
100
+ const held = schema[keyword];
101
+ return Array.isArray(held) ? (held as Schema[]) : [];
224
102
  }
225
103
 
226
- /**
227
- * The implemented key closest to `key`: the one sharing the most leading dotted
228
- * segments, and among those the smallest edit distance over the whole path, ties
229
- * broken in ascending order.
230
- */
231
- export function nearestKey(key: string): string {
232
- const segments = key.split(".");
233
- let nearest = "";
234
- let bestShared = -1;
235
- let bestDistance = 0;
236
- for (const candidate of declaredKeys()) {
237
- const shared = sharedSegments(segments, candidate.split("."));
238
- const distance = editDistance(key, candidate);
239
- if (shared < bestShared || (shared === bestShared && distance >= bestDistance)) {
240
- continue;
241
- }
242
- nearest = candidate;
243
- bestShared = shared;
244
- bestDistance = distance;
104
+ /** Whether a reader fixes refusal `a` before `b`: an object's before one inside it, then by kind. */
105
+ function before(a: SchemaRefusal, b: SchemaRefusal): boolean {
106
+ if (a.pointer === b.pointer) {
107
+ return rank(a) < rank(b);
245
108
  }
246
- return nearest;
109
+ return b.pointer.startsWith(`${a.pointer}/`);
247
110
  }
248
111
 
249
- /** A refusal of a document that is not one instance of the closed key list. */
250
- export function malformed(label: string, path: string, detail: string): ConfigError {
251
- const message = path === "" ? `${label}: ${detail}` : `${label}: ${path}: ${detail}`;
252
- return new ConfigError("malformed", path, message);
112
+ /** The order of the refusals of one place: an undeclared member, a missing one, any other. */
113
+ function rank(refusal: SchemaRefusal): number {
114
+ if (refusal.keyword === "additionalProperties" || refusal.keyword === "propertyNames") {
115
+ return 0;
116
+ }
117
+ return refusal.keyword === "required" ? 1 : 2;
253
118
  }
254
119
 
255
- /**
256
- * A refusal of a key the closed key list does not declare, naming the nearest key
257
- * it does.
258
- */
259
- function unimplementedKey(label: string, path: string): ConfigError {
260
- const nearest = nearestKey(path);
261
- let hint = "";
262
- if (nearest === path) {
263
- hint = `. ${JSON.stringify(path)} is one setting written as nested objects, not as one key`;
264
- } else if (nearest !== "") {
265
- hint = `. The nearest implemented key is ${JSON.stringify(nearest)}`;
120
+ /** The value one JSON Pointer segment names inside `value`, or undefined. */
121
+ function valueAt(value: unknown, segment: string): unknown {
122
+ if (Array.isArray(value)) {
123
+ return /^[0-9]+$/u.test(segment) ? value[Number(segment)] : undefined;
266
124
  }
267
- return new ConfigError(
268
- "unimplemented-key",
269
- path,
270
- `${label}: key ${JSON.stringify(path)} is not implemented${hint}`,
271
- );
125
+ return isRecord(value) && Object.hasOwn(value, segment) ? value[segment] : undefined;
272
126
  }
273
127
 
274
128
  /**
275
- * The position a walk over one document's text has reached. The walk exists
276
- * because the value a JSON parse returns cannot answer one question the Contract
277
- * asks: a member written twice at one level is a refusal, and a parse keeps the
278
- * last value, so the document reads as though it named the member once. Nothing
279
- * here produces a value; the value is the parse's.
129
+ * The schema `properties` declares for the member `name`, or undefined: a member named as an
130
+ * `Object.prototype` property, such as `__proto__`, is declared only where it is written.
280
131
  */
281
- interface Cursor {
282
- readonly text: string;
283
- at: number;
132
+ function declaredMember(properties: Readonly<Record<string, unknown>>, name: string): unknown {
133
+ return Object.hasOwn(properties, name) ? properties[name] : undefined;
284
134
  }
285
135
 
286
- const SPACE = new Set([0x20, 0x09, 0x0a, 0x0d]);
136
+ /** A schema with the document it is read from, so a relative `$ref` resolves there. */
137
+ type Located = readonly [string, Schema];
287
138
 
288
- function skipSpace(cursor: Cursor): void {
289
- while (cursor.at < cursor.text.length && SPACE.has(cursor.text.charCodeAt(cursor.at))) {
290
- cursor.at += 1;
291
- }
292
- }
139
+ /** The keyword checks over one set of schema documents, each named as a `$ref` names it. */
140
+ class Checker {
141
+ private readonly documents: Documents;
293
142
 
294
- /**
295
- * The string literal at the cursor, decoded by the standard library: the walk
296
- * finds the literal's extent and the parse reads it, so an escaped member name
297
- * compares equal to the name it spells.
298
- */
299
- function readName(cursor: Cursor, at: string, label: string): string {
300
- const start = cursor.at;
301
- cursor.at += 1;
302
- while (cursor.at < cursor.text.length) {
303
- const ch = cursor.text.charCodeAt(cursor.at);
304
- if (ch === 0x5c) {
305
- cursor.at += 2;
306
- continue;
307
- }
308
- cursor.at += 1;
309
- if (ch === 0x22) {
310
- return JSON.parse(cursor.text.slice(start, cursor.at)) as string;
311
- }
143
+ constructor(documents: Documents) {
144
+ this.documents = documents;
312
145
  }
313
- throw malformed(label, at, "a member name is not terminated");
314
- }
315
146
 
316
- function skipScalar(cursor: Cursor): void {
317
- while (cursor.at < cursor.text.length) {
318
- const ch = cursor.text.charCodeAt(cursor.at);
319
- if (ch === 0x2c || ch === 0x7d || ch === 0x5d || SPACE.has(ch)) {
320
- return;
147
+ resolve(doc: string, ref: string): Located {
148
+ const [file = "", pointer = ""] = ref.split("#");
149
+ const target = file === "" ? doc : file;
150
+ let schema: unknown = this.documents[target];
151
+ for (const token of pointerSegments(pointer)) {
152
+ schema = isRecord(schema) ? schema[token] : undefined;
153
+ }
154
+ if (!isRecord(schema)) {
155
+ throw new Error(`schema: ${ref} names no schema`);
321
156
  }
322
- cursor.at += 1;
157
+ return [target, schema];
323
158
  }
324
- }
325
159
 
326
- /** A number the document writes as an optional minus sign and digits alone. */
327
- const INTEGER_TEXT = /^-?[0-9]+$/u;
328
-
329
- /** The start of a JSON number: a minus sign or a digit. */
330
- const NUMBER_START = /^[-0-9]/u;
160
+ refusals(doc: string, schema: Schema, value: unknown, at: string): readonly SchemaRefusal[] {
161
+ const found: SchemaRefusal[] = [];
162
+ this.check(doc, schema, value, at, found);
163
+ return found;
164
+ }
331
165
 
332
- /**
333
- * Refuses a number written with a fraction or an exponent where the key list
334
- * declares an integer. A parse reads `100.0` and `1e2` as the integer 100, so only
335
- * the text can tell them apart. A scalar that is no number is the decoder's to
336
- * refuse by its type.
337
- */
338
- function checkInteger(text: string, at: string, label: string): void {
339
- if (NUMBER_START.test(text) && !INTEGER_TEXT.test(text)) {
340
- throw malformed(
341
- label,
342
- at,
343
- `${text} is written with a fraction or an exponent, and an integer is digits alone`,
344
- );
166
+ matches(doc: string, schema: Schema, value: unknown, at: string): boolean {
167
+ return this.refusals(doc, schema, value, at).length === 0;
345
168
  }
346
- }
347
169
 
348
- function walkValue(cursor: Cursor, at: string, node: KeyNode, label: string): void {
349
- skipSpace(cursor);
350
- const ch = cursor.text.charCodeAt(cursor.at);
351
- if (ch === 0x7b) {
352
- cursor.at += 1;
353
- walkObject(cursor, at, node, label);
354
- return;
170
+ check(doc: string, schema: Schema, value: unknown, at: string, found: SchemaRefusal[]): void {
171
+ for (const keyword of Object.keys(schema)) {
172
+ if (!KEYWORDS.has(keyword) && !keyword.startsWith("x-")) {
173
+ throw new Error(`schema: the keyword ${keyword} is not one this validator evaluates`);
174
+ }
175
+ }
176
+ const refuse = (keyword: string, reason: string, member = ""): void => {
177
+ found.push({ pointer: at, keyword, reason, member });
178
+ };
179
+ if (typeof schema["$ref"] === "string") {
180
+ const [target, referenced] = this.resolve(doc, schema["$ref"]);
181
+ this.check(target, referenced, value, at, found);
182
+ }
183
+ const named = typesOf(schema);
184
+ if (named.length > 0 && !named.some((one) => hasType(value, one))) {
185
+ refuse("type", `the value is ${typeOf(value)}, want ${named.join(", ")}`);
186
+ return;
187
+ }
188
+ this.checkValue(schema, value, refuse);
189
+ if (Array.isArray(value)) {
190
+ this.checkArray(doc, schema, value, at, found, refuse);
191
+ } else if (isRecord(value)) {
192
+ this.checkObject(doc, schema, value, at, found, refuse);
193
+ }
194
+ this.checkApplicators(doc, schema, value, at, found, refuse);
355
195
  }
356
- if (ch === 0x5b) {
357
- cursor.at += 1;
358
- walkArray(cursor, at, node, label);
359
- return;
196
+
197
+ private checkValue(
198
+ schema: Schema,
199
+ value: unknown,
200
+ refuse: (keyword: string, reason: string) => void,
201
+ ): void {
202
+ const values = schema["enum"];
203
+ if (Array.isArray(values) && !values.some((one) => canonical(one) === canonical(value))) {
204
+ refuse("enum", `${canonical(value)} is not one of ${values.map(canonical).join(", ")}`);
205
+ }
206
+ if (schema["const"] !== undefined && canonical(schema["const"]) !== canonical(value)) {
207
+ refuse("const", `${canonical(value)} is not ${canonical(schema["const"])}`);
208
+ }
209
+ if (typeof value === "string") {
210
+ if (typeof schema["pattern"] === "string" && !pattern(schema["pattern"]).test(value)) {
211
+ refuse("pattern", `${JSON.stringify(value)} does not match ${schema["pattern"]}`);
212
+ }
213
+ if (typeof schema["minLength"] === "number" && codePoints(value) < schema["minLength"]) {
214
+ refuse(
215
+ "minLength",
216
+ `${JSON.stringify(value)} is shorter than ${String(schema["minLength"])} characters`,
217
+ );
218
+ }
219
+ }
220
+ if (typeof value === "number") {
221
+ if (typeof schema["minimum"] === "number" && value < schema["minimum"]) {
222
+ refuse("minimum", `${String(value)} is below ${String(schema["minimum"])}`);
223
+ }
224
+ if (typeof schema["maximum"] === "number" && value > schema["maximum"]) {
225
+ refuse("maximum", `${String(value)} is above ${String(schema["maximum"])}`);
226
+ }
227
+ }
360
228
  }
361
- if (ch === 0x22) {
362
- readName(cursor, at, label);
363
- return;
229
+
230
+ private checkObject(
231
+ doc: string,
232
+ schema: Schema,
233
+ value: Readonly<Record<string, unknown>>,
234
+ at: string,
235
+ found: SchemaRefusal[],
236
+ refuse: (keyword: string, reason: string, member?: string) => void,
237
+ ): void {
238
+ const properties = isRecord(schema["properties"]) ? schema["properties"] : {};
239
+ const additional = schema["additionalProperties"];
240
+ const names = schema["propertyNames"];
241
+ for (const name of Object.keys(value).sort(bytewise)) {
242
+ const [refusedName] = isRecord(names) ? this.refusals(doc, names, name, at) : [];
243
+ if (refusedName !== undefined) {
244
+ refuse(
245
+ "propertyNames",
246
+ `the member name ${JSON.stringify(name)}: ${refusedName.reason}`,
247
+ name,
248
+ );
249
+ }
250
+ const member = `${at}${pointerOf([name])}`;
251
+ const declared = declaredMember(properties, name);
252
+ if (isRecord(declared)) {
253
+ this.check(doc, declared, value[name], member, found);
254
+ } else if (additional === false) {
255
+ refuse(
256
+ "additionalProperties",
257
+ `the member ${JSON.stringify(name)} is not one the schema declares`,
258
+ name,
259
+ );
260
+ } else if (isRecord(additional)) {
261
+ this.check(doc, additional, value[name], member, found);
262
+ }
263
+ }
264
+ for (const name of Array.isArray(schema["required"]) ? (schema["required"] as string[]) : []) {
265
+ if (!Object.hasOwn(value, name)) {
266
+ refuse("required", `the member ${JSON.stringify(name)} is absent`, name);
267
+ }
268
+ }
269
+ const fewest = schema["minProperties"];
270
+ if (typeof fewest === "number" && Object.keys(value).length < fewest) {
271
+ refuse(
272
+ "minProperties",
273
+ `the object holds ${String(Object.keys(value).length)} members, fewer than ${String(fewest)}`,
274
+ );
275
+ }
364
276
  }
365
- const start = cursor.at;
366
- skipScalar(cursor);
367
- if (node.integer === true) {
368
- checkInteger(cursor.text.slice(start, cursor.at), at, label);
277
+
278
+ private checkArray(
279
+ doc: string,
280
+ schema: Schema,
281
+ value: readonly unknown[],
282
+ at: string,
283
+ found: SchemaRefusal[],
284
+ refuse: (keyword: string, reason: string) => void,
285
+ ): void {
286
+ const items = schema["items"];
287
+ if (isRecord(items)) {
288
+ value.forEach((item, i) => {
289
+ this.check(doc, items, item, `${at}/${String(i)}`, found);
290
+ });
291
+ }
292
+ if (typeof schema["minItems"] === "number" && value.length < schema["minItems"]) {
293
+ refuse(
294
+ "minItems",
295
+ `the array holds ${String(value.length)} items, fewer than ${String(schema["minItems"])}`,
296
+ );
297
+ }
298
+ if (typeof schema["maxItems"] === "number" && value.length > schema["maxItems"]) {
299
+ refuse(
300
+ "maxItems",
301
+ `the array holds ${String(value.length)} items, more than ${String(schema["maxItems"])}`,
302
+ );
303
+ }
304
+ if (schema["uniqueItems"] === true && new Set(value.map(canonical)).size !== value.length) {
305
+ refuse("uniqueItems", "the array lists one value twice");
306
+ }
369
307
  }
370
- }
371
308
 
372
- function walkObject(cursor: Cursor, at: string, node: KeyNode, label: string): void {
373
- const seen = new Set<string>();
374
- for (;;) {
375
- skipSpace(cursor);
376
- const ch = cursor.text.charCodeAt(cursor.at);
377
- if (ch === 0x7d) {
378
- cursor.at += 1;
379
- return;
309
+ private checkApplicators(
310
+ doc: string,
311
+ schema: Schema,
312
+ value: unknown,
313
+ at: string,
314
+ found: SchemaRefusal[],
315
+ refuse: (keyword: string, reason: string) => void,
316
+ ): void {
317
+ for (const each of listOf(schema, "allOf")) {
318
+ this.check(doc, each, value, at, found);
380
319
  }
381
- if (ch === 0x2c) {
382
- cursor.at += 1;
383
- continue;
320
+ const matching = (keyword: string): number =>
321
+ listOf(schema, keyword).filter((each) => this.matches(doc, each, value, at)).length;
322
+ if (schema["anyOf"] !== undefined && matching("anyOf") === 0) {
323
+ refuse("anyOf", "the value matches none of the alternatives");
384
324
  }
385
- if (ch !== 0x22) {
386
- throw malformed(label, at, "a member name is expected");
325
+ if (schema["oneOf"] !== undefined && matching("oneOf") !== 1) {
326
+ refuse(
327
+ "oneOf",
328
+ `the value matches ${String(matching("oneOf"))} of the alternatives, want exactly one`,
329
+ );
330
+ }
331
+ if (isRecord(schema["not"]) && this.matches(doc, schema["not"], value, at)) {
332
+ refuse("not", "the value matches a schema it must not match");
387
333
  }
388
- const name = readName(cursor, at, label);
389
- const path = joinKey(at, name);
390
- if (seen.has(name)) {
391
- throw malformed(
392
- label,
393
- path,
394
- `member ${JSON.stringify(name)} is written twice, so neither value is chosen`,
334
+ if (isRecord(schema["if"])) {
335
+ const branch = this.matches(doc, schema["if"], value, at) ? schema["then"] : schema["else"];
336
+ if (isRecord(branch)) {
337
+ this.check(doc, branch, value, at, found);
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * The refusal of `value` a reader fixes first, none for an instance: an object's
344
+ * refusal before any inside it, at one place an undeclared member, then a missing one,
345
+ * then any other, else the order the check finds them in. A oneOf no alternative
346
+ * matches is explained by the first refusal of the alternative the value was written
347
+ * for: the one whose type admits it, or of several, the one declaring the most of an
348
+ * object's members, then the fewest. Where two remain, the refusal is the oneOf's own.
349
+ */
350
+ first(doc: string, schema: Schema, value: unknown, at: string): SchemaRefusal | undefined {
351
+ const found: SchemaRefusal[] = [];
352
+ this.check(doc, schema, value, at, found);
353
+ let first = found[0];
354
+ for (const other of found.slice(1)) {
355
+ if (first !== undefined && before(other, first)) {
356
+ first = other;
357
+ }
358
+ }
359
+ if (first?.keyword !== "oneOf") {
360
+ return first;
361
+ }
362
+ let held = value;
363
+ let holders: readonly Located[] = [[doc, schema]];
364
+ for (const segment of pointerSegments(first.pointer.slice(at.length))) {
365
+ holders = holders.flatMap(([d, s]) =>
366
+ this.applied(d, s, held).flatMap(([d2, s2]) => this.member(d2, s2, held, segment)),
395
367
  );
368
+ held = valueAt(held, segment);
369
+ }
370
+ for (const [d, holder] of holders) {
371
+ const alternatives = listOf(holder, "oneOf");
372
+ if (alternatives.some((each) => this.matches(d, each, held, first.pointer))) {
373
+ continue;
374
+ }
375
+ const alternative = closest(alternatives, held);
376
+ const inner = alternative && this.first(d, alternative, held, first.pointer);
377
+ if (inner !== undefined) {
378
+ return inner;
379
+ }
380
+ }
381
+ return first;
382
+ }
383
+
384
+ /** The schemas checked at one place when `schema` is: it, its `$ref`, its allOf and its if branch. */
385
+ private applied(doc: string, schema: Schema, value: unknown): readonly Located[] {
386
+ const held: Located[] = [[doc, schema]];
387
+ if (typeof schema["$ref"] === "string") {
388
+ const [target, referenced] = this.resolve(doc, schema["$ref"]);
389
+ held.push(...this.applied(target, referenced, value));
396
390
  }
397
- seen.add(name);
398
- const child = memberOf(node, name);
399
- if (child === undefined) {
400
- throw unimplementedKey(label, path);
391
+ for (const each of listOf(schema, "allOf")) {
392
+ held.push(...this.applied(doc, each, value));
401
393
  }
402
- skipSpace(cursor);
403
- if (cursor.text.charCodeAt(cursor.at) !== 0x3a) {
404
- throw malformed(label, path, "a member name is followed by a colon");
394
+ if (isRecord(schema["if"])) {
395
+ const branch = this.matches(doc, schema["if"], value, "") ? schema["then"] : schema["else"];
396
+ if (isRecord(branch)) {
397
+ held.push(...this.applied(doc, branch, value));
398
+ }
405
399
  }
406
- cursor.at += 1;
407
- walkValue(cursor, path, child, label);
400
+ return held;
408
401
  }
409
- }
410
402
 
411
- function walkArray(cursor: Cursor, at: string, node: KeyNode, label: string): void {
412
- const entry: KeyNode =
413
- node.kind === "list" ? { kind: "section", members: node.members ?? {} } : leaf;
414
- let index = 0;
415
- for (;;) {
416
- skipSpace(cursor);
417
- const ch = cursor.text.charCodeAt(cursor.at);
418
- if (ch === 0x5d) {
419
- cursor.at += 1;
420
- return;
403
+ /** The schemas `schema` checks the member or the entry `segment` names in `value` with. */
404
+ private member(doc: string, schema: Schema, value: unknown, segment: string): readonly Located[] {
405
+ if (Array.isArray(value)) {
406
+ return isRecord(schema["items"]) ? [[doc, schema["items"]]] : [];
421
407
  }
422
- if (ch === 0x2c) {
423
- cursor.at += 1;
424
- continue;
408
+ const properties = isRecord(schema["properties"]) ? schema["properties"] : {};
409
+ const declared = declaredMember(properties, segment);
410
+ if (isRecord(declared)) {
411
+ return [[doc, declared]];
412
+ }
413
+ const additional = schema["additionalProperties"];
414
+ return isRecord(additional) ? [[doc, additional]] : [];
415
+ }
416
+ }
417
+
418
+ /** The alternative a value matching none of them was written for, or none where none or two remain. */
419
+ function closest(alternatives: readonly Schema[], value: unknown): Schema | undefined {
420
+ const typed = alternatives.filter((each) => {
421
+ const types = typesOf(each);
422
+ return types.length === 0 || types.some((one) => hasType(value, one));
423
+ });
424
+ if (typed.length === 1 || !isRecord(value)) {
425
+ return typed.length === 1 ? typed[0] : undefined;
426
+ }
427
+ let best: Schema[] = [];
428
+ let most = -1;
429
+ let fewest = 0;
430
+ for (const alternative of typed) {
431
+ const properties = isRecord(alternative["properties"]) ? alternative["properties"] : {};
432
+ const named = Object.keys(value).filter((name) => Object.hasOwn(properties, name)).length;
433
+ const declared = Object.keys(properties).length;
434
+ if (named > most || (named === most && declared < fewest)) {
435
+ [best, most, fewest] = [[alternative], named, declared];
436
+ } else if (named === most && declared === fewest) {
437
+ best.push(alternative);
425
438
  }
426
- walkValue(cursor, `${at}[${index}]`, entry, label);
427
- index += 1;
428
439
  }
440
+ return best.length === 1 ? best[0] : undefined;
429
441
  }
430
442
 
431
443
  /**
432
- * Walks one document's text against the closed key list, refusing a member the
433
- * key list does not declare and a member one object writes twice, each naming its
434
- * dotted path. The document is already known to parse.
444
+ * A validator over one set of schema documents, each named as a `$ref` names it, rooted
445
+ * at one of them: what it refuses in a value, none for an instance.
435
446
  */
436
- export function checkDocument(text: string, node: KeyNode, label: string): void {
437
- const cursor: Cursor = { text, at: 0 };
438
- walkValue(cursor, "", node, label);
447
+ export function schemaRefusals(
448
+ documents: Documents,
449
+ root: string,
450
+ ): (value: unknown) => readonly SchemaRefusal[] {
451
+ const checker = new Checker(documents);
452
+ const top = documents[root];
453
+ if (top === undefined) {
454
+ throw new Error(`the schema set holds no document ${root}`);
455
+ }
456
+ return (value) => {
457
+ const found: SchemaRefusal[] = [];
458
+ checker.check(root, top, value, "", found);
459
+ return found;
460
+ };
439
461
  }
440
462
 
441
463
  /**
442
- * The flag document, whose keys are dotted setting paths, rewritten as the nested
443
- * object the closed key list declares, so one decoder reads every source. A key
444
- * naming no setting is refused.
464
+ * The refusal of `value` a reader fixes first under one schema, none for an instance. A
465
+ * `$ref` the schema holds names a place in the schema itself.
445
466
  */
446
- export function nestFlags(flat: Readonly<Record<string, unknown>>, label: string): unknown {
447
- const nested: Record<string, unknown> = {};
448
- for (const path of Object.keys(flat).sort()) {
449
- if (!declaresSetting(path)) {
450
- throw unimplementedKey(label, path);
451
- }
452
- const segments = path.split(".");
453
- let into = nested;
454
- for (const segment of segments.slice(0, -1)) {
455
- const child = into[segment] ?? {};
456
- if (typeof child !== "object" || Array.isArray(child)) {
457
- throw malformed(label, path, `the flag document sets ${JSON.stringify(segment)} twice`);
458
- }
459
- into[segment] = child;
460
- into = child as Record<string, unknown>;
461
- }
462
- const last = segments[segments.length - 1] ?? "";
463
- into[last] = flat[path];
464
- }
465
- return nested;
467
+ export function firstRefusal(schema: Schema, value: unknown): SchemaRefusal | undefined {
468
+ return new Checker({ "": schema }).first("", schema, value, "");
466
469
  }