wrec 0.29.2 → 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.
@@ -1,143 +1,388 @@
1
1
  #!/usr/bin/env node
2
+ // This script inspects a given Wrec component source file and
3
+ // determines the proper values for property config `usedBy` properties.
4
+ // Each value is a list of methods that use the property
5
+ // or a single method name.
6
+ //
7
+ // This uses the TypeScript compiler to parse the source file,
8
+ // discover which expressions call which methods,
9
+ // trace property usage through those call chains, and
10
+ // output or update the `usedBy` properties`.
11
+ //
12
+ // To run this, enter `npx wrec-usedby [--dry] [file-path]`
13
+ // If no file-path is specified, the script runs on
14
+ // all .js and .ts files in and below the current directory.
15
+ //
16
+ // Include the --dry flag for a dry run where `usedBy` values are output,
17
+ // but no files are modified.
2
18
 
3
19
  import fs from 'node:fs';
4
20
  import path from 'node:path';
5
21
  import ts from 'typescript';
6
22
 
7
- const args = process.argv.slice(2);
8
- const dry = args.includes('--dry');
9
- const verbose = args.includes('--verbose');
10
- const inputPaths = args.filter(arg => !arg.startsWith('--'));
23
+ const cwd = process.cwd();
11
24
 
12
- if (args.includes('--write')) {
13
- console.error('--write is no longer supported; writing is now the default.');
14
- process.exit(1);
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
+ }
15
36
  }
16
37
 
17
- if (args.includes('--check')) {
18
- console.error('Use --dry instead of --check.');
19
- process.exit(1);
20
- }
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;
21
42
 
22
- if (inputPaths.length !== 1) {
23
- console.error(
24
- 'Specify a single source file, e.g. npx wrec-usedby src/examples/radio-group.js'
25
- );
26
- process.exit(1);
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
+ }
27
51
  }
28
52
 
29
- const cwd = process.cwd();
30
- const targets = inputPaths.map(file => path.resolve(cwd, file));
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;
31
60
 
32
- for (const target of targets) {
33
- if (!fs.existsSync(target)) {
34
- console.error(`File not found: ${path.relative(cwd, target)}`);
35
- process.exit(1);
36
- }
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;
37
65
 
38
- const stat = fs.statSync(target);
39
- if (!stat.isFile()) {
40
- console.error(`Not a file: ${path.relative(cwd, target)}`);
41
- process.exit(1);
42
- }
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
+ }
43
80
 
44
- if (!/\.(js|ts)$/.test(target) || /\.d\.ts$/.test(target)) {
45
- console.error(`Unsupported file type: ${path.relative(cwd, target)}`);
46
- process.exit(1);
47
- }
48
- }
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();
49
111
 
50
- function collectFiles(startPath, files = []) {
51
- if (!fs.existsSync(startPath)) return files;
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
+ );
52
122
 
53
- const stat = fs.statSync(startPath);
54
- if (stat.isFile()) {
55
- if (/\.(js|ts)$/.test(startPath) && !/\.d\.ts$/.test(startPath)) {
56
- files.push(startPath);
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
+ }
57
158
  }
58
- return files;
59
159
  }
60
160
 
61
- for (const entry of fs.readdirSync(startPath, {withFileTypes: true})) {
62
- const fullPath = path.join(startPath, entry.name);
63
- if (entry.isDirectory()) {
64
- if (entry.name === 'node_modules' || entry.name === 'dist') continue;
65
- collectFiles(fullPath, files);
66
- } else if (
67
- entry.isFile() &&
68
- /\.(js|ts)$/.test(entry.name) &&
69
- !entry.name.endsWith('.d.ts') &&
70
- !entry.name.includes('.test.')
71
- ) {
72
- files.push(fullPath);
73
- }
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
+ };
74
170
  }
75
171
 
76
- return files;
77
- }
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
+ }
78
183
 
79
- const files = targets.flatMap(target => collectFiles(target));
184
+ // Get the current text in the source file.
185
+ let nextSource = sourceFile.text;
80
186
 
81
- function getNameText(name) {
82
- if (
83
- ts.isIdentifier(name) ||
84
- ts.isStringLiteral(name) ||
85
- ts.isPrivateIdentifier(name)
86
- ) {
87
- return name.text;
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);
88
194
  }
89
- return null;
195
+
196
+ return {
197
+ changed: true,
198
+ edits,
199
+ foundWrecSubclass: true,
200
+ suggestions,
201
+ text: nextSource
202
+ };
90
203
  }
91
204
 
92
- function hasStaticModifier(node) {
93
- return Boolean(
94
- node.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)
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.
211
+ function buildConfigText(sourceFile, member, methodNames, quote) {
212
+ const configObject = member.initializer;
213
+
214
+ // Get an array of AST nodes for all the config object properties
215
+ // except `usedBy`.
216
+ const existingMembers = configObject.properties.filter(
217
+ property =>
218
+ !(
219
+ ts.isPropertyAssignment(property) &&
220
+ getNameText(property.name) === 'usedBy'
221
+ )
95
222
  );
96
- }
97
223
 
98
- function getWrecImportInfo(sourceFile) {
99
- const names = new Set(['Wrec']);
100
- let quote = "'";
224
+ // Create property assignment strings from the AST nodes.
225
+ const {text} = sourceFile;
226
+ const propertyStrings = existingMembers.map(property =>
227
+ text.slice(property.getStart(sourceFile), property.end).trim()
228
+ );
101
229
 
102
- for (const statement of sourceFile.statements) {
103
- if (
104
- !ts.isImportDeclaration(statement) ||
105
- !statement.importClause ||
106
- !ts.isStringLiteral(statement.moduleSpecifier)
107
- ) {
108
- continue;
109
- }
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
+ }
110
235
 
111
- const moduleName = statement.moduleSpecifier.text;
112
- const isWrecModule =
113
- moduleName === 'wrec' ||
114
- moduleName === 'wrec/ssr' ||
115
- moduleName.endsWith('/wrec') ||
116
- moduleName.endsWith('/wrec-ssr');
117
- if (!isWrecModule) continue;
236
+ const existingPropertiesString = text.slice(
237
+ configObject.getStart(sourceFile),
238
+ configObject.end
239
+ );
118
240
 
119
- const namedBindings = statement.importClause.namedBindings;
120
- if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
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}}`;
254
+ }
121
255
 
122
- for (const element of namedBindings.elements) {
123
- const importedName = element.propertyName?.text ?? element.name.text;
124
- if (importedName === 'Wrec') {
125
- names.add(element.name.text);
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}}`;
263
+ }
126
264
 
127
- const moduleText = statement.moduleSpecifier.getText(sourceFile);
128
- if (moduleText.startsWith('"') || moduleText.startsWith("'")) {
129
- quote = moduleText[0];
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);
130
308
  }
131
309
  }
310
+ } else if (ts.isObjectBindingPattern(node.left)) {
311
+ addObjectBindingProps(props, node.left);
132
312
  }
133
313
  }
134
314
 
135
- return {names, quote};
315
+ ts.forEachChild(node, child =>
316
+ collectMethodBodyUsage(child, props, calledMethods)
317
+ );
136
318
  }
137
319
 
138
- function extendsWrec(node, wrecNames) {
320
+ // Returns a string for a usedBy property.
321
+ function createUsedByProperty(methodNames, quote) {
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
357
+ }
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);
379
+ }
380
+
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) {
139
384
  return Boolean(
140
- node.heritageClauses?.some(
385
+ classNode.heritageClauses?.some(
141
386
  clause =>
142
387
  clause.token === ts.SyntaxKind.ExtendsKeyword &&
143
388
  clause.types.some(
@@ -150,87 +395,103 @@ function extendsWrec(node, wrecNames) {
150
395
  );
151
396
  }
152
397
 
153
- function getTemplateCalledMethods(classNode) {
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) {
154
402
  const methodNames = new Set();
155
403
  const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
156
-
157
- function visit(node) {
158
- if (
159
- ts.isPropertyAccessExpression(node) &&
160
- node.expression.kind === ts.SyntaxKind.ThisKeyword &&
161
- ts.isCallExpression(node.parent) &&
162
- node.parent.expression === node
163
- ) {
164
- methodNames.add(node.name.text);
165
- }
166
-
167
- ts.forEachChild(node, visit);
168
- }
169
-
170
- function addTemplateTextMethods(template) {
171
- const text = template.getText();
172
- for (const match of text.matchAll(CALL_RE)) {
173
- methodNames.add(match[1]);
174
- }
175
- }
404
+ let template;
176
405
 
177
406
  for (const member of classNode.members) {
407
+ // If this member isn't the AST node for "static css = ..."
408
+ // then skip it.
178
409
  if (
179
410
  ts.isPropertyDeclaration(member) &&
180
411
  hasStaticModifier(member) &&
181
- getNameText(member.name) === 'html' &&
182
- member.initializer
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'
183
417
  ) {
184
- if (
185
- ts.isTaggedTemplateExpression(member.initializer) &&
186
- ts.isIdentifier(member.initializer.tag) &&
187
- member.initializer.tag.text === 'html'
188
- ) {
189
- addTemplateTextMethods(member.initializer.template);
190
- }
191
- ts.forEachChild(member.initializer, visit);
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;
192
422
  }
193
423
  }
194
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
+
195
435
  return methodNames;
196
436
  }
197
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.
198
441
  function getComputedCalledMethods(classNode) {
199
442
  const methodNames = new Set();
200
443
  const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
444
+ let propertiesNode;
201
445
 
202
446
  for (const member of classNode.members) {
447
+ // If this member isn't the AST node for "static properties = ..."
448
+ // then skip it.
203
449
  if (
204
- !ts.isPropertyDeclaration(member) ||
205
- !hasStaticModifier(member) ||
206
- getNameText(member.name) !== 'properties' ||
207
- !member.initializer ||
208
- !ts.isObjectLiteralExpression(member.initializer)
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)
209
473
  ) {
210
474
  continue;
211
475
  }
212
476
 
213
- for (const property of member.initializer.properties) {
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.
214
481
  if (
215
- !ts.isPropertyAssignment(property) ||
216
- !ts.isObjectLiteralExpression(property.initializer)
482
+ !ts.isPropertyAssignment(configProperty) ||
483
+ getNameText(configProperty.name) !== 'computed'
217
484
  ) {
218
485
  continue;
219
486
  }
220
487
 
221
- for (const configProperty of property.initializer.properties) {
222
- if (
223
- !ts.isPropertyAssignment(configProperty) ||
224
- getNameText(configProperty.name) !== 'computed'
225
- ) {
226
- continue;
227
- }
488
+ // If the property value isn't a string then skip it.
489
+ if (!ts.isStringLiteralLike(configProperty.initializer)) continue;
228
490
 
229
- if (!ts.isStringLiteralLike(configProperty.initializer)) continue;
230
- const computed = configProperty.initializer.text;
231
- for (const match of computed.matchAll(CALL_RE)) {
232
- methodNames.add(match[1]);
233
- }
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]);
234
495
  }
235
496
  }
236
497
  }
@@ -238,149 +499,53 @@ function getComputedCalledMethods(classNode) {
238
499
  return methodNames;
239
500
  }
240
501
 
502
+ // Returns the leading indentation in the line
503
+ // that begins at a given position (`pos`) inside `text`.
504
+ function getIndent(text, pos) {
505
+ const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
506
+ const match = /^[ \t]*/.exec(text.slice(lineStart));
507
+ return match ? match[0] : '';
508
+ }
509
+
510
+ // Returns a map where the keys are property names and
511
+ // the values are Sets of public methods that use it transitively.
241
512
  function getMethodUsages(classNode, propertyNames) {
242
513
  const methodInfo = new Map();
243
- for (const member of classNode.members) {
244
- if (hasStaticModifier(member)) continue;
245
-
246
- if (
247
- (ts.isMethodDeclaration(member) ||
248
- ts.isGetAccessorDeclaration(member) ||
249
- ts.isSetAccessorDeclaration(member)) &&
250
- member.body
251
- ) {
252
- const methodName = getNameText(member.name);
253
- if (!methodName) continue;
254
-
255
- const props = new Set();
256
- const calledMethods = new Set();
257
- const isPrivate = ts.isPrivateIdentifier(member.name);
258
-
259
- function addBindingName(name) {
260
- if (ts.isIdentifier(name)) {
261
- props.add(name.text);
262
- } else if (ts.isObjectBindingPattern(name)) {
263
- addObjectBindingProps(name);
264
- }
265
- }
266
-
267
- function addObjectBindingProps(bindingPattern) {
268
- for (const element of bindingPattern.elements) {
269
- if (element.dotDotDotToken) continue;
270
-
271
- if (element.propertyName) {
272
- const name = getNameText(element.propertyName);
273
- if (name) props.add(name);
274
- continue;
275
- }
276
-
277
- if (ts.isIdentifier(element.name)) {
278
- props.add(element.name.text);
279
- } else if (ts.isObjectBindingPattern(element.name)) {
280
- addObjectBindingProps(element.name);
281
- }
282
- }
283
- }
284
-
285
- function visit(child) {
286
- if (
287
- ts.isPropertyAccessExpression(child) &&
288
- child.expression.kind === ts.SyntaxKind.ThisKeyword
289
- ) {
290
- const name = child.name.text;
291
- props.add(name);
292
- if (
293
- ts.isCallExpression(child.parent) &&
294
- child.parent.expression === child
295
- ) {
296
- calledMethods.add(name);
297
- }
298
- } else if (
299
- ts.isElementAccessExpression(child) &&
300
- child.expression.kind === ts.SyntaxKind.ThisKeyword &&
301
- child.argumentExpression &&
302
- ts.isStringLiteralLike(child.argumentExpression)
303
- ) {
304
- const name = child.argumentExpression.text;
305
- props.add(name);
306
- if (
307
- ts.isCallExpression(child.parent) &&
308
- child.parent.expression === child
309
- ) {
310
- calledMethods.add(name);
311
- }
312
- } else if (
313
- ts.isVariableDeclaration(child) &&
314
- child.initializer &&
315
- child.initializer.kind === ts.SyntaxKind.ThisKeyword
316
- ) {
317
- if (ts.isObjectBindingPattern(child.name)) {
318
- addObjectBindingProps(child.name);
319
- } else {
320
- addBindingName(child.name);
321
- }
322
- } else if (
323
- ts.isBinaryExpression(child) &&
324
- child.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
325
- child.right.kind === ts.SyntaxKind.ThisKeyword
326
- ) {
327
- if (ts.isObjectLiteralExpression(child.left)) {
328
- for (const property of child.left.properties) {
329
- if (
330
- ts.isShorthandPropertyAssignment(property) ||
331
- ts.isPropertyAssignment(property)
332
- ) {
333
- const name = getNameText(property.name);
334
- if (name) props.add(name);
335
- }
336
- }
337
- } else if (ts.isObjectBindingPattern(child.left)) {
338
- addObjectBindingProps(child.left);
339
- }
340
- }
341
-
342
- ts.forEachChild(child, visit);
343
- }
344
514
 
345
- ts.forEachChild(member.body, visit);
346
- methodInfo.set(methodName, {calledMethods, isPrivate, props});
347
- }
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
+ });
348
531
  }
349
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.
350
536
  const entryMethods = new Set([
537
+ ...getCssCalledMethods(classNode),
351
538
  ...getTemplateCalledMethods(classNode),
352
539
  ...getComputedCalledMethods(classNode)
353
540
  ]);
354
541
  const memo = new Map();
355
542
 
356
- function getTransitiveProps(methodName, seen = new Set()) {
357
- if (memo.has(methodName)) return memo.get(methodName);
358
- if (seen.has(methodName)) return new Set();
359
-
360
- seen.add(methodName);
361
- const info = methodInfo.get(methodName);
362
- const props = new Set(info?.props ?? []);
363
-
364
- if (info) {
365
- for (const calledMethod of info.calledMethods) {
366
- const calledProps = getTransitiveProps(calledMethod, seen);
367
- for (const propName of calledProps) props.add(propName);
368
- }
369
- }
370
-
371
- seen.delete(methodName);
372
- memo.set(methodName, props);
373
- return props;
374
- }
375
-
376
543
  const propToMethods = new Map();
377
544
  for (const methodName of entryMethods) {
378
545
  const info = methodInfo.get(methodName);
379
546
  if (!info || info.isPrivate) continue;
380
547
 
381
- if (info.isPrivate) continue;
382
-
383
- for (const propName of getTransitiveProps(methodName)) {
548
+ for (const propName of getTransitiveProps(methodInfo, memo, methodName)) {
384
549
  if (!propertyNames.has(propName)) continue;
385
550
 
386
551
  let methods = propToMethods.get(propName);
@@ -395,191 +560,220 @@ function getMethodUsages(classNode, propertyNames) {
395
560
  return propToMethods;
396
561
  }
397
562
 
398
- function createUsedByProperty(methodNames, quote) {
399
- if (methodNames.length === 1) {
400
- return `usedBy: ${quote}${methodNames[0]}${quote}`;
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.
574
+ function getTemplateCalledMethods(classNode) {
575
+ // Find the last `static html = html\`...\`` declaration
576
+ // because duplicate static class fields use last-one-wins semantics.
577
+ let template;
578
+ for (const member of classNode.members) {
579
+ // If it's a "static html =" property assignment node ...
580
+ if (
581
+ ts.isPropertyDeclaration(member) &&
582
+ hasStaticModifier(member) &&
583
+ getNameText(member.name) === 'html' &&
584
+ member.initializer
585
+ ) {
586
+ // If the value is a tagged template literal with the "html" tag ...
587
+ if (
588
+ ts.isTaggedTemplateExpression(member.initializer) &&
589
+ ts.isIdentifier(member.initializer.tag) &&
590
+ member.initializer.tag.text === 'html'
591
+ ) {
592
+ template = member.initializer.template;
593
+ }
594
+ }
401
595
  }
402
- return `usedBy: [${methodNames.map(name => `${quote}${name}${quote}`).join(', ')}]`;
403
- }
404
596
 
405
- function getIndent(text, pos) {
406
- const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
407
- const match = /^[ \t]*/.exec(text.slice(lineStart));
408
- return match ? match[0] : '';
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
+ }
607
+ return methodNames;
409
608
  }
410
609
 
411
- function buildConfigText(sourceFile, member, methodNames, quote) {
412
- const {text} = sourceFile;
413
- const configObject = member.initializer;
414
- const existingMembers = configObject.properties.filter(
415
- property =>
416
- !(
417
- ts.isPropertyAssignment(property) &&
418
- getNameText(property.name) === 'usedBy'
419
- )
420
- );
421
- const existingTexts = existingMembers.map(property =>
422
- text.slice(property.getStart(sourceFile), property.end).trim()
423
- );
424
- if (methodNames.length > 0)
425
- existingTexts.push(createUsedByProperty(methodNames, quote));
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
+ }
426
634
 
427
- const original = text.slice(
428
- configObject.getStart(sourceFile),
429
- configObject.end
430
- );
431
- const multiline = original.includes('\n');
432
- if (!multiline) return `{ ${existingTexts.join(', ')} }`;
433
-
434
- const memberIndent = getIndent(text, member.getStart(sourceFile));
435
- const firstExisting = existingMembers[0];
436
- const innerIndent = firstExisting
437
- ? getIndent(text, firstExisting.getStart(sourceFile))
438
- : memberIndent + ' ';
439
- return `{\n${existingTexts.map(part => `${innerIndent}${part}`).join(',\n')}\n${memberIndent}}`;
635
+ seen.delete(methodName);
636
+ memo.set(methodName, props);
637
+ return props;
440
638
  }
441
639
 
442
- function transformSourceFile(sourceFile) {
443
- const edits = [];
444
- const {names: wrecNames, quote} = getWrecImportInfo(sourceFile);
445
- let foundWrecSubclass = false;
446
-
447
- for (const node of sourceFile.statements) {
448
- if (!ts.isClassDeclaration(node) || !extendsWrec(node, wrecNames)) continue;
449
- foundWrecSubclass = true;
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.
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.
649
+ const names = new Set(['Wrec']);
650
+ let quote = "'";
450
651
 
451
- let propertiesObject = null;
452
- for (const member of node.members) {
453
- if (
454
- ts.isPropertyDeclaration(member) &&
455
- hasStaticModifier(member) &&
456
- getNameText(member.name) === 'properties' &&
457
- member.initializer &&
458
- ts.isObjectLiteralExpression(member.initializer)
459
- ) {
460
- propertiesObject = member.initializer;
461
- break;
462
- }
652
+ // Support aliased imports such as `import {Wrec as Base} from 'wrec'`
653
+ // so subclass detection still works.
654
+ for (const statement of sourceFile.statements) {
655
+ // Ignore anything that is not an import declaration
656
+ // with a string module path.
657
+ if (
658
+ !ts.isImportDeclaration(statement) ||
659
+ !statement.importClause ||
660
+ !ts.isStringLiteral(statement.moduleSpecifier)
661
+ ) {
662
+ continue;
463
663
  }
464
664
 
465
- if (!propertiesObject) continue;
466
-
467
- const propertyNames = new Set(
468
- propertiesObject.properties
469
- .filter(ts.isPropertyAssignment)
470
- .map(property => getNameText(property.name))
471
- .filter(Boolean)
472
- );
473
-
474
- const propToMethods = getMethodUsages(node, propertyNames);
475
- for (const member of propertiesObject.properties) {
476
- if (!ts.isPropertyAssignment(member)) continue;
665
+ // Only inspect imports that come from Wrec itself or its supported variants.
666
+ const moduleName = statement.moduleSpecifier.text;
667
+ const isWrecModule = moduleName === 'wrec' || moduleName.endsWith('/wrec');
668
+ if (!isWrecModule) continue;
477
669
 
478
- const propName = getNameText(member.name);
479
- if (!propName || !ts.isObjectLiteralExpression(member.initializer))
480
- continue;
670
+ // Skip default or namespace imports
671
+ // because we only care about named imports.
672
+ const namedBindings = statement.importClause.namedBindings;
673
+ if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
481
674
 
482
- const methodNames = [
483
- ...(propToMethods.get(propName) ?? new Set())
484
- ].sort();
485
- const configObject = member.initializer;
486
- const existingMembers = configObject.properties.filter(
487
- property =>
488
- !(
489
- ts.isPropertyAssignment(property) &&
490
- getNameText(property.name) === 'usedBy'
491
- )
492
- );
493
- const hadUsedBy =
494
- existingMembers.length !== configObject.properties.length;
495
- const needsUsedBy = methodNames.length > 0;
496
- if (!hadUsedBy && !needsUsedBy) continue;
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.
678
+ const importedName = element.propertyName?.text ?? element.name.text;
679
+ if (importedName === 'Wrec') {
680
+ // Store the local identifier that this file uses to refer to Wrec.
681
+ names.add(element.name.text);
497
682
 
498
- const nextText = buildConfigText(sourceFile, member, methodNames, quote);
499
- const currentText = sourceFile.text.slice(
500
- configObject.getStart(sourceFile),
501
- configObject.end
502
- );
503
- if (nextText !== currentText) {
504
- edits.push({
505
- start: configObject.getStart(sourceFile),
506
- end: configObject.end,
507
- propName,
508
- oldText: currentText,
509
- text: nextText
510
- });
683
+ // Capture whether the source file uses single or double quotes
684
+ // so generated code can follow the file's existing style.
685
+ const moduleText = statement.moduleSpecifier.getText(sourceFile);
686
+ quote = moduleText[0];
511
687
  }
512
688
  }
513
689
  }
514
690
 
515
- if (!foundWrecSubclass) {
516
- return {
517
- changed: false,
518
- edits: [],
519
- foundWrecSubclass: false,
520
- text: sourceFile.text
521
- };
522
- }
523
-
524
- if (edits.length === 0) {
525
- return {
526
- changed: false,
527
- edits: [],
528
- foundWrecSubclass: true,
529
- text: sourceFile.text
530
- };
531
- }
532
-
533
- let nextSource = sourceFile.text;
534
- edits.sort((a, b) => b.start - a.start);
535
- for (const edit of edits) {
536
- nextSource =
537
- nextSource.slice(0, edit.start) + edit.text + nextSource.slice(edit.end);
538
- }
691
+ return {names, quote};
692
+ }
539
693
 
540
- return {changed: true, edits, foundWrecSubclass: true, text: nextSource};
694
+ // Determines if a class member is static.
695
+ function hasStaticModifier(member) {
696
+ return Boolean(
697
+ member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)
698
+ );
541
699
  }
542
700
 
543
- let changedCount = 0;
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
710
+ );
711
+ }
544
712
 
545
- for (const file of files) {
546
- const text = fs.readFileSync(file, 'utf8');
547
- const scriptKind = file.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS;
548
- const sourceFile = ts.createSourceFile(
549
- file,
550
- text,
551
- ts.ScriptTarget.Latest,
552
- true,
553
- scriptKind
713
+ // Determines if a path refers to a JavaScript or TypeScript source file.
714
+ function isSupportedSourceFile(filePath, excludeTests = false) {
715
+ return (
716
+ /\.(js|ts)$/.test(filePath) &&
717
+ !/\.d\.ts$/.test(filePath) &&
718
+ (!excludeTests || !filePath.includes('.test.'))
554
719
  );
720
+ }
555
721
 
556
- const {
557
- changed,
558
- edits,
559
- foundWrecSubclass,
560
- text: nextText
561
- } = transformSourceFile(sourceFile);
562
- if (!foundWrecSubclass) {
563
- console.error('No class extending Wrec was found.');
564
- process.exit(1);
722
+ // Handles CLI arguments and runs the script.
723
+ function main() {
724
+ const args = process.argv.slice(2);
725
+ const inputPaths = args.filter(arg => !arg.startsWith('--'));
726
+
727
+ if (inputPaths.length !== 1) {
728
+ throw new Error('Specify a single source file');
565
729
  }
566
- if (!changed) continue;
567
730
 
568
- changedCount++;
731
+ const dry = args.includes('--dry');
732
+ const result = evaluateSourceFile(inputPaths[0], {dry});
569
733
  if (dry) {
570
- for (const edit of edits.toReversed()) {
571
- const match = edit.text.match(/usedBy:\s*(?:['"][^'"]*['"]|\[[^\]]*\])/);
572
- const suggestion = match ? match[0] : 'remove usedBy';
573
- console.log(`${edit.propName} - ${suggestion}`);
734
+ // Report the proposed changes.
735
+ for (const {propName, suggestion} of result.suggestions) {
736
+ console.info(`${propName} - ${suggestion}`);
574
737
  }
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');
575
744
  } else {
576
- fs.writeFileSync(file, nextText);
745
+ console.info('no changes needed');
746
+ }
747
+ }
748
+
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);
577
755
  }
578
- if (verbose && !dry) console.log('updated');
579
756
  }
580
757
 
581
- if (dry && changedCount > 0) process.exit(1);
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');
582
761
 
583
- if (dry && changedCount === 0) {
584
- console.log('usedBy is already up to date.');
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');
767
+ }
768
+ }
769
+
770
+ // If this is being run as a script,
771
+ // versus being imported (likely by test code) ...
772
+ if (import.meta.main) {
773
+ try {
774
+ main();
775
+ } catch (error) {
776
+ console.error(error instanceof Error ? error.message : String(error));
777
+ process.exit(1);
778
+ }
585
779
  }