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/package.json +3 -2
- package/scripts/ast-utils.js +107 -0
- package/scripts/lint.js +351 -304
- package/scripts/used-by.js +513 -482
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:
|
|
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:
|
|
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
|
-
|
|
354
|
-
|
|
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 (!
|
|
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
|
|
394
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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}
|
|
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}"
|
|
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
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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}"
|
|
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
|
|
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
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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
|
-
|
|
1798
|
-
|
|
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
|
-
|
|
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) {
|