@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
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, and the one-pass reference resolution over that inventory. The inventory and the reference pass are library functions the package exports; no verb reports findings yet, so `analyze`, `explain`, `print-roots`, `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, 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
 
@@ -28,14 +28,15 @@ 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. Any other invocation, including no arguments, prints the usage line and exits 2.
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.
32
32
 
33
33
  | Verb | Job |
34
34
  | ---------------- | ------------------------------------------------------------------------------- |
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-roots` | Print the resolved set of entry points |
38
+ | `print-projects` | Print the identifier of every project a run analyzes |
39
+ | `print-roots` | Print every declaration the analysis keeps live without a reference, and why |
39
40
  | `print-retained` | Print every symbol an exemption class held back, with the class |
40
41
  | `describe` | Describe the analyzer: version, Contract version, supported issue kinds |
41
42
  | `version` | Print the analyzer and Contract versions |
@@ -48,7 +49,7 @@ npx deadset-ts version
48
49
 
49
50
  ## API
50
51
 
51
- 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.
52
53
 
53
54
  The command line:
54
55
 
@@ -61,18 +62,6 @@ The platform:
61
62
  - `Host`: the filesystem a run reads, the directory it reads relative paths against, and the analyzer's own version.
62
63
  - `DirectoryEntry`, `PathKind`: one entry of a directory read, and what one path names.
63
64
 
64
- The symbol inventory:
65
-
66
- - `inventory(project, host, targetRoot)`: every declaration one project's own files hold, in position order, down to class and type members.
67
- - `nodeKey(file, node)`: the key one declaration is recorded under.
68
- - `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.
69
-
70
- The reference pass:
71
-
72
- - `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.
73
- - `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.
74
- - `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.
75
-
76
65
  Positions:
77
66
 
78
67
  - `renderPosition(file, root, offset)`: one compiler offset as a path, a line and a column, the column counting UTF-16 code units.
@@ -88,6 +77,10 @@ Stable symbol references:
88
77
  - `REF_EXPRESSIONS`: one regular expression per form the grammar defines.
89
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.
90
79
 
80
+ Entry points:
81
+
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
+
91
84
  Versions:
92
85
 
93
86
  - `CONTRACT_VERSION`: the contract version this analyzer implements.
@@ -0,0 +1,142 @@
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
+ import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.js";
11
+ import { graphOf } from "./graph.js";
12
+ import { inventory } from "./inventory.js";
13
+ import { readManifest } from "./manifest.js";
14
+ import { relativePath, resolvePath } from "./paths.js";
15
+ import { byPosition } from "./position.js";
16
+ import { references } from "./references.js";
17
+ import { roots, unmatchedEverywhere, unmatchedRoots } from "./roots.js";
18
+ import { diagnosticsOf, runSession } from "./session.js";
19
+ import { sweep } from "./sweep.js";
20
+ /** The setting whose source decides where a finding about a configured root sits. */
21
+ const ROOTS_SETTING = "roots.patterns";
22
+ /**
23
+ * The document that supplied one setting, below the target root, or the empty
24
+ * string where the setting came from no document of the target. Both paths are
25
+ * resolved against the working directory before one is read below the other, so a
26
+ * relative target and a relative document name the same place.
27
+ */
28
+ function documentOf(host, provenance, setting, targetRoot) {
29
+ const origin = provenance.get(setting);
30
+ if (origin === undefined || origin.source === "default" || origin.source === "flag") {
31
+ return "";
32
+ }
33
+ const document = resolvePath(host.workingDirectory(), origin.label);
34
+ return relativePath(resolvePath(host.workingDirectory(), targetRoot), document) ?? "";
35
+ }
36
+ /** Two strings ordered bytewise. */
37
+ function compare(a, b) {
38
+ if (a === b) {
39
+ return 0;
40
+ }
41
+ return a < b ? -1 : 1;
42
+ }
43
+ /**
44
+ * Reads every project the scope discovers, in one snapshot: each is checked for errors
45
+ * first, then enumerated, then rooted, and `stage` reads the rest of what it needs from
46
+ * the project while its view is open. A project carrying an error fails the run with no
47
+ * answer, as every verb that reads declarations does.
48
+ */
49
+ function readProjects(engine, host, scope, config, stage) {
50
+ const targetRoot = scope.target.path;
51
+ const discovered = discoverProjects(engine, host, scope, config.analysis.configurations);
52
+ const ids = new Map(discovered.projects.map((project) => [project.configFile, project.id]));
53
+ const options = {
54
+ manifest: readManifest(host, targetRoot),
55
+ patterns: config.rootPatterns,
56
+ entryFiles: config.ts.entryFiles,
57
+ testFiles: config.ts.testFiles,
58
+ publishedAPI: config.targetKind === "library",
59
+ };
60
+ const failures = [];
61
+ const { projects } = runSession(engine, discovered.configFiles, (project) => {
62
+ const errors = diagnosticErrors(diagnosticsOf(project));
63
+ if (errors.length > 0) {
64
+ failures.push(...errors);
65
+ return undefined;
66
+ }
67
+ const held = inventory(project, host, targetRoot);
68
+ const rooted = roots(project, held, targetRoot, options);
69
+ const configuration = ids.get(project.configFile) ??
70
+ relativePath(targetRoot, project.configFile) ??
71
+ project.configFile;
72
+ return stage(project, { configuration, held, rooted });
73
+ });
74
+ if (failures.length > 0) {
75
+ throw new DiscoveryError(`${String(failures.length)} error(s); no answer was produced`, failures);
76
+ }
77
+ return projects.filter((answer) => answer !== undefined);
78
+ }
79
+ /**
80
+ * The root set of the run the scope and the configuration describe.
81
+ *
82
+ * A configured string is a finding when it names nothing in every project, and the
83
+ * finding sits in the document that declared the roots.
84
+ */
85
+ export function runRoots(engine, host, scope, config, provenance) {
86
+ const targetRoot = scope.target.path;
87
+ const projects = readProjects(engine, host, scope, config, (_project, read) => read);
88
+ const configurations = [];
89
+ const merged = new Map();
90
+ for (const answer of projects) {
91
+ const configuration = answer.configuration;
92
+ configurations.push(configuration);
93
+ const symbols = new Map(answer.held.symbols.map((symbol) => [symbol.id, symbol]));
94
+ for (const root of answer.rooted.liveUnderReachability) {
95
+ const symbol = symbols.get(root.id);
96
+ if (symbol === undefined) {
97
+ continue;
98
+ }
99
+ const key = [root.id, root.kind, root.source].join("\u0000");
100
+ const held = merged.get(key);
101
+ if (held === undefined) {
102
+ merged.set(key, {
103
+ ref: symbol.ref,
104
+ position: symbol.position,
105
+ kind: root.kind,
106
+ source: root.source,
107
+ configurations: [configuration],
108
+ });
109
+ }
110
+ else if (!held.configurations.includes(configuration)) {
111
+ held.configurations.push(configuration);
112
+ }
113
+ }
114
+ }
115
+ const ordered = [...merged.values()].sort((a, b) => byPosition(a.position, b.position) || compare(a.kind, b.kind) || compare(a.source, b.source));
116
+ const unmatched = unmatchedEverywhere(config.rootPatterns, projects.map((answer) => answer.rooted));
117
+ return {
118
+ configurations,
119
+ roots: ordered,
120
+ findings: unmatchedRoots(unmatched, documentOf(host, provenance, ROOTS_SETTING, targetRoot)),
121
+ };
122
+ }
123
+ /**
124
+ * Sweeps every project of the run the scope and the configuration describe, one
125
+ * configuration at a time, in discovery order. Each project's references are read
126
+ * against its own inventory with the configured test-file patterns, and its graph is
127
+ * swept under the input the caller decided once for the run.
128
+ */
129
+ export function runSweeps(engine, host, scope, config, input) {
130
+ const targetRoot = scope.target.path;
131
+ return readProjects(engine, host, scope, config, (project, read) => {
132
+ const resolved = references(project, read.held, targetRoot, {
133
+ testFiles: config.ts.testFiles,
134
+ });
135
+ const graph = graphOf(read.held.symbols, resolved.references, read.rooted.liveUnderReachability, resolved.testFilePaths);
136
+ return {
137
+ configuration: read.configuration,
138
+ symbols: read.held.symbols,
139
+ sweep: sweep(graph, input),
140
+ };
141
+ });
142
+ }
@@ -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
+ }
@@ -1,4 +1,10 @@
1
1
  import { CONTRACT_VERSION } from "./version.js";
2
+ /**
3
+ * The name of the repository configuration at the target root, which is also the
4
+ * document a finding about the configuration names where no document of the
5
+ * target supplied the setting it is about.
6
+ */
7
+ export const REPOSITORY_DOCUMENT = "deadset.json";
2
8
  /**
3
9
  * The configuration every documented default states. `targetKind` is empty: the
4
10
  * field has no default and is never inferred.
@@ -1,5 +1,5 @@
1
1
  import { DiagnosticCategory } from "@typescript/native/unstable/sync";
2
- import { dirnamePath, isAbsolutePath, joinPath, resolvePath } from "./paths.js";
2
+ import { dirnamePath, isAbsolutePath, joinPath, relativePath, resolvePath } from "./paths.js";
3
3
  /** Directory names discovery does not descend into. */
4
4
  const IGNORED_DIRECTORIES = new Set(["node_modules", ".git"]);
5
5
  /** A compiler configuration file discovery recognises under a root. */
@@ -128,24 +128,77 @@ function scopeConfigFiles(host, scope) {
128
128
  .map((path) => resolvePath(host.workingDirectory(), path));
129
129
  }
130
130
  /**
131
- * Every project one run analyzes, in the order discovery reads them: the paths the
132
- * scope names, then every compiler configuration under the target root that is not
133
- * inside an ignored directory, then the configurations each of those references,
134
- * transitively. Each configuration appears once.
135
- *
136
- * A configuration file the compiler cannot read ends the run with the failure code,
137
- * so a project that would have been analyzed with no type information is never
138
- * analyzed at all. What a configuration's own contents say is refused once its
139
- * project is open, by {@link diagnosticErrors} over the three sets a program
140
- * reports, because a resolution carries no diagnostics.
131
+ * The name a derived project carries: its configuration file's path below the target
132
+ * root, or the absolute path of a file outside it.
133
+ */
134
+ function derivedId(targetRoot, configFile) {
135
+ return relativePath(targetRoot, configFile) ?? configFile;
136
+ }
137
+ /**
138
+ * Resolves one compiler configuration file, or ends the run naming it: a project
139
+ * opened from a configuration the compiler cannot read would be analyzed with no
140
+ * type information.
141
+ */
142
+ function parseOrRefuse(engine, configFile, named) {
143
+ try {
144
+ engine.parseConfigFile(configFile);
145
+ }
146
+ catch (error) {
147
+ throw new DiscoveryError(`${named} cannot be read as a compiler configuration: ` +
148
+ (error instanceof Error ? error.message : String(error)), []);
149
+ }
150
+ }
151
+ /**
152
+ * The projects a build matrix declares, in the order it lists them. Each entry
153
+ * names a file below the target root, and an entry naming a file that is absent,
154
+ * that is a directory, or that the compiler cannot read ends the run naming it.
141
155
  *
142
- * Nothing reads a build output: a project is the files its configuration names, so a
143
- * package whose sources ship as TypeScript is analyzed as it stands.
156
+ * A declared project's `references` are not followed, because the entries are the
157
+ * matrix, and a reference to a configuration the matrix does not name ends the run
158
+ * naming both: the compiler builds a referenced project's sources into the program
159
+ * that references it without reporting their diagnostics, so the project the matrix
160
+ * left out would be analyzed with its errors unread. An entry naming a file an
161
+ * earlier entry named adds nothing, so the earlier identifier stands.
162
+ */
163
+ function declaredProjects(engine, host, targetRoot, entries) {
164
+ const seen = new Set();
165
+ const projects = [];
166
+ for (const entry of entries) {
167
+ const configFile = joinPath(targetRoot, entry.project);
168
+ const named = `the project ${JSON.stringify(entry.id)} (${configFile})`;
169
+ const kind = host.kindOf(configFile);
170
+ if (kind !== "file") {
171
+ throw new DiscoveryError(kind === "absent"
172
+ ? `${named} does not exist`
173
+ : `${named} is a directory, and a project names its compiler configuration file`, []);
174
+ }
175
+ if (seen.has(configFile)) {
176
+ continue;
177
+ }
178
+ seen.add(configFile);
179
+ parseOrRefuse(engine, configFile, named);
180
+ projects.push({ id: entry.id, configFile });
181
+ }
182
+ for (const project of projects) {
183
+ const unnamed = referencesOf(host, project.configFile).find((path) => !seen.has(path));
184
+ if (unnamed !== undefined) {
185
+ throw new DiscoveryError(`the project ${JSON.stringify(project.id)} (${project.configFile}) references ` +
186
+ `${unnamed}, which the build matrix does not name; a referenced project is built ` +
187
+ "into the program that references it, so the matrix names it too", []);
188
+ }
189
+ }
190
+ return { projects, configFiles: projects.map((project) => project.configFile), fromScope: [] };
191
+ }
192
+ /**
193
+ * The projects a run derives where the matrix declares none, in the order discovery
194
+ * reads them: the paths the scope names, then every compiler configuration under
195
+ * the target root that is not inside an ignored directory, then the configurations
196
+ * each of those references, transitively. Each configuration appears once.
144
197
  */
145
- export function discoverProjects(engine, host, scope) {
198
+ function derivedProjects(engine, host, scope) {
146
199
  const fromScope = scopeConfigFiles(host, scope);
147
200
  const seen = new Set();
148
- const configFiles = [];
201
+ const projects = [];
149
202
  const pending = [...fromScope, ...configFilesUnder(host, scope.target.path)];
150
203
  while (pending.length > 0) {
151
204
  const configFile = pending.shift();
@@ -157,15 +210,36 @@ export function discoverProjects(engine, host, scope) {
157
210
  continue;
158
211
  }
159
212
  seen.add(path);
160
- try {
161
- engine.parseConfigFile(path);
162
- }
163
- catch (error) {
164
- throw new DiscoveryError(`${path} cannot be read as a compiler configuration: ` +
165
- (error instanceof Error ? error.message : String(error)), []);
166
- }
167
- configFiles.push(path);
213
+ parseOrRefuse(engine, path, path);
214
+ projects.push({ id: derivedId(scope.target.path, path), configFile: path });
168
215
  pending.push(...referencesOf(host, path));
169
216
  }
170
- return { configFiles, fromScope };
217
+ return { projects, configFiles: projects.map((project) => project.configFile), fromScope };
218
+ }
219
+ /**
220
+ * Every project one run analyzes.
221
+ *
222
+ * The project entries of the build matrix, where it holds any, are the projects:
223
+ * exactly those, in the order the matrix lists them, each named by its declared
224
+ * identifier, as {@link declaredProjects} reads them. A matrix holding no project
225
+ * entry, the empty one included, leaves discovery to derive the projects from the
226
+ * scope and the target tree. A platform entry names a configuration of another
227
+ * language and plays no part here.
228
+ *
229
+ * A configuration file the compiler cannot read ends the run with the failure code,
230
+ * so a project that would have been analyzed with no type information is never
231
+ * analyzed at all, whether the matrix declared it or discovery derived it. What a
232
+ * configuration's own contents say is refused once its project is open, by
233
+ * {@link diagnosticErrors} over the three sets a program reports, because a
234
+ * resolution carries no diagnostics.
235
+ *
236
+ * Nothing reads a build output: a project is the files its configuration names, so a
237
+ * package whose sources ship as TypeScript is analyzed as it stands.
238
+ */
239
+ export function discoverProjects(engine, host, scope, configurations = []) {
240
+ const declared = configurations.filter((entry) => entry.shape === "project");
241
+ if (declared.length > 0) {
242
+ return declaredProjects(engine, host, scope.target.path, declared);
243
+ }
244
+ return derivedProjects(engine, host, scope);
171
245
  }