@cplieger/deadset-ts 2.2.1 → 3.0.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.
Files changed (45) hide show
  1. package/README.md +9 -16
  2. package/dist/src/analysis.js +142 -0
  3. package/dist/src/components.js +289 -0
  4. package/dist/src/config.js +6 -0
  5. package/dist/src/discover.js +98 -24
  6. package/dist/src/entry-point-gaps.js +278 -0
  7. package/dist/src/entry-points.js +385 -0
  8. package/dist/src/finding.js +12 -0
  9. package/dist/src/glob.js +65 -0
  10. package/dist/src/graph.js +116 -0
  11. package/dist/src/index.js +1 -2
  12. package/dist/src/manifest.js +120 -0
  13. package/dist/src/print.js +3 -6
  14. package/dist/src/references.js +156 -106
  15. package/dist/src/resolve.js +82 -20
  16. package/dist/src/roots.js +345 -0
  17. package/dist/src/run.js +59 -10
  18. package/dist/src/schema.js +1 -1
  19. package/dist/src/session.js +6 -1
  20. package/dist/src/source-files.js +122 -0
  21. package/dist/src/sweep.js +156 -0
  22. package/dist/src/version.js +1 -1
  23. package/jsr.json +1 -1
  24. package/package.json +3 -3
  25. package/src/analysis.ts +236 -0
  26. package/src/components.ts +372 -0
  27. package/src/config.ts +30 -2
  28. package/src/discover.ts +144 -29
  29. package/src/entry-point-gaps.ts +304 -0
  30. package/src/entry-points.ts +496 -0
  31. package/src/finding.ts +43 -0
  32. package/src/glob.ts +67 -0
  33. package/src/graph.ts +178 -0
  34. package/src/index.ts +1 -20
  35. package/src/manifest.ts +174 -0
  36. package/src/print.ts +5 -6
  37. package/src/references.ts +201 -111
  38. package/src/resolve.ts +117 -24
  39. package/src/roots.ts +467 -0
  40. package/src/run.ts +66 -11
  41. package/src/schema.ts +5 -2
  42. package/src/session.ts +6 -1
  43. package/src/source-files.ts +157 -0
  44. package/src/sweep.ts +230 -0
  45. package/src/version.ts +1 -1
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Which declarations of one graph are dead, under which liveness relation, and which
3
+ * dead component each belongs to. Reference counting holds a declaration live when any
4
+ * declaration references it, whatever that one's own liveness, and no root plays a part;
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 { componentsOf } from "./components.js";
9
+ import { namesACaller, OUTSIDE } from "./graph.js";
10
+ /**
11
+ * Answers which declarations of one graph are dead under the input's mode.
12
+ *
13
+ * The order is the order the answers depend on: the callers and the marks are live
14
+ * before either relation runs, the candidate set is every judged declaration at least
15
+ * one relation does not hold live, the tests of dead code join it, and the components
16
+ * are computed over the set that results.
17
+ */
18
+ export function sweep(graph, input) {
19
+ const marked = graph.symbols.map(() => false);
20
+ for (const id of input.marked) {
21
+ const at = graph.at(id);
22
+ if (at !== OUTSIDE) {
23
+ marked[at] = true;
24
+ }
25
+ }
26
+ // Every root and every mark seeds reachability. A root of a kind that names a caller,
27
+ // and a mark, are live under reference counting as well; a root that only supposes a
28
+ // caller is not.
29
+ const called = graph.symbols.map(() => false);
30
+ for (const root of graph.rooted) {
31
+ if (namesACaller(root.kind)) {
32
+ called[root.at] = true;
33
+ }
34
+ }
35
+ const counted = (at) => {
36
+ const made = graph.made[at];
37
+ if (made === undefined) {
38
+ return 0;
39
+ }
40
+ return input.mode.production ? made.production : made.production + made.test;
41
+ };
42
+ const reached = reachable(graph, marked, input.mode);
43
+ const liveUnder = graph.symbols.map((_symbol, at) => {
44
+ const relations = [];
45
+ if (marked[at] === true || called[at] === true || counted(at) > 0) {
46
+ relations.push("reference-counting");
47
+ }
48
+ if (reached[at] === true) {
49
+ relations.push("reachability");
50
+ }
51
+ return relations;
52
+ });
53
+ const dead = graph.symbols.map((_symbol, at) => graph.subject[at] === true && (liveUnder[at]?.length ?? 0) < 2);
54
+ const testOfDeadCode = admitTestsOfDeadCode(graph, dead, marked);
55
+ const candidates = [];
56
+ const live = new Map();
57
+ graph.symbols.forEach((symbol, at) => {
58
+ const relations = liveUnder[at] ?? [];
59
+ if (relations.length > 0) {
60
+ live.set(symbol.id, relations);
61
+ }
62
+ if (dead[at] !== true) {
63
+ return;
64
+ }
65
+ candidates.push({
66
+ id: symbol.id,
67
+ productionRefs: graph.made[at]?.production ?? 0,
68
+ testRefs: graph.made[at]?.test ?? 0,
69
+ relation: counted(at) === 0 ? "reference-counting" : "reachability",
70
+ testOfDeadCode: testOfDeadCode[at] === true,
71
+ });
72
+ });
73
+ return {
74
+ liveUnder: live,
75
+ candidates,
76
+ components: componentsOf(graph, dead, testOfDeadCode),
77
+ };
78
+ }
79
+ /**
80
+ * Per declaration, whether a seed reaches it. The two seeds reach through different
81
+ * reference sets: a marked declaration is live by a mechanism the analysis cannot see, so
82
+ * no mode withholds a reference it makes, while a root reaches through the references the
83
+ * mode counts. The marks run first, so what they reach is expanded under the wider rule.
84
+ */
85
+ function reachable(graph, marked, mode) {
86
+ const reached = graph.symbols.map(() => false);
87
+ const expanded = graph.symbols.map(() => false);
88
+ const queue = [];
89
+ // A reference to a file is a reference to the module's namespace, which reaches what
90
+ // the module exports as well as its top level. A root does not expand: a file a
91
+ // runtime runs without reading its exports is a root by itself.
92
+ const enter = (at, viaReference) => {
93
+ if (!reached[at]) {
94
+ reached[at] = true;
95
+ queue.push(at);
96
+ }
97
+ if (!viaReference || expanded[at] === true) {
98
+ return;
99
+ }
100
+ expanded[at] = true;
101
+ for (const exported of graph.exportsOf[at] ?? []) {
102
+ enter(exported, false);
103
+ }
104
+ };
105
+ const walk = (held) => {
106
+ for (let at = queue.pop(); at !== undefined; at = queue.pop()) {
107
+ for (const edge of graph.out[at] ?? []) {
108
+ if (!held && mode.production && edge.test) {
109
+ continue;
110
+ }
111
+ enter(edge.to, true);
112
+ }
113
+ }
114
+ };
115
+ marked.forEach((mark, at) => {
116
+ if (mark) {
117
+ enter(at, false);
118
+ }
119
+ });
120
+ walk(true);
121
+ for (const root of graph.rooted) {
122
+ enter(root.at, false);
123
+ }
124
+ walk(false);
125
+ return reached;
126
+ }
127
+ /**
128
+ * Admits every test declaration whose set of referenced production declarations is
129
+ * non-empty and wholly dead, and returns which it admitted. The set is read from every
130
+ * reference the test makes, not the ones the mode counts: a production sweep counts none,
131
+ * which is what makes the targets dead. A test referencing one live target is never admitted.
132
+ */
133
+ function admitTestsOfDeadCode(graph, dead, marked) {
134
+ const admitted = graph.symbols.map(() => false);
135
+ graph.symbols.forEach((_symbol, at) => {
136
+ if (graph.test[at] !== true || graph.subject[at] !== true || marked[at] === true) {
137
+ return;
138
+ }
139
+ let targets = 0;
140
+ let live = 0;
141
+ for (const edge of graph.out[at] ?? []) {
142
+ if (graph.test[edge.to] === true || graph.subject[edge.to] !== true) {
143
+ continue;
144
+ }
145
+ targets += 1;
146
+ if (dead[edge.to] !== true) {
147
+ live += 1;
148
+ }
149
+ }
150
+ if (targets > 0 && live === 0) {
151
+ dead[at] = true;
152
+ admitted[at] = true;
153
+ }
154
+ });
155
+ return admitted;
156
+ }
@@ -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.2.1",
3
+ "version": "3.0.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.2.1",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Deterministic dead-code analysis for TypeScript, class and type members included",
@@ -33,9 +33,9 @@
33
33
  "devDependencies": {
34
34
  "@eslint/js": "10.0.1",
35
35
  "@types/node": "24.13.5",
36
- "eslint": "10.10.0",
36
+ "eslint": "10.11.0",
37
37
  "fast-check": "4.10.1",
38
- "prettier": "3.9.7",
38
+ "prettier": "3.9.8",
39
39
  "typescript": "npm:@typescript/typescript6@6.0.2",
40
40
  "typescript-eslint": "8.70.0",
41
41
  "vitest": "5.0.1"
@@ -0,0 +1,236 @@
1
+ /**
2
+ * One run's passes, composed: the projects discovery names, opened in one snapshot
3
+ * of one client, each read through the stages in the order they depend on each
4
+ * other, and the per-project answers merged into the answer for the run.
5
+ *
6
+ * The stages take a project's view, which only this composition makes: a view
7
+ * carries a project's identity in its type so a handle cannot cross to another
8
+ * project's checker, and that holds only for views the session itself hands out.
9
+ */
10
+
11
+ import type { Diagnostic } from "@typescript/native/unstable/sync";
12
+ import type { Config, Provenance } from "./config.ts";
13
+ import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.ts";
14
+ import type { Finding } from "./finding.ts";
15
+ import { graphOf } from "./graph.ts";
16
+ import type { Host } from "./host.ts";
17
+ import { inventory, type Inventory, type InventorySymbol } from "./inventory.ts";
18
+ import { readManifest } from "./manifest.ts";
19
+ import { relativePath, resolvePath } from "./paths.ts";
20
+ import { byPosition, type Position } from "./position.ts";
21
+ import { references } from "./references.ts";
22
+ import { roots, unmatchedEverywhere, unmatchedRoots, type RootKind, type Roots } from "./roots.ts";
23
+ import type { Scope } from "./scope.ts";
24
+ import { diagnosticsOf, runSession, type Engine, type ProjectView } from "./session.ts";
25
+ import { sweep, type SweepInput, type SweepResult } from "./sweep.ts";
26
+
27
+ /** The setting whose source decides where a finding about a configured root sits. */
28
+ const ROOTS_SETTING = "roots.patterns";
29
+
30
+ /** One root of the run, merged across the projects that hold it. */
31
+ export interface RunRoot {
32
+ /** The stable reference of the declaration the root names. */
33
+ readonly ref: string;
34
+ /** Where that declaration is written. */
35
+ readonly position: Position;
36
+ readonly kind: RootKind;
37
+ readonly source: string;
38
+ /** The configurations whose project holds this root, in the run's order. */
39
+ readonly configurations: readonly string[];
40
+ }
41
+
42
+ /** The root set of one run, and the findings about the roots the configuration names. */
43
+ export interface RunRoots {
44
+ /**
45
+ * The configurations the run analyzed, each by the name discovery gives its
46
+ * project, in discovery order.
47
+ */
48
+ readonly configurations: readonly string[];
49
+ /**
50
+ * Every root of the run, ordered by the site of its declaration, then by kind, then
51
+ * by the string that named it. A root several projects hold is one entry naming each.
52
+ */
53
+ readonly roots: readonly RunRoot[];
54
+ /** One finding per configured root or pattern that named nothing in any project. */
55
+ readonly findings: readonly Finding[];
56
+ }
57
+
58
+ /**
59
+ * The document that supplied one setting, below the target root, or the empty
60
+ * string where the setting came from no document of the target. Both paths are
61
+ * resolved against the working directory before one is read below the other, so a
62
+ * relative target and a relative document name the same place.
63
+ */
64
+ function documentOf(
65
+ host: Host,
66
+ provenance: Provenance,
67
+ setting: string,
68
+ targetRoot: string,
69
+ ): string {
70
+ const origin = provenance.get(setting);
71
+ if (origin === undefined || origin.source === "default" || origin.source === "flag") {
72
+ return "";
73
+ }
74
+ const document = resolvePath(host.workingDirectory(), origin.label);
75
+ return relativePath(resolvePath(host.workingDirectory(), targetRoot), document) ?? "";
76
+ }
77
+
78
+ /** Two strings ordered bytewise. */
79
+ function compare(a: string, b: string): number {
80
+ if (a === b) {
81
+ return 0;
82
+ }
83
+ return a < b ? -1 : 1;
84
+ }
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
+
93
+ /**
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.
98
+ */
99
+ function readProjects<Answer>(
100
+ engine: Engine,
101
+ host: Host,
102
+ scope: Scope,
103
+ config: Config,
104
+ stage: <Brand>(project: ProjectView<Brand>, read: ProjectRead) => Answer,
105
+ ): readonly Answer[] {
106
+ const targetRoot = scope.target.path;
107
+ const discovered = discoverProjects(engine, host, scope, config.analysis.configurations);
108
+ const ids = new Map(discovered.projects.map((project) => [project.configFile, project.id]));
109
+ const options = {
110
+ manifest: readManifest(host, targetRoot),
111
+ patterns: config.rootPatterns,
112
+ entryFiles: config.ts.entryFiles,
113
+ testFiles: config.ts.testFiles,
114
+ publishedAPI: config.targetKind === "library",
115
+ };
116
+ const failures: Diagnostic[] = [];
117
+ const { projects } = runSession(engine, discovered.configFiles, (project) => {
118
+ const errors = diagnosticErrors(diagnosticsOf(project));
119
+ if (errors.length > 0) {
120
+ failures.push(...errors);
121
+ return undefined;
122
+ }
123
+ const held = inventory(project, host, targetRoot);
124
+ const rooted = roots(project, held, targetRoot, options);
125
+ const configuration =
126
+ ids.get(project.configFile) ??
127
+ relativePath(targetRoot, project.configFile) ??
128
+ project.configFile;
129
+ return stage(project, { configuration, held, rooted });
130
+ });
131
+ if (failures.length > 0) {
132
+ throw new DiscoveryError(
133
+ `${String(failures.length)} error(s); no answer was produced`,
134
+ failures,
135
+ );
136
+ }
137
+ return projects.filter((answer): answer is Answer => answer !== undefined);
138
+ }
139
+
140
+ /**
141
+ * The root set of the run the scope and the configuration describe.
142
+ *
143
+ * A configured string is a finding when it names nothing in every project, and the
144
+ * finding sits in the document that declared the roots.
145
+ */
146
+ export function runRoots(
147
+ engine: Engine,
148
+ host: Host,
149
+ scope: Scope,
150
+ config: Config,
151
+ provenance: Provenance,
152
+ ): RunRoots {
153
+ const targetRoot = scope.target.path;
154
+ const projects = readProjects(engine, host, scope, config, (_project, read) => read);
155
+
156
+ const configurations: string[] = [];
157
+ const merged = new Map<string, Omit<RunRoot, "configurations"> & { configurations: string[] }>();
158
+ for (const answer of projects) {
159
+ const configuration = answer.configuration;
160
+ configurations.push(configuration);
161
+ const symbols = new Map(answer.held.symbols.map((symbol) => [symbol.id, symbol]));
162
+ for (const root of answer.rooted.liveUnderReachability) {
163
+ const symbol = symbols.get(root.id);
164
+ if (symbol === undefined) {
165
+ continue;
166
+ }
167
+ const key = [root.id, root.kind, root.source].join("\u0000");
168
+ const held = merged.get(key);
169
+ if (held === undefined) {
170
+ merged.set(key, {
171
+ ref: symbol.ref,
172
+ position: symbol.position,
173
+ kind: root.kind,
174
+ source: root.source,
175
+ configurations: [configuration],
176
+ });
177
+ } else if (!held.configurations.includes(configuration)) {
178
+ held.configurations.push(configuration);
179
+ }
180
+ }
181
+ }
182
+
183
+ const ordered = [...merged.values()].sort(
184
+ (a, b) =>
185
+ byPosition(a.position, b.position) || compare(a.kind, b.kind) || compare(a.source, b.source),
186
+ );
187
+ const unmatched = unmatchedEverywhere(
188
+ config.rootPatterns,
189
+ projects.map((answer) => answer.rooted),
190
+ );
191
+ return {
192
+ configurations,
193
+ roots: ordered,
194
+ findings: unmatchedRoots(unmatched, documentOf(host, provenance, ROOTS_SETTING, targetRoot)),
195
+ };
196
+ }
197
+
198
+ /** One configuration's sweep, beside the declarations it judged. */
199
+ export interface ConfigurationSweep {
200
+ /** The name discovery gives the configuration's project. */
201
+ readonly configuration: string;
202
+ readonly symbols: readonly InventorySymbol[];
203
+ readonly sweep: SweepResult;
204
+ }
205
+
206
+ /**
207
+ * Sweeps every project of the run the scope and the configuration describe, one
208
+ * configuration at a time, in discovery order. Each project's references are read
209
+ * against its own inventory with the configured test-file patterns, and its graph is
210
+ * swept under the input the caller decided once for the run.
211
+ */
212
+ export function runSweeps(
213
+ engine: Engine,
214
+ host: Host,
215
+ scope: Scope,
216
+ config: Config,
217
+ input: SweepInput,
218
+ ): readonly ConfigurationSweep[] {
219
+ const targetRoot = scope.target.path;
220
+ return readProjects(engine, host, scope, config, (project, read) => {
221
+ const resolved = references(project, read.held, targetRoot, {
222
+ testFiles: config.ts.testFiles,
223
+ });
224
+ const graph = graphOf(
225
+ read.held.symbols,
226
+ resolved.references,
227
+ read.rooted.liveUnderReachability,
228
+ resolved.testFilePaths,
229
+ );
230
+ return {
231
+ configuration: read.configuration,
232
+ symbols: read.held.symbols,
233
+ sweep: sweep(graph, input),
234
+ };
235
+ });
236
+ }