@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,141 @@
1
+ /**
2
+ * The injection-container class: a container constructs a class passed to a call
3
+ * `ts.injection_registrations` names, or one a parameter of whose constructor carries a
4
+ * decorator, and populates each of its properties and accessors that carries one, a base
5
+ * class's included. A constructor and its parameters are no declarations of the
6
+ * inventory, so the class stands for them and the registration referencing it keeps it.
7
+ */
8
+
9
+ import {
10
+ isClassDeclaration,
11
+ isClassExpression,
12
+ isConstructorDeclaration,
13
+ isGetAccessorDeclaration,
14
+ isPropertyDeclaration,
15
+ isSetAccessorDeclaration,
16
+ type CallExpression,
17
+ type Node,
18
+ } from "@typescript/native/unstable/ast";
19
+ import { aliasChains } from "./alias-chain.ts";
20
+ import { calleeName, callsOf, classesPassed, decoratorsOf, spelledDecorator } from "./calls.ts";
21
+ import { classMembers, type ClassMember } from "./class-members.ts";
22
+ import {
23
+ entriesAt,
24
+ namesADeclaration,
25
+ resolveEntries,
26
+ spelledEntry,
27
+ type ResolvedEntry,
28
+ } from "./configured-declarations.ts";
29
+ import type { DetectorInput, Evidence } from "./exempt.ts";
30
+ import { nodeKey } from "./inventory.ts";
31
+ import { renderPosition } from "./position.ts";
32
+
33
+ /** One class a container constructs, by the registration that says so. */
34
+ interface Registration {
35
+ readonly id: string;
36
+ readonly site: Node;
37
+ readonly detail: string;
38
+ }
39
+
40
+ /** Whether one member declaration is a property or an accessor carrying a decorator. */
41
+ function isInjected(node: Node): boolean {
42
+ return (
43
+ (isPropertyDeclaration(node) ||
44
+ isGetAccessorDeclaration(node) ||
45
+ isSetAccessorDeclaration(node)) &&
46
+ decoratorsOf(node).length > 0
47
+ );
48
+ }
49
+
50
+ /** The classes of the target a call whose callee an entry names passes, with that call's entry. */
51
+ function registrations<Brand>(input: DetectorInput<Brand>): readonly Registration[] {
52
+ const { project, held } = input;
53
+ const resolved = resolveEntries(project, held, input.ts.injectionRegistrations);
54
+ if (!resolved.some(namesADeclaration)) {
55
+ return [];
56
+ }
57
+ const chains = aliasChains(project, held);
58
+ const calls = callsOf(project);
59
+ const named = entriesAt(
60
+ project,
61
+ chains,
62
+ calls.flatMap((call) => calleeName(call.expression) ?? []),
63
+ resolved,
64
+ );
65
+ const matched = new Map<CallExpression, ResolvedEntry>();
66
+ for (const call of calls) {
67
+ const name = calleeName(call.expression);
68
+ const [entry] = name === undefined ? [] : (named.get(name) ?? []);
69
+ if (entry !== undefined) {
70
+ matched.set(call, entry);
71
+ }
72
+ }
73
+ return [...classesPassed(project, held, chains, [...matched.keys()])].flatMap(([call, uses]) => {
74
+ const entry = matched.get(call);
75
+ const detail = entry === undefined ? "" : `registered by ${spelledEntry(entry.entry, held)}`;
76
+ return uses.map((use) => ({ id: use.id, site: use.site, detail }));
77
+ });
78
+ }
79
+
80
+ /** The classes of the target a parameter of whose constructor carries a decorator. */
81
+ function constructedForParameters<Brand>(input: DetectorInput<Brand>): ReadonlySet<string> {
82
+ const { project, held } = input;
83
+ const found = new Set<string>();
84
+ for (const file of project.ownSourceFiles()) {
85
+ const visit = (node: Node): void => {
86
+ if (isClassDeclaration(node) || isClassExpression(node)) {
87
+ const id = held.declarations.get(nodeKey(file, node));
88
+ const asks = node.members.some(
89
+ (member) =>
90
+ isConstructorDeclaration(member) &&
91
+ member.parameters.some((parameter) => decoratorsOf(parameter).length > 0),
92
+ );
93
+ if (id !== undefined && asks) {
94
+ found.add(id);
95
+ }
96
+ }
97
+ node.forEachChild(visit);
98
+ };
99
+ file.forEachChild(visit);
100
+ }
101
+ return found;
102
+ }
103
+
104
+ /**
105
+ * The injection-container detector: each decorated property or accessor of a class a
106
+ * container constructs, recorded at the registration that names the class, or, for a
107
+ * class whose constructor asks for its parameters, at the member's own decorator.
108
+ */
109
+ export function injectionContainer<Brand>(input: DetectorInput<Brand>): readonly Evidence[] {
110
+ const { project, held, targetRoot } = input;
111
+ const registered = registrations(input);
112
+ const constructed = constructedForParameters(input);
113
+ const classes = new Set([...registered.map((one) => one.id), ...constructed]);
114
+ if (classes.size === 0) {
115
+ return [];
116
+ }
117
+ const members = classMembers(project, held, classes, "instance");
118
+ const injected = (id: string): readonly ClassMember[] =>
119
+ (members.get(id) ?? []).filter((member) => isInjected(member.node));
120
+ const at = (node: Node) => renderPosition(node.getSourceFile(), targetRoot, node.getStart());
121
+
122
+ const found: Evidence[] = [];
123
+ for (const registration of registered) {
124
+ for (const member of injected(registration.id)) {
125
+ found.push({ id: member.id, detail: registration.detail, site: at(registration.site) });
126
+ }
127
+ }
128
+ for (const id of constructed) {
129
+ for (const member of injected(id)) {
130
+ const [decorator] = decoratorsOf(member.node);
131
+ if (decorator !== undefined) {
132
+ found.push({
133
+ id: member.id,
134
+ detail: `injected by ${spelledDecorator(decorator)}`,
135
+ site: at(decorator),
136
+ });
137
+ }
138
+ }
139
+ }
140
+ return found;
141
+ }
package/src/print.ts CHANGED
@@ -1,4 +1,16 @@
1
- import { renderOrigin, type Config, type Provenance } from "./config.ts";
1
+ import { renderOrigin, type Config, type DeclarationEntry, type Provenance } from "./config.ts";
2
+
3
+ /** One declaration entry as a document writes it: the members of its shape alone. */
4
+ function declarationOf(entry: DeclarationEntry): Record<string, string> {
5
+ switch (entry.shape) {
6
+ case "symbol":
7
+ return { symbol: entry.symbol };
8
+ case "module":
9
+ return { module: entry.module, name: entry.name };
10
+ case "global":
11
+ return { global: entry.global };
12
+ }
13
+ }
2
14
 
3
15
  /**
4
16
  * The resolved configuration and its provenance as one JSON object, indented,
@@ -47,6 +59,13 @@ export function printConfig(config: Config, provenance: Provenance): string {
47
59
  ts: {
48
60
  test_files: config.ts.testFiles,
49
61
  entry_files: config.ts.entryFiles,
62
+ injection_registrations: config.ts.injectionRegistrations.map(declarationOf),
63
+ lifecycle_contracts: config.ts.lifecycleContracts.map((entry) => ({
64
+ components: entry.components.map(declarationOf),
65
+ bases: entry.bases.map(declarationOf),
66
+ members: entry.members,
67
+ })),
68
+ serializers: config.ts.serializers.map(declarationOf),
50
69
  },
51
70
  provenance: Object.fromEntries(
52
71
  [...provenance]
package/src/resolve.ts CHANGED
@@ -6,10 +6,12 @@ import {
6
6
  type Config,
7
7
  type Confidence,
8
8
  type ConsumerTests,
9
+ type DeclarationEntry,
9
10
  type Format,
10
11
  type GeneratedFiles,
11
12
  type Inputs,
12
13
  type Language,
14
+ type LifecycleContract,
13
15
  type Origin,
14
16
  type Provenance,
15
17
  type Severity,
@@ -94,6 +96,9 @@ interface Doc {
94
96
  readonly failOn: Severity | undefined;
95
97
  readonly testFiles: readonly string[] | undefined;
96
98
  readonly entryFiles: readonly string[] | undefined;
99
+ readonly injectionRegistrations: readonly DeclarationEntry[] | undefined;
100
+ readonly lifecycleContracts: readonly LifecycleContract[] | undefined;
101
+ readonly serializers: readonly DeclarationEntry[] | undefined;
97
102
  }
98
103
 
99
104
  /** One document the resolution reads, with the origin its settings carry. */
@@ -476,6 +481,137 @@ function readExemptions(
476
481
  return disabled;
477
482
  }
478
483
 
484
+ /** The members that name a declaration, each belonging to exactly one shape. */
485
+ const SHAPE_MEMBERS = ["symbol", "module", "name", "global"] as const;
486
+
487
+ /** The prefix of a stable symbol reference in the TypeScript form. */
488
+ const SYMBOL_PREFIX = "ts://";
489
+
490
+ /**
491
+ * A bare specifier: neither relative nor absolute, and not one the package's own
492
+ * imports field maps, each of which names a file of the analyzed program.
493
+ */
494
+ const BARE_SPECIFIER = /^[^./#]/u;
495
+
496
+ /**
497
+ * One declaration entry, read as the one shape its members name. An entry carrying
498
+ * members of two shapes, or of none, is refused at the entry, as is a module named
499
+ * without the declaration's path or a path without its module.
500
+ */
501
+ function readDeclaration(entry: unknown, at: string, label: string): DeclarationEntry {
502
+ if (!isRecord(entry)) {
503
+ throw malformed(label, at, "is not an object");
504
+ }
505
+ const named = SHAPE_MEMBERS.filter((name) => Object.hasOwn(entry, name));
506
+ if (named.length === 1 && named[0] === "symbol") {
507
+ const symbol = requireMember(
508
+ readString(entry, "symbol", `${at}.symbol`, label),
509
+ `${at}.symbol`,
510
+ label,
511
+ );
512
+ if (!symbol.startsWith(SYMBOL_PREFIX)) {
513
+ throw malformed(label, `${at}.symbol`, `${JSON.stringify(symbol)} is not a ts:// reference`);
514
+ }
515
+ return { shape: "symbol", symbol };
516
+ }
517
+ if (named.length === 1 && named[0] === "global") {
518
+ const path = `${at}.global`;
519
+ return {
520
+ shape: "global",
521
+ global: requireMember(readString(entry, "global", path, label), path, label),
522
+ };
523
+ }
524
+ if (named.length === 2 && named.includes("module") && named.includes("name")) {
525
+ const module = requireMember(
526
+ readString(entry, "module", `${at}.module`, label),
527
+ `${at}.module`,
528
+ label,
529
+ );
530
+ if (!BARE_SPECIFIER.test(module)) {
531
+ throw malformed(
532
+ label,
533
+ `${at}.module`,
534
+ `${JSON.stringify(module)} is not a bare specifier: a file of the analyzed program is named by its symbol`,
535
+ );
536
+ }
537
+ const path = `${at}.name`;
538
+ return {
539
+ shape: "module",
540
+ module,
541
+ name: requireMember(readString(entry, "name", path, label), path, label),
542
+ };
543
+ }
544
+ throw malformed(
545
+ label,
546
+ at,
547
+ named.length === 0
548
+ ? 'names no declaration: an entry carries "symbol", "module" with "name", or "global"'
549
+ : `carries ${spell(named)}, which is not one of "symbol", "module" with "name", or "global"`,
550
+ );
551
+ }
552
+
553
+ /** One array of declaration entries, absent where the document does not write it. */
554
+ function readDeclarations(
555
+ parent: Record<string, unknown> | undefined,
556
+ name: string,
557
+ path: string,
558
+ label: string,
559
+ ): readonly DeclarationEntry[] | undefined {
560
+ const value = member(parent, name);
561
+ if (value === undefined) {
562
+ return undefined;
563
+ }
564
+ if (!Array.isArray(value)) {
565
+ throw malformed(label, path, "is not an array");
566
+ }
567
+ return (value as unknown[]).map((entry, index) =>
568
+ readDeclaration(entry, `${path}[${String(index)}]`, label),
569
+ );
570
+ }
571
+
572
+ /**
573
+ * One lifecycle contract. The member names are required, and the entry names at least
574
+ * one component declaration or one base class, because a contract naming neither
575
+ * makes no class a component and so retains nothing.
576
+ */
577
+ function readLifecycleContract(entry: unknown, at: string, label: string): LifecycleContract {
578
+ if (!isRecord(entry)) {
579
+ throw malformed(label, at, "is not an object");
580
+ }
581
+ const components = readDeclarations(entry, "components", `${at}.components`, label) ?? [];
582
+ const bases = readDeclarations(entry, "bases", `${at}.bases`, label) ?? [];
583
+ const members = readStrings(entry, "members", `${at}.members`, label, 1);
584
+ if (members === undefined) {
585
+ throw malformed(label, `${at}.members`, "is required and names nothing");
586
+ }
587
+ if (components.length === 0 && bases.length === 0) {
588
+ throw malformed(
589
+ label,
590
+ at,
591
+ 'names no declaration in "components" and no class in "bases", so it makes no class a component',
592
+ );
593
+ }
594
+ return { components, bases, members };
595
+ }
596
+
597
+ /** The lifecycle contracts, one per framework the project declares. */
598
+ function readLifecycleContracts(
599
+ ts: Record<string, unknown> | undefined,
600
+ label: string,
601
+ ): readonly LifecycleContract[] | undefined {
602
+ const path = "ts.lifecycle_contracts";
603
+ const value = member(ts, "lifecycle_contracts");
604
+ if (value === undefined) {
605
+ return undefined;
606
+ }
607
+ if (!Array.isArray(value)) {
608
+ throw malformed(label, path, "is not an array");
609
+ }
610
+ return (value as unknown[]).map((entry, index) =>
611
+ readLifecycleContract(entry, `${path}[${String(index)}]`, label),
612
+ );
613
+ }
614
+
479
615
  /**
480
616
  * Reads one already-parsed configuration document as the settings it supplies,
481
617
  * refusing every constraint the closed key list declares that a parse cannot
@@ -545,6 +681,14 @@ function readDoc(value: unknown, label: string): Doc {
545
681
  failOn: readEnum(reporters, "fail_on", "reporters.fail_on", label, SEVERITIES),
546
682
  testFiles: readStrings(ts, "test_files", "ts.test_files", label, 1),
547
683
  entryFiles: readStrings(ts, "entry_files", "ts.entry_files", label, 0),
684
+ injectionRegistrations: readDeclarations(
685
+ ts,
686
+ "injection_registrations",
687
+ "ts.injection_registrations",
688
+ label,
689
+ ),
690
+ lifecycleContracts: readLifecycleContracts(ts, label),
691
+ serializers: readDeclarations(ts, "serializers", "ts.serializers", label),
548
692
  };
549
693
  }
550
694
 
@@ -818,6 +962,17 @@ function settleAll(inputs: Inputs): { config: Config; provenance: Map<string, Or
818
962
  ts: {
819
963
  testFiles: at("ts.test_files", defaults.ts.testFiles, (doc) => doc.testFiles),
820
964
  entryFiles: at("ts.entry_files", defaults.ts.entryFiles, (doc) => doc.entryFiles),
965
+ injectionRegistrations: at(
966
+ "ts.injection_registrations",
967
+ defaults.ts.injectionRegistrations,
968
+ (doc) => doc.injectionRegistrations,
969
+ ),
970
+ lifecycleContracts: at(
971
+ "ts.lifecycle_contracts",
972
+ defaults.ts.lifecycleContracts,
973
+ (doc) => doc.lifecycleContracts,
974
+ ),
975
+ serializers: at("ts.serializers", defaults.ts.serializers, (doc) => doc.serializers),
821
976
  },
822
977
  };
823
978
 
package/src/schema.ts CHANGED
@@ -28,6 +28,12 @@ export interface KeyNode {
28
28
 
29
29
  const leaf: KeyNode = { kind: "leaf" };
30
30
 
31
+ /** A list of declarations, each entry in one of the three shapes the `ts` section states. */
32
+ const declarations: KeyNode = {
33
+ kind: "list",
34
+ members: { symbol: leaf, module: leaf, name: leaf, global: leaf },
35
+ };
36
+
31
37
  /**
32
38
  * The closed key list the Contract's configuration schema declares, derived from
33
39
  * that schema key by key. A test pins the two equal, so a Contract change that
@@ -72,7 +78,19 @@ export const SCHEMA_ROOT: KeyNode = {
72
78
  },
73
79
  },
74
80
  go: { kind: "section", members: {} },
75
- ts: { kind: "section", members: { test_files: leaf, entry_files: leaf } },
81
+ ts: {
82
+ kind: "section",
83
+ members: {
84
+ test_files: leaf,
85
+ entry_files: leaf,
86
+ injection_registrations: declarations,
87
+ lifecycle_contracts: {
88
+ kind: "list",
89
+ members: { components: declarations, bases: declarations, members: leaf },
90
+ },
91
+ serializers: declarations,
92
+ },
93
+ },
76
94
  provenance: { kind: "map" },
77
95
  },
78
96
  };