@cplieger/deadset-ts 3.1.0 → 3.3.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/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.2.0 and ships the analyzer's foundation: one compiler session per run over the projects `analysis.configurations` declares, in the order it lists them, 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.
11
+ **Status: pre-release.** This version implements contract 3.0.0 and ships the analyzer's foundation: one compiler session per run over the projects `analysis.configurations` declares, in the order it lists them, 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. Four exemption classes hold a declaration back from the sweep, and `print-retained` lists what each one held back: `interface-satisfaction`, a member an interface requires of a class whose value reaches that interface; `decorator`, a decorated member and every member of a decorated class; and, at the lowest confidence, `template-field`, a member an action of a template under `analysis.template_dirs` names, and `reflective-lookup`, a member a literal key of an element access or of `Reflect.get`, `Reflect.set` or `Reflect.has` names. None of them holds back a `#private` member. 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` 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,7 +28,7 @@ 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. `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.
31
+ `version` prints the analyzer version and the Contract version it implements, and exits 0. `print-config`, `print-projects`, `print-roots` and `print-retained` print what their row of the table says and exit 0, or 1 when `print-roots` reports a `DS1704`, and `print-retained` exits 2 when an `exemptions.disabled` entry is not an exemption class or an `analysis.template_dirs` entry is not a directory below the target root. `analyze`, `explain`, `describe`, any other invocation and no arguments print the usage line and exit 2.
32
32
 
33
33
  | Verb | Job |
34
34
  | ---------------- | ------------------------------------------------------------------------------- |
@@ -37,7 +37,7 @@ npx deadset-ts version
37
37
  | `print-config` | Print the resolved configuration and where each setting came from |
38
38
  | `print-projects` | Print the identifier of every project a run analyzes |
39
39
  | `print-roots` | Print every declaration the analysis keeps live without a reference, and why |
40
- | `print-retained` | Print every symbol an exemption class held back, with the class |
40
+ | `print-retained` | Print every declaration an exemption held back: each class, its site and detail |
41
41
  | `describe` | Describe the analyzer: version, Contract version, supported issue kinds |
42
42
  | `version` | Print the analyzer and Contract versions |
43
43
 
@@ -7,15 +7,20 @@
7
7
  * carries a project's identity in its type so a handle cannot cross to another
8
8
  * project's checker, and that holds only for views the session itself hands out.
9
9
  */
10
+ import { decorator } from "./decorator.js";
10
11
  import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.js";
12
+ import { computeExemptions, disabledClasses, exemptionsOf, retainedIn, } from "./exempt.js";
13
+ import { interfaceSatisfaction } from "./interface-satisfaction.js";
11
14
  import { inventory } from "./inventory.js";
12
15
  import { readManifest } from "./manifest.js";
13
16
  import { matrixOf, sweepMatrix } from "./matrix.js";
14
17
  import { relativePath, resolvePath } from "./paths.js";
15
18
  import { byPosition } from "./position.js";
19
+ import { reflectiveLookup } from "./reflective-lookup.js";
16
20
  import { references } from "./references.js";
17
21
  import { roots, unmatchedEverywhere, unmatchedRoots } from "./roots.js";
18
22
  import { diagnosticsOf, runSession } from "./session.js";
23
+ import { readTemplates, templateField } from "./template-field.js";
19
24
  /** The setting whose source decides where a finding about a configured root sits. */
20
25
  const ROOTS_SETTING = "roots.patterns";
21
26
  /**
@@ -117,27 +122,48 @@ export function runRoots(engine, host, scope, config, provenance) {
117
122
  findings: unmatchedRoots(unmatched, documentOf(host, provenance, ROOTS_SETTING, targetRoot)),
118
123
  };
119
124
  }
125
+ /** The exemption classes this analyzer detects, each by its detector; any other retains nothing. */
126
+ const DETECTORS = new Map([
127
+ ["interface-satisfaction", interfaceSatisfaction],
128
+ ["template-field", templateField],
129
+ ["reflective-lookup", reflectiveLookup],
130
+ ["decorator", decorator],
131
+ ]);
120
132
  /**
121
133
  * Sweeps the run the scope and the configuration describe. Each project's references
122
- * are read against its own inventory with the configured test-file patterns while its
123
- * view is open; the projects then merge into one matrix in discovery order, which is
124
- * swept under the input the caller decided once for the run. A project that carries
125
- * an error ends the run before any project is swept.
134
+ * and exemptions are read against its own inventory while its view is open; the
135
+ * projects then merge into one matrix in the run's order, which is swept under the
136
+ * caller's marks and mode with the exemptions every project found. A record found in
137
+ * one configuration holds in every configuration that holds its declaration, because
138
+ * an exemption is evidence of a use the analysis cannot see and a use in one
139
+ * configuration is a use. A project that carries an error ends the run before any
140
+ * project is swept.
126
141
  */
127
- export function runSweep(engine, host, scope, config, input) {
142
+ export function runSweep(engine, host, scope, config, input, detectors = DETECTORS) {
128
143
  const targetRoot = scope.target.path;
129
- const configured = readProjects(engine, host, scope, config, (project, read) => {
130
- const resolved = references(project, read.held, targetRoot, {
144
+ const disabled = disabledClasses(config);
145
+ const templates = disabled.has("template-field")
146
+ ? { delimiters: config.analysis.templateDelimiters, files: [] }
147
+ : readTemplates(host, targetRoot, config.analysis);
148
+ const read = readProjects(engine, host, scope, config, (project, projectRead) => {
149
+ const resolved = references(project, projectRead.held, targetRoot, {
131
150
  testFiles: config.ts.testFiles,
132
151
  });
133
- return {
134
- configuration: read.configuration,
135
- symbols: read.held.symbols,
152
+ const configured = {
153
+ configuration: projectRead.configuration,
154
+ symbols: projectRead.held.symbols,
136
155
  references: resolved.references,
137
- roots: read.rooted.liveUnderReachability,
156
+ roots: projectRead.rooted.liveUnderReachability,
138
157
  testFiles: resolved.testFilePaths,
139
158
  };
159
+ const exempt = computeExemptions({ project, held: projectRead.held, targetRoot, templates }, detectors, {
160
+ disabled,
161
+ mode: input.mode,
162
+ testFiles: new Set(resolved.testFilePaths),
163
+ });
164
+ return { configured, exempt };
140
165
  });
141
- const matrix = matrixOf(configured);
142
- return { matrix, sweep: sweepMatrix(matrix, input) };
166
+ const matrix = matrixOf(read.map((one) => one.configured));
167
+ const swept = { ...input, exempt: exemptionsOf(read.flatMap((one) => one.exempt)) };
168
+ return { matrix, sweep: sweepMatrix(matrix, swept), retained: retainedIn(matrix, swept) };
143
169
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The decorator class: a member a decorator expression is attached to, and every
3
+ * member of a class a decorator expression is attached to, is held back, because the
4
+ * decorator receives the member or the class when the class is defined and may reach
5
+ * the member by its name.
6
+ *
7
+ * The rule is read from the tree alone: a decorator is attached where it is written.
8
+ * The use the decorator itself makes of what it decorates is the reference pass's,
9
+ * which records it as a `decorator` reference; this class records only that the
10
+ * declaration is held back, and the member set of a decorated class is every member
11
+ * the inventory holds for the class, static members included.
12
+ */
13
+ import { isCallExpression, isDecorator, } from "@typescript/native/unstable/ast";
14
+ import { nodeKey } from "./inventory.js";
15
+ import { renderPosition } from "./position.js";
16
+ /** The kinds of declaration a decorator is attached to and holds back. */
17
+ const DECORATED = new Set(["class", "method", "class-member"]);
18
+ /** How one decorator is spelled in a record: its expression, without a call's arguments. */
19
+ function spelled(decorator) {
20
+ const expression = decorator.expression;
21
+ const named = isCallExpression(expression) ? expression.expression : expression;
22
+ return `@${named.getText().replace(/\s+/gu, "")}`;
23
+ }
24
+ /**
25
+ * The decorator detector: each decorated member, and each member of a decorated
26
+ * class, recorded at the decorator.
27
+ */
28
+ export function decorator(input) {
29
+ const { project, held, targetRoot } = input;
30
+ const byId = new Map(held.symbols.map((symbol) => [symbol.id, symbol]));
31
+ const membersOf = new Map();
32
+ for (const symbol of held.symbols) {
33
+ if (symbol.kind === "method" || symbol.kind === "class-member") {
34
+ const members = membersOf.get(symbol.parent);
35
+ if (members === undefined) {
36
+ membersOf.set(symbol.parent, [symbol]);
37
+ }
38
+ else {
39
+ members.push(symbol);
40
+ }
41
+ }
42
+ }
43
+ const found = [];
44
+ for (const file of project.ownSourceFiles()) {
45
+ const visit = (node) => {
46
+ const id = held.declarations.get(nodeKey(file, node));
47
+ const symbol = id === undefined ? undefined : byId.get(id);
48
+ if (symbol !== undefined && DECORATED.has(symbol.kind)) {
49
+ const modifiers = node.modifiers ?? [];
50
+ for (const attached of modifiers.filter(isDecorator)) {
51
+ const detail = `decorated by ${spelled(attached)}`;
52
+ const site = renderPosition(file, targetRoot, attached.getStart());
53
+ const reached = symbol.kind === "class" ? (membersOf.get(symbol.id) ?? []) : [symbol];
54
+ for (const member of reached) {
55
+ found.push({ id: member.id, detail, site });
56
+ }
57
+ }
58
+ }
59
+ node.forEachChild(visit);
60
+ };
61
+ file.forEachChild(visit);
62
+ }
63
+ return found;
64
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The exemption classes of the Contract's vocabulary, in its order: each class's name,
3
+ * the languages it runs on, the confidence it is recorded at and, for a class that runs
4
+ * on TypeScript, which private members it may retain.
5
+ *
6
+ * The rows are written out rather than read from the Contract at run time, for the
7
+ * reason the fixed severity codes are, and a test holds them equal to the Contract
8
+ * release this analyzer implements. A class name is a type, so a detector table or a
9
+ * record naming a class the vocabulary does not hold, or one that runs on another
10
+ * language only, does not compile.
11
+ */
12
+ /** Every class of the vocabulary, in the order the Contract lists them. */
13
+ export const EXEMPTION_CLASSES = [
14
+ {
15
+ class: "interface-satisfaction",
16
+ languages: ["go", "ts"],
17
+ confidence: "certain",
18
+ typescriptVisibility: { private: false, privateName: false },
19
+ },
20
+ { class: "encoding-reflection", languages: ["go"], confidence: "certain" },
21
+ { class: "format-verb-contract", languages: ["go"], confidence: "certain" },
22
+ { class: "errors-duck-typing", languages: ["go"], confidence: "certain" },
23
+ {
24
+ class: "enum-group",
25
+ languages: ["go", "ts"],
26
+ confidence: "certain",
27
+ typescriptVisibility: { private: false, privateName: false },
28
+ },
29
+ { class: "generated-file", languages: ["go"], confidence: "certain" },
30
+ { class: "linkname-cgo-asm-plugin", languages: ["go"], confidence: "certain" },
31
+ {
32
+ class: "template-field",
33
+ languages: ["go", "ts"],
34
+ confidence: "possible",
35
+ typescriptVisibility: { private: true, privateName: false },
36
+ },
37
+ {
38
+ class: "reflective-lookup",
39
+ languages: ["go", "ts"],
40
+ confidence: "possible",
41
+ typescriptVisibility: { private: true, privateName: false },
42
+ },
43
+ {
44
+ class: "decorator",
45
+ languages: ["ts"],
46
+ confidence: "certain",
47
+ typescriptVisibility: { private: true, privateName: false },
48
+ },
49
+ {
50
+ class: "injection-container",
51
+ languages: ["ts"],
52
+ confidence: "certain",
53
+ typescriptVisibility: { private: true, privateName: false },
54
+ },
55
+ {
56
+ class: "framework-lifecycle",
57
+ languages: ["ts"],
58
+ confidence: "certain",
59
+ typescriptVisibility: { private: true, privateName: false },
60
+ },
61
+ {
62
+ class: "serialization-contract",
63
+ languages: ["ts"],
64
+ confidence: "certain",
65
+ typescriptVisibility: { private: true, privateName: false },
66
+ },
67
+ ];
68
+ function runsOnTS(row) {
69
+ const languages = row.languages;
70
+ return languages.includes("ts");
71
+ }
72
+ const TS_ROWS = EXEMPTION_CLASSES.filter(runsOnTS);
73
+ /** The classes that run on TypeScript, in the vocabulary's order. */
74
+ export const TS_EXEMPTION_CLASSES = TS_ROWS.map((row) => row.class);
75
+ const VISIBILITY = new Map(TS_ROWS.map((row) => [row.class, row.typescriptVisibility]));
76
+ /** Which private members one class that runs on TypeScript may retain. */
77
+ export function typescriptVisibilityOf(exemptionClass) {
78
+ return VISIBILITY.get(exemptionClass) ?? { private: false, privateName: false };
79
+ }
80
+ /** Whether one name is a class of the vocabulary, whichever language it runs on. */
81
+ export function isExemptionClass(name) {
82
+ return EXEMPTION_CLASSES.some((row) => row.class === name);
83
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The exemption framework: the classes of reason for which a declaration the reference
3
+ * graph alone would report is live all the same. A class is policy over what one
4
+ * project's checker answers, and its detector names the declarations it holds back;
5
+ * this module runs the detectors, decides which of their records stand, and answers
6
+ * which records held something back. It names no class's detector: the composition
7
+ * hands it the table of the classes it has.
8
+ */
9
+ import { ConfigError } from "./config.js";
10
+ import { EXEMPTION_CLASSES, isExemptionClass, TS_EXEMPTION_CLASSES, typescriptVisibilityOf, } from "./exempt-classes.js";
11
+ import { byPosition, positionKey } from "./position.js";
12
+ import { sweep } from "./sweep.js";
13
+ /**
14
+ * The classes `exemptions.disabled` switches off. A name is refused unless the
15
+ * vocabulary holds it; a class that runs on another language only is admitted and
16
+ * switches nothing off here, so one configuration serves every analyzer.
17
+ */
18
+ export function disabledClasses(config) {
19
+ const disabled = new Set();
20
+ for (const name of config.exemptionsDisabled) {
21
+ if (!isExemptionClass(name)) {
22
+ const known = EXEMPTION_CLASSES.map((row) => row.class).join(", ");
23
+ throw new ConfigError("malformed", "exemptions.disabled", `exemptions.disabled: ${JSON.stringify(name)} is not an exemption class: the classes are ${known}`);
24
+ }
25
+ disabled.add(name);
26
+ }
27
+ return disabled;
28
+ }
29
+ /**
30
+ * Whether a class whose row states `allowed` may retain a member of one visibility. A
31
+ * declaration the inventory does not hold has no visibility to refuse it by.
32
+ */
33
+ function retainable(allowed, visibility) {
34
+ if (visibility === "private") {
35
+ return allowed.private;
36
+ }
37
+ if (visibility === "private-name") {
38
+ return allowed.privateName;
39
+ }
40
+ return true;
41
+ }
42
+ /**
43
+ * The records one project's detectors found, each class in the vocabulary's order and
44
+ * none of a switched-off class. Evidence on a private member the class's row says it
45
+ * cannot retain is dropped first, whatever the detector found. Under a production mode
46
+ * a record whose evidence is written in a test file holds nothing, as a reference a
47
+ * test file makes is none there.
48
+ */
49
+ export function computeExemptions(input, detectors, holding) {
50
+ const visibility = new Map(input.held.symbols.map((symbol) => [symbol.id, symbol.visibility]));
51
+ const found = [];
52
+ for (const exemptionClass of TS_EXEMPTION_CLASSES) {
53
+ const detect = detectors.get(exemptionClass);
54
+ if (detect === undefined || holding.disabled.has(exemptionClass)) {
55
+ continue;
56
+ }
57
+ const allowed = typescriptVisibilityOf(exemptionClass);
58
+ for (const evidence of detect(input)) {
59
+ if (!retainable(allowed, visibility.get(evidence.id))) {
60
+ continue;
61
+ }
62
+ if (holding.mode.production && holding.testFiles.has(evidence.site.path)) {
63
+ continue;
64
+ }
65
+ found.push({
66
+ id: evidence.id,
67
+ class: exemptionClass,
68
+ detail: evidence.detail,
69
+ site: evidence.site,
70
+ });
71
+ }
72
+ }
73
+ return exemptionsOf(found);
74
+ }
75
+ /** Two strings ordered bytewise. */
76
+ function compare(a, b) {
77
+ if (a === b) {
78
+ return 0;
79
+ }
80
+ return a < b ? -1 : 1;
81
+ }
82
+ /**
83
+ * Records as a run holds them: ordered by site, then class, then declaration, and one
84
+ * per declaration, class and detail, the one at the first site. A fact found at many
85
+ * sites, or by several projects, is one record.
86
+ */
87
+ export function exemptionsOf(found) {
88
+ const ordered = [...found].sort((a, b) => byPosition(a.site, b.site) || compare(a.class, b.class) || compare(a.id, b.id));
89
+ const seen = new Set();
90
+ return ordered.filter((record) => {
91
+ const fact = [record.id, record.class, record.detail].join("\u0000");
92
+ if (seen.has(fact)) {
93
+ return false;
94
+ }
95
+ seen.add(fact);
96
+ return true;
97
+ });
98
+ }
99
+ /** The records per declaration, each declaration's in the order given. */
100
+ function byDeclaration(records) {
101
+ const grouped = new Map();
102
+ for (const record of records) {
103
+ const held = grouped.get(record.id);
104
+ if (held === undefined) {
105
+ grouped.set(record.id, [record]);
106
+ }
107
+ else {
108
+ held.push(record);
109
+ }
110
+ }
111
+ return grouped;
112
+ }
113
+ /**
114
+ * The records that held back a declaration some configuration of the run would
115
+ * otherwise judge dead: each configuration's graph is swept once more with no
116
+ * exemption and the marks it was given, and a record is retained when one of those
117
+ * sweeps judged its declaration a candidate. A record that holds a declaration back in
118
+ * one configuration is retained whatever another configuration answers about it, as a
119
+ * run of that configuration alone would report the declaration without it. A record
120
+ * on a declaration that a relation or a mark holds live in every configuration held
121
+ * nothing back. The order is the declarations' site order, and for one declaration
122
+ * the input's.
123
+ */
124
+ export function retainedIn(matrix, input) {
125
+ const exempt = input.exempt ?? [];
126
+ if (exempt.length === 0) {
127
+ return [];
128
+ }
129
+ const bare = { marked: input.marked, mode: input.mode };
130
+ const dead = new Set(matrix.graphs.flatMap((graph) => sweep(graph, bare).candidates.map((candidate) => candidate.id)));
131
+ const held = byDeclaration(exempt.filter((record) => dead.has(record.id)));
132
+ return matrix.union.symbols.flatMap((symbol) => held.get(symbol.id) ?? []);
133
+ }
134
+ /**
135
+ * One line per held-back declaration, in site order: its reference, then each record's
136
+ * class, site and detail, all separated by tabs.
137
+ */
138
+ export function retainedLines(symbols, retained) {
139
+ const held = byDeclaration(retained);
140
+ return symbols.flatMap((symbol) => {
141
+ const records = held.get(symbol.id);
142
+ if (records === undefined) {
143
+ return [];
144
+ }
145
+ const fields = records.flatMap((record) => [
146
+ record.class,
147
+ positionKey(record.site),
148
+ record.detail,
149
+ ]);
150
+ return [[symbol.ref, ...fields].join("\t")];
151
+ });
152
+ }