wrec 0.29.1 → 0.29.3

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,50 +1,62 @@
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
+ // It uses the TypeScript compiler to parse the file,
7
+ // discover which expressions call which methods,
8
+ // trace property usage through those call chains, and
9
+ // output or update the `usedBy` properties`.
10
+ //
11
+ // To run this, enter `npx wrec-usedby [--dry] [file-path]`
12
+ // If no file-path is specified, the script runs on
13
+ // all .js and .ts files in and below the current directory.
14
+ //
15
+ // Include the --dry flag for a dry run where `usedBy` values are output,
16
+ // but no files are modified.
2
17
 
3
18
  import fs from 'node:fs';
4
19
  import path from 'node:path';
20
+ import {fileURLToPath} from 'node:url';
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('--'));
11
-
12
- if (args.includes('--write')) {
13
- console.error('--write is no longer supported; writing is now the default.');
14
- process.exit(1);
15
- }
16
-
17
- if (args.includes('--check')) {
18
- console.error('Use --dry instead of --check.');
19
- process.exit(1);
20
- }
21
-
22
- if (inputPaths.length !== 1) {
23
- console.error(
24
- 'Specify a single source file, e.g. npx wrec-usedby src/examples/radio-group.js'
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
+ )
25
32
  );
26
- process.exit(1);
27
- }
28
-
29
- const cwd = process.cwd();
30
- const targets = inputPaths.map(file => path.resolve(cwd, file));
31
-
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
- }
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));
37
38
 
38
- const stat = fs.statSync(target);
39
- if (!stat.isFile()) {
40
- console.error(`Not a file: ${path.relative(cwd, target)}`);
41
- process.exit(1);
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}}`;
42
50
  }
43
51
 
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
- }
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}}`;
48
60
  }
49
61
 
50
62
  function collectFiles(startPath, files = []) {
@@ -52,9 +64,7 @@ function collectFiles(startPath, files = []) {
52
64
 
53
65
  const stat = fs.statSync(startPath);
54
66
  if (stat.isFile()) {
55
- if (/\.(js|ts)$/.test(startPath) && !/\.d\.ts$/.test(startPath)) {
56
- files.push(startPath);
57
- }
67
+ if (isSupportedSourceFile(startPath)) files.push(startPath);
58
68
  return files;
59
69
  }
60
70
 
@@ -63,12 +73,7 @@ function collectFiles(startPath, files = []) {
63
73
  if (entry.isDirectory()) {
64
74
  if (entry.name === 'node_modules' || entry.name === 'dist') continue;
65
75
  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
- ) {
76
+ } else if (entry.isFile() && isSupportedSourceFile(entry.name, true)) {
72
77
  files.push(fullPath);
73
78
  }
74
79
  }
@@ -76,63 +81,11 @@ function collectFiles(startPath, files = []) {
76
81
  return files;
77
82
  }
78
83
 
79
- const files = targets.flatMap(target => collectFiles(target));
80
-
81
- function getNameText(name) {
82
- if (
83
- ts.isIdentifier(name) ||
84
- ts.isStringLiteral(name) ||
85
- ts.isPrivateIdentifier(name)
86
- ) {
87
- return name.text;
88
- }
89
- return null;
90
- }
91
-
92
- function hasStaticModifier(node) {
93
- return Boolean(
94
- node.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)
95
- );
96
- }
97
-
98
- function getWrecImportInfo(sourceFile) {
99
- const names = new Set(['Wrec']);
100
- let quote = "'";
101
-
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
- }
110
-
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;
118
-
119
- const namedBindings = statement.importClause.namedBindings;
120
- if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
121
-
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);
126
-
127
- const moduleText = statement.moduleSpecifier.getText(sourceFile);
128
- if (moduleText.startsWith('"') || moduleText.startsWith("'")) {
129
- quote = moduleText[0];
130
- }
131
- }
132
- }
84
+ function createUsedByProperty(methodNames, quote) {
85
+ if (methodNames.length === 1) {
86
+ return `usedBy: ${quote}${methodNames[0]}${quote}`;
133
87
  }
134
-
135
- return {names, quote};
88
+ return `usedBy: [${methodNames.map(name => `${quote}${name}${quote}`).join(', ')}]`;
136
89
  }
137
90
 
138
91
  function extendsWrec(node, wrecNames) {
@@ -150,51 +103,6 @@ function extendsWrec(node, wrecNames) {
150
103
  );
151
104
  }
152
105
 
153
- function getTemplateCalledMethods(classNode) {
154
- const methodNames = new Set();
155
- 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
- }
176
-
177
- for (const member of classNode.members) {
178
- if (
179
- ts.isPropertyDeclaration(member) &&
180
- hasStaticModifier(member) &&
181
- getNameText(member.name) === 'html' &&
182
- member.initializer
183
- ) {
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);
192
- }
193
- }
194
-
195
- return methodNames;
196
- }
197
-
198
106
  function getComputedCalledMethods(classNode) {
199
107
  const methodNames = new Set();
200
108
  const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
@@ -238,6 +146,12 @@ function getComputedCalledMethods(classNode) {
238
146
  return methodNames;
239
147
  }
240
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
+ }
154
+
241
155
  function getMethodUsages(classNode, propertyNames) {
242
156
  const methodInfo = new Map();
243
157
  for (const member of classNode.members) {
@@ -283,6 +197,9 @@ function getMethodUsages(classNode, propertyNames) {
283
197
  }
284
198
 
285
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.
286
203
  if (
287
204
  ts.isPropertyAccessExpression(child) &&
288
205
  child.expression.kind === ts.SyntaxKind.ThisKeyword
@@ -354,6 +271,9 @@ function getMethodUsages(classNode, propertyNames) {
354
271
  const memo = new Map();
355
272
 
356
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.
357
277
  if (memo.has(methodName)) return memo.get(methodName);
358
278
  if (seen.has(methodName)) return new Set();
359
279
 
@@ -395,55 +315,129 @@ function getMethodUsages(classNode, propertyNames) {
395
315
  return propToMethods;
396
316
  }
397
317
 
398
- function createUsedByProperty(methodNames, quote) {
399
- if (methodNames.length === 1) {
400
- return `usedBy: ${quote}${methodNames[0]}${quote}`;
318
+ function getNameText(name) {
319
+ if (
320
+ ts.isIdentifier(name) ||
321
+ ts.isStringLiteral(name) ||
322
+ ts.isPrivateIdentifier(name)
323
+ ) {
324
+ return name.text;
401
325
  }
402
- return `usedBy: [${methodNames.map(name => `${quote}${name}${quote}`).join(', ')}]`;
326
+ return null;
403
327
  }
404
328
 
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] : '';
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;
409
374
  }
410
375
 
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));
376
+ function getWrecImportInfo(sourceFile) {
377
+ const names = new Set(['Wrec']);
378
+ let quote = "'";
426
379
 
427
- const original = text.slice(
428
- configObject.getStart(sourceFile),
429
- configObject.end
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)
430
422
  );
431
- const multiline = original.includes('\n');
432
- if (!multiline) return `{ ${existingTexts.join(', ')} }`;
423
+ }
433
424
 
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}}`;
425
+ function isSupportedSourceFile(filePath, excludeTests = false) {
426
+ return (
427
+ /\.(js|ts)$/.test(filePath) &&
428
+ !/\.d\.ts$/.test(filePath) &&
429
+ (!excludeTests || !filePath.includes('.test.'))
430
+ );
440
431
  }
441
432
 
442
433
  function transformSourceFile(sourceFile) {
443
434
  const edits = [];
444
435
  const {names: wrecNames, quote} = getWrecImportInfo(sourceFile);
436
+ const suggestions = [];
445
437
  let foundWrecSubclass = false;
446
438
 
439
+ // Each matching class contributes text replacements for the specific
440
+ // property config objects that need `usedBy` added, updated, or removed.
447
441
  for (const node of sourceFile.statements) {
448
442
  if (!ts.isClassDeclaration(node) || !extendsWrec(node, wrecNames)) continue;
449
443
  foundWrecSubclass = true;
@@ -492,6 +486,15 @@ function transformSourceFile(sourceFile) {
492
486
  );
493
487
  const hadUsedBy =
494
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
+ }
495
498
  const needsUsedBy = methodNames.length > 0;
496
499
  if (!hadUsedBy && !needsUsedBy) continue;
497
500
 
@@ -517,6 +520,7 @@ function transformSourceFile(sourceFile) {
517
520
  changed: false,
518
521
  edits: [],
519
522
  foundWrecSubclass: false,
523
+ suggestions,
520
524
  text: sourceFile.text
521
525
  };
522
526
  }
@@ -526,6 +530,7 @@ function transformSourceFile(sourceFile) {
526
530
  changed: false,
527
531
  edits: [],
528
532
  foundWrecSubclass: true,
533
+ suggestions,
529
534
  text: sourceFile.text
530
535
  };
531
536
  }
@@ -537,49 +542,123 @@ function transformSourceFile(sourceFile) {
537
542
  nextSource.slice(0, edit.start) + edit.text + nextSource.slice(edit.end);
538
543
  }
539
544
 
540
- return {changed: true, edits, foundWrecSubclass: true, text: nextSource};
545
+ return {
546
+ changed: true,
547
+ edits,
548
+ foundWrecSubclass: true,
549
+ suggestions,
550
+ text: nextSource
551
+ };
541
552
  }
542
553
 
543
- let changedCount = 0;
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
+ }
544
568
 
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;
569
+ export function updateUsedBySource(filePath, text) {
570
+ const scriptKind = filePath.endsWith('.ts')
571
+ ? ts.ScriptKind.TS
572
+ : ts.ScriptKind.JS;
548
573
  const sourceFile = ts.createSourceFile(
549
- file,
574
+ filePath,
550
575
  text,
551
576
  ts.ScriptTarget.Latest,
552
577
  true,
553
578
  scriptKind
554
579
  );
555
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');
556
591
  const {
557
592
  changed,
558
- edits,
559
593
  foundWrecSubclass,
594
+ suggestions,
560
595
  text: nextText
561
- } = transformSourceFile(sourceFile);
596
+ } = updateUsedBySource(resolved, text);
562
597
  if (!foundWrecSubclass) {
563
- console.error('No class extending Wrec was found.');
564
- process.exit(1);
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
+
616
+ function main() {
617
+ const args = process.argv.slice(2);
618
+ const dry = args.includes('--dry');
619
+ const quiet = args.includes('--quiet');
620
+ const inputPaths = args.filter(arg => !arg.startsWith('--'));
621
+
622
+ if (args.includes('--check')) {
623
+ throw new Error('Use --dry instead of --check.');
624
+ }
625
+
626
+ 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
+ );
565
630
  }
566
- if (!changed) continue;
567
631
 
568
- changedCount++;
632
+ const result = updateUsedByFile(inputPaths[0], {dry, quiet});
569
633
  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}`);
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.
636
+ for (const {propName, suggestion} of result.suggestions) {
637
+ console.log(`${propName} - ${suggestion}`);
574
638
  }
575
- } else {
576
- fs.writeFileSync(file, nextText);
639
+ if (!result.changed) return;
640
+ process.exit(1);
577
641
  }
578
- if (verbose && !dry) console.log('updated');
642
+
643
+ if (result.changed) console.log('updated');
579
644
  }
580
645
 
581
- if (dry && changedCount > 0) process.exit(1);
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);
655
+ }
656
+ })();
582
657
 
583
- if (dry && changedCount === 0) {
584
- console.log('usedBy is already up to date.');
658
+ if (isCliEntry) {
659
+ try {
660
+ main();
661
+ } catch (error) {
662
+ fail(error instanceof Error ? error.message : String(error));
663
+ }
585
664
  }