@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,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline directive: `deadset:ignore`, the first token of a `//` line comment, naming
|
|
3
|
+
* one or more codes and a reason, and binding to every declaration that begins on the line
|
|
4
|
+
* below it.
|
|
5
|
+
*/
|
|
6
|
+
import { SyntaxKind } from "@typescript/native/unstable/ast";
|
|
7
|
+
import { nodeKey } from "./inventory.js";
|
|
8
|
+
import { byPosition, positionKey, renderPosition } from "./position.js";
|
|
9
|
+
import { SUPPRESSION_WITHOUT_REASON, SuppressionError, siteText, } from "./suppress.js";
|
|
10
|
+
/**
|
|
11
|
+
* The three expressions the grammar applies to the text of each line comment, in this
|
|
12
|
+
* order: a comment in the namespace, a well-formed directive, and a directive that lacks
|
|
13
|
+
* only its reason. A test pins each to the line the grammar page carries.
|
|
14
|
+
*/
|
|
15
|
+
export const DIRECTIVE_EXPRESSIONS = {
|
|
16
|
+
candidate: String.raw `^//[ \t]*deadset:`,
|
|
17
|
+
wellFormed: String.raw `^//[ \t]*deadset:ignore[ \t]+(?<codes>DS[0-9]{4}(?:,DS[0-9]{4})*)[ \t]+--[ \t]+(?<reason>[^ \t\r\n][^\r\n]*?)[ \t]*$`,
|
|
18
|
+
noReason: String.raw `^//[ \t]*deadset:ignore[ \t]+(?<codes>DS[0-9]{4}(?:,DS[0-9]{4})*)(?:[ \t]+--)?[ \t]*$`,
|
|
19
|
+
};
|
|
20
|
+
const CANDIDATE = new RegExp(DIRECTIVE_EXPRESSIONS.candidate);
|
|
21
|
+
const WELL_FORMED = new RegExp(DIRECTIVE_EXPRESSIONS.wellFormed);
|
|
22
|
+
const NO_REASON = new RegExp(DIRECTIVE_EXPRESSIONS.noReason);
|
|
23
|
+
/** The form a malformed directive is told to take. */
|
|
24
|
+
const DIRECTIVE_FORM = "// deadset:ignore DS0000[,DS0000...] -- <reason>";
|
|
25
|
+
/** Applies the grammar's three expressions to the text of one comment, in order. */
|
|
26
|
+
export function classifyComment(text) {
|
|
27
|
+
if (!CANDIDATE.test(text)) {
|
|
28
|
+
return { is: "none" };
|
|
29
|
+
}
|
|
30
|
+
const groups = WELL_FORMED.exec(text)?.groups;
|
|
31
|
+
if (groups !== undefined) {
|
|
32
|
+
return {
|
|
33
|
+
is: "directive",
|
|
34
|
+
codes: (groups["codes"] ?? "").split(","),
|
|
35
|
+
reason: groups["reason"] ?? "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const bare = NO_REASON.exec(text)?.groups;
|
|
39
|
+
if (bare !== undefined) {
|
|
40
|
+
return { is: "no-reason", codes: (bare["codes"] ?? "").split(",") };
|
|
41
|
+
}
|
|
42
|
+
return { is: "malformed" };
|
|
43
|
+
}
|
|
44
|
+
/** The literal tokens whose text may hold `//` or `/*` without starting a comment. */
|
|
45
|
+
const LITERALS = new Set([
|
|
46
|
+
SyntaxKind.StringLiteral,
|
|
47
|
+
SyntaxKind.NoSubstitutionTemplateLiteral,
|
|
48
|
+
SyntaxKind.TemplateHead,
|
|
49
|
+
SyntaxKind.TemplateMiddle,
|
|
50
|
+
SyntaxKind.TemplateTail,
|
|
51
|
+
SyntaxKind.RegularExpressionLiteral,
|
|
52
|
+
SyntaxKind.JsxText,
|
|
53
|
+
]);
|
|
54
|
+
/** Whether one character ends a line, under the language's line terminators. */
|
|
55
|
+
function endsLine(char) {
|
|
56
|
+
return char === "\n" || char === "\r" || char === "\u2028" || char === "\u2029";
|
|
57
|
+
}
|
|
58
|
+
/** The offset where the line holding `from` ends. */
|
|
59
|
+
function lineEnd(text, from) {
|
|
60
|
+
let at = from;
|
|
61
|
+
while (at < text.length && !endsLine(text[at])) {
|
|
62
|
+
at += 1;
|
|
63
|
+
}
|
|
64
|
+
return at;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The offset of the first token at or after `from`: whitespace, comments and a leading
|
|
68
|
+
* hashbang are trivia.
|
|
69
|
+
*/
|
|
70
|
+
export function tokenStart(text, from) {
|
|
71
|
+
let at = from;
|
|
72
|
+
if (at === 0 && text.startsWith("#!")) {
|
|
73
|
+
at = lineEnd(text, 0);
|
|
74
|
+
}
|
|
75
|
+
for (;;) {
|
|
76
|
+
if (/\s/u.test(text[at] ?? "")) {
|
|
77
|
+
at += 1;
|
|
78
|
+
}
|
|
79
|
+
else if (text.startsWith("//", at)) {
|
|
80
|
+
at = lineEnd(text, at);
|
|
81
|
+
}
|
|
82
|
+
else if (text.startsWith("/*", at)) {
|
|
83
|
+
const close = text.indexOf("*/", at + 2);
|
|
84
|
+
at = close < 0 ? text.length : close + 2;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
return at;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Every `//` line comment of one file, in file order. The text between tokens is read as
|
|
93
|
+
* the language reads it: a block comment holds no line comment, and the literal tokens the
|
|
94
|
+
* syntax tree holds are skipped, so a `//` inside a string, a template or a regular
|
|
95
|
+
* expression starts nothing.
|
|
96
|
+
*/
|
|
97
|
+
export function lineComments(file) {
|
|
98
|
+
const text = file.text;
|
|
99
|
+
const spans = [];
|
|
100
|
+
const visit = (node) => {
|
|
101
|
+
if (LITERALS.has(node.kind)) {
|
|
102
|
+
spans.push({ start: tokenStart(text, node.pos), end: node.end });
|
|
103
|
+
}
|
|
104
|
+
node.forEachChild(visit);
|
|
105
|
+
};
|
|
106
|
+
file.forEachChild(visit);
|
|
107
|
+
spans.sort((a, b) => a.start - b.start);
|
|
108
|
+
const found = [];
|
|
109
|
+
let next = 0;
|
|
110
|
+
let at = 0;
|
|
111
|
+
while (at < text.length) {
|
|
112
|
+
while (next < spans.length && (spans[next]?.start ?? 0) < at) {
|
|
113
|
+
next += 1;
|
|
114
|
+
}
|
|
115
|
+
const span = spans[next];
|
|
116
|
+
if (span?.start === at) {
|
|
117
|
+
at = Math.max(span.end, at + 1);
|
|
118
|
+
}
|
|
119
|
+
else if (text.startsWith("//", at)) {
|
|
120
|
+
const end = lineEnd(text, at);
|
|
121
|
+
found.push({ offset: at, text: text.slice(at, end) });
|
|
122
|
+
at = end;
|
|
123
|
+
}
|
|
124
|
+
else if (text.startsWith("/*", at)) {
|
|
125
|
+
const close = text.indexOf("*/", at + 2);
|
|
126
|
+
at = close < 0 ? text.length : close + 2;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
at += 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The declarations of one file by the line each begins on, the line of its first token: a
|
|
136
|
+
* decorator, a modifier or the keyword. A variable's first token is its statement's
|
|
137
|
+
* keyword, and a variable written on a later line of the statement also begins there.
|
|
138
|
+
*/
|
|
139
|
+
function declarationLines(file, held, symbols) {
|
|
140
|
+
const below = new Map();
|
|
141
|
+
const add = (offset, one) => {
|
|
142
|
+
const line = file.getLineAndCharacterOfPosition(tokenStart(file.text, offset)).line + 1;
|
|
143
|
+
const at = below.get(line) ?? [];
|
|
144
|
+
if (!at.some((known) => known.id === one.id)) {
|
|
145
|
+
at.push(one);
|
|
146
|
+
}
|
|
147
|
+
below.set(line, at);
|
|
148
|
+
};
|
|
149
|
+
const visit = (node) => {
|
|
150
|
+
const id = held.declarations.get(nodeKey(file, node));
|
|
151
|
+
const one = id === undefined ? undefined : symbols.get(id);
|
|
152
|
+
if (one !== undefined) {
|
|
153
|
+
add(node.pos, one);
|
|
154
|
+
const list = node.parent;
|
|
155
|
+
if (node.kind === SyntaxKind.VariableDeclaration &&
|
|
156
|
+
list.kind === SyntaxKind.VariableDeclarationList &&
|
|
157
|
+
list.parent.kind === SyntaxKind.VariableStatement) {
|
|
158
|
+
add(list.parent.pos, one);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
node.forEachChild(visit);
|
|
162
|
+
};
|
|
163
|
+
file.forEachChild(visit);
|
|
164
|
+
return below;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Every comment in the namespace one project's own files hold, each with the declarations
|
|
168
|
+
* it binds, read while the project's view is open.
|
|
169
|
+
*/
|
|
170
|
+
export function inlineDirectives(project, held, targetRoot) {
|
|
171
|
+
const symbols = new Map(held.symbols
|
|
172
|
+
.filter((symbol) => symbol.kind !== "file")
|
|
173
|
+
.map((symbol) => [symbol.id, { id: symbol.id, ref: symbol.ref, position: symbol.position }]));
|
|
174
|
+
const found = [];
|
|
175
|
+
for (const file of project.ownSourceFiles()) {
|
|
176
|
+
const comments = lineComments(file).filter((comment) => CANDIDATE.test(comment.text));
|
|
177
|
+
if (comments.length === 0) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const below = declarationLines(file, held, symbols);
|
|
181
|
+
for (const comment of comments) {
|
|
182
|
+
const site = renderPosition(file, targetRoot, comment.offset);
|
|
183
|
+
found.push({
|
|
184
|
+
site,
|
|
185
|
+
text: comment.text,
|
|
186
|
+
directive: classifyComment(comment.text),
|
|
187
|
+
bound: [...(below.get(site.line + 1) ?? [])],
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return found;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The inline directives of a run's projects, each bound to the declarations that begin on
|
|
195
|
+
* the line below it, one directive binding the union of what each project holding its file
|
|
196
|
+
* declares there. A directive that lacks only its reason is one {@link Refusal} per code;
|
|
197
|
+
* any other malformed comment in the namespace ends the read with a
|
|
198
|
+
* {@link SuppressionError} naming the first one written.
|
|
199
|
+
*/
|
|
200
|
+
export function inlineSuppressions(perProject) {
|
|
201
|
+
const merged = new Map();
|
|
202
|
+
for (const written of perProject.flat()) {
|
|
203
|
+
const key = positionKey(written.site);
|
|
204
|
+
const known = merged.get(key);
|
|
205
|
+
if (known === undefined) {
|
|
206
|
+
merged.set(key, { ...written, bound: [...written.bound] });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
for (const one of written.bound) {
|
|
210
|
+
if (!known.bound.some((other) => other.id === one.id)) {
|
|
211
|
+
known.bound.push(one);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const ordered = [...merged.values()].sort((a, b) => byPosition(a.site, b.site));
|
|
216
|
+
const records = [];
|
|
217
|
+
const refusals = [];
|
|
218
|
+
for (const { site, text, directive, bound } of ordered) {
|
|
219
|
+
switch (directive.is) {
|
|
220
|
+
case "none":
|
|
221
|
+
break;
|
|
222
|
+
case "malformed":
|
|
223
|
+
throw new SuppressionError("inline", siteText(site), JSON.stringify(text), DIRECTIVE_FORM);
|
|
224
|
+
case "no-reason":
|
|
225
|
+
for (const code of directive.codes) {
|
|
226
|
+
refusals.push({
|
|
227
|
+
reported: SUPPRESSION_WITHOUT_REASON,
|
|
228
|
+
code,
|
|
229
|
+
symbol: "",
|
|
230
|
+
path: site.path,
|
|
231
|
+
reason: "",
|
|
232
|
+
site,
|
|
233
|
+
mechanism: "inline",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
case "directive": {
|
|
238
|
+
const declared = [...bound].sort((a, b) => byPosition(a.position, b.position));
|
|
239
|
+
for (const code of directive.codes) {
|
|
240
|
+
const base = {
|
|
241
|
+
code,
|
|
242
|
+
path: site.path,
|
|
243
|
+
reason: directive.reason,
|
|
244
|
+
site,
|
|
245
|
+
mechanism: "inline",
|
|
246
|
+
};
|
|
247
|
+
if (declared.length === 0) {
|
|
248
|
+
records.push({ ...base, symbol: "", bound: "" });
|
|
249
|
+
}
|
|
250
|
+
for (const one of declared) {
|
|
251
|
+
records.push({ ...base, symbol: one.ref, bound: one.id });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { records, refusals };
|
|
259
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suppression grammar's records, and the ledger that decides what each one did. A
|
|
3
|
+
* record is one code bound to one site with its reason, from an inline directive, an
|
|
4
|
+
* ignore entry or a baseline row. A bound record marks its declaration live before the
|
|
5
|
+
* sweep, and is in effect when it withholds the finding its code would have produced
|
|
6
|
+
* there, dormant when the configuration withholds that finding anyway, and stale otherwise.
|
|
7
|
+
*/
|
|
8
|
+
import { ConfigError } from "./config.js";
|
|
9
|
+
import { positionKey } from "./position.js";
|
|
10
|
+
import { isRef } from "./ref.js";
|
|
11
|
+
/** The code a suppression that carries no reason is reported under. */
|
|
12
|
+
export const SUPPRESSION_WITHOUT_REASON = "DS1701";
|
|
13
|
+
/** The code an ignore entry or a baseline row that names no path is reported under. */
|
|
14
|
+
export const UNSCOPED_ENTRY = "DS1702";
|
|
15
|
+
/** The code a suppression that withholds nothing is reported under. */
|
|
16
|
+
export const STALE_SUPPRESSION = "DS1703";
|
|
17
|
+
/** A target that carries no suppression. */
|
|
18
|
+
export const NO_SUPPRESSIONS = { records: [], refusals: [] };
|
|
19
|
+
/**
|
|
20
|
+
* A directive, an entry or a document the grammar refuses before any finding exists: an
|
|
21
|
+
* instruction the analysis cannot carry out. It is a malformed input, so the command line
|
|
22
|
+
* answers it with the usage code.
|
|
23
|
+
*/
|
|
24
|
+
export class SuppressionError extends ConfigError {
|
|
25
|
+
constructor(mechanism, at, text, want) {
|
|
26
|
+
super("malformed", at, `${mechanism} ${at}: ${text}: want ${want}`);
|
|
27
|
+
this.name = "SuppressionError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Where a position is written, as an error names it. */
|
|
31
|
+
export function siteText(site) {
|
|
32
|
+
return site.line > 0 ? positionKey(site) : site.path;
|
|
33
|
+
}
|
|
34
|
+
/** An issue-kind code: `DS` and four digits. */
|
|
35
|
+
export const CODE_FORM = /^DS[0-9]{4}$/u;
|
|
36
|
+
/**
|
|
37
|
+
* A path inside the target: relative to the target root, the solidus as separator, no
|
|
38
|
+
* leading `./`, no trailing solidus, no `.` or `..` segment, never absolute.
|
|
39
|
+
*/
|
|
40
|
+
export const PATH_EXPRESSION = String.raw `^(?:[^/\\.\r\n][^/\\\r\n]*|\.[^/\\.\r\n][^/\\\r\n]*|\.\.[^/\\\r\n]+)(?:/(?:[^/\\.\r\n][^/\\\r\n]*|\.[^/\\.\r\n][^/\\\r\n]*|\.\.[^/\\\r\n]+))*$`;
|
|
41
|
+
const PATH_FORM = new RegExp(PATH_EXPRESSION, "u");
|
|
42
|
+
/** Whether one value is a path in the form an entry's `path` takes. */
|
|
43
|
+
export function isEntryPath(value) {
|
|
44
|
+
return PATH_FORM.test(value);
|
|
45
|
+
}
|
|
46
|
+
const GO_IDENT = String.raw `(?:[A-Za-z_]|[^\x00-\x7F])(?:[A-Za-z0-9_]|[^\x00-\x7F])*`;
|
|
47
|
+
const GO_PATH = String.raw `[A-Za-z0-9._~-]+(?:/[A-Za-z0-9._~-]+)*`;
|
|
48
|
+
const GO_VERSION = String.raw `[A-Za-z0-9.+-]+`;
|
|
49
|
+
const GO_FILE = String.raw `[^/\\:#\r\n]+\.go`;
|
|
50
|
+
/**
|
|
51
|
+
* The reference forms of the Go analyzer's grammar. One target holds one ignore file,
|
|
52
|
+
* whose entries may name a symbol of either language, so an entry naming a Go symbol is
|
|
53
|
+
* well formed here and binds nothing.
|
|
54
|
+
*/
|
|
55
|
+
export const GO_REF_EXPRESSIONS = [
|
|
56
|
+
`^go://${GO_PATH}#$`,
|
|
57
|
+
`^go://${GO_PATH}#${GO_IDENT}$`,
|
|
58
|
+
`^go://${GO_PATH}#${GO_IDENT}(?:\\.${GO_IDENT})+$`,
|
|
59
|
+
`^go://${GO_PATH}#${GO_IDENT}(?:\\.${GO_IDENT})?\\[${GO_IDENT}\\]$`,
|
|
60
|
+
`^go://${GO_PATH}#${GO_FILE}:file$`,
|
|
61
|
+
`^go://${GO_PATH}#${GO_PATH}(?:@${GO_VERSION})?:require$`,
|
|
62
|
+
`^go://${GO_PATH}#${GO_PATH}(?:@${GO_VERSION})?:replace$`,
|
|
63
|
+
];
|
|
64
|
+
const GO_REF_FORMS = GO_REF_EXPRESSIONS.map((expression) => new RegExp(expression, "u"));
|
|
65
|
+
/** Whether one value is a stable symbol reference of either language's grammar. */
|
|
66
|
+
export function isSymbolRef(value) {
|
|
67
|
+
return isRef(value) || GO_REF_FORMS.some((form) => form.test(value));
|
|
68
|
+
}
|
|
69
|
+
/** Whether a reason is absent, empty or whitespace only, which the grammar reads as none. */
|
|
70
|
+
export function blankReason(reason) {
|
|
71
|
+
return reason === undefined || /^[ \t\r\n]*$/u.test(reason);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The subject kinds that are a row of a document rather than a declaration of the program.
|
|
75
|
+
* A record binds to a declaration, so nothing withholds a finding about one.
|
|
76
|
+
*/
|
|
77
|
+
const ROW_SUBJECTS = new Set([
|
|
78
|
+
"file",
|
|
79
|
+
"dependency",
|
|
80
|
+
"module-directive",
|
|
81
|
+
"root",
|
|
82
|
+
"suppression",
|
|
83
|
+
"edge",
|
|
84
|
+
]);
|
|
85
|
+
/** Whether a finding's subject is a row of a document, which no record can withhold. */
|
|
86
|
+
export function isRowSubject(kind) {
|
|
87
|
+
return ROW_SUBJECTS.has(kind);
|
|
88
|
+
}
|
|
89
|
+
/** One finding's identity: its code and the position of the thing it names. */
|
|
90
|
+
function claimKey(finding) {
|
|
91
|
+
return `${finding.code}\u0000${positionKey(finding.position)}`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether one record names one finding. A record reaches a finding in two ways: it bound
|
|
95
|
+
* the declaration at the finding's position, or it names the finding's reference and the
|
|
96
|
+
* file the finding is reported in, each exactly. The second is how an entry copied from a
|
|
97
|
+
* finding about a part of a declaration reaches the part.
|
|
98
|
+
*/
|
|
99
|
+
export function pairs(record, finding) {
|
|
100
|
+
if (record.code !== finding.code || ROW_SUBJECTS.has(finding.symbol.kind)) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (record.bound !== "" && record.bound === positionKey(finding.position)) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return record.symbol === finding.symbol.ref && record.path === finding.position.path;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Decides every record against `would`, the findings the run would produce were no record
|
|
110
|
+
* there: swept with no mark, where a kind about liveness reports the marked declaration,
|
|
111
|
+
* and as swept, where a kind whose subject the sweep holds live reports it. The first
|
|
112
|
+
* record in reading order claims a finding, so a second naming it is stale. A record whose
|
|
113
|
+
* code the dials withhold from its declaration, or whose claimed finding they withhold, is
|
|
114
|
+
* dormant.
|
|
115
|
+
*/
|
|
116
|
+
export function ledgerOf(records, would, dials) {
|
|
117
|
+
const taken = new Set();
|
|
118
|
+
const verdicts = [];
|
|
119
|
+
const claims = [];
|
|
120
|
+
for (const record of records) {
|
|
121
|
+
if (record.bound !== "" && dials.withholdsCode(record.code, record.bound)) {
|
|
122
|
+
verdicts.push("dormant");
|
|
123
|
+
claims.push(undefined);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const claimed = would.find((finding) => !taken.has(claimKey(finding)) && pairs(record, finding));
|
|
127
|
+
claims.push(claimed);
|
|
128
|
+
if (claimed === undefined) {
|
|
129
|
+
verdicts.push("stale");
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
taken.add(claimKey(claimed));
|
|
133
|
+
verdicts.push(dials.withholds(claimed) ? "dormant" : "in-effect");
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
verdicts,
|
|
137
|
+
claims,
|
|
138
|
+
withheld: (finding) => taken.has(claimKey(finding)) && !ROW_SUBJECTS.has(finding.symbol.kind),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** The suppression counts of one run's records and the ledger over them. */
|
|
142
|
+
export function suppressionCounts(records, ledger) {
|
|
143
|
+
const sites = new Set(records.map((record) => `${record.mechanism}\u0000${positionKey(record.site)}`));
|
|
144
|
+
return {
|
|
145
|
+
inEffect: ledger.verdicts.filter((verdict) => verdict === "in-effect").length,
|
|
146
|
+
reasonsRecorded: sites.size,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { runAnalysis } from "../analysis.js";
|
|
2
|
+
import { ConfigError } from "../config.js";
|
|
3
|
+
import { RECORDED_CONFORMANCE, reportedConformance } from "../conformance.js";
|
|
4
|
+
import { analyzerProvenance, recordedFindings, verdictOf } from "../findings-pass.js";
|
|
5
|
+
import { packageScope } from "../inventory.js";
|
|
6
|
+
import { normalizePath, relativePath, resolvePath } from "../paths.js";
|
|
7
|
+
import { buildReport, capFindings, sortFindings, } from "../report.js";
|
|
8
|
+
import { json } from "../reporters/json.js";
|
|
9
|
+
import { RENDERINGS } from "../reporters/reporters.js";
|
|
10
|
+
import { resolve } from "../resolve.js";
|
|
11
|
+
import { writeBaseline } from "../suppress-file.js";
|
|
12
|
+
import { CONTRACT_VERSION } from "../version.js";
|
|
13
|
+
import { EXIT_CLEAN, EXIT_FAILURE } from "./verb.js";
|
|
14
|
+
/** The options `analyze` takes beside the ones every verb takes. */
|
|
15
|
+
export const ANALYZE_OPTIONS = {
|
|
16
|
+
plain: ["report", "baseline-write", "exit-code"],
|
|
17
|
+
repeatable: ["format"],
|
|
18
|
+
};
|
|
19
|
+
/** The two values `--exit-code` takes; on, the default, puts the run's verdict in the exit code. */
|
|
20
|
+
const EXIT_CODE_ON = "on";
|
|
21
|
+
const EXIT_CODE_OFF = "off";
|
|
22
|
+
/** Why a declared consumer is not loaded, which every report names it with. */
|
|
23
|
+
const NOT_LOADED = "this analyzer loads no consumer beside the target";
|
|
24
|
+
/** A path of the run no report can name, which ends the run with the failure code. */
|
|
25
|
+
class ReportPathError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "ReportPathError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The formats this analyzer renders, for a message that names them. */
|
|
32
|
+
function formatNames() {
|
|
33
|
+
return [...RENDERINGS.keys()].sort().join(", ");
|
|
34
|
+
}
|
|
35
|
+
/** The renderings one invocation writes: the ones it named, else the configuration's. */
|
|
36
|
+
function formatsOf(named, configured) {
|
|
37
|
+
const formats = named.length > 0 ? named : configured;
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (const format of formats) {
|
|
40
|
+
if (!RENDERINGS.has(format)) {
|
|
41
|
+
throw new ConfigError("malformed", "", `${JSON.stringify(format)} is not a format this analyzer renders: the formats are ${formatNames()}`);
|
|
42
|
+
}
|
|
43
|
+
if (seen.has(format)) {
|
|
44
|
+
throw new ConfigError("malformed", "", `the format ${JSON.stringify(format)} is named twice`);
|
|
45
|
+
}
|
|
46
|
+
seen.add(format);
|
|
47
|
+
}
|
|
48
|
+
return formats;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* One directory of the run below the directory the run was invoked from, as a report names
|
|
52
|
+
* it. A report admits that directory and every path below it and none that climbs out, so a
|
|
53
|
+
* target elsewhere has no spelling a report can carry and fails the run.
|
|
54
|
+
*/
|
|
55
|
+
function runRelative(invoked, path) {
|
|
56
|
+
if (normalizePath(invoked) === normalizePath(path)) {
|
|
57
|
+
return ".";
|
|
58
|
+
}
|
|
59
|
+
const below = relativePath(invoked, path);
|
|
60
|
+
if (below === undefined) {
|
|
61
|
+
throw new ReportPathError(`a report names its target relative to the directory the run was invoked from, and ${invoked} does not contain ${path}: run from a directory that does`);
|
|
62
|
+
}
|
|
63
|
+
return below;
|
|
64
|
+
}
|
|
65
|
+
/** Each project of the run as a report names it, by its configuration file below the target. */
|
|
66
|
+
function configurationsOf(projects, targetRoot) {
|
|
67
|
+
return projects.map((one) => {
|
|
68
|
+
const project = relativePath(targetRoot, one.configFile);
|
|
69
|
+
if (project === undefined) {
|
|
70
|
+
throw new ReportPathError(`the project ${one.id} is opened from ${one.configFile}, which is not below the target ${targetRoot}, so no report can name it`);
|
|
71
|
+
}
|
|
72
|
+
return { id: one.id, project };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** The name one module publishes itself under: the scope's, else its manifest's. */
|
|
76
|
+
function identityOf(module, host) {
|
|
77
|
+
return module.id === "" ? packageScope(host, module.path)("index.ts").package : module.id;
|
|
78
|
+
}
|
|
79
|
+
/** A count with its noun, so a message reads for one record as well as for several. */
|
|
80
|
+
function counted(n, noun) {
|
|
81
|
+
return `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Analyzes the scope under the configuration and writes the JSON report to the path
|
|
85
|
+
* `--report` names, each rendering `--format` names beside it at the report's path with the
|
|
86
|
+
* format's suffix appended, and a baseline of every finding where `--baseline-write` names a
|
|
87
|
+
* path. Nothing is written to the output stream; diagnostics go to the error stream and the
|
|
88
|
+
* verdict is the exit code, or 0 under `--exit-code=off`.
|
|
89
|
+
*/
|
|
90
|
+
export const analyzeVerb = ({ err, host, inputs, scope, openClient, option, repeated }) => {
|
|
91
|
+
const reportPath = option("report");
|
|
92
|
+
if (reportPath === undefined || reportPath === "") {
|
|
93
|
+
throw new ConfigError("malformed", "", "analyze writes its report to the path --report names, and no path was named");
|
|
94
|
+
}
|
|
95
|
+
const exitCode = option("exit-code") ?? EXIT_CODE_ON;
|
|
96
|
+
if (exitCode !== EXIT_CODE_ON && exitCode !== EXIT_CODE_OFF) {
|
|
97
|
+
throw new ConfigError("malformed", "", `--exit-code=${JSON.stringify(exitCode)}: the values are ${EXIT_CODE_ON} and ${EXIT_CODE_OFF}`);
|
|
98
|
+
}
|
|
99
|
+
const { config, provenance } = resolve(inputs());
|
|
100
|
+
const kind = config.targetKind;
|
|
101
|
+
if (kind === "") {
|
|
102
|
+
throw new ConfigError("missing-target-kind", "target.kind", "target.kind is not set");
|
|
103
|
+
}
|
|
104
|
+
const invoked = host.workingDirectory();
|
|
105
|
+
const baseline = option("baseline-write");
|
|
106
|
+
const documents = {
|
|
107
|
+
report: resolvePath(invoked, reportPath),
|
|
108
|
+
formats: formatsOf(repeated("format"), config.reporters.formats),
|
|
109
|
+
baseline: baseline === undefined ? undefined : resolvePath(invoked, baseline),
|
|
110
|
+
};
|
|
111
|
+
const scoped = scope();
|
|
112
|
+
const targetRoot = scoped.target.path;
|
|
113
|
+
let version;
|
|
114
|
+
try {
|
|
115
|
+
version = host.analyzerVersion();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
err.write(`deadset-ts: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
119
|
+
return EXIT_FAILURE;
|
|
120
|
+
}
|
|
121
|
+
let report;
|
|
122
|
+
let recorded;
|
|
123
|
+
let verdict;
|
|
124
|
+
try {
|
|
125
|
+
const root = runRelative(invoked, targetRoot);
|
|
126
|
+
const { result, run } = runAnalysis(openClient(false), host, scoped, config, provenance, {
|
|
127
|
+
production: true,
|
|
128
|
+
});
|
|
129
|
+
const built = buildReport({
|
|
130
|
+
contractVersion: CONTRACT_VERSION,
|
|
131
|
+
version,
|
|
132
|
+
conformance: reportedConformance(RECORDED_CONFORMANCE),
|
|
133
|
+
target: { kind, root, identity: identityOf(scoped.target, host) },
|
|
134
|
+
configurations: configurationsOf(run.projects, targetRoot),
|
|
135
|
+
unavailable: scoped.consumers.map((consumer) => ({
|
|
136
|
+
id: identityOf(consumer, host),
|
|
137
|
+
reason: NOT_LOADED,
|
|
138
|
+
})),
|
|
139
|
+
result,
|
|
140
|
+
testFileRules: run.testFileRules,
|
|
141
|
+
});
|
|
142
|
+
// The baseline records every finding, so its rows are taken before the cap bounds what
|
|
143
|
+
// a rendering prints: a baseline missing a reported finding fails the next run on it.
|
|
144
|
+
recorded = recordedFindings(result);
|
|
145
|
+
report = capFindings(sortFindings(built, config.reporters.sort), config.reporters.maxFindings);
|
|
146
|
+
verdict = verdictOf(result, config);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (!(error instanceof ReportPathError)) {
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
err.write(`deadset-ts: ${error.message}\n`);
|
|
153
|
+
return EXIT_FAILURE;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
host.writeFile(documents.report, json(report));
|
|
157
|
+
for (const format of documents.formats) {
|
|
158
|
+
const rendering = RENDERINGS.get(format);
|
|
159
|
+
if (rendering !== undefined) {
|
|
160
|
+
host.writeFile(documents.report + rendering.suffix, rendering.render(report));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (documents.baseline !== undefined) {
|
|
164
|
+
host.writeFile(documents.baseline, writeBaseline(recorded, analyzerProvenance(version)));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
err.write(`deadset-ts: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
169
|
+
return EXIT_FAILURE;
|
|
170
|
+
}
|
|
171
|
+
if (report.totals.pending > 0) {
|
|
172
|
+
err.write(`deadset-ts: ${counted(report.totals.pending, "pending finding")}: the report names a declared cross-language edge, and no merge has resolved it\n`);
|
|
173
|
+
}
|
|
174
|
+
if (report.totals.stale_suppressions > 0) {
|
|
175
|
+
err.write(`deadset-ts: ${counted(report.totals.stale_suppressions, "stale suppression")}\n`);
|
|
176
|
+
}
|
|
177
|
+
if (exitCode === EXIT_CODE_OFF) {
|
|
178
|
+
if (verdict !== EXIT_CLEAN) {
|
|
179
|
+
err.write(`deadset-ts: the exit code is configured off: the verdict of this run is ${String(verdict)}\n`);
|
|
180
|
+
}
|
|
181
|
+
return EXIT_CLEAN;
|
|
182
|
+
}
|
|
183
|
+
return verdict;
|
|
184
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { RECORDED_CONFORMANCE } from "../conformance.js";
|
|
2
|
+
import { ANALYZER_NAME, LANGUAGE, SCHEMA_VERSION } from "../report.js";
|
|
3
|
+
import { CONTRACT_VERSION } from "../version.js";
|
|
4
|
+
import { EXIT_CLEAN, EXIT_FAILURE, EXIT_USAGE } from "./verb.js";
|
|
5
|
+
/**
|
|
6
|
+
* The document `describe` writes: the analyzer, the Contract version it implements, the
|
|
7
|
+
* report schema versions it reads, the languages it claims, and its recorded result over
|
|
8
|
+
* the conformance corpus, absent where it records none.
|
|
9
|
+
*/
|
|
10
|
+
export function describeDocument(version, recorded) {
|
|
11
|
+
const document = {
|
|
12
|
+
name: ANALYZER_NAME,
|
|
13
|
+
version,
|
|
14
|
+
contract_version: CONTRACT_VERSION,
|
|
15
|
+
schema_versions_accepted: [SCHEMA_VERSION],
|
|
16
|
+
languages: [LANGUAGE],
|
|
17
|
+
...(recorded === undefined
|
|
18
|
+
? {}
|
|
19
|
+
: {
|
|
20
|
+
conformance: {
|
|
21
|
+
corpus_version: recorded.corpusVersion,
|
|
22
|
+
result: recorded.result,
|
|
23
|
+
digest: recorded.digest,
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
28
|
+
}
|
|
29
|
+
/** Writes this analyzer's description to the output stream, and nothing to the error stream. */
|
|
30
|
+
export const describeVerb = ({ out, err, host, args }) => {
|
|
31
|
+
if (args.length > 0) {
|
|
32
|
+
err.write(`deadset-ts: describe takes no argument, got ${JSON.stringify(args[0])}\n`);
|
|
33
|
+
return EXIT_USAGE;
|
|
34
|
+
}
|
|
35
|
+
let version;
|
|
36
|
+
try {
|
|
37
|
+
version = host.analyzerVersion();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
err.write(`deadset-ts: describe: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
41
|
+
return EXIT_FAILURE;
|
|
42
|
+
}
|
|
43
|
+
out.write(describeDocument(version, RECORDED_CONFORMANCE));
|
|
44
|
+
return EXIT_CLEAN;
|
|
45
|
+
};
|
package/jsr.json
CHANGED