@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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Which declarations of one graph are dead, and under which liveness relation.
3
+ * Reference counting holds a declaration live when any declaration references it,
4
+ * whatever that one's own liveness, or when it is a root of a kind that names a caller;
5
+ * reachability holds it live when a path of references reaches it from a root. So a
6
+ * declaration only unreachable ones reference is live under the first, dead under the second.
7
+ */
8
+ import { namesACaller, OUTSIDE } from "./graph.js";
9
+ /**
10
+ * Answers which declarations of one graph are dead under the input's mode.
11
+ *
12
+ * The order is the order the answers depend on: the callers and the marks are live
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.
15
+ */
16
+ 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.
27
+ const called = graph.symbols.map(() => false);
28
+ for (const root of graph.rooted) {
29
+ if (namesACaller(root.kind)) {
30
+ called[root.at] = true;
31
+ }
32
+ }
33
+ const counted = (at) => {
34
+ const made = graph.made[at];
35
+ if (made === undefined) {
36
+ return 0;
37
+ }
38
+ return input.mode.production ? made.production : made.production + made.test;
39
+ };
40
+ const reached = reachable(graph, marked, input.mode);
41
+ const liveUnder = graph.symbols.map((_symbol, at) => {
42
+ const relations = [];
43
+ if (marked[at] === true || called[at] === true || counted(at) > 0) {
44
+ relations.push("reference-counting");
45
+ }
46
+ if (reached[at] === true) {
47
+ relations.push("reachability");
48
+ }
49
+ return relations;
50
+ });
51
+ const dead = graph.symbols.map((_symbol, at) => graph.subject[at] === true && (liveUnder[at]?.length ?? 0) < 2);
52
+ const testOfDeadCode = admitTestsOfDeadCode(graph, dead, marked);
53
+ const candidates = [];
54
+ const live = new Map();
55
+ graph.symbols.forEach((symbol, at) => {
56
+ const relations = liveUnder[at] ?? [];
57
+ if (relations.length > 0) {
58
+ live.set(symbol.id, relations);
59
+ }
60
+ if (dead[at] !== true) {
61
+ return;
62
+ }
63
+ candidates.push({
64
+ id: symbol.id,
65
+ productionRefs: graph.made[at]?.production ?? 0,
66
+ testRefs: graph.made[at]?.test ?? 0,
67
+ relation: counted(at) === 0 ? "reference-counting" : "reachability",
68
+ testOfDeadCode: testOfDeadCode[at] === true,
69
+ });
70
+ });
71
+ return { liveUnder: live, candidates };
72
+ }
73
+ /**
74
+ * 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.
78
+ */
79
+ function reachable(graph, marked, mode) {
80
+ const reached = graph.symbols.map(() => false);
81
+ const expanded = graph.symbols.map(() => false);
82
+ const queue = [];
83
+ // Reaching a file reaches its top level: the references written outside every
84
+ // declaration it holds. A reference that reads a file reads the module's namespace,
85
+ // which reaches what the module exports as well. An evaluation runs the top level
86
+ // and reads no export, and a root does not expand either: a file a runtime runs
87
+ // without reading its exports is a root by itself.
88
+ const enter = (at, expands) => {
89
+ if (!reached[at]) {
90
+ reached[at] = true;
91
+ queue.push(at);
92
+ }
93
+ if (!expands || expanded[at] === true) {
94
+ return;
95
+ }
96
+ expanded[at] = true;
97
+ for (const exported of graph.exportsOf[at] ?? []) {
98
+ enter(exported, false);
99
+ }
100
+ };
101
+ const walk = (held) => {
102
+ for (let at = queue.pop(); at !== undefined; at = queue.pop()) {
103
+ for (const edge of graph.out[at] ?? []) {
104
+ if (!held && mode.production && edge.test) {
105
+ continue;
106
+ }
107
+ enter(edge.to, !edge.evaluation);
108
+ }
109
+ }
110
+ };
111
+ marked.forEach((mark, at) => {
112
+ if (mark) {
113
+ enter(at, false);
114
+ }
115
+ });
116
+ walk(true);
117
+ for (const root of graph.rooted) {
118
+ enter(root.at, false);
119
+ }
120
+ walk(false);
121
+ return reached;
122
+ }
123
+ /**
124
+ * Admits every test declaration whose set of referenced production declarations is
125
+ * non-empty and wholly dead, and returns which it admitted. The set is read from every
126
+ * 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.
128
+ */
129
+ function admitTestsOfDeadCode(graph, dead, marked) {
130
+ const admitted = graph.symbols.map(() => false);
131
+ graph.symbols.forEach((_symbol, at) => {
132
+ if (graph.test[at] !== true || graph.subject[at] !== true || marked[at] === true) {
133
+ return;
134
+ }
135
+ let targets = 0;
136
+ let live = 0;
137
+ for (const edge of graph.out[at] ?? []) {
138
+ if (graph.test[edge.to] === true || graph.subject[edge.to] !== true) {
139
+ continue;
140
+ }
141
+ targets += 1;
142
+ if (dead[edge.to] !== true) {
143
+ live += 1;
144
+ }
145
+ }
146
+ if (targets > 0 && live === 0) {
147
+ dead[at] = true;
148
+ admitted[at] = true;
149
+ }
150
+ });
151
+ return admitted;
152
+ }
@@ -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.1.0";
7
+ export const CONTRACT_VERSION = "2.2.0";
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "2.3.0",
3
+ "version": "3.1.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": "2.3.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Deterministic dead-code analysis for TypeScript, class and type members included",
@@ -0,0 +1,135 @@
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
+
7
+ import { SymbolFlags, type Symbol as TSSymbol } from "@typescript/native/unstable/sync";
8
+ import { nodeKey, type Inventory } from "./inventory.ts";
9
+ import type { ProjectView } from "./session.ts";
10
+
11
+ /** One declaration a chain reaches, and whether a step along an alias reached it. */
12
+ export interface ChainTarget {
13
+ readonly id: string;
14
+ /** False for a declaration the head symbol itself is, true for one a link stands for. */
15
+ readonly stepped: boolean;
16
+ }
17
+
18
+ /** The chain walks over one project's aliases, each step asked of the compiler once. */
19
+ export interface AliasChains {
20
+ /**
21
+ * The declarations of the inventory one symbol is, each once. A declaration outside
22
+ * the project's own files belongs to another program and is not one of them.
23
+ */
24
+ declarationsOf(symbol: TSSymbol): readonly string[];
25
+ /**
26
+ * The declarations one symbol is, and those of every link of the alias chain it
27
+ * stands at the head of, each once and in chain order. A link the inventory does
28
+ * not declare is stepped over rather than named.
29
+ */
30
+ chainOf(symbol: TSSymbol): readonly ChainTarget[];
31
+ /**
32
+ * The declarations of the first link after `symbol` along its chain that the
33
+ * inventory declares, stepping over the links it does not, or none where no later
34
+ * link is declared.
35
+ */
36
+ nextDeclared(symbol: TSSymbol): readonly string[];
37
+ /** How many steps the compiler was asked for: one per alias, whatever the chains it heads. */
38
+ readonly steps: number;
39
+ }
40
+
41
+ /**
42
+ * The chain walks over one project, against the declarations of `held`, the same
43
+ * project's inventory.
44
+ *
45
+ * A declaration handle carries the file it is in, so whether it is one of the
46
+ * project's own files is answered before the handle is resolved: a declaration of a
47
+ * library file is never fetched to find out that the inventory does not hold it.
48
+ */
49
+ export function aliasChains<Brand>(project: ProjectView<Brand>, held: Inventory): AliasChains {
50
+ const ownFiles = new Set(project.ownSourceFiles().map((file) => file.fileName));
51
+ const steps = new Map<number, TSSymbol | undefined>();
52
+ const chains = new Map<number, readonly ChainTarget[]>();
53
+ let asked = 0;
54
+
55
+ const declarationsOf = (symbol: TSSymbol): string[] => {
56
+ const ids: string[] = [];
57
+ for (const handle of symbol.declarations) {
58
+ if (!ownFiles.has(handle.path)) {
59
+ continue;
60
+ }
61
+ const node = project.declarationAt(handle)?.node;
62
+ const id =
63
+ node === undefined ? undefined : held.declarations.get(nodeKey(node.getSourceFile(), node));
64
+ if (id !== undefined && !ids.includes(id)) {
65
+ ids.push(id);
66
+ }
67
+ }
68
+ return ids;
69
+ };
70
+
71
+ /**
72
+ * What one alias names, one link along. Only a symbol the binder flagged an alias is
73
+ * asked, which is the condition the step asserts on: a default export of an
74
+ * expression is written in an alias form and names nothing declared elsewhere, so its
75
+ * chain ends at itself.
76
+ */
77
+ const stepOf = (symbol: TSSymbol): TSSymbol | undefined => {
78
+ if ((symbol.flags & SymbolFlags.Alias) === 0) {
79
+ return undefined;
80
+ }
81
+ if (steps.has(symbol.id)) {
82
+ return steps.get(symbol.id);
83
+ }
84
+ const next = project.aliasStepOf(symbol);
85
+ asked += 1;
86
+ steps.set(symbol.id, next);
87
+ return next;
88
+ };
89
+
90
+ const chainOf = (symbol: TSSymbol): readonly ChainTarget[] => {
91
+ const cached = chains.get(symbol.id);
92
+ if (cached !== undefined) {
93
+ return cached;
94
+ }
95
+ const targets: ChainTarget[] = [];
96
+ const walked = new Set<number>();
97
+ let at: TSSymbol | undefined = symbol;
98
+ let stepped = false;
99
+ while (at !== undefined && !walked.has(at.id)) {
100
+ walked.add(at.id);
101
+ for (const id of declarationsOf(at)) {
102
+ if (!targets.some((target) => target.id === id)) {
103
+ targets.push({ id, stepped });
104
+ }
105
+ }
106
+ at = stepOf(at);
107
+ stepped = true;
108
+ }
109
+ chains.set(symbol.id, targets);
110
+ return targets;
111
+ };
112
+
113
+ const nextDeclared = (symbol: TSSymbol): readonly string[] => {
114
+ const walked = new Set<number>([symbol.id]);
115
+ let at = stepOf(symbol);
116
+ while (at !== undefined && !walked.has(at.id)) {
117
+ walked.add(at.id);
118
+ const ids = declarationsOf(at);
119
+ if (ids.length > 0) {
120
+ return ids;
121
+ }
122
+ at = stepOf(at);
123
+ }
124
+ return [];
125
+ };
126
+
127
+ return {
128
+ declarationsOf,
129
+ chainOf,
130
+ nextDeclared,
131
+ get steps() {
132
+ return asked;
133
+ },
134
+ };
135
+ }
package/src/analysis.ts CHANGED
@@ -13,13 +13,16 @@ import type { Config, Provenance } from "./config.ts";
13
13
  import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.ts";
14
14
  import type { Finding } from "./finding.ts";
15
15
  import type { Host } from "./host.ts";
16
- import { inventory } from "./inventory.ts";
16
+ import { inventory, type Inventory } from "./inventory.ts";
17
17
  import { readManifest } from "./manifest.ts";
18
+ import { matrixOf, sweepMatrix, type Configured, type Matrix, type SweepResult } from "./matrix.ts";
18
19
  import { relativePath, resolvePath } from "./paths.ts";
19
20
  import { byPosition, type Position } from "./position.ts";
20
- import { roots, unmatchedEverywhere, unmatchedRoots, type RootKind } from "./roots.ts";
21
+ import { references } from "./references.ts";
22
+ import { roots, unmatchedEverywhere, unmatchedRoots, type RootKind, type Roots } from "./roots.ts";
21
23
  import type { Scope } from "./scope.ts";
22
- import { diagnosticsOf, runSession, type Engine } from "./session.ts";
24
+ import { diagnosticsOf, runSession, type Engine, type ProjectView } from "./session.ts";
25
+ import type { SweepInput } from "./sweep.ts";
23
26
 
24
27
  /** The setting whose source decides where a finding about a configured root sits. */
25
28
  const ROOTS_SETTING = "roots.patterns";
@@ -39,8 +42,8 @@ export interface RunRoot {
39
42
  /** The root set of one run, and the findings about the roots the configuration names. */
40
43
  export interface RunRoots {
41
44
  /**
42
- * The configurations the run analyzed: each compiler configuration file, by its
43
- * path below the target root, in discovery order.
45
+ * The configurations the run analyzed, each by the name discovery gives its
46
+ * project, in discovery order: the order a declared matrix lists them in.
44
47
  */
45
48
  readonly configurations: readonly string[];
46
49
  /**
@@ -80,26 +83,31 @@ function compare(a: string, b: string): number {
80
83
  return a < b ? -1 : 1;
81
84
  }
82
85
 
86
+ /** What every stage after the root set reads of one project. */
87
+ interface ProjectRead {
88
+ readonly configuration: string;
89
+ readonly held: Inventory;
90
+ readonly rooted: Roots;
91
+ }
92
+
83
93
  /**
84
- * The root set of the run the scope and the configuration describe.
85
- *
86
- * Every project is read for errors before anything else, and a project carrying one
87
- * fails the run with no root set, as every verb that reads declarations does. A
88
- * configured string is a finding when it names nothing in every project, and the
89
- * finding sits in the document that declared the roots.
94
+ * Reads every project the scope discovers, in one snapshot: each is checked for errors
95
+ * first, then enumerated, then rooted, and `stage` reads the rest of what it needs from
96
+ * the project while its view is open. A project carrying an error fails the run with no
97
+ * answer, as every verb that reads declarations does.
90
98
  */
91
- export function runRoots(
99
+ function readProjects<Answer>(
92
100
  engine: Engine,
93
101
  host: Host,
94
102
  scope: Scope,
95
103
  config: Config,
96
- provenance: Provenance,
97
- ): RunRoots {
104
+ stage: <Brand>(project: ProjectView<Brand>, read: ProjectRead) => Answer,
105
+ ): readonly Answer[] {
98
106
  const targetRoot = scope.target.path;
99
- const discovered = discoverProjects(engine, host, scope);
100
- const manifest = readManifest(host, targetRoot);
107
+ const discovered = discoverProjects(engine, host, scope, config.analysis.configurations);
108
+ const ids = new Map(discovered.projects.map((project) => [project.configFile, project.id]));
101
109
  const options = {
102
- manifest,
110
+ manifest: readManifest(host, targetRoot),
103
111
  patterns: config.rootPatterns,
104
112
  entryFiles: config.ts.entryFiles,
105
113
  testFiles: config.ts.testFiles,
@@ -113,7 +121,9 @@ export function runRoots(
113
121
  return undefined;
114
122
  }
115
123
  const held = inventory(project, host, targetRoot);
116
- return { held, rooted: roots(project, held, targetRoot, options) };
124
+ const rooted = roots(project, held, targetRoot, options);
125
+ const configuration = ids.get(project.configFile) ?? project.configFile;
126
+ return stage(project, { configuration, held, rooted });
117
127
  });
118
128
  if (failures.length > 0) {
119
129
  throw new DiscoveryError(
@@ -121,15 +131,29 @@ export function runRoots(
121
131
  failures,
122
132
  );
123
133
  }
134
+ return projects.filter((answer): answer is Answer => answer !== undefined);
135
+ }
136
+
137
+ /**
138
+ * The root set of the run the scope and the configuration describe.
139
+ *
140
+ * A configured string is a finding when it names nothing in every project, and the
141
+ * finding sits in the document that declared the roots.
142
+ */
143
+ export function runRoots(
144
+ engine: Engine,
145
+ host: Host,
146
+ scope: Scope,
147
+ config: Config,
148
+ provenance: Provenance,
149
+ ): RunRoots {
150
+ const targetRoot = scope.target.path;
151
+ const projects = readProjects(engine, host, scope, config, (_project, read) => read);
124
152
 
125
153
  const configurations: string[] = [];
126
154
  const merged = new Map<string, Omit<RunRoot, "configurations"> & { configurations: string[] }>();
127
155
  for (const answer of projects) {
128
- if (answer === undefined) {
129
- continue;
130
- }
131
- const configuration =
132
- relativePath(targetRoot, answer.rooted.configFile) ?? answer.rooted.configFile;
156
+ const configuration = answer.configuration;
133
157
  configurations.push(configuration);
134
158
  const symbols = new Map(answer.held.symbols.map((symbol) => [symbol.id, symbol]));
135
159
  for (const root of answer.rooted.liveUnderReachability) {
@@ -159,7 +183,7 @@ export function runRoots(
159
183
  );
160
184
  const unmatched = unmatchedEverywhere(
161
185
  config.rootPatterns,
162
- projects.flatMap((answer) => (answer === undefined ? [] : [answer.rooted])),
186
+ projects.map((answer) => answer.rooted),
163
187
  );
164
188
  return {
165
189
  configurations,
@@ -167,3 +191,40 @@ export function runRoots(
167
191
  findings: unmatchedRoots(unmatched, documentOf(host, provenance, ROOTS_SETTING, targetRoot)),
168
192
  };
169
193
  }
194
+
195
+ /** The run's sweep, beside the matrix it judged. */
196
+ export interface RunSweep {
197
+ readonly matrix: Matrix;
198
+ readonly sweep: SweepResult;
199
+ }
200
+
201
+ /**
202
+ * 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.
207
+ */
208
+ export function runSweep(
209
+ engine: Engine,
210
+ host: Host,
211
+ scope: Scope,
212
+ config: Config,
213
+ input: SweepInput,
214
+ ): RunSweep {
215
+ const targetRoot = scope.target.path;
216
+ const configured = readProjects(engine, host, scope, config, (project, read): Configured => {
217
+ const resolved = references(project, read.held, targetRoot, {
218
+ testFiles: config.ts.testFiles,
219
+ });
220
+ return {
221
+ configuration: read.configuration,
222
+ symbols: read.held.symbols,
223
+ references: resolved.references,
224
+ roots: read.rooted.liveUnderReachability,
225
+ testFiles: resolved.testFilePaths,
226
+ };
227
+ });
228
+ const matrix = matrixOf(configured);
229
+ return { matrix, sweep: sweepMatrix(matrix, input) };
230
+ }