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.
@@ -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
- // It uses the TypeScript compiler to parse the file,
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
- function buildConfigText(sourceFile, member, methodNames, quote) {
24
- const {text} = sourceFile;
25
- const configObject = member.initializer;
26
- const existingMembers = configObject.properties.filter(
27
- property =>
28
- !(
29
- ts.isPropertyAssignment(property) &&
30
- getNameText(property.name) === 'usedBy'
31
- )
32
- );
33
- const existingTexts = existingMembers.map(property =>
34
- text.slice(property.getStart(sourceFile), property.end).trim()
35
- );
36
- if (methodNames.length > 0)
37
- existingTexts.push(createUsedByProperty(methodNames, quote));
38
-
39
- const original = text.slice(
40
- configObject.getStart(sourceFile),
41
- configObject.end
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
- function getComputedCalledMethods(classNode) {
107
- const methodNames = new Set();
108
- const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
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
- for (const member of classNode.members) {
111
- if (
112
- !ts.isPropertyDeclaration(member) ||
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
- for (const property of member.initializer.properties) {
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
- function getMethodUsages(classNode, propertyNames) {
156
- const methodInfo = new Map();
157
- for (const member of classNode.members) {
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
- // Each matching class contributes text replacements for the specific
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
- const propertyNames = new Set(
462
- propertiesObject.properties
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
- const hadUsedBy =
488
- existingMembers.length !== configObject.properties.length;
489
- if (methodNames.length > 0 || hadUsedBy) {
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
- function validateTargetFile(target, cwd = process.cwd()) {
555
- if (!fs.existsSync(target)) {
556
- throw new Error(`File not found: ${path.relative(cwd, target)}`);
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 stat = fs.statSync(target);
560
- if (!stat.isFile()) {
561
- throw new Error(`Not a file: ${path.relative(cwd, target)}`);
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
- if (!/\.(js|ts)$/.test(target) || /\.d\.ts$/.test(target)) {
565
- throw new Error(`Unsupported file type: ${path.relative(cwd, target)}`);
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
- export function updateUsedBySource(filePath, text) {
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
- return transformSourceFile(sourceFile);
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
- 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);
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 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.');
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
- if (dry) {
601
- return {changed, foundWrecSubclass, suggestions, text: nextText};
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
- if (changed) {
605
- // Otherwise, apply the rewritten source text back to disk.
606
- fs.writeFileSync(resolved, nextText);
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 {changed, foundWrecSubclass, suggestions: [], text: nextText, quiet};
584
+ return methodNames;
609
585
  }
610
586
 
611
- function fail(message) {
612
- console.error(message);
613
- process.exit(1);
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 result = updateUsedByFile(inputPaths[0], {dry, quiet});
647
+ const dry = args.includes('--dry');
648
+ const result = evaluateSourceFile(inputPaths[0], {dry});
633
649
  if (dry) {
634
- // In dry mode, report the inferred change and exit non-zero when at
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.log(`${propName} - ${suggestion}`);
652
+ console.info(`${propName} - ${suggestion}`);
638
653
  }
639
- if (!result.changed) return;
640
- process.exit(1);
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
- if (result.changed) console.log('updated');
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
- const isCliEntry = (() => {
647
- if (!process.argv[1]) return false;
648
- try {
649
- return (
650
- fs.realpathSync(process.argv[1]) ===
651
- fs.realpathSync(fileURLToPath(import.meta.url))
652
- );
653
- } catch {
654
- return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
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
- if (isCliEntry) {
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
- fail(error instanceof Error ? error.message : String(error));
692
+ console.error(error instanceof Error ? error.message : String(error));
693
+ process.exit(1);
663
694
  }
664
695
  }