@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 +3 -3
- package/dist/src/analysis.js +39 -13
- package/dist/src/decorator.js +64 -0
- package/dist/src/exempt-classes.js +83 -0
- package/dist/src/exempt.js +152 -0
- package/dist/src/interface-satisfaction.js +310 -0
- package/dist/src/member-names.js +76 -0
- package/dist/src/references.js +22 -3
- package/dist/src/reflective-lookup.js +96 -0
- package/dist/src/run.js +33 -1
- package/dist/src/sweep.js +35 -25
- package/dist/src/template-field.js +173 -0
- package/dist/src/version.js +1 -1
- package/jsr.json +1 -1
- package/package.json +6 -6
- package/src/analysis.ts +59 -14
- package/src/decorator.ts +74 -0
- package/src/exempt-classes.ts +132 -0
- package/src/exempt.ts +226 -0
- package/src/interface-satisfaction.ts +417 -0
- package/src/member-names.ts +89 -0
- package/src/references.ts +41 -6
- package/src/reflective-lookup.ts +127 -0
- package/src/run.ts +39 -1
- package/src/sweep.ts +60 -25
- package/src/template-field.ts +214 -0
- package/src/version.ts +1 -1
package/dist/src/sweep.js
CHANGED
|
@@ -11,19 +11,16 @@ import { namesACaller, OUTSIDE } from "./graph.js";
|
|
|
11
11
|
*
|
|
12
12
|
* The order is the order the answers depend on: the callers and the marks are live
|
|
13
13
|
* before either relation runs, the candidate set is every judged declaration at least
|
|
14
|
-
* one relation does not hold live, and the tests of dead
|
|
14
|
+
* one relation does not hold live and no exemption holds back, and the tests of dead
|
|
15
|
+
* code join it.
|
|
15
16
|
*/
|
|
16
17
|
export function sweep(graph, input) {
|
|
17
|
-
const marked = graph.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
// Every root and every mark seeds reachability. A root of a kind that names a caller,
|
|
25
|
-
// and a mark, are live under reference counting as well; a root that only supposes a
|
|
26
|
-
// caller is not.
|
|
18
|
+
const marked = flagged(graph, input.marked);
|
|
19
|
+
const exempt = flagged(graph, (input.exempt ?? []).map((record) => record.id));
|
|
20
|
+
const held = marked.map((mark, at) => mark || exempt[at] === true);
|
|
21
|
+
// Every root, every mark and every exemption seeds reachability. A root of a kind that
|
|
22
|
+
// names a caller, and a mark, are live under reference counting as well; a root that
|
|
23
|
+
// only supposes a caller is not, and neither is an exemption.
|
|
27
24
|
const called = graph.symbols.map(() => false);
|
|
28
25
|
for (const root of graph.rooted) {
|
|
29
26
|
if (namesACaller(root.kind)) {
|
|
@@ -37,7 +34,7 @@ export function sweep(graph, input) {
|
|
|
37
34
|
}
|
|
38
35
|
return input.mode.production ? made.production : made.production + made.test;
|
|
39
36
|
};
|
|
40
|
-
const reached = reachable(graph,
|
|
37
|
+
const reached = reachable(graph, held, input.mode);
|
|
41
38
|
const liveUnder = graph.symbols.map((_symbol, at) => {
|
|
42
39
|
const relations = [];
|
|
43
40
|
if (marked[at] === true || called[at] === true || counted(at) > 0) {
|
|
@@ -48,8 +45,8 @@ export function sweep(graph, input) {
|
|
|
48
45
|
}
|
|
49
46
|
return relations;
|
|
50
47
|
});
|
|
51
|
-
const dead = graph.symbols.map((_symbol, at) => graph.subject[at] === true && (liveUnder[at]?.length ?? 0) < 2);
|
|
52
|
-
const testOfDeadCode = admitTestsOfDeadCode(graph, dead,
|
|
48
|
+
const dead = graph.symbols.map((_symbol, at) => graph.subject[at] === true && exempt[at] !== true && (liveUnder[at]?.length ?? 0) < 2);
|
|
49
|
+
const testOfDeadCode = admitTestsOfDeadCode(graph, dead, held);
|
|
53
50
|
const candidates = [];
|
|
54
51
|
const live = new Map();
|
|
55
52
|
graph.symbols.forEach((symbol, at) => {
|
|
@@ -70,13 +67,25 @@ export function sweep(graph, input) {
|
|
|
70
67
|
});
|
|
71
68
|
return { liveUnder: live, candidates };
|
|
72
69
|
}
|
|
70
|
+
/** Per declaration of the graph, whether one of the identifiers names it. */
|
|
71
|
+
function flagged(graph, ids) {
|
|
72
|
+
const flags = graph.symbols.map(() => false);
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
const at = graph.at(id);
|
|
75
|
+
if (at !== OUTSIDE) {
|
|
76
|
+
flags[at] = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return flags;
|
|
80
|
+
}
|
|
73
81
|
/**
|
|
74
82
|
* Per declaration, whether a seed reaches it. The two seeds reach through different
|
|
75
|
-
* reference sets: a
|
|
76
|
-
* no mode withholds a reference it makes,
|
|
77
|
-
*
|
|
83
|
+
* reference sets: a held declaration, marked or exempt, is live by a mechanism the
|
|
84
|
+
* analysis cannot see, so no mode withholds a reference it makes, a test declaration's
|
|
85
|
+
* included, while a root reaches through the references the mode counts. The held seed
|
|
86
|
+
* runs first, so what it reaches is expanded under the wider rule.
|
|
78
87
|
*/
|
|
79
|
-
function reachable(graph,
|
|
88
|
+
function reachable(graph, held, mode) {
|
|
80
89
|
const reached = graph.symbols.map(() => false);
|
|
81
90
|
const expanded = graph.symbols.map(() => false);
|
|
82
91
|
const queue = [];
|
|
@@ -98,18 +107,18 @@ function reachable(graph, marked, mode) {
|
|
|
98
107
|
enter(exported, false);
|
|
99
108
|
}
|
|
100
109
|
};
|
|
101
|
-
const walk = (
|
|
110
|
+
const walk = (holding) => {
|
|
102
111
|
for (let at = queue.pop(); at !== undefined; at = queue.pop()) {
|
|
103
112
|
for (const edge of graph.out[at] ?? []) {
|
|
104
|
-
if (!
|
|
113
|
+
if (!holding && mode.production && edge.test) {
|
|
105
114
|
continue;
|
|
106
115
|
}
|
|
107
116
|
enter(edge.to, !edge.evaluation);
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
119
|
};
|
|
111
|
-
|
|
112
|
-
if (
|
|
120
|
+
held.forEach((hold, at) => {
|
|
121
|
+
if (hold) {
|
|
113
122
|
enter(at, false);
|
|
114
123
|
}
|
|
115
124
|
});
|
|
@@ -124,12 +133,13 @@ function reachable(graph, marked, mode) {
|
|
|
124
133
|
* Admits every test declaration whose set of referenced production declarations is
|
|
125
134
|
* non-empty and wholly dead, and returns which it admitted. The set is read from every
|
|
126
135
|
* reference the test makes, not the ones the mode counts: a production sweep counts none,
|
|
127
|
-
* which is what makes the targets dead. A test referencing one live target is never
|
|
136
|
+
* which is what makes the targets dead. A test referencing one live target is never
|
|
137
|
+
* admitted, and neither is a marked or exempt test.
|
|
128
138
|
*/
|
|
129
|
-
function admitTestsOfDeadCode(graph, dead,
|
|
139
|
+
function admitTestsOfDeadCode(graph, dead, held) {
|
|
130
140
|
const admitted = graph.symbols.map(() => false);
|
|
131
141
|
graph.symbols.forEach((_symbol, at) => {
|
|
132
|
-
if (graph.test[at] !== true || graph.subject[at] !== true ||
|
|
142
|
+
if (graph.test[at] !== true || graph.subject[at] !== true || held[at] === true) {
|
|
133
143
|
return;
|
|
134
144
|
}
|
|
135
145
|
let targets = 0;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template-field class: a member is held back, at the lowest confidence, when a
|
|
3
|
+
* template under a configured template directory writes its name inside an action,
|
|
4
|
+
* because a template engine reads the member by that name at run time.
|
|
5
|
+
*
|
|
6
|
+
* Every file under a configured directory is a template. An action runs from an
|
|
7
|
+
* opening delimiter to the first closing one after it, at the pair
|
|
8
|
+
* `analysis.template_delimiters` sets, `{{` and `}}` by default, and each identifier
|
|
9
|
+
* it writes outside a quoted string is a name it reads: `{{ user.name }}` names `user`
|
|
10
|
+
* and `name`. A file holding an unclosed action names nothing. A name reaches the
|
|
11
|
+
* instance members of classes and the members of interfaces and object types, on any
|
|
12
|
+
* type, because nothing ties the name to a type.
|
|
13
|
+
*/
|
|
14
|
+
import { ConfigError } from "./config.js";
|
|
15
|
+
import { memberNames } from "./member-names.js";
|
|
16
|
+
import { isAbsolutePath, joinPath, normalizePath } from "./paths.js";
|
|
17
|
+
/** The kinds of declaration an action can name. */
|
|
18
|
+
const READ_BY_A_TEMPLATE = new Set([
|
|
19
|
+
"method",
|
|
20
|
+
"class-member",
|
|
21
|
+
"interface-method",
|
|
22
|
+
"type-member",
|
|
23
|
+
]);
|
|
24
|
+
/** One identifier, as the grammar of the analyzed language spells one. */
|
|
25
|
+
const IDENTIFIER = /[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*/uy;
|
|
26
|
+
/** Two strings ordered bytewise. */
|
|
27
|
+
function compare(a, b) {
|
|
28
|
+
if (a === b) {
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
return a < b ? -1 : 1;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Every template under the configured directories, read once for the run, in path
|
|
35
|
+
* order and each file once. A directory is named relative to the target root, and
|
|
36
|
+
* one the target does not hold refuses the run as a usage error: the class would
|
|
37
|
+
* scan nothing, so a name that no longer matches the tree would silently switch the
|
|
38
|
+
* exemption it was configured for off. A symbolic link to a directory is not
|
|
39
|
+
* followed.
|
|
40
|
+
*/
|
|
41
|
+
export function readTemplates(host, targetRoot, analysis) {
|
|
42
|
+
const files = new Map();
|
|
43
|
+
const read = (dir) => {
|
|
44
|
+
const entries = [...host.readDirectory(joinPath(targetRoot, dir))].sort((a, b) => compare(a.name, b.name));
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const path = joinPath(dir, entry.name);
|
|
47
|
+
if (entry.directory) {
|
|
48
|
+
read(path);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const absolute = joinPath(targetRoot, path);
|
|
52
|
+
if (!files.has(path) && host.kindOf(absolute) === "file") {
|
|
53
|
+
files.set(path, host.readFile(absolute));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
for (const configured of analysis.templateDirs) {
|
|
58
|
+
const dir = normalizePath(configured);
|
|
59
|
+
if (isAbsolutePath(dir) ||
|
|
60
|
+
dir === ".." ||
|
|
61
|
+
dir.startsWith("../") ||
|
|
62
|
+
host.kindOf(joinPath(targetRoot, dir)) !== "directory") {
|
|
63
|
+
throw new ConfigError("malformed", "analysis.template_dirs", `analysis.template_dirs: ${JSON.stringify(configured)} is not a directory below the target root`);
|
|
64
|
+
}
|
|
65
|
+
read(dir);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
delimiters: analysis.templateDelimiters,
|
|
69
|
+
files: [...files]
|
|
70
|
+
.map(([path, text]) => ({ path, text }))
|
|
71
|
+
.sort((a, b) => compare(a.path, b.path)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** The end of the quoted string that opens at `from`, past its closing quote. */
|
|
75
|
+
function stringEnd(text, from) {
|
|
76
|
+
const quote = text[from];
|
|
77
|
+
let at = from + 1;
|
|
78
|
+
while (at < text.length && text[at] !== quote) {
|
|
79
|
+
at += text[at] === "\\" ? 2 : 1;
|
|
80
|
+
}
|
|
81
|
+
return at + 1;
|
|
82
|
+
}
|
|
83
|
+
/** The identifiers one action's inner text writes outside a quoted string, with their offsets in it. */
|
|
84
|
+
function identifiersOf(inner) {
|
|
85
|
+
const found = [];
|
|
86
|
+
let at = 0;
|
|
87
|
+
while (at < inner.length) {
|
|
88
|
+
const char = inner[at] ?? "";
|
|
89
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
90
|
+
at = stringEnd(inner, at);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (/[0-9]/u.test(char)) {
|
|
94
|
+
// A number's letters are part of the number: `1e3` names nothing.
|
|
95
|
+
while (at < inner.length && /[0-9A-Za-z_.]/u.test(inner[at] ?? "")) {
|
|
96
|
+
at += 1;
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
IDENTIFIER.lastIndex = at;
|
|
101
|
+
const match = IDENTIFIER.exec(inner);
|
|
102
|
+
if (match !== null) {
|
|
103
|
+
found.push({ name: match[0], offset: at });
|
|
104
|
+
at += match[0].length;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
at += 1;
|
|
108
|
+
}
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Every identifier one template's actions write, in the order the file writes them,
|
|
113
|
+
* or none where an action is not closed.
|
|
114
|
+
*/
|
|
115
|
+
function namesOf(text, delimiters) {
|
|
116
|
+
const found = [];
|
|
117
|
+
let from = 0;
|
|
118
|
+
for (;;) {
|
|
119
|
+
const open = text.indexOf(delimiters.left, from);
|
|
120
|
+
if (open < 0) {
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
const start = open + delimiters.left.length;
|
|
124
|
+
const close = text.indexOf(delimiters.right, start);
|
|
125
|
+
if (close < 0) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const inner = text.slice(start, close);
|
|
129
|
+
const action = `${delimiters.left}${inner.trim().replace(/\s+/gu, " ")}${delimiters.right}`;
|
|
130
|
+
for (const { name, offset } of identifiersOf(inner)) {
|
|
131
|
+
found.push({ name, offset: start + offset, action });
|
|
132
|
+
}
|
|
133
|
+
from = close + delimiters.right.length;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Where one offset of a template is, its column counted in UTF-16 code units. */
|
|
137
|
+
function positionIn(file, offset) {
|
|
138
|
+
const before = file.text.slice(0, offset);
|
|
139
|
+
const lineStart = before.lastIndexOf("\n") + 1;
|
|
140
|
+
return {
|
|
141
|
+
path: file.path,
|
|
142
|
+
line: before.split("\n").length,
|
|
143
|
+
column: offset - lineStart + 1,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The template-field detector: every instance member of a class and every member of
|
|
148
|
+
* an interface or an object type whose name an action of a configured template
|
|
149
|
+
* writes, recorded at the identifier.
|
|
150
|
+
*/
|
|
151
|
+
export function templateField(input) {
|
|
152
|
+
const { templates } = input;
|
|
153
|
+
if (templates.files.length === 0) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
const members = memberNames(input.project.ownSourceFiles(), input.held);
|
|
157
|
+
const found = [];
|
|
158
|
+
for (const file of templates.files) {
|
|
159
|
+
for (const named of namesOf(file.text, templates.delimiters)) {
|
|
160
|
+
for (const member of members.get(named.name) ?? []) {
|
|
161
|
+
if (!READ_BY_A_TEMPLATE.has(member.kind) || member.static) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
found.push({
|
|
165
|
+
id: member.id,
|
|
166
|
+
detail: `named by ${named.action}`,
|
|
167
|
+
site: positionIn(file, named.offset),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return found;
|
|
173
|
+
}
|
package/dist/src/version.js
CHANGED
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/deadset-ts",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Deterministic dead-code analysis for TypeScript, class and type members included",
|
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@eslint/js": "10.0.1",
|
|
35
|
-
"@types/node": "24.
|
|
35
|
+
"@types/node": "24.19.0",
|
|
36
36
|
"eslint": "10.11.0",
|
|
37
|
-
"fast-check": "4.10.
|
|
38
|
-
"prettier": "3.9.
|
|
37
|
+
"fast-check": "4.10.2",
|
|
38
|
+
"prettier": "3.9.9",
|
|
39
39
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
40
|
-
"typescript-eslint": "8.70.
|
|
41
|
-
"vitest": "5.0.
|
|
40
|
+
"typescript-eslint": "8.70.1",
|
|
41
|
+
"vitest": "5.0.2"
|
|
42
42
|
},
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public"
|
package/src/analysis.ts
CHANGED
|
@@ -10,19 +10,32 @@
|
|
|
10
10
|
|
|
11
11
|
import type { Diagnostic } from "@typescript/native/unstable/sync";
|
|
12
12
|
import type { Config, Provenance } from "./config.ts";
|
|
13
|
+
import { decorator } from "./decorator.ts";
|
|
13
14
|
import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.ts";
|
|
15
|
+
import {
|
|
16
|
+
computeExemptions,
|
|
17
|
+
disabledClasses,
|
|
18
|
+
exemptionsOf,
|
|
19
|
+
retainedIn,
|
|
20
|
+
type Detector,
|
|
21
|
+
type Detectors,
|
|
22
|
+
} from "./exempt.ts";
|
|
23
|
+
import type { TSExemptionClass } from "./exempt-classes.ts";
|
|
14
24
|
import type { Finding } from "./finding.ts";
|
|
15
25
|
import type { Host } from "./host.ts";
|
|
26
|
+
import { interfaceSatisfaction } from "./interface-satisfaction.ts";
|
|
16
27
|
import { inventory, type Inventory } from "./inventory.ts";
|
|
17
28
|
import { readManifest } from "./manifest.ts";
|
|
18
29
|
import { matrixOf, sweepMatrix, type Configured, type Matrix, type SweepResult } from "./matrix.ts";
|
|
19
30
|
import { relativePath, resolvePath } from "./paths.ts";
|
|
20
31
|
import { byPosition, type Position } from "./position.ts";
|
|
32
|
+
import { reflectiveLookup } from "./reflective-lookup.ts";
|
|
21
33
|
import { references } from "./references.ts";
|
|
22
34
|
import { roots, unmatchedEverywhere, unmatchedRoots, type RootKind, type Roots } from "./roots.ts";
|
|
23
35
|
import type { Scope } from "./scope.ts";
|
|
24
36
|
import { diagnosticsOf, runSession, type Engine, type ProjectView } from "./session.ts";
|
|
25
|
-
import type { SweepInput } from "./sweep.ts";
|
|
37
|
+
import type { Exemption, SweepInput } from "./sweep.ts";
|
|
38
|
+
import { readTemplates, templateField } from "./template-field.ts";
|
|
26
39
|
|
|
27
40
|
/** The setting whose source decides where a finding about a configured root sits. */
|
|
28
41
|
const ROOTS_SETTING = "roots.patterns";
|
|
@@ -196,35 +209,67 @@ export function runRoots(
|
|
|
196
209
|
export interface RunSweep {
|
|
197
210
|
readonly matrix: Matrix;
|
|
198
211
|
readonly sweep: SweepResult;
|
|
212
|
+
/**
|
|
213
|
+
* The exemption records that held back a declaration some configuration of the run
|
|
214
|
+
* would otherwise judge dead, in the declarations' site order.
|
|
215
|
+
*/
|
|
216
|
+
readonly retained: readonly Exemption[];
|
|
199
217
|
}
|
|
200
218
|
|
|
219
|
+
/** The exemption classes this analyzer detects, each by its detector; any other retains nothing. */
|
|
220
|
+
const DETECTORS: Detectors = new Map<TSExemptionClass, Detector>([
|
|
221
|
+
["interface-satisfaction", interfaceSatisfaction],
|
|
222
|
+
["template-field", templateField],
|
|
223
|
+
["reflective-lookup", reflectiveLookup],
|
|
224
|
+
["decorator", decorator],
|
|
225
|
+
]);
|
|
226
|
+
|
|
201
227
|
/**
|
|
202
228
|
* Sweeps the run the scope and the configuration describe. Each project's references
|
|
203
|
-
* are read against its own inventory
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
229
|
+
* and exemptions are read against its own inventory while its view is open; the
|
|
230
|
+
* projects then merge into one matrix in the run's order, which is swept under the
|
|
231
|
+
* caller's marks and mode with the exemptions every project found. A record found in
|
|
232
|
+
* one configuration holds in every configuration that holds its declaration, because
|
|
233
|
+
* an exemption is evidence of a use the analysis cannot see and a use in one
|
|
234
|
+
* configuration is a use. A project that carries an error ends the run before any
|
|
235
|
+
* project is swept.
|
|
207
236
|
*/
|
|
208
237
|
export function runSweep(
|
|
209
238
|
engine: Engine,
|
|
210
239
|
host: Host,
|
|
211
240
|
scope: Scope,
|
|
212
241
|
config: Config,
|
|
213
|
-
input: SweepInput,
|
|
242
|
+
input: Pick<SweepInput, "marked" | "mode">,
|
|
243
|
+
detectors: Detectors = DETECTORS,
|
|
214
244
|
): RunSweep {
|
|
215
245
|
const targetRoot = scope.target.path;
|
|
216
|
-
const
|
|
217
|
-
|
|
246
|
+
const disabled = disabledClasses(config);
|
|
247
|
+
const templates = disabled.has("template-field")
|
|
248
|
+
? { delimiters: config.analysis.templateDelimiters, files: [] }
|
|
249
|
+
: readTemplates(host, targetRoot, config.analysis);
|
|
250
|
+
const read = readProjects(engine, host, scope, config, (project, projectRead) => {
|
|
251
|
+
const resolved = references(project, projectRead.held, targetRoot, {
|
|
218
252
|
testFiles: config.ts.testFiles,
|
|
219
253
|
});
|
|
220
|
-
|
|
221
|
-
configuration:
|
|
222
|
-
symbols:
|
|
254
|
+
const configured: Configured = {
|
|
255
|
+
configuration: projectRead.configuration,
|
|
256
|
+
symbols: projectRead.held.symbols,
|
|
223
257
|
references: resolved.references,
|
|
224
|
-
roots:
|
|
258
|
+
roots: projectRead.rooted.liveUnderReachability,
|
|
225
259
|
testFiles: resolved.testFilePaths,
|
|
226
260
|
};
|
|
261
|
+
const exempt = computeExemptions(
|
|
262
|
+
{ project, held: projectRead.held, targetRoot, templates },
|
|
263
|
+
detectors,
|
|
264
|
+
{
|
|
265
|
+
disabled,
|
|
266
|
+
mode: input.mode,
|
|
267
|
+
testFiles: new Set(resolved.testFilePaths),
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
return { configured, exempt };
|
|
227
271
|
});
|
|
228
|
-
const matrix = matrixOf(configured);
|
|
229
|
-
|
|
272
|
+
const matrix = matrixOf(read.map((one) => one.configured));
|
|
273
|
+
const swept: SweepInput = { ...input, exempt: exemptionsOf(read.flatMap((one) => one.exempt)) };
|
|
274
|
+
return { matrix, sweep: sweepMatrix(matrix, swept), retained: retainedIn(matrix, swept) };
|
|
230
275
|
}
|
package/src/decorator.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
|
|
14
|
+
import {
|
|
15
|
+
isCallExpression,
|
|
16
|
+
isDecorator,
|
|
17
|
+
type Decorator,
|
|
18
|
+
type Node,
|
|
19
|
+
} from "@typescript/native/unstable/ast";
|
|
20
|
+
import type { DetectorInput, Evidence } from "./exempt.ts";
|
|
21
|
+
import { nodeKey, type InventorySymbol, type SymbolKind } from "./inventory.ts";
|
|
22
|
+
import { renderPosition } from "./position.ts";
|
|
23
|
+
|
|
24
|
+
/** The kinds of declaration a decorator is attached to and holds back. */
|
|
25
|
+
const DECORATED: ReadonlySet<SymbolKind> = new Set(["class", "method", "class-member"]);
|
|
26
|
+
|
|
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
|
+
/**
|
|
35
|
+
* The decorator detector: each decorated member, and each member of a decorated
|
|
36
|
+
* class, recorded at the decorator.
|
|
37
|
+
*/
|
|
38
|
+
export function decorator<Brand>(input: DetectorInput<Brand>): readonly Evidence[] {
|
|
39
|
+
const { project, held, targetRoot } = input;
|
|
40
|
+
const byId = new Map(held.symbols.map((symbol) => [symbol.id, symbol]));
|
|
41
|
+
const membersOf = new Map<string, InventorySymbol[]>();
|
|
42
|
+
for (const symbol of held.symbols) {
|
|
43
|
+
if (symbol.kind === "method" || symbol.kind === "class-member") {
|
|
44
|
+
const members = membersOf.get(symbol.parent);
|
|
45
|
+
if (members === undefined) {
|
|
46
|
+
membersOf.set(symbol.parent, [symbol]);
|
|
47
|
+
} else {
|
|
48
|
+
members.push(symbol);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const found: Evidence[] = [];
|
|
54
|
+
for (const file of project.ownSourceFiles()) {
|
|
55
|
+
const visit = (node: Node): void => {
|
|
56
|
+
const id = held.declarations.get(nodeKey(file, node));
|
|
57
|
+
const symbol = id === undefined ? undefined : byId.get(id);
|
|
58
|
+
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)}`;
|
|
62
|
+
const site = renderPosition(file, targetRoot, attached.getStart());
|
|
63
|
+
const reached = symbol.kind === "class" ? (membersOf.get(symbol.id) ?? []) : [symbol];
|
|
64
|
+
for (const member of reached) {
|
|
65
|
+
found.push({ id: member.id, detail, site });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
node.forEachChild(visit);
|
|
70
|
+
};
|
|
71
|
+
file.forEachChild(visit);
|
|
72
|
+
}
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
|
|
13
|
+
import type { Confidence, Language } from "./config.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether a class may retain a member declared with the `private` modifier, and one
|
|
17
|
+
* declared with a `#private` name. The modifier is a compile-time constraint only, so
|
|
18
|
+
* such a member stays reachable by its name at run time; a private name cannot be
|
|
19
|
+
* named from outside its class body at all.
|
|
20
|
+
*/
|
|
21
|
+
export interface TypeScriptVisibility {
|
|
22
|
+
readonly private: boolean;
|
|
23
|
+
readonly privateName: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One class of the vocabulary. */
|
|
27
|
+
interface ExemptionClassRow {
|
|
28
|
+
readonly class: string;
|
|
29
|
+
readonly languages: readonly Language[];
|
|
30
|
+
readonly confidence: Confidence;
|
|
31
|
+
/** Present exactly on a class that runs on TypeScript. */
|
|
32
|
+
readonly typescriptVisibility?: TypeScriptVisibility;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every class of the vocabulary, in the order the Contract lists them. */
|
|
36
|
+
export const EXEMPTION_CLASSES = [
|
|
37
|
+
{
|
|
38
|
+
class: "interface-satisfaction",
|
|
39
|
+
languages: ["go", "ts"],
|
|
40
|
+
confidence: "certain",
|
|
41
|
+
typescriptVisibility: { private: false, privateName: false },
|
|
42
|
+
},
|
|
43
|
+
{ class: "encoding-reflection", languages: ["go"], confidence: "certain" },
|
|
44
|
+
{ class: "format-verb-contract", languages: ["go"], confidence: "certain" },
|
|
45
|
+
{ class: "errors-duck-typing", languages: ["go"], confidence: "certain" },
|
|
46
|
+
{
|
|
47
|
+
class: "enum-group",
|
|
48
|
+
languages: ["go", "ts"],
|
|
49
|
+
confidence: "certain",
|
|
50
|
+
typescriptVisibility: { private: false, privateName: false },
|
|
51
|
+
},
|
|
52
|
+
{ class: "generated-file", languages: ["go"], confidence: "certain" },
|
|
53
|
+
{ class: "linkname-cgo-asm-plugin", languages: ["go"], confidence: "certain" },
|
|
54
|
+
{
|
|
55
|
+
class: "template-field",
|
|
56
|
+
languages: ["go", "ts"],
|
|
57
|
+
confidence: "possible",
|
|
58
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
class: "reflective-lookup",
|
|
62
|
+
languages: ["go", "ts"],
|
|
63
|
+
confidence: "possible",
|
|
64
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
class: "decorator",
|
|
68
|
+
languages: ["ts"],
|
|
69
|
+
confidence: "certain",
|
|
70
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
class: "injection-container",
|
|
74
|
+
languages: ["ts"],
|
|
75
|
+
confidence: "certain",
|
|
76
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
class: "framework-lifecycle",
|
|
80
|
+
languages: ["ts"],
|
|
81
|
+
confidence: "certain",
|
|
82
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
class: "serialization-contract",
|
|
86
|
+
languages: ["ts"],
|
|
87
|
+
confidence: "certain",
|
|
88
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
89
|
+
},
|
|
90
|
+
] as const satisfies readonly ExemptionClassRow[];
|
|
91
|
+
|
|
92
|
+
type Row = (typeof EXEMPTION_CLASSES)[number];
|
|
93
|
+
|
|
94
|
+
/** The name of a class of the vocabulary. */
|
|
95
|
+
export type ExemptionClass = Row["class"];
|
|
96
|
+
|
|
97
|
+
/** The classes of the rows that list one language. */
|
|
98
|
+
type ClassesOf<R, L extends Language> = R extends {
|
|
99
|
+
readonly class: infer C;
|
|
100
|
+
readonly languages: readonly (infer Listed)[];
|
|
101
|
+
}
|
|
102
|
+
? L extends Listed
|
|
103
|
+
? C
|
|
104
|
+
: never
|
|
105
|
+
: never;
|
|
106
|
+
|
|
107
|
+
/** The name of a class that runs on TypeScript, the only classes this analyzer records. */
|
|
108
|
+
export type TSExemptionClass = ClassesOf<Row, "ts">;
|
|
109
|
+
|
|
110
|
+
function runsOnTS(row: Row): row is Extract<Row, { readonly class: TSExemptionClass }> {
|
|
111
|
+
const languages: readonly Language[] = row.languages;
|
|
112
|
+
return languages.includes("ts");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const TS_ROWS = EXEMPTION_CLASSES.filter(runsOnTS);
|
|
116
|
+
|
|
117
|
+
/** The classes that run on TypeScript, in the vocabulary's order. */
|
|
118
|
+
export const TS_EXEMPTION_CLASSES: readonly TSExemptionClass[] = TS_ROWS.map((row) => row.class);
|
|
119
|
+
|
|
120
|
+
const VISIBILITY: ReadonlyMap<TSExemptionClass, TypeScriptVisibility> = new Map(
|
|
121
|
+
TS_ROWS.map((row) => [row.class, row.typescriptVisibility]),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/** Which private members one class that runs on TypeScript may retain. */
|
|
125
|
+
export function typescriptVisibilityOf(exemptionClass: TSExemptionClass): TypeScriptVisibility {
|
|
126
|
+
return VISIBILITY.get(exemptionClass) ?? { private: false, privateName: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether one name is a class of the vocabulary, whichever language it runs on. */
|
|
130
|
+
export function isExemptionClass(name: string): name is ExemptionClass {
|
|
131
|
+
return EXEMPTION_CLASSES.some((row) => row.class === name);
|
|
132
|
+
}
|