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.
package/scripts/lint.js CHANGED
@@ -31,9 +31,14 @@
31
31
 
32
32
  import fs from 'node:fs';
33
33
  import path from 'node:path';
34
- import {fileURLToPath} from 'node:url';
35
34
  import ts from 'typescript';
36
35
  import {parse} from 'node-html-parser';
36
+ import {
37
+ collectWrecClasses,
38
+ getMemberName,
39
+ getPropertyAssignmentNames,
40
+ hasStaticModifier
41
+ } from './ast-utils.js';
37
42
 
38
43
  const CSS_PROPERTY_RE = /([a-zA-Z-]+)\s*:\s*([^;}]+)/g;
39
44
  const REFS_TEST_RE = /this\.[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)*/;
@@ -133,6 +138,8 @@ const SUPPORTED_EVENT_NAMES = new Set([
133
138
  const WREC_REF_NAME = '__wrec';
134
139
  const componentPropertyCache = new Map();
135
140
 
141
+ // Analyzes an expression for invalid property access,
142
+ // method calls, and arithmetic usage.
136
143
  function analyzeExpression(
137
144
  expressionNode,
138
145
  checker,
@@ -149,6 +156,7 @@ function analyzeExpression(
149
156
  }
150
157
  }
151
158
 
159
+ // Walks the expression tree and records any issues that are found.
152
160
  function visit(node) {
153
161
  if (ts.isPropertyAccessExpression(node) && isWrecRooted(node.expression)) {
154
162
  const ownerType = checker.getTypeAtLocation(node.expression);
@@ -252,14 +260,20 @@ function analyzeExpression(
252
260
  if (!isNumericLikeType(leftType)) {
253
261
  findings.typeErrors.push({
254
262
  expression: toUserFacingExpression(node.getText()),
255
- 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'
256
267
  });
257
268
  }
258
269
 
259
270
  if (!isNumericLikeType(rightType)) {
260
271
  findings.typeErrors.push({
261
272
  expression: toUserFacingExpression(node.getText()),
262
- 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'
263
277
  });
264
278
  }
265
279
  }
@@ -270,6 +284,12 @@ function analyzeExpression(
270
284
  visit(expressionNode);
271
285
  }
272
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.
273
293
  function buildAugmentedSource(
274
294
  sourceFile,
275
295
  classNode,
@@ -310,6 +330,7 @@ ${propLines.join('\n')}
310
330
  return `${sourceFile.text}\n${propInterface}\n${helperBlocks.join('\n')}`;
311
331
  }
312
332
 
333
+ // Collects all instance method and accessor names defined in a component class.
313
334
  function collectClassMethods(classNode) {
314
335
  const methods = new Set();
315
336
  for (const member of classNode.members) {
@@ -326,9 +347,16 @@ function collectClassMethods(classNode) {
326
347
  return methods;
327
348
  }
328
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.
329
355
  function collectHelperExpressions(augmentedSourceFile) {
330
356
  const helpers = [];
331
357
 
358
+ // Finds generated helper functions and
359
+ // stores their return expressions by index.
332
360
  function visit(node) {
333
361
  if (
334
362
  ts.isFunctionDeclaration(node) &&
@@ -350,35 +378,13 @@ function collectHelperExpressions(augmentedSourceFile) {
350
378
  return helpers;
351
379
  }
352
380
 
353
- function collectWrecClasses(sourceFile) {
354
- const classes = [];
355
-
356
- function visit(node) {
357
- if (ts.isClassDeclaration(node) && node.name) {
358
- const heritage = node.heritageClauses?.find(
359
- clause => clause.token === ts.SyntaxKind.ExtendsKeyword
360
- );
361
- const typeNode = heritage?.types[0];
362
- if (
363
- typeNode &&
364
- ts.isIdentifier(typeNode.expression) &&
365
- typeNode.expression.text === 'Wrec'
366
- ) {
367
- classes.push(node);
368
- }
369
- }
370
- ts.forEachChild(node, visit);
371
- }
372
-
373
- visit(sourceFile);
374
- return classes;
375
- }
376
-
381
+ // Collects the property names declared in
382
+ // a component's static properties object.
377
383
  function collectSupportedPropertyNames(classNode) {
378
384
  const supportedProps = new Set();
379
385
 
380
386
  for (const member of classNode.members) {
381
- if (!isStaticMember(member)) continue;
387
+ if (!hasStaticModifier(member)) continue;
382
388
  if (!ts.isPropertyDeclaration(member)) continue;
383
389
 
384
390
  const name = getMemberName(member);
@@ -390,38 +396,17 @@ function collectSupportedPropertyNames(classNode) {
390
396
  continue;
391
397
  }
392
398
 
393
- for (const property of member.initializer.properties) {
394
- if (!ts.isPropertyAssignment(property)) continue;
395
- const propName = getMemberName(property);
396
- if (propName) supportedProps.add(propName);
399
+ for (const propName of getPropertyAssignmentNames(member.initializer)) {
400
+ supportedProps.add(propName);
397
401
  }
398
402
  }
399
403
 
400
404
  return supportedProps;
401
405
  }
402
406
 
403
- function findDefinedTagNames(sourceFile) {
404
- const tagNames = new Map();
405
-
406
- function visit(node) {
407
- if (
408
- ts.isCallExpression(node) &&
409
- ts.isPropertyAccessExpression(node.expression) &&
410
- node.expression.name.text === 'define' &&
411
- ts.isIdentifier(node.expression.expression) &&
412
- node.arguments.length > 0 &&
413
- ts.isStringLiteral(node.arguments[0])
414
- ) {
415
- tagNames.set(node.expression.expression.text, node.arguments[0].text);
416
- }
417
- ts.forEachChild(node, visit);
418
- }
419
-
420
- visit(sourceFile);
421
- return tagNames;
422
- }
423
-
407
+ // Validates that useState mappings point at existing component properties.
424
408
  function collectUseStateMapErrors(classNode, supportedProps, findings) {
409
+ // Walks the class body looking for useState calls with mapping objects.
425
410
  function visit(node) {
426
411
  if (
427
412
  ts.isCallExpression(node) &&
@@ -445,7 +430,8 @@ function collectUseStateMapErrors(classNode, supportedProps, findings) {
445
430
  const componentProp = property.initializer.text;
446
431
  if (!supportedProps.has(componentProp)) {
447
432
  findings.invalidUseStateMaps.push(
448
- `useState maps state property "${statePath}" to missing component property "${componentProp}"`
433
+ `useState maps state property "${statePath}" to ` +
434
+ `missing component property "${componentProp}"`
449
435
  );
450
436
  }
451
437
  }
@@ -458,6 +444,7 @@ function collectUseStateMapErrors(classNode, supportedProps, findings) {
458
444
  visit(classNode);
459
445
  }
460
446
 
447
+ // Creates a TypeScript program that can type-check the given source text.
461
448
  function createProgram(filePath, sourceText) {
462
449
  const defaultHost = ts.createCompilerHost({}, true);
463
450
  const compilerOptions = {
@@ -514,6 +501,8 @@ function createProgram(filePath, sourceText) {
514
501
  return ts.createProgram([filePath], compilerOptions, host);
515
502
  }
516
503
 
504
+ // Extracts component property metadata and related validation inputs
505
+ // from a class.
517
506
  function extractProperties(sourceFile, checker, classNode) {
518
507
  const duplicateProperties = [];
519
508
  let formAssociated = false;
@@ -524,7 +513,7 @@ function extractProperties(sourceFile, checker, classNode) {
524
513
  let contextKeys = [];
525
514
 
526
515
  for (const member of classNode.members) {
527
- if (!isStaticMember(member)) continue;
516
+ if (!hasStaticModifier(member)) continue;
528
517
  if (!ts.isPropertyDeclaration(member)) continue;
529
518
 
530
519
  const name = getMemberName(member);
@@ -621,6 +610,7 @@ function extractProperties(sourceFile, checker, classNode) {
621
610
  };
622
611
  }
623
612
 
613
+ // Extracts analyzable expressions from static html and css templates.
624
614
  function extractTemplateExpressions(
625
615
  classNode,
626
616
  findings,
@@ -630,7 +620,7 @@ function extractTemplateExpressions(
630
620
  const expressions = [];
631
621
 
632
622
  for (const member of classNode.members) {
633
- if (!isStaticMember(member)) continue;
623
+ if (!hasStaticModifier(member)) continue;
634
624
  if (!ts.isPropertyDeclaration(member)) continue;
635
625
 
636
626
  const name = getMemberName(member);
@@ -697,11 +687,37 @@ function extractTemplateExpressions(
697
687
  return expressions;
698
688
  }
699
689
 
690
+ // Prints an error message and exits the process.
700
691
  function fail(message) {
701
692
  console.error(message);
702
693
  process.exit(1);
703
694
  }
704
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.
705
721
  function findWrecFiles(rootDir, onMatch) {
706
722
  const walk = currentDir => {
707
723
  const entries = fs
@@ -725,6 +741,13 @@ function findWrecFiles(rootDir, onMatch) {
725
741
  walk(rootDir);
726
742
  }
727
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.
728
751
  function formatReport(
729
752
  filePath,
730
753
  supportedProps,
@@ -892,7 +915,9 @@ function formatReport(
892
915
  lines.push('extra arguments:');
893
916
  findings.extraArguments.forEach(finding => {
894
917
  lines.push(
895
- ` ${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`
896
921
  );
897
922
  });
898
923
  }
@@ -901,7 +926,9 @@ function formatReport(
901
926
  lines.push('incompatible arguments:');
902
927
  findings.incompatibleArguments.forEach(finding => {
903
928
  lines.push(
904
- ` ${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}`
905
932
  );
906
933
  });
907
934
  }
@@ -932,6 +959,8 @@ function formatReport(
932
959
  return `${lines.join('\n')}\n`;
933
960
  }
934
961
 
962
+ // Builds a map from tag names to supported properties
963
+ // for the current file and imports.
935
964
  function getComponentPropertyMaps(filePath, sourceText, seen = new Set()) {
936
965
  const resolved = path.resolve(filePath);
937
966
  if (componentPropertyCache.has(resolved)) {
@@ -986,22 +1015,18 @@ function getComponentPropertyMaps(filePath, sourceText, seen = new Set()) {
986
1015
  return propertyMaps;
987
1016
  }
988
1017
 
989
- function formatLocation(location) {
990
- if (!location) return '';
991
- return `:${location.line + 1}:${location.character + 1}`;
992
- }
993
-
1018
+ // Returns trimmed source text for a TypeScript expression node.
994
1019
  function getExpressionText(sourceFile, expression) {
995
1020
  return expression.getText(sourceFile).trim();
996
1021
  }
997
1022
 
998
- function getMemberName(node) {
999
- const {name} = node;
1000
- if (!name) return undefined;
1001
- if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
1002
- 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() : '';
1003
1027
  }
1004
1028
 
1029
+ // Gets an object-literal property with the given key.
1005
1030
  function getObjectProperty(objectLiteral, key) {
1006
1031
  for (const property of objectLiteral.properties) {
1007
1032
  if (
@@ -1015,31 +1040,8 @@ function getObjectProperty(objectLiteral, key) {
1015
1040
  }
1016
1041
  }
1017
1042
 
1018
- function getStringArrayLiteral(property) {
1019
- if (!property || !ts.isPropertyAssignment(property)) return undefined;
1020
- if (!ts.isArrayLiteralExpression(property.initializer)) return undefined;
1021
-
1022
- const values = [];
1023
- for (const element of property.initializer.elements) {
1024
- if (
1025
- !ts.isStringLiteral(element) &&
1026
- !ts.isNoSubstitutionTemplateLiteral(element)
1027
- ) {
1028
- return undefined;
1029
- }
1030
- values.push(element.text);
1031
- }
1032
- return values;
1033
- }
1034
-
1035
- function getPropertyTypeText(checker, sourceFile, expression) {
1036
- const typeText = getTypeSyntaxText(sourceFile, expression);
1037
- if (typeText) return typeText;
1038
-
1039
- const type = checker.getTypeAtLocation(expression);
1040
- return checker.typeToString(type);
1041
- }
1042
-
1043
+ // Resolves the effective parameter type,
1044
+ // including element types for rest parameters.
1043
1045
  function getParameterType(checker, parameterSymbol, location, isRestArgument) {
1044
1046
  const parameterType = checker.getTypeOfSymbolAtLocation(
1045
1047
  parameterSymbol,
@@ -1057,6 +1059,34 @@ function getParameterType(checker, parameterSymbol, location, isRestArgument) {
1057
1059
  return typeArguments[0] ?? parameterType;
1058
1060
  }
1059
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.
1060
1090
  function getStringOrStringArrayLiteral(property) {
1061
1091
  if (!property || !ts.isPropertyAssignment(property)) return undefined;
1062
1092
 
@@ -1070,10 +1100,13 @@ function getStringOrStringArrayLiteral(property) {
1070
1100
  return getStringArrayLiteral(property);
1071
1101
  }
1072
1102
 
1103
+ // Gets the supported HTML attributes for a given tag.
1073
1104
  function getSupportedHtmlAttributes(tagName) {
1074
1105
  return HTML_TAG_ATTRIBUTES.get(tagName.toLowerCase());
1075
1106
  }
1076
1107
 
1108
+ // Reconstructs the text of a template literal
1109
+ // with placeholders for expressions.
1077
1110
  function getTemplateLiteralText(template) {
1078
1111
  if (ts.isNoSubstitutionTemplateLiteral(template)) return template.text;
1079
1112
 
@@ -1085,6 +1118,8 @@ function getTemplateLiteralText(template) {
1085
1118
  return text;
1086
1119
  }
1087
1120
 
1121
+ // Converts a property type expression into a user-facing type string
1122
+ // when possible.
1088
1123
  function getTypeSyntaxText(sourceFile, expression) {
1089
1124
  const text = expression.getText(sourceFile).trim();
1090
1125
  const arrayMatch = text.match(/^Array<(.+)>$/s);
@@ -1126,15 +1161,7 @@ function getTypeSyntaxText(sourceFile, expression) {
1126
1161
  return undefined;
1127
1162
  }
1128
1163
 
1129
- function isImportLikeDeclaration(node) {
1130
- return (
1131
- ts.isImportClause(node) ||
1132
- ts.isImportEqualsDeclaration(node) ||
1133
- ts.isImportSpecifier(node) ||
1134
- ts.isNamespaceImport(node)
1135
- );
1136
- }
1137
-
1164
+ // Returns whether a token kind is one of the supported arithmetic operators.
1138
1165
  function isArithmeticOperator(kind) {
1139
1166
  return (
1140
1167
  kind === ts.SyntaxKind.AsteriskToken ||
@@ -1145,10 +1172,22 @@ function isArithmeticOperator(kind) {
1145
1172
  );
1146
1173
  }
1147
1174
 
1175
+ // Returns whether a property access node is being called as a callee.
1148
1176
  function isCallCallee(node) {
1149
1177
  return ts.isCallExpression(node.parent) && node.parent.expression === node;
1150
1178
  }
1151
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.
1152
1191
  function isNumericLikeType(type) {
1153
1192
  const parts = type.isUnion() ? type.types : [type];
1154
1193
  return parts.every(part => {
@@ -1164,6 +1203,7 @@ function isNumericLikeType(type) {
1164
1203
  });
1165
1204
  }
1166
1205
 
1206
+ // Returns whether a file defines at least one Wrec component class.
1167
1207
  function isWrecComponentFile(filePath) {
1168
1208
  const sourceText = fs.readFileSync(filePath, 'utf8');
1169
1209
  const scriptKind = filePath.endsWith('.ts')
@@ -1179,14 +1219,7 @@ function isWrecComponentFile(filePath) {
1179
1219
  return collectWrecClasses(sourceFile).length > 0;
1180
1220
  }
1181
1221
 
1182
- function isStaticMember(node) {
1183
- return ts.canHaveModifiers(node)
1184
- ? ts
1185
- .getModifiers(node)
1186
- ?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword)
1187
- : false;
1188
- }
1189
-
1222
+ // Returns whether an expression is rooted at the synthetic Wrec receiver.
1190
1223
  function isWrecRooted(expression) {
1191
1224
  if (ts.isIdentifier(expression) && expression.text === WREC_REF_NAME) {
1192
1225
  return true;
@@ -1200,28 +1233,13 @@ function isWrecRooted(expression) {
1200
1233
  return false;
1201
1234
  }
1202
1235
 
1203
- function requiresContextFunction(symbol, sourceFile) {
1204
- const declarations = symbol.declarations ?? [];
1205
- return declarations.some(declaration => {
1206
- if (isImportLikeDeclaration(declaration)) return true;
1207
- return declaration.getSourceFile() === sourceFile;
1208
- });
1209
- }
1210
-
1211
- function resolveImportPath(baseDir, importPath) {
1212
- if (!importPath.startsWith('.')) return undefined;
1213
-
1214
- const candidates = [
1215
- path.resolve(baseDir, importPath),
1216
- path.resolve(baseDir, `${importPath}.js`),
1217
- path.resolve(baseDir, `${importPath}.ts`),
1218
- path.resolve(baseDir, importPath, 'index.js'),
1219
- path.resolve(baseDir, importPath, 'index.ts')
1220
- ];
1221
-
1222
- 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);
1223
1240
  }
1224
1241
 
1242
+ // Lints provided component source text and returns a formatted report.
1225
1243
  export function lintSource(filePath, sourceText, options = {}) {
1226
1244
  const baseProgram = createProgram(filePath, sourceText);
1227
1245
  const sourceFile = baseProgram.getSourceFile(filePath);
@@ -1375,11 +1393,7 @@ export function lintSource(filePath, sourceText, options = {}) {
1375
1393
  );
1376
1394
  }
1377
1395
 
1378
- export function lintFile(filePath, options = {}) {
1379
- const resolved = validateFilePath(filePath);
1380
- return lintSource(resolved, fs.readFileSync(resolved, 'utf8'), options);
1381
- }
1382
-
1396
+ // Runs the command-line interface for the linter.
1383
1397
  function main() {
1384
1398
  const args = process.argv.slice(2);
1385
1399
  const verbose = args.includes('--verbose');
@@ -1418,16 +1432,82 @@ function main() {
1418
1432
  });
1419
1433
  }
1420
1434
 
1435
+ // Determines whether a symbol should be treated as a required context function.
1436
+ function requiresContextFunction(symbol, sourceFile) {
1437
+ const declarations = symbol.declarations ?? [];
1438
+ return declarations.some(declaration => {
1439
+ if (isImportLikeDeclaration(declaration)) return true;
1440
+ return declaration.getSourceFile() === sourceFile;
1441
+ });
1442
+ }
1443
+
1444
+ // Resolves a relative import path to an existing source file.
1445
+ function resolveImportPath(baseDir, importPath) {
1446
+ if (!importPath.startsWith('.')) return undefined;
1447
+
1448
+ const candidates = [
1449
+ path.resolve(baseDir, importPath),
1450
+ path.resolve(baseDir, `${importPath}.js`),
1451
+ path.resolve(baseDir, `${importPath}.ts`),
1452
+ path.resolve(baseDir, importPath, 'index.js'),
1453
+ path.resolve(baseDir, importPath, 'index.ts')
1454
+ ];
1455
+
1456
+ return candidates.find(candidate => fs.existsSync(candidate));
1457
+ }
1458
+
1459
+ // Rewrites synthetic receiver references back to this for reporting.
1421
1460
  function toUserFacingExpression(text) {
1422
1461
  return text.replaceAll(WREC_REF_NAME, 'this');
1423
1462
  }
1424
1463
 
1464
+ // Classifies a constructor-based type expression by its identifier name.
1425
1465
  function typeExpressionKind(expression) {
1426
1466
  if (!expression) return undefined;
1427
1467
  if (ts.isIdentifier(expression)) return expression.text;
1428
1468
  return undefined;
1429
1469
  }
1430
1470
 
1471
+ // Converts a constructor-style type expression into a TypeScript type node.
1472
+ function typeNodeFromConstructorExpression(expression) {
1473
+ if (ts.isIdentifier(expression)) {
1474
+ switch (expression.text) {
1475
+ case 'String':
1476
+ return ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
1477
+ case 'Number':
1478
+ return ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
1479
+ case 'Boolean':
1480
+ return ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
1481
+ case 'Object':
1482
+ return ts.factory.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword);
1483
+ default:
1484
+ return ts.factory.createTypeReferenceNode(expression.text);
1485
+ }
1486
+ }
1487
+
1488
+ if (
1489
+ ts.isCallExpression(expression) &&
1490
+ ts.isIdentifier(expression.expression)
1491
+ ) {
1492
+ const name = expression.expression.text;
1493
+ if (name === 'Array' && expression.typeArguments?.length === 1) {
1494
+ return ts.factory.createArrayTypeNode(expression.typeArguments[0]);
1495
+ }
1496
+ }
1497
+
1498
+ if (ts.isPropertyAccessExpression(expression)) {
1499
+ return ts.factory.createTypeReferenceNode(expression.getText());
1500
+ }
1501
+
1502
+ return undefined;
1503
+ }
1504
+
1505
+ // Pushes a value into an array only if it is not already present.
1506
+ function uniquePush(array, value) {
1507
+ if (!array.includes(value)) array.push(value);
1508
+ }
1509
+
1510
+ // Validates computed property references and method calls.
1431
1511
  function validateComputedProperty(
1432
1512
  propName,
1433
1513
  computedText,
@@ -1442,7 +1522,8 @@ function validateComputedProperty(
1442
1522
  !classMethods.has(referencedName)
1443
1523
  ) {
1444
1524
  findings.invalidComputedProperties.push(
1445
- `property "${propName}" computed references missing property "${referencedName}"`
1525
+ `property "${propName}" computed references ` +
1526
+ `missing property "${referencedName}"`
1446
1527
  );
1447
1528
  }
1448
1529
  }
@@ -1451,12 +1532,14 @@ function validateComputedProperty(
1451
1532
  const methodName = match[1];
1452
1533
  if (!classMethods.has(methodName)) {
1453
1534
  findings.invalidComputedProperties.push(
1454
- `property "${propName}" computed calls non-method instance member "${methodName}"`
1535
+ `property "${propName}" computed calls ` +
1536
+ `non-method instance member "${methodName}"`
1455
1537
  );
1456
1538
  }
1457
1539
  }
1458
1540
  }
1459
1541
 
1542
+ // Validates that a default value matches the declared property type.
1460
1543
  function validateDefaultValue(checker, typeExpression, valueExpression) {
1461
1544
  if (!typeExpression || !valueExpression) return undefined;
1462
1545
 
@@ -1511,6 +1594,7 @@ function validateDefaultValue(checker, typeExpression, valueExpression) {
1511
1594
  return undefined;
1512
1595
  }
1513
1596
 
1597
+ // Resolves and validates a user-supplied file path argument.
1514
1598
  function validateFilePath(filePath) {
1515
1599
  const resolved = path.resolve(filePath);
1516
1600
  const ext = path.extname(resolved);
@@ -1523,6 +1607,112 @@ function validateFilePath(filePath) {
1523
1607
  return resolved;
1524
1608
  }
1525
1609
 
1610
+ // Validates the syntax of a form-assoc attribute value.
1611
+ function validateFormAssocAttribute(attrName, attrValue, findings) {
1612
+ if (attrName !== 'form-assoc') return;
1613
+
1614
+ const pairs = attrValue.split(',');
1615
+ for (const pair of pairs) {
1616
+ const trimmed = pair.trim();
1617
+ const [propName, fieldName, ...rest] = trimmed
1618
+ .split(':')
1619
+ .map(part => part.trim());
1620
+ if (!trimmed || rest.length > 0 || !propName || !fieldName) {
1621
+ findings.invalidFormAssocValues.push(
1622
+ `form-assoc="${attrValue}" is invalid; expected ` +
1623
+ '"property:field" or a comma-separated list of them'
1624
+ );
1625
+ return;
1626
+ }
1627
+ }
1628
+ }
1629
+
1630
+ // Validates that form-assoc property names exist on referenced components.
1631
+ function validateFormAssocPropertyMappings(
1632
+ node,
1633
+ attrName,
1634
+ attrValue,
1635
+ findings,
1636
+ componentPropertyMaps
1637
+ ) {
1638
+ if (attrName !== 'form-assoc') return;
1639
+ const tagName = (node.rawTagName || node.tagName || '').toLowerCase();
1640
+ const supportedProps = componentPropertyMaps.get(tagName);
1641
+ if (!supportedProps) return;
1642
+
1643
+ const pairs = attrValue.split(',');
1644
+ for (const pair of pairs) {
1645
+ const [propName] = pair.split(':').map(part => part.trim());
1646
+ if (!propName) continue;
1647
+ if (!supportedProps.has(propName)) {
1648
+ findings.invalidFormAssocValues.push(
1649
+ `form-assoc="${attrValue}" refers to ` +
1650
+ `missing component property "${propName}"`
1651
+ );
1652
+ }
1653
+ }
1654
+ }
1655
+
1656
+ // Validates that an HTML attribute is supported for the current element.
1657
+ function validateHtmlAttribute(node, attrName, findings) {
1658
+ if (attrName.startsWith('aria-') || attrName.startsWith('data-')) return;
1659
+ if (attrName.startsWith('on')) return;
1660
+ if (attrName === 'form-assoc') return;
1661
+ if (attrName === 'ref') return;
1662
+
1663
+ const [baseAttrName] = attrName.split(':');
1664
+ if (HTML_GLOBAL_ATTRIBUTES.has(baseAttrName)) return;
1665
+
1666
+ const tagName = (node.rawTagName || node.tagName || '').toLowerCase();
1667
+ if (!tagName || tagName.includes('-')) return;
1668
+
1669
+ const supported = getSupportedHtmlAttributes(tagName);
1670
+ if (!supported) return;
1671
+ if (supported.has(baseAttrName)) return;
1672
+
1673
+ findings.unsupportedHtmlAttributes.push(
1674
+ `${tagName} attribute "${attrName}" is not supported`
1675
+ );
1676
+ }
1677
+
1678
+ // Validates required parent-child relationships for supported HTML tags.
1679
+ function validateHtmlNesting(node, findings) {
1680
+ const tagName = getHtmlTagName(node);
1681
+ if (!tagName || tagName.includes('-')) return;
1682
+
1683
+ const parentNode = node.parentNode?.nodeType === 1 ? node.parentNode : null;
1684
+ const parentTagName = parentNode ? getHtmlTagName(parentNode) : '';
1685
+ const allowedParents = HTML_ALLOWED_PARENTS.get(tagName);
1686
+ if (
1687
+ allowedParents &&
1688
+ (!parentTagName || !allowedParents.has(parentTagName))
1689
+ ) {
1690
+ const parentDescription = parentTagName
1691
+ ? `<${parentTagName}>`
1692
+ : 'the document root';
1693
+ findings.invalidHtmlNesting.push(
1694
+ `<${tagName}> must be nested inside ${[...allowedParents]
1695
+ .map(name => `<${name}>`)
1696
+ .join(' or ')}, but parent is ${parentDescription}`
1697
+ );
1698
+ }
1699
+
1700
+ const allowedChildren = HTML_ALLOWED_CHILDREN.get(tagName);
1701
+ if (!allowedChildren) return;
1702
+
1703
+ for (const child of node.childNodes ?? []) {
1704
+ if (child.nodeType !== 1) continue;
1705
+ const childTagName = getHtmlTagName(child);
1706
+ if (!childTagName || childTagName.includes('-')) continue;
1707
+ if (allowedChildren.has(childTagName)) continue;
1708
+
1709
+ findings.invalidHtmlNesting.push(
1710
+ `<${childTagName}> is not allowed directly inside <${tagName}>`
1711
+ );
1712
+ }
1713
+ }
1714
+
1715
+ // Validates all configured component property metadata entries.
1526
1716
  function validatePropertyConfigs(
1527
1717
  checker,
1528
1718
  supportedProps,
@@ -1555,7 +1745,8 @@ function validatePropertyConfigs(
1555
1745
  for (const methodName of methods) {
1556
1746
  if (!classMethods.has(methodName)) {
1557
1747
  findings.invalidUsedByReferences.push(
1558
- `property "${propName}" usedBy references missing method "${methodName}"`
1748
+ `property "${propName}" usedBy references ` +
1749
+ `missing method "${methodName}"`
1559
1750
  );
1560
1751
  }
1561
1752
  }
@@ -1593,7 +1784,8 @@ function validatePropertyConfigs(
1593
1784
  !values.includes(valueProp.initializer.text)
1594
1785
  ) {
1595
1786
  findings.invalidDefaultValues.push(
1596
- `property "${propName}" default value "${valueProp.initializer.text}" is not in values`
1787
+ `property "${propName}" default value ` +
1788
+ `"${valueProp.initializer.text}" is not in values`
1597
1789
  );
1598
1790
  }
1599
1791
  }
@@ -1606,124 +1798,16 @@ function validatePropertyConfigs(
1606
1798
  );
1607
1799
  if (mismatch) {
1608
1800
  findings.invalidDefaultValues.push(
1609
- `property "${propName}" default value has type ${mismatch.valueTypeName}, but declared type is ${mismatch.typeName}`
1801
+ `property "${propName}" default value ` +
1802
+ `has type ${mismatch.valueTypeName}, ` +
1803
+ `but declared type is ${mismatch.typeName}`
1610
1804
  );
1611
1805
  }
1612
1806
  }
1613
1807
  }
1614
1808
  }
1615
1809
 
1616
- function typeNodeFromConstructorExpression(expression) {
1617
- if (ts.isIdentifier(expression)) {
1618
- switch (expression.text) {
1619
- case 'String':
1620
- return ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
1621
- case 'Number':
1622
- return ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
1623
- case 'Boolean':
1624
- return ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
1625
- case 'Object':
1626
- return ts.factory.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword);
1627
- default:
1628
- return ts.factory.createTypeReferenceNode(expression.text);
1629
- }
1630
- }
1631
-
1632
- if (
1633
- ts.isCallExpression(expression) &&
1634
- ts.isIdentifier(expression.expression)
1635
- ) {
1636
- const name = expression.expression.text;
1637
- if (name === 'Array' && expression.typeArguments?.length === 1) {
1638
- return ts.factory.createArrayTypeNode(expression.typeArguments[0]);
1639
- }
1640
- }
1641
-
1642
- if (ts.isPropertyAccessExpression(expression)) {
1643
- return ts.factory.createTypeReferenceNode(expression.getText());
1644
- }
1645
-
1646
- return undefined;
1647
- }
1648
-
1649
- function uniquePush(array, value) {
1650
- if (!array.includes(value)) array.push(value);
1651
- }
1652
-
1653
- function validateFormAssocAttribute(attrName, attrValue, findings) {
1654
- if (attrName !== 'form-assoc') return;
1655
-
1656
- const pairs = attrValue.split(',');
1657
- for (const pair of pairs) {
1658
- const trimmed = pair.trim();
1659
- const [propName, fieldName, ...rest] = trimmed
1660
- .split(':')
1661
- .map(part => part.trim());
1662
- if (!trimmed || rest.length > 0 || !propName || !fieldName) {
1663
- findings.invalidFormAssocValues.push(
1664
- `form-assoc="${attrValue}" is invalid; expected "property:field" or a comma-separated list of them`
1665
- );
1666
- return;
1667
- }
1668
- }
1669
- }
1670
-
1671
- function validateFormAssocPropertyMappings(
1672
- node,
1673
- attrName,
1674
- attrValue,
1675
- findings,
1676
- componentPropertyMaps
1677
- ) {
1678
- if (attrName !== 'form-assoc') return;
1679
- const tagName = (node.rawTagName || node.tagName || '').toLowerCase();
1680
- const supportedProps = componentPropertyMaps.get(tagName);
1681
- if (!supportedProps) return;
1682
-
1683
- const pairs = attrValue.split(',');
1684
- for (const pair of pairs) {
1685
- const [propName] = pair.split(':').map(part => part.trim());
1686
- if (!propName) continue;
1687
- if (!supportedProps.has(propName)) {
1688
- findings.invalidFormAssocValues.push(
1689
- `form-assoc="${attrValue}" refers to missing component property "${propName}"`
1690
- );
1691
- }
1692
- }
1693
- }
1694
-
1695
- function validateHtmlAttribute(node, attrName, findings) {
1696
- if (attrName.startsWith('aria-') || attrName.startsWith('data-')) return;
1697
- if (attrName.startsWith('on')) return;
1698
- if (attrName === 'form-assoc') return;
1699
- if (attrName === 'ref') return;
1700
-
1701
- const [baseAttrName] = attrName.split(':');
1702
- if (HTML_GLOBAL_ATTRIBUTES.has(baseAttrName)) return;
1703
-
1704
- const tagName = (node.rawTagName || node.tagName || '').toLowerCase();
1705
- if (!tagName || tagName.includes('-')) return;
1706
-
1707
- const supported = getSupportedHtmlAttributes(tagName);
1708
- if (!supported) return;
1709
- if (supported.has(baseAttrName)) return;
1710
-
1711
- findings.unsupportedHtmlAttributes.push(
1712
- `${tagName} attribute "${attrName}" is not supported`
1713
- );
1714
- }
1715
-
1716
- function validateValueBindingEvent(node, attrName, findings) {
1717
- const [realAttrName, eventName] = attrName.split(':');
1718
- if (realAttrName !== 'value' || !eventName) return;
1719
- if (SUPPORTED_EVENT_NAMES.has(eventName)) return;
1720
-
1721
- const tagName = node.rawTagName || node.tagName || 'element';
1722
- findings.unsupportedEventNames.push(
1723
- `${tagName} attribute "${attrName}" refers to an unsupported event name "${eventName}"`
1724
- );
1725
- }
1726
-
1810
+ // Validates that a ref attribute targets a unique HTMLElement property.
1727
1811
  function validateRefAttribute(
1728
1812
  attrValue,
1729
1813
  supportedProps,
@@ -1745,14 +1829,16 @@ function validateRefAttribute(
1745
1829
 
1746
1830
  if (propInfo.typeText !== 'HTMLElement') {
1747
1831
  findings.invalidRefAttributes.push(
1748
- `ref="${attrValue}" refers to property "${propName}" whose type is not HTMLElement`
1832
+ `ref="${attrValue}" refers to property "${propName}" ` +
1833
+ 'whose type is not HTMLElement'
1749
1834
  );
1750
1835
  return;
1751
1836
  }
1752
1837
 
1753
1838
  if (seenRefProps.has(propName)) {
1754
1839
  findings.invalidRefAttributes.push(
1755
- `ref="${attrValue}" is a duplicate reference to the property "${propName}"`
1840
+ `ref="${attrValue}" is a duplicate reference ` +
1841
+ `to the property "${propName}"`
1756
1842
  );
1757
1843
  return;
1758
1844
  }
@@ -1760,46 +1846,20 @@ function validateRefAttribute(
1760
1846
  seenRefProps.add(propName);
1761
1847
  }
1762
1848
 
1763
- function getHtmlTagName(node) {
1764
- const tagName = node.rawTagName || node.tagName;
1765
- return typeof tagName === 'string' ? tagName.toLowerCase() : '';
1766
- }
1767
-
1768
- function validateHtmlNesting(node, findings) {
1769
- const tagName = getHtmlTagName(node);
1770
- if (!tagName || tagName.includes('-')) return;
1771
-
1772
- const parentNode = node.parentNode?.nodeType === 1 ? node.parentNode : null;
1773
- const parentTagName = parentNode ? getHtmlTagName(parentNode) : '';
1774
- const allowedParents = HTML_ALLOWED_PARENTS.get(tagName);
1775
- if (
1776
- allowedParents &&
1777
- (!parentTagName || !allowedParents.has(parentTagName))
1778
- ) {
1779
- findings.invalidHtmlNesting.push(
1780
- `<${tagName}> must be nested inside ${[...allowedParents]
1781
- .map(name => `<${name}>`)
1782
- .join(
1783
- ' or '
1784
- )}, but parent is ${parentTagName ? `<${parentTagName}>` : 'the document root'}`
1785
- );
1786
- }
1787
-
1788
- const allowedChildren = HTML_ALLOWED_CHILDREN.get(tagName);
1789
- if (!allowedChildren) return;
1790
-
1791
- for (const child of node.childNodes ?? []) {
1792
- if (child.nodeType !== 1) continue;
1793
- const childTagName = getHtmlTagName(child);
1794
- if (!childTagName || childTagName.includes('-')) continue;
1795
- if (allowedChildren.has(childTagName)) continue;
1849
+ // Validates event names used in value-binding attributes.
1850
+ function validateValueBindingEvent(node, attrName, findings) {
1851
+ const [realAttrName, eventName] = attrName.split(':');
1852
+ if (realAttrName !== 'value' || !eventName) return;
1853
+ if (SUPPORTED_EVENT_NAMES.has(eventName)) return;
1796
1854
 
1797
- findings.invalidHtmlNesting.push(
1798
- `<${childTagName}> is not allowed directly inside <${tagName}>`
1799
- );
1800
- }
1855
+ const tagName = node.rawTagName || node.tagName || 'element';
1856
+ findings.unsupportedEventNames.push(
1857
+ `${tagName} attribute "${attrName}" refers to ` +
1858
+ `an unsupported event name "${eventName}"`
1859
+ );
1801
1860
  }
1802
1861
 
1862
+ // Walks parsed HTML nodes to extract expressions and apply HTML validations.
1803
1863
  function walkHtmlNode(
1804
1864
  node,
1805
1865
  expressions,
@@ -1869,20 +1929,7 @@ function walkHtmlNode(
1869
1929
  }
1870
1930
  }
1871
1931
 
1872
- const isCliEntry = (() => {
1873
- if (!process.argv[1]) return false;
1874
-
1875
- try {
1876
- return (
1877
- fs.realpathSync(process.argv[1]) ===
1878
- fs.realpathSync(fileURLToPath(import.meta.url))
1879
- );
1880
- } catch {
1881
- return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
1882
- }
1883
- })();
1884
-
1885
- if (isCliEntry) {
1932
+ if (import.meta.main) {
1886
1933
  try {
1887
1934
  main();
1888
1935
  } catch (error) {