@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 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 2.2.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`, `print-retained` 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.
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-roots` print what their row of the table says and exit 0, or 1 when `print-roots` reports a `DS1704`. `analyze`, `explain`, `print-retained`, `describe`, any other invocation and no arguments print the usage line and exit 2.
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 symbol an exemption class held back, with the class |
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
 
@@ -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 with the configured test-file patterns while its
123
- * view is open; the projects then merge into one matrix in discovery order, which is
124
- * swept under the input the caller decided once for the run. A project that carries
125
- * an error ends the run before any project is swept.
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 configured = readProjects(engine, host, scope, config, (project, read) => {
130
- const resolved = references(project, read.held, targetRoot, {
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
- return {
134
- configuration: read.configuration,
135
- symbols: read.held.symbols,
140
+ const configured = {
141
+ configuration: projectRead.configuration,
142
+ symbols: projectRead.held.symbols,
136
143
  references: resolved.references,
137
- roots: read.rooted.liveUnderReachability,
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
- return { matrix, sweep: sweepMatrix(matrix, input) };
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 code join it.
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.symbols.map(() => false);
18
- for (const id of input.marked) {
19
- const at = graph.at(id);
20
- if (at !== OUTSIDE) {
21
- marked[at] = true;
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, marked, input.mode);
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, marked);
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 marked declaration is live by a mechanism the analysis cannot see, so
76
- * no mode withholds a reference it makes, while a root reaches through the references the
77
- * mode counts. The marks run first, so what they reach is expanded under the wider rule.
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, marked, mode) {
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 = (held) => {
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 (!held && mode.production && edge.test) {
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
- marked.forEach((mark, at) => {
112
- if (mark) {
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 admitted.
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, marked) {
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 || marked[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;
@@ -4,4 +4,4 @@
4
4
  * independently of the analyzer's own version, which is the one the host answers
5
5
  * from the package the analyzer was installed from.
6
6
  */
7
- export const CONTRACT_VERSION = "2.2.0";
7
+ export const CONTRACT_VERSION = "3.0.0";
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.1.0",
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.13.5",
35
+ "@types/node": "24.19.0",
36
36
  "eslint": "10.11.0",
37
- "fast-check": "4.10.1",
38
- "prettier": "3.9.8",
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.0",
41
- "vitest": "5.0.1"
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 with the configured test-file patterns while its
204
- * view is open; the projects then merge into one matrix in discovery order, which is
205
- * swept under the input the caller decided once for the run. A project that carries
206
- * an error ends the run before any project is swept.
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 configured = readProjects(engine, host, scope, config, (project, read): Configured => {
217
- const resolved = references(project, read.held, targetRoot, {
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
- return {
221
- configuration: read.configuration,
222
- symbols: read.held.symbols,
240
+ const configured: Configured = {
241
+ configuration: projectRead.configuration,
242
+ symbols: projectRead.held.symbols,
223
243
  references: resolved.references,
224
- roots: read.rooted.liveUnderReachability,
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
- return { matrix, sweep: sweepMatrix(matrix, input) };
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 code join it.
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.symbols.map(() => false);
76
- for (const id of input.marked) {
77
- const at = graph.at(id);
78
- if (at !== OUTSIDE) {
79
- marked[at] = true;
80
- }
81
- }
82
- // Every root and every mark seeds reachability. A root of a kind that names a caller,
83
- // and a mark, are live under reference counting as well; a root that only supposes a
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, marked, input.mode);
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) => graph.subject[at] === true && (liveUnder[at]?.length ?? 0) < 2,
132
+ (_symbol, at) =>
133
+ graph.subject[at] === true && exempt[at] !== true && (liveUnder[at]?.length ?? 0) < 2,
113
134
  );
114
- const testOfDeadCode = admitTestsOfDeadCode(graph, dead, marked);
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 marked declaration is live by a mechanism the analysis cannot see, so
141
- * no mode withholds a reference it makes, while a root reaches through the references the
142
- * mode counts. The marks run first, so what they reach is expanded under the wider rule.
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, marked: readonly boolean[], mode: Mode): readonly boolean[] {
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 = (held: boolean): void => {
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 (!held && mode.production && edge.test) {
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
- marked.forEach((mark, at) => {
179
- if (mark) {
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 admitted.
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
- marked: readonly boolean[],
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 || marked[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
@@ -4,4 +4,4 @@
4
4
  * independently of the analyzer's own version, which is the one the host answers
5
5
  * from the package the analyzer was installed from.
6
6
  */
7
- export const CONTRACT_VERSION = "2.2.0";
7
+ export const CONTRACT_VERSION = "3.0.0";