@cplieger/deadset-ts 3.1.0 → 3.2.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 +3 -3
- package/dist/src/analysis.js +27 -13
- package/dist/src/exempt-classes.js +36 -0
- package/dist/src/exempt.js +132 -0
- package/dist/src/run.js +33 -1
- package/dist/src/sweep.js +35 -25
- package/dist/src/version.js +1 -1
- package/jsr.json +1 -1
- package/package.json +6 -6
- package/src/analysis.ts +41 -14
- package/src/exempt-classes.ts +69 -0
- package/src/exempt.ts +200 -0
- package/src/run.ts +39 -1
- package/src/sweep.ts +60 -25
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ deadset-ts is a command-line analyzer for TypeScript and JavaScript projects. It
|
|
|
8
8
|
|
|
9
9
|
deadset-ts is report-only. It never edits source, and it refuses any `--fix` flag.
|
|
10
10
|
|
|
11
|
-
**Status: pre-release.** This version implements contract
|
|
11
|
+
**Status: pre-release.** This version implements contract 3.0.0 and ships the analyzer's foundation: one compiler session per run over the projects `analysis.configurations` declares, in the order it lists them, each a compiler configuration file below the target, or, where it declares none, every `tsconfig` project discovered under the target (the explicit scope, every `tsconfig*.json`, each project's `references`), a fail-closed read of every project's diagnostics, configuration decoding against the contract's closed key list with `print-config` and its provenance, `print-projects`, the report-only guard, the symbol inventory of every declaration a project's own files hold down to its class and type members, the one-pass reference resolution over that inventory, which resolves each file's identifiers in batches ([batch-cap calibration](docs/batch-cap-calibration.md) is the measurement behind the batch size), and the root set `print-roots` prints. A root is a declaration the analysis keeps live without a reference: what a file exports when the manifest names it through `main`, `module`, `types`, `bin` or `exports`, or when `ts.entry_files` matches it; a library target's published API; the configuration, setup and test files of Vitest, Stryker and Playwright, the flat configuration of ESLint, and a worker or service worker a call addresses by a string literal; and every declaration a `roots.patterns` entry names. An entry that names nothing is reported as `DS1704` and fails the run. The entry-point conventions of other tools are not read, and `DECLINED_CONVENTIONS` lists each one with the reason. The analysis is reached through the command, and through `run()` for a caller that runs the command in-process; no other verb reports findings yet, so `analyze`, `explain` and `describe` exit with a usage message. The one runtime dependency is the `typescript` package at exactly 7.0.2, installed under the `@typescript/native` name until TypeScript 7.1 ships a compiler API the tooling can share.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -28,7 +28,7 @@ The npm package carries the command and the library as JavaScript that the compi
|
|
|
28
28
|
npx deadset-ts version
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
`version` prints the analyzer version and the Contract version it implements, and exits 0. `print-config`, `print-projects` and `print-
|
|
31
|
+
`version` prints the analyzer version and the Contract version it implements, and exits 0. `print-config`, `print-projects`, `print-roots` and `print-retained` print what their row of the table says and exit 0, or 1 when `print-roots` reports a `DS1704`, and `print-retained` exits 2 when an `exemptions.disabled` entry is not an exemption class. `analyze`, `explain`, `describe`, any other invocation and no arguments print the usage line and exit 2.
|
|
32
32
|
|
|
33
33
|
| Verb | Job |
|
|
34
34
|
| ---------------- | ------------------------------------------------------------------------------- |
|
|
@@ -37,7 +37,7 @@ npx deadset-ts version
|
|
|
37
37
|
| `print-config` | Print the resolved configuration and where each setting came from |
|
|
38
38
|
| `print-projects` | Print the identifier of every project a run analyzes |
|
|
39
39
|
| `print-roots` | Print every declaration the analysis keeps live without a reference, and why |
|
|
40
|
-
| `print-retained` | Print every
|
|
40
|
+
| `print-retained` | Print every declaration an exemption held back: each class, its site and detail |
|
|
41
41
|
| `describe` | Describe the analyzer: version, Contract version, supported issue kinds |
|
|
42
42
|
| `version` | Print the analyzer and Contract versions |
|
|
43
43
|
|
package/dist/src/analysis.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* project's checker, and that holds only for views the session itself hands out.
|
|
9
9
|
*/
|
|
10
10
|
import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.js";
|
|
11
|
+
import { computeExemptions, disabledClasses, exemptionsOf, retainedIn, } from "./exempt.js";
|
|
11
12
|
import { inventory } from "./inventory.js";
|
|
12
13
|
import { readManifest } from "./manifest.js";
|
|
13
14
|
import { matrixOf, sweepMatrix } from "./matrix.js";
|
|
@@ -117,27 +118,40 @@ export function runRoots(engine, host, scope, config, provenance) {
|
|
|
117
118
|
findings: unmatchedRoots(unmatched, documentOf(host, provenance, ROOTS_SETTING, targetRoot)),
|
|
118
119
|
};
|
|
119
120
|
}
|
|
121
|
+
/** The exemption classes this analyzer detects, each by its detector; any other retains nothing. */
|
|
122
|
+
const DETECTORS = new Map();
|
|
120
123
|
/**
|
|
121
124
|
* Sweeps the run the scope and the configuration describe. Each project's references
|
|
122
|
-
* are read against its own inventory
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
125
|
+
* and exemptions are read against its own inventory while its view is open; the
|
|
126
|
+
* projects then merge into one matrix in the run's order, which is swept under the
|
|
127
|
+
* caller's marks and mode with the exemptions every project found. A record found in
|
|
128
|
+
* one configuration holds in every configuration that holds its declaration, because
|
|
129
|
+
* an exemption is evidence of a use the analysis cannot see and a use in one
|
|
130
|
+
* configuration is a use. A project that carries an error ends the run before any
|
|
131
|
+
* project is swept.
|
|
126
132
|
*/
|
|
127
|
-
export function runSweep(engine, host, scope, config, input) {
|
|
133
|
+
export function runSweep(engine, host, scope, config, input, detectors = DETECTORS) {
|
|
128
134
|
const targetRoot = scope.target.path;
|
|
129
|
-
const
|
|
130
|
-
|
|
135
|
+
const disabled = disabledClasses(config);
|
|
136
|
+
const read = readProjects(engine, host, scope, config, (project, projectRead) => {
|
|
137
|
+
const resolved = references(project, projectRead.held, targetRoot, {
|
|
131
138
|
testFiles: config.ts.testFiles,
|
|
132
139
|
});
|
|
133
|
-
|
|
134
|
-
configuration:
|
|
135
|
-
symbols:
|
|
140
|
+
const configured = {
|
|
141
|
+
configuration: projectRead.configuration,
|
|
142
|
+
symbols: projectRead.held.symbols,
|
|
136
143
|
references: resolved.references,
|
|
137
|
-
roots:
|
|
144
|
+
roots: projectRead.rooted.liveUnderReachability,
|
|
138
145
|
testFiles: resolved.testFilePaths,
|
|
139
146
|
};
|
|
147
|
+
const exempt = computeExemptions({ project, held: projectRead.held, targetRoot }, detectors, {
|
|
148
|
+
disabled,
|
|
149
|
+
mode: input.mode,
|
|
150
|
+
testFiles: new Set(resolved.testFilePaths),
|
|
151
|
+
});
|
|
152
|
+
return { configured, exempt };
|
|
140
153
|
});
|
|
141
|
-
const matrix = matrixOf(configured);
|
|
142
|
-
|
|
154
|
+
const matrix = matrixOf(read.map((one) => one.configured));
|
|
155
|
+
const swept = { ...input, exempt: exemptionsOf(read.flatMap((one) => one.exempt)) };
|
|
156
|
+
return { matrix, sweep: sweepMatrix(matrix, swept), retained: retainedIn(matrix, swept) };
|
|
143
157
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exemption classes of the Contract's vocabulary, in its order: each class's name,
|
|
3
|
+
* the languages it runs on and the confidence it is recorded at.
|
|
4
|
+
*
|
|
5
|
+
* The rows are written out rather than read from the Contract at run time, for the
|
|
6
|
+
* reason the fixed severity codes are, and a test holds them equal to the Contract
|
|
7
|
+
* release this analyzer implements. A class name is a type, so a detector table or a
|
|
8
|
+
* record naming a class the vocabulary does not hold, or one that runs on another
|
|
9
|
+
* language only, does not compile.
|
|
10
|
+
*/
|
|
11
|
+
/** Every class of the vocabulary, in the order the Contract lists them. */
|
|
12
|
+
export const EXEMPTION_CLASSES = [
|
|
13
|
+
{ class: "interface-satisfaction", languages: ["go", "ts"], confidence: "certain" },
|
|
14
|
+
{ class: "encoding-reflection", languages: ["go"], confidence: "certain" },
|
|
15
|
+
{ class: "format-verb-contract", languages: ["go"], confidence: "certain" },
|
|
16
|
+
{ class: "errors-duck-typing", languages: ["go"], confidence: "certain" },
|
|
17
|
+
{ class: "enum-group", languages: ["go", "ts"], confidence: "certain" },
|
|
18
|
+
{ class: "generated-file", languages: ["go"], confidence: "certain" },
|
|
19
|
+
{ class: "linkname-cgo-asm-plugin", languages: ["go"], confidence: "certain" },
|
|
20
|
+
{ class: "template-field", languages: ["go", "ts"], confidence: "possible" },
|
|
21
|
+
{ class: "reflective-lookup", languages: ["go", "ts"], confidence: "possible" },
|
|
22
|
+
{ class: "decorator", languages: ["ts"], confidence: "certain" },
|
|
23
|
+
{ class: "injection-container", languages: ["ts"], confidence: "certain" },
|
|
24
|
+
{ class: "framework-lifecycle", languages: ["ts"], confidence: "certain" },
|
|
25
|
+
{ class: "serialization-contract", languages: ["ts"], confidence: "certain" },
|
|
26
|
+
];
|
|
27
|
+
function runsOnTS(row) {
|
|
28
|
+
const languages = row.languages;
|
|
29
|
+
return languages.includes("ts");
|
|
30
|
+
}
|
|
31
|
+
/** The classes that run on TypeScript, in the vocabulary's order. */
|
|
32
|
+
export const TS_EXEMPTION_CLASSES = EXEMPTION_CLASSES.filter(runsOnTS).map((row) => row.class);
|
|
33
|
+
/** Whether one name is a class of the vocabulary, whichever language it runs on. */
|
|
34
|
+
export function isExemptionClass(name) {
|
|
35
|
+
return EXEMPTION_CLASSES.some((row) => row.class === name);
|
|
36
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exemption framework: the classes of reason for which a declaration the reference
|
|
3
|
+
* graph alone would report is live all the same. A class is policy over what one
|
|
4
|
+
* project's checker answers, and its detector names the declarations it holds back;
|
|
5
|
+
* this module runs the detectors, decides which of their records stand, and answers
|
|
6
|
+
* which records held something back. It names no class's detector: the composition
|
|
7
|
+
* hands it the table of the classes it has.
|
|
8
|
+
*/
|
|
9
|
+
import { ConfigError } from "./config.js";
|
|
10
|
+
import { EXEMPTION_CLASSES, isExemptionClass, TS_EXEMPTION_CLASSES, } from "./exempt-classes.js";
|
|
11
|
+
import { byPosition, positionKey } from "./position.js";
|
|
12
|
+
import { sweep } from "./sweep.js";
|
|
13
|
+
/**
|
|
14
|
+
* The classes `exemptions.disabled` switches off. A name is refused unless the
|
|
15
|
+
* vocabulary holds it; a class that runs on another language only is admitted and
|
|
16
|
+
* switches nothing off here, so one configuration serves every analyzer.
|
|
17
|
+
*/
|
|
18
|
+
export function disabledClasses(config) {
|
|
19
|
+
const disabled = new Set();
|
|
20
|
+
for (const name of config.exemptionsDisabled) {
|
|
21
|
+
if (!isExemptionClass(name)) {
|
|
22
|
+
const known = EXEMPTION_CLASSES.map((row) => row.class).join(", ");
|
|
23
|
+
throw new ConfigError("malformed", "exemptions.disabled", `exemptions.disabled: ${JSON.stringify(name)} is not an exemption class: the classes are ${known}`);
|
|
24
|
+
}
|
|
25
|
+
disabled.add(name);
|
|
26
|
+
}
|
|
27
|
+
return disabled;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The records one project's detectors found, each class in the vocabulary's order and
|
|
31
|
+
* none of a switched-off class. Under a production mode a record whose evidence is
|
|
32
|
+
* written in a test file holds nothing, as a reference a test file makes is none there.
|
|
33
|
+
*/
|
|
34
|
+
export function computeExemptions(input, detectors, holding) {
|
|
35
|
+
const found = [];
|
|
36
|
+
for (const exemptionClass of TS_EXEMPTION_CLASSES) {
|
|
37
|
+
const detect = detectors.get(exemptionClass);
|
|
38
|
+
if (detect === undefined || holding.disabled.has(exemptionClass)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (const evidence of detect(input)) {
|
|
42
|
+
if (holding.mode.production && holding.testFiles.has(evidence.site.path)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
found.push({
|
|
46
|
+
id: evidence.id,
|
|
47
|
+
class: exemptionClass,
|
|
48
|
+
detail: evidence.detail,
|
|
49
|
+
site: evidence.site,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return exemptionsOf(found);
|
|
54
|
+
}
|
|
55
|
+
/** Two strings ordered bytewise. */
|
|
56
|
+
function compare(a, b) {
|
|
57
|
+
if (a === b) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
return a < b ? -1 : 1;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Records as a run holds them: ordered by site, then class, then declaration, and one
|
|
64
|
+
* per declaration, class and detail, the one at the first site. A fact found at many
|
|
65
|
+
* sites, or by several projects, is one record.
|
|
66
|
+
*/
|
|
67
|
+
export function exemptionsOf(found) {
|
|
68
|
+
const ordered = [...found].sort((a, b) => byPosition(a.site, b.site) || compare(a.class, b.class) || compare(a.id, b.id));
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
return ordered.filter((record) => {
|
|
71
|
+
const fact = [record.id, record.class, record.detail].join("\u0000");
|
|
72
|
+
if (seen.has(fact)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
seen.add(fact);
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** The records per declaration, each declaration's in the order given. */
|
|
80
|
+
function byDeclaration(records) {
|
|
81
|
+
const grouped = new Map();
|
|
82
|
+
for (const record of records) {
|
|
83
|
+
const held = grouped.get(record.id);
|
|
84
|
+
if (held === undefined) {
|
|
85
|
+
grouped.set(record.id, [record]);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
held.push(record);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return grouped;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The records that held back a declaration some configuration of the run would
|
|
95
|
+
* otherwise judge dead: each configuration's graph is swept once more with no
|
|
96
|
+
* exemption and the marks it was given, and a record is retained when one of those
|
|
97
|
+
* sweeps judged its declaration a candidate. A record that holds a declaration back in
|
|
98
|
+
* one configuration is retained whatever another configuration answers about it, as a
|
|
99
|
+
* run of that configuration alone would report the declaration without it. A record
|
|
100
|
+
* on a declaration that a relation or a mark holds live in every configuration held
|
|
101
|
+
* nothing back. The order is the declarations' site order, and for one declaration
|
|
102
|
+
* the input's.
|
|
103
|
+
*/
|
|
104
|
+
export function retainedIn(matrix, input) {
|
|
105
|
+
const exempt = input.exempt ?? [];
|
|
106
|
+
if (exempt.length === 0) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
const bare = { marked: input.marked, mode: input.mode };
|
|
110
|
+
const dead = new Set(matrix.graphs.flatMap((graph) => sweep(graph, bare).candidates.map((candidate) => candidate.id)));
|
|
111
|
+
const held = byDeclaration(exempt.filter((record) => dead.has(record.id)));
|
|
112
|
+
return matrix.union.symbols.flatMap((symbol) => held.get(symbol.id) ?? []);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* One line per held-back declaration, in site order: its reference, then each record's
|
|
116
|
+
* class, site and detail, all separated by tabs.
|
|
117
|
+
*/
|
|
118
|
+
export function retainedLines(symbols, retained) {
|
|
119
|
+
const held = byDeclaration(retained);
|
|
120
|
+
return symbols.flatMap((symbol) => {
|
|
121
|
+
const records = held.get(symbol.id);
|
|
122
|
+
if (records === undefined) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const fields = records.flatMap((record) => [
|
|
126
|
+
record.class,
|
|
127
|
+
positionKey(record.site),
|
|
128
|
+
record.detail,
|
|
129
|
+
]);
|
|
130
|
+
return [[symbol.ref, ...fields].join("\t")];
|
|
131
|
+
});
|
|
132
|
+
}
|
package/dist/src/run.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { runRoots } from "./analysis.js";
|
|
1
|
+
import { runRoots, runSweep } from "./analysis.js";
|
|
2
2
|
import { ConfigError, REPOSITORY_DOCUMENT } from "./config.js";
|
|
3
3
|
import { diagnosticErrors, discoverProjects, DiscoveryError, renderDiagnostic, } from "./discover.js";
|
|
4
|
+
import { retainedLines } from "./exempt.js";
|
|
4
5
|
import { joinPath, resolvePath } from "./paths.js";
|
|
5
6
|
import { printConfig } from "./print.js";
|
|
6
7
|
import { resolve, resolveMatrix } from "./resolve.js";
|
|
@@ -174,6 +175,9 @@ export function run(args, out, err, host, openClient = (collectTiming) => openEn
|
|
|
174
175
|
if (verb === "print-roots") {
|
|
175
176
|
return printRootsVerb(args.slice(1), out, err, host, openClient);
|
|
176
177
|
}
|
|
178
|
+
if (verb === "print-retained") {
|
|
179
|
+
return printRetainedVerb(args.slice(1), out, err, host, openClient);
|
|
180
|
+
}
|
|
177
181
|
if (verb !== undefined && VERBS.includes(verb)) {
|
|
178
182
|
err.write(`deadset-ts: ${verb} is not implemented\n`);
|
|
179
183
|
return EXIT_USAGE;
|
|
@@ -276,6 +280,34 @@ function printRootsVerb(args, out, err, host, openClient) {
|
|
|
276
280
|
return refuse(error, err);
|
|
277
281
|
}
|
|
278
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Writes every declaration an exemption held back in the production sweep a report is
|
|
285
|
+
* built from, one per line, and nothing where no exemption held one back. One fact
|
|
286
|
+
* several configurations found is one record, so the line names no configuration. The
|
|
287
|
+
* line shape is this command's own rather than a Contract format, so that sort and cut
|
|
288
|
+
* read it.
|
|
289
|
+
*/
|
|
290
|
+
function printRetainedVerb(args, out, err, host, openClient) {
|
|
291
|
+
let engine;
|
|
292
|
+
try {
|
|
293
|
+
const options = readOptions(args);
|
|
294
|
+
const { config } = resolve(inputsOf(host, options));
|
|
295
|
+
const scope = scopeOf(host, options);
|
|
296
|
+
engine = openClient(false);
|
|
297
|
+
const swept = runSweep(engine, host, scope, config, {
|
|
298
|
+
marked: [],
|
|
299
|
+
mode: { production: true },
|
|
300
|
+
});
|
|
301
|
+
for (const line of retainedLines(swept.matrix.union.symbols, swept.retained)) {
|
|
302
|
+
out.write(`${line}\n`);
|
|
303
|
+
}
|
|
304
|
+
return EXIT_CLEAN;
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
engine?.close();
|
|
308
|
+
return refuse(error, err);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
279
311
|
/** Turns one refusal into the exit code the Contract's table gives it. */
|
|
280
312
|
function refuse(error, err) {
|
|
281
313
|
if (error instanceof ConfigError) {
|
package/dist/src/sweep.js
CHANGED
|
@@ -11,19 +11,16 @@ import { namesACaller, OUTSIDE } from "./graph.js";
|
|
|
11
11
|
*
|
|
12
12
|
* The order is the order the answers depend on: the callers and the marks are live
|
|
13
13
|
* before either relation runs, the candidate set is every judged declaration at least
|
|
14
|
-
* one relation does not hold live, and the tests of dead
|
|
14
|
+
* one relation does not hold live and no exemption holds back, and the tests of dead
|
|
15
|
+
* code join it.
|
|
15
16
|
*/
|
|
16
17
|
export function sweep(graph, input) {
|
|
17
|
-
const marked = graph.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
// Every root and every mark seeds reachability. A root of a kind that names a caller,
|
|
25
|
-
// and a mark, are live under reference counting as well; a root that only supposes a
|
|
26
|
-
// caller is not.
|
|
18
|
+
const marked = flagged(graph, input.marked);
|
|
19
|
+
const exempt = flagged(graph, (input.exempt ?? []).map((record) => record.id));
|
|
20
|
+
const held = marked.map((mark, at) => mark || exempt[at] === true);
|
|
21
|
+
// Every root, every mark and every exemption seeds reachability. A root of a kind that
|
|
22
|
+
// names a caller, and a mark, are live under reference counting as well; a root that
|
|
23
|
+
// only supposes a caller is not, and neither is an exemption.
|
|
27
24
|
const called = graph.symbols.map(() => false);
|
|
28
25
|
for (const root of graph.rooted) {
|
|
29
26
|
if (namesACaller(root.kind)) {
|
|
@@ -37,7 +34,7 @@ export function sweep(graph, input) {
|
|
|
37
34
|
}
|
|
38
35
|
return input.mode.production ? made.production : made.production + made.test;
|
|
39
36
|
};
|
|
40
|
-
const reached = reachable(graph,
|
|
37
|
+
const reached = reachable(graph, held, input.mode);
|
|
41
38
|
const liveUnder = graph.symbols.map((_symbol, at) => {
|
|
42
39
|
const relations = [];
|
|
43
40
|
if (marked[at] === true || called[at] === true || counted(at) > 0) {
|
|
@@ -48,8 +45,8 @@ export function sweep(graph, input) {
|
|
|
48
45
|
}
|
|
49
46
|
return relations;
|
|
50
47
|
});
|
|
51
|
-
const dead = graph.symbols.map((_symbol, at) => graph.subject[at] === true && (liveUnder[at]?.length ?? 0) < 2);
|
|
52
|
-
const testOfDeadCode = admitTestsOfDeadCode(graph, dead,
|
|
48
|
+
const dead = graph.symbols.map((_symbol, at) => graph.subject[at] === true && exempt[at] !== true && (liveUnder[at]?.length ?? 0) < 2);
|
|
49
|
+
const testOfDeadCode = admitTestsOfDeadCode(graph, dead, held);
|
|
53
50
|
const candidates = [];
|
|
54
51
|
const live = new Map();
|
|
55
52
|
graph.symbols.forEach((symbol, at) => {
|
|
@@ -70,13 +67,25 @@ export function sweep(graph, input) {
|
|
|
70
67
|
});
|
|
71
68
|
return { liveUnder: live, candidates };
|
|
72
69
|
}
|
|
70
|
+
/** Per declaration of the graph, whether one of the identifiers names it. */
|
|
71
|
+
function flagged(graph, ids) {
|
|
72
|
+
const flags = graph.symbols.map(() => false);
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
const at = graph.at(id);
|
|
75
|
+
if (at !== OUTSIDE) {
|
|
76
|
+
flags[at] = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return flags;
|
|
80
|
+
}
|
|
73
81
|
/**
|
|
74
82
|
* Per declaration, whether a seed reaches it. The two seeds reach through different
|
|
75
|
-
* reference sets: a
|
|
76
|
-
* no mode withholds a reference it makes,
|
|
77
|
-
*
|
|
83
|
+
* reference sets: a held declaration, marked or exempt, is live by a mechanism the
|
|
84
|
+
* analysis cannot see, so no mode withholds a reference it makes, a test declaration's
|
|
85
|
+
* included, while a root reaches through the references the mode counts. The held seed
|
|
86
|
+
* runs first, so what it reaches is expanded under the wider rule.
|
|
78
87
|
*/
|
|
79
|
-
function reachable(graph,
|
|
88
|
+
function reachable(graph, held, mode) {
|
|
80
89
|
const reached = graph.symbols.map(() => false);
|
|
81
90
|
const expanded = graph.symbols.map(() => false);
|
|
82
91
|
const queue = [];
|
|
@@ -98,18 +107,18 @@ function reachable(graph, marked, mode) {
|
|
|
98
107
|
enter(exported, false);
|
|
99
108
|
}
|
|
100
109
|
};
|
|
101
|
-
const walk = (
|
|
110
|
+
const walk = (holding) => {
|
|
102
111
|
for (let at = queue.pop(); at !== undefined; at = queue.pop()) {
|
|
103
112
|
for (const edge of graph.out[at] ?? []) {
|
|
104
|
-
if (!
|
|
113
|
+
if (!holding && mode.production && edge.test) {
|
|
105
114
|
continue;
|
|
106
115
|
}
|
|
107
116
|
enter(edge.to, !edge.evaluation);
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
119
|
};
|
|
111
|
-
|
|
112
|
-
if (
|
|
120
|
+
held.forEach((hold, at) => {
|
|
121
|
+
if (hold) {
|
|
113
122
|
enter(at, false);
|
|
114
123
|
}
|
|
115
124
|
});
|
|
@@ -124,12 +133,13 @@ function reachable(graph, marked, mode) {
|
|
|
124
133
|
* Admits every test declaration whose set of referenced production declarations is
|
|
125
134
|
* non-empty and wholly dead, and returns which it admitted. The set is read from every
|
|
126
135
|
* reference the test makes, not the ones the mode counts: a production sweep counts none,
|
|
127
|
-
* which is what makes the targets dead. A test referencing one live target is never
|
|
136
|
+
* which is what makes the targets dead. A test referencing one live target is never
|
|
137
|
+
* admitted, and neither is a marked or exempt test.
|
|
128
138
|
*/
|
|
129
|
-
function admitTestsOfDeadCode(graph, dead,
|
|
139
|
+
function admitTestsOfDeadCode(graph, dead, held) {
|
|
130
140
|
const admitted = graph.symbols.map(() => false);
|
|
131
141
|
graph.symbols.forEach((_symbol, at) => {
|
|
132
|
-
if (graph.test[at] !== true || graph.subject[at] !== true ||
|
|
142
|
+
if (graph.test[at] !== true || graph.subject[at] !== true || held[at] === true) {
|
|
133
143
|
return;
|
|
134
144
|
}
|
|
135
145
|
let targets = 0;
|
package/dist/src/version.js
CHANGED
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/deadset-ts",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Deterministic dead-code analysis for TypeScript, class and type members included",
|
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@eslint/js": "10.0.1",
|
|
35
|
-
"@types/node": "24.
|
|
35
|
+
"@types/node": "24.19.0",
|
|
36
36
|
"eslint": "10.11.0",
|
|
37
|
-
"fast-check": "4.10.
|
|
38
|
-
"prettier": "3.9.
|
|
37
|
+
"fast-check": "4.10.2",
|
|
38
|
+
"prettier": "3.9.9",
|
|
39
39
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
40
|
-
"typescript-eslint": "8.70.
|
|
41
|
-
"vitest": "5.0.
|
|
40
|
+
"typescript-eslint": "8.70.1",
|
|
41
|
+
"vitest": "5.0.2"
|
|
42
42
|
},
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public"
|
package/src/analysis.ts
CHANGED
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
import type { Diagnostic } from "@typescript/native/unstable/sync";
|
|
12
12
|
import type { Config, Provenance } from "./config.ts";
|
|
13
13
|
import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.ts";
|
|
14
|
+
import {
|
|
15
|
+
computeExemptions,
|
|
16
|
+
disabledClasses,
|
|
17
|
+
exemptionsOf,
|
|
18
|
+
retainedIn,
|
|
19
|
+
type Detectors,
|
|
20
|
+
} from "./exempt.ts";
|
|
14
21
|
import type { Finding } from "./finding.ts";
|
|
15
22
|
import type { Host } from "./host.ts";
|
|
16
23
|
import { inventory, type Inventory } from "./inventory.ts";
|
|
@@ -22,7 +29,7 @@ import { references } from "./references.ts";
|
|
|
22
29
|
import { roots, unmatchedEverywhere, unmatchedRoots, type RootKind, type Roots } from "./roots.ts";
|
|
23
30
|
import type { Scope } from "./scope.ts";
|
|
24
31
|
import { diagnosticsOf, runSession, type Engine, type ProjectView } from "./session.ts";
|
|
25
|
-
import type { SweepInput } from "./sweep.ts";
|
|
32
|
+
import type { Exemption, SweepInput } from "./sweep.ts";
|
|
26
33
|
|
|
27
34
|
/** The setting whose source decides where a finding about a configured root sits. */
|
|
28
35
|
const ROOTS_SETTING = "roots.patterns";
|
|
@@ -196,35 +203,55 @@ export function runRoots(
|
|
|
196
203
|
export interface RunSweep {
|
|
197
204
|
readonly matrix: Matrix;
|
|
198
205
|
readonly sweep: SweepResult;
|
|
206
|
+
/**
|
|
207
|
+
* The exemption records that held back a declaration some configuration of the run
|
|
208
|
+
* would otherwise judge dead, in the declarations' site order.
|
|
209
|
+
*/
|
|
210
|
+
readonly retained: readonly Exemption[];
|
|
199
211
|
}
|
|
200
212
|
|
|
213
|
+
/** The exemption classes this analyzer detects, each by its detector; any other retains nothing. */
|
|
214
|
+
const DETECTORS: Detectors = new Map();
|
|
215
|
+
|
|
201
216
|
/**
|
|
202
217
|
* Sweeps the run the scope and the configuration describe. Each project's references
|
|
203
|
-
* are read against its own inventory
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
218
|
+
* and exemptions are read against its own inventory while its view is open; the
|
|
219
|
+
* projects then merge into one matrix in the run's order, which is swept under the
|
|
220
|
+
* caller's marks and mode with the exemptions every project found. A record found in
|
|
221
|
+
* one configuration holds in every configuration that holds its declaration, because
|
|
222
|
+
* an exemption is evidence of a use the analysis cannot see and a use in one
|
|
223
|
+
* configuration is a use. A project that carries an error ends the run before any
|
|
224
|
+
* project is swept.
|
|
207
225
|
*/
|
|
208
226
|
export function runSweep(
|
|
209
227
|
engine: Engine,
|
|
210
228
|
host: Host,
|
|
211
229
|
scope: Scope,
|
|
212
230
|
config: Config,
|
|
213
|
-
input: SweepInput,
|
|
231
|
+
input: Pick<SweepInput, "marked" | "mode">,
|
|
232
|
+
detectors: Detectors = DETECTORS,
|
|
214
233
|
): RunSweep {
|
|
215
234
|
const targetRoot = scope.target.path;
|
|
216
|
-
const
|
|
217
|
-
|
|
235
|
+
const disabled = disabledClasses(config);
|
|
236
|
+
const read = readProjects(engine, host, scope, config, (project, projectRead) => {
|
|
237
|
+
const resolved = references(project, projectRead.held, targetRoot, {
|
|
218
238
|
testFiles: config.ts.testFiles,
|
|
219
239
|
});
|
|
220
|
-
|
|
221
|
-
configuration:
|
|
222
|
-
symbols:
|
|
240
|
+
const configured: Configured = {
|
|
241
|
+
configuration: projectRead.configuration,
|
|
242
|
+
symbols: projectRead.held.symbols,
|
|
223
243
|
references: resolved.references,
|
|
224
|
-
roots:
|
|
244
|
+
roots: projectRead.rooted.liveUnderReachability,
|
|
225
245
|
testFiles: resolved.testFilePaths,
|
|
226
246
|
};
|
|
247
|
+
const exempt = computeExemptions({ project, held: projectRead.held, targetRoot }, detectors, {
|
|
248
|
+
disabled,
|
|
249
|
+
mode: input.mode,
|
|
250
|
+
testFiles: new Set(resolved.testFilePaths),
|
|
251
|
+
});
|
|
252
|
+
return { configured, exempt };
|
|
227
253
|
});
|
|
228
|
-
const matrix = matrixOf(configured);
|
|
229
|
-
|
|
254
|
+
const matrix = matrixOf(read.map((one) => one.configured));
|
|
255
|
+
const swept: SweepInput = { ...input, exempt: exemptionsOf(read.flatMap((one) => one.exempt)) };
|
|
256
|
+
return { matrix, sweep: sweepMatrix(matrix, swept), retained: retainedIn(matrix, swept) };
|
|
230
257
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exemption classes of the Contract's vocabulary, in its order: each class's name,
|
|
3
|
+
* the languages it runs on and the confidence it is recorded at.
|
|
4
|
+
*
|
|
5
|
+
* The rows are written out rather than read from the Contract at run time, for the
|
|
6
|
+
* reason the fixed severity codes are, and a test holds them equal to the Contract
|
|
7
|
+
* release this analyzer implements. A class name is a type, so a detector table or a
|
|
8
|
+
* record naming a class the vocabulary does not hold, or one that runs on another
|
|
9
|
+
* language only, does not compile.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Confidence, Language } from "./config.ts";
|
|
13
|
+
|
|
14
|
+
/** One class of the vocabulary. */
|
|
15
|
+
interface ExemptionClassRow {
|
|
16
|
+
readonly class: string;
|
|
17
|
+
readonly languages: readonly Language[];
|
|
18
|
+
readonly confidence: Confidence;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Every class of the vocabulary, in the order the Contract lists them. */
|
|
22
|
+
export const EXEMPTION_CLASSES = [
|
|
23
|
+
{ class: "interface-satisfaction", languages: ["go", "ts"], confidence: "certain" },
|
|
24
|
+
{ class: "encoding-reflection", languages: ["go"], confidence: "certain" },
|
|
25
|
+
{ class: "format-verb-contract", languages: ["go"], confidence: "certain" },
|
|
26
|
+
{ class: "errors-duck-typing", languages: ["go"], confidence: "certain" },
|
|
27
|
+
{ class: "enum-group", languages: ["go", "ts"], confidence: "certain" },
|
|
28
|
+
{ class: "generated-file", languages: ["go"], confidence: "certain" },
|
|
29
|
+
{ class: "linkname-cgo-asm-plugin", languages: ["go"], confidence: "certain" },
|
|
30
|
+
{ class: "template-field", languages: ["go", "ts"], confidence: "possible" },
|
|
31
|
+
{ class: "reflective-lookup", languages: ["go", "ts"], confidence: "possible" },
|
|
32
|
+
{ class: "decorator", languages: ["ts"], confidence: "certain" },
|
|
33
|
+
{ class: "injection-container", languages: ["ts"], confidence: "certain" },
|
|
34
|
+
{ class: "framework-lifecycle", languages: ["ts"], confidence: "certain" },
|
|
35
|
+
{ class: "serialization-contract", languages: ["ts"], confidence: "certain" },
|
|
36
|
+
] as const satisfies readonly ExemptionClassRow[];
|
|
37
|
+
|
|
38
|
+
type Row = (typeof EXEMPTION_CLASSES)[number];
|
|
39
|
+
|
|
40
|
+
/** The name of a class of the vocabulary. */
|
|
41
|
+
export type ExemptionClass = Row["class"];
|
|
42
|
+
|
|
43
|
+
/** The classes of the rows that list one language. */
|
|
44
|
+
type ClassesOf<R, L extends Language> = R extends {
|
|
45
|
+
readonly class: infer C;
|
|
46
|
+
readonly languages: readonly (infer Listed)[];
|
|
47
|
+
}
|
|
48
|
+
? L extends Listed
|
|
49
|
+
? C
|
|
50
|
+
: never
|
|
51
|
+
: never;
|
|
52
|
+
|
|
53
|
+
/** The name of a class that runs on TypeScript, the only classes this analyzer records. */
|
|
54
|
+
export type TSExemptionClass = ClassesOf<Row, "ts">;
|
|
55
|
+
|
|
56
|
+
function runsOnTS(row: Row): row is Extract<Row, { readonly class: TSExemptionClass }> {
|
|
57
|
+
const languages: readonly Language[] = row.languages;
|
|
58
|
+
return languages.includes("ts");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The classes that run on TypeScript, in the vocabulary's order. */
|
|
62
|
+
export const TS_EXEMPTION_CLASSES: readonly TSExemptionClass[] = EXEMPTION_CLASSES.filter(
|
|
63
|
+
runsOnTS,
|
|
64
|
+
).map((row) => row.class);
|
|
65
|
+
|
|
66
|
+
/** Whether one name is a class of the vocabulary, whichever language it runs on. */
|
|
67
|
+
export function isExemptionClass(name: string): name is ExemptionClass {
|
|
68
|
+
return EXEMPTION_CLASSES.some((row) => row.class === name);
|
|
69
|
+
}
|
package/src/exempt.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exemption framework: the classes of reason for which a declaration the reference
|
|
3
|
+
* graph alone would report is live all the same. A class is policy over what one
|
|
4
|
+
* project's checker answers, and its detector names the declarations it holds back;
|
|
5
|
+
* this module runs the detectors, decides which of their records stand, and answers
|
|
6
|
+
* which records held something back. It names no class's detector: the composition
|
|
7
|
+
* hands it the table of the classes it has.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Config } from "./config.ts";
|
|
11
|
+
import { ConfigError } from "./config.ts";
|
|
12
|
+
import {
|
|
13
|
+
EXEMPTION_CLASSES,
|
|
14
|
+
isExemptionClass,
|
|
15
|
+
TS_EXEMPTION_CLASSES,
|
|
16
|
+
type ExemptionClass,
|
|
17
|
+
type TSExemptionClass,
|
|
18
|
+
} from "./exempt-classes.ts";
|
|
19
|
+
import type { Inventory, InventorySymbol } from "./inventory.ts";
|
|
20
|
+
import type { Matrix } from "./matrix.ts";
|
|
21
|
+
import { byPosition, positionKey, type Position } from "./position.ts";
|
|
22
|
+
import type { ProjectView } from "./session.ts";
|
|
23
|
+
import { sweep, type Exemption, type Mode, type SweepInput } from "./sweep.ts";
|
|
24
|
+
|
|
25
|
+
/** One declaration a detector holds back, and where and why. */
|
|
26
|
+
export interface Evidence {
|
|
27
|
+
/** The identifier of the declaration held back. */
|
|
28
|
+
readonly id: string;
|
|
29
|
+
/** One clause naming the relation, then the thing it relates to. */
|
|
30
|
+
readonly detail: string;
|
|
31
|
+
readonly site: Position;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a detector reads of one project, while the project's view is open. */
|
|
35
|
+
export interface DetectorInput<Brand> {
|
|
36
|
+
readonly project: ProjectView<Brand>;
|
|
37
|
+
readonly held: Inventory;
|
|
38
|
+
readonly targetRoot: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One class's detection over one project. The framework records each piece of evidence
|
|
43
|
+
* under the class the table names the detector by, so a detector names no class.
|
|
44
|
+
*/
|
|
45
|
+
export type Detector = <Brand>(input: DetectorInput<Brand>) => readonly Evidence[];
|
|
46
|
+
|
|
47
|
+
/** The detector of each class one composition implements. */
|
|
48
|
+
export type Detectors = ReadonlyMap<TSExemptionClass, Detector>;
|
|
49
|
+
|
|
50
|
+
/** What decides which records of one project stand. */
|
|
51
|
+
export interface Holding {
|
|
52
|
+
/** The classes the configuration switches off, whose detectors do not run. */
|
|
53
|
+
readonly disabled: ReadonlySet<ExemptionClass>;
|
|
54
|
+
readonly mode: Mode;
|
|
55
|
+
/** The paths the reference pass classified as test files. */
|
|
56
|
+
readonly testFiles: ReadonlySet<string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The classes `exemptions.disabled` switches off. A name is refused unless the
|
|
61
|
+
* vocabulary holds it; a class that runs on another language only is admitted and
|
|
62
|
+
* switches nothing off here, so one configuration serves every analyzer.
|
|
63
|
+
*/
|
|
64
|
+
export function disabledClasses(config: Config): ReadonlySet<ExemptionClass> {
|
|
65
|
+
const disabled = new Set<ExemptionClass>();
|
|
66
|
+
for (const name of config.exemptionsDisabled) {
|
|
67
|
+
if (!isExemptionClass(name)) {
|
|
68
|
+
const known = EXEMPTION_CLASSES.map((row) => row.class).join(", ");
|
|
69
|
+
throw new ConfigError(
|
|
70
|
+
"malformed",
|
|
71
|
+
"exemptions.disabled",
|
|
72
|
+
`exemptions.disabled: ${JSON.stringify(name)} is not an exemption class: the classes are ${known}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
disabled.add(name);
|
|
76
|
+
}
|
|
77
|
+
return disabled;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The records one project's detectors found, each class in the vocabulary's order and
|
|
82
|
+
* none of a switched-off class. Under a production mode a record whose evidence is
|
|
83
|
+
* written in a test file holds nothing, as a reference a test file makes is none there.
|
|
84
|
+
*/
|
|
85
|
+
export function computeExemptions<Brand>(
|
|
86
|
+
input: DetectorInput<Brand>,
|
|
87
|
+
detectors: Detectors,
|
|
88
|
+
holding: Holding,
|
|
89
|
+
): readonly Exemption[] {
|
|
90
|
+
const found: Exemption[] = [];
|
|
91
|
+
for (const exemptionClass of TS_EXEMPTION_CLASSES) {
|
|
92
|
+
const detect = detectors.get(exemptionClass);
|
|
93
|
+
if (detect === undefined || holding.disabled.has(exemptionClass)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const evidence of detect(input)) {
|
|
97
|
+
if (holding.mode.production && holding.testFiles.has(evidence.site.path)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
found.push({
|
|
101
|
+
id: evidence.id,
|
|
102
|
+
class: exemptionClass,
|
|
103
|
+
detail: evidence.detail,
|
|
104
|
+
site: evidence.site,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return exemptionsOf(found);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Two strings ordered bytewise. */
|
|
112
|
+
function compare(a: string, b: string): number {
|
|
113
|
+
if (a === b) {
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
return a < b ? -1 : 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Records as a run holds them: ordered by site, then class, then declaration, and one
|
|
121
|
+
* per declaration, class and detail, the one at the first site. A fact found at many
|
|
122
|
+
* sites, or by several projects, is one record.
|
|
123
|
+
*/
|
|
124
|
+
export function exemptionsOf(found: readonly Exemption[]): readonly Exemption[] {
|
|
125
|
+
const ordered = [...found].sort(
|
|
126
|
+
(a, b) => byPosition(a.site, b.site) || compare(a.class, b.class) || compare(a.id, b.id),
|
|
127
|
+
);
|
|
128
|
+
const seen = new Set<string>();
|
|
129
|
+
return ordered.filter((record) => {
|
|
130
|
+
const fact = [record.id, record.class, record.detail].join("\u0000");
|
|
131
|
+
if (seen.has(fact)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
seen.add(fact);
|
|
135
|
+
return true;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The records per declaration, each declaration's in the order given. */
|
|
140
|
+
function byDeclaration(records: readonly Exemption[]): ReadonlyMap<string, readonly Exemption[]> {
|
|
141
|
+
const grouped = new Map<string, Exemption[]>();
|
|
142
|
+
for (const record of records) {
|
|
143
|
+
const held = grouped.get(record.id);
|
|
144
|
+
if (held === undefined) {
|
|
145
|
+
grouped.set(record.id, [record]);
|
|
146
|
+
} else {
|
|
147
|
+
held.push(record);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return grouped;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The records that held back a declaration some configuration of the run would
|
|
155
|
+
* otherwise judge dead: each configuration's graph is swept once more with no
|
|
156
|
+
* exemption and the marks it was given, and a record is retained when one of those
|
|
157
|
+
* sweeps judged its declaration a candidate. A record that holds a declaration back in
|
|
158
|
+
* one configuration is retained whatever another configuration answers about it, as a
|
|
159
|
+
* run of that configuration alone would report the declaration without it. A record
|
|
160
|
+
* on a declaration that a relation or a mark holds live in every configuration held
|
|
161
|
+
* nothing back. The order is the declarations' site order, and for one declaration
|
|
162
|
+
* the input's.
|
|
163
|
+
*/
|
|
164
|
+
export function retainedIn(matrix: Matrix, input: SweepInput): readonly Exemption[] {
|
|
165
|
+
const exempt = input.exempt ?? [];
|
|
166
|
+
if (exempt.length === 0) {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
const bare: SweepInput = { marked: input.marked, mode: input.mode };
|
|
170
|
+
const dead = new Set(
|
|
171
|
+
matrix.graphs.flatMap((graph) =>
|
|
172
|
+
sweep(graph, bare).candidates.map((candidate) => candidate.id),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
const held = byDeclaration(exempt.filter((record) => dead.has(record.id)));
|
|
176
|
+
return matrix.union.symbols.flatMap((symbol) => held.get(symbol.id) ?? []);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One line per held-back declaration, in site order: its reference, then each record's
|
|
181
|
+
* class, site and detail, all separated by tabs.
|
|
182
|
+
*/
|
|
183
|
+
export function retainedLines(
|
|
184
|
+
symbols: readonly InventorySymbol[],
|
|
185
|
+
retained: readonly Exemption[],
|
|
186
|
+
): readonly string[] {
|
|
187
|
+
const held = byDeclaration(retained);
|
|
188
|
+
return symbols.flatMap((symbol) => {
|
|
189
|
+
const records = held.get(symbol.id);
|
|
190
|
+
if (records === undefined) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const fields = records.flatMap((record) => [
|
|
194
|
+
record.class,
|
|
195
|
+
positionKey(record.site),
|
|
196
|
+
record.detail,
|
|
197
|
+
]);
|
|
198
|
+
return [[symbol.ref, ...fields].join("\t")];
|
|
199
|
+
});
|
|
200
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { runRoots, type RunRoot } from "./analysis.ts";
|
|
1
|
+
import { runRoots, runSweep, type RunRoot } from "./analysis.ts";
|
|
2
2
|
import { ConfigError, REPOSITORY_DOCUMENT, type Inputs } from "./config.ts";
|
|
3
3
|
import {
|
|
4
4
|
diagnosticErrors,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
DiscoveryError,
|
|
7
7
|
renderDiagnostic,
|
|
8
8
|
} from "./discover.ts";
|
|
9
|
+
import { retainedLines } from "./exempt.ts";
|
|
9
10
|
import type { Host } from "./host.ts";
|
|
10
11
|
import { joinPath, resolvePath } from "./paths.ts";
|
|
11
12
|
import { printConfig } from "./print.ts";
|
|
@@ -221,6 +222,9 @@ export function run(
|
|
|
221
222
|
if (verb === "print-roots") {
|
|
222
223
|
return printRootsVerb(args.slice(1), out, err, host, openClient);
|
|
223
224
|
}
|
|
225
|
+
if (verb === "print-retained") {
|
|
226
|
+
return printRetainedVerb(args.slice(1), out, err, host, openClient);
|
|
227
|
+
}
|
|
224
228
|
if (verb !== undefined && (VERBS as readonly string[]).includes(verb)) {
|
|
225
229
|
err.write(`deadset-ts: ${verb} is not implemented\n`);
|
|
226
230
|
return EXIT_USAGE;
|
|
@@ -337,6 +341,40 @@ function printRootsVerb(
|
|
|
337
341
|
}
|
|
338
342
|
}
|
|
339
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Writes every declaration an exemption held back in the production sweep a report is
|
|
346
|
+
* built from, one per line, and nothing where no exemption held one back. One fact
|
|
347
|
+
* several configurations found is one record, so the line names no configuration. The
|
|
348
|
+
* line shape is this command's own rather than a Contract format, so that sort and cut
|
|
349
|
+
* read it.
|
|
350
|
+
*/
|
|
351
|
+
function printRetainedVerb(
|
|
352
|
+
args: readonly string[],
|
|
353
|
+
out: Writer,
|
|
354
|
+
err: Writer,
|
|
355
|
+
host: Host,
|
|
356
|
+
openClient: (collectTiming: boolean) => Engine,
|
|
357
|
+
): number {
|
|
358
|
+
let engine: Engine | undefined;
|
|
359
|
+
try {
|
|
360
|
+
const options = readOptions(args);
|
|
361
|
+
const { config } = resolve(inputsOf(host, options));
|
|
362
|
+
const scope = scopeOf(host, options);
|
|
363
|
+
engine = openClient(false);
|
|
364
|
+
const swept = runSweep(engine, host, scope, config, {
|
|
365
|
+
marked: [],
|
|
366
|
+
mode: { production: true },
|
|
367
|
+
});
|
|
368
|
+
for (const line of retainedLines(swept.matrix.union.symbols, swept.retained)) {
|
|
369
|
+
out.write(`${line}\n`);
|
|
370
|
+
}
|
|
371
|
+
return EXIT_CLEAN;
|
|
372
|
+
} catch (error: unknown) {
|
|
373
|
+
engine?.close();
|
|
374
|
+
return refuse(error, err);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
340
378
|
/** Turns one refusal into the exit code the Contract's table gives it. */
|
|
341
379
|
function refuse(error: unknown, err: Writer): number {
|
|
342
380
|
if (error instanceof ConfigError) {
|
package/src/sweep.ts
CHANGED
|
@@ -6,11 +6,24 @@
|
|
|
6
6
|
* declaration only unreachable ones reference is live under the first, dead under the second.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { TSExemptionClass } from "./exempt-classes.ts";
|
|
9
10
|
import { namesACaller, OUTSIDE, type Graph } from "./graph.ts";
|
|
11
|
+
import type { Position } from "./position.ts";
|
|
10
12
|
|
|
11
13
|
/** The liveness relation that decided a declaration, as a finding spells it. */
|
|
12
14
|
export type Relation = "reference-counting" | "reachability";
|
|
13
15
|
|
|
16
|
+
/** One declaration an exemption class holds back, and the evidence it was found by. */
|
|
17
|
+
export interface Exemption {
|
|
18
|
+
/** The identifier of the declaration held back. */
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly class: TSExemptionClass;
|
|
21
|
+
/** One clause naming the relation, then the thing it relates to: `passed to JSON.stringify`. */
|
|
22
|
+
readonly detail: string;
|
|
23
|
+
/** Where the evidence is written. */
|
|
24
|
+
readonly site: Position;
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
/**
|
|
15
28
|
* The run's reference mode, decided once by the composition and read by every stage
|
|
16
29
|
* that counts references.
|
|
@@ -31,6 +44,13 @@ export interface SweepInput {
|
|
|
31
44
|
* nothing only a marked declaration references is dead.
|
|
32
45
|
*/
|
|
33
46
|
readonly marked: readonly string[];
|
|
47
|
+
/**
|
|
48
|
+
* The exemptions the run computed. An exempt declaration is never a candidate, and it
|
|
49
|
+
* seeds reachability as a mark does, so nothing it references is dead; it is live
|
|
50
|
+
* under reference counting only where something references it. Absent, the sweep
|
|
51
|
+
* holds nothing back.
|
|
52
|
+
*/
|
|
53
|
+
readonly exempt?: readonly Exemption[];
|
|
34
54
|
readonly mode: Mode;
|
|
35
55
|
}
|
|
36
56
|
|
|
@@ -69,19 +89,19 @@ export interface Liveness {
|
|
|
69
89
|
*
|
|
70
90
|
* The order is the order the answers depend on: the callers and the marks are live
|
|
71
91
|
* before either relation runs, the candidate set is every judged declaration at least
|
|
72
|
-
* one relation does not hold live, and the tests of dead
|
|
92
|
+
* one relation does not hold live and no exemption holds back, and the tests of dead
|
|
93
|
+
* code join it.
|
|
73
94
|
*/
|
|
74
95
|
export function sweep(graph: Graph, input: SweepInput): Liveness {
|
|
75
|
-
const marked = graph.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// caller is not.
|
|
96
|
+
const marked = flagged(graph, input.marked);
|
|
97
|
+
const exempt = flagged(
|
|
98
|
+
graph,
|
|
99
|
+
(input.exempt ?? []).map((record) => record.id),
|
|
100
|
+
);
|
|
101
|
+
const held = marked.map((mark, at) => mark || exempt[at] === true);
|
|
102
|
+
// Every root, every mark and every exemption seeds reachability. A root of a kind that
|
|
103
|
+
// names a caller, and a mark, are live under reference counting as well; a root that
|
|
104
|
+
// only supposes a caller is not, and neither is an exemption.
|
|
85
105
|
const called = graph.symbols.map(() => false);
|
|
86
106
|
for (const root of graph.rooted) {
|
|
87
107
|
if (namesACaller(root.kind)) {
|
|
@@ -96,7 +116,7 @@ export function sweep(graph: Graph, input: SweepInput): Liveness {
|
|
|
96
116
|
}
|
|
97
117
|
return input.mode.production ? made.production : made.production + made.test;
|
|
98
118
|
};
|
|
99
|
-
const reached = reachable(graph,
|
|
119
|
+
const reached = reachable(graph, held, input.mode);
|
|
100
120
|
const liveUnder = graph.symbols.map((_symbol, at): readonly Relation[] => {
|
|
101
121
|
const relations: Relation[] = [];
|
|
102
122
|
if (marked[at] === true || called[at] === true || counted(at) > 0) {
|
|
@@ -109,9 +129,10 @@ export function sweep(graph: Graph, input: SweepInput): Liveness {
|
|
|
109
129
|
});
|
|
110
130
|
|
|
111
131
|
const dead = graph.symbols.map(
|
|
112
|
-
(_symbol, at) =>
|
|
132
|
+
(_symbol, at) =>
|
|
133
|
+
graph.subject[at] === true && exempt[at] !== true && (liveUnder[at]?.length ?? 0) < 2,
|
|
113
134
|
);
|
|
114
|
-
const testOfDeadCode = admitTestsOfDeadCode(graph, dead,
|
|
135
|
+
const testOfDeadCode = admitTestsOfDeadCode(graph, dead, held);
|
|
115
136
|
|
|
116
137
|
const candidates: Candidate[] = [];
|
|
117
138
|
const live = new Map<string, readonly Relation[]>();
|
|
@@ -135,13 +156,26 @@ export function sweep(graph: Graph, input: SweepInput): Liveness {
|
|
|
135
156
|
return { liveUnder: live, candidates };
|
|
136
157
|
}
|
|
137
158
|
|
|
159
|
+
/** Per declaration of the graph, whether one of the identifiers names it. */
|
|
160
|
+
function flagged(graph: Graph, ids: readonly string[]): boolean[] {
|
|
161
|
+
const flags = graph.symbols.map(() => false);
|
|
162
|
+
for (const id of ids) {
|
|
163
|
+
const at = graph.at(id);
|
|
164
|
+
if (at !== OUTSIDE) {
|
|
165
|
+
flags[at] = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return flags;
|
|
169
|
+
}
|
|
170
|
+
|
|
138
171
|
/**
|
|
139
172
|
* Per declaration, whether a seed reaches it. The two seeds reach through different
|
|
140
|
-
* reference sets: a
|
|
141
|
-
* no mode withholds a reference it makes,
|
|
142
|
-
*
|
|
173
|
+
* reference sets: a held declaration, marked or exempt, is live by a mechanism the
|
|
174
|
+
* analysis cannot see, so no mode withholds a reference it makes, a test declaration's
|
|
175
|
+
* included, while a root reaches through the references the mode counts. The held seed
|
|
176
|
+
* runs first, so what it reaches is expanded under the wider rule.
|
|
143
177
|
*/
|
|
144
|
-
function reachable(graph: Graph,
|
|
178
|
+
function reachable(graph: Graph, held: readonly boolean[], mode: Mode): readonly boolean[] {
|
|
145
179
|
const reached = graph.symbols.map(() => false);
|
|
146
180
|
const expanded = graph.symbols.map(() => false);
|
|
147
181
|
const queue: number[] = [];
|
|
@@ -164,10 +198,10 @@ function reachable(graph: Graph, marked: readonly boolean[], mode: Mode): readon
|
|
|
164
198
|
enter(exported, false);
|
|
165
199
|
}
|
|
166
200
|
};
|
|
167
|
-
const walk = (
|
|
201
|
+
const walk = (holding: boolean): void => {
|
|
168
202
|
for (let at = queue.pop(); at !== undefined; at = queue.pop()) {
|
|
169
203
|
for (const edge of graph.out[at] ?? []) {
|
|
170
|
-
if (!
|
|
204
|
+
if (!holding && mode.production && edge.test) {
|
|
171
205
|
continue;
|
|
172
206
|
}
|
|
173
207
|
enter(edge.to, !edge.evaluation);
|
|
@@ -175,8 +209,8 @@ function reachable(graph: Graph, marked: readonly boolean[], mode: Mode): readon
|
|
|
175
209
|
}
|
|
176
210
|
};
|
|
177
211
|
|
|
178
|
-
|
|
179
|
-
if (
|
|
212
|
+
held.forEach((hold, at) => {
|
|
213
|
+
if (hold) {
|
|
180
214
|
enter(at, false);
|
|
181
215
|
}
|
|
182
216
|
});
|
|
@@ -192,16 +226,17 @@ function reachable(graph: Graph, marked: readonly boolean[], mode: Mode): readon
|
|
|
192
226
|
* Admits every test declaration whose set of referenced production declarations is
|
|
193
227
|
* non-empty and wholly dead, and returns which it admitted. The set is read from every
|
|
194
228
|
* reference the test makes, not the ones the mode counts: a production sweep counts none,
|
|
195
|
-
* which is what makes the targets dead. A test referencing one live target is never
|
|
229
|
+
* which is what makes the targets dead. A test referencing one live target is never
|
|
230
|
+
* admitted, and neither is a marked or exempt test.
|
|
196
231
|
*/
|
|
197
232
|
function admitTestsOfDeadCode(
|
|
198
233
|
graph: Graph,
|
|
199
234
|
dead: boolean[],
|
|
200
|
-
|
|
235
|
+
held: readonly boolean[],
|
|
201
236
|
): readonly boolean[] {
|
|
202
237
|
const admitted = graph.symbols.map(() => false);
|
|
203
238
|
graph.symbols.forEach((_symbol, at) => {
|
|
204
|
-
if (graph.test[at] !== true || graph.subject[at] !== true ||
|
|
239
|
+
if (graph.test[at] !== true || graph.subject[at] !== true || held[at] === true) {
|
|
205
240
|
return;
|
|
206
241
|
}
|
|
207
242
|
let targets = 0;
|
package/src/version.ts
CHANGED