wrec 0.29.4 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "wrec",
3
3
  "description": "a small library that greatly simplifies building web components",
4
4
  "author": "R. Mark Volkmann",
5
- "version": "0.29.4",
5
+ "version": "0.30.1",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -34,8 +34,9 @@
34
34
  },
35
35
  "files": [
36
36
  "dist",
37
- "scripts/used-by.js",
37
+ "scripts/ast-utils.js",
38
38
  "scripts/lint.js",
39
+ "scripts/used-by.js",
39
40
  "README.md"
40
41
  ],
41
42
  "scripts": {
@@ -0,0 +1,107 @@
1
+ import ts from 'typescript';
2
+
3
+ // Finds all classes in a source file that extend Wrec.
4
+ export function collectWrecClasses(sourceFile) {
5
+ const {names: wrecNames} = getWrecImportInfo(sourceFile);
6
+ const classes = [];
7
+
8
+ function visit(node) {
9
+ if (
10
+ ts.isClassDeclaration(node) &&
11
+ node.name &&
12
+ extendsWrec(node, wrecNames)
13
+ ) {
14
+ classes.push(node);
15
+ }
16
+ ts.forEachChild(node, visit);
17
+ }
18
+
19
+ visit(sourceFile);
20
+ return classes;
21
+ }
22
+
23
+ // Determines whether a class declaration extends one of the known Wrec imports.
24
+ export function extendsWrec(classNode, wrecNames) {
25
+ return Boolean(
26
+ classNode.heritageClauses?.some(
27
+ clause =>
28
+ clause.token === ts.SyntaxKind.ExtendsKeyword &&
29
+ clause.types.some(
30
+ type =>
31
+ ts.isExpressionWithTypeArguments(type) &&
32
+ ts.isIdentifier(type.expression) &&
33
+ wrecNames.has(type.expression.text)
34
+ )
35
+ )
36
+ );
37
+ }
38
+
39
+ // Gets a plain string member name when one can be statically determined.
40
+ export function getMemberName(node) {
41
+ const {name} = node;
42
+ return name ? (getNameText(name) ?? undefined) : undefined;
43
+ }
44
+
45
+ // Gets the text value of an AST name node.
46
+ export function getNameText(name) {
47
+ return ts.isIdentifier(name) ||
48
+ ts.isStringLiteral(name) ||
49
+ ts.isPrivateIdentifier(name)
50
+ ? name.text
51
+ : null;
52
+ }
53
+
54
+ // Collects object-literal property names from property assignments.
55
+ export function getPropertyAssignmentNames(objectLiteral) {
56
+ return objectLiteral.properties
57
+ .filter(ts.isPropertyAssignment)
58
+ .map(property => getMemberName(property))
59
+ .filter(name => name !== undefined);
60
+ }
61
+
62
+ // Collects imported Wrec class names and the quote style used for those imports.
63
+ export function getWrecImportInfo(sourceFile) {
64
+ const names = new Set(['Wrec']);
65
+ let quote = "'";
66
+
67
+ for (const statement of sourceFile.statements) {
68
+ if (
69
+ !ts.isImportDeclaration(statement) ||
70
+ !statement.importClause ||
71
+ !ts.isStringLiteral(statement.moduleSpecifier)
72
+ ) {
73
+ continue;
74
+ }
75
+
76
+ const moduleName = statement.moduleSpecifier.text;
77
+ const isWrecModule =
78
+ moduleName === 'wrec' ||
79
+ moduleName.endsWith('/wrec') ||
80
+ moduleName.endsWith('/wrec.js') ||
81
+ moduleName.endsWith('/wrec.ts');
82
+ if (!isWrecModule) continue;
83
+
84
+ const namedBindings = statement.importClause.namedBindings;
85
+ if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
86
+
87
+ for (const element of namedBindings.elements) {
88
+ const importedName = element.propertyName?.text ?? element.name.text;
89
+ if (importedName === 'Wrec') {
90
+ names.add(element.name.text);
91
+ const moduleText = statement.moduleSpecifier.getText(sourceFile);
92
+ quote = moduleText[0];
93
+ }
94
+ }
95
+ }
96
+
97
+ return {names, quote};
98
+ }
99
+
100
+ // Determines if an AST node has the static modifier.
101
+ export function hasStaticModifier(node) {
102
+ return ts.canHaveModifiers(node)
103
+ ? ts
104
+ .getModifiers(node)
105
+ ?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword)
106
+ : false;
107
+ }
package/scripts/lint.js CHANGED
@@ -33,6 +33,12 @@ import fs from 'node:fs';
33
33
  import path from 'node:path';
34
34
  import ts from 'typescript';
35
35
  import {parse} from 'node-html-parser';
36
+ import {
37
+ collectWrecClasses,
38
+ getMemberName,
39
+ getPropertyAssignmentNames,
40
+ hasStaticModifier
41
+ } from './ast-utils.js';
36
42
 
37
43
  const CSS_PROPERTY_RE = /([a-zA-Z-]+)\s*:\s*([^;}]+)/g;
38
44
  const REFS_TEST_RE = /this\.[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)*/;
@@ -132,6 +138,8 @@ const SUPPORTED_EVENT_NAMES = new Set([
132
138
  const WREC_REF_NAME = '__wrec';
133
139
  const componentPropertyCache = new Map();
134
140
 
141
+ // Analyzes an expression for invalid property access,
142
+ // method calls, and arithmetic usage.
135
143
  function analyzeExpression(
136
144
  expressionNode,
137
145
  checker,
@@ -148,6 +156,7 @@ function analyzeExpression(
148
156
  }
149
157
  }
150
158
 
159
+ // Walks the expression tree and records any issues that are found.
151
160
  function visit(node) {
152
161
  if (ts.isPropertyAccessExpression(node) && isWrecRooted(node.expression)) {
153
162
  const ownerType = checker.getTypeAtLocation(node.expression);
@@ -251,14 +260,20 @@ function analyzeExpression(
251
260
  if (!isNumericLikeType(leftType)) {
252
261
  findings.typeErrors.push({
253
262
  expression: toUserFacingExpression(node.getText()),
254
- message: `left operand "${toUserFacingExpression(node.left.getText())}" has type ${checker.typeToString(leftType)}, but arithmetic operators require number`
263
+ message:
264
+ `left operand "${toUserFacingExpression(node.left.getText())}" ` +
265
+ `has type ${checker.typeToString(leftType)}, ` +
266
+ 'but arithmetic operators require number'
255
267
  });
256
268
  }
257
269
 
258
270
  if (!isNumericLikeType(rightType)) {
259
271
  findings.typeErrors.push({
260
272
  expression: toUserFacingExpression(node.getText()),
261
- message: `right operand "${toUserFacingExpression(node.right.getText())}" has type ${checker.typeToString(rightType)}, but arithmetic operators require number`
273
+ message:
274
+ `right operand "${toUserFacingExpression(node.right.getText())}" ` +
275
+ `has type ${checker.typeToString(rightType)}, ` +
276
+ 'but arithmetic operators require number'
262
277
  });
263
278
  }
264
279
  }
@@ -269,6 +284,12 @@ function analyzeExpression(
269
284
  visit(expressionNode);
270
285
  }
271
286
 
287
+ // Builds a temporary source string used only for type-checking
288
+ // extracted expressions. It appends helper types and generated functions
289
+ // to the original component source so each template or computed expression
290
+ // can be analyzed as if it were normal code. This gives TypeScript
291
+ // enough // context to understand available properties, methods, and
292
+ // context functions when the linter validates those expressions.
272
293
  function buildAugmentedSource(
273
294
  sourceFile,
274
295
  classNode,
@@ -309,6 +330,7 @@ ${propLines.join('\n')}
309
330
  return `${sourceFile.text}\n${propInterface}\n${helperBlocks.join('\n')}`;
310
331
  }
311
332
 
333
+ // Collects all instance method and accessor names defined in a component class.
312
334
  function collectClassMethods(classNode) {
313
335
  const methods = new Set();
314
336
  for (const member of classNode.members) {
@@ -325,9 +347,16 @@ function collectClassMethods(classNode) {
325
347
  return methods;
326
348
  }
327
349
 
350
+ // Finds the synthetic `__wrec_expr_*` helper functions that were added by
351
+ // `buildAugmentedSource` and pulls out the expression each one returns.
352
+ // This gives the linter a stable list of typed expression nodes
353
+ // that line up with the original template and computed expressions
354
+ // for later analysis.
328
355
  function collectHelperExpressions(augmentedSourceFile) {
329
356
  const helpers = [];
330
357
 
358
+ // Finds generated helper functions and
359
+ // stores their return expressions by index.
331
360
  function visit(node) {
332
361
  if (
333
362
  ts.isFunctionDeclaration(node) &&
@@ -349,35 +378,13 @@ function collectHelperExpressions(augmentedSourceFile) {
349
378
  return helpers;
350
379
  }
351
380
 
352
- function collectWrecClasses(sourceFile) {
353
- const classes = [];
354
-
355
- function visit(node) {
356
- if (ts.isClassDeclaration(node) && node.name) {
357
- const heritage = node.heritageClauses?.find(
358
- clause => clause.token === ts.SyntaxKind.ExtendsKeyword
359
- );
360
- const typeNode = heritage?.types[0];
361
- if (
362
- typeNode &&
363
- ts.isIdentifier(typeNode.expression) &&
364
- typeNode.expression.text === 'Wrec'
365
- ) {
366
- classes.push(node);
367
- }
368
- }
369
- ts.forEachChild(node, visit);
370
- }
371
-
372
- visit(sourceFile);
373
- return classes;
374
- }
375
-
381
+ // Collects the property names declared in
382
+ // a component's static properties object.
376
383
  function collectSupportedPropertyNames(classNode) {
377
384
  const supportedProps = new Set();
378
385
 
379
386
  for (const member of classNode.members) {
380
- if (!isStaticMember(member)) continue;
387
+ if (!hasStaticModifier(member)) continue;
381
388
  if (!ts.isPropertyDeclaration(member)) continue;
382
389
 
383
390
  const name = getMemberName(member);
@@ -389,38 +396,17 @@ function collectSupportedPropertyNames(classNode) {
389
396
  continue;
390
397
  }
391
398
 
392
- for (const property of member.initializer.properties) {
393
- if (!ts.isPropertyAssignment(property)) continue;
394
- const propName = getMemberName(property);
395
- if (propName) supportedProps.add(propName);
399
+ for (const propName of getPropertyAssignmentNames(member.initializer)) {
400
+ supportedProps.add(propName);
396
401
  }
397
402
  }
398
403
 
399
404
  return supportedProps;
400
405
  }
401
406
 
402
- function findDefinedTagNames(sourceFile) {
403
- const tagNames = new Map();
404
-
405
- function visit(node) {
406
- if (
407
- ts.isCallExpression(node) &&
408
- ts.isPropertyAccessExpression(node.expression) &&
409
- node.expression.name.text === 'define' &&
410
- ts.isIdentifier(node.expression.expression) &&
411
- node.arguments.length > 0 &&
412
- ts.isStringLiteral(node.arguments[0])
413
- ) {
414
- tagNames.set(node.expression.expression.text, node.arguments[0].text);
415
- }
416
- ts.forEachChild(node, visit);
417
- }
418
-
419
- visit(sourceFile);
420
- return tagNames;
421
- }
422
-
407
+ // Validates that useState mappings point at existing component properties.
423
408
  function collectUseStateMapErrors(classNode, supportedProps, findings) {
409
+ // Walks the class body looking for useState calls with mapping objects.
424
410
  function visit(node) {
425
411
  if (
426
412
  ts.isCallExpression(node) &&
@@ -444,7 +430,8 @@ function collectUseStateMapErrors(classNode, supportedProps, findings) {
444
430
  const componentProp = property.initializer.text;
445
431
  if (!supportedProps.has(componentProp)) {
446
432
  findings.invalidUseStateMaps.push(
447
- `useState maps state property "${statePath}" to missing component property "${componentProp}"`
433
+ `useState maps state property "${statePath}" to ` +
434
+ `missing component property "${componentProp}"`
448
435
  );
449
436
  }
450
437
  }
@@ -457,6 +444,7 @@ function collectUseStateMapErrors(classNode, supportedProps, findings) {
457
444
  visit(classNode);
458
445
  }
459
446
 
447
+ // Creates a TypeScript program that can type-check the given source text.
460
448
  function createProgram(filePath, sourceText) {
461
449
  const defaultHost = ts.createCompilerHost({}, true);
462
450
  const compilerOptions = {
@@ -513,6 +501,8 @@ function createProgram(filePath, sourceText) {
513
501
  return ts.createProgram([filePath], compilerOptions, host);
514
502
  }
515
503
 
504
+ // Extracts component property metadata and related validation inputs
505
+ // from a class.
516
506
  function extractProperties(sourceFile, checker, classNode) {
517
507
  const duplicateProperties = [];
518
508
  let formAssociated = false;
@@ -523,7 +513,7 @@ function extractProperties(sourceFile, checker, classNode) {
523
513
  let contextKeys = [];
524
514
 
525
515
  for (const member of classNode.members) {
526
- if (!isStaticMember(member)) continue;
516
+ if (!hasStaticModifier(member)) continue;
527
517
  if (!ts.isPropertyDeclaration(member)) continue;
528
518
 
529
519
  const name = getMemberName(member);
@@ -620,6 +610,7 @@ function extractProperties(sourceFile, checker, classNode) {
620
610
  };
621
611
  }
622
612
 
613
+ // Extracts analyzable expressions from static html and css templates.
623
614
  function extractTemplateExpressions(
624
615
  classNode,
625
616
  findings,
@@ -629,7 +620,7 @@ function extractTemplateExpressions(
629
620
  const expressions = [];
630
621
 
631
622
  for (const member of classNode.members) {
632
- if (!isStaticMember(member)) continue;
623
+ if (!hasStaticModifier(member)) continue;
633
624
  if (!ts.isPropertyDeclaration(member)) continue;
634
625
 
635
626
  const name = getMemberName(member);
@@ -696,11 +687,37 @@ function extractTemplateExpressions(
696
687
  return expressions;
697
688
  }
698
689
 
690
+ // Prints an error message and exits the process.
699
691
  function fail(message) {
700
692
  console.error(message);
701
693
  process.exit(1);
702
694
  }
703
695
 
696
+ // Maps component class names to tag names defined in the source file.
697
+ function findDefinedTagNames(sourceFile) {
698
+ const tagNames = new Map();
699
+
700
+ // Walks the file looking for static define calls with string tag names.
701
+ function visit(node) {
702
+ if (
703
+ ts.isCallExpression(node) &&
704
+ ts.isPropertyAccessExpression(node.expression) &&
705
+ node.expression.name.text === 'define' &&
706
+ ts.isIdentifier(node.expression.expression) &&
707
+ node.arguments.length > 0 &&
708
+ ts.isStringLiteral(node.arguments[0])
709
+ ) {
710
+ tagNames.set(node.expression.expression.text, node.arguments[0].text);
711
+ }
712
+ ts.forEachChild(node, visit);
713
+ }
714
+
715
+ visit(sourceFile);
716
+ return tagNames;
717
+ }
718
+
719
+ // Recursively finds Wrec component files under a directory
720
+ // and reports each match.
704
721
  function findWrecFiles(rootDir, onMatch) {
705
722
  const walk = currentDir => {
706
723
  const entries = fs
@@ -724,6 +741,13 @@ function findWrecFiles(rootDir, onMatch) {
724
741
  walk(rootDir);
725
742
  }
726
743
 
744
+ // Formats an optional source location as line and column text.
745
+ function formatLocation(location) {
746
+ if (!location) return '';
747
+ return `:${location.line + 1}:${location.character + 1}`;
748
+ }
749
+
750
+ // Formats the collected lint findings into the command-line report output.
727
751
  function formatReport(
728
752
  filePath,
729
753
  supportedProps,
@@ -891,7 +915,9 @@ function formatReport(
891
915
  lines.push('extra arguments:');
892
916
  findings.extraArguments.forEach(finding => {
893
917
  lines.push(
894
- ` ${finding.methodName}: argument ${finding.argumentIndex} "${finding.argument}" exceeds the ${finding.parameterCount}-parameter signature`
918
+ ` ${finding.methodName}: argument ${finding.argumentIndex} ` +
919
+ `"${finding.argument}" exceeds the ` +
920
+ `${finding.parameterCount}-parameter signature`
895
921
  );
896
922
  });
897
923
  }
@@ -900,7 +926,9 @@ function formatReport(
900
926
  lines.push('incompatible arguments:');
901
927
  findings.incompatibleArguments.forEach(finding => {
902
928
  lines.push(
903
- ` ${finding.methodName}: argument "${finding.argument}" has type ${finding.argumentType}, but parameter "${finding.parameterName}" expects ${finding.parameterType}`
929
+ ` ${finding.methodName}: argument "${finding.argument}" ` +
930
+ `has type ${finding.argumentType}, but parameter ` +
931
+ `"${finding.parameterName}" expects ${finding.parameterType}`
904
932
  );
905
933
  });
906
934
  }
@@ -931,6 +959,8 @@ function formatReport(
931
959
  return `${lines.join('\n')}\n`;
932
960
  }
933
961
 
962
+ // Builds a map from tag names to supported properties
963
+ // for the current file and imports.
934
964
  function getComponentPropertyMaps(filePath, sourceText, seen = new Set()) {
935
965
  const resolved = path.resolve(filePath);
936
966
  if (componentPropertyCache.has(resolved)) {
@@ -985,22 +1015,18 @@ function getComponentPropertyMaps(filePath, sourceText, seen = new Set()) {
985
1015
  return propertyMaps;
986
1016
  }
987
1017
 
988
- function formatLocation(location) {
989
- if (!location) return '';
990
- return `:${location.line + 1}:${location.character + 1}`;
991
- }
992
-
1018
+ // Returns trimmed source text for a TypeScript expression node.
993
1019
  function getExpressionText(sourceFile, expression) {
994
1020
  return expression.getText(sourceFile).trim();
995
1021
  }
996
1022
 
997
- function getMemberName(node) {
998
- const {name} = node;
999
- if (!name) return undefined;
1000
- if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
1001
- return undefined;
1023
+ // Returns a lowercased HTML tag name for a parsed HTML node.
1024
+ function getHtmlTagName(node) {
1025
+ const tagName = node.rawTagName || node.tagName;
1026
+ return typeof tagName === 'string' ? tagName.toLowerCase() : '';
1002
1027
  }
1003
1028
 
1029
+ // Gets an object-literal property with the given key.
1004
1030
  function getObjectProperty(objectLiteral, key) {
1005
1031
  for (const property of objectLiteral.properties) {
1006
1032
  if (
@@ -1014,31 +1040,8 @@ function getObjectProperty(objectLiteral, key) {
1014
1040
  }
1015
1041
  }
1016
1042
 
1017
- function getStringArrayLiteral(property) {
1018
- if (!property || !ts.isPropertyAssignment(property)) return undefined;
1019
- if (!ts.isArrayLiteralExpression(property.initializer)) return undefined;
1020
-
1021
- const values = [];
1022
- for (const element of property.initializer.elements) {
1023
- if (
1024
- !ts.isStringLiteral(element) &&
1025
- !ts.isNoSubstitutionTemplateLiteral(element)
1026
- ) {
1027
- return undefined;
1028
- }
1029
- values.push(element.text);
1030
- }
1031
- return values;
1032
- }
1033
-
1034
- function getPropertyTypeText(checker, sourceFile, expression) {
1035
- const typeText = getTypeSyntaxText(sourceFile, expression);
1036
- if (typeText) return typeText;
1037
-
1038
- const type = checker.getTypeAtLocation(expression);
1039
- return checker.typeToString(type);
1040
- }
1041
-
1043
+ // Resolves the effective parameter type,
1044
+ // including element types for rest parameters.
1042
1045
  function getParameterType(checker, parameterSymbol, location, isRestArgument) {
1043
1046
  const parameterType = checker.getTypeOfSymbolAtLocation(
1044
1047
  parameterSymbol,
@@ -1056,6 +1059,34 @@ function getParameterType(checker, parameterSymbol, location, isRestArgument) {
1056
1059
  return typeArguments[0] ?? parameterType;
1057
1060
  }
1058
1061
 
1062
+ // Derives a readable property type string from syntax or the type checker.
1063
+ function getPropertyTypeText(checker, sourceFile, expression) {
1064
+ const typeText = getTypeSyntaxText(sourceFile, expression);
1065
+ if (typeText) return typeText;
1066
+
1067
+ const type = checker.getTypeAtLocation(expression);
1068
+ return checker.typeToString(type);
1069
+ }
1070
+
1071
+ // Returns an array of string literal values from a property when possible.
1072
+ function getStringArrayLiteral(property) {
1073
+ if (!property || !ts.isPropertyAssignment(property)) return undefined;
1074
+ if (!ts.isArrayLiteralExpression(property.initializer)) return undefined;
1075
+
1076
+ const values = [];
1077
+ for (const element of property.initializer.elements) {
1078
+ if (
1079
+ !ts.isStringLiteral(element) &&
1080
+ !ts.isNoSubstitutionTemplateLiteral(element)
1081
+ ) {
1082
+ return undefined;
1083
+ }
1084
+ values.push(element.text);
1085
+ }
1086
+ return values;
1087
+ }
1088
+
1089
+ // Returns either a single string value or a string array value as an array.
1059
1090
  function getStringOrStringArrayLiteral(property) {
1060
1091
  if (!property || !ts.isPropertyAssignment(property)) return undefined;
1061
1092
 
@@ -1069,10 +1100,13 @@ function getStringOrStringArrayLiteral(property) {
1069
1100
  return getStringArrayLiteral(property);
1070
1101
  }
1071
1102
 
1103
+ // Gets the supported HTML attributes for a given tag.
1072
1104
  function getSupportedHtmlAttributes(tagName) {
1073
1105
  return HTML_TAG_ATTRIBUTES.get(tagName.toLowerCase());
1074
1106
  }
1075
1107
 
1108
+ // Reconstructs the text of a template literal
1109
+ // with placeholders for expressions.
1076
1110
  function getTemplateLiteralText(template) {
1077
1111
  if (ts.isNoSubstitutionTemplateLiteral(template)) return template.text;
1078
1112
 
@@ -1084,6 +1118,8 @@ function getTemplateLiteralText(template) {
1084
1118
  return text;
1085
1119
  }
1086
1120
 
1121
+ // Converts a property type expression into a user-facing type string
1122
+ // when possible.
1087
1123
  function getTypeSyntaxText(sourceFile, expression) {
1088
1124
  const text = expression.getText(sourceFile).trim();
1089
1125
  const arrayMatch = text.match(/^Array<(.+)>$/s);
@@ -1125,15 +1161,7 @@ function getTypeSyntaxText(sourceFile, expression) {
1125
1161
  return undefined;
1126
1162
  }
1127
1163
 
1128
- function isImportLikeDeclaration(node) {
1129
- return (
1130
- ts.isImportClause(node) ||
1131
- ts.isImportEqualsDeclaration(node) ||
1132
- ts.isImportSpecifier(node) ||
1133
- ts.isNamespaceImport(node)
1134
- );
1135
- }
1136
-
1164
+ // Returns whether a token kind is one of the supported arithmetic operators.
1137
1165
  function isArithmeticOperator(kind) {
1138
1166
  return (
1139
1167
  kind === ts.SyntaxKind.AsteriskToken ||
@@ -1144,10 +1172,22 @@ function isArithmeticOperator(kind) {
1144
1172
  );
1145
1173
  }
1146
1174
 
1175
+ // Returns whether a property access node is being called as a callee.
1147
1176
  function isCallCallee(node) {
1148
1177
  return ts.isCallExpression(node.parent) && node.parent.expression === node;
1149
1178
  }
1150
1179
 
1180
+ // Returns whether a declaration represents an imported binding.
1181
+ function isImportLikeDeclaration(node) {
1182
+ return (
1183
+ ts.isImportClause(node) ||
1184
+ ts.isImportEqualsDeclaration(node) ||
1185
+ ts.isImportSpecifier(node) ||
1186
+ ts.isNamespaceImport(node)
1187
+ );
1188
+ }
1189
+
1190
+ // Returns whether a type is fully numeric or any-like for arithmetic checks.
1151
1191
  function isNumericLikeType(type) {
1152
1192
  const parts = type.isUnion() ? type.types : [type];
1153
1193
  return parts.every(part => {
@@ -1163,6 +1203,7 @@ function isNumericLikeType(type) {
1163
1203
  });
1164
1204
  }
1165
1205
 
1206
+ // Returns whether a file defines at least one Wrec component class.
1166
1207
  function isWrecComponentFile(filePath) {
1167
1208
  const sourceText = fs.readFileSync(filePath, 'utf8');
1168
1209
  const scriptKind = filePath.endsWith('.ts')
@@ -1178,14 +1219,7 @@ function isWrecComponentFile(filePath) {
1178
1219
  return collectWrecClasses(sourceFile).length > 0;
1179
1220
  }
1180
1221
 
1181
- function isStaticMember(node) {
1182
- return ts.canHaveModifiers(node)
1183
- ? ts
1184
- .getModifiers(node)
1185
- ?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword)
1186
- : false;
1187
- }
1188
-
1222
+ // Returns whether an expression is rooted at the synthetic Wrec receiver.
1189
1223
  function isWrecRooted(expression) {
1190
1224
  if (ts.isIdentifier(expression) && expression.text === WREC_REF_NAME) {
1191
1225
  return true;
@@ -1199,28 +1233,13 @@ function isWrecRooted(expression) {
1199
1233
  return false;
1200
1234
  }
1201
1235
 
1202
- function requiresContextFunction(symbol, sourceFile) {
1203
- const declarations = symbol.declarations ?? [];
1204
- return declarations.some(declaration => {
1205
- if (isImportLikeDeclaration(declaration)) return true;
1206
- return declaration.getSourceFile() === sourceFile;
1207
- });
1208
- }
1209
-
1210
- function resolveImportPath(baseDir, importPath) {
1211
- if (!importPath.startsWith('.')) return undefined;
1212
-
1213
- const candidates = [
1214
- path.resolve(baseDir, importPath),
1215
- path.resolve(baseDir, `${importPath}.js`),
1216
- path.resolve(baseDir, `${importPath}.ts`),
1217
- path.resolve(baseDir, importPath, 'index.js'),
1218
- path.resolve(baseDir, importPath, 'index.ts')
1219
- ];
1220
-
1221
- return candidates.find(candidate => fs.existsSync(candidate));
1236
+ // Lints a component file by path after resolving and reading it.
1237
+ export function lintFile(filePath, options = {}) {
1238
+ const resolved = validateFilePath(filePath);
1239
+ return lintSource(resolved, fs.readFileSync(resolved, 'utf8'), options);
1222
1240
  }
1223
1241
 
1242
+ // Lints provided component source text and returns a formatted report.
1224
1243
  export function lintSource(filePath, sourceText, options = {}) {
1225
1244
  const baseProgram = createProgram(filePath, sourceText);
1226
1245
  const sourceFile = baseProgram.getSourceFile(filePath);
@@ -1374,13 +1393,16 @@ export function lintSource(filePath, sourceText, options = {}) {
1374
1393
  );
1375
1394
  }
1376
1395
 
1377
- export function lintFile(filePath, options = {}) {
1378
- const resolved = validateFilePath(filePath);
1379
- return lintSource(resolved, fs.readFileSync(resolved, 'utf8'), options);
1380
- }
1381
-
1396
+ // Runs the command-line interface for the linter.
1382
1397
  function main() {
1383
1398
  const args = process.argv.slice(2);
1399
+ const unknownFlags = args.filter(
1400
+ arg => arg.startsWith('--') && arg !== '--verbose'
1401
+ );
1402
+ if (unknownFlags.length > 0) {
1403
+ fail(`unknown option: ${unknownFlags[0]}`);
1404
+ }
1405
+
1384
1406
  const verbose = args.includes('--verbose');
1385
1407
  const positionalArgs = args.filter(arg => arg !== '--verbose');
1386
1408
 
@@ -1417,16 +1439,82 @@ function main() {
1417
1439
  });
1418
1440
  }
1419
1441
 
1442
+ // Determines whether a symbol should be treated as a required context function.
1443
+ function requiresContextFunction(symbol, sourceFile) {
1444
+ const declarations = symbol.declarations ?? [];
1445
+ return declarations.some(declaration => {
1446
+ if (isImportLikeDeclaration(declaration)) return true;
1447
+ return declaration.getSourceFile() === sourceFile;
1448
+ });
1449
+ }
1450
+
1451
+ // Resolves a relative import path to an existing source file.
1452
+ function resolveImportPath(baseDir, importPath) {
1453
+ if (!importPath.startsWith('.')) return undefined;
1454
+
1455
+ const candidates = [
1456
+ path.resolve(baseDir, importPath),
1457
+ path.resolve(baseDir, `${importPath}.js`),
1458
+ path.resolve(baseDir, `${importPath}.ts`),
1459
+ path.resolve(baseDir, importPath, 'index.js'),
1460
+ path.resolve(baseDir, importPath, 'index.ts')
1461
+ ];
1462
+
1463
+ return candidates.find(candidate => fs.existsSync(candidate));
1464
+ }
1465
+
1466
+ // Rewrites synthetic receiver references back to this for reporting.
1420
1467
  function toUserFacingExpression(text) {
1421
1468
  return text.replaceAll(WREC_REF_NAME, 'this');
1422
1469
  }
1423
1470
 
1471
+ // Classifies a constructor-based type expression by its identifier name.
1424
1472
  function typeExpressionKind(expression) {
1425
1473
  if (!expression) return undefined;
1426
1474
  if (ts.isIdentifier(expression)) return expression.text;
1427
1475
  return undefined;
1428
1476
  }
1429
1477
 
1478
+ // Converts a constructor-style type expression into a TypeScript type node.
1479
+ function typeNodeFromConstructorExpression(expression) {
1480
+ if (ts.isIdentifier(expression)) {
1481
+ switch (expression.text) {
1482
+ case 'String':
1483
+ return ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
1484
+ case 'Number':
1485
+ return ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
1486
+ case 'Boolean':
1487
+ return ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
1488
+ case 'Object':
1489
+ return ts.factory.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword);
1490
+ default:
1491
+ return ts.factory.createTypeReferenceNode(expression.text);
1492
+ }
1493
+ }
1494
+
1495
+ if (
1496
+ ts.isCallExpression(expression) &&
1497
+ ts.isIdentifier(expression.expression)
1498
+ ) {
1499
+ const name = expression.expression.text;
1500
+ if (name === 'Array' && expression.typeArguments?.length === 1) {
1501
+ return ts.factory.createArrayTypeNode(expression.typeArguments[0]);
1502
+ }
1503
+ }
1504
+
1505
+ if (ts.isPropertyAccessExpression(expression)) {
1506
+ return ts.factory.createTypeReferenceNode(expression.getText());
1507
+ }
1508
+
1509
+ return undefined;
1510
+ }
1511
+
1512
+ // Pushes a value into an array only if it is not already present.
1513
+ function uniquePush(array, value) {
1514
+ if (!array.includes(value)) array.push(value);
1515
+ }
1516
+
1517
+ // Validates computed property references and method calls.
1430
1518
  function validateComputedProperty(
1431
1519
  propName,
1432
1520
  computedText,
@@ -1441,7 +1529,8 @@ function validateComputedProperty(
1441
1529
  !classMethods.has(referencedName)
1442
1530
  ) {
1443
1531
  findings.invalidComputedProperties.push(
1444
- `property "${propName}" computed references missing property "${referencedName}"`
1532
+ `property "${propName}" computed references ` +
1533
+ `missing property "${referencedName}"`
1445
1534
  );
1446
1535
  }
1447
1536
  }
@@ -1450,12 +1539,14 @@ function validateComputedProperty(
1450
1539
  const methodName = match[1];
1451
1540
  if (!classMethods.has(methodName)) {
1452
1541
  findings.invalidComputedProperties.push(
1453
- `property "${propName}" computed calls non-method instance member "${methodName}"`
1542
+ `property "${propName}" computed calls ` +
1543
+ `non-method instance member "${methodName}"`
1454
1544
  );
1455
1545
  }
1456
1546
  }
1457
1547
  }
1458
1548
 
1549
+ // Validates that a default value matches the declared property type.
1459
1550
  function validateDefaultValue(checker, typeExpression, valueExpression) {
1460
1551
  if (!typeExpression || !valueExpression) return undefined;
1461
1552
 
@@ -1510,6 +1601,7 @@ function validateDefaultValue(checker, typeExpression, valueExpression) {
1510
1601
  return undefined;
1511
1602
  }
1512
1603
 
1604
+ // Resolves and validates a user-supplied file path argument.
1513
1605
  function validateFilePath(filePath) {
1514
1606
  const resolved = path.resolve(filePath);
1515
1607
  const ext = path.extname(resolved);
@@ -1522,6 +1614,112 @@ function validateFilePath(filePath) {
1522
1614
  return resolved;
1523
1615
  }
1524
1616
 
1617
+ // Validates the syntax of a form-assoc attribute value.
1618
+ function validateFormAssocAttribute(attrName, attrValue, findings) {
1619
+ if (attrName !== 'form-assoc') return;
1620
+
1621
+ const pairs = attrValue.split(',');
1622
+ for (const pair of pairs) {
1623
+ const trimmed = pair.trim();
1624
+ const [propName, fieldName, ...rest] = trimmed
1625
+ .split(':')
1626
+ .map(part => part.trim());
1627
+ if (!trimmed || rest.length > 0 || !propName || !fieldName) {
1628
+ findings.invalidFormAssocValues.push(
1629
+ `form-assoc="${attrValue}" is invalid; expected ` +
1630
+ '"property:field" or a comma-separated list of them'
1631
+ );
1632
+ return;
1633
+ }
1634
+ }
1635
+ }
1636
+
1637
+ // Validates that form-assoc property names exist on referenced components.
1638
+ function validateFormAssocPropertyMappings(
1639
+ node,
1640
+ attrName,
1641
+ attrValue,
1642
+ findings,
1643
+ componentPropertyMaps
1644
+ ) {
1645
+ if (attrName !== 'form-assoc') return;
1646
+ const tagName = (node.rawTagName || node.tagName || '').toLowerCase();
1647
+ const supportedProps = componentPropertyMaps.get(tagName);
1648
+ if (!supportedProps) return;
1649
+
1650
+ const pairs = attrValue.split(',');
1651
+ for (const pair of pairs) {
1652
+ const [propName] = pair.split(':').map(part => part.trim());
1653
+ if (!propName) continue;
1654
+ if (!supportedProps.has(propName)) {
1655
+ findings.invalidFormAssocValues.push(
1656
+ `form-assoc="${attrValue}" refers to ` +
1657
+ `missing component property "${propName}"`
1658
+ );
1659
+ }
1660
+ }
1661
+ }
1662
+
1663
+ // Validates that an HTML attribute is supported for the current element.
1664
+ function validateHtmlAttribute(node, attrName, findings) {
1665
+ if (attrName.startsWith('aria-') || attrName.startsWith('data-')) return;
1666
+ if (attrName.startsWith('on')) return;
1667
+ if (attrName === 'form-assoc') return;
1668
+ if (attrName === 'ref') return;
1669
+
1670
+ const [baseAttrName] = attrName.split(':');
1671
+ if (HTML_GLOBAL_ATTRIBUTES.has(baseAttrName)) return;
1672
+
1673
+ const tagName = (node.rawTagName || node.tagName || '').toLowerCase();
1674
+ if (!tagName || tagName.includes('-')) return;
1675
+
1676
+ const supported = getSupportedHtmlAttributes(tagName);
1677
+ if (!supported) return;
1678
+ if (supported.has(baseAttrName)) return;
1679
+
1680
+ findings.unsupportedHtmlAttributes.push(
1681
+ `${tagName} attribute "${attrName}" is not supported`
1682
+ );
1683
+ }
1684
+
1685
+ // Validates required parent-child relationships for supported HTML tags.
1686
+ function validateHtmlNesting(node, findings) {
1687
+ const tagName = getHtmlTagName(node);
1688
+ if (!tagName || tagName.includes('-')) return;
1689
+
1690
+ const parentNode = node.parentNode?.nodeType === 1 ? node.parentNode : null;
1691
+ const parentTagName = parentNode ? getHtmlTagName(parentNode) : '';
1692
+ const allowedParents = HTML_ALLOWED_PARENTS.get(tagName);
1693
+ if (
1694
+ allowedParents &&
1695
+ (!parentTagName || !allowedParents.has(parentTagName))
1696
+ ) {
1697
+ const parentDescription = parentTagName
1698
+ ? `<${parentTagName}>`
1699
+ : 'the document root';
1700
+ findings.invalidHtmlNesting.push(
1701
+ `<${tagName}> must be nested inside ${[...allowedParents]
1702
+ .map(name => `<${name}>`)
1703
+ .join(' or ')}, but parent is ${parentDescription}`
1704
+ );
1705
+ }
1706
+
1707
+ const allowedChildren = HTML_ALLOWED_CHILDREN.get(tagName);
1708
+ if (!allowedChildren) return;
1709
+
1710
+ for (const child of node.childNodes ?? []) {
1711
+ if (child.nodeType !== 1) continue;
1712
+ const childTagName = getHtmlTagName(child);
1713
+ if (!childTagName || childTagName.includes('-')) continue;
1714
+ if (allowedChildren.has(childTagName)) continue;
1715
+
1716
+ findings.invalidHtmlNesting.push(
1717
+ `<${childTagName}> is not allowed directly inside <${tagName}>`
1718
+ );
1719
+ }
1720
+ }
1721
+
1722
+ // Validates all configured component property metadata entries.
1525
1723
  function validatePropertyConfigs(
1526
1724
  checker,
1527
1725
  supportedProps,
@@ -1554,7 +1752,8 @@ function validatePropertyConfigs(
1554
1752
  for (const methodName of methods) {
1555
1753
  if (!classMethods.has(methodName)) {
1556
1754
  findings.invalidUsedByReferences.push(
1557
- `property "${propName}" usedBy references missing method "${methodName}"`
1755
+ `property "${propName}" usedBy references ` +
1756
+ `missing method "${methodName}"`
1558
1757
  );
1559
1758
  }
1560
1759
  }
@@ -1592,7 +1791,8 @@ function validatePropertyConfigs(
1592
1791
  !values.includes(valueProp.initializer.text)
1593
1792
  ) {
1594
1793
  findings.invalidDefaultValues.push(
1595
- `property "${propName}" default value "${valueProp.initializer.text}" is not in values`
1794
+ `property "${propName}" default value ` +
1795
+ `"${valueProp.initializer.text}" is not in values`
1596
1796
  );
1597
1797
  }
1598
1798
  }
@@ -1605,124 +1805,16 @@ function validatePropertyConfigs(
1605
1805
  );
1606
1806
  if (mismatch) {
1607
1807
  findings.invalidDefaultValues.push(
1608
- `property "${propName}" default value has type ${mismatch.valueTypeName}, but declared type is ${mismatch.typeName}`
1808
+ `property "${propName}" default value ` +
1809
+ `has type ${mismatch.valueTypeName}, ` +
1810
+ `but declared type is ${mismatch.typeName}`
1609
1811
  );
1610
1812
  }
1611
1813
  }
1612
1814
  }
1613
1815
  }
1614
1816
 
1615
- function typeNodeFromConstructorExpression(expression) {
1616
- if (ts.isIdentifier(expression)) {
1617
- switch (expression.text) {
1618
- case 'String':
1619
- return ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
1620
- case 'Number':
1621
- return ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
1622
- case 'Boolean':
1623
- return ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
1624
- case 'Object':
1625
- return ts.factory.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword);
1626
- default:
1627
- return ts.factory.createTypeReferenceNode(expression.text);
1628
- }
1629
- }
1630
-
1631
- if (
1632
- ts.isCallExpression(expression) &&
1633
- ts.isIdentifier(expression.expression)
1634
- ) {
1635
- const name = expression.expression.text;
1636
- if (name === 'Array' && expression.typeArguments?.length === 1) {
1637
- return ts.factory.createArrayTypeNode(expression.typeArguments[0]);
1638
- }
1639
- }
1640
-
1641
- if (ts.isPropertyAccessExpression(expression)) {
1642
- return ts.factory.createTypeReferenceNode(expression.getText());
1643
- }
1644
-
1645
- return undefined;
1646
- }
1647
-
1648
- function uniquePush(array, value) {
1649
- if (!array.includes(value)) array.push(value);
1650
- }
1651
-
1652
- function validateFormAssocAttribute(attrName, attrValue, findings) {
1653
- if (attrName !== 'form-assoc') return;
1654
-
1655
- const pairs = attrValue.split(',');
1656
- for (const pair of pairs) {
1657
- const trimmed = pair.trim();
1658
- const [propName, fieldName, ...rest] = trimmed
1659
- .split(':')
1660
- .map(part => part.trim());
1661
- if (!trimmed || rest.length > 0 || !propName || !fieldName) {
1662
- findings.invalidFormAssocValues.push(
1663
- `form-assoc="${attrValue}" is invalid; expected "property:field" or a comma-separated list of them`
1664
- );
1665
- return;
1666
- }
1667
- }
1668
- }
1669
-
1670
- function validateFormAssocPropertyMappings(
1671
- node,
1672
- attrName,
1673
- attrValue,
1674
- findings,
1675
- componentPropertyMaps
1676
- ) {
1677
- if (attrName !== 'form-assoc') return;
1678
- const tagName = (node.rawTagName || node.tagName || '').toLowerCase();
1679
- const supportedProps = componentPropertyMaps.get(tagName);
1680
- if (!supportedProps) return;
1681
-
1682
- const pairs = attrValue.split(',');
1683
- for (const pair of pairs) {
1684
- const [propName] = pair.split(':').map(part => part.trim());
1685
- if (!propName) continue;
1686
- if (!supportedProps.has(propName)) {
1687
- findings.invalidFormAssocValues.push(
1688
- `form-assoc="${attrValue}" refers to missing component property "${propName}"`
1689
- );
1690
- }
1691
- }
1692
- }
1693
-
1694
- function validateHtmlAttribute(node, attrName, findings) {
1695
- if (attrName.startsWith('aria-') || attrName.startsWith('data-')) return;
1696
- if (attrName.startsWith('on')) return;
1697
- if (attrName === 'form-assoc') return;
1698
- if (attrName === 'ref') return;
1699
-
1700
- const [baseAttrName] = attrName.split(':');
1701
- if (HTML_GLOBAL_ATTRIBUTES.has(baseAttrName)) return;
1702
-
1703
- const tagName = (node.rawTagName || node.tagName || '').toLowerCase();
1704
- if (!tagName || tagName.includes('-')) return;
1705
-
1706
- const supported = getSupportedHtmlAttributes(tagName);
1707
- if (!supported) return;
1708
- if (supported.has(baseAttrName)) return;
1709
-
1710
- findings.unsupportedHtmlAttributes.push(
1711
- `${tagName} attribute "${attrName}" is not supported`
1712
- );
1713
- }
1714
-
1715
- function validateValueBindingEvent(node, attrName, findings) {
1716
- const [realAttrName, eventName] = attrName.split(':');
1717
- if (realAttrName !== 'value' || !eventName) return;
1718
- if (SUPPORTED_EVENT_NAMES.has(eventName)) return;
1719
-
1720
- const tagName = node.rawTagName || node.tagName || 'element';
1721
- findings.unsupportedEventNames.push(
1722
- `${tagName} attribute "${attrName}" refers to an unsupported event name "${eventName}"`
1723
- );
1724
- }
1725
-
1817
+ // Validates that a ref attribute targets a unique HTMLElement property.
1726
1818
  function validateRefAttribute(
1727
1819
  attrValue,
1728
1820
  supportedProps,
@@ -1744,14 +1836,16 @@ function validateRefAttribute(
1744
1836
 
1745
1837
  if (propInfo.typeText !== 'HTMLElement') {
1746
1838
  findings.invalidRefAttributes.push(
1747
- `ref="${attrValue}" refers to property "${propName}" whose type is not HTMLElement`
1839
+ `ref="${attrValue}" refers to property "${propName}" ` +
1840
+ 'whose type is not HTMLElement'
1748
1841
  );
1749
1842
  return;
1750
1843
  }
1751
1844
 
1752
1845
  if (seenRefProps.has(propName)) {
1753
1846
  findings.invalidRefAttributes.push(
1754
- `ref="${attrValue}" is a duplicate reference to the property "${propName}"`
1847
+ `ref="${attrValue}" is a duplicate reference ` +
1848
+ `to the property "${propName}"`
1755
1849
  );
1756
1850
  return;
1757
1851
  }
@@ -1759,46 +1853,20 @@ function validateRefAttribute(
1759
1853
  seenRefProps.add(propName);
1760
1854
  }
1761
1855
 
1762
- function getHtmlTagName(node) {
1763
- const tagName = node.rawTagName || node.tagName;
1764
- return typeof tagName === 'string' ? tagName.toLowerCase() : '';
1765
- }
1766
-
1767
- function validateHtmlNesting(node, findings) {
1768
- const tagName = getHtmlTagName(node);
1769
- if (!tagName || tagName.includes('-')) return;
1770
-
1771
- const parentNode = node.parentNode?.nodeType === 1 ? node.parentNode : null;
1772
- const parentTagName = parentNode ? getHtmlTagName(parentNode) : '';
1773
- const allowedParents = HTML_ALLOWED_PARENTS.get(tagName);
1774
- if (
1775
- allowedParents &&
1776
- (!parentTagName || !allowedParents.has(parentTagName))
1777
- ) {
1778
- findings.invalidHtmlNesting.push(
1779
- `<${tagName}> must be nested inside ${[...allowedParents]
1780
- .map(name => `<${name}>`)
1781
- .join(
1782
- ' or '
1783
- )}, but parent is ${parentTagName ? `<${parentTagName}>` : 'the document root'}`
1784
- );
1785
- }
1786
-
1787
- const allowedChildren = HTML_ALLOWED_CHILDREN.get(tagName);
1788
- if (!allowedChildren) return;
1789
-
1790
- for (const child of node.childNodes ?? []) {
1791
- if (child.nodeType !== 1) continue;
1792
- const childTagName = getHtmlTagName(child);
1793
- if (!childTagName || childTagName.includes('-')) continue;
1794
- if (allowedChildren.has(childTagName)) continue;
1856
+ // Validates event names used in value-binding attributes.
1857
+ function validateValueBindingEvent(node, attrName, findings) {
1858
+ const [realAttrName, eventName] = attrName.split(':');
1859
+ if (realAttrName !== 'value' || !eventName) return;
1860
+ if (SUPPORTED_EVENT_NAMES.has(eventName)) return;
1795
1861
 
1796
- findings.invalidHtmlNesting.push(
1797
- `<${childTagName}> is not allowed directly inside <${tagName}>`
1798
- );
1799
- }
1862
+ const tagName = node.rawTagName || node.tagName || 'element';
1863
+ findings.unsupportedEventNames.push(
1864
+ `${tagName} attribute "${attrName}" refers to ` +
1865
+ `an unsupported event name "${eventName}"`
1866
+ );
1800
1867
  }
1801
1868
 
1869
+ // Walks parsed HTML nodes to extract expressions and apply HTML validations.
1802
1870
  function walkHtmlNode(
1803
1871
  node,
1804
1872
  expressions,
@@ -19,6 +19,13 @@
19
19
  import fs from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import ts from 'typescript';
22
+ import {
23
+ extendsWrec,
24
+ getNameText,
25
+ getPropertyAssignmentNames,
26
+ getWrecImportInfo,
27
+ hasStaticModifier
28
+ } from './ast-utils.js';
22
29
 
23
30
  const cwd = process.cwd();
24
31
 
@@ -82,12 +89,7 @@ function analyzeSourceFile(sourceFile) {
82
89
  if (!propertiesObject) continue;
83
90
 
84
91
  // 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
- );
92
+ const propertyNames = new Set(getPropertyAssignmentNames(propertiesObject));
91
93
 
92
94
  // Get a map where the keys are property names and
93
95
  // the values are Sets of public methods that use it transitively.
@@ -378,23 +380,6 @@ export function evaluateSourceText(filePath, text) {
378
380
  return analyzeSourceFile(sourceFile);
379
381
  }
380
382
 
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) {
384
- return Boolean(
385
- classNode.heritageClauses?.some(
386
- clause =>
387
- clause.token === ts.SyntaxKind.ExtendsKeyword &&
388
- clause.types.some(
389
- type =>
390
- ts.isExpressionWithTypeArguments(type) &&
391
- ts.isIdentifier(type.expression) &&
392
- wrecNames.has(type.expression.text)
393
- )
394
- )
395
- );
396
- }
397
-
398
383
  // Gets the names of all methods that are called from
399
384
  // JavaScript expressions in the component's static CSS template.
400
385
  // This is only called by getMethodUsages.
@@ -560,14 +545,6 @@ function getMethodUsages(classNode, propertyNames) {
560
545
  return propToMethods;
561
546
  }
562
547
 
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
548
  // Gets the names of all methods that are called
572
549
  // from the component's static HTML template.
573
550
  // This is only called by getMethodUsages.
@@ -637,67 +614,6 @@ function getTransitiveProps(methodInfo, memo, methodName, seen = new Set()) {
637
614
  return props;
638
615
  }
639
616
 
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 = "'";
651
-
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;
663
- }
664
-
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;
669
-
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;
674
-
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);
682
-
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];
687
- }
688
- }
689
- }
690
-
691
- return {names, quote};
692
- }
693
-
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
- );
699
- }
700
-
701
617
  // Determines if a class member represents an instance method.
702
618
  // This is only called by getMethodUsages.
703
619
  function isInstanceMethodMember(member) {
@@ -722,6 +638,13 @@ function isSupportedSourceFile(filePath, excludeTests = false) {
722
638
  // Handles CLI arguments and runs the script.
723
639
  function main() {
724
640
  const args = process.argv.slice(2);
641
+ const unknownFlags = args.filter(
642
+ arg => arg.startsWith('--') && arg !== '--dry'
643
+ );
644
+ if (unknownFlags.length > 0) {
645
+ throw new Error(`unknown option: ${unknownFlags[0]}`);
646
+ }
647
+
725
648
  const inputPaths = args.filter(arg => !arg.startsWith('--'));
726
649
 
727
650
  if (inputPaths.length !== 1) {