@cplieger/deadset-ts 3.7.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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The suppression grammar's records, and the ledger that decides what each one did. A
3
+ * record is one code bound to one site with its reason, from an inline directive, an
4
+ * ignore entry or a baseline row. A bound record marks its declaration live before the
5
+ * sweep, and is in effect when it withholds the finding its code would have produced
6
+ * there, dormant when the configuration withholds that finding anyway, and stale otherwise.
7
+ */
8
+
9
+ import { ConfigError } from "./config.ts";
10
+ import type { Finding } from "./finding.ts";
11
+ import { positionKey, type Position } from "./position.ts";
12
+ import { isRef } from "./ref.ts";
13
+
14
+ /** Which document a suppression is written in, spelled as a finding carries it. */
15
+ export type Mechanism = "inline" | "ignore" | "baseline";
16
+
17
+ /** The code a suppression that carries no reason is reported under. */
18
+ export const SUPPRESSION_WITHOUT_REASON = "DS1701";
19
+
20
+ /** The code an ignore entry or a baseline row that names no path is reported under. */
21
+ export const UNSCOPED_ENTRY = "DS1702";
22
+
23
+ /** The code a suppression that withholds nothing is reported under. */
24
+ export const STALE_SUPPRESSION = "DS1703";
25
+
26
+ /**
27
+ * One suppression record: one code bound to one site. A directive naming two codes is two
28
+ * records with one reason, and a directive above a line that declares two symbols is one
29
+ * record per symbol.
30
+ */
31
+ export interface SuppressionRecord {
32
+ readonly code: string;
33
+ /** The stable symbol reference named or bound, empty for a directive that bound nothing. */
34
+ readonly symbol: string;
35
+ /** The file the suppression names: the directive's own file, or the entry's `path`. */
36
+ readonly path: string;
37
+ /** Never blank: a suppression carrying no reason is a {@link Refusal}. */
38
+ readonly reason: string;
39
+ /** The declaration the record marks live, empty where it bound none. */
40
+ readonly bound: string;
41
+ /** Where the suppression is written. */
42
+ readonly site: Position;
43
+ readonly mechanism: Mechanism;
44
+ }
45
+
46
+ /**
47
+ * One suppression the grammar refuses with a finding rather than with an exit. It binds
48
+ * nothing, and its finding reports the suppression as written.
49
+ */
50
+ export interface Refusal {
51
+ readonly reported: typeof SUPPRESSION_WITHOUT_REASON | typeof UNSCOPED_ENTRY;
52
+ readonly code: string;
53
+ /** The reference the entry names, empty for a directive. */
54
+ readonly symbol: string;
55
+ readonly path: string;
56
+ readonly reason: string;
57
+ readonly site: Position;
58
+ readonly mechanism: Mechanism;
59
+ }
60
+
61
+ /** What the three documents of one target hold, in reading order. */
62
+ export interface Suppressions {
63
+ /** Inline directives by position, then the ignore file's entries, then the baseline's rows. */
64
+ readonly records: readonly SuppressionRecord[];
65
+ readonly refusals: readonly Refusal[];
66
+ }
67
+
68
+ /** A target that carries no suppression. */
69
+ export const NO_SUPPRESSIONS: Suppressions = { records: [], refusals: [] };
70
+
71
+ /**
72
+ * A directive, an entry or a document the grammar refuses before any finding exists: an
73
+ * instruction the analysis cannot carry out. It is a malformed input, so the command line
74
+ * answers it with the usage code.
75
+ */
76
+ export class SuppressionError extends ConfigError {
77
+ constructor(mechanism: Mechanism, at: string, text: string, want: string) {
78
+ super("malformed", at, `${mechanism} ${at}: ${text}: want ${want}`);
79
+ this.name = "SuppressionError";
80
+ }
81
+ }
82
+
83
+ /** Where a position is written, as an error names it. */
84
+ export function siteText(site: Position): string {
85
+ return site.line > 0 ? positionKey(site) : site.path;
86
+ }
87
+
88
+ /** An issue-kind code: `DS` and four digits. */
89
+ export const CODE_FORM = /^DS[0-9]{4}$/u;
90
+
91
+ /**
92
+ * A path inside the target: relative to the target root, the solidus as separator, no
93
+ * leading `./`, no trailing solidus, no `.` or `..` segment, never absolute.
94
+ */
95
+ export const PATH_EXPRESSION = String.raw`^(?:[^/\\.\r\n][^/\\\r\n]*|\.[^/\\.\r\n][^/\\\r\n]*|\.\.[^/\\\r\n]+)(?:/(?:[^/\\.\r\n][^/\\\r\n]*|\.[^/\\.\r\n][^/\\\r\n]*|\.\.[^/\\\r\n]+))*$`;
96
+
97
+ const PATH_FORM = new RegExp(PATH_EXPRESSION, "u");
98
+
99
+ /** Whether one value is a path in the form an entry's `path` takes. */
100
+ export function isEntryPath(value: string): boolean {
101
+ return PATH_FORM.test(value);
102
+ }
103
+
104
+ const GO_IDENT = String.raw`(?:[A-Za-z_]|[^\x00-\x7F])(?:[A-Za-z0-9_]|[^\x00-\x7F])*`;
105
+ const GO_PATH = String.raw`[A-Za-z0-9._~-]+(?:/[A-Za-z0-9._~-]+)*`;
106
+ const GO_VERSION = String.raw`[A-Za-z0-9.+-]+`;
107
+ const GO_FILE = String.raw`[^/\\:#\r\n]+\.go`;
108
+
109
+ /**
110
+ * The reference forms of the Go analyzer's grammar. One target holds one ignore file,
111
+ * whose entries may name a symbol of either language, so an entry naming a Go symbol is
112
+ * well formed here and binds nothing.
113
+ */
114
+ export const GO_REF_EXPRESSIONS: readonly string[] = [
115
+ `^go://${GO_PATH}#$`,
116
+ `^go://${GO_PATH}#${GO_IDENT}$`,
117
+ `^go://${GO_PATH}#${GO_IDENT}(?:\\.${GO_IDENT})+$`,
118
+ `^go://${GO_PATH}#${GO_IDENT}(?:\\.${GO_IDENT})?\\[${GO_IDENT}\\]$`,
119
+ `^go://${GO_PATH}#${GO_FILE}:file$`,
120
+ `^go://${GO_PATH}#${GO_PATH}(?:@${GO_VERSION})?:require$`,
121
+ `^go://${GO_PATH}#${GO_PATH}(?:@${GO_VERSION})?:replace$`,
122
+ ];
123
+
124
+ const GO_REF_FORMS: readonly RegExp[] = GO_REF_EXPRESSIONS.map(
125
+ (expression) => new RegExp(expression, "u"),
126
+ );
127
+
128
+ /** Whether one value is a stable symbol reference of either language's grammar. */
129
+ export function isSymbolRef(value: string): boolean {
130
+ return isRef(value) || GO_REF_FORMS.some((form) => form.test(value));
131
+ }
132
+
133
+ /** Whether a reason is absent, empty or whitespace only, which the grammar reads as none. */
134
+ export function blankReason(reason: string | undefined): boolean {
135
+ return reason === undefined || /^[ \t\r\n]*$/u.test(reason);
136
+ }
137
+
138
+ /** What a record did, as the ledger decides it. */
139
+ export type Verdict = "in-effect" | "dormant" | "stale";
140
+
141
+ /**
142
+ * The configuration's dials, as the ledger reads them. Each is a dial that decides whether
143
+ * a run reports a finding rather than whether one exists, so a record whose finding one of
144
+ * them withholds is dormant.
145
+ */
146
+ export interface Dials<F extends Finding = Finding> {
147
+ /** Whether the configuration withholds every finding of one code from one declaration. */
148
+ readonly withholdsCode: (code: string, bound: string) => boolean;
149
+ /** Whether it withholds one finding: by its severity, its confidence, or its component's root. */
150
+ readonly withholds: (finding: F) => boolean;
151
+ }
152
+
153
+ /** What every record did, and the findings the records withhold. */
154
+ export interface Ledger<F extends Finding = Finding> {
155
+ /** One verdict per record, in reading order. */
156
+ readonly verdicts: readonly Verdict[];
157
+ /** Per record, the finding it claimed, where it claimed one. */
158
+ readonly claims: readonly (F | undefined)[];
159
+ /** Whether a record withholds one finding of the run. */
160
+ readonly withheld: (finding: Finding) => boolean;
161
+ }
162
+
163
+ /**
164
+ * The subject kinds that are a row of a document rather than a declaration of the program.
165
+ * A record binds to a declaration, so nothing withholds a finding about one.
166
+ */
167
+ const ROW_SUBJECTS: ReadonlySet<string> = new Set([
168
+ "file",
169
+ "dependency",
170
+ "module-directive",
171
+ "root",
172
+ "suppression",
173
+ "edge",
174
+ ]);
175
+
176
+ /** Whether a finding's subject is a row of a document, which no record can withhold. */
177
+ export function isRowSubject(kind: string): boolean {
178
+ return ROW_SUBJECTS.has(kind);
179
+ }
180
+
181
+ /** One finding's identity: its code and the position of the thing it names. */
182
+ function claimKey(finding: Finding): string {
183
+ return `${finding.code}\u0000${positionKey(finding.position)}`;
184
+ }
185
+
186
+ /**
187
+ * Whether one record names one finding. A record reaches a finding in two ways: it bound
188
+ * the declaration at the finding's position, or it names the finding's reference and the
189
+ * file the finding is reported in, each exactly. The second is how an entry copied from a
190
+ * finding about a part of a declaration reaches the part.
191
+ */
192
+ export function pairs(record: SuppressionRecord, finding: Finding): boolean {
193
+ if (record.code !== finding.code || ROW_SUBJECTS.has(finding.symbol.kind)) {
194
+ return false;
195
+ }
196
+ if (record.bound !== "" && record.bound === positionKey(finding.position)) {
197
+ return true;
198
+ }
199
+ return record.symbol === finding.symbol.ref && record.path === finding.position.path;
200
+ }
201
+
202
+ /**
203
+ * Decides every record against `would`, the findings the run would produce were no record
204
+ * there: swept with no mark, where a kind about liveness reports the marked declaration,
205
+ * and as swept, where a kind whose subject the sweep holds live reports it. The first
206
+ * record in reading order claims a finding, so a second naming it is stale. A record whose
207
+ * code the dials withhold from its declaration, or whose claimed finding they withhold, is
208
+ * dormant.
209
+ */
210
+ export function ledgerOf<F extends Finding>(
211
+ records: readonly SuppressionRecord[],
212
+ would: readonly F[],
213
+ dials: Dials<F>,
214
+ ): Ledger<F> {
215
+ const taken = new Set<string>();
216
+ const verdicts: Verdict[] = [];
217
+ const claims: (F | undefined)[] = [];
218
+ for (const record of records) {
219
+ if (record.bound !== "" && dials.withholdsCode(record.code, record.bound)) {
220
+ verdicts.push("dormant");
221
+ claims.push(undefined);
222
+ continue;
223
+ }
224
+ const claimed = would.find(
225
+ (finding) => !taken.has(claimKey(finding)) && pairs(record, finding),
226
+ );
227
+ claims.push(claimed);
228
+ if (claimed === undefined) {
229
+ verdicts.push("stale");
230
+ continue;
231
+ }
232
+ taken.add(claimKey(claimed));
233
+ verdicts.push(dials.withholds(claimed) ? "dormant" : "in-effect");
234
+ }
235
+ return {
236
+ verdicts,
237
+ claims,
238
+ withheld: (finding) => taken.has(claimKey(finding)) && !ROW_SUBJECTS.has(finding.symbol.kind),
239
+ };
240
+ }
241
+
242
+ /** The two suppression counts a report's totals carry. */
243
+ export interface SuppressionCounts {
244
+ /** The records in effect. */
245
+ readonly inEffect: number;
246
+ /** The directives, entries and rows that carry a reason, each once whatever its codes. */
247
+ readonly reasonsRecorded: number;
248
+ }
249
+
250
+ /** The suppression counts of one run's records and the ledger over them. */
251
+ export function suppressionCounts<F extends Finding>(
252
+ records: readonly SuppressionRecord[],
253
+ ledger: Ledger<F>,
254
+ ): SuppressionCounts {
255
+ const sites = new Set(
256
+ records.map((record) => `${record.mechanism}\u0000${positionKey(record.site)}`),
257
+ );
258
+ return {
259
+ inEffect: ledger.verdicts.filter((verdict) => verdict === "in-effect").length,
260
+ reasonsRecorded: sites.size,
261
+ };
262
+ }