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