@cplieger/deadset-ts 3.6.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/analysis.js +92 -23
- package/dist/src/edges.js +190 -0
- package/dist/src/finding.js +0 -11
- package/dist/src/findings/completion.js +92 -35
- package/dist/src/findings/dependencies-and-module-machinery.js +3 -11
- package/dist/src/findings/emitters.js +42 -4
- package/dist/src/findings/reads-and-writes.js +16 -69
- package/dist/src/findings/self-check.js +127 -2
- package/dist/src/findings/unused-declarations.js +8 -40
- 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/kinds.js +47 -45
- package/dist/src/roots.js +0 -34
- package/dist/src/suppress-file.js +196 -0
- package/dist/src/suppress-inline.js +259 -0
- package/dist/src/suppress.js +148 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +176 -32
- package/src/edges.ts +241 -0
- package/src/finding.ts +76 -10
- package/src/findings/boundary.ts +0 -11
- package/src/findings/completion.ts +117 -71
- package/src/findings/dependencies-and-module-machinery.ts +4 -19
- package/src/findings/emitter.ts +6 -2
- package/src/findings/emitters.ts +81 -7
- package/src/findings/interfaces.ts +5 -17
- package/src/findings/reads-and-writes.ts +37 -151
- package/src/findings/self-check.ts +205 -2
- package/src/findings/unused-declarations.ts +11 -58
- package/src/findings/visibility-narrowing.ts +11 -58
- package/src/findings-pass.ts +145 -0
- package/src/json-document.ts +130 -0
- package/src/kinds.ts +79 -45
- package/src/roots.ts +0 -39
- package/src/suppress-file.ts +283 -0
- package/src/suppress-inline.ts +318 -0
- package/src/suppress.ts +262 -0
package/dist/src/kinds.js
CHANGED
|
@@ -1,52 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* The codes are written out rather than read from the Contract at run time: the
|
|
7
|
-
* Contract is data a reader outside this program consults, and a product that
|
|
8
|
-
* read it while running would need it installed beside itself. A test compares
|
|
9
|
-
* this list against the Contract release the analyzer is written against, so a
|
|
10
|
-
* code the Contract fixes arrives here as a failing test.
|
|
2
|
+
* Every live row of the issue-kind vocabulary, in ascending code order, written out
|
|
3
|
+
* rather than read at run time, because a product that read the Contract while running
|
|
4
|
+
* would need it installed beside itself. A test compares this table against the
|
|
5
|
+
* Contract release the analyzer is written against.
|
|
11
6
|
*/
|
|
12
|
-
|
|
7
|
+
const ROWS = [
|
|
8
|
+
row("DS1001", "unused-exported", "deny", "deletable"),
|
|
9
|
+
row("DS1002", "unused-unexported", "deny", "deletable"),
|
|
10
|
+
row("DS1003", "unused-member", "deny", "deletable"),
|
|
11
|
+
row("DS1004", "test-only-use", "deny", "deletable"),
|
|
12
|
+
row("DS1005", "test-of-dead-code", "deny", "deletable"),
|
|
13
|
+
row("DS1006", "deprecated-unused", "deny", "deletable"),
|
|
14
|
+
row("DS1101", "unnecessary-export", "warn", "narrowable"),
|
|
15
|
+
row("DS1102", "unnecessary-exposure", "warn", "narrowable"),
|
|
16
|
+
row("DS1103", "unreachable-export", "deny", "deletable"),
|
|
17
|
+
row("DS1104", "redundant-export-keyword", "warn", "narrowable"),
|
|
18
|
+
row("DS1201", "unused-interface", "deny", "deletable"),
|
|
19
|
+
row("DS1203", "uncalled-interface-method", "warn", "manual"),
|
|
20
|
+
row("DS1204", "unused-satisfaction-assertion", "deny", "deletable"),
|
|
21
|
+
row("DS1301", "write-only-symbol", "deny", "deletable"),
|
|
22
|
+
row("DS1302", "unused-enum-member", "deny", "deletable"),
|
|
23
|
+
row("DS1303", "unused-type-parameter", "deny", "deletable"),
|
|
24
|
+
row("DS1501", "file-never-built", "deny", "deletable"),
|
|
25
|
+
row("DS1502", "file-never-imported", "deny", "deletable"),
|
|
26
|
+
row("DS1601", "unused-dependency", "deny", "manual"),
|
|
27
|
+
row("DS1605", "unused-module-directive", "warn", "deletable"),
|
|
28
|
+
row("DS1701", "suppression-without-reason", "deny", "none"),
|
|
29
|
+
row("DS1702", "unscoped-ignore-entry", "deny", "none"),
|
|
30
|
+
{ ...row("DS1703", "stale-suppression", "deny", "none"), fixed: true },
|
|
31
|
+
{ ...row("DS1704", "unmatched-root", "deny", "none"), fixed: true },
|
|
32
|
+
row("DS1705", "stale-cross-language-edge", "deny", "none"),
|
|
33
|
+
row("DS1801", "unused-parameter", "warn", "manual"),
|
|
34
|
+
row("DS1802", "unused-receiver", "warn", "deletable"),
|
|
35
|
+
row("DS1803", "unused-result", "warn", "manual"),
|
|
36
|
+
row("DS1805", "unreachable-statement", "deny", "deletable"),
|
|
37
|
+
row("DS1807", "dead-store", "deny", "deletable"),
|
|
38
|
+
row("DS1809", "unreachable-case", "deny", "deletable"),
|
|
39
|
+
];
|
|
40
|
+
/** A row at the `certain` ceiling whose severity a configuration may set. */
|
|
41
|
+
function row(code, name, defaultSeverity, fixability) {
|
|
42
|
+
return { code, name, defaultSeverity, maxClass: "certain", fixability, fixed: false };
|
|
43
|
+
}
|
|
44
|
+
/** Every live row of the issue-kind vocabulary, by its code, in ascending code order. */
|
|
45
|
+
export const KINDS = new Map(ROWS.map((one) => [one.code, one]));
|
|
13
46
|
/**
|
|
14
47
|
* The code of every live issue kind, in ascending order. A severity key names one of
|
|
15
48
|
* them or a family prefix whose range holds one; a key naming a retired code, an
|
|
16
49
|
* unassigned code or a range holding no live kind is an unimplemented key, so every
|
|
17
|
-
* severity a configuration sets is one some analyzer reports under.
|
|
18
|
-
* this list against the Contract release the analyzer is written against.
|
|
50
|
+
* severity a configuration sets is one some analyzer reports under.
|
|
19
51
|
*/
|
|
20
|
-
export const LIVE_CODES =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"DS1003",
|
|
24
|
-
"DS1004",
|
|
25
|
-
"DS1005",
|
|
26
|
-
"DS1006",
|
|
27
|
-
"DS1101",
|
|
28
|
-
"DS1102",
|
|
29
|
-
"DS1103",
|
|
30
|
-
"DS1104",
|
|
31
|
-
"DS1201",
|
|
32
|
-
"DS1203",
|
|
33
|
-
"DS1204",
|
|
34
|
-
"DS1301",
|
|
35
|
-
"DS1302",
|
|
36
|
-
"DS1303",
|
|
37
|
-
"DS1501",
|
|
38
|
-
"DS1502",
|
|
39
|
-
"DS1601",
|
|
40
|
-
"DS1605",
|
|
41
|
-
"DS1701",
|
|
42
|
-
"DS1702",
|
|
43
|
-
"DS1703",
|
|
44
|
-
"DS1704",
|
|
45
|
-
"DS1705",
|
|
46
|
-
"DS1801",
|
|
47
|
-
"DS1802",
|
|
48
|
-
"DS1803",
|
|
49
|
-
"DS1805",
|
|
50
|
-
"DS1807",
|
|
51
|
-
"DS1809",
|
|
52
|
-
];
|
|
52
|
+
export const LIVE_CODES = ROWS.map((one) => one.code);
|
|
53
|
+
/** The issue-kind codes whose severity the Contract fixes, in ascending order. */
|
|
54
|
+
export const FIXED_SEVERITY_CODES = ROWS.filter((one) => one.fixed).map((one) => one.code);
|
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
|
-
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|