@cplieger/deadset-ts 3.10.0 → 3.11.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/src/analysis.js +5 -3
- package/dist/src/config.js +6 -0
- package/dist/src/findings-pass.js +3 -5
- package/dist/src/reporters/annotations.js +36 -0
- package/dist/src/reporters/fingerprint.js +61 -0
- package/dist/src/reporters/reporter.js +7 -1
- package/dist/src/reporters/reporters.js +18 -0
- package/dist/src/reporters/rules.js +178 -0
- package/dist/src/reporters/sarif.js +235 -0
- package/dist/src/reporters/sha256.js +101 -0
- package/dist/src/reporters/template.js +877 -0
- package/dist/src/run.js +4 -10
- package/dist/src/verbs/analyze.js +42 -5
- package/dist/src/verbs/explain.js +303 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +14 -4
- package/src/config.ts +8 -0
- package/src/findings-pass.ts +5 -7
- package/src/reporters/annotations.ts +67 -0
- package/src/reporters/fingerprint.ts +69 -0
- package/src/reporters/json.ts +2 -2
- package/src/reporters/reporter.ts +21 -2
- package/src/reporters/reporters.ts +18 -1
- package/src/reporters/rules.ts +195 -0
- package/src/reporters/sarif.ts +287 -0
- package/src/reporters/sha256.ts +120 -0
- package/src/reporters/template.ts +989 -0
- package/src/reporters/text.ts +2 -3
- package/src/run.ts +5 -11
- package/src/verbs/analyze.ts +54 -5
- package/src/verbs/explain.ts +363 -0
package/dist/src/run.js
CHANGED
|
@@ -10,15 +10,13 @@ import { printRetainedVerb } from "./verbs/print-retained.js";
|
|
|
10
10
|
import { printRootsVerb } from "./verbs/print-roots.js";
|
|
11
11
|
import { ANALYZE_OPTIONS, analyzeVerb } from "./verbs/analyze.js";
|
|
12
12
|
import { describeVerb } from "./verbs/describe.js";
|
|
13
|
+
import { EXPLAIN_OPTIONS, explainVerb } from "./verbs/explain.js";
|
|
13
14
|
import { EXIT_FAILURE, EXIT_USAGE } from "./verbs/verb.js";
|
|
14
15
|
import { versionVerb } from "./verbs/version.js";
|
|
15
|
-
/**
|
|
16
|
-
* Every verb this command answers, in the order the usage text lists them. A verb
|
|
17
|
-
* with no `run` is listed, and invoking it is a usage error.
|
|
18
|
-
*/
|
|
16
|
+
/** Every verb this command answers, in the order the usage text lists them. */
|
|
19
17
|
const VERBS = [
|
|
20
18
|
{ name: "analyze", run: analyzeVerb, options: ANALYZE_OPTIONS },
|
|
21
|
-
{ name: "explain" },
|
|
19
|
+
{ name: "explain", run: explainVerb, options: EXPLAIN_OPTIONS },
|
|
22
20
|
{ name: "print-config", run: printConfigVerb },
|
|
23
21
|
{ name: "print-projects", run: printProjectsVerb },
|
|
24
22
|
{ name: "print-roots", run: printRootsVerb },
|
|
@@ -169,12 +167,8 @@ export function run(args, out, err, host, openClient = (collectTiming) => openEn
|
|
|
169
167
|
}
|
|
170
168
|
const name = args[0];
|
|
171
169
|
const verb = VERBS.find((listed) => listed.name === name);
|
|
172
|
-
if (verb?.run !== undefined) {
|
|
173
|
-
return invoke(verb.run, verb.options ?? NO_OPTIONS, args.slice(1), { out, err, host }, openClient);
|
|
174
|
-
}
|
|
175
170
|
if (verb !== undefined) {
|
|
176
|
-
|
|
177
|
-
return EXIT_USAGE;
|
|
171
|
+
return invoke(verb.run, verb.options ?? NO_OPTIONS, args.slice(1), { out, err, host }, openClient);
|
|
178
172
|
}
|
|
179
173
|
if (name !== undefined) {
|
|
180
174
|
err.write(`deadset-ts: unknown verb ${JSON.stringify(name)}\n`);
|
|
@@ -3,17 +3,18 @@ import { ConfigError } from "../config.js";
|
|
|
3
3
|
import { CONFORMANCE, DECLARED_GAPS } from "../conformance.js";
|
|
4
4
|
import { analyzerProvenance, recordedFindings, verdictOf } from "../findings-pass.js";
|
|
5
5
|
import { packageScope } from "../inventory.js";
|
|
6
|
-
import { normalizePath, relativePath, resolvePath } from "../paths.js";
|
|
6
|
+
import { joinPath, normalizePath, relativePath, resolvePath } from "../paths.js";
|
|
7
7
|
import { buildReport, capFindings, sortFindings, } from "../report.js";
|
|
8
8
|
import { json } from "../reporters/json.js";
|
|
9
9
|
import { RENDERINGS } from "../reporters/reporters.js";
|
|
10
|
+
import { parseTemplate, TemplateError } from "../reporters/template.js";
|
|
10
11
|
import { resolve } from "../resolve.js";
|
|
11
12
|
import { writeBaseline } from "../suppress-file.js";
|
|
12
13
|
import { CONTRACT_VERSION } from "../version.js";
|
|
13
14
|
import { EXIT_CLEAN, EXIT_FAILURE } from "./verb.js";
|
|
14
15
|
/** The options `analyze` takes beside the ones every verb takes. */
|
|
15
16
|
export const ANALYZE_OPTIONS = {
|
|
16
|
-
plain: ["report", "baseline-write", "exit-code"],
|
|
17
|
+
plain: ["report", "baseline-write", "exit-code", "template"],
|
|
17
18
|
repeatable: ["format"],
|
|
18
19
|
};
|
|
19
20
|
/** The two values `--exit-code` takes; on, the default, puts the run's verdict in the exit code. */
|
|
@@ -80,12 +81,42 @@ function identityOf(module, host) {
|
|
|
80
81
|
function counted(n, noun) {
|
|
81
82
|
return `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
|
|
82
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The template `--template` names, read and parsed before any analysis, so a template that
|
|
86
|
+
* cannot be read or does not parse refuses the invocation. The template format with no
|
|
87
|
+
* template named is refused the same way.
|
|
88
|
+
*/
|
|
89
|
+
function templateOf(host, invoked, named, formats) {
|
|
90
|
+
if (named === undefined || named === "") {
|
|
91
|
+
if (formats.includes("template")) {
|
|
92
|
+
throw new ConfigError("malformed", "", "the template format renders the template --template names, and none was named");
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
let source;
|
|
97
|
+
try {
|
|
98
|
+
source = host.readFile(resolvePath(invoked, named));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw new ConfigError("malformed", "", `--template=${named}: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return parseTemplate(source);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!(error instanceof TemplateError)) {
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
throw new ConfigError("malformed", "", `--template=${named}: ${error.message}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
83
113
|
/**
|
|
84
114
|
* Analyzes the scope under the configuration and writes the JSON report to the path
|
|
85
115
|
* `--report` names, each rendering `--format` names beside it at the report's path with the
|
|
86
116
|
* format's suffix appended, and a baseline of every finding where `--baseline-write` names a
|
|
87
|
-
* path.
|
|
88
|
-
* verdict is the exit code, or 0
|
|
117
|
+
* path. The template format renders the template `--template` names. Nothing is written to
|
|
118
|
+
* the output stream; diagnostics go to the error stream and the verdict is the exit code, or 0
|
|
119
|
+
* under `--exit-code=off`, and a rendering that cannot be written ends the run with 3.
|
|
89
120
|
*/
|
|
90
121
|
export const analyzeVerb = ({ err, host, inputs, scope, openClient, option, repeated }) => {
|
|
91
122
|
const reportPath = option("report");
|
|
@@ -108,6 +139,7 @@ export const analyzeVerb = ({ err, host, inputs, scope, openClient, option, repe
|
|
|
108
139
|
formats: formatsOf(repeated("format"), config.reporters.formats),
|
|
109
140
|
baseline: baseline === undefined ? undefined : resolvePath(invoked, baseline),
|
|
110
141
|
};
|
|
142
|
+
const template = templateOf(host, invoked, option("template"), documents.formats);
|
|
111
143
|
const scoped = scope();
|
|
112
144
|
const targetRoot = scoped.target.path;
|
|
113
145
|
let version;
|
|
@@ -155,10 +187,15 @@ export const analyzeVerb = ({ err, host, inputs, scope, openClient, option, repe
|
|
|
155
187
|
}
|
|
156
188
|
try {
|
|
157
189
|
host.writeFile(documents.report, json(report));
|
|
190
|
+
const options = {
|
|
191
|
+
failOn: config.reporters.failOn,
|
|
192
|
+
readSource: (path) => host.readFile(joinPath(targetRoot, path)),
|
|
193
|
+
template,
|
|
194
|
+
};
|
|
158
195
|
for (const format of documents.formats) {
|
|
159
196
|
const rendering = RENDERINGS.get(format);
|
|
160
197
|
if (rendering !== undefined) {
|
|
161
|
-
host.writeFile(documents.report + rendering.suffix, rendering.render(report));
|
|
198
|
+
host.writeFile(documents.report + rendering.suffix, rendering.render(report, options));
|
|
162
199
|
}
|
|
163
200
|
}
|
|
164
201
|
if (documents.baseline !== undefined) {
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { runAnalysis } from "../analysis.js";
|
|
2
|
+
import { listing } from "../components.js";
|
|
3
|
+
import { ConfigError } from "../config.js";
|
|
4
|
+
import { OUTSIDE, swept as judged } from "../graph.js";
|
|
5
|
+
import { positionKey } from "../position.js";
|
|
6
|
+
import { resolve } from "../resolve.js";
|
|
7
|
+
import { EXIT_CLEAN, EXIT_USAGE } from "./verb.js";
|
|
8
|
+
/**
|
|
9
|
+
* The three spellings of an explanation request, each naming the symbol to explain. They are
|
|
10
|
+
* one request, because exactly one state holds for a symbol and the answer is that state.
|
|
11
|
+
*/
|
|
12
|
+
const WHY_OPTIONS = ["why", "why-live", "why-not"];
|
|
13
|
+
/** The options `explain` takes beside the ones every verb takes. */
|
|
14
|
+
export const EXPLAIN_OPTIONS = { plain: [...WHY_OPTIONS], repeatable: [] };
|
|
15
|
+
/** The most partial matches a refused request prints. */
|
|
16
|
+
const MAX_PARTIAL_MATCHES = 20;
|
|
17
|
+
/** A count with its noun, so a line reads for one as well as for several. */
|
|
18
|
+
function counted(n, noun) {
|
|
19
|
+
return `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
|
|
20
|
+
}
|
|
21
|
+
/** A list for a line a maintainer reads, which says none rather than leaving the field blank. */
|
|
22
|
+
function listed(items) {
|
|
23
|
+
return items.length === 0 ? "none" : items.join(" ");
|
|
24
|
+
}
|
|
25
|
+
/** The one symbol a request names, from whichever of the three options names it. */
|
|
26
|
+
function namedSymbol(option) {
|
|
27
|
+
const named = WHY_OPTIONS.flatMap((name) => {
|
|
28
|
+
const value = option(name);
|
|
29
|
+
return value === undefined || value === "" ? [] : [{ name, value }];
|
|
30
|
+
});
|
|
31
|
+
const [only, ...more] = named;
|
|
32
|
+
if (only === undefined) {
|
|
33
|
+
throw new ConfigError("malformed", "", "explain explains the symbol --why, --why-live or --why-not names, and none was named");
|
|
34
|
+
}
|
|
35
|
+
if (more.length > 0) {
|
|
36
|
+
throw new ConfigError("malformed", "", `explain explains one symbol, and ${named.map((one) => `--${one.name}`).join(", ")} each name one`);
|
|
37
|
+
}
|
|
38
|
+
return only.value;
|
|
39
|
+
}
|
|
40
|
+
/** The part of a reference after its separator: the `Type.member` or bare name a maintainer types. */
|
|
41
|
+
function fragmentOf(symbol) {
|
|
42
|
+
const at = symbol.ref.indexOf("#");
|
|
43
|
+
return at < 0 ? "" : symbol.ref.slice(at + 1);
|
|
44
|
+
}
|
|
45
|
+
function described(symbol) {
|
|
46
|
+
return `${symbol.ref}\t${positionKey(symbol.position)}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The one declaration a request names, or the candidates where it names none or several.
|
|
50
|
+
* The reference, the fragment of it a maintainer types and the display name are tried in
|
|
51
|
+
* order, each an exact match; a key several declarations answer is no answer.
|
|
52
|
+
*/
|
|
53
|
+
function subjectOf(symbols, named) {
|
|
54
|
+
const keys = [
|
|
55
|
+
(symbol) => symbol.ref,
|
|
56
|
+
fragmentOf,
|
|
57
|
+
(symbol) => symbol.name,
|
|
58
|
+
];
|
|
59
|
+
for (const key of keys) {
|
|
60
|
+
const matched = symbols.filter((symbol) => key(symbol) === named);
|
|
61
|
+
const [only] = matched;
|
|
62
|
+
if (only !== undefined && matched.length === 1) {
|
|
63
|
+
return { subject: only, candidates: [] };
|
|
64
|
+
}
|
|
65
|
+
if (matched.length > 1) {
|
|
66
|
+
return { candidates: matched.map(described) };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const folded = named.toLowerCase();
|
|
70
|
+
const partial = symbols
|
|
71
|
+
.filter((symbol) => symbol.ref.toLowerCase().includes(folded) || symbol.name.toLowerCase().includes(folded))
|
|
72
|
+
.map(described)
|
|
73
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
74
|
+
return { candidates: partial.slice(0, MAX_PARTIAL_MATCHES) };
|
|
75
|
+
}
|
|
76
|
+
/** Every reference of the run once, a reference several configurations make counted once. */
|
|
77
|
+
function referencesOf(configured) {
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const held = [];
|
|
80
|
+
for (const reference of configured.flatMap((one) => one.references)) {
|
|
81
|
+
const key = [reference.from, reference.to, positionKey(reference.position), reference.use].join("\u0000");
|
|
82
|
+
if (!seen.has(key)) {
|
|
83
|
+
seen.add(key);
|
|
84
|
+
held.push(reference);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return held;
|
|
88
|
+
}
|
|
89
|
+
/** Every root of the run once per reason, in the order the projects name them. */
|
|
90
|
+
function rootsOf(configured) {
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
return configured
|
|
93
|
+
.flatMap((one) => one.roots)
|
|
94
|
+
.filter((root) => {
|
|
95
|
+
const key = [root.id, root.kind, root.source].join("\u0000");
|
|
96
|
+
if (seen.has(key)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
seen.add(key);
|
|
100
|
+
return true;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The shortest path from a root to one declaration, walked breadth first from every root at
|
|
105
|
+
* once in the order the run names them, following the references the sweep follows: a
|
|
106
|
+
* reference that reads a module reaches what the module exports, and an evaluation or a root
|
|
107
|
+
* reaches the module alone. Under `production` no reference a test file made is followed,
|
|
108
|
+
* which is the set the report's sweep counts.
|
|
109
|
+
*/
|
|
110
|
+
function shortestPath(analysis, to, production) {
|
|
111
|
+
const graph = analysis.swept.matrix.union;
|
|
112
|
+
const out = new Map();
|
|
113
|
+
for (const reference of referencesOf(analysis.configured)) {
|
|
114
|
+
if (production && reference.test) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const held = out.get(reference.from);
|
|
118
|
+
if (held === undefined) {
|
|
119
|
+
out.set(reference.from, [reference]);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
held.push(reference);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const steps = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const expanded = new Set();
|
|
128
|
+
const enter = (step) => {
|
|
129
|
+
if (!seen.has(step.at)) {
|
|
130
|
+
seen.add(step.at);
|
|
131
|
+
steps.push(step);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
for (const root of rootsOf(analysis.configured)) {
|
|
135
|
+
enter({ at: root.id, hop: undefined, previous: -1, root });
|
|
136
|
+
}
|
|
137
|
+
for (let head = 0; head < steps.length; head += 1) {
|
|
138
|
+
const step = steps[head];
|
|
139
|
+
if (step === undefined) {
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
if (step.at === to) {
|
|
143
|
+
const path = [];
|
|
144
|
+
for (let at = step; at?.hop !== undefined; at = steps[at.previous]) {
|
|
145
|
+
path.unshift(at.hop);
|
|
146
|
+
}
|
|
147
|
+
return { root: step.root, path };
|
|
148
|
+
}
|
|
149
|
+
for (const reference of out.get(step.at) ?? []) {
|
|
150
|
+
enter({
|
|
151
|
+
at: reference.to,
|
|
152
|
+
hop: { k: "reference", reference },
|
|
153
|
+
previous: head,
|
|
154
|
+
root: step.root,
|
|
155
|
+
});
|
|
156
|
+
const target = graph.at(reference.to);
|
|
157
|
+
if (reference.use === "evaluation" || target === OUTSIDE || expanded.has(reference.to)) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
expanded.add(reference.to);
|
|
161
|
+
for (const exported of graph.exportsOf[target] ?? []) {
|
|
162
|
+
const id = graph.symbols[exported]?.id;
|
|
163
|
+
if (id !== undefined) {
|
|
164
|
+
enter({
|
|
165
|
+
at: id,
|
|
166
|
+
hop: { k: "export", reference, to: id },
|
|
167
|
+
previous: head,
|
|
168
|
+
root: step.root,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
function referenceLine(reference, tail) {
|
|
177
|
+
return ` reference: ${positionKey(reference.position)}\t${reference.use}\t${tail}${reference.test ? "\tfrom a test file" : ""}\n`;
|
|
178
|
+
}
|
|
179
|
+
/** Why a declaration no finding names and no exemption held back is live, or how it fell. */
|
|
180
|
+
function liveness(analysis, subject, cascade) {
|
|
181
|
+
const { sweep } = analysis.swept;
|
|
182
|
+
const candidate = sweep.candidates.find((one) => one.id === subject.id);
|
|
183
|
+
if (candidate === undefined && !judged(subject.kind)) {
|
|
184
|
+
return `answer: unjudged\n unjudged: neither liveness relation judges a ${subject.kind}\n`;
|
|
185
|
+
}
|
|
186
|
+
let text = `answer: ${candidate === undefined ? "live" : "dead"}\n`;
|
|
187
|
+
text += ` live under: ${listed(sweep.liveUnder.get(subject.id) ?? [])}\n`;
|
|
188
|
+
for (const root of rootsOf(analysis.configured).filter((one) => one.id === subject.id)) {
|
|
189
|
+
text += ` root: ${root.kind}${root.source === "" ? "" : `\tnamed by ${root.source}`}\n`;
|
|
190
|
+
}
|
|
191
|
+
const found = shortestPath(analysis, subject.id, true) ?? shortestPath(analysis, subject.id, false);
|
|
192
|
+
if (found !== undefined && found.path.length > 0) {
|
|
193
|
+
text += ` reached from: root ${found.root.kind} ${found.root.id}\n`;
|
|
194
|
+
for (const hop of found.path) {
|
|
195
|
+
text += referenceLine(hop.reference, `${hop.reference.from} -> ${hop.reference.to}`);
|
|
196
|
+
if (hop.k === "export") {
|
|
197
|
+
text += ` export: ${hop.reference.to} -> ${hop.to}\n`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else if (found === undefined) {
|
|
202
|
+
const references = referencesOf(analysis.configured).filter((one) => one.to === subject.id);
|
|
203
|
+
text += " unreachable: no path of references reaches it from a root\n";
|
|
204
|
+
text += ` references: ${String(references.length)}\n`;
|
|
205
|
+
for (const reference of references) {
|
|
206
|
+
text += referenceLine(reference, `from ${reference.from}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (candidate !== undefined) {
|
|
210
|
+
const component = sweep.components.find((one) => one.members.includes(subject.id));
|
|
211
|
+
if (component !== undefined) {
|
|
212
|
+
const listed = listing(component, cascade);
|
|
213
|
+
text += ` component: ${component.id}, ${counted(listed.symbolCount, "symbol")} and ${counted(listed.deletableLines, "deletable line")} fall with it, listed at cascade ${cascade}\n`;
|
|
214
|
+
for (const root of listed.roots) {
|
|
215
|
+
text += ` component root: ${root}\n`;
|
|
216
|
+
}
|
|
217
|
+
for (const member of listed.members) {
|
|
218
|
+
text += ` component member: ${member}\n`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
text += ` candidate: dead under ${candidate.relation}, with ${String(candidate.productionRefs)} production and ${String(candidate.testRefs)} test references\n`;
|
|
222
|
+
}
|
|
223
|
+
return text;
|
|
224
|
+
}
|
|
225
|
+
function reported(found) {
|
|
226
|
+
return [
|
|
227
|
+
`answer: reported`,
|
|
228
|
+
` code: ${found.code} ${found.kind}`,
|
|
229
|
+
` message: ${found.message}`,
|
|
230
|
+
` class: ${found.reachabilityClass}`,
|
|
231
|
+
` confidence: ${found.confidence}`,
|
|
232
|
+
...(found.livenessRelation === undefined
|
|
233
|
+
? []
|
|
234
|
+
: [` liveness relation: ${found.livenessRelation}`]),
|
|
235
|
+
` configurations: ${listed(found.configurations)}`,
|
|
236
|
+
` loaded consumers: ${listed(found.consumersLoaded)}`,
|
|
237
|
+
...found.retainedBy.map((one) => ` retained by: ${one}`),
|
|
238
|
+
"",
|
|
239
|
+
].join("\n");
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Every finding of the run that names the declaration by its reference and reports at
|
|
243
|
+
* another position: a part of it, or a suppression record naming it. Each is published under
|
|
244
|
+
* the reference asked about, so an explanation leaving it out would disagree with the report.
|
|
245
|
+
*/
|
|
246
|
+
function namedElsewhere(findings, subject) {
|
|
247
|
+
return findings
|
|
248
|
+
.filter((found) => found.symbol.ref === subject.ref && positionKey(found.position) !== subject.id)
|
|
249
|
+
.map((found) => `also reported: ${found.code} ${found.kind}\t${positionKey(found.position)}\t${found.symbol.kind} ${found.symbol.name}\n`)
|
|
250
|
+
.join("");
|
|
251
|
+
}
|
|
252
|
+
/** The one explanation that applies to a declaration, built whole before it is written. */
|
|
253
|
+
function explanation(analysis, subject, cascade) {
|
|
254
|
+
const graph = analysis.swept.matrix.union;
|
|
255
|
+
const at = graph.at(subject.id);
|
|
256
|
+
let text = `symbol: ${subject.ref}\n`;
|
|
257
|
+
text += `declaration: ${subject.kind} ${subject.name}\n`;
|
|
258
|
+
text += `position: ${subject.id}\n`;
|
|
259
|
+
text += `configurations: ${listed(analysis.swept.matrix.heldIn[at] ?? [])}\n`;
|
|
260
|
+
const found = analysis.result.findings.find((one) => positionKey(one.position) === subject.id);
|
|
261
|
+
const retained = analysis.swept.retained.filter((one) => one.id === subject.id);
|
|
262
|
+
if (found !== undefined) {
|
|
263
|
+
text += reported(found);
|
|
264
|
+
}
|
|
265
|
+
else if (retained.length > 0) {
|
|
266
|
+
text += `answer: retained\n held back by: ${counted(retained.length, "exemption class")}\n`;
|
|
267
|
+
for (const one of retained) {
|
|
268
|
+
text += ` class: ${one.class}\t${positionKey(one.site)}\t${one.detail}\n`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
text += liveness(analysis, subject, cascade);
|
|
273
|
+
}
|
|
274
|
+
return text + namedElsewhere(analysis.result.findings, subject);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Explains one symbol from the analysis the report is built from, so an explanation cannot
|
|
278
|
+
* disagree with a finding: why a reported symbol is reported, which exemption classes held a
|
|
279
|
+
* retained one back, and for any other declaration the relations that hold it live and the
|
|
280
|
+
* shortest path of references from a root, or the references it has where no path reaches it.
|
|
281
|
+
* A request naming no one declaration exits with the usage code and names what partially
|
|
282
|
+
* matches it.
|
|
283
|
+
*/
|
|
284
|
+
export const explainVerb = ({ out, err, host, inputs, scope, openClient, option }) => {
|
|
285
|
+
const named = namedSymbol(option);
|
|
286
|
+
const { config, provenance } = resolve(inputs());
|
|
287
|
+
const analysis = runAnalysis(openClient(false), host, scope(), config, provenance, {
|
|
288
|
+
production: true,
|
|
289
|
+
});
|
|
290
|
+
const { subject, candidates } = subjectOf(analysis.swept.matrix.union.symbols, named);
|
|
291
|
+
if (subject === undefined) {
|
|
292
|
+
err.write(`deadset-ts: ${JSON.stringify(named)} names no one symbol of the target\n`);
|
|
293
|
+
for (const candidate of candidates) {
|
|
294
|
+
err.write(` ${candidate}\n`);
|
|
295
|
+
}
|
|
296
|
+
if (candidates.length === 0) {
|
|
297
|
+
err.write(" no symbol of the target partially matches it either\n");
|
|
298
|
+
}
|
|
299
|
+
return EXIT_USAGE;
|
|
300
|
+
}
|
|
301
|
+
out.write(explanation(analysis, subject, config.reporters.cascade));
|
|
302
|
+
return EXIT_CLEAN;
|
|
303
|
+
};
|
package/jsr.json
CHANGED
package/package.json
CHANGED
package/src/analysis.ts
CHANGED
|
@@ -483,7 +483,11 @@ function runAnalysisInputs(
|
|
|
483
483
|
provenance: Provenance,
|
|
484
484
|
mode: Mode,
|
|
485
485
|
detectors: Detectors,
|
|
486
|
-
): {
|
|
486
|
+
): {
|
|
487
|
+
readonly inputs: AnalysisInputs;
|
|
488
|
+
readonly run: RunFacts;
|
|
489
|
+
readonly configured: readonly Configured[];
|
|
490
|
+
} {
|
|
487
491
|
const targetRoot = scope.target.path;
|
|
488
492
|
const read = readEmitterRun(engine, host, scope, config, mode, detectors);
|
|
489
493
|
const symbols = read.matrix.union.symbols;
|
|
@@ -523,6 +527,7 @@ function runAnalysisInputs(
|
|
|
523
527
|
read.projects.flatMap((one) => one.configured.testFiles),
|
|
524
528
|
),
|
|
525
529
|
},
|
|
530
|
+
configured: read.projects.map((one) => one.configured),
|
|
526
531
|
};
|
|
527
532
|
}
|
|
528
533
|
|
|
@@ -545,12 +550,17 @@ export interface RunFacts {
|
|
|
545
550
|
export interface RunAnalysis {
|
|
546
551
|
readonly result: PassResult;
|
|
547
552
|
readonly run: RunFacts;
|
|
553
|
+
/** The sweep the findings were decided over, under the mark of every bound record. */
|
|
554
|
+
readonly swept: RunSweep;
|
|
555
|
+
/** What each project answered, in the run's order, which the sweep was built from. */
|
|
556
|
+
readonly configured: readonly Configured[];
|
|
548
557
|
}
|
|
549
558
|
|
|
550
559
|
/**
|
|
551
560
|
* The analysis of the run the scope and the configuration describe: the run read with its
|
|
552
561
|
* suppressions bound, every family's findings with the suppressions applied and the
|
|
553
|
-
* declared edges evaluated,
|
|
562
|
+
* declared edges evaluated, the projects and test-file rules the report names, and the sweep
|
|
563
|
+
* an explanation reads.
|
|
554
564
|
*/
|
|
555
565
|
export function runAnalysis(
|
|
556
566
|
engine: Engine,
|
|
@@ -561,7 +571,7 @@ export function runAnalysis(
|
|
|
561
571
|
mode: Mode,
|
|
562
572
|
detectors: Detectors = DETECTORS,
|
|
563
573
|
): RunAnalysis {
|
|
564
|
-
const { inputs, run } = runAnalysisInputs(
|
|
574
|
+
const { inputs, run, configured } = runAnalysisInputs(
|
|
565
575
|
engine,
|
|
566
576
|
host,
|
|
567
577
|
scope,
|
|
@@ -570,5 +580,5 @@ export function runAnalysis(
|
|
|
570
580
|
mode,
|
|
571
581
|
detectors,
|
|
572
582
|
);
|
|
573
|
-
return { result: findingsPass(inputs), run };
|
|
583
|
+
return { result: findingsPass(inputs), run, swept: inputs.marked.swept, configured };
|
|
574
584
|
}
|
package/src/config.ts
CHANGED
|
@@ -28,6 +28,14 @@ export type Language = "go" | "ts";
|
|
|
28
28
|
/** What a finding of one issue kind does to a run. */
|
|
29
29
|
export type Severity = "allow" | "warn" | "deny";
|
|
30
30
|
|
|
31
|
+
/** The severities ranked, the strongest highest. */
|
|
32
|
+
const SEVERITY_RANK: Readonly<Record<Severity, number>> = { allow: 0, warn: 1, deny: 2 };
|
|
33
|
+
|
|
34
|
+
/** Whether a finding at one severity fails a run under the failing severity. */
|
|
35
|
+
export function fails(severity: Severity, failOn: Severity): boolean {
|
|
36
|
+
return SEVERITY_RANK[severity] >= SEVERITY_RANK[failOn];
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
/** One rendering of the finding list. */
|
|
32
40
|
export type Format = "text" | "json" | "github" | "sarif" | "template";
|
|
33
41
|
|
package/src/findings-pass.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { AnalysisInputs } from "./analysis.ts";
|
|
8
|
-
import
|
|
8
|
+
import { fails, type Config } from "./config.ts";
|
|
9
9
|
import type { EdgeEvaluation } from "./edges.ts";
|
|
10
10
|
import type { CompletedFinding, Finding } from "./finding.ts";
|
|
11
11
|
import { decidedFindings } from "./findings/emitters.ts";
|
|
@@ -44,9 +44,6 @@ export interface PassResult {
|
|
|
44
44
|
/** The exit code of a report holding at least one pending finding. */
|
|
45
45
|
export const EXIT_PENDING = 4;
|
|
46
46
|
|
|
47
|
-
/** The severities ranked, the strongest highest. */
|
|
48
|
-
const SEVERITY_RANK: Readonly<Record<Severity, number>> = { allow: 0, warn: 1, deny: 2 };
|
|
49
|
-
|
|
50
47
|
/** Two strings ordered bytewise. */
|
|
51
48
|
function compare(a: string, b: string): number {
|
|
52
49
|
if (a === b) {
|
|
@@ -119,9 +116,10 @@ export function verdictOf(result: PassResult, config: Config): number {
|
|
|
119
116
|
if (result.totals.staleSuppressions > 0) {
|
|
120
117
|
return EXIT_FINDINGS;
|
|
121
118
|
}
|
|
122
|
-
const failing =
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
const failing = result.findings.some((finding) =>
|
|
120
|
+
fails(finding.severity, config.reporters.failOn),
|
|
121
|
+
);
|
|
122
|
+
return failing ? EXIT_FINDINGS : EXIT_CLEAN;
|
|
125
123
|
}
|
|
126
124
|
|
|
127
125
|
/**
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { fails } from "../config.ts";
|
|
2
|
+
import { KINDS } from "../kinds.ts";
|
|
3
|
+
import type { Report } from "../report.ts";
|
|
4
|
+
import type { RenderOptions } from "./reporter.ts";
|
|
5
|
+
|
|
6
|
+
/** The escapes a workflow command's message needs, so a line break does not end the command. */
|
|
7
|
+
const DATA_ESCAPES: Readonly<Record<string, string>> = { "%": "%25", "\r": "%0D", "\n": "%0A" };
|
|
8
|
+
|
|
9
|
+
/** The escapes a property value needs: a message's, and the property list's two separators. */
|
|
10
|
+
const PROPERTY_ESCAPES: Readonly<Record<string, string>> = {
|
|
11
|
+
...DATA_ESCAPES,
|
|
12
|
+
":": "%3A",
|
|
13
|
+
",": "%2C",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function escapeData(value: string): string {
|
|
17
|
+
return value.replace(/[%\r\n]/gu, (char) => DATA_ESCAPES[char] ?? char);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function escapeProperty(value: string): string {
|
|
21
|
+
return value.replace(/[%\r\n:,]/gu, (char) => PROPERTY_ESCAPES[char] ?? char);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The name of the kind a code names, and the code itself where no live kind carries it. */
|
|
25
|
+
function kindName(code: string): string {
|
|
26
|
+
return KINDS.get(code)?.name ?? code;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function command(
|
|
30
|
+
level: "error" | "warning",
|
|
31
|
+
at: { readonly path: string; readonly line: number; readonly column: number },
|
|
32
|
+
endLine: number,
|
|
33
|
+
title: string,
|
|
34
|
+
message: string,
|
|
35
|
+
): string {
|
|
36
|
+
return `::${level} file=${escapeProperty(at.path)},line=${String(at.line)},col=${String(at.column)},endLine=${String(endLine)},title=${escapeProperty(title)}::${escapeData(message)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One workflow annotation per finding, then one per stale suppression, each naming its
|
|
41
|
+
* file, line, column and last line, titled by its code and kind. A finding at or above the
|
|
42
|
+
* failing severity is an error and one below it a warning; a stale suppression is an error,
|
|
43
|
+
* because its kind is fixed on at the failing severity.
|
|
44
|
+
*/
|
|
45
|
+
export function annotations(report: Report, options: RenderOptions): string {
|
|
46
|
+
const lines = report.findings.map((found) =>
|
|
47
|
+
command(
|
|
48
|
+
fails(found.severity, options.failOn) ? "error" : "warning",
|
|
49
|
+
found.position,
|
|
50
|
+
found.position.end_line,
|
|
51
|
+
`${found.code} ${found.kind}`,
|
|
52
|
+
found.message,
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
for (const stale of report.stale_suppressions) {
|
|
56
|
+
lines.push(
|
|
57
|
+
command(
|
|
58
|
+
"error",
|
|
59
|
+
stale.position,
|
|
60
|
+
stale.position.line,
|
|
61
|
+
`${stale.code} ${kindName(stale.code)}`,
|
|
62
|
+
stale.message,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return lines.map((line) => `${line}\n`).join("");
|
|
67
|
+
}
|