@cplieger/deadset-ts 1.0.0 → 1.1.0
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/NOTICE +3 -0
- package/README.md +2 -5
- package/bin/deadset-ts.ts +2 -1
- package/bin/node-host.ts +28 -0
- package/jsr.json +4 -2
- package/package.json +9 -5
- package/src/config.ts +218 -0
- package/src/discover.ts +198 -0
- package/src/host.ts +29 -0
- package/src/index.ts +3 -1
- package/src/kinds.ts +12 -0
- package/src/paths.ts +73 -0
- package/src/print.ts +59 -0
- package/src/resolve.ts +732 -0
- package/src/run.ts +272 -17
- package/src/schema.ts +401 -0
- package/src/scope.ts +153 -0
- package/src/session.ts +205 -0
- package/src/version.ts +22 -0
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
defaultConfig,
|
|
4
|
+
type BuildConfiguration,
|
|
5
|
+
type Cascade,
|
|
6
|
+
type Config,
|
|
7
|
+
type Confidence,
|
|
8
|
+
type ConsumerTests,
|
|
9
|
+
type Format,
|
|
10
|
+
type GeneratedFiles,
|
|
11
|
+
type Inputs,
|
|
12
|
+
type Language,
|
|
13
|
+
type Origin,
|
|
14
|
+
type Provenance,
|
|
15
|
+
type Severity,
|
|
16
|
+
type Sort,
|
|
17
|
+
type Source,
|
|
18
|
+
type TargetKind,
|
|
19
|
+
type TemplateDelimiters,
|
|
20
|
+
} from "./config.ts";
|
|
21
|
+
import { FIXED_SEVERITY_CODES } from "./kinds.ts";
|
|
22
|
+
import {
|
|
23
|
+
checkDocument,
|
|
24
|
+
malformed,
|
|
25
|
+
nestFlags,
|
|
26
|
+
SCHEMA_ROOT,
|
|
27
|
+
SEVERITY_SECTION,
|
|
28
|
+
type KeyNode,
|
|
29
|
+
} from "./schema.ts";
|
|
30
|
+
|
|
31
|
+
/** Names the command-line flags in a refusal, where a document is named by its path. */
|
|
32
|
+
const FLAGS_LABEL = "the command-line flags";
|
|
33
|
+
|
|
34
|
+
/** The length of a severity key naming a whole family: the prefix and two digits. */
|
|
35
|
+
const FAMILY_KEY_LENGTH = 4;
|
|
36
|
+
|
|
37
|
+
const SEVERITY_KEY = /^DS[0-9]{2}([0-9]{2})?$/u;
|
|
38
|
+
const EXEMPTION_CLASS = /^[a-z][a-z0-9-]*$/u;
|
|
39
|
+
const CONTRACT_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/u;
|
|
40
|
+
|
|
41
|
+
const TARGET_KINDS: readonly TargetKind[] = ["application", "library"];
|
|
42
|
+
const CONFIDENCES: readonly Confidence[] = ["certain", "probable", "possible"];
|
|
43
|
+
const GENERATED_FILES: readonly GeneratedFiles[] = ["exclude", "include"];
|
|
44
|
+
const CONSUMER_TESTS: readonly ConsumerTests[] = ["test", "production"];
|
|
45
|
+
const LANGUAGES: readonly Language[] = ["go", "ts"];
|
|
46
|
+
const SEVERITIES: readonly Severity[] = ["allow", "warn", "deny"];
|
|
47
|
+
const FORMATS: readonly Format[] = ["text", "json", "github", "sarif", "template"];
|
|
48
|
+
const SORTS: readonly Sort[] = ["position", "size"];
|
|
49
|
+
const CASCADES: readonly Cascade[] = ["roots", "full"];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One configuration document, read as the settings it supplies. Every member is
|
|
53
|
+
* possibly undefined, so a key the document omits is distinguishable from one it
|
|
54
|
+
* sets to a value equal to the default, which is what makes resolution per
|
|
55
|
+
* setting rather than per document.
|
|
56
|
+
*/
|
|
57
|
+
interface Doc {
|
|
58
|
+
readonly contractVersion: string | undefined;
|
|
59
|
+
readonly targetKind: TargetKind | undefined;
|
|
60
|
+
readonly languages: readonly Language[] | undefined;
|
|
61
|
+
readonly minConfidence: Confidence | undefined;
|
|
62
|
+
readonly generatedFiles: GeneratedFiles | undefined;
|
|
63
|
+
readonly consumerTests: ConsumerTests | undefined;
|
|
64
|
+
readonly configurations: readonly BuildConfiguration[] | undefined;
|
|
65
|
+
readonly matrixComplete: boolean | undefined;
|
|
66
|
+
readonly templateDirs: readonly string[] | undefined;
|
|
67
|
+
readonly templateDelimiters: TemplateDelimiters | undefined;
|
|
68
|
+
readonly consumersComplete: boolean | undefined;
|
|
69
|
+
readonly rootPatterns: readonly string[] | undefined;
|
|
70
|
+
readonly severity: ReadonlyMap<string, Severity> | undefined;
|
|
71
|
+
readonly exemptionsDisabled: readonly string[] | undefined;
|
|
72
|
+
readonly formats: readonly Format[] | undefined;
|
|
73
|
+
readonly sort: Sort | undefined;
|
|
74
|
+
readonly cascade: Cascade | undefined;
|
|
75
|
+
readonly maxFindings: number | undefined;
|
|
76
|
+
readonly failOn: Severity | undefined;
|
|
77
|
+
readonly testFiles: readonly string[] | undefined;
|
|
78
|
+
readonly entryFiles: readonly string[] | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One document the resolution reads, with the origin its settings carry. */
|
|
82
|
+
interface DocSource {
|
|
83
|
+
readonly doc: Doc;
|
|
84
|
+
readonly kind: Source;
|
|
85
|
+
readonly label: string;
|
|
86
|
+
readonly flagLabels: ReadonlyMap<string, string>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The origin one setting of this source carries. A flag names itself per setting,
|
|
91
|
+
* because one invocation carries many.
|
|
92
|
+
*/
|
|
93
|
+
function originOf(source: DocSource, path: string): Origin {
|
|
94
|
+
if (source.kind === "flag") {
|
|
95
|
+
return { source: "flag", label: source.flagLabels.get(path) ?? "" };
|
|
96
|
+
}
|
|
97
|
+
return { source: source.kind, label: source.label };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
101
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function member(parent: Record<string, unknown> | undefined, name: string): unknown {
|
|
105
|
+
if (parent === undefined || !Object.hasOwn(parent, name)) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
return parent[name];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function section(
|
|
112
|
+
parent: Record<string, unknown> | undefined,
|
|
113
|
+
name: string,
|
|
114
|
+
path: string,
|
|
115
|
+
label: string,
|
|
116
|
+
): Record<string, unknown> | undefined {
|
|
117
|
+
const value = member(parent, name);
|
|
118
|
+
if (value === undefined) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (!isRecord(value)) {
|
|
122
|
+
throw malformed(label, path, "is not an object");
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function spell(values: readonly string[]): string {
|
|
128
|
+
return values.map((value) => JSON.stringify(value)).join(", ");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readEnum<T extends string>(
|
|
132
|
+
parent: Record<string, unknown> | undefined,
|
|
133
|
+
name: string,
|
|
134
|
+
path: string,
|
|
135
|
+
label: string,
|
|
136
|
+
allowed: readonly T[],
|
|
137
|
+
): T | undefined {
|
|
138
|
+
const value = member(parent, name);
|
|
139
|
+
if (value === undefined) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
if (typeof value !== "string") {
|
|
143
|
+
throw malformed(label, path, "is not a string");
|
|
144
|
+
}
|
|
145
|
+
if (!(allowed as readonly string[]).includes(value)) {
|
|
146
|
+
throw malformed(label, path, `${JSON.stringify(value)} is not one of ${spell(allowed)}`);
|
|
147
|
+
}
|
|
148
|
+
return value as T;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readString(
|
|
152
|
+
parent: Record<string, unknown> | undefined,
|
|
153
|
+
name: string,
|
|
154
|
+
path: string,
|
|
155
|
+
label: string,
|
|
156
|
+
): string | undefined {
|
|
157
|
+
const value = member(parent, name);
|
|
158
|
+
if (value === undefined) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
if (typeof value !== "string") {
|
|
162
|
+
throw malformed(label, path, "is not a string");
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readBoolean(
|
|
168
|
+
parent: Record<string, unknown> | undefined,
|
|
169
|
+
name: string,
|
|
170
|
+
path: string,
|
|
171
|
+
label: string,
|
|
172
|
+
): boolean | undefined {
|
|
173
|
+
const value = member(parent, name);
|
|
174
|
+
if (value === undefined) {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
if (typeof value !== "boolean") {
|
|
178
|
+
throw malformed(label, path, "is not a boolean");
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readCount(
|
|
184
|
+
parent: Record<string, unknown> | undefined,
|
|
185
|
+
name: string,
|
|
186
|
+
path: string,
|
|
187
|
+
label: string,
|
|
188
|
+
): number | undefined {
|
|
189
|
+
const value = member(parent, name);
|
|
190
|
+
if (value === undefined) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
194
|
+
throw malformed(label, path, "is not an integer");
|
|
195
|
+
}
|
|
196
|
+
if (value < 0) {
|
|
197
|
+
throw malformed(label, path, `${String(value)} is below the minimum of 0`);
|
|
198
|
+
}
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One array of strings, refusing an array shorter than its minimum, one holding an
|
|
204
|
+
* empty entry, one naming an entry twice, and, where a closed set is given, one
|
|
205
|
+
* holding an entry outside it.
|
|
206
|
+
*/
|
|
207
|
+
function readStrings<T extends string>(
|
|
208
|
+
parent: Record<string, unknown> | undefined,
|
|
209
|
+
name: string,
|
|
210
|
+
path: string,
|
|
211
|
+
label: string,
|
|
212
|
+
minItems: number,
|
|
213
|
+
allowed?: readonly T[],
|
|
214
|
+
): readonly T[] | undefined {
|
|
215
|
+
const value = member(parent, name);
|
|
216
|
+
if (value === undefined) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
if (!Array.isArray(value)) {
|
|
220
|
+
throw malformed(label, path, "is not an array");
|
|
221
|
+
}
|
|
222
|
+
if (value.length < minItems) {
|
|
223
|
+
throw malformed(
|
|
224
|
+
label,
|
|
225
|
+
path,
|
|
226
|
+
`holds ${String(value.length)} entries, want at least ${String(minItems)}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const seen = new Set<string>();
|
|
230
|
+
for (const entry of value as unknown[]) {
|
|
231
|
+
if (typeof entry !== "string") {
|
|
232
|
+
throw malformed(label, path, "holds an entry that is not a string");
|
|
233
|
+
}
|
|
234
|
+
if (entry === "") {
|
|
235
|
+
throw malformed(label, path, "holds an empty entry");
|
|
236
|
+
}
|
|
237
|
+
if (seen.has(entry)) {
|
|
238
|
+
throw malformed(label, path, `names ${JSON.stringify(entry)} twice`);
|
|
239
|
+
}
|
|
240
|
+
if (allowed !== undefined && !(allowed as readonly string[]).includes(entry)) {
|
|
241
|
+
throw malformed(label, path, `${JSON.stringify(entry)} is not one of ${spell(allowed)}`);
|
|
242
|
+
}
|
|
243
|
+
seen.add(entry);
|
|
244
|
+
}
|
|
245
|
+
return value as readonly T[];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function requireMember(value: string | undefined, path: string, label: string): string {
|
|
249
|
+
if (value === undefined || value === "") {
|
|
250
|
+
throw malformed(label, path, "is required and names nothing");
|
|
251
|
+
}
|
|
252
|
+
return value;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The build matrix: every entry names an identifier, an operating system and an
|
|
257
|
+
* architecture.
|
|
258
|
+
*/
|
|
259
|
+
function readConfigurations(
|
|
260
|
+
analysis: Record<string, unknown> | undefined,
|
|
261
|
+
label: string,
|
|
262
|
+
): readonly BuildConfiguration[] | undefined {
|
|
263
|
+
const value = member(analysis, "configurations");
|
|
264
|
+
if (value === undefined) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
if (!Array.isArray(value)) {
|
|
268
|
+
throw malformed(label, "analysis.configurations", "is not an array");
|
|
269
|
+
}
|
|
270
|
+
return (value as unknown[]).map((entry, index) => {
|
|
271
|
+
const at = `analysis.configurations[${String(index)}]`;
|
|
272
|
+
if (!isRecord(entry)) {
|
|
273
|
+
throw malformed(label, at, "is not an object");
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
id: requireMember(readString(entry, "id", `${at}.id`, label), `${at}.id`, label),
|
|
277
|
+
os: requireMember(readString(entry, "os", `${at}.os`, label), `${at}.os`, label),
|
|
278
|
+
arch: requireMember(readString(entry, "arch", `${at}.arch`, label), `${at}.arch`, label),
|
|
279
|
+
tags: readStrings(entry, "tags", `${at}.tags`, label, 0) ?? [],
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The action delimiter pair: once a document names the object both members are
|
|
286
|
+
* required, because a pair carrying one member names no delimiters at all, so the
|
|
287
|
+
* refusal names the member the document left out rather than pairing the one it
|
|
288
|
+
* named with a default.
|
|
289
|
+
*/
|
|
290
|
+
function readDelimiters(
|
|
291
|
+
analysis: Record<string, unknown> | undefined,
|
|
292
|
+
label: string,
|
|
293
|
+
): TemplateDelimiters | undefined {
|
|
294
|
+
const at = "analysis.template_delimiters";
|
|
295
|
+
const pair = section(analysis, "template_delimiters", at, label);
|
|
296
|
+
if (pair === undefined) {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
left: requireMember(readString(pair, "left", `${at}.left`, label), `${at}.left`, label),
|
|
301
|
+
right: requireMember(readString(pair, "right", `${at}.right`, label), `${at}.right`, label),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A severity key that names no setting, because the Contract fixes the severity of
|
|
307
|
+
* the kind or kinds it names.
|
|
308
|
+
*/
|
|
309
|
+
function unimplementedSeverityKey(label: string, path: string, reason: string): ConfigError {
|
|
310
|
+
return new ConfigError(
|
|
311
|
+
"unimplemented-key",
|
|
312
|
+
path,
|
|
313
|
+
`${label}: key ${JSON.stringify(path)} is not implemented: ${reason}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Every code whose severity the Contract fixes that one severity key names, in
|
|
319
|
+
* ascending code order: the code itself, or every code of the family its
|
|
320
|
+
* two-digit prefix names. A family key covers every such code, so a refusal names
|
|
321
|
+
* them all rather than the first one found.
|
|
322
|
+
*/
|
|
323
|
+
function fixedByContract(code: string): string[] {
|
|
324
|
+
return FIXED_SEVERITY_CODES.filter(
|
|
325
|
+
(candidate) =>
|
|
326
|
+
code === candidate || (code.length === FAMILY_KEY_LENGTH && candidate.startsWith(code)),
|
|
327
|
+
).sort();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The codes a refusal names: one on its own, and several with the last joined by and. */
|
|
331
|
+
function spellCodes(codes: readonly string[]): string {
|
|
332
|
+
if (codes.length < 2) {
|
|
333
|
+
return codes.join("");
|
|
334
|
+
}
|
|
335
|
+
return `${codes.slice(0, -1).join(", ")} and ${String(codes[codes.length - 1])}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The severity object, one code at a time.
|
|
340
|
+
*
|
|
341
|
+
* A key is one issue-kind code or one two-digit family prefix, and a key naming a
|
|
342
|
+
* kind whose severity the Contract fixes, or a family prefix whose range holds
|
|
343
|
+
* one, is an unimplemented key rather than a setting.
|
|
344
|
+
*
|
|
345
|
+
* Whether a key names a kind this analyzer reports is not decided here. This
|
|
346
|
+
* analyzer reports no issue kind, so the question has one answer for every key
|
|
347
|
+
* and asking it would refuse the whole object.
|
|
348
|
+
*/
|
|
349
|
+
function readSeverity(
|
|
350
|
+
document: Record<string, unknown> | undefined,
|
|
351
|
+
label: string,
|
|
352
|
+
): ReadonlyMap<string, Severity> | undefined {
|
|
353
|
+
const object = section(document, SEVERITY_SECTION, SEVERITY_SECTION, label);
|
|
354
|
+
if (object === undefined) {
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
const severity = new Map<string, Severity>();
|
|
358
|
+
for (const code of Object.keys(object).sort()) {
|
|
359
|
+
const path = `${SEVERITY_SECTION}.${code}`;
|
|
360
|
+
if (!SEVERITY_KEY.test(code)) {
|
|
361
|
+
throw unimplementedSeverityKey(
|
|
362
|
+
label,
|
|
363
|
+
path,
|
|
364
|
+
"a severity key is one issue-kind code or one two-digit family prefix",
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const fixed = fixedByContract(code);
|
|
368
|
+
if (fixed.length > 0) {
|
|
369
|
+
throw unimplementedSeverityKey(
|
|
370
|
+
label,
|
|
371
|
+
path,
|
|
372
|
+
`the Contract fixes the severity of ${spellCodes(fixed)}, which this key names`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const value = readEnum(object, code, path, label, SEVERITIES);
|
|
376
|
+
if (value !== undefined) {
|
|
377
|
+
severity.set(code, value);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return severity;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The exemption classes named for switching off. */
|
|
384
|
+
function readExemptions(
|
|
385
|
+
document: Record<string, unknown> | undefined,
|
|
386
|
+
label: string,
|
|
387
|
+
): readonly string[] | undefined {
|
|
388
|
+
const exemptions = section(document, "exemptions", "exemptions", label);
|
|
389
|
+
const disabled = readStrings(exemptions, "disabled", "exemptions.disabled", label, 0);
|
|
390
|
+
if (disabled === undefined) {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
for (const entry of disabled) {
|
|
394
|
+
if (!EXEMPTION_CLASS.test(entry)) {
|
|
395
|
+
throw malformed(
|
|
396
|
+
label,
|
|
397
|
+
"exemptions.disabled",
|
|
398
|
+
`${JSON.stringify(entry)} is not an exemption class name`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return disabled;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Reads one already-parsed configuration document as the settings it supplies,
|
|
407
|
+
* refusing every constraint the closed key list declares that a parse cannot
|
|
408
|
+
* express: a value of the wrong type, a value outside a closed set, an array that
|
|
409
|
+
* repeats an entry or holds too few, and a count below its minimum.
|
|
410
|
+
*/
|
|
411
|
+
function readDoc(value: unknown, label: string): Doc {
|
|
412
|
+
if (!isRecord(value)) {
|
|
413
|
+
throw malformed(label, "", "is not one JSON object");
|
|
414
|
+
}
|
|
415
|
+
const analysis = section(value, "analysis", "analysis", label);
|
|
416
|
+
const matrix = section(analysis, "matrix", "analysis.matrix", label);
|
|
417
|
+
const target = section(value, "target", "target", label);
|
|
418
|
+
const consumers = section(value, "consumers", "consumers", label);
|
|
419
|
+
const roots = section(value, "roots", "roots", label);
|
|
420
|
+
const reporters = section(value, "reporters", "reporters", label);
|
|
421
|
+
const ts = section(value, "ts", "ts", label);
|
|
422
|
+
section(value, "go", "go", label);
|
|
423
|
+
section(value, "provenance", "provenance", label);
|
|
424
|
+
|
|
425
|
+
const contractVersion = readString(value, "contract_version", "contract_version", label);
|
|
426
|
+
if (contractVersion !== undefined && !CONTRACT_VERSION_PATTERN.test(contractVersion)) {
|
|
427
|
+
throw malformed(
|
|
428
|
+
label,
|
|
429
|
+
"contract_version",
|
|
430
|
+
`${JSON.stringify(contractVersion)} is not a semantic version`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
contractVersion,
|
|
436
|
+
targetKind: readEnum(target, "kind", "target.kind", label, TARGET_KINDS),
|
|
437
|
+
languages: readStrings(analysis, "languages", "analysis.languages", label, 0, LANGUAGES),
|
|
438
|
+
minConfidence: readEnum(
|
|
439
|
+
analysis,
|
|
440
|
+
"min_confidence",
|
|
441
|
+
"analysis.min_confidence",
|
|
442
|
+
label,
|
|
443
|
+
CONFIDENCES,
|
|
444
|
+
),
|
|
445
|
+
generatedFiles: readEnum(
|
|
446
|
+
analysis,
|
|
447
|
+
"generated_files",
|
|
448
|
+
"analysis.generated_files",
|
|
449
|
+
label,
|
|
450
|
+
GENERATED_FILES,
|
|
451
|
+
),
|
|
452
|
+
consumerTests: readEnum(
|
|
453
|
+
analysis,
|
|
454
|
+
"consumer_tests",
|
|
455
|
+
"analysis.consumer_tests",
|
|
456
|
+
label,
|
|
457
|
+
CONSUMER_TESTS,
|
|
458
|
+
),
|
|
459
|
+
configurations: readConfigurations(analysis, label),
|
|
460
|
+
matrixComplete: readBoolean(matrix, "complete", "analysis.matrix.complete", label),
|
|
461
|
+
templateDirs: readStrings(analysis, "template_dirs", "analysis.template_dirs", label, 0),
|
|
462
|
+
templateDelimiters: readDelimiters(analysis, label),
|
|
463
|
+
consumersComplete: readBoolean(consumers, "complete", "consumers.complete", label),
|
|
464
|
+
rootPatterns: readStrings(roots, "patterns", "roots.patterns", label, 0),
|
|
465
|
+
severity: readSeverity(value, label),
|
|
466
|
+
exemptionsDisabled: readExemptions(value, label),
|
|
467
|
+
formats: readStrings(reporters, "formats", "reporters.formats", label, 1, FORMATS),
|
|
468
|
+
sort: readEnum(reporters, "sort", "reporters.sort", label, SORTS),
|
|
469
|
+
cascade: readEnum(reporters, "cascade", "reporters.cascade", label, CASCADES),
|
|
470
|
+
maxFindings: readCount(reporters, "max_findings", "reporters.max_findings", label),
|
|
471
|
+
failOn: readEnum(reporters, "fail_on", "reporters.fail_on", label, SEVERITIES),
|
|
472
|
+
testFiles: readStrings(ts, "test_files", "ts.test_files", label, 1),
|
|
473
|
+
entryFiles: readStrings(ts, "entry_files", "ts.entry_files", label, 0),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Parses one document's text and walks it against the closed key list. */
|
|
478
|
+
function parseDocument(text: string, node: KeyNode, label: string): unknown {
|
|
479
|
+
let value: unknown;
|
|
480
|
+
try {
|
|
481
|
+
value = JSON.parse(text);
|
|
482
|
+
} catch (error: unknown) {
|
|
483
|
+
throw malformed(label, "", error instanceof Error ? error.message : String(error));
|
|
484
|
+
}
|
|
485
|
+
checkDocument(text, node, label);
|
|
486
|
+
return value;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Names a document in a refusal: the path the invocation passed, or what it is. */
|
|
490
|
+
function labelOr(label: string | undefined, named: string): string {
|
|
491
|
+
return label === undefined || label === "" ? named : label;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Decodes the documents one invocation supplies, in the order they outrank each
|
|
496
|
+
* other, skipping every source it does not carry.
|
|
497
|
+
*/
|
|
498
|
+
function readSources(inputs: Inputs): DocSource[] {
|
|
499
|
+
const sources: DocSource[] = [];
|
|
500
|
+
const flagLabels = inputs.flagLabels ?? new Map<string, string>();
|
|
501
|
+
if (inputs.flags !== undefined) {
|
|
502
|
+
const flat = parseDocument(inputs.flags, { kind: "map" }, FLAGS_LABEL);
|
|
503
|
+
if (!isRecord(flat)) {
|
|
504
|
+
throw malformed(FLAGS_LABEL, "", "is not one JSON object");
|
|
505
|
+
}
|
|
506
|
+
sources.push({
|
|
507
|
+
doc: readDoc(nestFlags(flat, FLAGS_LABEL), FLAGS_LABEL),
|
|
508
|
+
kind: "flag",
|
|
509
|
+
label: "",
|
|
510
|
+
flagLabels,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
const held: readonly { kind: Source; named: string; text?: string; label?: string }[] = [
|
|
514
|
+
{
|
|
515
|
+
kind: "repository",
|
|
516
|
+
named: "the repository configuration",
|
|
517
|
+
...(inputs.repository === undefined ? {} : { text: inputs.repository }),
|
|
518
|
+
...(inputs.repositoryLabel === undefined ? {} : { label: inputs.repositoryLabel }),
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
kind: "central",
|
|
522
|
+
named: "the central configuration",
|
|
523
|
+
...(inputs.central === undefined ? {} : { text: inputs.central }),
|
|
524
|
+
...(inputs.centralLabel === undefined ? {} : { label: inputs.centralLabel }),
|
|
525
|
+
},
|
|
526
|
+
];
|
|
527
|
+
for (const entry of held) {
|
|
528
|
+
if (entry.text === undefined) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const label = labelOr(entry.label, entry.named);
|
|
532
|
+
sources.push({
|
|
533
|
+
doc: readDoc(parseDocument(entry.text, SCHEMA_ROOT, label), label),
|
|
534
|
+
kind: entry.kind,
|
|
535
|
+
label: entry.label ?? "",
|
|
536
|
+
flagLabels,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
return sources;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The value the highest-ranked source carrying one supplies, with where it came
|
|
544
|
+
* from recorded, or the default.
|
|
545
|
+
*/
|
|
546
|
+
function settle<T>(
|
|
547
|
+
fallback: T,
|
|
548
|
+
path: string,
|
|
549
|
+
provenance: Map<string, Origin>,
|
|
550
|
+
sources: readonly DocSource[],
|
|
551
|
+
read: (doc: Doc) => T | undefined,
|
|
552
|
+
): T {
|
|
553
|
+
for (const source of sources) {
|
|
554
|
+
const value = read(source.doc);
|
|
555
|
+
if (value !== undefined) {
|
|
556
|
+
provenance.set(path, originOf(source, path));
|
|
557
|
+
return value;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
provenance.set(path, { source: "default", label: "" });
|
|
561
|
+
return fallback;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The severity object, resolved one code at a time so a code only the central
|
|
566
|
+
* configuration names keeps its central value. An object that resolves empty
|
|
567
|
+
* carries one entry for the object itself.
|
|
568
|
+
*/
|
|
569
|
+
function settleSeverity(
|
|
570
|
+
provenance: Map<string, Origin>,
|
|
571
|
+
sources: readonly DocSource[],
|
|
572
|
+
): ReadonlyMap<string, Severity> {
|
|
573
|
+
const codes = new Set<string>();
|
|
574
|
+
for (const source of sources) {
|
|
575
|
+
for (const code of source.doc.severity?.keys() ?? []) {
|
|
576
|
+
codes.add(code);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const severity = new Map<string, Severity>();
|
|
580
|
+
for (const code of [...codes].sort()) {
|
|
581
|
+
const path = `${SEVERITY_SECTION}.${code}`;
|
|
582
|
+
for (const source of sources) {
|
|
583
|
+
const value = source.doc.severity?.get(code);
|
|
584
|
+
if (value === undefined) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
severity.set(code, value);
|
|
588
|
+
provenance.set(path, originOf(source, path));
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (codes.size > 0) {
|
|
593
|
+
return severity;
|
|
594
|
+
}
|
|
595
|
+
provenance.set(SEVERITY_SECTION, { source: "default", label: "" });
|
|
596
|
+
for (const source of sources) {
|
|
597
|
+
if (source.doc.severity !== undefined) {
|
|
598
|
+
provenance.set(SEVERITY_SECTION, originOf(source, SEVERITY_SECTION));
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return severity;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Names one configuration source and whether the invocation carried it. */
|
|
606
|
+
function describeSource(
|
|
607
|
+
named: string,
|
|
608
|
+
text: string | undefined,
|
|
609
|
+
label: string | undefined,
|
|
610
|
+
): string {
|
|
611
|
+
if (text === undefined) {
|
|
612
|
+
return `${named} (not present)`;
|
|
613
|
+
}
|
|
614
|
+
return `${named} (${labelOr(label, "unnamed")})`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Refuses a provenance entry whose source names no file or flag, which would
|
|
619
|
+
* print a provenance value the closed key list refuses to read back.
|
|
620
|
+
*/
|
|
621
|
+
function checkLabels(provenance: ReadonlyMap<string, Origin>): void {
|
|
622
|
+
for (const path of [...provenance.keys()].sort()) {
|
|
623
|
+
const origin = provenance.get(path);
|
|
624
|
+
if (origin !== undefined && origin.source !== "default" && origin.label === "") {
|
|
625
|
+
throw malformed(
|
|
626
|
+
origin.source === "flag" ? FLAGS_LABEL : origin.source,
|
|
627
|
+
path,
|
|
628
|
+
`came from ${origin.source}, which names no file or flag`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Applies a command-line flag over the repository configuration over the central
|
|
636
|
+
* configuration over the default each key declares, and returns the resolved
|
|
637
|
+
* configuration with the origin of every setting.
|
|
638
|
+
*
|
|
639
|
+
* It throws a {@link ConfigError} for a document that names a key the closed key
|
|
640
|
+
* list does not declare, a document that names one key twice, and a resolved
|
|
641
|
+
* configuration no source supplied a target kind for. Every one of them maps to
|
|
642
|
+
* the usage exit code.
|
|
643
|
+
*/
|
|
644
|
+
export function resolve(inputs: Inputs): { config: Config; provenance: Provenance } {
|
|
645
|
+
const sources = readSources(inputs);
|
|
646
|
+
const defaults = defaultConfig();
|
|
647
|
+
const provenance = new Map<string, Origin>();
|
|
648
|
+
const at = <T>(path: string, fallback: T, read: (doc: Doc) => T | undefined): T =>
|
|
649
|
+
settle(fallback, path, provenance, sources, read);
|
|
650
|
+
|
|
651
|
+
const config: Config = {
|
|
652
|
+
contractVersion: at("contract_version", defaults.contractVersion, (doc) => doc.contractVersion),
|
|
653
|
+
targetKind: at<TargetKind | "">("target.kind", "", (doc) => doc.targetKind),
|
|
654
|
+
analysis: {
|
|
655
|
+
languages: at("analysis.languages", defaults.analysis.languages, (doc) => doc.languages),
|
|
656
|
+
minConfidence: at(
|
|
657
|
+
"analysis.min_confidence",
|
|
658
|
+
defaults.analysis.minConfidence,
|
|
659
|
+
(doc) => doc.minConfidence,
|
|
660
|
+
),
|
|
661
|
+
generatedFiles: at(
|
|
662
|
+
"analysis.generated_files",
|
|
663
|
+
defaults.analysis.generatedFiles,
|
|
664
|
+
(doc) => doc.generatedFiles,
|
|
665
|
+
),
|
|
666
|
+
consumerTests: at(
|
|
667
|
+
"analysis.consumer_tests",
|
|
668
|
+
defaults.analysis.consumerTests,
|
|
669
|
+
(doc) => doc.consumerTests,
|
|
670
|
+
),
|
|
671
|
+
configurations: at(
|
|
672
|
+
"analysis.configurations",
|
|
673
|
+
defaults.analysis.configurations,
|
|
674
|
+
(doc) => doc.configurations,
|
|
675
|
+
),
|
|
676
|
+
matrixComplete: at(
|
|
677
|
+
"analysis.matrix.complete",
|
|
678
|
+
defaults.analysis.matrixComplete,
|
|
679
|
+
(doc) => doc.matrixComplete,
|
|
680
|
+
),
|
|
681
|
+
templateDirs: at(
|
|
682
|
+
"analysis.template_dirs",
|
|
683
|
+
defaults.analysis.templateDirs,
|
|
684
|
+
(doc) => doc.templateDirs,
|
|
685
|
+
),
|
|
686
|
+
templateDelimiters: at(
|
|
687
|
+
"analysis.template_delimiters",
|
|
688
|
+
defaults.analysis.templateDelimiters,
|
|
689
|
+
(doc) => doc.templateDelimiters,
|
|
690
|
+
),
|
|
691
|
+
},
|
|
692
|
+
consumersComplete: at(
|
|
693
|
+
"consumers.complete",
|
|
694
|
+
defaults.consumersComplete,
|
|
695
|
+
(doc) => doc.consumersComplete,
|
|
696
|
+
),
|
|
697
|
+
rootPatterns: at("roots.patterns", defaults.rootPatterns, (doc) => doc.rootPatterns),
|
|
698
|
+
severity: settleSeverity(provenance, sources),
|
|
699
|
+
exemptionsDisabled: at(
|
|
700
|
+
"exemptions.disabled",
|
|
701
|
+
defaults.exemptionsDisabled,
|
|
702
|
+
(doc) => doc.exemptionsDisabled,
|
|
703
|
+
),
|
|
704
|
+
reporters: {
|
|
705
|
+
formats: at("reporters.formats", defaults.reporters.formats, (doc) => doc.formats),
|
|
706
|
+
sort: at("reporters.sort", defaults.reporters.sort, (doc) => doc.sort),
|
|
707
|
+
cascade: at("reporters.cascade", defaults.reporters.cascade, (doc) => doc.cascade),
|
|
708
|
+
maxFindings: at(
|
|
709
|
+
"reporters.max_findings",
|
|
710
|
+
defaults.reporters.maxFindings,
|
|
711
|
+
(doc) => doc.maxFindings,
|
|
712
|
+
),
|
|
713
|
+
failOn: at("reporters.fail_on", defaults.reporters.failOn, (doc) => doc.failOn),
|
|
714
|
+
},
|
|
715
|
+
ts: {
|
|
716
|
+
testFiles: at("ts.test_files", defaults.ts.testFiles, (doc) => doc.testFiles),
|
|
717
|
+
entryFiles: at("ts.entry_files", defaults.ts.entryFiles, (doc) => doc.entryFiles),
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
if (config.targetKind === "") {
|
|
722
|
+
throw new ConfigError(
|
|
723
|
+
"missing-target-kind",
|
|
724
|
+
"target.kind",
|
|
725
|
+
"target.kind is not set, it has no default and is never inferred: searched " +
|
|
726
|
+
`${describeSource("the repository configuration", inputs.repository, inputs.repositoryLabel)} and ` +
|
|
727
|
+
describeSource("the central configuration", inputs.central, inputs.centralLabel),
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
checkLabels(provenance);
|
|
731
|
+
return { config, provenance };
|
|
732
|
+
}
|