@cplieger/deadset-ts 3.2.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 +2 -2
- package/dist/src/analysis.js +14 -2
- package/dist/src/decorator.js +64 -0
- package/dist/src/exempt-classes.js +57 -10
- package/dist/src/exempt.js +23 -3
- 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/template-field.js +173 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +24 -6
- package/src/decorator.ts +74 -0
- package/src/exempt-classes.ts +75 -12
- package/src/exempt.ts +29 -3
- 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/template-field.ts +214 -0
package/dist/src/references.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isArrayLiteralExpression, isBinaryExpression, isCallExpression, isDeleteExpression, isElementAccessExpression, isExportDeclaration, isExternalModuleReference, isForInStatement, isForOfStatement, isImportDeclaration, isImportEqualsDeclaration, isNamespaceExport, isNoSubstitutionTemplateLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPostfixUnaryExpression, isPrefixUnaryExpression, isPropertyAccessExpression, isPropertyAssignment, isShorthandPropertyAssignment, isSpreadAssignment, isSpreadElement, isStringLiteral, SyntaxKind, } from "@typescript/native/unstable/ast";
|
|
1
|
+
import { isArrayLiteralExpression, isBinaryExpression, isCallExpression, isDecorator, isDeleteExpression, isElementAccessExpression, isExportDeclaration, isExternalModuleReference, isForInStatement, isForOfStatement, isImportDeclaration, isImportEqualsDeclaration, isNamespaceExport, isNoSubstitutionTemplateLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPostfixUnaryExpression, isPrefixUnaryExpression, isPropertyAccessExpression, isPropertyAssignment, isShorthandPropertyAssignment, isSpreadAssignment, isSpreadElement, isStringLiteral, SyntaxKind, } from "@typescript/native/unstable/ast";
|
|
2
2
|
import { aliasChains } from "./alias-chain.js";
|
|
3
3
|
import { globExpression } from "./glob.js";
|
|
4
4
|
import { nodeKey } from "./inventory.js";
|
|
@@ -207,6 +207,7 @@ function sitesOf(file, declarations, fileId) {
|
|
|
207
207
|
const found = [];
|
|
208
208
|
const links = [];
|
|
209
209
|
const evaluations = [];
|
|
210
|
+
const decorated = [];
|
|
210
211
|
const writes = new Set();
|
|
211
212
|
const declared = new Set();
|
|
212
213
|
const shorthands = new Map();
|
|
@@ -251,12 +252,17 @@ function sitesOf(file, declarations, fileId) {
|
|
|
251
252
|
const outer = enclosing;
|
|
252
253
|
if (held !== undefined) {
|
|
253
254
|
enclosing = held;
|
|
255
|
+
for (const modifier of node.modifiers ?? []) {
|
|
256
|
+
if (isDecorator(modifier)) {
|
|
257
|
+
decorated.push({ decorator: modifier, declaration: held });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
254
260
|
}
|
|
255
261
|
node.forEachChild(visit);
|
|
256
262
|
enclosing = outer;
|
|
257
263
|
};
|
|
258
264
|
file.forEachChild(visit);
|
|
259
|
-
return { uses: found, links, evaluations };
|
|
265
|
+
return { uses: found, links, evaluations, decorated };
|
|
260
266
|
}
|
|
261
267
|
/**
|
|
262
268
|
* The name one test-file rule takes. A rule's name is a word list the report's own
|
|
@@ -336,7 +342,20 @@ export function references(project, held, targetRoot, options) {
|
|
|
336
342
|
if (test) {
|
|
337
343
|
tests.push(path);
|
|
338
344
|
}
|
|
339
|
-
const { uses, links, evaluations } = sitesOf(file, declarations, declaredAt(file) ?? "");
|
|
345
|
+
const { uses, links, evaluations, decorated } = sitesOf(file, declarations, declaredAt(file) ?? "");
|
|
346
|
+
// A decorator is written inside the declaration it is attached to and receives it,
|
|
347
|
+
// so the declaration is used where the decorator is written, whatever the
|
|
348
|
+
// decorator does with it.
|
|
349
|
+
for (const { decorator, declaration } of decorated) {
|
|
350
|
+
found.push({
|
|
351
|
+
from: declaration,
|
|
352
|
+
to: declaration,
|
|
353
|
+
position: renderPosition(file, targetRoot, decorator.getStart()),
|
|
354
|
+
use: "decorator",
|
|
355
|
+
resolution: "syntax",
|
|
356
|
+
test,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
340
359
|
// One batch per capped run of the file's name nodes, the re-exports' nodes after
|
|
341
360
|
// the uses and the evaluated modules' specifiers last, each node once: a bare star
|
|
342
361
|
// re-export's specifier is a link and an evaluation both. A shorthand is left out
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reflective-lookup class: a member is held back, at the lowest confidence, when
|
|
3
|
+
* a string literal equal to its name is the key of an element access or the property
|
|
4
|
+
* key of a `Reflect.get`, `Reflect.set` or `Reflect.has` call, because the member is
|
|
5
|
+
* then read by a name the program computes nowhere the reference pass can follow.
|
|
6
|
+
*
|
|
7
|
+
* The evidence is a string beside a lookup rather than a type relation, so the
|
|
8
|
+
* string reaches every member of that name on any type, static members included,
|
|
9
|
+
* and every record names the string's own position. A key that is not literal text
|
|
10
|
+
* holds nothing back: a computed name is the case no exact rule serves. `Reflect` is
|
|
11
|
+
* the global one only: a local declaration of that name is a different object.
|
|
12
|
+
*/
|
|
13
|
+
import { isCallExpression, isElementAccessExpression, isIdentifier, isNoSubstitutionTemplateLiteral, isPropertyAccessExpression, isStringLiteral, } from "@typescript/native/unstable/ast";
|
|
14
|
+
import { memberNames } from "./member-names.js";
|
|
15
|
+
import { renderPosition } from "./position.js";
|
|
16
|
+
/** The `Reflect` methods whose second argument is a property key. */
|
|
17
|
+
const REFLECT_KEYED = new Set(["get", "set", "has"]);
|
|
18
|
+
/** The text one key denotes, where it is literal text. */
|
|
19
|
+
function literalText(node) {
|
|
20
|
+
if (node !== undefined && (isStringLiteral(node) || isNoSubstitutionTemplateLiteral(node))) {
|
|
21
|
+
return node.text;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
/** Every lookup one file writes with a literal key, in source order. */
|
|
26
|
+
function lookupsOf(file) {
|
|
27
|
+
const found = [];
|
|
28
|
+
const visit = (node) => {
|
|
29
|
+
if (isElementAccessExpression(node)) {
|
|
30
|
+
const name = literalText(node.argumentExpression);
|
|
31
|
+
if (name !== undefined) {
|
|
32
|
+
found.push({
|
|
33
|
+
key: node.argumentExpression,
|
|
34
|
+
name,
|
|
35
|
+
callee: `[${JSON.stringify(name)}]`,
|
|
36
|
+
reflect: undefined,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (isCallExpression(node) && isPropertyAccessExpression(node.expression)) {
|
|
41
|
+
const target = node.expression.expression;
|
|
42
|
+
const method = node.expression.name;
|
|
43
|
+
const key = node.arguments[1];
|
|
44
|
+
const name = literalText(key);
|
|
45
|
+
if (isIdentifier(target) &&
|
|
46
|
+
target.text === "Reflect" &&
|
|
47
|
+
isIdentifier(method) &&
|
|
48
|
+
REFLECT_KEYED.has(method.text) &&
|
|
49
|
+
key !== undefined &&
|
|
50
|
+
name !== undefined) {
|
|
51
|
+
found.push({ key, name, callee: `Reflect.${method.text}`, reflect: target });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
node.forEachChild(visit);
|
|
55
|
+
};
|
|
56
|
+
file.forEachChild(visit);
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The reflective-lookup detector: every member whose name a literal key of a lookup
|
|
61
|
+
* spells, recorded at the key.
|
|
62
|
+
*/
|
|
63
|
+
export function reflectiveLookup(input) {
|
|
64
|
+
const { project, held, targetRoot } = input;
|
|
65
|
+
const files = project.ownSourceFiles();
|
|
66
|
+
const lookups = files.flatMap(lookupsOf);
|
|
67
|
+
if (lookups.length === 0) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
// Whether each written `Reflect` is the global one, asked in one batch: a
|
|
71
|
+
// declaration of that name in one of the project's own files shadows it.
|
|
72
|
+
const ownFiles = new Set(files.map((file) => file.fileName));
|
|
73
|
+
const reflects = lookups.flatMap((lookup) => lookup.reflect === undefined ? [] : [lookup.reflect]);
|
|
74
|
+
const global = new Set();
|
|
75
|
+
if (reflects.length > 0) {
|
|
76
|
+
project.symbolsAt(reflects.map((node) => project.handle(node))).forEach((symbol, index) => {
|
|
77
|
+
const node = reflects[index];
|
|
78
|
+
const declaredHere = symbol?.declarations.some((declaration) => ownFiles.has(declaration.path));
|
|
79
|
+
if (node !== undefined && declaredHere === false) {
|
|
80
|
+
global.add(node);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const members = memberNames(files, held);
|
|
85
|
+
const found = [];
|
|
86
|
+
for (const lookup of lookups) {
|
|
87
|
+
if (lookup.reflect !== undefined && !global.has(lookup.reflect)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const site = renderPosition(lookup.key.getSourceFile(), targetRoot, lookup.key.getStart());
|
|
91
|
+
for (const member of members.get(lookup.name) ?? []) {
|
|
92
|
+
found.push({ id: member.id, detail: `looked up by ${lookup.callee}`, site });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
@@ -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/jsr.json
CHANGED
package/package.json
CHANGED
package/src/analysis.ts
CHANGED
|
@@ -10,26 +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";
|
|
14
15
|
import {
|
|
15
16
|
computeExemptions,
|
|
16
17
|
disabledClasses,
|
|
17
18
|
exemptionsOf,
|
|
18
19
|
retainedIn,
|
|
20
|
+
type Detector,
|
|
19
21
|
type Detectors,
|
|
20
22
|
} from "./exempt.ts";
|
|
23
|
+
import type { TSExemptionClass } from "./exempt-classes.ts";
|
|
21
24
|
import type { Finding } from "./finding.ts";
|
|
22
25
|
import type { Host } from "./host.ts";
|
|
26
|
+
import { interfaceSatisfaction } from "./interface-satisfaction.ts";
|
|
23
27
|
import { inventory, type Inventory } from "./inventory.ts";
|
|
24
28
|
import { readManifest } from "./manifest.ts";
|
|
25
29
|
import { matrixOf, sweepMatrix, type Configured, type Matrix, type SweepResult } from "./matrix.ts";
|
|
26
30
|
import { relativePath, resolvePath } from "./paths.ts";
|
|
27
31
|
import { byPosition, type Position } from "./position.ts";
|
|
32
|
+
import { reflectiveLookup } from "./reflective-lookup.ts";
|
|
28
33
|
import { references } from "./references.ts";
|
|
29
34
|
import { roots, unmatchedEverywhere, unmatchedRoots, type RootKind, type Roots } from "./roots.ts";
|
|
30
35
|
import type { Scope } from "./scope.ts";
|
|
31
36
|
import { diagnosticsOf, runSession, type Engine, type ProjectView } from "./session.ts";
|
|
32
37
|
import type { Exemption, SweepInput } from "./sweep.ts";
|
|
38
|
+
import { readTemplates, templateField } from "./template-field.ts";
|
|
33
39
|
|
|
34
40
|
/** The setting whose source decides where a finding about a configured root sits. */
|
|
35
41
|
const ROOTS_SETTING = "roots.patterns";
|
|
@@ -211,7 +217,12 @@ export interface RunSweep {
|
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
/** The exemption classes this analyzer detects, each by its detector; any other retains nothing. */
|
|
214
|
-
const DETECTORS: Detectors = new Map(
|
|
220
|
+
const DETECTORS: Detectors = new Map<TSExemptionClass, Detector>([
|
|
221
|
+
["interface-satisfaction", interfaceSatisfaction],
|
|
222
|
+
["template-field", templateField],
|
|
223
|
+
["reflective-lookup", reflectiveLookup],
|
|
224
|
+
["decorator", decorator],
|
|
225
|
+
]);
|
|
215
226
|
|
|
216
227
|
/**
|
|
217
228
|
* Sweeps the run the scope and the configuration describe. Each project's references
|
|
@@ -233,6 +244,9 @@ export function runSweep(
|
|
|
233
244
|
): RunSweep {
|
|
234
245
|
const targetRoot = scope.target.path;
|
|
235
246
|
const disabled = disabledClasses(config);
|
|
247
|
+
const templates = disabled.has("template-field")
|
|
248
|
+
? { delimiters: config.analysis.templateDelimiters, files: [] }
|
|
249
|
+
: readTemplates(host, targetRoot, config.analysis);
|
|
236
250
|
const read = readProjects(engine, host, scope, config, (project, projectRead) => {
|
|
237
251
|
const resolved = references(project, projectRead.held, targetRoot, {
|
|
238
252
|
testFiles: config.ts.testFiles,
|
|
@@ -244,11 +258,15 @@ export function runSweep(
|
|
|
244
258
|
roots: projectRead.rooted.liveUnderReachability,
|
|
245
259
|
testFiles: resolved.testFilePaths,
|
|
246
260
|
};
|
|
247
|
-
const exempt = computeExemptions(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
);
|
|
252
270
|
return { configured, exempt };
|
|
253
271
|
});
|
|
254
272
|
const matrix = matrixOf(read.map((one) => one.configured));
|
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
|
+
}
|
package/src/exempt-classes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The exemption classes of the Contract's vocabulary, in its order: each class's name,
|
|
3
|
-
* the languages it runs on
|
|
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.
|
|
4
5
|
*
|
|
5
6
|
* The rows are written out rather than read from the Contract at run time, for the
|
|
6
7
|
* reason the fixed severity codes are, and a test holds them equal to the Contract
|
|
@@ -11,28 +12,81 @@
|
|
|
11
12
|
|
|
12
13
|
import type { Confidence, Language } from "./config.ts";
|
|
13
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
|
+
|
|
14
26
|
/** One class of the vocabulary. */
|
|
15
27
|
interface ExemptionClassRow {
|
|
16
28
|
readonly class: string;
|
|
17
29
|
readonly languages: readonly Language[];
|
|
18
30
|
readonly confidence: Confidence;
|
|
31
|
+
/** Present exactly on a class that runs on TypeScript. */
|
|
32
|
+
readonly typescriptVisibility?: TypeScriptVisibility;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
/** Every class of the vocabulary, in the order the Contract lists them. */
|
|
22
36
|
export const EXEMPTION_CLASSES = [
|
|
23
|
-
{
|
|
37
|
+
{
|
|
38
|
+
class: "interface-satisfaction",
|
|
39
|
+
languages: ["go", "ts"],
|
|
40
|
+
confidence: "certain",
|
|
41
|
+
typescriptVisibility: { private: false, privateName: false },
|
|
42
|
+
},
|
|
24
43
|
{ class: "encoding-reflection", languages: ["go"], confidence: "certain" },
|
|
25
44
|
{ class: "format-verb-contract", languages: ["go"], confidence: "certain" },
|
|
26
45
|
{ class: "errors-duck-typing", languages: ["go"], confidence: "certain" },
|
|
27
|
-
{
|
|
46
|
+
{
|
|
47
|
+
class: "enum-group",
|
|
48
|
+
languages: ["go", "ts"],
|
|
49
|
+
confidence: "certain",
|
|
50
|
+
typescriptVisibility: { private: false, privateName: false },
|
|
51
|
+
},
|
|
28
52
|
{ class: "generated-file", languages: ["go"], confidence: "certain" },
|
|
29
53
|
{ class: "linkname-cgo-asm-plugin", languages: ["go"], confidence: "certain" },
|
|
30
|
-
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
},
|
|
36
90
|
] as const satisfies readonly ExemptionClassRow[];
|
|
37
91
|
|
|
38
92
|
type Row = (typeof EXEMPTION_CLASSES)[number];
|
|
@@ -58,10 +112,19 @@ function runsOnTS(row: Row): row is Extract<Row, { readonly class: TSExemptionCl
|
|
|
58
112
|
return languages.includes("ts");
|
|
59
113
|
}
|
|
60
114
|
|
|
115
|
+
const TS_ROWS = EXEMPTION_CLASSES.filter(runsOnTS);
|
|
116
|
+
|
|
61
117
|
/** The classes that run on TypeScript, in the vocabulary's order. */
|
|
62
|
-
export const TS_EXEMPTION_CLASSES: readonly TSExemptionClass[] =
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
|
65
128
|
|
|
66
129
|
/** Whether one name is a class of the vocabulary, whichever language it runs on. */
|
|
67
130
|
export function isExemptionClass(name: string): name is ExemptionClass {
|