@cplieger/deadset-ts 3.7.0 → 3.9.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/README.md +13 -13
- package/dist/bin/node-host.js +32 -2
- package/dist/src/analysis.js +111 -26
- package/dist/src/conformance.js +22 -0
- package/dist/src/edges.js +190 -0
- package/dist/src/findings/completion.js +39 -12
- package/dist/src/findings/emitters.js +42 -6
- package/dist/src/findings/self-check.js +139 -2
- package/dist/src/findings/visibility-narrowing.js +9 -40
- package/dist/src/findings-pass.js +97 -0
- package/dist/src/json-document.js +113 -0
- package/dist/src/references.js +18 -12
- package/dist/src/report.js +237 -0
- package/dist/src/reporters/json.js +4 -0
- package/dist/src/reporters/reporter.js +1 -0
- package/dist/src/reporters/reporters.js +10 -0
- package/dist/src/reporters/text.js +34 -0
- package/dist/src/roots.js +0 -34
- package/dist/src/run.js +23 -10
- package/dist/src/suppress-file.js +196 -0
- package/dist/src/suppress-inline.js +259 -0
- package/dist/src/suppress.js +148 -0
- package/dist/src/verbs/analyze.js +184 -0
- package/dist/src/verbs/describe.js +45 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +230 -35
- package/src/conformance.ts +34 -0
- package/src/edges.ts +241 -0
- package/src/finding.ts +13 -0
- package/src/findings/boundary.ts +0 -11
- package/src/findings/completion.ts +54 -15
- package/src/findings/emitter.ts +3 -0
- package/src/findings/emitters.ts +81 -7
- package/src/findings/self-check.ts +226 -2
- package/src/findings/visibility-narrowing.ts +10 -52
- package/src/findings-pass.ts +145 -0
- package/src/host.ts +5 -0
- package/src/json-document.ts +130 -0
- package/src/references.ts +23 -15
- package/src/report.ts +430 -0
- package/src/reporters/json.ts +7 -0
- package/src/reporters/reporter.ts +18 -0
- package/src/reporters/reporters.ts +13 -0
- package/src/reporters/text.ts +45 -0
- package/src/roots.ts +0 -39
- package/src/run.ts +40 -12
- package/src/suppress-file.ts +283 -0
- package/src/suppress-inline.ts +318 -0
- package/src/suppress.ts +262 -0
- package/src/verbs/analyze.ts +234 -0
- package/src/verbs/describe.ts +46 -0
- package/src/verbs/verb.ts +13 -0
package/src/run.ts
CHANGED
|
@@ -9,7 +9,9 @@ import { printConfigVerb } from "./verbs/print-config.ts";
|
|
|
9
9
|
import { printProjectsVerb } from "./verbs/print-projects.ts";
|
|
10
10
|
import { printRetainedVerb } from "./verbs/print-retained.ts";
|
|
11
11
|
import { printRootsVerb } from "./verbs/print-roots.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { ANALYZE_OPTIONS, analyzeVerb } from "./verbs/analyze.ts";
|
|
13
|
+
import { describeVerb } from "./verbs/describe.ts";
|
|
14
|
+
import { EXIT_FAILURE, EXIT_USAGE, type Verb, type VerbOptions } from "./verbs/verb.ts";
|
|
13
15
|
import { versionVerb } from "./verbs/version.ts";
|
|
14
16
|
|
|
15
17
|
/** One output stream of the command line. `process.stdout` and `process.stderr` satisfy it. */
|
|
@@ -21,14 +23,18 @@ export interface Writer {
|
|
|
21
23
|
* Every verb this command answers, in the order the usage text lists them. A verb
|
|
22
24
|
* with no `run` is listed, and invoking it is a usage error.
|
|
23
25
|
*/
|
|
24
|
-
const VERBS: readonly {
|
|
25
|
-
|
|
26
|
+
const VERBS: readonly {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly run?: Verb;
|
|
29
|
+
readonly options?: VerbOptions;
|
|
30
|
+
}[] = [
|
|
31
|
+
{ name: "analyze", run: analyzeVerb, options: ANALYZE_OPTIONS },
|
|
26
32
|
{ name: "explain" },
|
|
27
33
|
{ name: "print-config", run: printConfigVerb },
|
|
28
34
|
{ name: "print-projects", run: printProjectsVerb },
|
|
29
35
|
{ name: "print-roots", run: printRootsVerb },
|
|
30
36
|
{ name: "print-retained", run: printRetainedVerb },
|
|
31
|
-
{ name: "describe" },
|
|
37
|
+
{ name: "describe", run: describeVerb },
|
|
32
38
|
{ name: "version", run: versionVerb },
|
|
33
39
|
];
|
|
34
40
|
|
|
@@ -69,11 +75,15 @@ const INTEGER_OPTIONS: ReadonlySet<string> = new Set(["max-findings"]);
|
|
|
69
75
|
/** An integer as an option writes it: an optional minus sign and digits alone. */
|
|
70
76
|
const INTEGER_TEXT = /^-?[0-9]+$/u;
|
|
71
77
|
|
|
72
|
-
/** One invocation's options, by name,
|
|
78
|
+
/** One invocation's options, by name: the last value of each, and every value of a repeatable one. */
|
|
73
79
|
interface Options {
|
|
74
80
|
readonly named: ReadonlyMap<string, string>;
|
|
81
|
+
readonly repeated: ReadonlyMap<string, readonly string[]>;
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
/** No option beyond the ones every verb takes. */
|
|
85
|
+
const NO_OPTIONS: VerbOptions = { plain: [], repeatable: [] };
|
|
86
|
+
|
|
77
87
|
/**
|
|
78
88
|
* The first option whose name carries a source-edit token, because every verb
|
|
79
89
|
* reports and never edits. The match is on the name alone, so an option whose
|
|
@@ -93,16 +103,22 @@ function sourceEditOption(args: readonly string[]): string | undefined {
|
|
|
93
103
|
return undefined;
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
/** Reads one verb's options, refusing a name this command
|
|
97
|
-
function readOptions(args: readonly string[]): Options {
|
|
106
|
+
/** Reads one verb's options, refusing a name neither this command nor the verb offers. */
|
|
107
|
+
function readOptions(args: readonly string[], own: VerbOptions): Options {
|
|
98
108
|
const named = new Map<string, string>();
|
|
109
|
+
const repeated = new Map<string, string[]>();
|
|
99
110
|
for (const arg of args) {
|
|
100
111
|
if (!arg.startsWith("-")) {
|
|
101
112
|
throw new ConfigError("malformed", "", `unexpected argument ${JSON.stringify(arg)}`);
|
|
102
113
|
}
|
|
103
114
|
const [spelled, ...rest] = arg.split("=");
|
|
104
115
|
const name = (spelled ?? "").replace(/^-+/u, "");
|
|
105
|
-
if (
|
|
116
|
+
if (
|
|
117
|
+
!PLAIN_OPTIONS.has(name) &&
|
|
118
|
+
!SETTING_OPTIONS.has(name) &&
|
|
119
|
+
!own.plain.includes(name) &&
|
|
120
|
+
!own.repeatable.includes(name)
|
|
121
|
+
) {
|
|
106
122
|
throw new ConfigError("malformed", "", `unknown option ${JSON.stringify(spelled ?? arg)}`);
|
|
107
123
|
}
|
|
108
124
|
if (rest.length === 0) {
|
|
@@ -112,9 +128,11 @@ function readOptions(args: readonly string[]): Options {
|
|
|
112
128
|
`option ${JSON.stringify(spelled ?? arg)} takes a value`,
|
|
113
129
|
);
|
|
114
130
|
}
|
|
115
|
-
|
|
131
|
+
const value = rest.join("=");
|
|
132
|
+
named.set(name, value);
|
|
133
|
+
repeated.set(name, [...(repeated.get(name) ?? []), value]);
|
|
116
134
|
}
|
|
117
|
-
return { named };
|
|
135
|
+
return { named, repeated };
|
|
118
136
|
}
|
|
119
137
|
|
|
120
138
|
function readFileOr(host: Host, path: string, orUndefined: boolean): string | undefined {
|
|
@@ -202,7 +220,13 @@ export function run(
|
|
|
202
220
|
const name = args[0];
|
|
203
221
|
const verb = VERBS.find((listed) => listed.name === name);
|
|
204
222
|
if (verb?.run !== undefined) {
|
|
205
|
-
return invoke(
|
|
223
|
+
return invoke(
|
|
224
|
+
verb.run,
|
|
225
|
+
verb.options ?? NO_OPTIONS,
|
|
226
|
+
args.slice(1),
|
|
227
|
+
{ out, err, host },
|
|
228
|
+
openClient,
|
|
229
|
+
);
|
|
206
230
|
}
|
|
207
231
|
if (verb !== undefined) {
|
|
208
232
|
err.write(`deadset-ts: ${verb.name} is not implemented\n`);
|
|
@@ -222,18 +246,22 @@ export function run(
|
|
|
222
246
|
*/
|
|
223
247
|
function invoke(
|
|
224
248
|
verb: Verb,
|
|
249
|
+
own: VerbOptions,
|
|
225
250
|
args: readonly string[],
|
|
226
251
|
streams: { readonly out: Writer; readonly err: Writer; readonly host: Host },
|
|
227
252
|
openClient: (collectTiming: boolean) => Engine,
|
|
228
253
|
): number {
|
|
229
254
|
let options: Options | undefined;
|
|
230
|
-
const read = (): Options => (options ??= readOptions(args));
|
|
255
|
+
const read = (): Options => (options ??= readOptions(args, own));
|
|
231
256
|
let opened: Engine | undefined;
|
|
232
257
|
try {
|
|
233
258
|
return verb({
|
|
234
259
|
...streams,
|
|
260
|
+
args,
|
|
235
261
|
inputs: () => inputsOf(streams.host, read()),
|
|
236
262
|
scope: () => scopeOf(streams.host, read()),
|
|
263
|
+
option: (name) => read().named.get(name),
|
|
264
|
+
repeated: (name) => read().repeated.get(name) ?? [],
|
|
237
265
|
openClient: (collectTiming) => {
|
|
238
266
|
opened = openClient(collectTiming);
|
|
239
267
|
return opened;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two file-backed suppression documents at the target root: the ignore file, whose
|
|
3
|
+
* entries a maintainer writes, and the baseline, whose rows an analyzer writes. They share
|
|
4
|
+
* one record shape, so one decoder and one matcher serve both, and a row moves into the
|
|
5
|
+
* ignore file by gaining a maintainer's reason.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Host } from "./host.ts";
|
|
9
|
+
import type { InventorySymbol } from "./inventory.ts";
|
|
10
|
+
import { joinPath } from "./paths.ts";
|
|
11
|
+
import { positionAt, walkDocument } from "./json-document.ts";
|
|
12
|
+
import type { Position } from "./position.ts";
|
|
13
|
+
import {
|
|
14
|
+
CODE_FORM,
|
|
15
|
+
SUPPRESSION_WITHOUT_REASON,
|
|
16
|
+
SuppressionError,
|
|
17
|
+
UNSCOPED_ENTRY,
|
|
18
|
+
blankReason,
|
|
19
|
+
isEntryPath,
|
|
20
|
+
isSymbolRef,
|
|
21
|
+
siteText,
|
|
22
|
+
type Mechanism,
|
|
23
|
+
type Refusal,
|
|
24
|
+
type SuppressionRecord,
|
|
25
|
+
type Suppressions,
|
|
26
|
+
} from "./suppress.ts";
|
|
27
|
+
|
|
28
|
+
/** The name and the location the grammar fixes for the ignore file. */
|
|
29
|
+
export const IGNORE_FILE = "deadset-ignore.json";
|
|
30
|
+
|
|
31
|
+
/** The name and the location the grammar fixes for the baseline. */
|
|
32
|
+
export const BASELINE_FILE = "deadset-baseline.json";
|
|
33
|
+
|
|
34
|
+
/** One file-backed document as a reader sees it. */
|
|
35
|
+
interface Shape {
|
|
36
|
+
readonly file: string;
|
|
37
|
+
/** The member holding the records. */
|
|
38
|
+
readonly array: string;
|
|
39
|
+
readonly mechanism: Mechanism;
|
|
40
|
+
readonly documentWant: string;
|
|
41
|
+
readonly recordWant: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const IGNORE_SHAPE: Shape = {
|
|
45
|
+
file: IGNORE_FILE,
|
|
46
|
+
array: "ignore",
|
|
47
|
+
mechanism: "ignore",
|
|
48
|
+
documentWant: "an object carrying the ignore array, and an optional description",
|
|
49
|
+
recordWant: "an entry naming code, symbol, path and reason, each a string",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const BASELINE_SHAPE: Shape = {
|
|
53
|
+
file: BASELINE_FILE,
|
|
54
|
+
array: "baseline",
|
|
55
|
+
mechanism: "baseline",
|
|
56
|
+
documentWant: "an object carrying the baseline array, and an optional description",
|
|
57
|
+
recordWant: "a row naming code, symbol, path and reason, each a string",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The closed key list of one entry or row. */
|
|
61
|
+
const RECORD_KEYS: ReadonlySet<string> = new Set(["code", "symbol", "path", "reason"]);
|
|
62
|
+
|
|
63
|
+
const CODE_WANT = "DS followed by four digits";
|
|
64
|
+
const SYMBOL_WANT =
|
|
65
|
+
"a stable symbol reference, go://<import-path>#<fragment> or ts://<package>/<source-path>#<fragment>";
|
|
66
|
+
const PATH_WANT = "a path relative to the target root, with the solidus as separator";
|
|
67
|
+
|
|
68
|
+
/** The document itself, for a defect no record position describes. */
|
|
69
|
+
function documentSite(shape: Shape): Position {
|
|
70
|
+
return { path: shape.file, line: 0, column: 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
74
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What one entry or row resolves to. */
|
|
78
|
+
interface Read {
|
|
79
|
+
readonly records: readonly SuppressionRecord[];
|
|
80
|
+
readonly refusals: readonly Refusal[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The declarations of the run by reference and file, which is what an entry names. */
|
|
84
|
+
type ByReference = ReadonlyMap<string, readonly InventorySymbol[]>;
|
|
85
|
+
|
|
86
|
+
function referenceKey(ref: string, path: string): string {
|
|
87
|
+
return `${ref}\u0000${path}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Reads one entry or row: the records it binds, the refusals it carries, or the
|
|
92
|
+
* malformed value that ends the run.
|
|
93
|
+
*/
|
|
94
|
+
function readRecord(value: unknown, site: Position, byReference: ByReference, shape: Shape): Read {
|
|
95
|
+
const refuse = (text: string, want: string): never => {
|
|
96
|
+
throw new SuppressionError(shape.mechanism, `${shape.file} ${siteText(site)}`, text, want);
|
|
97
|
+
};
|
|
98
|
+
if (!isObject(value)) {
|
|
99
|
+
return refuse(JSON.stringify(value), shape.recordWant);
|
|
100
|
+
}
|
|
101
|
+
for (const [key, held] of Object.entries(value)) {
|
|
102
|
+
if (!RECORD_KEYS.has(key)) {
|
|
103
|
+
refuse(`the member ${JSON.stringify(key)} is not one the grammar declares`, shape.recordWant);
|
|
104
|
+
}
|
|
105
|
+
if (typeof held !== "string") {
|
|
106
|
+
refuse(`the member ${JSON.stringify(key)} holds ${JSON.stringify(held)}`, shape.recordWant);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const entry = value as Readonly<Record<string, string | undefined>>;
|
|
110
|
+
const { code, symbol, path, reason } = {
|
|
111
|
+
code: entry["code"],
|
|
112
|
+
symbol: entry["symbol"],
|
|
113
|
+
path: entry["path"],
|
|
114
|
+
reason: entry["reason"],
|
|
115
|
+
};
|
|
116
|
+
if (code === undefined) {
|
|
117
|
+
return refuse("a record naming no code", shape.recordWant);
|
|
118
|
+
}
|
|
119
|
+
if (symbol === undefined) {
|
|
120
|
+
return refuse("a record naming no symbol", shape.recordWant);
|
|
121
|
+
}
|
|
122
|
+
if (!CODE_FORM.test(code)) {
|
|
123
|
+
return refuse(JSON.stringify(code), CODE_WANT);
|
|
124
|
+
}
|
|
125
|
+
if (!isSymbolRef(symbol)) {
|
|
126
|
+
return refuse(JSON.stringify(symbol), SYMBOL_WANT);
|
|
127
|
+
}
|
|
128
|
+
if (path !== undefined && path !== "" && !isEntryPath(path)) {
|
|
129
|
+
return refuse(JSON.stringify(path), PATH_WANT);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const written = {
|
|
133
|
+
code,
|
|
134
|
+
symbol,
|
|
135
|
+
path: path ?? "",
|
|
136
|
+
reason: reason ?? "",
|
|
137
|
+
site,
|
|
138
|
+
mechanism: shape.mechanism,
|
|
139
|
+
};
|
|
140
|
+
const refusals: Refusal[] = [];
|
|
141
|
+
if (blankReason(reason)) {
|
|
142
|
+
refusals.push({ ...written, reported: SUPPRESSION_WITHOUT_REASON });
|
|
143
|
+
}
|
|
144
|
+
if (written.path === "") {
|
|
145
|
+
refusals.push({ ...written, reported: UNSCOPED_ENTRY });
|
|
146
|
+
}
|
|
147
|
+
if (refusals.length > 0) {
|
|
148
|
+
return { records: [], refusals };
|
|
149
|
+
}
|
|
150
|
+
const named = byReference.get(referenceKey(symbol, written.path)) ?? [];
|
|
151
|
+
if (named.length === 0) {
|
|
152
|
+
return { records: [{ ...written, bound: "" }], refusals: [] };
|
|
153
|
+
}
|
|
154
|
+
return { records: named.map((one) => ({ ...written, bound: one.id })), refusals: [] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Reads one document at the target root, binding each record against the run's declarations. */
|
|
158
|
+
function readDocument(
|
|
159
|
+
host: Host,
|
|
160
|
+
targetRoot: string,
|
|
161
|
+
symbols: readonly InventorySymbol[],
|
|
162
|
+
shape: Shape,
|
|
163
|
+
): Suppressions {
|
|
164
|
+
const file = joinPath(targetRoot, shape.file);
|
|
165
|
+
if (host.kindOf(file) === "absent") {
|
|
166
|
+
return { records: [], refusals: [] };
|
|
167
|
+
}
|
|
168
|
+
const text = host.readFile(file);
|
|
169
|
+
const malformed = (detail: string, site = documentSite(shape)): never => {
|
|
170
|
+
throw new SuppressionError(shape.mechanism, siteText(site), detail, shape.documentWant);
|
|
171
|
+
};
|
|
172
|
+
let parsed: unknown;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(text);
|
|
175
|
+
} catch (error: unknown) {
|
|
176
|
+
return malformed(`${shape.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
177
|
+
}
|
|
178
|
+
if (!isObject(parsed)) {
|
|
179
|
+
return malformed(shape.file);
|
|
180
|
+
}
|
|
181
|
+
const walk = walkDocument(text, shape.array);
|
|
182
|
+
if (walk.twice !== undefined) {
|
|
183
|
+
malformed(
|
|
184
|
+
`the member ${JSON.stringify(walk.twice.name)} is written twice, so neither value is chosen`,
|
|
185
|
+
positionAt(text, shape.file, walk.twice.offset),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
for (const key of Object.keys(parsed)) {
|
|
189
|
+
if (key !== "description" && key !== shape.array) {
|
|
190
|
+
malformed(`the member ${JSON.stringify(key)} is not one the grammar declares`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const description = parsed["description"];
|
|
194
|
+
const held = parsed[shape.array];
|
|
195
|
+
if ((description !== undefined && typeof description !== "string") || !Array.isArray(held)) {
|
|
196
|
+
return malformed(shape.file);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const byReference = new Map<string, InventorySymbol[]>();
|
|
200
|
+
for (const symbol of symbols) {
|
|
201
|
+
const key = referenceKey(symbol.ref, symbol.position.path);
|
|
202
|
+
byReference.set(key, [...(byReference.get(key) ?? []), symbol]);
|
|
203
|
+
}
|
|
204
|
+
const records: SuppressionRecord[] = [];
|
|
205
|
+
const refusals: Refusal[] = [];
|
|
206
|
+
(held as readonly unknown[]).forEach((value, index) => {
|
|
207
|
+
const offset = walk.opened[index];
|
|
208
|
+
const site = offset === undefined ? documentSite(shape) : positionAt(text, shape.file, offset);
|
|
209
|
+
const read = readRecord(value, site, byReference, shape);
|
|
210
|
+
records.push(...read.records);
|
|
211
|
+
refusals.push(...read.refusals);
|
|
212
|
+
});
|
|
213
|
+
return { records, refusals };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The ignore file at the target root, each entry bound to the declaration whose reference
|
|
218
|
+
* and file both equal the ones it names, and never by glob, pattern or substring. An
|
|
219
|
+
* absent file is an empty one.
|
|
220
|
+
*/
|
|
221
|
+
export function readIgnoreFile(
|
|
222
|
+
host: Host,
|
|
223
|
+
targetRoot: string,
|
|
224
|
+
symbols: readonly InventorySymbol[],
|
|
225
|
+
): Suppressions {
|
|
226
|
+
return readDocument(host, targetRoot, symbols, IGNORE_SHAPE);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The baseline at the target root, each row matched exactly as an ignore entry is. A row's
|
|
231
|
+
* reason is the provenance an analyzer wrote, never a maintainer's judgement: the finding
|
|
232
|
+
* it records is real, and the row says only that it was there when the baseline was
|
|
233
|
+
* written. An absent file is an empty one.
|
|
234
|
+
*/
|
|
235
|
+
export function readBaseline(
|
|
236
|
+
host: Host,
|
|
237
|
+
targetRoot: string,
|
|
238
|
+
symbols: readonly InventorySymbol[],
|
|
239
|
+
): Suppressions {
|
|
240
|
+
return readDocument(host, targetRoot, symbols, BASELINE_SHAPE);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** One finding a written row records: what a later run matches the row on. */
|
|
244
|
+
export interface Recorded {
|
|
245
|
+
readonly code: string;
|
|
246
|
+
readonly symbol: string;
|
|
247
|
+
readonly path: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The analyzer a written row names as its provenance, as the same run's report names it. */
|
|
251
|
+
export interface Provenance {
|
|
252
|
+
readonly analyzer: string;
|
|
253
|
+
readonly version: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** What a written document says about itself, so the same report writes the same bytes. */
|
|
257
|
+
const BASELINE_DESCRIPTION =
|
|
258
|
+
"Recorded findings: a run fails only on a finding this document does not hold. " +
|
|
259
|
+
"Every reason is the analyzer that recorded the row, never an adjudication.";
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A baseline holding one row per finding, in the order given, which is the report's. Every
|
|
263
|
+
* row carries the provenance as its reason. A finding missing a value a row needs, and an
|
|
264
|
+
* identity missing its name or version, are refused rather than written, because the
|
|
265
|
+
* grammar requires a reason on every row.
|
|
266
|
+
*/
|
|
267
|
+
export function writeBaseline(findings: readonly Recorded[], provenance: Provenance): string {
|
|
268
|
+
if (provenance.analyzer === "" || provenance.version === "") {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`a baseline row would carry no reason: the analyzer identity names ${JSON.stringify(provenance.analyzer)} at version ${JSON.stringify(provenance.version)}`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const reason = `recorded by ${provenance.analyzer} ${provenance.version}`;
|
|
274
|
+
const baseline = findings.map((found) => {
|
|
275
|
+
if (found.code === "" || found.symbol === "" || found.path === "") {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`a baseline row would carry no reason: a finding names code ${JSON.stringify(found.code)}, symbol ${JSON.stringify(found.symbol)} and path ${JSON.stringify(found.path)}`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return { code: found.code, symbol: found.symbol, path: found.path, reason };
|
|
281
|
+
});
|
|
282
|
+
return `${JSON.stringify({ description: BASELINE_DESCRIPTION, baseline }, null, 2)}\n`;
|
|
283
|
+
}
|