wrec 0.29.3 → 0.30.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/package.json +3 -2
- package/scripts/ast-utils.js +107 -0
- package/scripts/lint.js +351 -304
- package/scripts/used-by.js +513 -482
package/scripts/used-by.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// determines the proper values for property config `usedBy` properties.
|
|
4
4
|
// Each value is a list of methods that use the property
|
|
5
5
|
// or a single method name.
|
|
6
|
-
//
|
|
6
|
+
//
|
|
7
|
+
// This uses the TypeScript compiler to parse the source file,
|
|
7
8
|
// discover which expressions call which methods,
|
|
8
9
|
// trace property usage through those call chains, and
|
|
9
10
|
// output or update the `usedBy` properties`.
|
|
@@ -17,432 +18,60 @@
|
|
|
17
18
|
|
|
18
19
|
import fs from 'node:fs';
|
|
19
20
|
import path from 'node:path';
|
|
20
|
-
import {fileURLToPath} from 'node:url';
|
|
21
21
|
import ts from 'typescript';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
);
|
|
43
|
-
const multiline = original.includes('\n');
|
|
44
|
-
if (!multiline) {
|
|
45
|
-
const openMatch = original.match(/^\{(\s*)/);
|
|
46
|
-
const closeMatch = original.match(/(\s*)\}$/);
|
|
47
|
-
const openSpacing = openMatch ? openMatch[1] : ' ';
|
|
48
|
-
const closeSpacing = closeMatch ? closeMatch[1] : ' ';
|
|
49
|
-
return `{${openSpacing}${existingTexts.join(', ')}${closeSpacing}}`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Preserve the surrounding formatting style so the update feels like a
|
|
53
|
-
// minimal edit instead of reformatting the whole object.
|
|
54
|
-
const memberIndent = getIndent(text, member.getStart(sourceFile));
|
|
55
|
-
const firstExisting = existingMembers[0];
|
|
56
|
-
const innerIndent = firstExisting
|
|
57
|
-
? getIndent(text, firstExisting.getStart(sourceFile))
|
|
58
|
-
: memberIndent + ' ';
|
|
59
|
-
return `{\n${existingTexts.map(part => `${innerIndent}${part}`).join(',\n')}\n${memberIndent}}`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function collectFiles(startPath, files = []) {
|
|
63
|
-
if (!fs.existsSync(startPath)) return files;
|
|
64
|
-
|
|
65
|
-
const stat = fs.statSync(startPath);
|
|
66
|
-
if (stat.isFile()) {
|
|
67
|
-
if (isSupportedSourceFile(startPath)) files.push(startPath);
|
|
68
|
-
return files;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
for (const entry of fs.readdirSync(startPath, {withFileTypes: true})) {
|
|
72
|
-
const fullPath = path.join(startPath, entry.name);
|
|
73
|
-
if (entry.isDirectory()) {
|
|
74
|
-
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
|
75
|
-
collectFiles(fullPath, files);
|
|
76
|
-
} else if (entry.isFile() && isSupportedSourceFile(entry.name, true)) {
|
|
77
|
-
files.push(fullPath);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return files;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function createUsedByProperty(methodNames, quote) {
|
|
85
|
-
if (methodNames.length === 1) {
|
|
86
|
-
return `usedBy: ${quote}${methodNames[0]}${quote}`;
|
|
22
|
+
import {
|
|
23
|
+
extendsWrec,
|
|
24
|
+
getNameText,
|
|
25
|
+
getPropertyAssignmentNames,
|
|
26
|
+
getWrecImportInfo,
|
|
27
|
+
hasStaticModifier
|
|
28
|
+
} from './ast-utils.js';
|
|
29
|
+
|
|
30
|
+
const cwd = process.cwd();
|
|
31
|
+
|
|
32
|
+
// Records property names introduced by
|
|
33
|
+
// an identifier or object binding pattern.
|
|
34
|
+
// An object binding pattern is destructuring syntax
|
|
35
|
+
// used in a binding position, like a variable declaration,
|
|
36
|
+
// parameter list, or assignment target.
|
|
37
|
+
function addBindingName(props, name) {
|
|
38
|
+
if (ts.isIdentifier(name)) {
|
|
39
|
+
props.add(name.text);
|
|
40
|
+
} else if (ts.isObjectBindingPattern(name)) {
|
|
41
|
+
addObjectBindingProps(props, name);
|
|
87
42
|
}
|
|
88
|
-
return `usedBy: [${methodNames.map(name => `${quote}${name}${quote}`).join(', ')}]`;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function extendsWrec(node, wrecNames) {
|
|
92
|
-
return Boolean(
|
|
93
|
-
node.heritageClauses?.some(
|
|
94
|
-
clause =>
|
|
95
|
-
clause.token === ts.SyntaxKind.ExtendsKeyword &&
|
|
96
|
-
clause.types.some(
|
|
97
|
-
type =>
|
|
98
|
-
ts.isExpressionWithTypeArguments(type) &&
|
|
99
|
-
ts.isIdentifier(type.expression) &&
|
|
100
|
-
wrecNames.has(type.expression.text)
|
|
101
|
-
)
|
|
102
|
-
)
|
|
103
|
-
);
|
|
104
43
|
}
|
|
105
44
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
45
|
+
// Adds property names referenced by an object binding pattern.
|
|
46
|
+
function addObjectBindingProps(props, bindingPattern) {
|
|
47
|
+
for (const element of bindingPattern.elements) {
|
|
48
|
+
if (element.dotDotDotToken) continue;
|
|
109
49
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
!hasStaticModifier(member) ||
|
|
114
|
-
getNameText(member.name) !== 'properties' ||
|
|
115
|
-
!member.initializer ||
|
|
116
|
-
!ts.isObjectLiteralExpression(member.initializer)
|
|
117
|
-
) {
|
|
50
|
+
if (element.propertyName) {
|
|
51
|
+
const name = getNameText(element.propertyName);
|
|
52
|
+
if (name) props.add(name);
|
|
118
53
|
continue;
|
|
119
54
|
}
|
|
120
55
|
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
!ts.isPropertyAssignment(property) ||
|
|
124
|
-
!ts.isObjectLiteralExpression(property.initializer)
|
|
125
|
-
) {
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
for (const configProperty of property.initializer.properties) {
|
|
130
|
-
if (
|
|
131
|
-
!ts.isPropertyAssignment(configProperty) ||
|
|
132
|
-
getNameText(configProperty.name) !== 'computed'
|
|
133
|
-
) {
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (!ts.isStringLiteralLike(configProperty.initializer)) continue;
|
|
138
|
-
const computed = configProperty.initializer.text;
|
|
139
|
-
for (const match of computed.matchAll(CALL_RE)) {
|
|
140
|
-
methodNames.add(match[1]);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
56
|
+
addBindingName(props, element.name);
|
|
144
57
|
}
|
|
145
|
-
|
|
146
|
-
return methodNames;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function getIndent(text, pos) {
|
|
150
|
-
const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
|
|
151
|
-
const match = /^[ \t]*/.exec(text.slice(lineStart));
|
|
152
|
-
return match ? match[0] : '';
|
|
153
58
|
}
|
|
154
59
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (hasStaticModifier(member)) continue;
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
(ts.isMethodDeclaration(member) ||
|
|
162
|
-
ts.isGetAccessorDeclaration(member) ||
|
|
163
|
-
ts.isSetAccessorDeclaration(member)) &&
|
|
164
|
-
member.body
|
|
165
|
-
) {
|
|
166
|
-
const methodName = getNameText(member.name);
|
|
167
|
-
if (!methodName) continue;
|
|
168
|
-
|
|
169
|
-
const props = new Set();
|
|
170
|
-
const calledMethods = new Set();
|
|
171
|
-
const isPrivate = ts.isPrivateIdentifier(member.name);
|
|
172
|
-
|
|
173
|
-
function addBindingName(name) {
|
|
174
|
-
if (ts.isIdentifier(name)) {
|
|
175
|
-
props.add(name.text);
|
|
176
|
-
} else if (ts.isObjectBindingPattern(name)) {
|
|
177
|
-
addObjectBindingProps(name);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function addObjectBindingProps(bindingPattern) {
|
|
182
|
-
for (const element of bindingPattern.elements) {
|
|
183
|
-
if (element.dotDotDotToken) continue;
|
|
184
|
-
|
|
185
|
-
if (element.propertyName) {
|
|
186
|
-
const name = getNameText(element.propertyName);
|
|
187
|
-
if (name) props.add(name);
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (ts.isIdentifier(element.name)) {
|
|
192
|
-
props.add(element.name.text);
|
|
193
|
-
} else if (ts.isObjectBindingPattern(element.name)) {
|
|
194
|
-
addObjectBindingProps(element.name);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function visit(child) {
|
|
200
|
-
// Record both direct property reads like `this.foo` and method calls
|
|
201
|
-
// like `this.renderFoo()` so we can later propagate property usage
|
|
202
|
-
// through method-to-method call chains.
|
|
203
|
-
if (
|
|
204
|
-
ts.isPropertyAccessExpression(child) &&
|
|
205
|
-
child.expression.kind === ts.SyntaxKind.ThisKeyword
|
|
206
|
-
) {
|
|
207
|
-
const name = child.name.text;
|
|
208
|
-
props.add(name);
|
|
209
|
-
if (
|
|
210
|
-
ts.isCallExpression(child.parent) &&
|
|
211
|
-
child.parent.expression === child
|
|
212
|
-
) {
|
|
213
|
-
calledMethods.add(name);
|
|
214
|
-
}
|
|
215
|
-
} else if (
|
|
216
|
-
ts.isElementAccessExpression(child) &&
|
|
217
|
-
child.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
218
|
-
child.argumentExpression &&
|
|
219
|
-
ts.isStringLiteralLike(child.argumentExpression)
|
|
220
|
-
) {
|
|
221
|
-
const name = child.argumentExpression.text;
|
|
222
|
-
props.add(name);
|
|
223
|
-
if (
|
|
224
|
-
ts.isCallExpression(child.parent) &&
|
|
225
|
-
child.parent.expression === child
|
|
226
|
-
) {
|
|
227
|
-
calledMethods.add(name);
|
|
228
|
-
}
|
|
229
|
-
} else if (
|
|
230
|
-
ts.isVariableDeclaration(child) &&
|
|
231
|
-
child.initializer &&
|
|
232
|
-
child.initializer.kind === ts.SyntaxKind.ThisKeyword
|
|
233
|
-
) {
|
|
234
|
-
if (ts.isObjectBindingPattern(child.name)) {
|
|
235
|
-
addObjectBindingProps(child.name);
|
|
236
|
-
} else {
|
|
237
|
-
addBindingName(child.name);
|
|
238
|
-
}
|
|
239
|
-
} else if (
|
|
240
|
-
ts.isBinaryExpression(child) &&
|
|
241
|
-
child.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
242
|
-
child.right.kind === ts.SyntaxKind.ThisKeyword
|
|
243
|
-
) {
|
|
244
|
-
if (ts.isObjectLiteralExpression(child.left)) {
|
|
245
|
-
for (const property of child.left.properties) {
|
|
246
|
-
if (
|
|
247
|
-
ts.isShorthandPropertyAssignment(property) ||
|
|
248
|
-
ts.isPropertyAssignment(property)
|
|
249
|
-
) {
|
|
250
|
-
const name = getNameText(property.name);
|
|
251
|
-
if (name) props.add(name);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
} else if (ts.isObjectBindingPattern(child.left)) {
|
|
255
|
-
addObjectBindingProps(child.left);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
ts.forEachChild(child, visit);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
ts.forEachChild(member.body, visit);
|
|
263
|
-
methodInfo.set(methodName, {calledMethods, isPrivate, props});
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const entryMethods = new Set([
|
|
268
|
-
...getTemplateCalledMethods(classNode),
|
|
269
|
-
...getComputedCalledMethods(classNode)
|
|
270
|
-
]);
|
|
271
|
-
const memo = new Map();
|
|
272
|
-
|
|
273
|
-
function getTransitiveProps(methodName, seen = new Set()) {
|
|
274
|
-
// Starting from methods that are reachable from the template/computed
|
|
275
|
-
// properties, walk through nested method calls and accumulate every
|
|
276
|
-
// component property touched along the way.
|
|
277
|
-
if (memo.has(methodName)) return memo.get(methodName);
|
|
278
|
-
if (seen.has(methodName)) return new Set();
|
|
279
|
-
|
|
280
|
-
seen.add(methodName);
|
|
281
|
-
const info = methodInfo.get(methodName);
|
|
282
|
-
const props = new Set(info?.props ?? []);
|
|
283
|
-
|
|
284
|
-
if (info) {
|
|
285
|
-
for (const calledMethod of info.calledMethods) {
|
|
286
|
-
const calledProps = getTransitiveProps(calledMethod, seen);
|
|
287
|
-
for (const propName of calledProps) props.add(propName);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
seen.delete(methodName);
|
|
292
|
-
memo.set(methodName, props);
|
|
293
|
-
return props;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const propToMethods = new Map();
|
|
297
|
-
for (const methodName of entryMethods) {
|
|
298
|
-
const info = methodInfo.get(methodName);
|
|
299
|
-
if (!info || info.isPrivate) continue;
|
|
300
|
-
|
|
301
|
-
if (info.isPrivate) continue;
|
|
302
|
-
|
|
303
|
-
for (const propName of getTransitiveProps(methodName)) {
|
|
304
|
-
if (!propertyNames.has(propName)) continue;
|
|
305
|
-
|
|
306
|
-
let methods = propToMethods.get(propName);
|
|
307
|
-
if (!methods) {
|
|
308
|
-
methods = new Set();
|
|
309
|
-
propToMethods.set(propName, methods);
|
|
310
|
-
}
|
|
311
|
-
methods.add(methodName);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
return propToMethods;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function getNameText(name) {
|
|
319
|
-
if (
|
|
320
|
-
ts.isIdentifier(name) ||
|
|
321
|
-
ts.isStringLiteral(name) ||
|
|
322
|
-
ts.isPrivateIdentifier(name)
|
|
323
|
-
) {
|
|
324
|
-
return name.text;
|
|
325
|
-
}
|
|
326
|
-
return null;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function getTemplateCalledMethods(classNode) {
|
|
330
|
-
const methodNames = new Set();
|
|
331
|
-
const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
|
|
332
|
-
|
|
333
|
-
function visit(node) {
|
|
334
|
-
if (
|
|
335
|
-
ts.isPropertyAccessExpression(node) &&
|
|
336
|
-
node.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
337
|
-
ts.isCallExpression(node.parent) &&
|
|
338
|
-
node.parent.expression === node
|
|
339
|
-
) {
|
|
340
|
-
methodNames.add(node.name.text);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
ts.forEachChild(node, visit);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function addTemplateTextMethods(template) {
|
|
347
|
-
// Template expressions can hide method calls inside raw template text,
|
|
348
|
-
// so use a regex in addition to AST traversal to catch those names.
|
|
349
|
-
const text = template.getText();
|
|
350
|
-
for (const match of text.matchAll(CALL_RE)) {
|
|
351
|
-
methodNames.add(match[1]);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
for (const member of classNode.members) {
|
|
356
|
-
if (
|
|
357
|
-
ts.isPropertyDeclaration(member) &&
|
|
358
|
-
hasStaticModifier(member) &&
|
|
359
|
-
getNameText(member.name) === 'html' &&
|
|
360
|
-
member.initializer
|
|
361
|
-
) {
|
|
362
|
-
if (
|
|
363
|
-
ts.isTaggedTemplateExpression(member.initializer) &&
|
|
364
|
-
ts.isIdentifier(member.initializer.tag) &&
|
|
365
|
-
member.initializer.tag.text === 'html'
|
|
366
|
-
) {
|
|
367
|
-
addTemplateTextMethods(member.initializer.template);
|
|
368
|
-
}
|
|
369
|
-
ts.forEachChild(member.initializer, visit);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
return methodNames;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function getWrecImportInfo(sourceFile) {
|
|
377
|
-
const names = new Set(['Wrec']);
|
|
378
|
-
let quote = "'";
|
|
379
|
-
|
|
380
|
-
// Support aliased imports such as `import {Wrec as Base} from 'wrec'`
|
|
381
|
-
// so subclass detection still works and generated text matches the
|
|
382
|
-
// file's existing quote style.
|
|
383
|
-
for (const statement of sourceFile.statements) {
|
|
384
|
-
if (
|
|
385
|
-
!ts.isImportDeclaration(statement) ||
|
|
386
|
-
!statement.importClause ||
|
|
387
|
-
!ts.isStringLiteral(statement.moduleSpecifier)
|
|
388
|
-
) {
|
|
389
|
-
continue;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const moduleName = statement.moduleSpecifier.text;
|
|
393
|
-
const isWrecModule =
|
|
394
|
-
moduleName === 'wrec' ||
|
|
395
|
-
moduleName === 'wrec/ssr' ||
|
|
396
|
-
moduleName.endsWith('/wrec') ||
|
|
397
|
-
moduleName.endsWith('/wrec-ssr');
|
|
398
|
-
if (!isWrecModule) continue;
|
|
399
|
-
|
|
400
|
-
const namedBindings = statement.importClause.namedBindings;
|
|
401
|
-
if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
|
|
402
|
-
|
|
403
|
-
for (const element of namedBindings.elements) {
|
|
404
|
-
const importedName = element.propertyName?.text ?? element.name.text;
|
|
405
|
-
if (importedName === 'Wrec') {
|
|
406
|
-
names.add(element.name.text);
|
|
407
|
-
|
|
408
|
-
const moduleText = statement.moduleSpecifier.getText(sourceFile);
|
|
409
|
-
if (moduleText.startsWith('"') || moduleText.startsWith("'")) {
|
|
410
|
-
quote = moduleText[0];
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return {names, quote};
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function hasStaticModifier(node) {
|
|
420
|
-
return Boolean(
|
|
421
|
-
node.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)
|
|
422
|
-
);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function isSupportedSourceFile(filePath, excludeTests = false) {
|
|
426
|
-
return (
|
|
427
|
-
/\.(js|ts)$/.test(filePath) &&
|
|
428
|
-
!/\.d\.ts$/.test(filePath) &&
|
|
429
|
-
(!excludeTests || !filePath.includes('.test.'))
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function transformSourceFile(sourceFile) {
|
|
60
|
+
// Analyzes a parsed source file and returns any proposed `usedBy` edits.
|
|
61
|
+
// This is the heart of the functionality in this script.
|
|
62
|
+
function analyzeSourceFile(sourceFile) {
|
|
434
63
|
const edits = [];
|
|
435
64
|
const {names: wrecNames, quote} = getWrecImportInfo(sourceFile);
|
|
436
65
|
const suggestions = [];
|
|
437
66
|
let foundWrecSubclass = false;
|
|
438
67
|
|
|
439
|
-
//
|
|
440
|
-
// property config objects that need `usedBy` added, updated, or removed.
|
|
68
|
+
// Find the statement that defines a class that extends Wrec.
|
|
441
69
|
for (const node of sourceFile.statements) {
|
|
442
70
|
if (!ts.isClassDeclaration(node) || !extendsWrec(node, wrecNames)) continue;
|
|
443
71
|
foundWrecSubclass = true;
|
|
444
72
|
|
|
445
73
|
let propertiesObject = null;
|
|
74
|
+
// Find the static property named "properties".
|
|
446
75
|
for (const member of node.members) {
|
|
447
76
|
if (
|
|
448
77
|
ts.isPropertyDeclaration(member) &&
|
|
@@ -456,26 +85,34 @@ function transformSourceFile(sourceFile) {
|
|
|
456
85
|
}
|
|
457
86
|
}
|
|
458
87
|
|
|
88
|
+
// Bail out if no static property named "properties" was found.
|
|
459
89
|
if (!propertiesObject) continue;
|
|
460
90
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
.filter(ts.isPropertyAssignment)
|
|
464
|
-
.map(property => getNameText(property.name))
|
|
465
|
-
.filter(Boolean)
|
|
466
|
-
);
|
|
91
|
+
// Get a Set of the defined property names.
|
|
92
|
+
const propertyNames = new Set(getPropertyAssignmentNames(propertiesObject));
|
|
467
93
|
|
|
94
|
+
// Get a map where the keys are property names and
|
|
95
|
+
// the values are Sets of public methods that use it transitively.
|
|
468
96
|
const propToMethods = getMethodUsages(node, propertyNames);
|
|
97
|
+
|
|
98
|
+
// For each member that represents a component property ...
|
|
469
99
|
for (const member of propertiesObject.properties) {
|
|
100
|
+
// Skip the member if not a property assignment.
|
|
470
101
|
if (!ts.isPropertyAssignment(member)) continue;
|
|
471
102
|
|
|
103
|
+
// Skip the member if we can't gets its name
|
|
104
|
+
// or if its value isn't an object literal.
|
|
472
105
|
const propName = getNameText(member.name);
|
|
473
106
|
if (!propName || !ts.isObjectLiteralExpression(member.initializer))
|
|
474
107
|
continue;
|
|
475
108
|
|
|
109
|
+
// Convert the Set of methods that use the property into a sorted array.
|
|
476
110
|
const methodNames = [
|
|
477
111
|
...(propToMethods.get(propName) ?? new Set())
|
|
478
112
|
].sort();
|
|
113
|
+
|
|
114
|
+
// Get an array of all the NodeObjects that represent
|
|
115
|
+
// properties in the configuration object, except the one for "usedBy".
|
|
479
116
|
const configObject = member.initializer;
|
|
480
117
|
const existingMembers = configObject.properties.filter(
|
|
481
118
|
property =>
|
|
@@ -484,25 +121,33 @@ function transformSourceFile(sourceFile) {
|
|
|
484
121
|
getNameText(property.name) === 'usedBy'
|
|
485
122
|
)
|
|
486
123
|
);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if (methodNames.length > 0
|
|
124
|
+
|
|
125
|
+
// If the property is used by any methods ...
|
|
126
|
+
if (methodNames.length > 0) {
|
|
490
127
|
suggestions.push({
|
|
491
128
|
propName,
|
|
492
|
-
suggestion:
|
|
493
|
-
methodNames.length > 0
|
|
494
|
-
? createUsedByProperty(methodNames, quote)
|
|
495
|
-
: 'remove usedBy'
|
|
129
|
+
suggestion: createUsedByProperty(methodNames, quote)
|
|
496
130
|
});
|
|
131
|
+
} else {
|
|
132
|
+
// Determine if the configuration object already had a "usedBy" property.
|
|
133
|
+
const hadUsedBy =
|
|
134
|
+
existingMembers.length !== configObject.properties.length;
|
|
135
|
+
if (hadUsedBy) {
|
|
136
|
+
suggestions.push({propName, suggestion: 'remove usedBy'});
|
|
137
|
+
} else {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
497
140
|
}
|
|
498
|
-
const needsUsedBy = methodNames.length > 0;
|
|
499
|
-
if (!hadUsedBy && !needsUsedBy) continue;
|
|
500
141
|
|
|
142
|
+
// Build the new text for the property configuration object.
|
|
501
143
|
const nextText = buildConfigText(sourceFile, member, methodNames, quote);
|
|
144
|
+
// Get the existing text for the property configuration object.
|
|
502
145
|
const currentText = sourceFile.text.slice(
|
|
503
146
|
configObject.getStart(sourceFile),
|
|
504
147
|
configObject.end
|
|
505
148
|
);
|
|
149
|
+
// If they differ, add an object describing
|
|
150
|
+
// the desired edit to the edits array.
|
|
506
151
|
if (nextText !== currentText) {
|
|
507
152
|
edits.push({
|
|
508
153
|
start: configObject.getStart(sourceFile),
|
|
@@ -515,6 +160,7 @@ function transformSourceFile(sourceFile) {
|
|
|
515
160
|
}
|
|
516
161
|
}
|
|
517
162
|
|
|
163
|
+
// If we didn't find a Wrec subclass, then there are no changes to make.
|
|
518
164
|
if (!foundWrecSubclass) {
|
|
519
165
|
return {
|
|
520
166
|
changed: false,
|
|
@@ -525,6 +171,8 @@ function transformSourceFile(sourceFile) {
|
|
|
525
171
|
};
|
|
526
172
|
}
|
|
527
173
|
|
|
174
|
+
// If we found a Wrec subclass,
|
|
175
|
+
// but no edits for usedBy properties are needed ...
|
|
528
176
|
if (edits.length === 0) {
|
|
529
177
|
return {
|
|
530
178
|
changed: false,
|
|
@@ -535,8 +183,13 @@ function transformSourceFile(sourceFile) {
|
|
|
535
183
|
};
|
|
536
184
|
}
|
|
537
185
|
|
|
186
|
+
// Get the current text in the source file.
|
|
538
187
|
let nextSource = sourceFile.text;
|
|
188
|
+
|
|
189
|
+
// Sort the edits to be made from last to first.
|
|
539
190
|
edits.sort((a, b) => b.start - a.start);
|
|
191
|
+
|
|
192
|
+
// Make each of the edits to the source file.
|
|
540
193
|
for (const edit of edits) {
|
|
541
194
|
nextSource =
|
|
542
195
|
nextSource.slice(0, edit.start) + edit.text + nextSource.slice(edit.end);
|
|
@@ -551,22 +204,169 @@ function transformSourceFile(sourceFile) {
|
|
|
551
204
|
};
|
|
552
205
|
}
|
|
553
206
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
207
|
+
// Builds a new property config object
|
|
208
|
+
// with an updated `usedBy` entry if needed.
|
|
209
|
+
// - member is an AST node for the initialization of a property
|
|
210
|
+
// inside the "static properties" object.
|
|
211
|
+
// - methodsNames is an array of the methods that use the property.
|
|
212
|
+
// - quote is either a single or double quote.
|
|
213
|
+
function buildConfigText(sourceFile, member, methodNames, quote) {
|
|
214
|
+
const configObject = member.initializer;
|
|
215
|
+
|
|
216
|
+
// Get an array of AST nodes for all the config object properties
|
|
217
|
+
// except `usedBy`.
|
|
218
|
+
const existingMembers = configObject.properties.filter(
|
|
219
|
+
property =>
|
|
220
|
+
!(
|
|
221
|
+
ts.isPropertyAssignment(property) &&
|
|
222
|
+
getNameText(property.name) === 'usedBy'
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Create property assignment strings from the AST nodes.
|
|
227
|
+
const {text} = sourceFile;
|
|
228
|
+
const propertyStrings = existingMembers.map(property =>
|
|
229
|
+
text.slice(property.getStart(sourceFile), property.end).trim()
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// If this property is used by any methods,
|
|
233
|
+
// add a usedBy property that lists them.
|
|
234
|
+
if (methodNames.length > 0) {
|
|
235
|
+
propertyStrings.push(createUsedByProperty(methodNames, quote));
|
|
557
236
|
}
|
|
558
237
|
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
238
|
+
const existingPropertiesString = text.slice(
|
|
239
|
+
configObject.getStart(sourceFile),
|
|
240
|
+
configObject.end
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const multiline = existingPropertiesString.includes('\n');
|
|
244
|
+
if (multiline) {
|
|
245
|
+
// Build and return a multi-line config object string
|
|
246
|
+
// that preserves the existing indentation.
|
|
247
|
+
const memberIndent = getIndent(text, member.getStart(sourceFile));
|
|
248
|
+
const [firstMember] = existingMembers;
|
|
249
|
+
const propertyIndent = firstMember
|
|
250
|
+
? getIndent(text, firstMember.getStart(sourceFile))
|
|
251
|
+
: memberIndent + ' ';
|
|
252
|
+
const content = propertyStrings
|
|
253
|
+
.map(part => `${propertyIndent}${part}`)
|
|
254
|
+
.join(',\n');
|
|
255
|
+
return `{\n${content}\n${memberIndent}}`;
|
|
562
256
|
}
|
|
563
257
|
|
|
564
|
-
|
|
565
|
-
|
|
258
|
+
// Build and return a single line config object string
|
|
259
|
+
// that preserves the existing spacing around curly braces.
|
|
260
|
+
const openMatch = existingPropertiesString.match(/^\{(\s*)/);
|
|
261
|
+
const closeMatch = existingPropertiesString.match(/(\s*)\}$/);
|
|
262
|
+
const openSpacing = openMatch ? openMatch[1] : ' ';
|
|
263
|
+
const closeSpacing = closeMatch ? closeMatch[1] : ' ';
|
|
264
|
+
return `{${openSpacing}${propertyStrings.join(', ')}${closeSpacing}}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Walks a method body to collect component property reads
|
|
268
|
+
// and method calls made through `this`.
|
|
269
|
+
// This is only called by getMethodUsages.
|
|
270
|
+
function collectMethodBodyUsage(node, props, calledMethods) {
|
|
271
|
+
if (
|
|
272
|
+
ts.isPropertyAccessExpression(node) &&
|
|
273
|
+
node.expression.kind === ts.SyntaxKind.ThisKeyword
|
|
274
|
+
) {
|
|
275
|
+
// Handles direct property access like `this.foo`
|
|
276
|
+
// and records method calls like `this.foo()`.
|
|
277
|
+
recordThisAccess(props, calledMethods, node, node.name.text);
|
|
278
|
+
} else if (
|
|
279
|
+
ts.isElementAccessExpression(node) &&
|
|
280
|
+
node.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
281
|
+
node.argumentExpression &&
|
|
282
|
+
ts.isStringLiteralLike(node.argumentExpression)
|
|
283
|
+
) {
|
|
284
|
+
// Handles string-based element access like `this['foo']`
|
|
285
|
+
// and records method calls like `this['foo']()`.
|
|
286
|
+
recordThisAccess(props, calledMethods, node, node.argumentExpression.text);
|
|
287
|
+
} else if (
|
|
288
|
+
ts.isVariableDeclaration(node) &&
|
|
289
|
+
node.initializer &&
|
|
290
|
+
node.initializer.kind === ts.SyntaxKind.ThisKeyword
|
|
291
|
+
) {
|
|
292
|
+
// Handles variable declarations initialized from `this`,
|
|
293
|
+
// such as `const {foo} = this` or `const self = this`.
|
|
294
|
+
addBindingName(props, node.name);
|
|
295
|
+
} else if (
|
|
296
|
+
ts.isBinaryExpression(node) &&
|
|
297
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
298
|
+
node.right.kind === ts.SyntaxKind.ThisKeyword
|
|
299
|
+
) {
|
|
300
|
+
// Handles assignments from `this`, such as destructuring
|
|
301
|
+
// or object-literal patterns that pull named properties from it.
|
|
302
|
+
if (ts.isObjectLiteralExpression(node.left)) {
|
|
303
|
+
for (const property of node.left.properties) {
|
|
304
|
+
if (
|
|
305
|
+
ts.isShorthandPropertyAssignment(property) ||
|
|
306
|
+
ts.isPropertyAssignment(property)
|
|
307
|
+
) {
|
|
308
|
+
const name = getNameText(property.name);
|
|
309
|
+
if (name) props.add(name);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} else if (ts.isObjectBindingPattern(node.left)) {
|
|
313
|
+
addObjectBindingProps(props, node.left);
|
|
314
|
+
}
|
|
566
315
|
}
|
|
316
|
+
|
|
317
|
+
ts.forEachChild(node, child =>
|
|
318
|
+
collectMethodBodyUsage(child, props, calledMethods)
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Returns a string for a usedBy property.
|
|
323
|
+
function createUsedByProperty(methodNames, quote) {
|
|
324
|
+
const value =
|
|
325
|
+
methodNames.length === 1
|
|
326
|
+
? `${quote}${methodNames[0]}${quote}`
|
|
327
|
+
: `[${methodNames.map(name => `${quote}${name}${quote}`).join(', ')}]`;
|
|
328
|
+
return `usedBy: ${value}`;
|
|
567
329
|
}
|
|
568
330
|
|
|
569
|
-
|
|
331
|
+
// Determines what changes, if any, should be made in
|
|
332
|
+
// the usedBy properties in property configuration objects
|
|
333
|
+
// found in the file at a given relative path.
|
|
334
|
+
export function evaluateSourceFile(filePath, options = {}) {
|
|
335
|
+
const {dry = false} = options;
|
|
336
|
+
const absFilePath = path.resolve(cwd, filePath);
|
|
337
|
+
validateFile(absFilePath);
|
|
338
|
+
|
|
339
|
+
// The file is read in this function instead of in evaluateSourceText
|
|
340
|
+
// so unit tests can pass hard-coded text to that function.
|
|
341
|
+
const text = fs.readFileSync(absFilePath, 'utf8');
|
|
342
|
+
let {
|
|
343
|
+
changed,
|
|
344
|
+
foundWrecSubclass,
|
|
345
|
+
suggestions,
|
|
346
|
+
text: nextText
|
|
347
|
+
} = evaluateSourceText(absFilePath, text);
|
|
348
|
+
|
|
349
|
+
// If we didn't find the definition of a class that extends Wrec ...
|
|
350
|
+
if (!foundWrecSubclass) {
|
|
351
|
+
throw new Error('No class extending Wrec was found.');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// If this isn't a dry run and changes were made,
|
|
355
|
+
// write the modified source code back to the file.
|
|
356
|
+
if (!dry && changed) {
|
|
357
|
+
fs.writeFileSync(absFilePath, nextText);
|
|
358
|
+
suggestions = []; // all the suggestions have been applied
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {changed, foundWrecSubclass, suggestions, text: nextText};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Determines what changes, if any, should be made in
|
|
365
|
+
// the usedBy properties in property configuration objects
|
|
366
|
+
// found in source file text.
|
|
367
|
+
// This function was factored out of evaluateSourceFile
|
|
368
|
+
// to support unit tests.
|
|
369
|
+
export function evaluateSourceText(filePath, text) {
|
|
570
370
|
const scriptKind = filePath.endsWith('.ts')
|
|
571
371
|
? ts.ScriptKind.TS
|
|
572
372
|
: ts.ScriptKind.JS;
|
|
@@ -577,88 +377,319 @@ export function updateUsedBySource(filePath, text) {
|
|
|
577
377
|
true,
|
|
578
378
|
scriptKind
|
|
579
379
|
);
|
|
380
|
+
return analyzeSourceFile(sourceFile);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Gets the names of all methods that are called from
|
|
384
|
+
// JavaScript expressions in the component's static CSS template.
|
|
385
|
+
// This is only called by getMethodUsages.
|
|
386
|
+
function getCssCalledMethods(classNode) {
|
|
387
|
+
const methodNames = new Set();
|
|
388
|
+
const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
|
|
389
|
+
let template;
|
|
390
|
+
|
|
391
|
+
for (const member of classNode.members) {
|
|
392
|
+
// If this member isn't the AST node for "static css = ..."
|
|
393
|
+
// then skip it.
|
|
394
|
+
if (
|
|
395
|
+
ts.isPropertyDeclaration(member) &&
|
|
396
|
+
hasStaticModifier(member) &&
|
|
397
|
+
getNameText(member.name) === 'css' &&
|
|
398
|
+
member.initializer &&
|
|
399
|
+
ts.isTaggedTemplateExpression(member.initializer) &&
|
|
400
|
+
ts.isIdentifier(member.initializer.tag) &&
|
|
401
|
+
member.initializer.tag.text === 'css'
|
|
402
|
+
) {
|
|
403
|
+
// Keep the last matching declaration because
|
|
404
|
+
// JavaScript class fields use last-one-wins semantics
|
|
405
|
+
// when the same static field is declared twice.
|
|
406
|
+
template = member.initializer.template;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// If no matching declaration was found, return an empty Set.
|
|
411
|
+
if (!template) return methodNames;
|
|
580
412
|
|
|
581
|
-
|
|
413
|
+
// Finds all method names called in CSS property value expressions
|
|
414
|
+
// matching `this.method()`.
|
|
415
|
+
const text = template.getText();
|
|
416
|
+
for (const match of text.matchAll(CALL_RE)) {
|
|
417
|
+
methodNames.add(match[1]);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return methodNames;
|
|
582
421
|
}
|
|
583
422
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
423
|
+
// Gets the names of all methods that are called from
|
|
424
|
+
// the "computed" JavaScript expression of an component property.
|
|
425
|
+
// This is only called by getMethodUsages.
|
|
426
|
+
function getComputedCalledMethods(classNode) {
|
|
427
|
+
const methodNames = new Set();
|
|
428
|
+
const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
|
|
429
|
+
let propertiesNode;
|
|
589
430
|
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
431
|
+
for (const member of classNode.members) {
|
|
432
|
+
// If this member isn't the AST node for "static properties = ..."
|
|
433
|
+
// then skip it.
|
|
434
|
+
if (
|
|
435
|
+
ts.isPropertyDeclaration(member) &&
|
|
436
|
+
hasStaticModifier(member) &&
|
|
437
|
+
getNameText(member.name) === 'properties' &&
|
|
438
|
+
member.initializer &&
|
|
439
|
+
ts.isObjectLiteralExpression(member.initializer)
|
|
440
|
+
) {
|
|
441
|
+
// Keep the last matching declaration because
|
|
442
|
+
// JavaScript class fields use last-one-wins semantics
|
|
443
|
+
// when the same static field is declared twice.
|
|
444
|
+
propertiesNode = member.initializer;
|
|
445
|
+
}
|
|
599
446
|
}
|
|
600
|
-
|
|
601
|
-
|
|
447
|
+
|
|
448
|
+
// If no matching declaration was found, return an empty Set.
|
|
449
|
+
if (!propertiesNode) return methodNames;
|
|
450
|
+
|
|
451
|
+
// For each property in the last "static properties" object ...
|
|
452
|
+
for (const property of propertiesNode.properties) {
|
|
453
|
+
// If it isn't a property assignment or
|
|
454
|
+
// the property value isn't an object literal then skip it.
|
|
455
|
+
if (
|
|
456
|
+
!ts.isPropertyAssignment(property) ||
|
|
457
|
+
!ts.isObjectLiteralExpression(property.initializer)
|
|
458
|
+
) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// For each property in the configuration object ...
|
|
463
|
+
for (const configProperty of property.initializer.properties) {
|
|
464
|
+
// If it isn't a property assignment or
|
|
465
|
+
// the property name isn't "computed" then skip it.
|
|
466
|
+
if (
|
|
467
|
+
!ts.isPropertyAssignment(configProperty) ||
|
|
468
|
+
getNameText(configProperty.name) !== 'computed'
|
|
469
|
+
) {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// If the property value isn't a string then skip it.
|
|
474
|
+
if (!ts.isStringLiteralLike(configProperty.initializer)) continue;
|
|
475
|
+
|
|
476
|
+
// Find all the method calls in the string JavaScript expression.
|
|
477
|
+
const computed = configProperty.initializer.text;
|
|
478
|
+
for (const match of computed.matchAll(CALL_RE)) {
|
|
479
|
+
methodNames.add(match[1]);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return methodNames;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Returns the leading indentation in the line
|
|
488
|
+
// that begins at a given position (`pos`) inside `text`.
|
|
489
|
+
function getIndent(text, pos) {
|
|
490
|
+
const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
|
|
491
|
+
const match = /^[ \t]*/.exec(text.slice(lineStart));
|
|
492
|
+
return match ? match[0] : '';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Returns a map where the keys are property names and
|
|
496
|
+
// the values are Sets of public methods that use it transitively.
|
|
497
|
+
function getMethodUsages(classNode, propertyNames) {
|
|
498
|
+
const methodInfo = new Map();
|
|
499
|
+
|
|
500
|
+
for (const member of classNode.members) {
|
|
501
|
+
// If the member doesn't represent an instance method, skip it.
|
|
502
|
+
if (!isInstanceMethodMember(member)) continue;
|
|
503
|
+
|
|
504
|
+
// If the member doesn't have a string name, skip it.
|
|
505
|
+
const methodName = getNameText(member.name);
|
|
506
|
+
if (!methodName) continue;
|
|
507
|
+
|
|
508
|
+
const props = new Set();
|
|
509
|
+
const calledMethods = new Set();
|
|
510
|
+
collectMethodBodyUsage(member.body, props, calledMethods);
|
|
511
|
+
methodInfo.set(methodName, {
|
|
512
|
+
calledMethods,
|
|
513
|
+
isPrivate: ts.isPrivateIdentifier(member.name),
|
|
514
|
+
props
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Build a Set of method names that are called from
|
|
519
|
+
// the "static html" template, the "static css" template,
|
|
520
|
+
// or computed property expressions.
|
|
521
|
+
const entryMethods = new Set([
|
|
522
|
+
...getCssCalledMethods(classNode),
|
|
523
|
+
...getTemplateCalledMethods(classNode),
|
|
524
|
+
...getComputedCalledMethods(classNode)
|
|
525
|
+
]);
|
|
526
|
+
const memo = new Map();
|
|
527
|
+
|
|
528
|
+
const propToMethods = new Map();
|
|
529
|
+
for (const methodName of entryMethods) {
|
|
530
|
+
const info = methodInfo.get(methodName);
|
|
531
|
+
if (!info || info.isPrivate) continue;
|
|
532
|
+
|
|
533
|
+
for (const propName of getTransitiveProps(methodInfo, memo, methodName)) {
|
|
534
|
+
if (!propertyNames.has(propName)) continue;
|
|
535
|
+
|
|
536
|
+
let methods = propToMethods.get(propName);
|
|
537
|
+
if (!methods) {
|
|
538
|
+
methods = new Set();
|
|
539
|
+
propToMethods.set(propName, methods);
|
|
540
|
+
}
|
|
541
|
+
methods.add(methodName);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return propToMethods;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Gets the names of all methods that are called
|
|
549
|
+
// from the component's static HTML template.
|
|
550
|
+
// This is only called by getMethodUsages.
|
|
551
|
+
function getTemplateCalledMethods(classNode) {
|
|
552
|
+
// Find the last `static html = html\`...\`` declaration
|
|
553
|
+
// because duplicate static class fields use last-one-wins semantics.
|
|
554
|
+
let template;
|
|
555
|
+
for (const member of classNode.members) {
|
|
556
|
+
// If it's a "static html =" property assignment node ...
|
|
557
|
+
if (
|
|
558
|
+
ts.isPropertyDeclaration(member) &&
|
|
559
|
+
hasStaticModifier(member) &&
|
|
560
|
+
getNameText(member.name) === 'html' &&
|
|
561
|
+
member.initializer
|
|
562
|
+
) {
|
|
563
|
+
// If the value is a tagged template literal with the "html" tag ...
|
|
564
|
+
if (
|
|
565
|
+
ts.isTaggedTemplateExpression(member.initializer) &&
|
|
566
|
+
ts.isIdentifier(member.initializer.tag) &&
|
|
567
|
+
member.initializer.tag.text === 'html'
|
|
568
|
+
) {
|
|
569
|
+
template = member.initializer.template;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
602
572
|
}
|
|
603
573
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
574
|
+
const methodNames = new Set();
|
|
575
|
+
if (template) {
|
|
576
|
+
// Finds all method names called in the HTML template
|
|
577
|
+
// matching `this.method()`.
|
|
578
|
+
const text = template.getText();
|
|
579
|
+
const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
|
|
580
|
+
for (const match of text.matchAll(CALL_RE)) {
|
|
581
|
+
methodNames.add(match[1]);
|
|
582
|
+
}
|
|
607
583
|
}
|
|
608
|
-
return
|
|
584
|
+
return methodNames;
|
|
609
585
|
}
|
|
610
586
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
587
|
+
// Recursively follow method-call chains to accumulate all accessed properties.
|
|
588
|
+
// This is only called by getMethodUsages.
|
|
589
|
+
function getTransitiveProps(methodInfo, memo, methodName, seen = new Set()) {
|
|
590
|
+
// Starting from methods that are reachable from
|
|
591
|
+
// css/template/computed properties, walk through nested method calls
|
|
592
|
+
// and accumulate every component property touched along the way.
|
|
593
|
+
if (memo.has(methodName)) return memo.get(methodName);
|
|
594
|
+
if (seen.has(methodName)) return new Set();
|
|
595
|
+
|
|
596
|
+
seen.add(methodName);
|
|
597
|
+
const info = methodInfo.get(methodName);
|
|
598
|
+
const props = new Set(info?.props ?? []);
|
|
599
|
+
|
|
600
|
+
if (info) {
|
|
601
|
+
for (const calledMethod of info.calledMethods) {
|
|
602
|
+
const calledProps = getTransitiveProps(
|
|
603
|
+
methodInfo,
|
|
604
|
+
memo,
|
|
605
|
+
calledMethod,
|
|
606
|
+
seen
|
|
607
|
+
);
|
|
608
|
+
for (const propName of calledProps) props.add(propName);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
seen.delete(methodName);
|
|
613
|
+
memo.set(methodName, props);
|
|
614
|
+
return props;
|
|
614
615
|
}
|
|
615
616
|
|
|
617
|
+
// Determines if a class member represents an instance method.
|
|
618
|
+
// This is only called by getMethodUsages.
|
|
619
|
+
function isInstanceMethodMember(member) {
|
|
620
|
+
return (
|
|
621
|
+
!hasStaticModifier(member) &&
|
|
622
|
+
(ts.isMethodDeclaration(member) ||
|
|
623
|
+
ts.isGetAccessorDeclaration(member) ||
|
|
624
|
+
ts.isSetAccessorDeclaration(member)) &&
|
|
625
|
+
member.body
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Determines if a path refers to a JavaScript or TypeScript source file.
|
|
630
|
+
function isSupportedSourceFile(filePath, excludeTests = false) {
|
|
631
|
+
return (
|
|
632
|
+
/\.(js|ts)$/.test(filePath) &&
|
|
633
|
+
!/\.d\.ts$/.test(filePath) &&
|
|
634
|
+
(!excludeTests || !filePath.includes('.test.'))
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Handles CLI arguments and runs the script.
|
|
616
639
|
function main() {
|
|
617
640
|
const args = process.argv.slice(2);
|
|
618
|
-
const dry = args.includes('--dry');
|
|
619
|
-
const quiet = args.includes('--quiet');
|
|
620
641
|
const inputPaths = args.filter(arg => !arg.startsWith('--'));
|
|
621
642
|
|
|
622
|
-
if (args.includes('--check')) {
|
|
623
|
-
throw new Error('Use --dry instead of --check.');
|
|
624
|
-
}
|
|
625
|
-
|
|
626
643
|
if (inputPaths.length !== 1) {
|
|
627
|
-
throw new Error(
|
|
628
|
-
'Specify a single source file, e.g. npx wrec-usedby src/examples/radio-group.js'
|
|
629
|
-
);
|
|
644
|
+
throw new Error('Specify a single source file');
|
|
630
645
|
}
|
|
631
646
|
|
|
632
|
-
const
|
|
647
|
+
const dry = args.includes('--dry');
|
|
648
|
+
const result = evaluateSourceFile(inputPaths[0], {dry});
|
|
633
649
|
if (dry) {
|
|
634
|
-
//
|
|
635
|
-
// least one update would be needed so the script can be used in checks.
|
|
650
|
+
// Report the proposed changes.
|
|
636
651
|
for (const {propName, suggestion} of result.suggestions) {
|
|
637
|
-
console.
|
|
652
|
+
console.info(`${propName} - ${suggestion}`);
|
|
638
653
|
}
|
|
639
|
-
|
|
640
|
-
|
|
654
|
+
|
|
655
|
+
// Exit with a non-zero when there is at least one change
|
|
656
|
+
// so that condition can be checked.
|
|
657
|
+
if (result.changed) process.exit(1);
|
|
658
|
+
} else if (result.changed) {
|
|
659
|
+
console.info('updated source file');
|
|
660
|
+
} else {
|
|
661
|
+
console.info('no changes needed');
|
|
641
662
|
}
|
|
663
|
+
}
|
|
642
664
|
|
|
643
|
-
|
|
665
|
+
// Records a `this` property access and
|
|
666
|
+
// tracks it as a method call when applicable.
|
|
667
|
+
function recordThisAccess(props, calledMethods, node, name) {
|
|
668
|
+
props.add(name);
|
|
669
|
+
if (ts.isCallExpression(node.parent) && node.parent.expression === node) {
|
|
670
|
+
calledMethods.add(name);
|
|
671
|
+
}
|
|
644
672
|
}
|
|
645
673
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
674
|
+
// Validates that a source file exists and has a supported extension.
|
|
675
|
+
function validateFile(absFilePath) {
|
|
676
|
+
if (!fs.existsSync(absFilePath)) throw new Error('File not found');
|
|
677
|
+
|
|
678
|
+
const stat = fs.statSync(absFilePath);
|
|
679
|
+
if (!stat.isFile()) throw new Error('Not a file');
|
|
680
|
+
|
|
681
|
+
if (!/\.(js|ts)$/.test(absFilePath) || /\.d\.ts$/.test(absFilePath)) {
|
|
682
|
+
throw new Error('Unsupported file type');
|
|
655
683
|
}
|
|
656
|
-
}
|
|
684
|
+
}
|
|
657
685
|
|
|
658
|
-
|
|
686
|
+
// If this is being run as a script,
|
|
687
|
+
// versus being imported (likely by test code) ...
|
|
688
|
+
if (import.meta.main) {
|
|
659
689
|
try {
|
|
660
690
|
main();
|
|
661
691
|
} catch (error) {
|
|
662
|
-
|
|
692
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
693
|
+
process.exit(1);
|
|
663
694
|
}
|
|
664
695
|
}
|