@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
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
|
|
15
|
+
import { ConfigError, type Analysis, type TemplateDelimiters } from "./config.ts";
|
|
16
|
+
import type { DetectorInput, Evidence } from "./exempt.ts";
|
|
17
|
+
import type { Host } from "./host.ts";
|
|
18
|
+
import type { SymbolKind } from "./inventory.ts";
|
|
19
|
+
import { memberNames } from "./member-names.ts";
|
|
20
|
+
import { isAbsolutePath, joinPath, normalizePath } from "./paths.ts";
|
|
21
|
+
import type { Position } from "./position.ts";
|
|
22
|
+
|
|
23
|
+
/** One template, by its path below the target root. */
|
|
24
|
+
export interface TemplateFile {
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly text: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The templates one run scans, and the pair their actions are written with. */
|
|
30
|
+
export interface Templates {
|
|
31
|
+
readonly delimiters: TemplateDelimiters;
|
|
32
|
+
readonly files: readonly TemplateFile[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The kinds of declaration an action can name. */
|
|
36
|
+
const READ_BY_A_TEMPLATE: ReadonlySet<SymbolKind> = new Set([
|
|
37
|
+
"method",
|
|
38
|
+
"class-member",
|
|
39
|
+
"interface-method",
|
|
40
|
+
"type-member",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/** One identifier, as the grammar of the analyzed language spells one. */
|
|
44
|
+
const IDENTIFIER = /[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*/uy;
|
|
45
|
+
|
|
46
|
+
/** Two strings ordered bytewise. */
|
|
47
|
+
function compare(a: string, b: string): number {
|
|
48
|
+
if (a === b) {
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
return a < b ? -1 : 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every template under the configured directories, read once for the run, in path
|
|
56
|
+
* order and each file once. A directory is named relative to the target root, and
|
|
57
|
+
* one the target does not hold refuses the run as a usage error: the class would
|
|
58
|
+
* scan nothing, so a name that no longer matches the tree would silently switch the
|
|
59
|
+
* exemption it was configured for off. A symbolic link to a directory is not
|
|
60
|
+
* followed.
|
|
61
|
+
*/
|
|
62
|
+
export function readTemplates(host: Host, targetRoot: string, analysis: Analysis): Templates {
|
|
63
|
+
const files = new Map<string, string>();
|
|
64
|
+
const read = (dir: string): void => {
|
|
65
|
+
const entries = [...host.readDirectory(joinPath(targetRoot, dir))].sort((a, b) =>
|
|
66
|
+
compare(a.name, b.name),
|
|
67
|
+
);
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const path = joinPath(dir, entry.name);
|
|
70
|
+
if (entry.directory) {
|
|
71
|
+
read(path);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const absolute = joinPath(targetRoot, path);
|
|
75
|
+
if (!files.has(path) && host.kindOf(absolute) === "file") {
|
|
76
|
+
files.set(path, host.readFile(absolute));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
for (const configured of analysis.templateDirs) {
|
|
81
|
+
const dir = normalizePath(configured);
|
|
82
|
+
if (
|
|
83
|
+
isAbsolutePath(dir) ||
|
|
84
|
+
dir === ".." ||
|
|
85
|
+
dir.startsWith("../") ||
|
|
86
|
+
host.kindOf(joinPath(targetRoot, dir)) !== "directory"
|
|
87
|
+
) {
|
|
88
|
+
throw new ConfigError(
|
|
89
|
+
"malformed",
|
|
90
|
+
"analysis.template_dirs",
|
|
91
|
+
`analysis.template_dirs: ${JSON.stringify(configured)} is not a directory below the target root`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
read(dir);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
delimiters: analysis.templateDelimiters,
|
|
98
|
+
files: [...files]
|
|
99
|
+
.map(([path, text]) => ({ path, text }))
|
|
100
|
+
.sort((a, b) => compare(a.path, b.path)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One identifier an action writes, where it is written, and the action that writes it. */
|
|
105
|
+
interface Named {
|
|
106
|
+
readonly name: string;
|
|
107
|
+
readonly offset: number;
|
|
108
|
+
readonly action: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The end of the quoted string that opens at `from`, past its closing quote. */
|
|
112
|
+
function stringEnd(text: string, from: number): number {
|
|
113
|
+
const quote = text[from];
|
|
114
|
+
let at = from + 1;
|
|
115
|
+
while (at < text.length && text[at] !== quote) {
|
|
116
|
+
at += text[at] === "\\" ? 2 : 1;
|
|
117
|
+
}
|
|
118
|
+
return at + 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The identifiers one action's inner text writes outside a quoted string, with their offsets in it. */
|
|
122
|
+
function identifiersOf(inner: string): readonly { name: string; offset: number }[] {
|
|
123
|
+
const found: { name: string; offset: number }[] = [];
|
|
124
|
+
let at = 0;
|
|
125
|
+
while (at < inner.length) {
|
|
126
|
+
const char = inner[at] ?? "";
|
|
127
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
128
|
+
at = stringEnd(inner, at);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (/[0-9]/u.test(char)) {
|
|
132
|
+
// A number's letters are part of the number: `1e3` names nothing.
|
|
133
|
+
while (at < inner.length && /[0-9A-Za-z_.]/u.test(inner[at] ?? "")) {
|
|
134
|
+
at += 1;
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
IDENTIFIER.lastIndex = at;
|
|
139
|
+
const match = IDENTIFIER.exec(inner);
|
|
140
|
+
if (match !== null) {
|
|
141
|
+
found.push({ name: match[0], offset: at });
|
|
142
|
+
at += match[0].length;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
at += 1;
|
|
146
|
+
}
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every identifier one template's actions write, in the order the file writes them,
|
|
152
|
+
* or none where an action is not closed.
|
|
153
|
+
*/
|
|
154
|
+
function namesOf(text: string, delimiters: TemplateDelimiters): readonly Named[] {
|
|
155
|
+
const found: Named[] = [];
|
|
156
|
+
let from = 0;
|
|
157
|
+
for (;;) {
|
|
158
|
+
const open = text.indexOf(delimiters.left, from);
|
|
159
|
+
if (open < 0) {
|
|
160
|
+
return found;
|
|
161
|
+
}
|
|
162
|
+
const start = open + delimiters.left.length;
|
|
163
|
+
const close = text.indexOf(delimiters.right, start);
|
|
164
|
+
if (close < 0) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
const inner = text.slice(start, close);
|
|
168
|
+
const action = `${delimiters.left}${inner.trim().replace(/\s+/gu, " ")}${delimiters.right}`;
|
|
169
|
+
for (const { name, offset } of identifiersOf(inner)) {
|
|
170
|
+
found.push({ name, offset: start + offset, action });
|
|
171
|
+
}
|
|
172
|
+
from = close + delimiters.right.length;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Where one offset of a template is, its column counted in UTF-16 code units. */
|
|
177
|
+
function positionIn(file: TemplateFile, offset: number): Position {
|
|
178
|
+
const before = file.text.slice(0, offset);
|
|
179
|
+
const lineStart = before.lastIndexOf("\n") + 1;
|
|
180
|
+
return {
|
|
181
|
+
path: file.path,
|
|
182
|
+
line: before.split("\n").length,
|
|
183
|
+
column: offset - lineStart + 1,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The template-field detector: every instance member of a class and every member of
|
|
189
|
+
* an interface or an object type whose name an action of a configured template
|
|
190
|
+
* writes, recorded at the identifier.
|
|
191
|
+
*/
|
|
192
|
+
export function templateField<Brand>(input: DetectorInput<Brand>): readonly Evidence[] {
|
|
193
|
+
const { templates } = input;
|
|
194
|
+
if (templates.files.length === 0) {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
const members = memberNames(input.project.ownSourceFiles(), input.held);
|
|
198
|
+
const found: Evidence[] = [];
|
|
199
|
+
for (const file of templates.files) {
|
|
200
|
+
for (const named of namesOf(file.text, templates.delimiters)) {
|
|
201
|
+
for (const member of members.get(named.name) ?? []) {
|
|
202
|
+
if (!READ_BY_A_TEMPLATE.has(member.kind) || member.static) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
found.push({
|
|
206
|
+
id: member.id,
|
|
207
|
+
detail: `named by ${named.action}`,
|
|
208
|
+
site: positionIn(file, named.offset),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return found;
|
|
214
|
+
}
|
package/src/version.ts
CHANGED