@cplieger/deadset-ts 8.0.0-dev.7 → 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.
- package/dist/bin/node-host.js +21 -3
- package/dist/src/analysis.js +13 -14
- package/dist/src/config.js +6 -83
- package/dist/src/configured-declarations.js +15 -20
- package/dist/src/consumers.js +2 -2
- package/dist/src/contract/config.generated.js +544 -0
- package/dist/src/contract/report.generated.js +3 -2
- package/dist/src/discover.js +1 -1
- package/dist/src/edges.js +36 -112
- package/dist/src/exempt-classes.js +0 -2
- package/dist/src/exempt.js +1 -1
- package/dist/src/findings/completion.js +5 -4
- package/dist/src/findings/emitters.js +1 -1
- package/dist/src/findings/intra-function.js +1 -1
- package/dist/src/findings/non-code-artifacts.js +2 -2
- package/dist/src/findings/unused-declarations.js +1 -1
- package/dist/src/findings/visibility-narrowing.js +2 -2
- package/dist/src/findings-pass.js +1 -1
- package/dist/src/framework-lifecycle.js +7 -7
- package/dist/src/fs/document.js +59 -0
- package/dist/src/injection-container.js +1 -1
- package/dist/src/json-document.js +194 -67
- package/dist/src/kinds.js +0 -7
- package/dist/src/print.js +10 -79
- package/dist/src/report-schema.js +3 -224
- package/dist/src/resolve.js +245 -715
- package/dist/src/run.js +48 -55
- package/dist/src/schema.js +320 -344
- package/dist/src/scope.js +33 -101
- package/dist/src/serialization-contract.js +1 -1
- package/dist/src/suppress-file.js +15 -17
- package/dist/src/suppress.js +3 -3
- package/dist/src/template-field.js +4 -4
- package/dist/src/usage.js +10 -0
- package/dist/src/verbs/analyze.js +12 -15
- package/dist/src/verbs/explain.js +3 -3
- package/dist/src/verbs/print-projects.js +2 -2
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +30 -26
- package/src/config.ts +31 -298
- package/src/configured-declarations.ts +18 -23
- package/src/consumers.ts +4 -4
- package/src/contract/config.generated.ts +798 -0
- package/src/contract/report.generated.ts +3 -2
- package/src/discover.ts +1 -1
- package/src/edges.ts +39 -124
- package/src/exempt-classes.ts +0 -5
- package/src/exempt.ts +4 -4
- package/src/findings/completion.ts +5 -6
- package/src/findings/emitter.ts +2 -2
- package/src/findings/emitters.ts +1 -1
- package/src/findings/intra-function.ts +1 -1
- package/src/findings/non-code-artifacts.ts +4 -4
- package/src/findings/unused-declarations.ts +1 -1
- package/src/findings/visibility-narrowing.ts +2 -2
- package/src/findings-pass.ts +3 -3
- package/src/framework-lifecycle.ts +7 -7
- package/src/fs/document.ts +74 -0
- package/src/host.ts +12 -5
- package/src/injection-container.ts +1 -1
- package/src/json-document.ts +236 -76
- package/src/kinds.ts +0 -8
- package/src/print.ts +11 -92
- package/src/report-schema.ts +3 -299
- package/src/resolve.ts +281 -1105
- package/src/run.ts +50 -63
- package/src/schema.ts +391 -389
- package/src/scope.ts +39 -119
- package/src/serialization-contract.ts +1 -1
- package/src/suppress-file.ts +15 -20
- package/src/suppress.ts +3 -3
- package/src/template-field.ts +10 -7
- package/src/usage.ts +10 -0
- package/src/verbs/analyze.ts +12 -23
- package/src/verbs/explain.ts +3 -7
- package/src/verbs/print-projects.ts +2 -2
- package/src/verbs/verb.ts +2 -2
|
@@ -1,106 +1,228 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A
|
|
3
|
-
*
|
|
2
|
+
* A text that is not one strict JSON document: a syntax error, data after the value, a
|
|
3
|
+
* member one object writes twice, or nesting past {@link MAX_DEPTH}. `place` is the
|
|
4
|
+
* value the fault is in, followed by the member written twice.
|
|
4
5
|
*/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
export class StrictJSONError extends Error {
|
|
7
|
+
offset;
|
|
8
|
+
place;
|
|
9
|
+
constructor(reason, offset, place) {
|
|
10
|
+
super(reason);
|
|
11
|
+
this.name = "StrictJSONError";
|
|
12
|
+
this.offset = offset;
|
|
13
|
+
this.place = place;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* How deep values may nest. A document this analyzer reads nests a few levels, and a
|
|
18
|
+
* bound turns a document built to exhaust the stack into a refusal.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_DEPTH = 1000;
|
|
21
|
+
const SPACE = /[ \t\n\r]*/uy;
|
|
22
|
+
const STRING = /"(?:[ !#$%&'()*+,\-./0-9:;<=>?@A-Z[\]-\u{10FFFF}]|\\(?:["\\/bfnrt]|u[0-9A-Fa-f]{4}))*"/uy;
|
|
23
|
+
const NUMBER = /-?(?:0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/uy;
|
|
24
|
+
const LITERALS = new Map([
|
|
25
|
+
["true", true],
|
|
26
|
+
["false", false],
|
|
27
|
+
["null", null],
|
|
28
|
+
]);
|
|
29
|
+
/** One member name spelled as a JSON Pointer token. */
|
|
30
|
+
const escaped = (name) => name.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
31
|
+
/** The member names and indexes a JSON Pointer holds, unescaped. */
|
|
32
|
+
export function pointerSegments(pointer) {
|
|
33
|
+
return pointer === ""
|
|
34
|
+
? []
|
|
35
|
+
: pointer
|
|
36
|
+
.slice(1)
|
|
37
|
+
.split("/")
|
|
38
|
+
.map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
39
|
+
}
|
|
40
|
+
/** The JSON Pointer of a place written as segments. */
|
|
41
|
+
export function pointerOf(segments) {
|
|
42
|
+
return segments.map((segment) => `/${escaped(segment)}`).join("");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The place a JSON Pointer names inside `value`, followed by `member` where it is not
|
|
46
|
+
* empty: a pointer's text does not say whether a segment of digits is an index, so the
|
|
47
|
+
* value it points into does.
|
|
48
|
+
*/
|
|
49
|
+
export function placeIn(value, pointer, member = "") {
|
|
50
|
+
const place = [];
|
|
51
|
+
let held = value;
|
|
52
|
+
for (const segment of pointerSegments(pointer)) {
|
|
53
|
+
if (Array.isArray(held)) {
|
|
54
|
+
place.push(Number(segment));
|
|
55
|
+
held = held[Number(segment)];
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
place.push(segment);
|
|
59
|
+
held = isRecord(held) ? held[segment] : undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return member === "" ? place : [...place, member];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A place as a refusal names it: member names joined by full stops and an index in
|
|
66
|
+
* square brackets, as `ts.serializers[0].returns`.
|
|
67
|
+
*/
|
|
68
|
+
export function dotted(place) {
|
|
69
|
+
let path = "";
|
|
70
|
+
for (const segment of place) {
|
|
71
|
+
path +=
|
|
72
|
+
typeof segment === "number" ? `[${String(segment)}]` : path === "" ? segment : `.${segment}`;
|
|
73
|
+
}
|
|
74
|
+
return path;
|
|
75
|
+
}
|
|
76
|
+
class Reader {
|
|
12
77
|
at = 0;
|
|
13
|
-
|
|
78
|
+
path = [];
|
|
79
|
+
fractions = new Map();
|
|
80
|
+
starts = new Map();
|
|
81
|
+
text;
|
|
82
|
+
records;
|
|
83
|
+
constructor(text, records) {
|
|
14
84
|
this.text = text;
|
|
15
|
-
this.
|
|
85
|
+
this.records = records;
|
|
16
86
|
}
|
|
17
|
-
|
|
18
|
-
this.value(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this.at += 1;
|
|
87
|
+
read() {
|
|
88
|
+
const value = this.value(0);
|
|
89
|
+
this.match(SPACE);
|
|
90
|
+
if (this.at < this.text.length) {
|
|
91
|
+
this.fail("data follows the document's value");
|
|
23
92
|
}
|
|
93
|
+
return value;
|
|
24
94
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
95
|
+
fail(reason, offset = this.at, member = "") {
|
|
96
|
+
throw new StrictJSONError(reason, offset, member === "" ? [...this.path] : [...this.path, member]);
|
|
97
|
+
}
|
|
98
|
+
match(pattern) {
|
|
99
|
+
pattern.lastIndex = this.at;
|
|
100
|
+
const found = pattern.exec(this.text)?.[0];
|
|
101
|
+
this.at = found === undefined ? this.at : pattern.lastIndex;
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
expect(char, what) {
|
|
105
|
+
this.match(SPACE);
|
|
106
|
+
if (this.text[this.at] !== char) {
|
|
107
|
+
this.fail(`${what} is expected`);
|
|
30
108
|
}
|
|
31
109
|
this.at += 1;
|
|
32
|
-
return JSON.parse(this.text.slice(start, this.at));
|
|
33
110
|
}
|
|
34
|
-
value(
|
|
35
|
-
this.
|
|
36
|
-
if (
|
|
37
|
-
this.
|
|
111
|
+
value(depth) {
|
|
112
|
+
this.match(SPACE);
|
|
113
|
+
if (depth > MAX_DEPTH) {
|
|
114
|
+
this.fail(`values nest deeper than ${String(MAX_DEPTH)} levels`);
|
|
38
115
|
}
|
|
39
116
|
const char = this.text[this.at];
|
|
40
117
|
if (char === "{") {
|
|
41
|
-
this.object(
|
|
118
|
+
return this.object(depth);
|
|
42
119
|
}
|
|
43
|
-
|
|
44
|
-
this.
|
|
120
|
+
if (char === "[") {
|
|
121
|
+
return this.array(depth);
|
|
45
122
|
}
|
|
46
|
-
|
|
47
|
-
|
|
123
|
+
const string = this.match(STRING);
|
|
124
|
+
if (string !== undefined) {
|
|
125
|
+
return JSON.parse(string);
|
|
48
126
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
127
|
+
NUMBER.lastIndex = this.at;
|
|
128
|
+
const number = NUMBER.exec(this.text);
|
|
129
|
+
if (number !== null) {
|
|
130
|
+
this.at = NUMBER.lastIndex;
|
|
131
|
+
if (number[1] !== undefined || number[2] !== undefined) {
|
|
132
|
+
this.fractions.set(pointerOf(this.path.map(String)), number[0]);
|
|
133
|
+
}
|
|
134
|
+
return Number(number[0]);
|
|
135
|
+
}
|
|
136
|
+
for (const [literal, held] of LITERALS) {
|
|
137
|
+
if (this.text.startsWith(literal, this.at)) {
|
|
138
|
+
this.at += literal.length;
|
|
139
|
+
return held;
|
|
52
140
|
}
|
|
53
141
|
}
|
|
142
|
+
return this.fail(char === undefined ? "the document ends where a value is expected" : "a value is expected");
|
|
54
143
|
}
|
|
55
|
-
object(
|
|
56
|
-
const
|
|
144
|
+
object(depth) {
|
|
145
|
+
const held = {};
|
|
57
146
|
this.at += 1;
|
|
147
|
+
this.match(SPACE);
|
|
148
|
+
if (this.text[this.at] === "}") {
|
|
149
|
+
this.at += 1;
|
|
150
|
+
return held;
|
|
151
|
+
}
|
|
58
152
|
for (;;) {
|
|
59
|
-
this.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
153
|
+
this.match(SPACE);
|
|
154
|
+
const offset = this.at;
|
|
155
|
+
const spelled = this.match(STRING);
|
|
156
|
+
if (spelled === undefined) {
|
|
157
|
+
return this.fail("a member name is expected");
|
|
63
158
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.
|
|
159
|
+
const name = JSON.parse(spelled);
|
|
160
|
+
if (Object.hasOwn(held, name)) {
|
|
161
|
+
this.fail(`the member ${JSON.stringify(name)} is written twice, so neither value is chosen`, offset, name);
|
|
67
162
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
this.
|
|
163
|
+
this.expect(":", "a colon after the member name");
|
|
164
|
+
if (depth === 1 && this.path[0] === this.records) {
|
|
165
|
+
this.match(SPACE);
|
|
166
|
+
this.starts.set(name, this.at);
|
|
72
167
|
}
|
|
73
|
-
|
|
74
|
-
|
|
168
|
+
this.path.push(name);
|
|
169
|
+
Object.defineProperty(held, name, {
|
|
170
|
+
value: this.value(depth + 1),
|
|
171
|
+
enumerable: true,
|
|
172
|
+
writable: true,
|
|
173
|
+
configurable: true,
|
|
174
|
+
});
|
|
175
|
+
this.path.pop();
|
|
176
|
+
this.match(SPACE);
|
|
177
|
+
const next = this.text[this.at];
|
|
75
178
|
this.at += 1;
|
|
76
|
-
|
|
179
|
+
if (next === "}") {
|
|
180
|
+
return held;
|
|
181
|
+
}
|
|
182
|
+
if (next !== ",") {
|
|
183
|
+
this.at -= 1;
|
|
184
|
+
this.fail("a comma or a closing brace is expected");
|
|
185
|
+
}
|
|
77
186
|
}
|
|
78
187
|
}
|
|
79
|
-
|
|
188
|
+
array(depth) {
|
|
189
|
+
const held = [];
|
|
190
|
+
const records = depth === 1 && this.path[0] === this.records;
|
|
80
191
|
this.at += 1;
|
|
192
|
+
this.match(SPACE);
|
|
193
|
+
if (this.text[this.at] === "]") {
|
|
194
|
+
this.at += 1;
|
|
195
|
+
return held;
|
|
196
|
+
}
|
|
81
197
|
for (;;) {
|
|
82
|
-
this.
|
|
83
|
-
if (
|
|
84
|
-
this.at
|
|
85
|
-
|
|
198
|
+
this.match(SPACE);
|
|
199
|
+
if (records) {
|
|
200
|
+
this.starts.set(held.length, this.at);
|
|
201
|
+
}
|
|
202
|
+
this.path.push(held.length);
|
|
203
|
+
held.push(this.value(depth + 1));
|
|
204
|
+
this.path.pop();
|
|
205
|
+
this.match(SPACE);
|
|
206
|
+
const next = this.text[this.at];
|
|
207
|
+
this.at += 1;
|
|
208
|
+
if (next === "]") {
|
|
209
|
+
return held;
|
|
86
210
|
}
|
|
87
|
-
if (
|
|
88
|
-
this.at
|
|
211
|
+
if (next !== ",") {
|
|
212
|
+
this.at -= 1;
|
|
213
|
+
this.fail("a comma or a closing bracket is expected");
|
|
89
214
|
}
|
|
90
|
-
this.value(element);
|
|
91
215
|
}
|
|
92
216
|
}
|
|
93
217
|
}
|
|
94
218
|
/**
|
|
95
|
-
*
|
|
96
|
-
* records
|
|
97
|
-
* later value silently replacing the earlier one, so a document holding one is one a
|
|
98
|
-
* reader names rather than reads.
|
|
219
|
+
* Reads `text` as one strict JSON document, or throws a {@link StrictJSONError}.
|
|
220
|
+
* `records` names the top-level member whose values' offsets the read keeps.
|
|
99
221
|
*/
|
|
100
|
-
export function
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
return {
|
|
222
|
+
export function readStrict(text, records) {
|
|
223
|
+
const reader = new Reader(text, records);
|
|
224
|
+
const value = reader.read();
|
|
225
|
+
return { value, fractions: reader.fractions, starts: reader.starts };
|
|
104
226
|
}
|
|
105
227
|
/**
|
|
106
228
|
* One offset of a document as a finding carries it: the line counted from one, and the
|
|
@@ -111,6 +233,11 @@ export function positionAt(text, file, offset) {
|
|
|
111
233
|
const lineStart = before.lastIndexOf("\n") + 1;
|
|
112
234
|
return { path: file, line: before.split("\n").length, column: offset - lineStart + 1 };
|
|
113
235
|
}
|
|
236
|
+
/** A strict read's fault with the line and column it is at, as a refusal states it. */
|
|
237
|
+
export function locatedFault(text, error) {
|
|
238
|
+
const { line, column } = positionAt(text, "", error.offset);
|
|
239
|
+
return `line ${String(line)}, column ${String(column)}: ${error.message}`;
|
|
240
|
+
}
|
|
114
241
|
/** Whether a decoded JSON value is an object: not `null` and not an array. */
|
|
115
242
|
export function isRecord(value) {
|
|
116
243
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
package/dist/src/kinds.js
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { KIND_ROWS } from "./contract/contract.generated.js";
|
|
2
2
|
/** Every live row of the issue-kind vocabulary, by its code, in ascending code order. */
|
|
3
3
|
export const KINDS = new Map(KIND_ROWS.map((one) => [one.code, one]));
|
|
4
|
-
/**
|
|
5
|
-
* The code of every live issue kind, in ascending order. A severity key names one of
|
|
6
|
-
* them or a family prefix whose range holds one; a key naming a retired code, an
|
|
7
|
-
* unassigned code or a range holding no live kind is an unimplemented key, so every
|
|
8
|
-
* severity a configuration sets is one some analyzer reports under.
|
|
9
|
-
*/
|
|
10
|
-
export const LIVE_CODES = KIND_ROWS.map((one) => one.code);
|
|
11
4
|
/** The issue-kind codes whose severity the Contract fixes, in ascending order. */
|
|
12
5
|
export const FIXED_SEVERITY_CODES = KIND_ROWS.filter((one) => one.fixed).map((one) => one.code);
|
package/dist/src/print.js
CHANGED
|
@@ -1,84 +1,15 @@
|
|
|
1
|
-
import { renderOrigin
|
|
1
|
+
import { renderOrigin } from "./config.js";
|
|
2
2
|
import { bytewise } from "./text/compare.js";
|
|
3
|
-
/** One declaration entry as a document writes it: the members of its shape alone. */
|
|
4
|
-
function declarationOf(entry) {
|
|
5
|
-
switch (entry.shape) {
|
|
6
|
-
case "symbol":
|
|
7
|
-
return { symbol: entry.symbol };
|
|
8
|
-
case "module":
|
|
9
|
-
return { module: entry.module, name: entry.name };
|
|
10
|
-
case "global":
|
|
11
|
-
return { global: entry.global };
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
/** One provider entry as a document writes it: the members of its shape alone. */
|
|
15
|
-
function providerOf(entry) {
|
|
16
|
-
const installed = { name: entry.name, languages: entry.languages, command: entry.command };
|
|
17
|
-
if (entry.shape === "installed") {
|
|
18
|
-
return installed;
|
|
19
|
-
}
|
|
20
|
-
return { ...installed, source: entry.source, version: entry.version, digest: entry.digest };
|
|
21
|
-
}
|
|
22
3
|
/**
|
|
23
|
-
* The resolved configuration and its provenance as one JSON object, indented,
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* object is a declared key resolution ignores. Keys are written in the schema's
|
|
29
|
-
* order and the severity and provenance objects in ascending key order, so one
|
|
30
|
-
* configuration renders to one string whatever order a source named its settings
|
|
31
|
-
* in.
|
|
4
|
+
* The resolved configuration and its provenance as one JSON object, indented, ending
|
|
5
|
+
* in a newline: the configuration in the order resolution gives it, then the provenance
|
|
6
|
+
* object in ascending key order. Read back as a repository configuration it resolves to
|
|
7
|
+
* the same configuration, because resolution checks the provenance object and then
|
|
8
|
+
* ignores it.
|
|
32
9
|
*/
|
|
33
10
|
export function printConfig(config, provenance) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
languages: config.analysis.languages,
|
|
39
|
-
min_confidence: config.analysis.minConfidence,
|
|
40
|
-
generated_files: config.analysis.generatedFiles,
|
|
41
|
-
consumer_tests: config.analysis.consumerTests,
|
|
42
|
-
configurations: config.analysis.configurations.map((entry) => entry.shape === "project"
|
|
43
|
-
? { id: entry.id, project: entry.project }
|
|
44
|
-
: { id: entry.id, os: entry.os, arch: entry.arch, tags: entry.tags }),
|
|
45
|
-
matrix: { complete: config.analysis.matrixComplete },
|
|
46
|
-
template_dirs: config.analysis.templateDirs,
|
|
47
|
-
template_delimiters: {
|
|
48
|
-
left: config.analysis.templateDelimiters.left,
|
|
49
|
-
right: config.analysis.templateDelimiters.right,
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
consumers: { complete: config.consumersComplete },
|
|
53
|
-
roots: { patterns: config.rootPatterns },
|
|
54
|
-
severity: Object.fromEntries([...config.severity].sort(([a], [b]) => bytewise(a, b))),
|
|
55
|
-
exemptions: { disabled: config.exemptionsDisabled },
|
|
56
|
-
reporters: {
|
|
57
|
-
formats: config.reporters.formats,
|
|
58
|
-
sort: config.reporters.sort,
|
|
59
|
-
cascade: config.reporters.cascade,
|
|
60
|
-
max_findings: config.reporters.maxFindings,
|
|
61
|
-
fail_on: config.reporters.failOn,
|
|
62
|
-
},
|
|
63
|
-
providers: { analyzers: config.providers.analyzers.map(providerOf) },
|
|
64
|
-
go: {},
|
|
65
|
-
ts: {
|
|
66
|
-
test_files: config.ts.testFiles,
|
|
67
|
-
entry_files: config.ts.entryFiles,
|
|
68
|
-
component_extensions: config.ts.componentExtensions,
|
|
69
|
-
disabled_conventions: config.ts.disabledConventions,
|
|
70
|
-
injection_registrations: config.ts.injectionRegistrations.map(declarationOf),
|
|
71
|
-
lifecycle_contracts: config.ts.lifecycleContracts.map((entry) => ({
|
|
72
|
-
components: entry.components.map(declarationOf),
|
|
73
|
-
bases: entry.bases.map(declarationOf),
|
|
74
|
-
members: entry.members,
|
|
75
|
-
})),
|
|
76
|
-
serializers: config.ts.serializers.map(declarationOf),
|
|
77
|
-
roots_complete: config.ts.rootsComplete,
|
|
78
|
-
},
|
|
79
|
-
provenance: Object.fromEntries([...provenance]
|
|
80
|
-
.sort(([a], [b]) => bytewise(a, b))
|
|
81
|
-
.map(([path, origin]) => [path, renderOrigin(origin)])),
|
|
82
|
-
};
|
|
83
|
-
return `${JSON.stringify(document, undefined, 2)}\n`;
|
|
11
|
+
const origins = [...provenance]
|
|
12
|
+
.sort(([a], [b]) => bytewise(a, b))
|
|
13
|
+
.map(([path, origin]) => [path, renderOrigin(origin)]);
|
|
14
|
+
return `${JSON.stringify({ ...config, provenance: Object.fromEntries(origins) }, undefined, 2)}\n`;
|
|
84
15
|
}
|
|
@@ -1,231 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Holds a report to `contract/report.schema.json` and `contract/finding.schema.json`
|
|
3
3
|
* before it is written, reading the schemas the generator copies from the Contract, so
|
|
4
|
-
* this analyzer never writes a document a reader refuses.
|
|
5
|
-
* Contract's schemas use; the schemas' patterns are written to match alike in RE2 and in
|
|
6
|
-
* ECMAScript.
|
|
4
|
+
* this analyzer never writes a document a reader refuses.
|
|
7
5
|
*/
|
|
8
6
|
import { REPORT_SCHEMAS } from "./contract/report.generated.js";
|
|
9
|
-
import {
|
|
10
|
-
import { isRecord } from "./json-document.js";
|
|
11
|
-
/**
|
|
12
|
-
* Every keyword the validator evaluates or knows to carry no assertion. A schema naming
|
|
13
|
-
* another fails every check rather than passing values the keyword would refuse.
|
|
14
|
-
*/
|
|
15
|
-
const KEYWORDS = new Set([
|
|
16
|
-
...["$schema", "$id", "$comment", "$defs", "title", "description", "default", "examples"],
|
|
17
|
-
...["$ref", "type", "enum", "const", "pattern", "minLength", "minimum", "maximum"],
|
|
18
|
-
...["properties", "additionalProperties", "required", "minProperties"],
|
|
19
|
-
...["items", "minItems", "maxItems", "uniqueItems"],
|
|
20
|
-
...["allOf", "anyOf", "oneOf", "not", "if", "then", "else"],
|
|
21
|
-
]);
|
|
22
|
-
const patterns = new Map();
|
|
23
|
-
function pattern(source) {
|
|
24
|
-
let held = patterns.get(source);
|
|
25
|
-
if (held === undefined) {
|
|
26
|
-
held = new RegExp(source, "u");
|
|
27
|
-
patterns.set(source, held);
|
|
28
|
-
}
|
|
29
|
-
return held;
|
|
30
|
-
}
|
|
31
|
-
function typeOf(value) {
|
|
32
|
-
if (value === null) {
|
|
33
|
-
return "null";
|
|
34
|
-
}
|
|
35
|
-
if (Array.isArray(value)) {
|
|
36
|
-
return "array";
|
|
37
|
-
}
|
|
38
|
-
if (typeof value === "number") {
|
|
39
|
-
return Number.isInteger(value) ? "integer" : "number";
|
|
40
|
-
}
|
|
41
|
-
return typeof value;
|
|
42
|
-
}
|
|
43
|
-
function hasType(value, name) {
|
|
44
|
-
const held = typeOf(value);
|
|
45
|
-
return held === name || (name === "number" && held === "integer");
|
|
46
|
-
}
|
|
47
|
-
/** One value's encoding with object members sorted, so two equal values encode alike. */
|
|
48
|
-
function canonical(value) {
|
|
49
|
-
if (Array.isArray(value)) {
|
|
50
|
-
return `[${value.map(canonical).join(",")}]`;
|
|
51
|
-
}
|
|
52
|
-
if (isRecord(value)) {
|
|
53
|
-
return `{${Object.keys(value)
|
|
54
|
-
.sort(bytewise)
|
|
55
|
-
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
|
56
|
-
.join(",")}}`;
|
|
57
|
-
}
|
|
58
|
-
return JSON.stringify(value);
|
|
59
|
-
}
|
|
60
|
-
/** A string's length in code points, which JSON Schema's minLength counts. */
|
|
61
|
-
function codePoints(value) {
|
|
62
|
-
let counted = 0;
|
|
63
|
-
for (const _point of value) {
|
|
64
|
-
counted += 1;
|
|
65
|
-
}
|
|
66
|
-
return counted;
|
|
67
|
-
}
|
|
68
|
-
const escape = (name) => name.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
69
|
-
/** A schema evaluated in the document it is read from, so a relative `$ref` resolves there. */
|
|
70
|
-
class Checker {
|
|
71
|
-
documents;
|
|
72
|
-
constructor(documents) {
|
|
73
|
-
this.documents = documents;
|
|
74
|
-
}
|
|
75
|
-
resolve(doc, ref) {
|
|
76
|
-
const [file = "", pointer = ""] = ref.split("#");
|
|
77
|
-
const target = file === "" ? doc : file;
|
|
78
|
-
let schema = this.documents[target];
|
|
79
|
-
for (const token of pointer.split("/").filter((one) => one !== "")) {
|
|
80
|
-
schema = isRecord(schema)
|
|
81
|
-
? schema[token.replaceAll("~1", "/").replaceAll("~0", "~")]
|
|
82
|
-
: undefined;
|
|
83
|
-
}
|
|
84
|
-
if (!isRecord(schema)) {
|
|
85
|
-
throw new Error(`report schema: ${ref} names no schema`);
|
|
86
|
-
}
|
|
87
|
-
return [target, schema];
|
|
88
|
-
}
|
|
89
|
-
matches(doc, schema, value, at) {
|
|
90
|
-
const found = [];
|
|
91
|
-
this.check(doc, schema, value, at, found);
|
|
92
|
-
return found.length === 0;
|
|
93
|
-
}
|
|
94
|
-
check(doc, schema, value, at, found) {
|
|
95
|
-
for (const keyword of Object.keys(schema)) {
|
|
96
|
-
if (!KEYWORDS.has(keyword) && !keyword.startsWith("x-")) {
|
|
97
|
-
throw new Error(`report schema: the keyword ${keyword} is not one this validator evaluates`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const refuse = (keyword, reason, pointer = at) => {
|
|
101
|
-
found.push({ pointer, keyword, reason });
|
|
102
|
-
};
|
|
103
|
-
if (typeof schema["$ref"] === "string") {
|
|
104
|
-
const [target, referenced] = this.resolve(doc, schema["$ref"]);
|
|
105
|
-
this.check(target, referenced, value, at, found);
|
|
106
|
-
}
|
|
107
|
-
const types = schema["type"];
|
|
108
|
-
const named = types === undefined ? [] : Array.isArray(types) ? types : [types];
|
|
109
|
-
if (named.length > 0 && !named.some((one) => hasType(value, String(one)))) {
|
|
110
|
-
refuse("type", `the value is ${typeOf(value)}, want ${named.join(", ")}`);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
this.checkValue(schema, value, refuse);
|
|
114
|
-
if (Array.isArray(value)) {
|
|
115
|
-
this.checkArray(doc, schema, value, at, found, refuse);
|
|
116
|
-
}
|
|
117
|
-
else if (isRecord(value)) {
|
|
118
|
-
this.checkObject(doc, schema, value, at, found, refuse);
|
|
119
|
-
}
|
|
120
|
-
this.checkApplicators(doc, schema, value, at, found, refuse);
|
|
121
|
-
}
|
|
122
|
-
checkValue(schema, value, refuse) {
|
|
123
|
-
const values = schema["enum"];
|
|
124
|
-
if (Array.isArray(values) && !values.some((one) => canonical(one) === canonical(value))) {
|
|
125
|
-
refuse("enum", `${canonical(value)} is not one of the values the schema lists`);
|
|
126
|
-
}
|
|
127
|
-
if (schema["const"] !== undefined && canonical(schema["const"]) !== canonical(value)) {
|
|
128
|
-
refuse("const", `${canonical(value)} is not ${canonical(schema["const"])}`);
|
|
129
|
-
}
|
|
130
|
-
if (typeof value === "string") {
|
|
131
|
-
if (typeof schema["pattern"] === "string" && !pattern(schema["pattern"]).test(value)) {
|
|
132
|
-
refuse("pattern", `${JSON.stringify(value)} does not match ${schema["pattern"]}`);
|
|
133
|
-
}
|
|
134
|
-
if (typeof schema["minLength"] === "number" && codePoints(value) < schema["minLength"]) {
|
|
135
|
-
refuse("minLength", `${JSON.stringify(value)} is shorter than ${String(schema["minLength"])} characters`);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (typeof value === "number") {
|
|
139
|
-
if (typeof schema["minimum"] === "number" && value < schema["minimum"]) {
|
|
140
|
-
refuse("minimum", `${String(value)} is below ${String(schema["minimum"])}`);
|
|
141
|
-
}
|
|
142
|
-
if (typeof schema["maximum"] === "number" && value > schema["maximum"]) {
|
|
143
|
-
refuse("maximum", `${String(value)} is above ${String(schema["maximum"])}`);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
checkObject(doc, schema, value, at, found, refuse) {
|
|
148
|
-
const properties = isRecord(schema["properties"]) ? schema["properties"] : {};
|
|
149
|
-
const additional = schema["additionalProperties"];
|
|
150
|
-
for (const name of Object.keys(value).sort(bytewise)) {
|
|
151
|
-
const member = `${at}/${escape(name)}`;
|
|
152
|
-
const declared = properties[name];
|
|
153
|
-
if (isRecord(declared)) {
|
|
154
|
-
this.check(doc, declared, value[name], member, found);
|
|
155
|
-
}
|
|
156
|
-
else if (additional === false) {
|
|
157
|
-
refuse("additionalProperties", `the member ${JSON.stringify(name)} is not one the schema declares`);
|
|
158
|
-
}
|
|
159
|
-
else if (isRecord(additional)) {
|
|
160
|
-
this.check(doc, additional, value[name], member, found);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
for (const name of Array.isArray(schema["required"]) ? schema["required"] : []) {
|
|
164
|
-
if (!Object.hasOwn(value, name)) {
|
|
165
|
-
refuse("required", `the member ${JSON.stringify(name)} is absent`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
const fewest = schema["minProperties"];
|
|
169
|
-
if (typeof fewest === "number" && Object.keys(value).length < fewest) {
|
|
170
|
-
refuse("minProperties", `the object holds ${String(Object.keys(value).length)} members, fewer than ${String(fewest)}`);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
checkArray(doc, schema, value, at, found, refuse) {
|
|
174
|
-
const items = schema["items"];
|
|
175
|
-
if (isRecord(items)) {
|
|
176
|
-
value.forEach((item, i) => {
|
|
177
|
-
this.check(doc, items, item, `${at}/${String(i)}`, found);
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
if (typeof schema["minItems"] === "number" && value.length < schema["minItems"]) {
|
|
181
|
-
refuse("minItems", `the array holds ${String(value.length)} items, fewer than ${String(schema["minItems"])}`);
|
|
182
|
-
}
|
|
183
|
-
if (typeof schema["maxItems"] === "number" && value.length > schema["maxItems"]) {
|
|
184
|
-
refuse("maxItems", `the array holds ${String(value.length)} items, more than ${String(schema["maxItems"])}`);
|
|
185
|
-
}
|
|
186
|
-
if (schema["uniqueItems"] === true && new Set(value.map(canonical)).size !== value.length) {
|
|
187
|
-
refuse("uniqueItems", "the array lists one value twice");
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
checkApplicators(doc, schema, value, at, found, refuse) {
|
|
191
|
-
const list = (keyword) => Array.isArray(schema[keyword]) ? schema[keyword] : [];
|
|
192
|
-
for (const each of list("allOf")) {
|
|
193
|
-
this.check(doc, each, value, at, found);
|
|
194
|
-
}
|
|
195
|
-
const matching = (keyword) => list(keyword).filter((each) => this.matches(doc, each, value, at)).length;
|
|
196
|
-
if (schema["anyOf"] !== undefined && matching("anyOf") === 0) {
|
|
197
|
-
refuse("anyOf", "the value matches none of the alternatives");
|
|
198
|
-
}
|
|
199
|
-
if (schema["oneOf"] !== undefined && matching("oneOf") !== 1) {
|
|
200
|
-
refuse("oneOf", `the value matches ${String(matching("oneOf"))} of the alternatives, want exactly one`);
|
|
201
|
-
}
|
|
202
|
-
if (isRecord(schema["not"]) && this.matches(doc, schema["not"], value, at)) {
|
|
203
|
-
refuse("not", "the value matches a schema it must not match");
|
|
204
|
-
}
|
|
205
|
-
if (isRecord(schema["if"])) {
|
|
206
|
-
const branch = this.matches(doc, schema["if"], value, at) ? schema["then"] : schema["else"];
|
|
207
|
-
if (isRecord(branch)) {
|
|
208
|
-
this.check(doc, branch, value, at, found);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* A validator over one set of schema documents, each named as a `$ref` names it, rooted
|
|
215
|
-
* at one of them: what it refuses in a value, none for an instance.
|
|
216
|
-
*/
|
|
217
|
-
export function schemaRefusals(documents, root) {
|
|
218
|
-
const checker = new Checker(documents);
|
|
219
|
-
const top = documents[root];
|
|
220
|
-
if (top === undefined) {
|
|
221
|
-
throw new Error(`the schema set holds no document ${root}`);
|
|
222
|
-
}
|
|
223
|
-
return (value) => {
|
|
224
|
-
const found = [];
|
|
225
|
-
checker.check(root, top, value, "", found);
|
|
226
|
-
return found;
|
|
227
|
-
};
|
|
228
|
-
}
|
|
7
|
+
import { schemaRefusals } from "./schema.js";
|
|
229
8
|
const reportSchema = schemaRefusals(REPORT_SCHEMAS, "report.schema.json");
|
|
230
9
|
/**
|
|
231
10
|
* What the report schema refuses in one report document, none for an instance, and, as
|
|
@@ -242,7 +21,7 @@ function listingRefusals(report) {
|
|
|
242
21
|
return [];
|
|
243
22
|
}
|
|
244
23
|
const refuse = (pointer, reason) => [
|
|
245
|
-
{ pointer, keyword: "components", reason },
|
|
24
|
+
{ pointer, keyword: "components", reason, member: "" },
|
|
246
25
|
];
|
|
247
26
|
const listed = new Set();
|
|
248
27
|
for (const [i, entry] of report.components.entries()) {
|