wrec 0.29.4 → 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 +350 -289
- package/scripts/used-by.js +8 -92
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.
|
|
5
|
+
"version": "0.30.0",
|
|
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/
|
|
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:
|
|
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:
|
|
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
|
-
|
|
353
|
-
|
|
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 (!
|
|
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
|
|
393
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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}
|
|
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}"
|
|
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
|
-
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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,11 +1393,7 @@ export function lintSource(filePath, sourceText, options = {}) {
|
|
|
1374
1393
|
);
|
|
1375
1394
|
}
|
|
1376
1395
|
|
|
1377
|
-
|
|
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);
|
|
1384
1399
|
const verbose = args.includes('--verbose');
|
|
@@ -1417,16 +1432,82 @@ function main() {
|
|
|
1417
1432
|
});
|
|
1418
1433
|
}
|
|
1419
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.
|
|
1420
1460
|
function toUserFacingExpression(text) {
|
|
1421
1461
|
return text.replaceAll(WREC_REF_NAME, 'this');
|
|
1422
1462
|
}
|
|
1423
1463
|
|
|
1464
|
+
// Classifies a constructor-based type expression by its identifier name.
|
|
1424
1465
|
function typeExpressionKind(expression) {
|
|
1425
1466
|
if (!expression) return undefined;
|
|
1426
1467
|
if (ts.isIdentifier(expression)) return expression.text;
|
|
1427
1468
|
return undefined;
|
|
1428
1469
|
}
|
|
1429
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.
|
|
1430
1511
|
function validateComputedProperty(
|
|
1431
1512
|
propName,
|
|
1432
1513
|
computedText,
|
|
@@ -1441,7 +1522,8 @@ function validateComputedProperty(
|
|
|
1441
1522
|
!classMethods.has(referencedName)
|
|
1442
1523
|
) {
|
|
1443
1524
|
findings.invalidComputedProperties.push(
|
|
1444
|
-
`property "${propName}" computed references
|
|
1525
|
+
`property "${propName}" computed references ` +
|
|
1526
|
+
`missing property "${referencedName}"`
|
|
1445
1527
|
);
|
|
1446
1528
|
}
|
|
1447
1529
|
}
|
|
@@ -1450,12 +1532,14 @@ function validateComputedProperty(
|
|
|
1450
1532
|
const methodName = match[1];
|
|
1451
1533
|
if (!classMethods.has(methodName)) {
|
|
1452
1534
|
findings.invalidComputedProperties.push(
|
|
1453
|
-
`property "${propName}" computed calls
|
|
1535
|
+
`property "${propName}" computed calls ` +
|
|
1536
|
+
`non-method instance member "${methodName}"`
|
|
1454
1537
|
);
|
|
1455
1538
|
}
|
|
1456
1539
|
}
|
|
1457
1540
|
}
|
|
1458
1541
|
|
|
1542
|
+
// Validates that a default value matches the declared property type.
|
|
1459
1543
|
function validateDefaultValue(checker, typeExpression, valueExpression) {
|
|
1460
1544
|
if (!typeExpression || !valueExpression) return undefined;
|
|
1461
1545
|
|
|
@@ -1510,6 +1594,7 @@ function validateDefaultValue(checker, typeExpression, valueExpression) {
|
|
|
1510
1594
|
return undefined;
|
|
1511
1595
|
}
|
|
1512
1596
|
|
|
1597
|
+
// Resolves and validates a user-supplied file path argument.
|
|
1513
1598
|
function validateFilePath(filePath) {
|
|
1514
1599
|
const resolved = path.resolve(filePath);
|
|
1515
1600
|
const ext = path.extname(resolved);
|
|
@@ -1522,6 +1607,112 @@ function validateFilePath(filePath) {
|
|
|
1522
1607
|
return resolved;
|
|
1523
1608
|
}
|
|
1524
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.
|
|
1525
1716
|
function validatePropertyConfigs(
|
|
1526
1717
|
checker,
|
|
1527
1718
|
supportedProps,
|
|
@@ -1554,7 +1745,8 @@ function validatePropertyConfigs(
|
|
|
1554
1745
|
for (const methodName of methods) {
|
|
1555
1746
|
if (!classMethods.has(methodName)) {
|
|
1556
1747
|
findings.invalidUsedByReferences.push(
|
|
1557
|
-
`property "${propName}" usedBy references
|
|
1748
|
+
`property "${propName}" usedBy references ` +
|
|
1749
|
+
`missing method "${methodName}"`
|
|
1558
1750
|
);
|
|
1559
1751
|
}
|
|
1560
1752
|
}
|
|
@@ -1592,7 +1784,8 @@ function validatePropertyConfigs(
|
|
|
1592
1784
|
!values.includes(valueProp.initializer.text)
|
|
1593
1785
|
) {
|
|
1594
1786
|
findings.invalidDefaultValues.push(
|
|
1595
|
-
`property "${propName}" default value
|
|
1787
|
+
`property "${propName}" default value ` +
|
|
1788
|
+
`"${valueProp.initializer.text}" is not in values`
|
|
1596
1789
|
);
|
|
1597
1790
|
}
|
|
1598
1791
|
}
|
|
@@ -1605,124 +1798,16 @@ function validatePropertyConfigs(
|
|
|
1605
1798
|
);
|
|
1606
1799
|
if (mismatch) {
|
|
1607
1800
|
findings.invalidDefaultValues.push(
|
|
1608
|
-
`property "${propName}" default value
|
|
1801
|
+
`property "${propName}" default value ` +
|
|
1802
|
+
`has type ${mismatch.valueTypeName}, ` +
|
|
1803
|
+
`but declared type is ${mismatch.typeName}`
|
|
1609
1804
|
);
|
|
1610
1805
|
}
|
|
1611
1806
|
}
|
|
1612
1807
|
}
|
|
1613
1808
|
}
|
|
1614
1809
|
|
|
1615
|
-
|
|
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
|
-
|
|
1810
|
+
// Validates that a ref attribute targets a unique HTMLElement property.
|
|
1726
1811
|
function validateRefAttribute(
|
|
1727
1812
|
attrValue,
|
|
1728
1813
|
supportedProps,
|
|
@@ -1744,14 +1829,16 @@ function validateRefAttribute(
|
|
|
1744
1829
|
|
|
1745
1830
|
if (propInfo.typeText !== 'HTMLElement') {
|
|
1746
1831
|
findings.invalidRefAttributes.push(
|
|
1747
|
-
`ref="${attrValue}" refers to property "${propName}"
|
|
1832
|
+
`ref="${attrValue}" refers to property "${propName}" ` +
|
|
1833
|
+
'whose type is not HTMLElement'
|
|
1748
1834
|
);
|
|
1749
1835
|
return;
|
|
1750
1836
|
}
|
|
1751
1837
|
|
|
1752
1838
|
if (seenRefProps.has(propName)) {
|
|
1753
1839
|
findings.invalidRefAttributes.push(
|
|
1754
|
-
`ref="${attrValue}" is a duplicate reference
|
|
1840
|
+
`ref="${attrValue}" is a duplicate reference ` +
|
|
1841
|
+
`to the property "${propName}"`
|
|
1755
1842
|
);
|
|
1756
1843
|
return;
|
|
1757
1844
|
}
|
|
@@ -1759,46 +1846,20 @@ function validateRefAttribute(
|
|
|
1759
1846
|
seenRefProps.add(propName);
|
|
1760
1847
|
}
|
|
1761
1848
|
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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;
|
|
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;
|
|
1795
1854
|
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
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
|
+
);
|
|
1800
1860
|
}
|
|
1801
1861
|
|
|
1862
|
+
// Walks parsed HTML nodes to extract expressions and apply HTML validations.
|
|
1802
1863
|
function walkHtmlNode(
|
|
1803
1864
|
node,
|
|
1804
1865
|
expressions,
|
package/scripts/used-by.js
CHANGED
|
@@ -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) {
|