@cplieger/deadset-ts 2.3.0 → 3.1.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.1.0 and ships the analyzer's foundation: one compiler session per run over 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, 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 inventory and the reference pass are library functions the package exports; 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 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.
12
12
 
13
13
  ## Install
14
14
 
@@ -35,7 +35,7 @@ npx deadset-ts version
35
35
  | `analyze` | Analyze a project and print the findings |
36
36
  | `explain` | Explain one finding: the relation, the roots and the references that decided it |
37
37
  | `print-config` | Print the resolved configuration and where each setting came from |
38
- | `print-projects` | Print the compiler configuration of every project a run analyzes |
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
40
  | `print-retained` | Print every symbol an exemption class held back, with the class |
41
41
  | `describe` | Describe the analyzer: version, Contract version, supported issue kinds |
@@ -49,7 +49,7 @@ npx deadset-ts version
49
49
 
50
50
  ## API
51
51
 
52
- The package exports the command line and the analyzer's own stages, for callers that embed them. Every one of them reads the platform through a `Host`: the filesystem, the directory relative paths resolve against, and the version of the package the analyzer was installed from. `bin/node-host.ts` is the Node one the command line binds; a caller embedding the analyzer, or running it on another platform, supplies its own and supplies its own version with it.
52
+ The package exports the command line, for callers that run it in-process, and the vocabularies and helpers its output is spelled with. The command line reads the platform through a `Host`: the filesystem, the directory relative paths resolve against, and the version of the package the analyzer was installed from. `bin/node-host.ts` is the Node one the command line binds; a caller embedding the analyzer, or running it on another platform, supplies its own and supplies its own version with it.
53
53
 
54
54
  The command line:
55
55
 
@@ -62,18 +62,6 @@ The platform:
62
62
  - `Host`: the filesystem a run reads, the directory it reads relative paths against, and the analyzer's own version.
63
63
  - `DirectoryEntry`, `PathKind`: one entry of a directory read, and what one path names.
64
64
 
65
- The symbol inventory:
66
-
67
- - `inventory(project, host, targetRoot)`: every declaration one project's own files hold, in position order, down to class and type members.
68
- - `nodeKey(file, node)`: the key one declaration is recorded under.
69
- - `Inventory`, `InventorySymbol`, `InventoryCost`, `SymbolKind`, `Visibility`: one project's inventory, one declaration of it, what reading it cost, and the two vocabularies a declaration is described by.
70
-
71
- The reference pass:
72
-
73
- - `references(project, held, targetRoot, options)`: every reference from one project's own files to a declaration of its inventory, in position order, with the rules that classified files as test files.
74
- - `DEFAULT_BATCH_CAP`: the batch size the reference pass takes when its caller passes none; it is an option of the pass, not a configuration key. [Batch-cap calibration](docs/batch-cap-calibration.md) is the measurement that set it.
75
- - `Reference`, `References`, `ReferenceOptions`, `ReferenceCost`, `TestFileRule`, `Resolution`, `Use`: one reference, one project's set of them, how a run resolves them, what that cost, one test-file rule, which accessor answered a reference, and whether it reads or writes.
76
-
77
65
  Positions:
78
66
 
79
67
  - `renderPosition(file, root, offset)`: one compiler offset as a path, a line and a column, the column counting UTF-16 code units.
@@ -89,9 +77,12 @@ Stable symbol references:
89
77
  - `REF_EXPRESSIONS`: one regular expression per form the grammar defines.
90
78
  - `Module`, `Component`, `Fragment`, `DependencySection`: the module half of a reference, one component of its fragment, what the fragment names, and the manifest section a dependency was declared in.
91
79
 
92
- Versions:
80
+ Entry points:
93
81
 
94
82
  - `DECLINED_CONVENTIONS`, `DeclinedConvention`: the entry-point conventions of other tools the analysis does not read, one row per tool or convention with the reason.
83
+
84
+ Versions:
85
+
95
86
  - `CONTRACT_VERSION`: the contract version this analyzer implements.
96
87
 
97
88
  ## Contributing
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Where an import or export chain leads in one project: from one symbol to each
3
+ * declaration of the target it is, and along the chain of aliases it heads to each
4
+ * declaration every link of that chain is.
5
+ */
6
+ import { SymbolFlags } from "@typescript/native/unstable/sync";
7
+ import { nodeKey } from "./inventory.js";
8
+ /**
9
+ * The chain walks over one project, against the declarations of `held`, the same
10
+ * project's inventory.
11
+ *
12
+ * A declaration handle carries the file it is in, so whether it is one of the
13
+ * project's own files is answered before the handle is resolved: a declaration of a
14
+ * library file is never fetched to find out that the inventory does not hold it.
15
+ */
16
+ export function aliasChains(project, held) {
17
+ const ownFiles = new Set(project.ownSourceFiles().map((file) => file.fileName));
18
+ const steps = new Map();
19
+ const chains = new Map();
20
+ let asked = 0;
21
+ const declarationsOf = (symbol) => {
22
+ const ids = [];
23
+ for (const handle of symbol.declarations) {
24
+ if (!ownFiles.has(handle.path)) {
25
+ continue;
26
+ }
27
+ const node = project.declarationAt(handle)?.node;
28
+ const id = node === undefined ? undefined : held.declarations.get(nodeKey(node.getSourceFile(), node));
29
+ if (id !== undefined && !ids.includes(id)) {
30
+ ids.push(id);
31
+ }
32
+ }
33
+ return ids;
34
+ };
35
+ /**
36
+ * What one alias names, one link along. Only a symbol the binder flagged an alias is
37
+ * asked, which is the condition the step asserts on: a default export of an
38
+ * expression is written in an alias form and names nothing declared elsewhere, so its
39
+ * chain ends at itself.
40
+ */
41
+ const stepOf = (symbol) => {
42
+ if ((symbol.flags & SymbolFlags.Alias) === 0) {
43
+ return undefined;
44
+ }
45
+ if (steps.has(symbol.id)) {
46
+ return steps.get(symbol.id);
47
+ }
48
+ const next = project.aliasStepOf(symbol);
49
+ asked += 1;
50
+ steps.set(symbol.id, next);
51
+ return next;
52
+ };
53
+ const chainOf = (symbol) => {
54
+ const cached = chains.get(symbol.id);
55
+ if (cached !== undefined) {
56
+ return cached;
57
+ }
58
+ const targets = [];
59
+ const walked = new Set();
60
+ let at = symbol;
61
+ let stepped = false;
62
+ while (at !== undefined && !walked.has(at.id)) {
63
+ walked.add(at.id);
64
+ for (const id of declarationsOf(at)) {
65
+ if (!targets.some((target) => target.id === id)) {
66
+ targets.push({ id, stepped });
67
+ }
68
+ }
69
+ at = stepOf(at);
70
+ stepped = true;
71
+ }
72
+ chains.set(symbol.id, targets);
73
+ return targets;
74
+ };
75
+ const nextDeclared = (symbol) => {
76
+ const walked = new Set([symbol.id]);
77
+ let at = stepOf(symbol);
78
+ while (at !== undefined && !walked.has(at.id)) {
79
+ walked.add(at.id);
80
+ const ids = declarationsOf(at);
81
+ if (ids.length > 0) {
82
+ return ids;
83
+ }
84
+ at = stepOf(at);
85
+ }
86
+ return [];
87
+ };
88
+ return {
89
+ declarationsOf,
90
+ chainOf,
91
+ nextDeclared,
92
+ get steps() {
93
+ return asked;
94
+ },
95
+ };
96
+ }
@@ -10,8 +10,10 @@
10
10
  import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.js";
11
11
  import { inventory } from "./inventory.js";
12
12
  import { readManifest } from "./manifest.js";
13
+ import { matrixOf, sweepMatrix } from "./matrix.js";
13
14
  import { relativePath, resolvePath } from "./paths.js";
14
15
  import { byPosition } from "./position.js";
16
+ import { references } from "./references.js";
15
17
  import { roots, unmatchedEverywhere, unmatchedRoots } from "./roots.js";
16
18
  import { diagnosticsOf, runSession } from "./session.js";
17
19
  /** The setting whose source decides where a finding about a configured root sits. */
@@ -38,19 +40,17 @@ function compare(a, b) {
38
40
  return a < b ? -1 : 1;
39
41
  }
40
42
  /**
41
- * The root set of the run the scope and the configuration describe.
42
- *
43
- * Every project is read for errors before anything else, and a project carrying one
44
- * fails the run with no root set, as every verb that reads declarations does. A
45
- * configured string is a finding when it names nothing in every project, and the
46
- * finding sits in the document that declared the roots.
43
+ * Reads every project the scope discovers, in one snapshot: each is checked for errors
44
+ * first, then enumerated, then rooted, and `stage` reads the rest of what it needs from
45
+ * the project while its view is open. A project carrying an error fails the run with no
46
+ * answer, as every verb that reads declarations does.
47
47
  */
48
- export function runRoots(engine, host, scope, config, provenance) {
48
+ function readProjects(engine, host, scope, config, stage) {
49
49
  const targetRoot = scope.target.path;
50
- const discovered = discoverProjects(engine, host, scope);
51
- const manifest = readManifest(host, targetRoot);
50
+ const discovered = discoverProjects(engine, host, scope, config.analysis.configurations);
51
+ const ids = new Map(discovered.projects.map((project) => [project.configFile, project.id]));
52
52
  const options = {
53
- manifest,
53
+ manifest: readManifest(host, targetRoot),
54
54
  patterns: config.rootPatterns,
55
55
  entryFiles: config.ts.entryFiles,
56
56
  testFiles: config.ts.testFiles,
@@ -64,18 +64,28 @@ export function runRoots(engine, host, scope, config, provenance) {
64
64
  return undefined;
65
65
  }
66
66
  const held = inventory(project, host, targetRoot);
67
- return { held, rooted: roots(project, held, targetRoot, options) };
67
+ const rooted = roots(project, held, targetRoot, options);
68
+ const configuration = ids.get(project.configFile) ?? project.configFile;
69
+ return stage(project, { configuration, held, rooted });
68
70
  });
69
71
  if (failures.length > 0) {
70
72
  throw new DiscoveryError(`${String(failures.length)} error(s); no answer was produced`, failures);
71
73
  }
74
+ return projects.filter((answer) => answer !== undefined);
75
+ }
76
+ /**
77
+ * The root set of the run the scope and the configuration describe.
78
+ *
79
+ * A configured string is a finding when it names nothing in every project, and the
80
+ * finding sits in the document that declared the roots.
81
+ */
82
+ export function runRoots(engine, host, scope, config, provenance) {
83
+ const targetRoot = scope.target.path;
84
+ const projects = readProjects(engine, host, scope, config, (_project, read) => read);
72
85
  const configurations = [];
73
86
  const merged = new Map();
74
87
  for (const answer of projects) {
75
- if (answer === undefined) {
76
- continue;
77
- }
78
- const configuration = relativePath(targetRoot, answer.rooted.configFile) ?? answer.rooted.configFile;
88
+ const configuration = answer.configuration;
79
89
  configurations.push(configuration);
80
90
  const symbols = new Map(answer.held.symbols.map((symbol) => [symbol.id, symbol]));
81
91
  for (const root of answer.rooted.liveUnderReachability) {
@@ -100,10 +110,34 @@ export function runRoots(engine, host, scope, config, provenance) {
100
110
  }
101
111
  }
102
112
  const ordered = [...merged.values()].sort((a, b) => byPosition(a.position, b.position) || compare(a.kind, b.kind) || compare(a.source, b.source));
103
- const unmatched = unmatchedEverywhere(config.rootPatterns, projects.flatMap((answer) => (answer === undefined ? [] : [answer.rooted])));
113
+ const unmatched = unmatchedEverywhere(config.rootPatterns, projects.map((answer) => answer.rooted));
104
114
  return {
105
115
  configurations,
106
116
  roots: ordered,
107
117
  findings: unmatchedRoots(unmatched, documentOf(host, provenance, ROOTS_SETTING, targetRoot)),
108
118
  };
109
119
  }
120
+ /**
121
+ * 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.
126
+ */
127
+ export function runSweep(engine, host, scope, config, input) {
128
+ const targetRoot = scope.target.path;
129
+ const configured = readProjects(engine, host, scope, config, (project, read) => {
130
+ const resolved = references(project, read.held, targetRoot, {
131
+ testFiles: config.ts.testFiles,
132
+ });
133
+ return {
134
+ configuration: read.configuration,
135
+ symbols: read.held.symbols,
136
+ references: resolved.references,
137
+ roots: read.rooted.liveUnderReachability,
138
+ testFiles: resolved.testFilePaths,
139
+ };
140
+ });
141
+ const matrix = matrixOf(configured);
142
+ return { matrix, sweep: sweepMatrix(matrix, input) };
143
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The dead components of one graph: the strongly connected components of the subgraph
3
+ * the dead declarations induce, ordered so a report is worked from the top down, each
4
+ * with the root members a deletion starts at and what falls with it.
5
+ */
6
+ import { OUTSIDE } from "./graph.js";
7
+ /** The name every component identifier this analyzer mints starts with. */
8
+ const ANALYZER = "deadset-ts";
9
+ /** The fewest digits a component identifier's number is written with. */
10
+ const ID_DIGITS = 4;
11
+ /** The index of a declaration the component walk has not reached. */
12
+ const UNVISITED = -1;
13
+ /** What a report carries for one component: its roots and what falls, and its members in full. */
14
+ export function listing(component, cascade) {
15
+ return {
16
+ roots: component.roots,
17
+ members: cascade === "full" ? component.members : [],
18
+ symbolCount: component.falls.length,
19
+ deletableLines: component.deletableLines,
20
+ };
21
+ }
22
+ /**
23
+ * Groups the declarations `dead` marks into components and orders them so each precedes
24
+ * every component it reaches, two that neither reaches ordered by their first member's
25
+ * site. `dead` and `testOfDeadCode` hold one flag per declaration of the graph; the dead
26
+ * set arrives rather than being read from a sweep, so a set several sweeps intersect is
27
+ * grouped over this graph's edges the same way.
28
+ */
29
+ export function componentsOf(graph, dead, testOfDeadCode) {
30
+ const { at, adjacent } = deadSubgraph(graph, dead, testOfDeadCode);
31
+ if (at.length === 0) {
32
+ return [];
33
+ }
34
+ const { componentOf, members } = connected(adjacent);
35
+ const { edges, into } = condense(adjacent, componentOf, members.length);
36
+ const falls = fallSets(edges, members, into);
37
+ const outsideReference = externallyReferenced(adjacent, componentOf);
38
+ const idOf = (position) => graph.symbols[at[position] ?? 0]?.id ?? "";
39
+ return order(edges, members).map((group, place) => {
40
+ const own = members[group] ?? [];
41
+ const roots = own.filter((position) => {
42
+ // A dead member of a dead container is never a root: the container is where the
43
+ // deletion starts and the member falls with it.
44
+ const container = graph.parent[at[position] ?? 0] ?? OUTSIDE;
45
+ return outsideReference[position] !== true && dead[container] !== true;
46
+ });
47
+ const fallen = falls[group] ?? [];
48
+ return {
49
+ id: `${ANALYZER}/c-${String(place + 1).padStart(ID_DIGITS, "0")}`,
50
+ members: own.map(idOf),
51
+ roots: roots.map(idOf),
52
+ falls: fallen.map(idOf),
53
+ deletableLines: linesSpanned(fallen.map((position) => graph.symbols[at[position] ?? 0])),
54
+ };
55
+ });
56
+ }
57
+ /**
58
+ * The distinct source lines a set of declarations spans, each declaration at least the
59
+ * line it starts on. A member written inside its container's span adds no line, because
60
+ * deleting the container removes it.
61
+ */
62
+ function linesSpanned(symbols) {
63
+ const byPath = new Map();
64
+ for (const symbol of symbols) {
65
+ if (symbol === undefined) {
66
+ continue;
67
+ }
68
+ const span = [
69
+ symbol.position.line,
70
+ Math.max(symbol.position.line, symbol.endLine),
71
+ ];
72
+ const spans = byPath.get(symbol.position.path);
73
+ if (spans === undefined) {
74
+ byPath.set(symbol.position.path, [span]);
75
+ }
76
+ else {
77
+ spans.push(span);
78
+ }
79
+ }
80
+ let total = 0;
81
+ for (const spans of byPath.values()) {
82
+ spans.sort((a, b) => a[0] - b[0]);
83
+ let end = 0;
84
+ for (const [first, last] of spans) {
85
+ total += Math.max(0, last - Math.max(first - 1, end));
86
+ end = Math.max(end, last);
87
+ }
88
+ }
89
+ return total;
90
+ }
91
+ /**
92
+ * The dead declarations by their place in the subgraph, and the edges between them: each
93
+ * reference one makes to another, an edge each way between a dead member and its dead
94
+ * container (one direction alone leaves the member a component the container reaches),
95
+ * and an edge back from each production target of an admitted test, which puts the test in
96
+ * the component of the code it exercises because a subject never references its test.
97
+ */
98
+ function deadSubgraph(graph, dead, testOfDeadCode) {
99
+ const at = [];
100
+ const position = graph.symbols.map((_symbol, index) => {
101
+ if (dead[index] !== true) {
102
+ return UNVISITED;
103
+ }
104
+ at.push(index);
105
+ return at.length - 1;
106
+ });
107
+ const adjacent = at.map(() => []);
108
+ at.forEach((index, from) => {
109
+ for (const edge of graph.out[index] ?? []) {
110
+ const to = position[edge.to] ?? UNVISITED;
111
+ if (to === UNVISITED) {
112
+ continue;
113
+ }
114
+ adjacent[from]?.push(to);
115
+ if (testOfDeadCode[index] === true &&
116
+ graph.test[edge.to] !== true &&
117
+ graph.subject[edge.to] === true) {
118
+ adjacent[to]?.push(from);
119
+ }
120
+ }
121
+ const container = position[graph.parent[index] ?? OUTSIDE] ?? UNVISITED;
122
+ if (container !== UNVISITED) {
123
+ adjacent[from]?.push(container);
124
+ adjacent[container]?.push(from);
125
+ }
126
+ });
127
+ return { at, adjacent };
128
+ }
129
+ /**
130
+ * Each declaration's component and each component's members by site, by Tarjan's
131
+ * algorithm in one pass. The walk keeps its own stack of frames, so a chain of any length
132
+ * is walked without growing the call stack. Components are closed in an order in which
133
+ * each follows every component it reaches.
134
+ */
135
+ function connected(adjacent) {
136
+ const index = adjacent.map(() => UNVISITED);
137
+ const low = adjacent.map(() => UNVISITED);
138
+ const onStack = adjacent.map(() => false);
139
+ const stack = [];
140
+ const members = [];
141
+ let next = 0;
142
+ const open = (at, frames) => {
143
+ index[at] = next;
144
+ low[at] = next;
145
+ next += 1;
146
+ stack.push(at);
147
+ onStack[at] = true;
148
+ frames.push({ at, next: 0 });
149
+ };
150
+ adjacent.forEach((_edges, start) => {
151
+ if (index[start] !== UNVISITED) {
152
+ return;
153
+ }
154
+ const frames = [];
155
+ open(start, frames);
156
+ for (let frame = frames.at(-1); frame !== undefined; frame = frames.at(-1)) {
157
+ const edges = adjacent[frame.at] ?? [];
158
+ const to = edges[frame.next];
159
+ if (to !== undefined) {
160
+ frame.next += 1;
161
+ if (index[to] === UNVISITED) {
162
+ open(to, frames);
163
+ }
164
+ else if (onStack[to] === true) {
165
+ low[frame.at] = Math.min(low[frame.at] ?? 0, index[to] ?? 0);
166
+ }
167
+ continue;
168
+ }
169
+ frames.pop();
170
+ const parent = frames.at(-1);
171
+ if (parent !== undefined) {
172
+ low[parent.at] = Math.min(low[parent.at] ?? 0, low[frame.at] ?? 0);
173
+ }
174
+ if (low[frame.at] !== index[frame.at]) {
175
+ continue;
176
+ }
177
+ const group = [];
178
+ for (let top = stack.pop(); top !== undefined; top = stack.pop()) {
179
+ onStack[top] = false;
180
+ group.push(top);
181
+ if (top === frame.at) {
182
+ break;
183
+ }
184
+ }
185
+ members.push(group.sort((a, b) => a - b));
186
+ }
187
+ });
188
+ const componentOf = adjacent.map(() => 0);
189
+ members.forEach((group, component) => {
190
+ for (const at of group) {
191
+ componentOf[at] = component;
192
+ }
193
+ });
194
+ return { componentOf, members };
195
+ }
196
+ /**
197
+ * Per component, the components it reaches in one step, and how many components reach
198
+ * it, each pair counted once. An edge inside a component is not an edge between two, so
199
+ * the result is acyclic.
200
+ */
201
+ function condense(adjacent, componentOf, count) {
202
+ const edges = Array.from({ length: count }, () => []);
203
+ const into = Array.from({ length: count }, () => 0);
204
+ const seen = new Set();
205
+ adjacent.forEach((targets, from) => {
206
+ for (const to of targets) {
207
+ const a = componentOf[from] ?? 0;
208
+ const b = componentOf[to] ?? 0;
209
+ const pair = `${String(a)}>${String(b)}`;
210
+ if (a === b || seen.has(pair)) {
211
+ continue;
212
+ }
213
+ seen.add(pair);
214
+ edges[a]?.push(b);
215
+ into[b] = (into[b] ?? 0) + 1;
216
+ }
217
+ });
218
+ return { edges, into };
219
+ }
220
+ /** Per dead declaration, whether a dead component other than its own references it. */
221
+ function externallyReferenced(adjacent, componentOf) {
222
+ const referenced = adjacent.map(() => false);
223
+ adjacent.forEach((targets, from) => {
224
+ for (const to of targets) {
225
+ if (componentOf[from] !== componentOf[to]) {
226
+ referenced[to] = true;
227
+ }
228
+ }
229
+ });
230
+ return referenced;
231
+ }
232
+ /**
233
+ * The components in the order a report is worked in. Tarjan closes each component after
234
+ * every one it reaches, so reading the closing order backwards is a topological order,
235
+ * and one pass over it gives each component its depth below the roots.
236
+ */
237
+ function order(edges, members) {
238
+ const sequence = edges.map((_targets, component) => component).reverse();
239
+ const depth = edges.map(() => 0);
240
+ for (const from of sequence) {
241
+ for (const to of edges[from] ?? []) {
242
+ depth[to] = Math.max(depth[to] ?? 0, (depth[from] ?? 0) + 1);
243
+ }
244
+ }
245
+ return sequence.sort((a, b) => (depth[a] ?? 0) - (depth[b] ?? 0) || (members[a]?.[0] ?? 0) - (members[b]?.[0] ?? 0));
246
+ }
247
+ /**
248
+ * Per component, the dead declarations one deletion of it removes. A root of the
249
+ * condensation carries its members and every component only it reaches; any other
250
+ * component carries its own members alone, because what it reaches is reached through a
251
+ * root above it as well.
252
+ */
253
+ function fallSets(edges, members, into) {
254
+ // Each root's walk stamps what it reaches with the root's own number, so the walks
255
+ // share one array rather than allocating one per root.
256
+ const stamp = edges.map(() => UNVISITED);
257
+ const reach = new Map();
258
+ const owners = edges.map(() => 0);
259
+ edges.forEach((_targets, root) => {
260
+ if (into[root] !== 0) {
261
+ return;
262
+ }
263
+ const reached = [root];
264
+ stamp[root] = root;
265
+ // An array's iterator reads its length at every step, so what a step pushes is
266
+ // walked in the same loop.
267
+ for (const at of reached) {
268
+ for (const to of edges[at] ?? []) {
269
+ if (stamp[to] !== root) {
270
+ stamp[to] = root;
271
+ reached.push(to);
272
+ }
273
+ }
274
+ }
275
+ reach.set(root, reached);
276
+ for (const component of reached) {
277
+ owners[component] = (owners[component] ?? 0) + 1;
278
+ }
279
+ });
280
+ return members.map((own, component) => {
281
+ const set = [...own];
282
+ for (const other of reach.get(component) ?? []) {
283
+ if (other !== component && owners[other] === 1) {
284
+ set.push(...(members[other] ?? []));
285
+ }
286
+ }
287
+ return set.sort((a, b) => a - b);
288
+ });
289
+ }