@cplieger/deadset-ts 3.6.0 → 3.8.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/dist/src/analysis.js +92 -23
- package/dist/src/edges.js +190 -0
- package/dist/src/finding.js +0 -11
- package/dist/src/findings/completion.js +92 -35
- package/dist/src/findings/dependencies-and-module-machinery.js +3 -11
- package/dist/src/findings/emitters.js +42 -4
- package/dist/src/findings/reads-and-writes.js +16 -69
- package/dist/src/findings/self-check.js +127 -2
- package/dist/src/findings/unused-declarations.js +8 -40
- 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/kinds.js +47 -45
- package/dist/src/roots.js +0 -34
- package/dist/src/suppress-file.js +196 -0
- package/dist/src/suppress-inline.js +259 -0
- package/dist/src/suppress.js +148 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +176 -32
- package/src/edges.ts +241 -0
- package/src/finding.ts +76 -10
- package/src/findings/boundary.ts +0 -11
- package/src/findings/completion.ts +117 -71
- package/src/findings/dependencies-and-module-machinery.ts +4 -19
- package/src/findings/emitter.ts +6 -2
- package/src/findings/emitters.ts +81 -7
- package/src/findings/interfaces.ts +5 -17
- package/src/findings/reads-and-writes.ts +37 -151
- package/src/findings/self-check.ts +205 -2
- package/src/findings/unused-declarations.ts +11 -58
- package/src/findings/visibility-narrowing.ts +11 -58
- package/src/findings-pass.ts +145 -0
- package/src/json-document.ts +130 -0
- package/src/kinds.ts +79 -45
- package/src/roots.ts +0 -39
- package/src/suppress-file.ts +283 -0
- package/src/suppress-inline.ts +318 -0
- package/src/suppress.ts +262 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read of a strict JSON document beside its parse: what a parse into a value cannot
|
|
3
|
+
* report, the offset of each record of one array and a member written twice.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Position } from "./position.ts";
|
|
7
|
+
|
|
8
|
+
/** The token walk over a document `JSON.parse` accepted, so every token is well formed. */
|
|
9
|
+
class Walk {
|
|
10
|
+
readonly opened: number[] = [];
|
|
11
|
+
/** The member written twice, where one is, as the offset of its name. */
|
|
12
|
+
twice: { readonly name: string; readonly offset: number } | undefined;
|
|
13
|
+
private readonly text: string;
|
|
14
|
+
private readonly array: string;
|
|
15
|
+
private at = 0;
|
|
16
|
+
|
|
17
|
+
constructor(text: string, array: string) {
|
|
18
|
+
this.text = text;
|
|
19
|
+
this.array = array;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
run(): void {
|
|
23
|
+
this.value("document");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private skip(): void {
|
|
27
|
+
while (/[ \t\r\n]/u.test(this.text[this.at] ?? "")) {
|
|
28
|
+
this.at += 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private string(): string {
|
|
33
|
+
const start = this.at;
|
|
34
|
+
this.at += 1;
|
|
35
|
+
while (this.text[this.at] !== '"') {
|
|
36
|
+
this.at += this.text[this.at] === "\\" ? 2 : 1;
|
|
37
|
+
}
|
|
38
|
+
this.at += 1;
|
|
39
|
+
return JSON.parse(this.text.slice(start, this.at)) as string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private value(role: "document" | "array" | "record" | "other"): void {
|
|
43
|
+
this.skip();
|
|
44
|
+
const char = this.text[this.at];
|
|
45
|
+
if (char === "{") {
|
|
46
|
+
if (role === "record") {
|
|
47
|
+
this.opened.push(this.at);
|
|
48
|
+
}
|
|
49
|
+
this.object(role);
|
|
50
|
+
} else if (char === "[") {
|
|
51
|
+
this.list(role === "array" ? "record" : "other");
|
|
52
|
+
} else if (char === '"') {
|
|
53
|
+
this.string();
|
|
54
|
+
} else {
|
|
55
|
+
while (this.at < this.text.length && !/[,\]} \t\r\n]/u.test(this.text[this.at] ?? "")) {
|
|
56
|
+
this.at += 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private object(role: string): void {
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
this.at += 1;
|
|
64
|
+
for (;;) {
|
|
65
|
+
this.skip();
|
|
66
|
+
if (this.text[this.at] === "}") {
|
|
67
|
+
this.at += 1;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (this.text[this.at] === ",") {
|
|
71
|
+
this.at += 1;
|
|
72
|
+
this.skip();
|
|
73
|
+
}
|
|
74
|
+
const offset = this.at;
|
|
75
|
+
const name = this.string();
|
|
76
|
+
if (seen.has(name) && this.twice === undefined) {
|
|
77
|
+
this.twice = { name, offset };
|
|
78
|
+
}
|
|
79
|
+
seen.add(name);
|
|
80
|
+
this.skip();
|
|
81
|
+
this.at += 1;
|
|
82
|
+
this.value(role === "document" && name === this.array ? "array" : "other");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private list(element: "record" | "other"): void {
|
|
87
|
+
this.at += 1;
|
|
88
|
+
for (;;) {
|
|
89
|
+
this.skip();
|
|
90
|
+
if (this.text[this.at] === "]") {
|
|
91
|
+
this.at += 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (this.text[this.at] === ",") {
|
|
95
|
+
this.at += 1;
|
|
96
|
+
}
|
|
97
|
+
this.value(element);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What a walk of one document found. */
|
|
103
|
+
export interface DocumentWalk {
|
|
104
|
+
/** The offset of the brace opening each object of the document's array member, in order. */
|
|
105
|
+
readonly opened: readonly number[];
|
|
106
|
+
/** The first member one object writes twice, at any depth, as the offset of its name. */
|
|
107
|
+
readonly twice: { readonly name: string; readonly offset: number } | undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Walks a document `JSON.parse` accepted, whose top-level member `array` holds the
|
|
112
|
+
* records. A member one object writes twice reads as though it were written once, its
|
|
113
|
+
* later value silently replacing the earlier one, so a document holding one is one a
|
|
114
|
+
* reader names rather than reads.
|
|
115
|
+
*/
|
|
116
|
+
export function walkDocument(text: string, array: string): DocumentWalk {
|
|
117
|
+
const walk = new Walk(text, array);
|
|
118
|
+
walk.run();
|
|
119
|
+
return { opened: walk.opened, twice: walk.twice };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One offset of a document as a finding carries it: the line counted from one, and the
|
|
124
|
+
* column counted from one in UTF-16 code units.
|
|
125
|
+
*/
|
|
126
|
+
export function positionAt(text: string, file: string, offset: number): Position {
|
|
127
|
+
const before = text.slice(0, offset);
|
|
128
|
+
const lineStart = before.lastIndexOf("\n") + 1;
|
|
129
|
+
return { path: file, line: before.split("\n").length, column: offset - lineStart + 1 };
|
|
130
|
+
}
|
package/src/kinds.ts
CHANGED
|
@@ -1,53 +1,87 @@
|
|
|
1
|
+
import type { Confidence, Severity } from "./config.ts";
|
|
2
|
+
|
|
3
|
+
/** What a mechanical edit may do with a finding of one kind. */
|
|
4
|
+
export type Fixability = "deletable" | "narrowable" | "manual" | "none";
|
|
5
|
+
|
|
6
|
+
/** One live row of the issue-kind vocabulary, as far as an analyzer reads it. */
|
|
7
|
+
export interface KindRow {
|
|
8
|
+
readonly code: string;
|
|
9
|
+
/** The kind's name, which a finding carries as its `kind`. */
|
|
10
|
+
readonly name: string;
|
|
11
|
+
/** The severity a finding carries where no severity key names its code or its family. */
|
|
12
|
+
readonly defaultSeverity: Severity;
|
|
13
|
+
/** The highest confidence a finding of the kind carries. */
|
|
14
|
+
readonly maxClass: Confidence;
|
|
15
|
+
readonly fixability: Fixability;
|
|
16
|
+
/**
|
|
17
|
+
* Whether the Contract fixes the kind on at its default severity, so a severity key
|
|
18
|
+
* naming the code, or a family prefix whose range holds it, is an unimplemented key.
|
|
19
|
+
*/
|
|
20
|
+
readonly fixed: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
1
23
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* The codes are written out rather than read from the Contract at run time: the
|
|
7
|
-
* Contract is data a reader outside this program consults, and a product that
|
|
8
|
-
* read it while running would need it installed beside itself. A test compares
|
|
9
|
-
* this list against the Contract release the analyzer is written against, so a
|
|
10
|
-
* code the Contract fixes arrives here as a failing test.
|
|
24
|
+
* Every live row of the issue-kind vocabulary, in ascending code order, written out
|
|
25
|
+
* rather than read at run time, because a product that read the Contract while running
|
|
26
|
+
* would need it installed beside itself. A test compares this table against the
|
|
27
|
+
* Contract release the analyzer is written against.
|
|
11
28
|
*/
|
|
12
|
-
|
|
29
|
+
const ROWS: readonly KindRow[] = [
|
|
30
|
+
row("DS1001", "unused-exported", "deny", "deletable"),
|
|
31
|
+
row("DS1002", "unused-unexported", "deny", "deletable"),
|
|
32
|
+
row("DS1003", "unused-member", "deny", "deletable"),
|
|
33
|
+
row("DS1004", "test-only-use", "deny", "deletable"),
|
|
34
|
+
row("DS1005", "test-of-dead-code", "deny", "deletable"),
|
|
35
|
+
row("DS1006", "deprecated-unused", "deny", "deletable"),
|
|
36
|
+
row("DS1101", "unnecessary-export", "warn", "narrowable"),
|
|
37
|
+
row("DS1102", "unnecessary-exposure", "warn", "narrowable"),
|
|
38
|
+
row("DS1103", "unreachable-export", "deny", "deletable"),
|
|
39
|
+
row("DS1104", "redundant-export-keyword", "warn", "narrowable"),
|
|
40
|
+
row("DS1201", "unused-interface", "deny", "deletable"),
|
|
41
|
+
row("DS1203", "uncalled-interface-method", "warn", "manual"),
|
|
42
|
+
row("DS1204", "unused-satisfaction-assertion", "deny", "deletable"),
|
|
43
|
+
row("DS1301", "write-only-symbol", "deny", "deletable"),
|
|
44
|
+
row("DS1302", "unused-enum-member", "deny", "deletable"),
|
|
45
|
+
row("DS1303", "unused-type-parameter", "deny", "deletable"),
|
|
46
|
+
row("DS1501", "file-never-built", "deny", "deletable"),
|
|
47
|
+
row("DS1502", "file-never-imported", "deny", "deletable"),
|
|
48
|
+
row("DS1601", "unused-dependency", "deny", "manual"),
|
|
49
|
+
row("DS1605", "unused-module-directive", "warn", "deletable"),
|
|
50
|
+
row("DS1701", "suppression-without-reason", "deny", "none"),
|
|
51
|
+
row("DS1702", "unscoped-ignore-entry", "deny", "none"),
|
|
52
|
+
{ ...row("DS1703", "stale-suppression", "deny", "none"), fixed: true },
|
|
53
|
+
{ ...row("DS1704", "unmatched-root", "deny", "none"), fixed: true },
|
|
54
|
+
row("DS1705", "stale-cross-language-edge", "deny", "none"),
|
|
55
|
+
row("DS1801", "unused-parameter", "warn", "manual"),
|
|
56
|
+
row("DS1802", "unused-receiver", "warn", "deletable"),
|
|
57
|
+
row("DS1803", "unused-result", "warn", "manual"),
|
|
58
|
+
row("DS1805", "unreachable-statement", "deny", "deletable"),
|
|
59
|
+
row("DS1807", "dead-store", "deny", "deletable"),
|
|
60
|
+
row("DS1809", "unreachable-case", "deny", "deletable"),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** A row at the `certain` ceiling whose severity a configuration may set. */
|
|
64
|
+
function row(
|
|
65
|
+
code: string,
|
|
66
|
+
name: string,
|
|
67
|
+
defaultSeverity: Severity,
|
|
68
|
+
fixability: Fixability,
|
|
69
|
+
): KindRow {
|
|
70
|
+
return { code, name, defaultSeverity, maxClass: "certain", fixability, fixed: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every live row of the issue-kind vocabulary, by its code, in ascending code order. */
|
|
74
|
+
export const KINDS: ReadonlyMap<string, KindRow> = new Map(ROWS.map((one) => [one.code, one]));
|
|
13
75
|
|
|
14
76
|
/**
|
|
15
77
|
* The code of every live issue kind, in ascending order. A severity key names one of
|
|
16
78
|
* them or a family prefix whose range holds one; a key naming a retired code, an
|
|
17
79
|
* unassigned code or a range holding no live kind is an unimplemented key, so every
|
|
18
|
-
* severity a configuration sets is one some analyzer reports under.
|
|
19
|
-
* this list against the Contract release the analyzer is written against.
|
|
80
|
+
* severity a configuration sets is one some analyzer reports under.
|
|
20
81
|
*/
|
|
21
|
-
export const LIVE_CODES: readonly string[] =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"DS1006",
|
|
28
|
-
"DS1101",
|
|
29
|
-
"DS1102",
|
|
30
|
-
"DS1103",
|
|
31
|
-
"DS1104",
|
|
32
|
-
"DS1201",
|
|
33
|
-
"DS1203",
|
|
34
|
-
"DS1204",
|
|
35
|
-
"DS1301",
|
|
36
|
-
"DS1302",
|
|
37
|
-
"DS1303",
|
|
38
|
-
"DS1501",
|
|
39
|
-
"DS1502",
|
|
40
|
-
"DS1601",
|
|
41
|
-
"DS1605",
|
|
42
|
-
"DS1701",
|
|
43
|
-
"DS1702",
|
|
44
|
-
"DS1703",
|
|
45
|
-
"DS1704",
|
|
46
|
-
"DS1705",
|
|
47
|
-
"DS1801",
|
|
48
|
-
"DS1802",
|
|
49
|
-
"DS1803",
|
|
50
|
-
"DS1805",
|
|
51
|
-
"DS1807",
|
|
52
|
-
"DS1809",
|
|
53
|
-
];
|
|
82
|
+
export const LIVE_CODES: readonly string[] = ROWS.map((one) => one.code);
|
|
83
|
+
|
|
84
|
+
/** The issue-kind codes whose severity the Contract fixes, in ascending order. */
|
|
85
|
+
export const FIXED_SEVERITY_CODES: readonly string[] = ROWS.filter((one) => one.fixed).map(
|
|
86
|
+
(one) => one.code,
|
|
87
|
+
);
|
package/src/roots.ts
CHANGED
|
@@ -11,9 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { SourceFile } from "@typescript/native/unstable/ast";
|
|
13
13
|
import { aliasChains } from "./alias-chain.ts";
|
|
14
|
-
import { REPOSITORY_DOCUMENT } from "./config.ts";
|
|
15
14
|
import { entryPoints, type EntryRule } from "./entry-points.ts";
|
|
16
|
-
import type { Finding } from "./finding.ts";
|
|
17
15
|
import { globExpression } from "./glob.ts";
|
|
18
16
|
import { nodeKey, type Inventory, type InventorySymbol } from "./inventory.ts";
|
|
19
17
|
import type { Manifest, ManifestEntry } from "./manifest.ts";
|
|
@@ -89,15 +87,6 @@ export interface RootOptions {
|
|
|
89
87
|
readonly publishedAPI: boolean;
|
|
90
88
|
}
|
|
91
89
|
|
|
92
|
-
/** The code the Contract gives a configured root that matches nothing. */
|
|
93
|
-
const UNMATCHED_ROOT_CODE = "DS1704";
|
|
94
|
-
|
|
95
|
-
/** The subject kind a finding under that code carries, from the Contract's vocabulary. */
|
|
96
|
-
const UNMATCHED_ROOT_SUBJECT = "root";
|
|
97
|
-
|
|
98
|
-
/** The fixed position of a finding about a document rather than about a line of one. */
|
|
99
|
-
const DOCUMENT_POSITION = 1;
|
|
100
|
-
|
|
101
90
|
/** Whether a configured string carries either special character. */
|
|
102
91
|
function isPattern(text: string): boolean {
|
|
103
92
|
return text.includes("*") || text.includes("?");
|
|
@@ -406,31 +395,3 @@ export function unmatchedEverywhere(
|
|
|
406
395
|
perProject.every((project) => project.unmatched.includes(pattern)),
|
|
407
396
|
);
|
|
408
397
|
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* The findings a run reports for the configured strings that named nothing.
|
|
412
|
-
*
|
|
413
|
-
* `document` is the path of the document that declared the roots, below the target
|
|
414
|
-
* root; where the run read them from no document of the target it is empty and the
|
|
415
|
-
* finding names the conventional repository configuration, which is the document a
|
|
416
|
-
* maintainer writes a configured root in. Every finding of one run sits at that
|
|
417
|
-
* document's first position: the roots are written as an array whose members carry
|
|
418
|
-
* no line of their own, so the string each finding names is what tells one from
|
|
419
|
-
* another.
|
|
420
|
-
*/
|
|
421
|
-
export function unmatchedRoots(sources: readonly string[], document: string): readonly Finding[] {
|
|
422
|
-
const position = {
|
|
423
|
-
path: document === "" ? REPOSITORY_DOCUMENT : document,
|
|
424
|
-
line: DOCUMENT_POSITION,
|
|
425
|
-
column: DOCUMENT_POSITION,
|
|
426
|
-
endLine: DOCUMENT_POSITION,
|
|
427
|
-
};
|
|
428
|
-
return sources.map((source) => ({
|
|
429
|
-
code: UNMATCHED_ROOT_CODE,
|
|
430
|
-
position,
|
|
431
|
-
symbol: { ref: source, kind: UNMATCHED_ROOT_SUBJECT, name: source, sizeLines: 1 },
|
|
432
|
-
message: isPattern(source)
|
|
433
|
-
? "configured root pattern matches no symbol of the inventory"
|
|
434
|
-
: "configured root matches no symbol of the inventory",
|
|
435
|
-
}));
|
|
436
|
-
}
|
|
@@ -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
|
+
}
|