@cplieger/deadset-ts 3.8.0 → 3.9.1
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 +28 -12
- package/dist/src/conformance.js +22 -0
- package/dist/src/destructuring.js +273 -0
- package/dist/src/findings/completion.js +9 -3
- package/dist/src/findings/self-check.js +14 -2
- package/dist/src/references.js +54 -15
- 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/run.js +23 -10
- 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 +65 -14
- package/src/conformance.ts +34 -0
- package/src/destructuring.ts +353 -0
- package/src/finding.ts +13 -0
- package/src/findings/completion.ts +9 -4
- package/src/findings/self-check.ts +23 -2
- package/src/host.ts +5 -0
- package/src/references.ts +80 -19
- 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/run.ts +40 -12
- package/src/verbs/analyze.ts +234 -0
- package/src/verbs/describe.ts +46 -0
- package/src/verbs/verb.ts +13 -0
package/src/report.ts
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The report one analysis writes: the Contract's report document, member for member and
|
|
3
|
+
* in the order its schema lists them, built from what one run answered. Every reporter
|
|
4
|
+
* renders this document, so no two renderings disagree about which findings exist.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Severity, Sort, TargetKind } from "./config.ts";
|
|
8
|
+
import type { Conformance } from "./conformance.ts";
|
|
9
|
+
import type { EdgeEvaluation } from "./edges.ts";
|
|
10
|
+
import type {
|
|
11
|
+
CompletedFinding,
|
|
12
|
+
FindingDetails,
|
|
13
|
+
FindingPosition,
|
|
14
|
+
PositionedSymbol,
|
|
15
|
+
} from "./finding.ts";
|
|
16
|
+
import type { PassResult } from "./findings-pass.ts";
|
|
17
|
+
import type { StaleSuppression } from "./findings/self-check.ts";
|
|
18
|
+
import type { TestFileRule } from "./references.ts";
|
|
19
|
+
|
|
20
|
+
/** The version of the report schema this analyzer writes a report to. */
|
|
21
|
+
export const SCHEMA_VERSION = "6.0.0";
|
|
22
|
+
|
|
23
|
+
/** The name this analyzer writes every document under and mints every identifier with. */
|
|
24
|
+
export const ANALYZER_NAME = "deadset-ts";
|
|
25
|
+
|
|
26
|
+
/** The language this analyzer claims. */
|
|
27
|
+
export const LANGUAGE = "ts";
|
|
28
|
+
|
|
29
|
+
export interface WirePosition {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly line: number;
|
|
32
|
+
readonly column: number;
|
|
33
|
+
readonly end_line: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WireDetails {
|
|
37
|
+
readonly narrower_visibility?: string;
|
|
38
|
+
readonly implementations?: readonly {
|
|
39
|
+
readonly ref: string;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly position: WirePosition;
|
|
42
|
+
}[];
|
|
43
|
+
readonly write_positions?: readonly WirePosition[];
|
|
44
|
+
readonly dependency_class?: string;
|
|
45
|
+
readonly mechanism?: string;
|
|
46
|
+
readonly entry?: {
|
|
47
|
+
readonly code: string;
|
|
48
|
+
readonly symbol?: string;
|
|
49
|
+
readonly path?: string;
|
|
50
|
+
readonly reason?: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One finding as the finding schema spells it. */
|
|
55
|
+
export interface WireFinding {
|
|
56
|
+
readonly code: string;
|
|
57
|
+
readonly kind: string;
|
|
58
|
+
readonly language: string;
|
|
59
|
+
readonly position: WirePosition;
|
|
60
|
+
readonly symbol: {
|
|
61
|
+
readonly ref: string;
|
|
62
|
+
readonly kind: string;
|
|
63
|
+
readonly name: string;
|
|
64
|
+
readonly size_lines: number;
|
|
65
|
+
};
|
|
66
|
+
readonly reachability_class: string;
|
|
67
|
+
readonly confidence: string;
|
|
68
|
+
readonly liveness_relation?: string;
|
|
69
|
+
readonly test_only: boolean;
|
|
70
|
+
readonly generated: boolean;
|
|
71
|
+
readonly component: {
|
|
72
|
+
readonly id: string;
|
|
73
|
+
readonly root: boolean;
|
|
74
|
+
readonly symbol_count: number;
|
|
75
|
+
readonly deletable_lines: number;
|
|
76
|
+
};
|
|
77
|
+
readonly retained_by: readonly string[];
|
|
78
|
+
readonly configurations: readonly string[];
|
|
79
|
+
readonly consumers_loaded: readonly string[];
|
|
80
|
+
readonly fixability: string;
|
|
81
|
+
readonly severity: Severity;
|
|
82
|
+
readonly message: string;
|
|
83
|
+
readonly details: WireDetails;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface WireEdgeEvaluation {
|
|
87
|
+
readonly edge: string;
|
|
88
|
+
readonly side: string;
|
|
89
|
+
readonly symbol: string;
|
|
90
|
+
readonly state: string;
|
|
91
|
+
readonly finding?: WireFinding;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface WireStaleSuppression {
|
|
95
|
+
readonly code: string;
|
|
96
|
+
readonly mechanism: string;
|
|
97
|
+
readonly entry: {
|
|
98
|
+
readonly code: string;
|
|
99
|
+
readonly symbol?: string;
|
|
100
|
+
readonly path: string;
|
|
101
|
+
readonly reason: string;
|
|
102
|
+
};
|
|
103
|
+
readonly position: { readonly path: string; readonly line: number; readonly column: number };
|
|
104
|
+
readonly symbol: string;
|
|
105
|
+
readonly message: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface WireTotals {
|
|
109
|
+
readonly findings: number;
|
|
110
|
+
readonly by_severity: { readonly allow: number; readonly warn: number; readonly deny: number };
|
|
111
|
+
readonly deletable_lines: number;
|
|
112
|
+
readonly suppressions_in_effect: number;
|
|
113
|
+
readonly reasons_recorded: number;
|
|
114
|
+
readonly stale_suppressions: number;
|
|
115
|
+
readonly pending: number;
|
|
116
|
+
readonly omitted: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One project configuration of the matrix a run analyzed. */
|
|
120
|
+
export interface ReportConfiguration {
|
|
121
|
+
readonly id: string;
|
|
122
|
+
/** The compiler configuration file below the target root. */
|
|
123
|
+
readonly project: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One consumer the scope declared and the run did not load, with why. */
|
|
127
|
+
export interface UnavailableConsumer {
|
|
128
|
+
readonly id: string;
|
|
129
|
+
readonly reason: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The report document. */
|
|
133
|
+
export interface Report {
|
|
134
|
+
readonly schema_version: string;
|
|
135
|
+
readonly contract_version: string;
|
|
136
|
+
readonly analyzer: {
|
|
137
|
+
readonly name: string;
|
|
138
|
+
readonly version: string;
|
|
139
|
+
readonly languages: readonly string[];
|
|
140
|
+
readonly schema_versions_accepted: readonly string[];
|
|
141
|
+
readonly conformance: {
|
|
142
|
+
readonly corpus_version: string;
|
|
143
|
+
readonly result: string;
|
|
144
|
+
readonly digest: string;
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
readonly target: { readonly kind: TargetKind; readonly root: string; readonly identity: string };
|
|
148
|
+
readonly configurations: readonly ReportConfiguration[];
|
|
149
|
+
readonly configurations_not_built: readonly never[];
|
|
150
|
+
readonly consumers: {
|
|
151
|
+
readonly declared: number;
|
|
152
|
+
readonly loaded: readonly never[];
|
|
153
|
+
readonly unavailable: readonly {
|
|
154
|
+
readonly id: string;
|
|
155
|
+
readonly role: "consumer";
|
|
156
|
+
readonly reason: string;
|
|
157
|
+
}[];
|
|
158
|
+
};
|
|
159
|
+
readonly findings: readonly WireFinding[];
|
|
160
|
+
readonly edge_evaluations: readonly WireEdgeEvaluation[];
|
|
161
|
+
readonly stale_suppressions: readonly WireStaleSuppression[];
|
|
162
|
+
readonly declared_gaps: readonly never[];
|
|
163
|
+
readonly excluded_by_cgo: readonly never[];
|
|
164
|
+
readonly test_file_rules: readonly TestFileRule[];
|
|
165
|
+
readonly totals: WireTotals;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** What one run answered, from which its report is built. */
|
|
169
|
+
export interface ReportInput {
|
|
170
|
+
readonly contractVersion: string;
|
|
171
|
+
readonly version: string;
|
|
172
|
+
readonly conformance: Conformance;
|
|
173
|
+
readonly target: { readonly kind: TargetKind; readonly root: string; readonly identity: string };
|
|
174
|
+
readonly configurations: readonly ReportConfiguration[];
|
|
175
|
+
readonly unavailable: readonly UnavailableConsumer[];
|
|
176
|
+
readonly result: PassResult;
|
|
177
|
+
readonly testFileRules: readonly TestFileRule[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Two strings ordered bytewise. */
|
|
181
|
+
function compare(a: string, b: string): number {
|
|
182
|
+
if (a === b) {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
return a < b ? -1 : 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function wirePosition(position: FindingPosition): WirePosition {
|
|
189
|
+
return {
|
|
190
|
+
path: position.path,
|
|
191
|
+
line: position.line,
|
|
192
|
+
column: position.column,
|
|
193
|
+
end_line: position.endLine,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function wireSymbol(symbol: PositionedSymbol): {
|
|
198
|
+
ref: string;
|
|
199
|
+
name: string;
|
|
200
|
+
position: WirePosition;
|
|
201
|
+
} {
|
|
202
|
+
return { ref: symbol.ref, name: symbol.name, position: wirePosition(symbol.position) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The details one code carries, in the order the finding schema lists them. */
|
|
206
|
+
function wireDetails(details: FindingDetails): WireDetails {
|
|
207
|
+
const { entry } = details;
|
|
208
|
+
return {
|
|
209
|
+
...(details.narrowerVisibility === undefined
|
|
210
|
+
? {}
|
|
211
|
+
: { narrower_visibility: details.narrowerVisibility }),
|
|
212
|
+
...(details.implementations === undefined
|
|
213
|
+
? {}
|
|
214
|
+
: { implementations: details.implementations.map(wireSymbol) }),
|
|
215
|
+
...(details.writePositions === undefined
|
|
216
|
+
? {}
|
|
217
|
+
: { write_positions: details.writePositions.map(wirePosition) }),
|
|
218
|
+
...(details.dependencyClass === undefined ? {} : { dependency_class: details.dependencyClass }),
|
|
219
|
+
...(details.mechanism === undefined ? {} : { mechanism: details.mechanism }),
|
|
220
|
+
...(entry === undefined
|
|
221
|
+
? {}
|
|
222
|
+
: {
|
|
223
|
+
entry: {
|
|
224
|
+
code: entry.code,
|
|
225
|
+
...(entry.symbol === undefined ? {} : { symbol: entry.symbol }),
|
|
226
|
+
...(entry.path === undefined ? {} : { path: entry.path }),
|
|
227
|
+
...(entry.reason === undefined ? {} : { reason: entry.reason }),
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One finding as the finding schema spells it, member for member in its order. */
|
|
234
|
+
export function wireFinding(finding: CompletedFinding): WireFinding {
|
|
235
|
+
return {
|
|
236
|
+
code: finding.code,
|
|
237
|
+
kind: finding.kind,
|
|
238
|
+
language: finding.language,
|
|
239
|
+
position: wirePosition(finding.position),
|
|
240
|
+
symbol: {
|
|
241
|
+
ref: finding.symbol.ref,
|
|
242
|
+
kind: finding.symbol.kind,
|
|
243
|
+
name: finding.symbol.name,
|
|
244
|
+
size_lines: finding.symbol.sizeLines,
|
|
245
|
+
},
|
|
246
|
+
reachability_class: finding.reachabilityClass,
|
|
247
|
+
confidence: finding.confidence,
|
|
248
|
+
...(finding.livenessRelation === undefined
|
|
249
|
+
? {}
|
|
250
|
+
: { liveness_relation: finding.livenessRelation }),
|
|
251
|
+
test_only: finding.testOnly,
|
|
252
|
+
generated: finding.generated,
|
|
253
|
+
component: {
|
|
254
|
+
id: finding.component.id,
|
|
255
|
+
root: finding.component.root,
|
|
256
|
+
symbol_count: finding.component.symbolCount,
|
|
257
|
+
deletable_lines: finding.component.deletableLines,
|
|
258
|
+
},
|
|
259
|
+
retained_by: finding.retainedBy,
|
|
260
|
+
configurations: finding.configurations,
|
|
261
|
+
consumers_loaded: finding.consumersLoaded,
|
|
262
|
+
fixability: finding.fixability,
|
|
263
|
+
severity: finding.severity,
|
|
264
|
+
message: finding.message,
|
|
265
|
+
details: wireDetails(finding.details),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function wireEvaluation(evaluation: EdgeEvaluation<CompletedFinding>): WireEdgeEvaluation {
|
|
270
|
+
return {
|
|
271
|
+
edge: evaluation.edge,
|
|
272
|
+
side: evaluation.side,
|
|
273
|
+
symbol: evaluation.symbol,
|
|
274
|
+
state: evaluation.state,
|
|
275
|
+
...(evaluation.finding === undefined ? {} : { finding: wireFinding(evaluation.finding) }),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function wireStale(stale: StaleSuppression): WireStaleSuppression {
|
|
280
|
+
return {
|
|
281
|
+
code: stale.code,
|
|
282
|
+
mechanism: stale.mechanism,
|
|
283
|
+
entry: {
|
|
284
|
+
code: stale.entry.code,
|
|
285
|
+
...(stale.entry.symbol === undefined ? {} : { symbol: stale.entry.symbol }),
|
|
286
|
+
path: stale.entry.path,
|
|
287
|
+
reason: stale.entry.reason,
|
|
288
|
+
},
|
|
289
|
+
position: {
|
|
290
|
+
path: stale.position.path,
|
|
291
|
+
line: stale.position.line,
|
|
292
|
+
column: stale.position.column,
|
|
293
|
+
},
|
|
294
|
+
symbol: stale.symbol,
|
|
295
|
+
message: stale.message,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The canonical key over two findings: path, line, column, code, then the symbol reference. */
|
|
300
|
+
function canonical(a: WireFinding, b: WireFinding): number {
|
|
301
|
+
return (
|
|
302
|
+
compare(a.position.path, b.position.path) ||
|
|
303
|
+
a.position.line - b.position.line ||
|
|
304
|
+
a.position.column - b.position.column ||
|
|
305
|
+
compare(a.code, b.code) ||
|
|
306
|
+
compare(a.symbol.ref, b.symbol.ref)
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The canonical key over two stale suppressions, each component from the record's own member. */
|
|
311
|
+
function canonicalStale(a: WireStaleSuppression, b: WireStaleSuppression): number {
|
|
312
|
+
return (
|
|
313
|
+
compare(a.position.path, b.position.path) ||
|
|
314
|
+
a.position.line - b.position.line ||
|
|
315
|
+
a.position.column - b.position.column ||
|
|
316
|
+
compare(a.code, b.code) ||
|
|
317
|
+
compare(a.symbol, b.symbol)
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Edge evaluations by edge, then side, then their compact encodings. */
|
|
322
|
+
function byEdge(a: WireEdgeEvaluation, b: WireEdgeEvaluation): number {
|
|
323
|
+
return (
|
|
324
|
+
compare(a.edge, b.edge) ||
|
|
325
|
+
compare(a.side, b.side) ||
|
|
326
|
+
compare(JSON.stringify(a), JSON.stringify(b))
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The lines a reader deletes by acting on every finding: each distinct component a root
|
|
332
|
+
* finding names, counted once however many of its roots the findings hold.
|
|
333
|
+
*/
|
|
334
|
+
function deletableLines(findings: readonly WireFinding[]): number {
|
|
335
|
+
const counted = new Map<string, number>();
|
|
336
|
+
for (const finding of findings) {
|
|
337
|
+
if (finding.component.root) {
|
|
338
|
+
counted.set(finding.component.id, finding.component.deletable_lines);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
let total = 0;
|
|
342
|
+
for (const lines of counted.values()) {
|
|
343
|
+
total += lines;
|
|
344
|
+
}
|
|
345
|
+
return total;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The totals of one finding set, counted over the whole set. */
|
|
349
|
+
function totalsOf(findings: readonly WireFinding[], result: PassResult): WireTotals {
|
|
350
|
+
const bySeverity = { allow: 0, warn: 0, deny: 0 };
|
|
351
|
+
for (const finding of findings) {
|
|
352
|
+
bySeverity[finding.severity] += 1;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
findings: findings.length,
|
|
356
|
+
by_severity: bySeverity,
|
|
357
|
+
deletable_lines: deletableLines(findings),
|
|
358
|
+
suppressions_in_effect: result.totals.suppressionsInEffect,
|
|
359
|
+
reasons_recorded: result.totals.reasonsRecorded,
|
|
360
|
+
stale_suppressions: result.staleSuppressions.length,
|
|
361
|
+
pending: result.totals.pending,
|
|
362
|
+
omitted: 0,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Builds the report of one run, every array in the order the Contract fixes for it. */
|
|
367
|
+
export function buildReport(input: ReportInput): Report {
|
|
368
|
+
const findings = input.result.findings.map(wireFinding).sort(canonical);
|
|
369
|
+
const unavailable = [...new Map(input.unavailable.map((one) => [one.id, one])).values()]
|
|
370
|
+
.sort((a, b) => compare(a.id, b.id))
|
|
371
|
+
.map((one) => ({ id: one.id, role: "consumer" as const, reason: one.reason }));
|
|
372
|
+
return {
|
|
373
|
+
schema_version: SCHEMA_VERSION,
|
|
374
|
+
contract_version: input.contractVersion,
|
|
375
|
+
analyzer: {
|
|
376
|
+
name: ANALYZER_NAME,
|
|
377
|
+
version: input.version,
|
|
378
|
+
languages: [LANGUAGE],
|
|
379
|
+
schema_versions_accepted: [SCHEMA_VERSION],
|
|
380
|
+
conformance: {
|
|
381
|
+
corpus_version: input.conformance.corpusVersion,
|
|
382
|
+
result: input.conformance.result,
|
|
383
|
+
digest: input.conformance.digest,
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
target: { kind: input.target.kind, root: input.target.root, identity: input.target.identity },
|
|
387
|
+
configurations: [...input.configurations]
|
|
388
|
+
.sort((a, b) => compare(a.id, b.id))
|
|
389
|
+
.map((one) => ({ id: one.id, project: one.project })),
|
|
390
|
+
configurations_not_built: [],
|
|
391
|
+
consumers: { declared: unavailable.length, loaded: [], unavailable },
|
|
392
|
+
findings,
|
|
393
|
+
edge_evaluations: input.result.edgeEvaluations.map(wireEvaluation).sort(byEdge),
|
|
394
|
+
stale_suppressions: input.result.staleSuppressions.map(wireStale).sort(canonicalStale),
|
|
395
|
+
declared_gaps: [],
|
|
396
|
+
excluded_by_cgo: [],
|
|
397
|
+
test_file_rules: [...input.testFileRules]
|
|
398
|
+
.sort((a, b) => compare(a.rule, b.rule) || a.matched - b.matched)
|
|
399
|
+
.map((one) => ({ rule: one.rule, matched: one.matched })),
|
|
400
|
+
totals: totalsOf(findings, input.result),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The report with its findings in the order a configuration asks for. By size, a finding
|
|
406
|
+
* whose component a deletion shrinks more comes first, then one whose subject is larger,
|
|
407
|
+
* then the canonical key, so the order stays total; by position it is the canonical key.
|
|
408
|
+
*/
|
|
409
|
+
export function sortFindings(report: Report, by: Sort): Report {
|
|
410
|
+
const bySize = (a: WireFinding, b: WireFinding): number =>
|
|
411
|
+
b.component.deletable_lines - a.component.deletable_lines ||
|
|
412
|
+
b.symbol.size_lines - a.symbol.size_lines ||
|
|
413
|
+
canonical(a, b);
|
|
414
|
+
return { ...report, findings: [...report.findings].sort(by === "size" ? bySize : canonical) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The report holding the first `most` findings of its order, the rest counted as omitted.
|
|
419
|
+
* Every other total keeps describing the whole set. Zero keeps every finding.
|
|
420
|
+
*/
|
|
421
|
+
export function capFindings(report: Report, most: number): Report {
|
|
422
|
+
if (most <= 0 || report.findings.length <= most) {
|
|
423
|
+
return report;
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
...report,
|
|
427
|
+
findings: report.findings.slice(0, most),
|
|
428
|
+
totals: { ...report.totals, omitted: report.findings.length - most },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Reporter } from "./reporter.ts";
|
|
2
|
+
|
|
3
|
+
/** The indentation of the report document, which with the schema's member order fixes its bytes. */
|
|
4
|
+
const INDENT = 2;
|
|
5
|
+
|
|
6
|
+
/** The report document itself: the report, indented, with a closing newline. */
|
|
7
|
+
export const json: Reporter = (report) => `${JSON.stringify(report, null, INDENT)}\n`;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Format } from "../config.ts";
|
|
2
|
+
import type { Report } from "../report.ts";
|
|
3
|
+
|
|
4
|
+
/** One rendering of a report, as the text of the file it is written to. */
|
|
5
|
+
export type Reporter = (report: Report) => string;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One format this analyzer renders: its reporter, and the suffix appended to the report's
|
|
9
|
+
* path to name the file the rendering is written to, so no rendering lands on the report or
|
|
10
|
+
* on another rendering whatever extension the report's path carries.
|
|
11
|
+
*/
|
|
12
|
+
export interface Rendering {
|
|
13
|
+
readonly render: Reporter;
|
|
14
|
+
readonly suffix: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Every format this analyzer renders, each by its rendering. */
|
|
18
|
+
export type Renderings = ReadonlyMap<Format, Rendering>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Format } from "../config.ts";
|
|
2
|
+
import { json } from "./json.ts";
|
|
3
|
+
import type { Rendering, Renderings } from "./reporter.ts";
|
|
4
|
+
import { text } from "./text.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every format this analyzer renders, each by its reporter and its file suffix. A format
|
|
8
|
+
* absent from the table is refused rather than silently not written.
|
|
9
|
+
*/
|
|
10
|
+
export const RENDERINGS: Renderings = new Map<Format, Rendering>([
|
|
11
|
+
["text", { render: text, suffix: ".txt" }],
|
|
12
|
+
["json", { render: json, suffix: ".json" }],
|
|
13
|
+
]);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { WireTotals } from "../report.ts";
|
|
2
|
+
import type { Reporter } from "./reporter.ts";
|
|
3
|
+
|
|
4
|
+
/** The kind token and the confidence a stale suppression is rendered with, both fixed. */
|
|
5
|
+
const STALE_KIND = "suppression";
|
|
6
|
+
const STALE_CONFIDENCE = "certain";
|
|
7
|
+
|
|
8
|
+
/** A count with the word for it, so the summary reads as a sentence. */
|
|
9
|
+
function counted(n: number, one: string, many: string): string {
|
|
10
|
+
return `${String(n)} ${n === 1 ? one : many}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The report's totals in one line, in the order the report declares them. */
|
|
14
|
+
function summary(totals: WireTotals): string {
|
|
15
|
+
const { allow, warn, deny } = totals.by_severity;
|
|
16
|
+
return [
|
|
17
|
+
`summary: ${counted(totals.findings, "finding", "findings")} (${String(allow)} allow, ${String(warn)} warn, ${String(deny)} deny)`,
|
|
18
|
+
counted(totals.deletable_lines, "deletable line", "deletable lines"),
|
|
19
|
+
counted(totals.suppressions_in_effect, "suppression in effect", "suppressions in effect"),
|
|
20
|
+
counted(totals.reasons_recorded, "reason recorded", "reasons recorded"),
|
|
21
|
+
counted(totals.stale_suppressions, "stale suppression", "stale suppressions"),
|
|
22
|
+
`${String(totals.pending)} pending`,
|
|
23
|
+
`${String(totals.omitted)} omitted`,
|
|
24
|
+
].join(", ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One line per finding in the report's order, then one per stale suppression, then the
|
|
29
|
+
* summary. A finding line is `path:line:col: kind name: message [confidence] (CODE)`; the
|
|
30
|
+
* summary is deliberately not in that shape, so a filter for finding lines yields exactly
|
|
31
|
+
* them. Nothing else is written: no timestamp, duration or host detail.
|
|
32
|
+
*/
|
|
33
|
+
export const text: Reporter = (report) => {
|
|
34
|
+
const lines = report.findings.map(
|
|
35
|
+
(found) =>
|
|
36
|
+
`${found.position.path}:${String(found.position.line)}:${String(found.position.column)}: ${found.symbol.kind} ${found.symbol.name}: ${found.message} [${found.confidence}] (${found.code})`,
|
|
37
|
+
);
|
|
38
|
+
for (const stale of report.stale_suppressions) {
|
|
39
|
+
lines.push(
|
|
40
|
+
`${stale.position.path}:${String(stale.position.line)}:${String(stale.position.column)}: ${STALE_KIND} ${stale.symbol}: ${stale.message} [${STALE_CONFIDENCE}] (${stale.code})`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
lines.push(summary(report.totals));
|
|
44
|
+
return `${lines.join("\n")}\n`;
|
|
45
|
+
};
|
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;
|