@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
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
/** The version of the report schema this analyzer writes a report to. */
|
|
7
|
+
export const SCHEMA_VERSION = "6.0.0";
|
|
8
|
+
/** The name this analyzer writes every document under and mints every identifier with. */
|
|
9
|
+
export const ANALYZER_NAME = "deadset-ts";
|
|
10
|
+
/** The language this analyzer claims. */
|
|
11
|
+
export const LANGUAGE = "ts";
|
|
12
|
+
/** Two strings ordered bytewise. */
|
|
13
|
+
function compare(a, b) {
|
|
14
|
+
if (a === b) {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
return a < b ? -1 : 1;
|
|
18
|
+
}
|
|
19
|
+
function wirePosition(position) {
|
|
20
|
+
return {
|
|
21
|
+
path: position.path,
|
|
22
|
+
line: position.line,
|
|
23
|
+
column: position.column,
|
|
24
|
+
end_line: position.endLine,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function wireSymbol(symbol) {
|
|
28
|
+
return { ref: symbol.ref, name: symbol.name, position: wirePosition(symbol.position) };
|
|
29
|
+
}
|
|
30
|
+
/** The details one code carries, in the order the finding schema lists them. */
|
|
31
|
+
function wireDetails(details) {
|
|
32
|
+
const { entry } = details;
|
|
33
|
+
return {
|
|
34
|
+
...(details.narrowerVisibility === undefined
|
|
35
|
+
? {}
|
|
36
|
+
: { narrower_visibility: details.narrowerVisibility }),
|
|
37
|
+
...(details.implementations === undefined
|
|
38
|
+
? {}
|
|
39
|
+
: { implementations: details.implementations.map(wireSymbol) }),
|
|
40
|
+
...(details.writePositions === undefined
|
|
41
|
+
? {}
|
|
42
|
+
: { write_positions: details.writePositions.map(wirePosition) }),
|
|
43
|
+
...(details.dependencyClass === undefined ? {} : { dependency_class: details.dependencyClass }),
|
|
44
|
+
...(details.mechanism === undefined ? {} : { mechanism: details.mechanism }),
|
|
45
|
+
...(entry === undefined
|
|
46
|
+
? {}
|
|
47
|
+
: {
|
|
48
|
+
entry: {
|
|
49
|
+
code: entry.code,
|
|
50
|
+
...(entry.symbol === undefined ? {} : { symbol: entry.symbol }),
|
|
51
|
+
...(entry.path === undefined ? {} : { path: entry.path }),
|
|
52
|
+
...(entry.reason === undefined ? {} : { reason: entry.reason }),
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** One finding as the finding schema spells it, member for member in its order. */
|
|
58
|
+
export function wireFinding(finding) {
|
|
59
|
+
return {
|
|
60
|
+
code: finding.code,
|
|
61
|
+
kind: finding.kind,
|
|
62
|
+
language: finding.language,
|
|
63
|
+
position: wirePosition(finding.position),
|
|
64
|
+
symbol: {
|
|
65
|
+
ref: finding.symbol.ref,
|
|
66
|
+
kind: finding.symbol.kind,
|
|
67
|
+
name: finding.symbol.name,
|
|
68
|
+
size_lines: finding.symbol.sizeLines,
|
|
69
|
+
},
|
|
70
|
+
reachability_class: finding.reachabilityClass,
|
|
71
|
+
confidence: finding.confidence,
|
|
72
|
+
...(finding.livenessRelation === undefined
|
|
73
|
+
? {}
|
|
74
|
+
: { liveness_relation: finding.livenessRelation }),
|
|
75
|
+
test_only: finding.testOnly,
|
|
76
|
+
generated: finding.generated,
|
|
77
|
+
component: {
|
|
78
|
+
id: finding.component.id,
|
|
79
|
+
root: finding.component.root,
|
|
80
|
+
symbol_count: finding.component.symbolCount,
|
|
81
|
+
deletable_lines: finding.component.deletableLines,
|
|
82
|
+
},
|
|
83
|
+
retained_by: finding.retainedBy,
|
|
84
|
+
configurations: finding.configurations,
|
|
85
|
+
consumers_loaded: finding.consumersLoaded,
|
|
86
|
+
fixability: finding.fixability,
|
|
87
|
+
severity: finding.severity,
|
|
88
|
+
message: finding.message,
|
|
89
|
+
details: wireDetails(finding.details),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function wireEvaluation(evaluation) {
|
|
93
|
+
return {
|
|
94
|
+
edge: evaluation.edge,
|
|
95
|
+
side: evaluation.side,
|
|
96
|
+
symbol: evaluation.symbol,
|
|
97
|
+
state: evaluation.state,
|
|
98
|
+
...(evaluation.finding === undefined ? {} : { finding: wireFinding(evaluation.finding) }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function wireStale(stale) {
|
|
102
|
+
return {
|
|
103
|
+
code: stale.code,
|
|
104
|
+
mechanism: stale.mechanism,
|
|
105
|
+
entry: {
|
|
106
|
+
code: stale.entry.code,
|
|
107
|
+
...(stale.entry.symbol === undefined ? {} : { symbol: stale.entry.symbol }),
|
|
108
|
+
path: stale.entry.path,
|
|
109
|
+
reason: stale.entry.reason,
|
|
110
|
+
},
|
|
111
|
+
position: {
|
|
112
|
+
path: stale.position.path,
|
|
113
|
+
line: stale.position.line,
|
|
114
|
+
column: stale.position.column,
|
|
115
|
+
},
|
|
116
|
+
symbol: stale.symbol,
|
|
117
|
+
message: stale.message,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** The canonical key over two findings: path, line, column, code, then the symbol reference. */
|
|
121
|
+
function canonical(a, b) {
|
|
122
|
+
return (compare(a.position.path, b.position.path) ||
|
|
123
|
+
a.position.line - b.position.line ||
|
|
124
|
+
a.position.column - b.position.column ||
|
|
125
|
+
compare(a.code, b.code) ||
|
|
126
|
+
compare(a.symbol.ref, b.symbol.ref));
|
|
127
|
+
}
|
|
128
|
+
/** The canonical key over two stale suppressions, each component from the record's own member. */
|
|
129
|
+
function canonicalStale(a, b) {
|
|
130
|
+
return (compare(a.position.path, b.position.path) ||
|
|
131
|
+
a.position.line - b.position.line ||
|
|
132
|
+
a.position.column - b.position.column ||
|
|
133
|
+
compare(a.code, b.code) ||
|
|
134
|
+
compare(a.symbol, b.symbol));
|
|
135
|
+
}
|
|
136
|
+
/** Edge evaluations by edge, then side, then their compact encodings. */
|
|
137
|
+
function byEdge(a, b) {
|
|
138
|
+
return (compare(a.edge, b.edge) ||
|
|
139
|
+
compare(a.side, b.side) ||
|
|
140
|
+
compare(JSON.stringify(a), JSON.stringify(b)));
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The lines a reader deletes by acting on every finding: each distinct component a root
|
|
144
|
+
* finding names, counted once however many of its roots the findings hold.
|
|
145
|
+
*/
|
|
146
|
+
function deletableLines(findings) {
|
|
147
|
+
const counted = new Map();
|
|
148
|
+
for (const finding of findings) {
|
|
149
|
+
if (finding.component.root) {
|
|
150
|
+
counted.set(finding.component.id, finding.component.deletable_lines);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
let total = 0;
|
|
154
|
+
for (const lines of counted.values()) {
|
|
155
|
+
total += lines;
|
|
156
|
+
}
|
|
157
|
+
return total;
|
|
158
|
+
}
|
|
159
|
+
/** The totals of one finding set, counted over the whole set. */
|
|
160
|
+
function totalsOf(findings, result) {
|
|
161
|
+
const bySeverity = { allow: 0, warn: 0, deny: 0 };
|
|
162
|
+
for (const finding of findings) {
|
|
163
|
+
bySeverity[finding.severity] += 1;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
findings: findings.length,
|
|
167
|
+
by_severity: bySeverity,
|
|
168
|
+
deletable_lines: deletableLines(findings),
|
|
169
|
+
suppressions_in_effect: result.totals.suppressionsInEffect,
|
|
170
|
+
reasons_recorded: result.totals.reasonsRecorded,
|
|
171
|
+
stale_suppressions: result.staleSuppressions.length,
|
|
172
|
+
pending: result.totals.pending,
|
|
173
|
+
omitted: 0,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** Builds the report of one run, every array in the order the Contract fixes for it. */
|
|
177
|
+
export function buildReport(input) {
|
|
178
|
+
const findings = input.result.findings.map(wireFinding).sort(canonical);
|
|
179
|
+
const unavailable = [...new Map(input.unavailable.map((one) => [one.id, one])).values()]
|
|
180
|
+
.sort((a, b) => compare(a.id, b.id))
|
|
181
|
+
.map((one) => ({ id: one.id, role: "consumer", reason: one.reason }));
|
|
182
|
+
return {
|
|
183
|
+
schema_version: SCHEMA_VERSION,
|
|
184
|
+
contract_version: input.contractVersion,
|
|
185
|
+
analyzer: {
|
|
186
|
+
name: ANALYZER_NAME,
|
|
187
|
+
version: input.version,
|
|
188
|
+
languages: [LANGUAGE],
|
|
189
|
+
schema_versions_accepted: [SCHEMA_VERSION],
|
|
190
|
+
conformance: {
|
|
191
|
+
corpus_version: input.conformance.corpusVersion,
|
|
192
|
+
result: input.conformance.result,
|
|
193
|
+
digest: input.conformance.digest,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
target: { kind: input.target.kind, root: input.target.root, identity: input.target.identity },
|
|
197
|
+
configurations: [...input.configurations]
|
|
198
|
+
.sort((a, b) => compare(a.id, b.id))
|
|
199
|
+
.map((one) => ({ id: one.id, project: one.project })),
|
|
200
|
+
configurations_not_built: [],
|
|
201
|
+
consumers: { declared: unavailable.length, loaded: [], unavailable },
|
|
202
|
+
findings,
|
|
203
|
+
edge_evaluations: input.result.edgeEvaluations.map(wireEvaluation).sort(byEdge),
|
|
204
|
+
stale_suppressions: input.result.staleSuppressions.map(wireStale).sort(canonicalStale),
|
|
205
|
+
declared_gaps: [],
|
|
206
|
+
excluded_by_cgo: [],
|
|
207
|
+
test_file_rules: [...input.testFileRules]
|
|
208
|
+
.sort((a, b) => compare(a.rule, b.rule) || a.matched - b.matched)
|
|
209
|
+
.map((one) => ({ rule: one.rule, matched: one.matched })),
|
|
210
|
+
totals: totalsOf(findings, input.result),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The report with its findings in the order a configuration asks for. By size, a finding
|
|
215
|
+
* whose component a deletion shrinks more comes first, then one whose subject is larger,
|
|
216
|
+
* then the canonical key, so the order stays total; by position it is the canonical key.
|
|
217
|
+
*/
|
|
218
|
+
export function sortFindings(report, by) {
|
|
219
|
+
const bySize = (a, b) => b.component.deletable_lines - a.component.deletable_lines ||
|
|
220
|
+
b.symbol.size_lines - a.symbol.size_lines ||
|
|
221
|
+
canonical(a, b);
|
|
222
|
+
return { ...report, findings: [...report.findings].sort(by === "size" ? bySize : canonical) };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The report holding the first `most` findings of its order, the rest counted as omitted.
|
|
226
|
+
* Every other total keeps describing the whole set. Zero keeps every finding.
|
|
227
|
+
*/
|
|
228
|
+
export function capFindings(report, most) {
|
|
229
|
+
if (most <= 0 || report.findings.length <= most) {
|
|
230
|
+
return report;
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
...report,
|
|
234
|
+
findings: report.findings.slice(0, most),
|
|
235
|
+
totals: { ...report.totals, omitted: report.findings.length - most },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The indentation of the report document, which with the schema's member order fixes its bytes. */
|
|
2
|
+
const INDENT = 2;
|
|
3
|
+
/** The report document itself: the report, indented, with a closing newline. */
|
|
4
|
+
export const json = (report) => `${JSON.stringify(report, null, INDENT)}\n`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { json } from "./json.js";
|
|
2
|
+
import { text } from "./text.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every format this analyzer renders, each by its reporter and its file suffix. A format
|
|
5
|
+
* absent from the table is refused rather than silently not written.
|
|
6
|
+
*/
|
|
7
|
+
export const RENDERINGS = new Map([
|
|
8
|
+
["text", { render: text, suffix: ".txt" }],
|
|
9
|
+
["json", { render: json, suffix: ".json" }],
|
|
10
|
+
]);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The kind token and the confidence a stale suppression is rendered with, both fixed. */
|
|
2
|
+
const STALE_KIND = "suppression";
|
|
3
|
+
const STALE_CONFIDENCE = "certain";
|
|
4
|
+
/** A count with the word for it, so the summary reads as a sentence. */
|
|
5
|
+
function counted(n, one, many) {
|
|
6
|
+
return `${String(n)} ${n === 1 ? one : many}`;
|
|
7
|
+
}
|
|
8
|
+
/** The report's totals in one line, in the order the report declares them. */
|
|
9
|
+
function summary(totals) {
|
|
10
|
+
const { allow, warn, deny } = totals.by_severity;
|
|
11
|
+
return [
|
|
12
|
+
`summary: ${counted(totals.findings, "finding", "findings")} (${String(allow)} allow, ${String(warn)} warn, ${String(deny)} deny)`,
|
|
13
|
+
counted(totals.deletable_lines, "deletable line", "deletable lines"),
|
|
14
|
+
counted(totals.suppressions_in_effect, "suppression in effect", "suppressions in effect"),
|
|
15
|
+
counted(totals.reasons_recorded, "reason recorded", "reasons recorded"),
|
|
16
|
+
counted(totals.stale_suppressions, "stale suppression", "stale suppressions"),
|
|
17
|
+
`${String(totals.pending)} pending`,
|
|
18
|
+
`${String(totals.omitted)} omitted`,
|
|
19
|
+
].join(", ");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* One line per finding in the report's order, then one per stale suppression, then the
|
|
23
|
+
* summary. A finding line is `path:line:col: kind name: message [confidence] (CODE)`; the
|
|
24
|
+
* summary is deliberately not in that shape, so a filter for finding lines yields exactly
|
|
25
|
+
* them. Nothing else is written: no timestamp, duration or host detail.
|
|
26
|
+
*/
|
|
27
|
+
export const text = (report) => {
|
|
28
|
+
const lines = report.findings.map((found) => `${found.position.path}:${String(found.position.line)}:${String(found.position.column)}: ${found.symbol.kind} ${found.symbol.name}: ${found.message} [${found.confidence}] (${found.code})`);
|
|
29
|
+
for (const stale of report.stale_suppressions) {
|
|
30
|
+
lines.push(`${stale.position.path}:${String(stale.position.line)}:${String(stale.position.column)}: ${STALE_KIND} ${stale.symbol}: ${stale.message} [${STALE_CONFIDENCE}] (${stale.code})`);
|
|
31
|
+
}
|
|
32
|
+
lines.push(summary(report.totals));
|
|
33
|
+
return `${lines.join("\n")}\n`;
|
|
34
|
+
};
|
package/dist/src/roots.js
CHANGED
|
@@ -9,18 +9,11 @@
|
|
|
9
9
|
* candidate under reference counting.
|
|
10
10
|
*/
|
|
11
11
|
import { aliasChains } from "./alias-chain.js";
|
|
12
|
-
import { REPOSITORY_DOCUMENT } from "./config.js";
|
|
13
12
|
import { entryPoints } from "./entry-points.js";
|
|
14
13
|
import { globExpression } from "./glob.js";
|
|
15
14
|
import { nodeKey } from "./inventory.js";
|
|
16
15
|
import { byPosition } from "./position.js";
|
|
17
16
|
import { sourceFilesOf } from "./source-files.js";
|
|
18
|
-
/** The code the Contract gives a configured root that matches nothing. */
|
|
19
|
-
const UNMATCHED_ROOT_CODE = "DS1704";
|
|
20
|
-
/** The subject kind a finding under that code carries, from the Contract's vocabulary. */
|
|
21
|
-
const UNMATCHED_ROOT_SUBJECT = "root";
|
|
22
|
-
/** The fixed position of a finding about a document rather than about a line of one. */
|
|
23
|
-
const DOCUMENT_POSITION = 1;
|
|
24
17
|
/** Whether a configured string carries either special character. */
|
|
25
18
|
function isPattern(text) {
|
|
26
19
|
return text.includes("*") || text.includes("?");
|
|
@@ -293,30 +286,3 @@ function configured(held, patterns, add) {
|
|
|
293
286
|
export function unmatchedEverywhere(patterns, perProject) {
|
|
294
287
|
return [...new Set(patterns)].filter((pattern) => perProject.every((project) => project.unmatched.includes(pattern)));
|
|
295
288
|
}
|
|
296
|
-
/**
|
|
297
|
-
* The findings a run reports for the configured strings that named nothing.
|
|
298
|
-
*
|
|
299
|
-
* `document` is the path of the document that declared the roots, below the target
|
|
300
|
-
* root; where the run read them from no document of the target it is empty and the
|
|
301
|
-
* finding names the conventional repository configuration, which is the document a
|
|
302
|
-
* maintainer writes a configured root in. Every finding of one run sits at that
|
|
303
|
-
* document's first position: the roots are written as an array whose members carry
|
|
304
|
-
* no line of their own, so the string each finding names is what tells one from
|
|
305
|
-
* another.
|
|
306
|
-
*/
|
|
307
|
-
export function unmatchedRoots(sources, document) {
|
|
308
|
-
const position = {
|
|
309
|
-
path: document === "" ? REPOSITORY_DOCUMENT : document,
|
|
310
|
-
line: DOCUMENT_POSITION,
|
|
311
|
-
column: DOCUMENT_POSITION,
|
|
312
|
-
endLine: DOCUMENT_POSITION,
|
|
313
|
-
};
|
|
314
|
-
return sources.map((source) => ({
|
|
315
|
-
code: UNMATCHED_ROOT_CODE,
|
|
316
|
-
position,
|
|
317
|
-
symbol: { ref: source, kind: UNMATCHED_ROOT_SUBJECT, name: source, sizeLines: 1 },
|
|
318
|
-
message: isPattern(source)
|
|
319
|
-
? "configured root pattern matches no symbol of the inventory"
|
|
320
|
-
: "configured root matches no symbol of the inventory",
|
|
321
|
-
}));
|
|
322
|
-
}
|
package/dist/src/run.js
CHANGED
|
@@ -8,6 +8,8 @@ import { printConfigVerb } from "./verbs/print-config.js";
|
|
|
8
8
|
import { printProjectsVerb } from "./verbs/print-projects.js";
|
|
9
9
|
import { printRetainedVerb } from "./verbs/print-retained.js";
|
|
10
10
|
import { printRootsVerb } from "./verbs/print-roots.js";
|
|
11
|
+
import { ANALYZE_OPTIONS, analyzeVerb } from "./verbs/analyze.js";
|
|
12
|
+
import { describeVerb } from "./verbs/describe.js";
|
|
11
13
|
import { EXIT_FAILURE, EXIT_USAGE } from "./verbs/verb.js";
|
|
12
14
|
import { versionVerb } from "./verbs/version.js";
|
|
13
15
|
/**
|
|
@@ -15,13 +17,13 @@ import { versionVerb } from "./verbs/version.js";
|
|
|
15
17
|
* with no `run` is listed, and invoking it is a usage error.
|
|
16
18
|
*/
|
|
17
19
|
const VERBS = [
|
|
18
|
-
{ name: "analyze" },
|
|
20
|
+
{ name: "analyze", run: analyzeVerb, options: ANALYZE_OPTIONS },
|
|
19
21
|
{ name: "explain" },
|
|
20
22
|
{ name: "print-config", run: printConfigVerb },
|
|
21
23
|
{ name: "print-projects", run: printProjectsVerb },
|
|
22
24
|
{ name: "print-roots", run: printRootsVerb },
|
|
23
25
|
{ name: "print-retained", run: printRetainedVerb },
|
|
24
|
-
{ name: "describe" },
|
|
26
|
+
{ name: "describe", run: describeVerb },
|
|
25
27
|
{ name: "version", run: versionVerb },
|
|
26
28
|
];
|
|
27
29
|
const USAGE = `usage: deadset-ts <verb> [options]\nverbs: ${VERBS.map((verb) => verb.name).join(", ")}\n`;
|
|
@@ -55,6 +57,8 @@ const PLAIN_OPTIONS = new Set(["target", "config", "central", "scope"]);
|
|
|
55
57
|
const INTEGER_OPTIONS = new Set(["max-findings"]);
|
|
56
58
|
/** An integer as an option writes it: an optional minus sign and digits alone. */
|
|
57
59
|
const INTEGER_TEXT = /^-?[0-9]+$/u;
|
|
60
|
+
/** No option beyond the ones every verb takes. */
|
|
61
|
+
const NO_OPTIONS = { plain: [], repeatable: [] };
|
|
58
62
|
/**
|
|
59
63
|
* The first option whose name carries a source-edit token, because every verb
|
|
60
64
|
* reports and never edits. The match is on the name alone, so an option whose
|
|
@@ -73,24 +77,30 @@ function sourceEditOption(args) {
|
|
|
73
77
|
}
|
|
74
78
|
return undefined;
|
|
75
79
|
}
|
|
76
|
-
/** Reads one verb's options, refusing a name this command
|
|
77
|
-
function readOptions(args) {
|
|
80
|
+
/** Reads one verb's options, refusing a name neither this command nor the verb offers. */
|
|
81
|
+
function readOptions(args, own) {
|
|
78
82
|
const named = new Map();
|
|
83
|
+
const repeated = new Map();
|
|
79
84
|
for (const arg of args) {
|
|
80
85
|
if (!arg.startsWith("-")) {
|
|
81
86
|
throw new ConfigError("malformed", "", `unexpected argument ${JSON.stringify(arg)}`);
|
|
82
87
|
}
|
|
83
88
|
const [spelled, ...rest] = arg.split("=");
|
|
84
89
|
const name = (spelled ?? "").replace(/^-+/u, "");
|
|
85
|
-
if (!PLAIN_OPTIONS.has(name) &&
|
|
90
|
+
if (!PLAIN_OPTIONS.has(name) &&
|
|
91
|
+
!SETTING_OPTIONS.has(name) &&
|
|
92
|
+
!own.plain.includes(name) &&
|
|
93
|
+
!own.repeatable.includes(name)) {
|
|
86
94
|
throw new ConfigError("malformed", "", `unknown option ${JSON.stringify(spelled ?? arg)}`);
|
|
87
95
|
}
|
|
88
96
|
if (rest.length === 0) {
|
|
89
97
|
throw new ConfigError("malformed", "", `option ${JSON.stringify(spelled ?? arg)} takes a value`);
|
|
90
98
|
}
|
|
91
|
-
|
|
99
|
+
const value = rest.join("=");
|
|
100
|
+
named.set(name, value);
|
|
101
|
+
repeated.set(name, [...(repeated.get(name) ?? []), value]);
|
|
92
102
|
}
|
|
93
|
-
return { named };
|
|
103
|
+
return { named, repeated };
|
|
94
104
|
}
|
|
95
105
|
function readFileOr(host, path, orUndefined) {
|
|
96
106
|
try {
|
|
@@ -160,7 +170,7 @@ export function run(args, out, err, host, openClient = (collectTiming) => openEn
|
|
|
160
170
|
const name = args[0];
|
|
161
171
|
const verb = VERBS.find((listed) => listed.name === name);
|
|
162
172
|
if (verb?.run !== undefined) {
|
|
163
|
-
return invoke(verb.run, args.slice(1), { out, err, host }, openClient);
|
|
173
|
+
return invoke(verb.run, verb.options ?? NO_OPTIONS, args.slice(1), { out, err, host }, openClient);
|
|
164
174
|
}
|
|
165
175
|
if (verb !== undefined) {
|
|
166
176
|
err.write(`deadset-ts: ${verb.name} is not implemented\n`);
|
|
@@ -177,15 +187,18 @@ export function run(args, out, err, host, openClient = (collectTiming) => openEn
|
|
|
177
187
|
* time the verb asks for what they name, and a refusal the verb throws is written
|
|
178
188
|
* and turned into its exit code once the client the verb opened is closed.
|
|
179
189
|
*/
|
|
180
|
-
function invoke(verb, args, streams, openClient) {
|
|
190
|
+
function invoke(verb, own, args, streams, openClient) {
|
|
181
191
|
let options;
|
|
182
|
-
const read = () => (options ??= readOptions(args));
|
|
192
|
+
const read = () => (options ??= readOptions(args, own));
|
|
183
193
|
let opened;
|
|
184
194
|
try {
|
|
185
195
|
return verb({
|
|
186
196
|
...streams,
|
|
197
|
+
args,
|
|
187
198
|
inputs: () => inputsOf(streams.host, read()),
|
|
188
199
|
scope: () => scopeOf(streams.host, read()),
|
|
200
|
+
option: (name) => read().named.get(name),
|
|
201
|
+
repeated: (name) => read().repeated.get(name) ?? [],
|
|
189
202
|
openClient: (collectTiming) => {
|
|
190
203
|
opened = openClient(collectTiming);
|
|
191
204
|
return opened;
|
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
import { joinPath } from "./paths.js";
|
|
8
|
+
import { positionAt, walkDocument } from "./json-document.js";
|
|
9
|
+
import { CODE_FORM, SUPPRESSION_WITHOUT_REASON, SuppressionError, UNSCOPED_ENTRY, blankReason, isEntryPath, isSymbolRef, siteText, } from "./suppress.js";
|
|
10
|
+
/** The name and the location the grammar fixes for the ignore file. */
|
|
11
|
+
export const IGNORE_FILE = "deadset-ignore.json";
|
|
12
|
+
/** The name and the location the grammar fixes for the baseline. */
|
|
13
|
+
export const BASELINE_FILE = "deadset-baseline.json";
|
|
14
|
+
const IGNORE_SHAPE = {
|
|
15
|
+
file: IGNORE_FILE,
|
|
16
|
+
array: "ignore",
|
|
17
|
+
mechanism: "ignore",
|
|
18
|
+
documentWant: "an object carrying the ignore array, and an optional description",
|
|
19
|
+
recordWant: "an entry naming code, symbol, path and reason, each a string",
|
|
20
|
+
};
|
|
21
|
+
const BASELINE_SHAPE = {
|
|
22
|
+
file: BASELINE_FILE,
|
|
23
|
+
array: "baseline",
|
|
24
|
+
mechanism: "baseline",
|
|
25
|
+
documentWant: "an object carrying the baseline array, and an optional description",
|
|
26
|
+
recordWant: "a row naming code, symbol, path and reason, each a string",
|
|
27
|
+
};
|
|
28
|
+
/** The closed key list of one entry or row. */
|
|
29
|
+
const RECORD_KEYS = new Set(["code", "symbol", "path", "reason"]);
|
|
30
|
+
const CODE_WANT = "DS followed by four digits";
|
|
31
|
+
const SYMBOL_WANT = "a stable symbol reference, go://<import-path>#<fragment> or ts://<package>/<source-path>#<fragment>";
|
|
32
|
+
const PATH_WANT = "a path relative to the target root, with the solidus as separator";
|
|
33
|
+
/** The document itself, for a defect no record position describes. */
|
|
34
|
+
function documentSite(shape) {
|
|
35
|
+
return { path: shape.file, line: 0, column: 0 };
|
|
36
|
+
}
|
|
37
|
+
function isObject(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function referenceKey(ref, path) {
|
|
41
|
+
return `${ref}\u0000${path}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Reads one entry or row: the records it binds, the refusals it carries, or the
|
|
45
|
+
* malformed value that ends the run.
|
|
46
|
+
*/
|
|
47
|
+
function readRecord(value, site, byReference, shape) {
|
|
48
|
+
const refuse = (text, want) => {
|
|
49
|
+
throw new SuppressionError(shape.mechanism, `${shape.file} ${siteText(site)}`, text, want);
|
|
50
|
+
};
|
|
51
|
+
if (!isObject(value)) {
|
|
52
|
+
return refuse(JSON.stringify(value), shape.recordWant);
|
|
53
|
+
}
|
|
54
|
+
for (const [key, held] of Object.entries(value)) {
|
|
55
|
+
if (!RECORD_KEYS.has(key)) {
|
|
56
|
+
refuse(`the member ${JSON.stringify(key)} is not one the grammar declares`, shape.recordWant);
|
|
57
|
+
}
|
|
58
|
+
if (typeof held !== "string") {
|
|
59
|
+
refuse(`the member ${JSON.stringify(key)} holds ${JSON.stringify(held)}`, shape.recordWant);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const entry = value;
|
|
63
|
+
const { code, symbol, path, reason } = {
|
|
64
|
+
code: entry["code"],
|
|
65
|
+
symbol: entry["symbol"],
|
|
66
|
+
path: entry["path"],
|
|
67
|
+
reason: entry["reason"],
|
|
68
|
+
};
|
|
69
|
+
if (code === undefined) {
|
|
70
|
+
return refuse("a record naming no code", shape.recordWant);
|
|
71
|
+
}
|
|
72
|
+
if (symbol === undefined) {
|
|
73
|
+
return refuse("a record naming no symbol", shape.recordWant);
|
|
74
|
+
}
|
|
75
|
+
if (!CODE_FORM.test(code)) {
|
|
76
|
+
return refuse(JSON.stringify(code), CODE_WANT);
|
|
77
|
+
}
|
|
78
|
+
if (!isSymbolRef(symbol)) {
|
|
79
|
+
return refuse(JSON.stringify(symbol), SYMBOL_WANT);
|
|
80
|
+
}
|
|
81
|
+
if (path !== undefined && path !== "" && !isEntryPath(path)) {
|
|
82
|
+
return refuse(JSON.stringify(path), PATH_WANT);
|
|
83
|
+
}
|
|
84
|
+
const written = {
|
|
85
|
+
code,
|
|
86
|
+
symbol,
|
|
87
|
+
path: path ?? "",
|
|
88
|
+
reason: reason ?? "",
|
|
89
|
+
site,
|
|
90
|
+
mechanism: shape.mechanism,
|
|
91
|
+
};
|
|
92
|
+
const refusals = [];
|
|
93
|
+
if (blankReason(reason)) {
|
|
94
|
+
refusals.push({ ...written, reported: SUPPRESSION_WITHOUT_REASON });
|
|
95
|
+
}
|
|
96
|
+
if (written.path === "") {
|
|
97
|
+
refusals.push({ ...written, reported: UNSCOPED_ENTRY });
|
|
98
|
+
}
|
|
99
|
+
if (refusals.length > 0) {
|
|
100
|
+
return { records: [], refusals };
|
|
101
|
+
}
|
|
102
|
+
const named = byReference.get(referenceKey(symbol, written.path)) ?? [];
|
|
103
|
+
if (named.length === 0) {
|
|
104
|
+
return { records: [{ ...written, bound: "" }], refusals: [] };
|
|
105
|
+
}
|
|
106
|
+
return { records: named.map((one) => ({ ...written, bound: one.id })), refusals: [] };
|
|
107
|
+
}
|
|
108
|
+
/** Reads one document at the target root, binding each record against the run's declarations. */
|
|
109
|
+
function readDocument(host, targetRoot, symbols, shape) {
|
|
110
|
+
const file = joinPath(targetRoot, shape.file);
|
|
111
|
+
if (host.kindOf(file) === "absent") {
|
|
112
|
+
return { records: [], refusals: [] };
|
|
113
|
+
}
|
|
114
|
+
const text = host.readFile(file);
|
|
115
|
+
const malformed = (detail, site = documentSite(shape)) => {
|
|
116
|
+
throw new SuppressionError(shape.mechanism, siteText(site), detail, shape.documentWant);
|
|
117
|
+
};
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(text);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return malformed(`${shape.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
if (!isObject(parsed)) {
|
|
126
|
+
return malformed(shape.file);
|
|
127
|
+
}
|
|
128
|
+
const walk = walkDocument(text, shape.array);
|
|
129
|
+
if (walk.twice !== undefined) {
|
|
130
|
+
malformed(`the member ${JSON.stringify(walk.twice.name)} is written twice, so neither value is chosen`, positionAt(text, shape.file, walk.twice.offset));
|
|
131
|
+
}
|
|
132
|
+
for (const key of Object.keys(parsed)) {
|
|
133
|
+
if (key !== "description" && key !== shape.array) {
|
|
134
|
+
malformed(`the member ${JSON.stringify(key)} is not one the grammar declares`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const description = parsed["description"];
|
|
138
|
+
const held = parsed[shape.array];
|
|
139
|
+
if ((description !== undefined && typeof description !== "string") || !Array.isArray(held)) {
|
|
140
|
+
return malformed(shape.file);
|
|
141
|
+
}
|
|
142
|
+
const byReference = new Map();
|
|
143
|
+
for (const symbol of symbols) {
|
|
144
|
+
const key = referenceKey(symbol.ref, symbol.position.path);
|
|
145
|
+
byReference.set(key, [...(byReference.get(key) ?? []), symbol]);
|
|
146
|
+
}
|
|
147
|
+
const records = [];
|
|
148
|
+
const refusals = [];
|
|
149
|
+
held.forEach((value, index) => {
|
|
150
|
+
const offset = walk.opened[index];
|
|
151
|
+
const site = offset === undefined ? documentSite(shape) : positionAt(text, shape.file, offset);
|
|
152
|
+
const read = readRecord(value, site, byReference, shape);
|
|
153
|
+
records.push(...read.records);
|
|
154
|
+
refusals.push(...read.refusals);
|
|
155
|
+
});
|
|
156
|
+
return { records, refusals };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The ignore file at the target root, each entry bound to the declaration whose reference
|
|
160
|
+
* and file both equal the ones it names, and never by glob, pattern or substring. An
|
|
161
|
+
* absent file is an empty one.
|
|
162
|
+
*/
|
|
163
|
+
export function readIgnoreFile(host, targetRoot, symbols) {
|
|
164
|
+
return readDocument(host, targetRoot, symbols, IGNORE_SHAPE);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The baseline at the target root, each row matched exactly as an ignore entry is. A row's
|
|
168
|
+
* reason is the provenance an analyzer wrote, never a maintainer's judgement: the finding
|
|
169
|
+
* it records is real, and the row says only that it was there when the baseline was
|
|
170
|
+
* written. An absent file is an empty one.
|
|
171
|
+
*/
|
|
172
|
+
export function readBaseline(host, targetRoot, symbols) {
|
|
173
|
+
return readDocument(host, targetRoot, symbols, BASELINE_SHAPE);
|
|
174
|
+
}
|
|
175
|
+
/** What a written document says about itself, so the same report writes the same bytes. */
|
|
176
|
+
const BASELINE_DESCRIPTION = "Recorded findings: a run fails only on a finding this document does not hold. " +
|
|
177
|
+
"Every reason is the analyzer that recorded the row, never an adjudication.";
|
|
178
|
+
/**
|
|
179
|
+
* A baseline holding one row per finding, in the order given, which is the report's. Every
|
|
180
|
+
* row carries the provenance as its reason. A finding missing a value a row needs, and an
|
|
181
|
+
* identity missing its name or version, are refused rather than written, because the
|
|
182
|
+
* grammar requires a reason on every row.
|
|
183
|
+
*/
|
|
184
|
+
export function writeBaseline(findings, provenance) {
|
|
185
|
+
if (provenance.analyzer === "" || provenance.version === "") {
|
|
186
|
+
throw new Error(`a baseline row would carry no reason: the analyzer identity names ${JSON.stringify(provenance.analyzer)} at version ${JSON.stringify(provenance.version)}`);
|
|
187
|
+
}
|
|
188
|
+
const reason = `recorded by ${provenance.analyzer} ${provenance.version}`;
|
|
189
|
+
const baseline = findings.map((found) => {
|
|
190
|
+
if (found.code === "" || found.symbol === "" || found.path === "") {
|
|
191
|
+
throw new Error(`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)}`);
|
|
192
|
+
}
|
|
193
|
+
return { code: found.code, symbol: found.symbol, path: found.path, reason };
|
|
194
|
+
});
|
|
195
|
+
return `${JSON.stringify({ description: BASELINE_DESCRIPTION, baseline }, null, 2)}\n`;
|
|
196
|
+
}
|