@cplieger/deadset-ts 3.3.1 → 3.4.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,308 @@
1
+ /**
2
+ * The declarations the configuration's entries name, resolved in one project, and
3
+ * which of them a symbol is. Declarations are compared rather than symbols, because a
4
+ * member read through an instantiation of a generic type is a symbol of its own that
5
+ * keeps the declarations of the member it instantiates.
6
+ */
7
+
8
+ import {
9
+ isCallExpression,
10
+ isExportDeclaration,
11
+ isExternalModuleReference,
12
+ isImportDeclaration,
13
+ isImportEqualsDeclaration,
14
+ isNoSubstitutionTemplateLiteral,
15
+ isStringLiteral,
16
+ SyntaxKind,
17
+ type Node,
18
+ } from "@typescript/native/unstable/ast";
19
+ import {
20
+ SymbolFlags,
21
+ type NodeHandle,
22
+ type Symbol as TSSymbol,
23
+ } from "@typescript/native/unstable/sync";
24
+ import type { AliasChains } from "./alias-chain.ts";
25
+ import { resolvedTargets } from "./calls.ts";
26
+ import type { DeclarationEntry } from "./config.ts";
27
+ import type { Inventory } from "./inventory.ts";
28
+ import { nameComponent } from "./ref.ts";
29
+ import type { ProjectView } from "./session.ts";
30
+
31
+ /** The meanings a name of the global scope is looked up under: any of them. */
32
+ const ANY_MEANING: SymbolFlags = SymbolFlags.Value | SymbolFlags.Type | SymbolFlags.Namespace;
33
+
34
+ /** The suffix that marks a static member at the end of a path. */
35
+ const STATIC = ":static";
36
+
37
+ /**
38
+ * One entry and the declaration it names in one project. Both sets are empty where
39
+ * the entry names no declaration there, which is how an entry that names nothing is
40
+ * told from one that names something no call reaches.
41
+ */
42
+ export interface ResolvedEntry {
43
+ readonly entry: DeclarationEntry;
44
+ /** The declarations a module or global entry resolves to, each as its handle's key. */
45
+ readonly handles: ReadonlySet<string>;
46
+ /** The inventory's identifier of the declaration a symbol entry names. */
47
+ readonly ids: ReadonlySet<string>;
48
+ }
49
+
50
+ /** The key one declaration handle is compared by: its file and its index in that file. */
51
+ function handleKey(handle: NodeHandle): string {
52
+ return `${handle.path}:${String(handle.index)}`;
53
+ }
54
+
55
+ /**
56
+ * The components of one declaration path and whether its last one is a static member.
57
+ * A path is split at the dots outside a quoted or a bracketed component, and each
58
+ * component keeps the spelling a reference gives it.
59
+ */
60
+ function componentsOf(path: string): { readonly components: string[]; readonly static: boolean } {
61
+ const isStatic = path.endsWith(STATIC);
62
+ const body = isStatic ? path.slice(0, -STATIC.length) : path;
63
+ const components: string[] = [];
64
+ let current = "";
65
+ let closing = "";
66
+ for (let at = 0; at < body.length; at += 1) {
67
+ const ch = body.charAt(at);
68
+ if (closing === "'" && ch === "\\") {
69
+ current += ch + body.charAt(at + 1);
70
+ at += 1;
71
+ continue;
72
+ }
73
+ if (closing !== "") {
74
+ closing = ch === closing ? "" : closing;
75
+ } else if (ch === "'" || ch === "[") {
76
+ closing = ch === "'" ? "'" : "]";
77
+ } else if (ch === ".") {
78
+ components.push(current);
79
+ current = "";
80
+ continue;
81
+ }
82
+ current += ch;
83
+ }
84
+ components.push(current);
85
+ return { components, static: isStatic };
86
+ }
87
+
88
+ /** What a symbol is once every alias it stands for is followed. */
89
+ function unaliased<Brand>(project: ProjectView<Brand>, symbol: TSSymbol): TSSymbol | undefined {
90
+ if ((symbol.flags & SymbolFlags.Alias) === 0) {
91
+ return symbol;
92
+ }
93
+ const target = project.checker.getAliasedSymbol(symbol);
94
+ return target.declarations.length === 0 ? undefined : target;
95
+ }
96
+
97
+ /** Whether one symbol is a member a class declares: a property, a method or an accessor. */
98
+ function isClassMember(symbol: TSSymbol): boolean {
99
+ return (symbol.flags & SymbolFlags.ClassMember) !== 0;
100
+ }
101
+
102
+ /**
103
+ * The members of one container a path component may name. A class keeps its static
104
+ * members among its exports beside what a merged namespace exports, so a component
105
+ * with the static suffix names one of the first and a component without it names an
106
+ * instance member or one of the second.
107
+ */
108
+ function candidatesOf(container: TSSymbol, isStatic: boolean): readonly TSSymbol[] {
109
+ const exported = [...container.getExports().values()];
110
+ if (isStatic) {
111
+ return exported.filter(isClassMember);
112
+ }
113
+ const isClass = (container.flags & SymbolFlags.Class) !== 0;
114
+ return [
115
+ ...container.getMembers().values(),
116
+ ...exported.filter((member) => !isClass || !isClassMember(member)),
117
+ ];
118
+ }
119
+
120
+ /** The member of one container a path component spells, followed through any alias. */
121
+ function memberOf<Brand>(
122
+ project: ProjectView<Brand>,
123
+ container: TSSymbol,
124
+ component: string,
125
+ isStatic: boolean,
126
+ ): TSSymbol | undefined {
127
+ const member = candidatesOf(container, isStatic).find(
128
+ (candidate) => nameComponent(candidate.name) === component,
129
+ );
130
+ return member === undefined ? undefined : unaliased(project, member);
131
+ }
132
+
133
+ /** The declaration a path names from one starting symbol, component by component. */
134
+ function walkPath<Brand>(
135
+ project: ProjectView<Brand>,
136
+ start: TSSymbol | undefined,
137
+ rest: readonly string[],
138
+ isStatic: boolean,
139
+ ): TSSymbol | undefined {
140
+ let at = start;
141
+ rest.forEach((component, index) => {
142
+ if (at !== undefined) {
143
+ at = memberOf(project, at, component, isStatic && index === rest.length - 1);
144
+ }
145
+ });
146
+ return at;
147
+ }
148
+
149
+ /** Whether one node is literal text: a string literal or a template with no substitution. */
150
+ function isLiteralText(node: Node | undefined): node is Node & { readonly text: string } {
151
+ return node !== undefined && (isStringLiteral(node) || isNoSubstitutionTemplateLiteral(node));
152
+ }
153
+
154
+ /** The module specifier one node writes, for each form that imports or re-exports a module. */
155
+ function specifierOf(node: Node): Node | undefined {
156
+ if (isImportDeclaration(node) || isExportDeclaration(node)) {
157
+ return node.moduleSpecifier;
158
+ }
159
+ if (isImportEqualsDeclaration(node) && isExternalModuleReference(node.moduleReference)) {
160
+ return node.moduleReference.expression;
161
+ }
162
+ if (isCallExpression(node) && node.expression.kind === SyntaxKind.ImportKeyword) {
163
+ return node.arguments[0];
164
+ }
165
+ return undefined;
166
+ }
167
+
168
+ /**
169
+ * The module each specifier the entries name resolves to in one project, read from a
170
+ * specifier the project's own files write, so the module is the one the target's own
171
+ * import resolves. A specifier no file writes resolves to no module.
172
+ */
173
+ function modulesNamed<Brand>(
174
+ project: ProjectView<Brand>,
175
+ specifiers: ReadonlySet<string>,
176
+ ): ReadonlyMap<string, TSSymbol> {
177
+ const written = new Map<string, Node>();
178
+ for (const file of project.ownSourceFiles()) {
179
+ const visit = (node: Node): void => {
180
+ const specifier = specifierOf(node);
181
+ if (
182
+ isLiteralText(specifier) &&
183
+ specifiers.has(specifier.text) &&
184
+ !written.has(specifier.text)
185
+ ) {
186
+ written.set(specifier.text, specifier);
187
+ }
188
+ node.forEachChild(visit);
189
+ };
190
+ file.forEachChild(visit);
191
+ }
192
+ const specified = [...written];
193
+ const symbols = project.symbolsAt(specified.map(([, node]) => project.handle(node)));
194
+ const modules = new Map<string, TSSymbol>();
195
+ specified.forEach(([name], index) => {
196
+ const symbol = symbols[index];
197
+ if (symbol !== undefined) {
198
+ modules.set(name, symbol);
199
+ }
200
+ });
201
+ return modules;
202
+ }
203
+
204
+ /**
205
+ * The declaration each entry names in one project. A symbol entry names the
206
+ * inventory's declaration whose reference it spells exactly; a module entry names the
207
+ * declaration its path reaches from the exports of the module its specifier resolves
208
+ * to; a global entry names the declaration its path reaches from the global scope.
209
+ * Every alias along a path is followed to the declaration it stands for.
210
+ */
211
+ export function resolveEntries<Brand>(
212
+ project: ProjectView<Brand>,
213
+ held: Inventory,
214
+ entries: readonly DeclarationEntry[],
215
+ ): readonly ResolvedEntry[] {
216
+ if (entries.length === 0) {
217
+ return [];
218
+ }
219
+ const byRef = new Map(held.symbols.map((symbol) => [symbol.ref, symbol.id]));
220
+ const modules = modulesNamed(
221
+ project,
222
+ new Set(entries.flatMap((entry) => (entry.shape === "module" ? [entry.module] : []))),
223
+ );
224
+ return entries.map((entry) => {
225
+ if (entry.shape === "symbol") {
226
+ const id = byRef.get(entry.symbol);
227
+ return { entry, handles: new Set<string>(), ids: new Set(id === undefined ? [] : [id]) };
228
+ }
229
+ const { components, static: isStatic } = componentsOf(
230
+ entry.shape === "module" ? entry.name : entry.global,
231
+ );
232
+ const [first = "", ...rest] = components;
233
+ let start: TSSymbol | undefined;
234
+ if (isStatic && rest.length === 0) {
235
+ // Only a member is static, and a path of one component names no member.
236
+ start = undefined;
237
+ } else if (entry.shape === "module") {
238
+ const module = modules.get(entry.module);
239
+ start = module === undefined ? undefined : memberOf(project, module, first, false);
240
+ } else if (nameComponent(first) === first) {
241
+ const global = project.checker.resolveName(first, ANY_MEANING, undefined, false);
242
+ start = global === undefined ? undefined : unaliased(project, global);
243
+ }
244
+ const declared = walkPath(project, start, rest, isStatic);
245
+ return {
246
+ entry,
247
+ handles: new Set(declared?.declarations.map(handleKey) ?? []),
248
+ ids: new Set<string>(),
249
+ };
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Whether one symbol is the declaration a resolved entry names: it shares one of the
255
+ * entry's declarations, or, for an entry naming a declaration of the program, it is
256
+ * declared there. `chains` reads the symbol's declarations against the inventory the
257
+ * entries were resolved with.
258
+ */
259
+ export function names(resolved: ResolvedEntry, symbol: TSSymbol, chains: AliasChains): boolean {
260
+ if (symbol.declarations.some((handle) => resolved.handles.has(handleKey(handle)))) {
261
+ return true;
262
+ }
263
+ return resolved.ids.size > 0 && chains.declarationsOf(symbol).some((id) => resolved.ids.has(id));
264
+ }
265
+
266
+ /** Whether a resolved entry names a declaration, so that some symbol can be it. */
267
+ export function namesADeclaration(resolved: ResolvedEntry): boolean {
268
+ return resolved.handles.size > 0 || resolved.ids.size > 0;
269
+ }
270
+
271
+ /**
272
+ * The entries each of a batch of name nodes is: what the node resolves to, every alias
273
+ * followed, is the declaration each of them names. A node that is none is absent.
274
+ */
275
+ export function entriesAt<Brand>(
276
+ project: ProjectView<Brand>,
277
+ chains: AliasChains,
278
+ nodes: readonly Node[],
279
+ resolved: readonly ResolvedEntry[],
280
+ ): ReadonlyMap<Node, readonly ResolvedEntry[]> {
281
+ const naming = resolved.filter(namesADeclaration);
282
+ const found = new Map<Node, readonly ResolvedEntry[]>();
283
+ if (naming.length === 0) {
284
+ return found;
285
+ }
286
+ for (const [node, target] of resolvedTargets(project, nodes)) {
287
+ const entries = naming.filter((entry) => names(entry, target, chains));
288
+ if (entries.length > 0) {
289
+ found.set(node, entries);
290
+ }
291
+ }
292
+ return found;
293
+ }
294
+
295
+ /**
296
+ * How one entry is spelled in a record's detail: its path, or for a declaration of the
297
+ * program the display name the inventory gives it.
298
+ */
299
+ export function spelledEntry(entry: DeclarationEntry, held: Inventory): string {
300
+ switch (entry.shape) {
301
+ case "symbol":
302
+ return held.symbols.find((symbol) => symbol.ref === entry.symbol)?.name ?? entry.symbol;
303
+ case "module":
304
+ return entry.name;
305
+ case "global":
306
+ return entry.global;
307
+ }
308
+ }
package/src/decorator.ts CHANGED
@@ -11,12 +11,8 @@
11
11
  * the inventory holds for the class, static members included.
12
12
  */
13
13
 
14
- import {
15
- isCallExpression,
16
- isDecorator,
17
- type Decorator,
18
- type Node,
19
- } from "@typescript/native/unstable/ast";
14
+ import type { Node } from "@typescript/native/unstable/ast";
15
+ import { decoratorsOf, spelledDecorator } from "./calls.ts";
20
16
  import type { DetectorInput, Evidence } from "./exempt.ts";
21
17
  import { nodeKey, type InventorySymbol, type SymbolKind } from "./inventory.ts";
22
18
  import { renderPosition } from "./position.ts";
@@ -24,13 +20,6 @@ import { renderPosition } from "./position.ts";
24
20
  /** The kinds of declaration a decorator is attached to and holds back. */
25
21
  const DECORATED: ReadonlySet<SymbolKind> = new Set(["class", "method", "class-member"]);
26
22
 
27
- /** How one decorator is spelled in a record: its expression, without a call's arguments. */
28
- function spelled(decorator: Decorator): string {
29
- const expression = decorator.expression;
30
- const named = isCallExpression(expression) ? expression.expression : expression;
31
- return `@${named.getText().replace(/\s+/gu, "")}`;
32
- }
33
-
34
23
  /**
35
24
  * The decorator detector: each decorated member, and each member of a decorated
36
25
  * class, recorded at the decorator.
@@ -56,9 +45,8 @@ export function decorator<Brand>(input: DetectorInput<Brand>): readonly Evidence
56
45
  const id = held.declarations.get(nodeKey(file, node));
57
46
  const symbol = id === undefined ? undefined : byId.get(id);
58
47
  if (symbol !== undefined && DECORATED.has(symbol.kind)) {
59
- const modifiers = (node as { readonly modifiers?: readonly Node[] }).modifiers ?? [];
60
- for (const attached of modifiers.filter(isDecorator)) {
61
- const detail = `decorated by ${spelled(attached)}`;
48
+ for (const attached of decoratorsOf(node)) {
49
+ const detail = `decorated by ${spelledDecorator(attached)}`;
62
50
  const site = renderPosition(file, targetRoot, attached.getStart());
63
51
  const reached = symbol.kind === "class" ? (membersOf.get(symbol.id) ?? []) : [symbol];
64
52
  for (const member of reached) {
package/src/exempt.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * hands it the table of the classes it has.
8
8
  */
9
9
 
10
- import type { Config } from "./config.ts";
10
+ import type { Config, TSSection } from "./config.ts";
11
11
  import { ConfigError } from "./config.ts";
12
12
  import {
13
13
  EXEMPTION_CLASSES,
@@ -41,6 +41,10 @@ export interface DetectorInput<Brand> {
41
41
  readonly targetRoot: string;
42
42
  /** The configured templates, read once for the run. */
43
43
  readonly templates: Templates;
44
+ /** The `ts` section, whose declarations the configured classes read. */
45
+ readonly ts: TSSection;
46
+ /** The absolute path of each declared consumer, whose modules are part of the analysis. */
47
+ readonly consumers: readonly string[];
44
48
  }
45
49
 
46
50
  /**
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The framework-lifecycle class: a member a `ts.lifecycle_contracts` entry lists is held
3
+ * back on each class that is the entry's component, being a class a decorator or a call
4
+ * the entry's `components` name is attached to or passes, or one extending, directly or
5
+ * through a chain, a class its `bases` name. A component's members are its type's, so a
6
+ * member a base class declares answers for every subclass.
7
+ */
8
+
9
+ import {
10
+ isClassDeclaration,
11
+ isClassExpression,
12
+ isHeritageClause,
13
+ SyntaxKind,
14
+ type CallExpression,
15
+ type Node,
16
+ } from "@typescript/native/unstable/ast";
17
+ import type { Type } from "@typescript/native/unstable/sync";
18
+ import { aliasChains, type AliasChains } from "./alias-chain.ts";
19
+ import { calleeName, callsOf, classesPassed, decoratedClasses, decoratorCallee } from "./calls.ts";
20
+ import { classMembers, memberComponent, typesAt, type ClassSide } from "./class-members.ts";
21
+ import type { DeclarationEntry, LifecycleContract } from "./config.ts";
22
+ import {
23
+ entriesAt,
24
+ names,
25
+ namesADeclaration,
26
+ resolveEntries,
27
+ spelledEntry,
28
+ type ResolvedEntry,
29
+ } from "./configured-declarations.ts";
30
+ import type { DetectorInput, Evidence } from "./exempt.ts";
31
+ import { nodeKey, type Inventory } from "./inventory.ts";
32
+ import { renderPosition } from "./position.ts";
33
+ import type { ProjectView } from "./session.ts";
34
+
35
+ /** One class that is a component of one contract, and what says it is. */
36
+ interface Component {
37
+ readonly contract: LifecycleContract;
38
+ readonly id: string;
39
+ readonly site: Node;
40
+ readonly detail: string;
41
+ }
42
+
43
+ /** One contract's declarations, resolved in the project. */
44
+ interface ResolvedContract {
45
+ readonly contract: LifecycleContract;
46
+ readonly components: readonly ResolvedEntry[];
47
+ readonly bases: readonly ResolvedEntry[];
48
+ }
49
+
50
+ /** Every contract's components and bases, resolved in one pass over the entries. */
51
+ function resolveContracts<Brand>(
52
+ project: ProjectView<Brand>,
53
+ held: Inventory,
54
+ contracts: readonly LifecycleContract[],
55
+ ): readonly ResolvedContract[] {
56
+ const entries: DeclarationEntry[] = contracts.flatMap((contract) => [
57
+ ...contract.components,
58
+ ...contract.bases,
59
+ ]);
60
+ const resolved = resolveEntries(project, held, entries);
61
+ let next = 0;
62
+ const take = (count: number): readonly ResolvedEntry[] => {
63
+ const taken = resolved.slice(next, next + count).filter(namesADeclaration);
64
+ next += count;
65
+ return taken;
66
+ };
67
+ return contracts.map((contract) => ({
68
+ contract,
69
+ components: take(contract.components.length),
70
+ bases: take(contract.bases.length),
71
+ }));
72
+ }
73
+
74
+ /** The classes a decorator or a call the contracts' components name makes a component. */
75
+ function byComponents<Brand>(
76
+ project: ProjectView<Brand>,
77
+ held: Inventory,
78
+ chains: AliasChains,
79
+ contracts: readonly ResolvedContract[],
80
+ ): readonly Component[] {
81
+ const routed = contracts.filter((one) => one.components.length > 0);
82
+ if (routed.length === 0) {
83
+ return [];
84
+ }
85
+ const contractOf = new Map(
86
+ routed.flatMap((one) => one.components.map((entry) => [entry, one.contract] as const)),
87
+ );
88
+ const calls = callsOf(project);
89
+ const decorated = decoratedClasses(project, held);
90
+ const named = entriesAt(
91
+ project,
92
+ chains,
93
+ [
94
+ ...calls.flatMap((call) => calleeName(call.expression) ?? []),
95
+ ...decorated.flatMap((one) =>
96
+ one.decorators.flatMap((decorator) => decoratorCallee(decorator) ?? []),
97
+ ),
98
+ ],
99
+ [...contractOf.keys()],
100
+ );
101
+ const naming = (node: Node | undefined): { contract: LifecycleContract; detail: string }[] =>
102
+ (node === undefined ? [] : (named.get(node) ?? [])).flatMap((entry) => {
103
+ const contract = contractOf.get(entry);
104
+ return contract === undefined ? [] : [{ contract, detail: spelledEntry(entry.entry, held) }];
105
+ });
106
+
107
+ const found: Component[] = [];
108
+ for (const one of decorated) {
109
+ for (const decorator of one.decorators) {
110
+ for (const { contract, detail } of naming(decoratorCallee(decorator))) {
111
+ found.push({ contract, id: one.id, site: decorator, detail: `decorated by @${detail}` });
112
+ }
113
+ }
114
+ }
115
+ const matched = new Map<CallExpression, { contract: LifecycleContract; detail: string }[]>();
116
+ for (const call of calls) {
117
+ const routes = naming(calleeName(call.expression));
118
+ if (routes.length > 0) {
119
+ matched.set(call, routes);
120
+ }
121
+ }
122
+ for (const [call, uses] of classesPassed(project, held, chains, [...matched.keys()])) {
123
+ for (const route of matched.get(call) ?? []) {
124
+ for (const use of uses) {
125
+ found.push({
126
+ contract: route.contract,
127
+ id: use.id,
128
+ site: use.site,
129
+ detail: `registered by ${route.detail}`,
130
+ });
131
+ }
132
+ }
133
+ }
134
+ return found;
135
+ }
136
+
137
+ /** Each class declaration of the target, with its name and the class its `extends` names. */
138
+ interface Declared {
139
+ readonly id: string;
140
+ readonly name: Node;
141
+ readonly extended: Node;
142
+ }
143
+
144
+ /** The class declarations of the target that extend another class. */
145
+ function extendingClasses<Brand>(
146
+ project: ProjectView<Brand>,
147
+ held: Inventory,
148
+ ): readonly Declared[] {
149
+ const found: Declared[] = [];
150
+ for (const file of project.ownSourceFiles()) {
151
+ const visit = (node: Node): void => {
152
+ if ((isClassDeclaration(node) || isClassExpression(node)) && node.name !== undefined) {
153
+ const id = held.declarations.get(nodeKey(file, node));
154
+ const clause = (node.heritageClauses ?? []).find(
155
+ (one) => isHeritageClause(one) && one.token === SyntaxKind.ExtendsKeyword,
156
+ );
157
+ const extended =
158
+ clause !== undefined && isHeritageClause(clause) ? clause.types[0] : undefined;
159
+ if (id !== undefined && extended !== undefined) {
160
+ found.push({ id, name: node.name, extended });
161
+ }
162
+ }
163
+ node.forEachChild(visit);
164
+ };
165
+ file.forEachChild(visit);
166
+ }
167
+ return found;
168
+ }
169
+
170
+ /** The classes that extend, directly or through a chain, a class the contracts' bases name. */
171
+ function byBases<Brand>(
172
+ project: ProjectView<Brand>,
173
+ held: Inventory,
174
+ chains: AliasChains,
175
+ contracts: readonly ResolvedContract[],
176
+ ): readonly Component[] {
177
+ const based = contracts.filter((one) => one.bases.length > 0);
178
+ if (based.length === 0) {
179
+ return [];
180
+ }
181
+ const classes = extendingClasses(project, held);
182
+ if (classes.length === 0) {
183
+ return [];
184
+ }
185
+ const checker = project.checker;
186
+ const ancestry = new Map<number, readonly Type[]>();
187
+ const ancestorsOf = (type: Type, seen: Set<number>): readonly Type[] => {
188
+ const known = ancestry.get(type.id);
189
+ if (known !== undefined) {
190
+ return known;
191
+ }
192
+ const declared = type.isClassOrInterface()
193
+ ? type
194
+ : type.isTypeReference()
195
+ ? type.getTarget()
196
+ : undefined;
197
+ if (declared === undefined || !declared.isClassOrInterface() || seen.has(declared.id)) {
198
+ return [];
199
+ }
200
+ seen.add(declared.id);
201
+ const found = checker
202
+ .getBaseTypes(declared)
203
+ .flatMap((base) => [base, ...ancestorsOf(base, seen)]);
204
+ ancestry.set(type.id, found);
205
+ return found;
206
+ };
207
+ const types = typesAt(
208
+ project,
209
+ classes.map((one) => one.name),
210
+ );
211
+
212
+ const found: Component[] = [];
213
+ classes.forEach((one, index) => {
214
+ const type = types[index];
215
+ if (type === undefined) {
216
+ return;
217
+ }
218
+ const ancestors = ancestorsOf(type, new Set()).flatMap((base) => base.getSymbol() ?? []);
219
+ for (const contract of based) {
220
+ for (const entry of contract.bases) {
221
+ if (ancestors.some((symbol) => names(entry, symbol, chains))) {
222
+ found.push({
223
+ contract: contract.contract,
224
+ id: one.id,
225
+ site: one.extended,
226
+ detail: `extends ${spelledEntry(entry.entry, held)}`,
227
+ });
228
+ }
229
+ }
230
+ }
231
+ });
232
+ return found;
233
+ }
234
+
235
+ /**
236
+ * The framework-lifecycle detector: each member a contract lists, on each class that
237
+ * is a component of that contract's framework, recorded where the class was found to
238
+ * be one.
239
+ */
240
+ export function frameworkLifecycle<Brand>(input: DetectorInput<Brand>): readonly Evidence[] {
241
+ const { project, held, targetRoot } = input;
242
+ if (input.ts.lifecycleContracts.length === 0) {
243
+ return [];
244
+ }
245
+ const chains = aliasChains(project, held);
246
+ const contracts = resolveContracts(project, held, input.ts.lifecycleContracts);
247
+ const components = [
248
+ ...byComponents(project, held, chains, contracts),
249
+ ...byBases(project, held, chains, contracts),
250
+ ];
251
+ if (components.length === 0) {
252
+ return [];
253
+ }
254
+ const ids = new Set(components.map((one) => one.id));
255
+ const sides: readonly ClassSide[] = input.ts.lifecycleContracts.some((contract) =>
256
+ contract.members.some((name) => name.endsWith(":static")),
257
+ )
258
+ ? ["instance", "static"]
259
+ : ["instance"];
260
+ const members = sides.map((side) => classMembers(project, held, ids, side));
261
+ const byId = new Map(held.symbols.map((symbol) => [symbol.id, symbol]));
262
+
263
+ const found: Evidence[] = [];
264
+ for (const component of components) {
265
+ const listed = new Set(component.contract.members);
266
+ const site = renderPosition(
267
+ component.site.getSourceFile(),
268
+ targetRoot,
269
+ component.site.getStart(),
270
+ );
271
+ for (const member of members.flatMap((side) => side.get(component.id) ?? [])) {
272
+ const symbol = byId.get(member.id);
273
+ const spelled = symbol === undefined ? undefined : memberComponent(symbol, byId);
274
+ if (spelled !== undefined && listed.has(spelled)) {
275
+ found.push({ id: member.id, detail: component.detail, site });
276
+ }
277
+ }
278
+ }
279
+ return found;
280
+ }