@cplieger/deadset-ts 3.4.0 → 3.5.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/dist/src/scope.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { ConfigError } from "./config.js";
1
2
  import { dirnamePath, isAbsolutePath, joinPath, resolvePath } from "./paths.js";
3
+ import { checkDocument } from "./schema.js";
2
4
  /** The roles a scope document declares for a module. */
3
5
  export const ROLE_TARGET = "target";
4
6
  export const ROLE_CONSUMER = "consumer";
@@ -17,18 +19,37 @@ export class ScopeError extends Error {
17
19
  this.name = "ScopeError";
18
20
  }
19
21
  }
20
- const DECLARED_KEYS = new Set(["target", "workspace", "consumers"]);
21
- const MODULE_KEYS = new Set(["id", "role", "path"]);
22
+ const MODULE = {
23
+ kind: "section",
24
+ members: { id: { kind: "leaf" }, role: { kind: "leaf" }, path: { kind: "leaf" } },
25
+ };
26
+ /** The closed key list of the scope document, compared as bytes. */
27
+ const SCOPE_ROOT = {
28
+ kind: "section",
29
+ members: {
30
+ target: MODULE,
31
+ workspace: { kind: "leaf" },
32
+ consumers: { kind: "list", members: MODULE.members ?? {} },
33
+ },
34
+ };
22
35
  function isRecord(value) {
23
36
  return typeof value === "object" && value !== null && !Array.isArray(value);
24
37
  }
38
+ /**
39
+ * One string member, or undefined where the document omits it. A member the
40
+ * document writes is a string holding at least one character, so `null` and `""`
41
+ * are refused rather than read as absent.
42
+ */
25
43
  function stringMember(parent, name, at) {
26
44
  if (!Object.hasOwn(parent, name)) {
27
- return "";
45
+ return undefined;
28
46
  }
29
47
  const value = parent[name];
30
48
  if (typeof value !== "string") {
31
- throw new ScopeError(`scope: ${at}.${name} is not a string`);
49
+ throw new ScopeError(`scope: ${at === "" ? name : `${at}.${name}`} is not a string`);
50
+ }
51
+ if (value === "") {
52
+ throw new ScopeError(`scope: ${at === "" ? name : `${at}.${name}`} names nothing`);
32
53
  }
33
54
  return value;
34
55
  }
@@ -36,30 +57,46 @@ function readModule(value, at, role, documentDir) {
36
57
  if (!isRecord(value)) {
37
58
  throw new ScopeError(`scope: ${at} is not an object`);
38
59
  }
39
- for (const name of Object.keys(value)) {
40
- if (!MODULE_KEYS.has(name)) {
41
- throw new ScopeError(`scope: ${at}.${name} is not a declared key`);
42
- }
43
- }
44
60
  const declared = stringMember(value, "role", at);
45
- if (declared !== "" && declared !== role) {
61
+ if (declared !== undefined && declared !== role) {
46
62
  throw new ScopeError(`scope: ${at}.role is ${JSON.stringify(declared)}, want ${JSON.stringify(role)}`);
47
63
  }
48
64
  const path = stringMember(value, "path", at);
49
- if (path === "") {
65
+ if (path === undefined) {
50
66
  throw new ScopeError(`scope: ${at}.path names nothing`);
51
67
  }
52
68
  return {
53
- id: stringMember(value, "id", at),
69
+ id: stringMember(value, "id", at) ?? "",
54
70
  path: isAbsolutePath(path) ? path : joinPath(documentDir, path),
55
71
  };
56
72
  }
57
73
  /**
58
- * Decodes the scope document at `file`. Decoding is strict: an undeclared key is an
59
- * error, and a relative path inside the document resolves against the document's
60
- * own directory.
74
+ * Walks the document's text against the closed key list, refusing a key the list
75
+ * does not declare, compared as bytes, and a key one object writes twice, which a
76
+ * parse cannot report because it keeps the last value.
77
+ */
78
+ function checkKeys(text, file) {
79
+ try {
80
+ checkDocument(text, SCOPE_ROOT, `scope: ${file}`);
81
+ }
82
+ catch (error) {
83
+ if (error instanceof ConfigError) {
84
+ // The configuration's refusal names the nearest configuration key, which a
85
+ // scope document does not have.
86
+ throw new ScopeError(error.kind === "unimplemented-key"
87
+ ? `scope: ${file}: ${error.key} is not a declared key`
88
+ : error.message);
89
+ }
90
+ throw error;
91
+ }
92
+ }
93
+ /**
94
+ * Decodes the scope document at `file`. Decoding is strict: an undeclared key, a
95
+ * key written twice in one object, and a value of a type the document does not
96
+ * admit, `null` included, are errors, and a relative path inside the document
97
+ * resolves against the document's own directory.
61
98
  *
62
- * The `workspace` key the format declares is read and carries nothing here: it
99
+ * The `workspace` key the format declares is checked and carries nothing here: it
63
100
  * names the file a Go consumer resolves the target through, and a TypeScript
64
101
  * project resolves one through its own compiler configuration.
65
102
  */
@@ -85,11 +122,8 @@ export function readScope(host, file) {
85
122
  if (!isRecord(value)) {
86
123
  throw new ScopeError(`scope: ${file} is not one JSON object`);
87
124
  }
88
- for (const name of Object.keys(value)) {
89
- if (!DECLARED_KEYS.has(name)) {
90
- throw new ScopeError(`scope: ${file}: ${name} is not a declared key`);
91
- }
92
- }
125
+ checkKeys(text, file);
126
+ stringMember(value, "workspace", "");
93
127
  const documentDir = dirnamePath(absolute);
94
128
  if (!Object.hasOwn(value, "target")) {
95
129
  throw new ScopeError(`scope: ${file} names no target`);
@@ -3,10 +3,12 @@
3
3
  * `JSON.stringify` or a call `ts.serializers` names, and those and `toJSON` and
4
4
  * `toString` where a value is passed to an `unknown` or `any` parameter of a function
5
5
  * outside the analysis. A function of the project handing such a parameter of its own
6
- * to a destination is one, holding back what that destination does, to a fixpoint.
6
+ * to a destination is one, holding back what that destination does, to a fixpoint. A
7
+ * function invoked through `Function.prototype.call`, `apply` or `bind` is the callee,
8
+ * given the arguments those methods pass it.
7
9
  */
8
- import { isArrowFunction, isClassDeclaration, isClassExpression, isClassStaticBlockDeclaration, isFunctionDeclaration, isFunctionExpression, isGetAccessorDeclaration, isIdentifier, isMethodDeclaration, isNoSubstitutionTemplateLiteral, isNumericLiteral, isObjectLiteralExpression, isParenthesizedExpression, isSetAccessorDeclaration, isSpreadElement, isStringLiteral, isVariableDeclaration, ModifierFlags, SyntaxKind, } from "@typescript/native/unstable/ast";
9
- import { SymbolFlags, TypeFlags, } from "@typescript/native/unstable/sync";
10
+ import { isArrayLiteralExpression, isArrowFunction, isCallExpression, isClassDeclaration, isClassExpression, isClassStaticBlockDeclaration, isFunctionDeclaration, isFunctionExpression, isGetAccessorDeclaration, isIdentifier, isMethodDeclaration, isNoSubstitutionTemplateLiteral, isNumericLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPropertyAccessExpression, isSetAccessorDeclaration, isSpreadElement, isStringLiteral, isVariableDeclaration, ModifierFlags, SyntaxKind, } from "@typescript/native/unstable/ast";
11
+ import { SignatureKind, SymbolFlags, TypeFlags, } from "@typescript/native/unstable/sync";
10
12
  import { aliasChains } from "./alias-chain.js";
11
13
  import { calleeName, resolvedTargets, valuesPassed } from "./calls.js";
12
14
  import { classMembers, memberComponent, typesAt } from "./class-members.js";
@@ -71,6 +73,55 @@ function evaluated(argument) {
71
73
  const value = isSpreadElement(argument) ? argument.expression : argument;
72
74
  return isParenthesizedExpression(value) ? evaluated(value.expression) : value;
73
75
  }
76
+ /** The methods of `Function.prototype` that invoke a function with arguments of their own. */
77
+ const FUNCTION_METHODS = new Set(["call", "apply", "bind"]);
78
+ /** The interfaces of the standard library that declare those methods for a function value. */
79
+ const FUNCTION_INTERFACES = new Set([
80
+ "Function",
81
+ "CallableFunction",
82
+ "NewableFunction",
83
+ ]);
84
+ function unparenthesized(expression) {
85
+ return isParenthesizedExpression(expression)
86
+ ? unparenthesized(expression.expression)
87
+ : expression;
88
+ }
89
+ /**
90
+ * The invocation a call writes through a method of `Function.prototype`, read from
91
+ * the syntax alone: `f.call(self, a)`, `f.apply(self, [a])`, `f.bind(self, a)`, which
92
+ * binds `a`, and `f.bind(self, a)(b)`, which passes `b` after it. An `apply` argument
93
+ * that is no array literal is the value of the first parameter, whose elements it
94
+ * carries. Whether the method is the prototype's is resolution's to say.
95
+ */
96
+ function throughMethod(call) {
97
+ const expression = unparenthesized(call.expression);
98
+ if (isPropertyAccessExpression(expression) && FUNCTION_METHODS.has(expression.name.text)) {
99
+ const [, ...rest] = call.arguments;
100
+ let passed = rest;
101
+ if (expression.name.text === "apply") {
102
+ const list = rest[0] === undefined ? undefined : unparenthesized(rest[0]);
103
+ passed = list === undefined ? [] : isArrayLiteralExpression(list) ? list.elements : [list];
104
+ }
105
+ return {
106
+ method: expression.name,
107
+ invocation: { callee: expression.expression, arguments: passed, indirect: true },
108
+ };
109
+ }
110
+ if (isCallExpression(expression)) {
111
+ const bound = unparenthesized(expression.expression);
112
+ if (isPropertyAccessExpression(bound) && bound.name.text === "bind") {
113
+ return {
114
+ method: bound.name,
115
+ invocation: {
116
+ callee: bound.expression,
117
+ arguments: [...expression.arguments.slice(1), ...call.arguments],
118
+ indirect: true,
119
+ },
120
+ };
121
+ }
122
+ }
123
+ return undefined;
124
+ }
74
125
  /**
75
126
  * Every call of the project's own files with the class `this` stands for there, and
76
127
  * every identifier-named parameter of every function, method and function-valued
@@ -146,9 +197,9 @@ function erasedParameters(project, parameters) {
146
197
  return bySymbol;
147
198
  }
148
199
  /** Each place a call's argument passes one of the erased parameters it can see. */
149
- function handedOn(project, sites, erased) {
200
+ function handedOn(project, sites, erased, invocationOf) {
150
201
  const spellings = new Set([...erased.values()].map((parameter) => parameter.name.getText()));
151
- const candidates = sites.flatMap((site) => site.call.arguments.flatMap((argument, index) => valuesPassed(argument)
202
+ const candidates = sites.flatMap((site) => invocationOf(site.call).arguments.flatMap((argument, index) => valuesPassed(argument)
152
203
  .filter((value) => spellings.has(value.name.getText()))
153
204
  .map((value) => ({ site, argument: index, value }))));
154
205
  const plain = candidates.filter((one) => !one.value.shorthand);
@@ -175,11 +226,20 @@ function destinations(input, chains, walked) {
175
226
  const checker = project.checker;
176
227
  const own = new Set(project.ownSourceFiles().map((file) => file.fileName));
177
228
  const named = resolveEntries(project, held, [JSON_STRINGIFY, ...input.ts.serializers]).filter(namesADeclaration);
178
- const callees = resolvedTargets(project, walked.sites.flatMap((site) => calleeName(site.call.expression) ?? []));
179
- const calleeOf = (call) => {
180
- const name = calleeName(call.expression);
181
- return name === undefined ? undefined : callees.get(name);
182
- };
229
+ const forms = new Map();
230
+ for (const site of walked.sites) {
231
+ const form = throughMethod(site.call);
232
+ if (form !== undefined) {
233
+ forms.set(site.call, form);
234
+ }
235
+ }
236
+ const callees = resolvedTargets(project, [
237
+ ...walked.sites.flatMap((site) => calleeName(site.call.expression) ?? []),
238
+ ...[...forms.values()].flatMap((form) => {
239
+ const name = calleeName(form.invocation.callee);
240
+ return name === undefined ? [form.method] : [form.method, name];
241
+ }),
242
+ ]);
183
243
  const erased = erasedParameters(project, walked.parameters);
184
244
  const byFunction = new Map();
185
245
  for (const parameter of erased.values()) {
@@ -188,15 +248,42 @@ function destinations(input, chains, walked) {
188
248
  const outside = (target) => target.declarations.length > 0 &&
189
249
  target.declarations.every((handle) => !own.has(handle.path) &&
190
250
  !input.consumers.some((root) => handle.path === root || handle.path.startsWith(`${root}/`)));
251
+ const invocation = (call) => {
252
+ const form = forms.get(call);
253
+ const method = form === undefined ? undefined : callees.get(form.method);
254
+ // Only the standard library's function methods pass their arguments on: the
255
+ // program's own `call` and `Reflect.apply` invoke nothing here.
256
+ if (form !== undefined &&
257
+ method !== undefined &&
258
+ outside(method) &&
259
+ FUNCTION_INTERFACES.has(method.getParent()?.name ?? "")) {
260
+ return form.invocation;
261
+ }
262
+ return { callee: call.expression, arguments: call.arguments, indirect: false };
263
+ };
264
+ const calleeOf = (call) => {
265
+ const name = calleeName(invocation(call).callee);
266
+ return name === undefined ? undefined : callees.get(name);
267
+ };
191
268
  const erasedArguments = new Map();
192
269
  const erasedAt = (call, argument) => {
193
270
  let indexes = erasedArguments.get(call);
194
271
  if (indexes === undefined) {
195
- const signature = checker.getResolvedSignature(call);
196
- indexes = new Set(call.arguments.flatMap((_unused, index) => {
197
- const type = signature === undefined ? undefined : checker.getParameterType(signature, index);
198
- return type !== undefined && (type.flags & ERASED) !== 0 ? [index] : [];
199
- }));
272
+ const invoked = invocation(call);
273
+ // Through a method of the prototype the resolved signature is the method's, so
274
+ // the parameters are read off the callee's own signatures, any one erasing.
275
+ const type = invoked.indirect ? checker.getTypeAtLocation(invoked.callee) : undefined;
276
+ const signatures = invoked.indirect
277
+ ? type === undefined
278
+ ? []
279
+ : checker.getSignaturesOfType(type, SignatureKind.Call)
280
+ : [checker.getResolvedSignature(call)].filter((one) => one !== undefined);
281
+ indexes = new Set(invoked.arguments.flatMap((_unused, index) => signatures.some((signature) => {
282
+ const parameter = checker.getParameterType(signature, index);
283
+ return parameter !== undefined && (parameter.flags & ERASED) !== 0;
284
+ })
285
+ ? [index]
286
+ : []));
200
287
  erasedArguments.set(call, indexes);
201
288
  }
202
289
  return indexes.has(argument);
@@ -215,7 +302,10 @@ function destinations(input, chains, walked) {
215
302
  const declared = chains.declarationsOf(target);
216
303
  if (declared.length === 0) {
217
304
  return outside(target) && erasedAt(call, argument)
218
- ? { reach: "conversions", callee: call.expression.getText().replace(/\s+/gu, "") }
305
+ ? {
306
+ reach: "conversions",
307
+ callee: invocation(call).callee.getText().replace(/\s+/gu, ""),
308
+ }
219
309
  : undefined;
220
310
  }
221
311
  for (const fn of declared) {
@@ -227,7 +317,7 @@ function destinations(input, chains, walked) {
227
317
  }
228
318
  return undefined;
229
319
  };
230
- const forwarded = handedOn(project, walked.sites, erased);
320
+ const forwarded = handedOn(project, walked.sites, erased, invocation);
231
321
  for (let moved = true; moved;) {
232
322
  moved = false;
233
323
  for (const one of forwarded) {
@@ -254,13 +344,13 @@ function destinations(input, chains, walked) {
254
344
  }
255
345
  return declared.some((fn) => (byFunction.get(fn) ?? []).some((parameter) => forwards.has(keyOf(parameter))));
256
346
  };
257
- return { at, mayReceive };
347
+ return { invocation, at, mayReceive };
258
348
  }
259
349
  /** Each argument of a call that can receive one whose value carries a class of the target. */
260
350
  function flowsOf(project, held, sites, to) {
261
351
  const reach = valueReach(project, held);
262
352
  const arguments_ = sites.flatMap((site) => to.mayReceive(site.call)
263
- ? site.call.arguments.flatMap((argument, index) => {
353
+ ? to.invocation(site.call).arguments.flatMap((argument, index) => {
264
354
  const value = evaluated(argument);
265
355
  return holdsNoInstance(value) ? [] : [{ site, argument: index, value }];
266
356
  })
@@ -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 = "3.1.0";
7
+ export const CONTRACT_VERSION = "3.2.0";
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Deterministic dead-code analysis for TypeScript, class and type members included",
package/src/components.ts CHANGED
@@ -1,7 +1,7 @@
1
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.
2
+ * The dead components of one graph. A component is a set of dead declarations one
3
+ * deletion removes together: its root members, which are where the deletion starts,
4
+ * and every dead declaration that falls with them, being dead only through them.
5
5
  */
6
6
 
7
7
  import type { Cascade } from "./config.ts";
@@ -17,25 +17,22 @@ const ID_DIGITS = 4;
17
17
  /** The index of a declaration the component walk has not reached. */
18
18
  const UNVISITED = -1;
19
19
 
20
- /** One dead component and its place in the acyclic graph over the components. */
20
+ /** One dead component. */
21
21
  export interface Component {
22
22
  /** The analyzer's name, a solidus, and `c-` followed by the component's place in the order. */
23
23
  readonly id: string;
24
- /** The component's declarations, by site. A dead member of a dead container is one of them. */
25
- readonly members: readonly string[];
26
24
  /**
27
- * The members no other dead component references and whose container is not dead,
28
- * which are where a deletion starts. A cycle has as many as it has members no dead
29
- * component outside it references.
25
+ * The component's declarations by site: its root members and every dead declaration
26
+ * that falls with them. A dead member of a dead container is one of them.
30
27
  */
31
- readonly roots: readonly string[];
28
+ readonly members: readonly string[];
32
29
  /**
33
- * What one deletion of the component removes: its members, and every dead declaration
34
- * only this component reaches. A dead declaration two components both reach falls with
35
- * neither and is counted by its own component.
30
+ * The root members, by site. A root member is a member of a cycle no dead declaration
31
+ * outside the cycle references, whose container is not dead; every member of such a
32
+ * cycle with a live container is one, so a component has at least one.
36
33
  */
37
- readonly falls: readonly string[];
38
- /** The source lines the deletion removes: the distinct lines {@link Component.falls} spans. */
34
+ readonly roots: readonly string[];
35
+ /** The source lines one deletion of the component removes: the distinct lines its members span. */
39
36
  readonly deletableLines: number;
40
37
  }
41
38
 
@@ -48,22 +45,22 @@ export interface Listing {
48
45
  readonly deletableLines: number;
49
46
  }
50
47
 
51
- /** What a report carries for one component: its roots and what falls, and its members in full. */
48
+ /** What a report carries for one component: its roots and counts, and its members in full. */
52
49
  export function listing(component: Component, cascade: Cascade): Listing {
53
50
  return {
54
51
  roots: component.roots,
55
52
  members: cascade === "full" ? component.members : [],
56
- symbolCount: component.falls.length,
53
+ symbolCount: component.members.length,
57
54
  deletableLines: component.deletableLines,
58
55
  };
59
56
  }
60
57
 
61
58
  /**
62
- * Groups the declarations `dead` marks into components and orders them so each precedes
63
- * every component it reaches, two that neither reaches ordered by their first member's
64
- * site. `dead` and `testOfDeadCode` hold one flag per declaration of the graph; the dead
65
- * set arrives rather than being read from a sweep, so a set several sweeps intersect is
66
- * grouped over this graph's edges the same way.
59
+ * Groups the declarations `dead` marks into components, ordered by their first member's
60
+ * site; `dead` and `testOfDeadCode` hold one flag per declaration of the graph. Root
61
+ * members sit on the cycles no other cycle references. A cycle two roots both reach is
62
+ * dead only through both, so a component is a weakly connected part of the dead
63
+ * subgraph: no dead declaration of one references a declaration of another.
67
64
  */
68
65
  export function componentsOf(
69
66
  graph: Graph,
@@ -75,26 +72,22 @@ export function componentsOf(
75
72
  return [];
76
73
  }
77
74
  const { componentOf, members } = connected(adjacent);
78
- const { edges, into } = condense(adjacent, componentOf, members.length);
79
- const falls = fallSets(edges, members, into);
80
- const outsideReference = externallyReferenced(adjacent, componentOf);
75
+ const referenced = referencedCycles(adjacent, componentOf, members.length);
76
+ const groups = joined(adjacent);
81
77
  const idOf = (position: number): string => graph.symbols[at[position] ?? 0]?.id ?? "";
82
78
 
83
- return order(edges, members).map((group, place) => {
84
- const own = members[group] ?? [];
85
- const roots = own.filter((position) => {
86
- // A dead member of a dead container is never a root: the container is where the
87
- // deletion starts and the member falls with it.
79
+ return groups.map((group, place) => {
80
+ const roots = group.filter((position) => {
81
+ // A dead member's dead container is on the member's own cycle, and the container
82
+ // is where the deletion starts.
88
83
  const container = graph.parent[at[position] ?? 0] ?? OUTSIDE;
89
- return outsideReference[position] !== true && dead[container] !== true;
84
+ return referenced[componentOf[position] ?? 0] !== true && dead[container] !== true;
90
85
  });
91
- const fallen = falls[group] ?? [];
92
86
  return {
93
87
  id: `${ANALYZER}/c-${String(place + 1).padStart(ID_DIGITS, "0")}`,
94
- members: own.map(idOf),
88
+ members: group.map(idOf),
95
89
  roots: roots.map(idOf),
96
- falls: fallen.map(idOf),
97
- deletableLines: linesSpanned(fallen.map((position) => graph.symbols[at[position] ?? 0])),
90
+ deletableLines: linesSpanned(group.map((position) => graph.symbols[at[position] ?? 0])),
98
91
  };
99
92
  });
100
93
  }
@@ -185,10 +178,9 @@ interface Frame {
185
178
  }
186
179
 
187
180
  /**
188
- * Each declaration's component and each component's members by site, by Tarjan's
181
+ * Each declaration's cycle and each cycle's members by site, by Tarjan's
189
182
  * algorithm in one pass. The walk keeps its own stack of frames, so a chain of any length
190
- * is walked without growing the call stack. Components are closed in an order in which
191
- * each follows every component it reaches.
183
+ * is walked without growing the call stack.
192
184
  */
193
185
  function connected(adjacent: readonly (readonly number[])[]): {
194
186
  readonly componentOf: readonly number[];
@@ -257,45 +249,18 @@ function connected(adjacent: readonly (readonly number[])[]): {
257
249
  return { componentOf, members };
258
250
  }
259
251
 
260
- /**
261
- * Per component, the components it reaches in one step, and how many components reach
262
- * it, each pair counted once. An edge inside a component is not an edge between two, so
263
- * the result is acyclic.
264
- */
265
- function condense(
252
+ /** Per cycle, whether a dead declaration outside it references one of its members. */
253
+ function referencedCycles(
266
254
  adjacent: readonly (readonly number[])[],
267
255
  componentOf: readonly number[],
268
256
  count: number,
269
- ): { readonly edges: readonly (readonly number[])[]; readonly into: readonly number[] } {
270
- const edges: number[][] = Array.from({ length: count }, () => []);
271
- const into = Array.from({ length: count }, () => 0);
272
- const seen = new Set<string>();
273
- adjacent.forEach((targets, from) => {
274
- for (const to of targets) {
275
- const a = componentOf[from] ?? 0;
276
- const b = componentOf[to] ?? 0;
277
- const pair = `${String(a)}>${String(b)}`;
278
- if (a === b || seen.has(pair)) {
279
- continue;
280
- }
281
- seen.add(pair);
282
- edges[a]?.push(b);
283
- into[b] = (into[b] ?? 0) + 1;
284
- }
285
- });
286
- return { edges, into };
287
- }
288
-
289
- /** Per dead declaration, whether a dead component other than its own references it. */
290
- function externallyReferenced(
291
- adjacent: readonly (readonly number[])[],
292
- componentOf: readonly number[],
293
257
  ): readonly boolean[] {
294
- const referenced = adjacent.map(() => false);
258
+ const referenced = Array.from({ length: count }, () => false);
295
259
  adjacent.forEach((targets, from) => {
296
260
  for (const to of targets) {
297
- if (componentOf[from] !== componentOf[to]) {
298
- referenced[to] = true;
261
+ const cycle = componentOf[to] ?? 0;
262
+ if (componentOf[from] !== cycle) {
263
+ referenced[cycle] = true;
299
264
  }
300
265
  }
301
266
  });
@@ -303,70 +268,42 @@ function externallyReferenced(
303
268
  }
304
269
 
305
270
  /**
306
- * The components in the order a report is worked in. Tarjan closes each component after
307
- * every one it reaches, so reading the closing order backwards is a topological order,
308
- * and one pass over it gives each component its depth below the roots.
271
+ * The weakly connected parts of the dead subgraph, each its positions in ascending
272
+ * order, ordered by their first position. Positions follow the graph's declaration
273
+ * order, so a part's first position is its first member's site.
309
274
  */
310
- function order(
311
- edges: readonly (readonly number[])[],
312
- members: readonly (readonly number[])[],
313
- ): readonly number[] {
314
- const sequence = edges.map((_targets, component) => component).reverse();
315
- const depth = edges.map(() => 0);
316
- for (const from of sequence) {
317
- for (const to of edges[from] ?? []) {
318
- depth[to] = Math.max(depth[to] ?? 0, (depth[from] ?? 0) + 1);
275
+ function joined(adjacent: readonly (readonly number[])[]): readonly (readonly number[])[] {
276
+ const leader = adjacent.map((_edges, position) => position);
277
+ const find = (position: number): number => {
278
+ let top = position;
279
+ while ((leader[top] ?? top) !== top) {
280
+ top = leader[top] ?? top;
319
281
  }
320
- }
321
- return sequence.sort(
322
- (a, b) => (depth[a] ?? 0) - (depth[b] ?? 0) || (members[a]?.[0] ?? 0) - (members[b]?.[0] ?? 0),
323
- );
324
- }
325
-
326
- /**
327
- * Per component, the dead declarations one deletion of it removes. A root of the
328
- * condensation carries its members and every component only it reaches; any other
329
- * component carries its own members alone, because what it reaches is reached through a
330
- * root above it as well.
331
- */
332
- function fallSets(
333
- edges: readonly (readonly number[])[],
334
- members: readonly (readonly number[])[],
335
- into: readonly number[],
336
- ): readonly (readonly number[])[] {
337
- // Each root's walk stamps what it reaches with the root's own number, so the walks
338
- // share one array rather than allocating one per root.
339
- const stamp = edges.map(() => UNVISITED);
340
- const reach = new Map<number, readonly number[]>();
341
- const owners = edges.map(() => 0);
342
- edges.forEach((_targets, root) => {
343
- if (into[root] !== 0) {
344
- return;
282
+ // Every position on the walk points at the top afterwards, so a later find is short.
283
+ for (let step = position; step !== top;) {
284
+ const next = leader[step] ?? top;
285
+ leader[step] = top;
286
+ step = next;
345
287
  }
346
- const reached = [root];
347
- stamp[root] = root;
348
- // An array's iterator reads its length at every step, so what a step pushes is
349
- // walked in the same loop.
350
- for (const at of reached) {
351
- for (const to of edges[at] ?? []) {
352
- if (stamp[to] !== root) {
353
- stamp[to] = root;
354
- reached.push(to);
355
- }
356
- }
357
- }
358
- reach.set(root, reached);
359
- for (const component of reached) {
360
- owners[component] = (owners[component] ?? 0) + 1;
288
+ return top;
289
+ };
290
+ adjacent.forEach((targets, from) => {
291
+ for (const to of targets) {
292
+ const a = find(from);
293
+ const b = find(to);
294
+ // The smaller position leads, so a part's leader is its first position.
295
+ leader[Math.max(a, b)] = Math.min(a, b);
361
296
  }
362
297
  });
363
- return members.map((own, component) => {
364
- const set = [...own];
365
- for (const other of reach.get(component) ?? []) {
366
- if (other !== component && owners[other] === 1) {
367
- set.push(...(members[other] ?? []));
368
- }
298
+ const parts = new Map<number, number[]>();
299
+ adjacent.forEach((_edges, position) => {
300
+ const top = find(position);
301
+ const part = parts.get(top);
302
+ if (part === undefined) {
303
+ parts.set(top, [position]);
304
+ } else {
305
+ part.push(position);
369
306
  }
370
- return set.sort((a, b) => a - b);
371
307
  });
308
+ return [...parts.values()];
372
309
  }