@cplieger/deadset-ts 3.3.1 → 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/src/config.ts CHANGED
@@ -97,10 +97,66 @@ export interface Reporters {
97
97
  readonly failOn: Severity;
98
98
  }
99
99
 
100
+ /**
101
+ * One declaration a configuration entry names, in exactly one of three shapes: a
102
+ * declaration of the analyzed program by its stable symbol reference, a declaration a
103
+ * module exports by the bare specifier an import writes for the module and the
104
+ * declaration's path from its exports, or a declaration of the global scope by its
105
+ * path from that scope. A path joins the name and each member with dots and spells a
106
+ * static member with `:static` after it.
107
+ */
108
+ export type DeclarationEntry =
109
+ | { readonly shape: "symbol"; readonly symbol: string }
110
+ | { readonly shape: "module"; readonly module: string; readonly name: string }
111
+ | { readonly shape: "global"; readonly global: string };
112
+
113
+ /**
114
+ * One framework's lifecycle contract: the declarations whose use makes a class one of
115
+ * the framework's components, the classes whose subclasses are its components, and the
116
+ * names of the members the framework calls on a component, each spelled as the last
117
+ * component of a member's stable symbol reference.
118
+ */
119
+ export interface LifecycleContract {
120
+ readonly components: readonly DeclarationEntry[];
121
+ readonly bases: readonly DeclarationEntry[];
122
+ readonly members: readonly string[];
123
+ }
124
+
125
+ /**
126
+ * One analyzer the orchestrator may run, in exactly one of two shapes: an installed
127
+ * analyzer, named by its command alone, or an acquirable one, which also names the
128
+ * artifact acquisition fetches and verifies. This analyzer validates and prints the
129
+ * list and runs nothing from it.
130
+ */
131
+ export type Provider =
132
+ | {
133
+ readonly shape: "installed";
134
+ readonly name: string;
135
+ readonly languages: readonly Language[];
136
+ readonly command: string;
137
+ }
138
+ | {
139
+ readonly shape: "acquirable";
140
+ readonly name: string;
141
+ readonly languages: readonly Language[];
142
+ readonly command: string;
143
+ readonly source: string;
144
+ readonly version: string;
145
+ readonly digest: string;
146
+ };
147
+
148
+ /** The analyzers the orchestrator runs. */
149
+ export interface Providers {
150
+ readonly analyzers: readonly Provider[];
151
+ }
152
+
100
153
  /** The section the TypeScript analyzer owns. */
101
154
  export interface TSSection {
102
155
  readonly testFiles: readonly string[];
103
156
  readonly entryFiles: readonly string[];
157
+ readonly injectionRegistrations: readonly DeclarationEntry[];
158
+ readonly lifecycleContracts: readonly LifecycleContract[];
159
+ readonly serializers: readonly DeclarationEntry[];
104
160
  }
105
161
 
106
162
  /**
@@ -124,6 +180,7 @@ export interface Config {
124
180
  readonly severity: ReadonlyMap<string, Severity>;
125
181
  readonly exemptionsDisabled: readonly string[];
126
182
  readonly reporters: Reporters;
183
+ readonly providers: Providers;
127
184
  readonly ts: TSSection;
128
185
  }
129
186
 
@@ -156,9 +213,18 @@ export function defaultConfig(): Config {
156
213
  maxFindings: 0,
157
214
  failOn: "deny",
158
215
  },
216
+ providers: {
217
+ analyzers: [
218
+ { shape: "installed", name: "deadset-go", languages: ["go"], command: "deadset-go" },
219
+ { shape: "installed", name: "deadset-ts", languages: ["ts"], command: "deadset-ts" },
220
+ ],
221
+ },
159
222
  ts: {
160
223
  testFiles: ["**/*.test.{ts,tsx,mts,cts}"],
161
224
  entryFiles: [],
225
+ injectionRegistrations: [],
226
+ lifecycleContracts: [],
227
+ serializers: [],
162
228
  },
163
229
  };
164
230
  }
@@ -215,8 +281,8 @@ export type ErrorKind =
215
281
  */
216
282
  | "malformed"
217
283
  /**
218
- * A key the closed key list does not declare, or a severity key naming a kind
219
- * whose severity the Contract fixes.
284
+ * A key the closed key list does not declare, or a severity key naming no live
285
+ * kind or a kind whose severity the Contract fixes.
220
286
  */
221
287
  | "unimplemented-key"
222
288
  /** A resolved configuration no source supplied a target kind for. */
@@ -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
  /**