wrec 0.24.4 → 0.24.6
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/dist/wrec-ssr.d.ts +1 -1
- package/dist/wrec.d.ts +1 -1
- package/package.json +4 -2
- package/scripts/lint.js +1656 -0
package/scripts/lint.js
ADDED
|
@@ -0,0 +1,1656 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// This linter checks Wrec components for:
|
|
4
|
+
// - duplicate property names
|
|
5
|
+
// - invalid computed property references and non-method calls
|
|
6
|
+
// - invalid default values
|
|
7
|
+
// - invalid form-assoc values
|
|
8
|
+
// - invalid event handler references
|
|
9
|
+
// - invalid useState map entries
|
|
10
|
+
// - invalid usedBy references
|
|
11
|
+
// - invalid values configurations
|
|
12
|
+
// - missing formAssociated property
|
|
13
|
+
// - missing type properties in property configurations
|
|
14
|
+
// - reserved property names
|
|
15
|
+
// - undefined context functions called in expressions
|
|
16
|
+
// - undefined instance methods called in expressions
|
|
17
|
+
// - undefined properties accessed in expressions
|
|
18
|
+
// - incompatible method arguments
|
|
19
|
+
// - unsupported HTML attributes in html templates
|
|
20
|
+
// - unsupported event names
|
|
21
|
+
// - arithmetic type errors in expressions
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import {fileURLToPath} from 'node:url';
|
|
26
|
+
import ts from 'typescript';
|
|
27
|
+
import {parse} from 'node-html-parser';
|
|
28
|
+
|
|
29
|
+
const CSS_PROPERTY_RE = /([a-zA-Z-]+)\s*:\s*([^;}]+)/g;
|
|
30
|
+
const REFS_TEST_RE = /this\.[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)*/;
|
|
31
|
+
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/;
|
|
32
|
+
const HTML_GLOBAL_ATTRIBUTES = new Set([
|
|
33
|
+
'aria-label',
|
|
34
|
+
'class',
|
|
35
|
+
'disabled',
|
|
36
|
+
'hidden',
|
|
37
|
+
'id',
|
|
38
|
+
'part',
|
|
39
|
+
'role',
|
|
40
|
+
'slot',
|
|
41
|
+
'style',
|
|
42
|
+
'tabindex',
|
|
43
|
+
'title'
|
|
44
|
+
]);
|
|
45
|
+
const HTML_TAG_ATTRIBUTES = new Map([
|
|
46
|
+
['a', new Set(['href', 'rel', 'target'])],
|
|
47
|
+
['button', new Set(['name', 'type', 'value'])],
|
|
48
|
+
['div', new Set([])],
|
|
49
|
+
['fieldset', new Set(['name'])],
|
|
50
|
+
['form', new Set(['action', 'method', 'name'])],
|
|
51
|
+
['img', new Set(['alt', 'height', 'src', 'width'])],
|
|
52
|
+
['input', new Set(['checked', 'max', 'min', 'name', 'placeholder', 'step', 'type', 'value'])],
|
|
53
|
+
['label', new Set(['for'])],
|
|
54
|
+
['legend', new Set([])],
|
|
55
|
+
['li', new Set(['value'])],
|
|
56
|
+
['option', new Set(['label', 'selected', 'value'])],
|
|
57
|
+
['p', new Set([])],
|
|
58
|
+
['select', new Set(['multiple', 'name', 'value'])],
|
|
59
|
+
['span', new Set([])],
|
|
60
|
+
['table', new Set([])],
|
|
61
|
+
['tbody', new Set([])],
|
|
62
|
+
['td', new Set(['colspan', 'rowspan'])],
|
|
63
|
+
['textarea', new Set(['name', 'placeholder', 'rows', 'value'])],
|
|
64
|
+
['th', new Set(['colspan', 'rowspan', 'scope'])],
|
|
65
|
+
['thead', new Set([])],
|
|
66
|
+
['tr', new Set([])],
|
|
67
|
+
['ul', new Set([])]
|
|
68
|
+
]);
|
|
69
|
+
const THIS_CALL_RE = /this\.([A-Za-z_$][\w$]*)\s*\(/g;
|
|
70
|
+
const THIS_REF_RE = /this\.([A-Za-z_$][\w$]*)(\.[A-Za-z_$][\w$]*)*/g;
|
|
71
|
+
const PLACEHOLDER_PREFIX = '__WREC_PLACEHOLDER__';
|
|
72
|
+
const RESERVED_PROPERTY_NAMES = new Set(['class', 'style']);
|
|
73
|
+
const SUPPORTED_EVENT_NAMES = new Set([
|
|
74
|
+
'blur',
|
|
75
|
+
'change',
|
|
76
|
+
'click',
|
|
77
|
+
'dblclick',
|
|
78
|
+
'focus',
|
|
79
|
+
'focusin',
|
|
80
|
+
'focusout',
|
|
81
|
+
'input',
|
|
82
|
+
'keydown',
|
|
83
|
+
'keypress',
|
|
84
|
+
'keyup',
|
|
85
|
+
'mousedown',
|
|
86
|
+
'mouseenter',
|
|
87
|
+
'mouseleave',
|
|
88
|
+
'mousemove',
|
|
89
|
+
'mouseout',
|
|
90
|
+
'mouseover',
|
|
91
|
+
'mouseup',
|
|
92
|
+
'paste'
|
|
93
|
+
]);
|
|
94
|
+
const WREC_REF_NAME = '__wrec';
|
|
95
|
+
const componentPropertyCache = new Map();
|
|
96
|
+
|
|
97
|
+
function analyzeExpression(
|
|
98
|
+
expressionNode,
|
|
99
|
+
checker,
|
|
100
|
+
classNode,
|
|
101
|
+
findings,
|
|
102
|
+
metadata
|
|
103
|
+
) {
|
|
104
|
+
if (metadata.eventHandler && ts.isIdentifier(expressionNode)) {
|
|
105
|
+
if (!metadata.classMethods.has(expressionNode.text)) {
|
|
106
|
+
uniquePush(
|
|
107
|
+
findings.invalidEventHandlers,
|
|
108
|
+
`"${expressionNode.text}" is not a defined instance method`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function visit(node) {
|
|
114
|
+
if (ts.isPropertyAccessExpression(node) && isWrecRooted(node.expression)) {
|
|
115
|
+
const ownerType = checker.getTypeAtLocation(node.expression);
|
|
116
|
+
const symbol = ownerType.getProperty(node.name.text);
|
|
117
|
+
if (!symbol) {
|
|
118
|
+
const name = node.name.text;
|
|
119
|
+
if (isCallCallee(node)) {
|
|
120
|
+
uniquePush(findings.undefinedMethods, name);
|
|
121
|
+
} else {
|
|
122
|
+
uniquePush(findings.undefinedProperties, name);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (ts.isCallExpression(node)) {
|
|
128
|
+
const callee = node.expression;
|
|
129
|
+
if (ts.isIdentifier(callee)) {
|
|
130
|
+
if (!metadata.contextKeys.has(callee.text)) {
|
|
131
|
+
const symbol = checker.getSymbolAtLocation(callee);
|
|
132
|
+
if (!symbol || requiresContextFunction(symbol, metadata.sourceFile)) {
|
|
133
|
+
uniquePush(findings.undefinedContextFunctions, callee.text);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} else if (
|
|
137
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
138
|
+
isWrecRooted(callee.expression)
|
|
139
|
+
) {
|
|
140
|
+
const ownerType = checker.getTypeAtLocation(callee.expression);
|
|
141
|
+
const symbol = ownerType.getProperty(callee.name.text);
|
|
142
|
+
if (!symbol) uniquePush(findings.undefinedMethods, callee.name.text);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const signature =
|
|
146
|
+
checker.getResolvedSignature(node) ??
|
|
147
|
+
checker.getSignaturesOfType(
|
|
148
|
+
checker.getTypeAtLocation(callee),
|
|
149
|
+
ts.SignatureKind.Call
|
|
150
|
+
)[0];
|
|
151
|
+
|
|
152
|
+
if (signature) {
|
|
153
|
+
const parameters = signature.getParameters();
|
|
154
|
+
const declaration = signature.getDeclaration();
|
|
155
|
+
const isRest =
|
|
156
|
+
declaration &&
|
|
157
|
+
ts.isFunctionLike(declaration) &&
|
|
158
|
+
declaration.parameters.length > 0 &&
|
|
159
|
+
Boolean(
|
|
160
|
+
declaration.parameters[declaration.parameters.length - 1]
|
|
161
|
+
.dotDotDotToken
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
node.arguments.forEach((argument, index) => {
|
|
165
|
+
let parameterSymbol = parameters[index];
|
|
166
|
+
let isRestArgument =
|
|
167
|
+
Boolean(isRest) &&
|
|
168
|
+
parameters.length > 0 &&
|
|
169
|
+
index >= parameters.length - 1;
|
|
170
|
+
if (!parameterSymbol && isRest && parameters.length > 0) {
|
|
171
|
+
parameterSymbol = parameters[parameters.length - 1];
|
|
172
|
+
}
|
|
173
|
+
if (!parameterSymbol) return;
|
|
174
|
+
|
|
175
|
+
const argumentType = checker.getTypeAtLocation(argument);
|
|
176
|
+
const parameterType = getParameterType(
|
|
177
|
+
checker,
|
|
178
|
+
parameterSymbol,
|
|
179
|
+
declaration ?? classNode,
|
|
180
|
+
isRestArgument
|
|
181
|
+
);
|
|
182
|
+
if (!checker.isTypeAssignableTo(argumentType, parameterType)) {
|
|
183
|
+
findings.incompatibleArguments.push({
|
|
184
|
+
argument: toUserFacingExpression(argument.getText()),
|
|
185
|
+
argumentType: checker.typeToString(argumentType),
|
|
186
|
+
methodName: toUserFacingExpression(callee.getText()),
|
|
187
|
+
parameterName: parameterSymbol.getName(),
|
|
188
|
+
parameterType: checker.typeToString(parameterType)
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
ts.isBinaryExpression(node) &&
|
|
197
|
+
isArithmeticOperator(node.operatorToken.kind)
|
|
198
|
+
) {
|
|
199
|
+
const leftType = checker.getTypeAtLocation(node.left);
|
|
200
|
+
const rightType = checker.getTypeAtLocation(node.right);
|
|
201
|
+
|
|
202
|
+
if (!isNumericLikeType(leftType)) {
|
|
203
|
+
findings.typeErrors.push({
|
|
204
|
+
expression: toUserFacingExpression(node.getText()),
|
|
205
|
+
message: `left operand "${toUserFacingExpression(node.left.getText())}" has type ${checker.typeToString(leftType)}, but arithmetic operators require number`
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!isNumericLikeType(rightType)) {
|
|
210
|
+
findings.typeErrors.push({
|
|
211
|
+
expression: toUserFacingExpression(node.getText()),
|
|
212
|
+
message: `right operand "${toUserFacingExpression(node.right.getText())}" has type ${checker.typeToString(rightType)}, but arithmetic operators require number`
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
ts.forEachChild(node, visit);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
visit(expressionNode);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildAugmentedSource(
|
|
224
|
+
sourceFile,
|
|
225
|
+
classNode,
|
|
226
|
+
supportedProps,
|
|
227
|
+
contextKeys,
|
|
228
|
+
expressions
|
|
229
|
+
) {
|
|
230
|
+
const propLines = [];
|
|
231
|
+
for (const [name, info] of supportedProps.entries()) {
|
|
232
|
+
propLines.push(` ${JSON.stringify(name)}: ${info.typeText};`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const contextLine = contextKeys.length
|
|
236
|
+
? `const {${contextKeys.join(', ')}} = ${classNode.name.text}.context;`
|
|
237
|
+
: '';
|
|
238
|
+
|
|
239
|
+
const helperBlocks = expressions.map((expr, index) => {
|
|
240
|
+
const targetType =
|
|
241
|
+
expr.context === 'static'
|
|
242
|
+
? `typeof ${classNode.name.text}`
|
|
243
|
+
: `${classNode.name.text} & __WrecSupportedProps`;
|
|
244
|
+
const rewrittenText = expr.text.replace(/\bthis\b/g, WREC_REF_NAME);
|
|
245
|
+
return `
|
|
246
|
+
function __wrec_expr_${index}() {
|
|
247
|
+
const ${WREC_REF_NAME} = null as unknown as ${targetType};
|
|
248
|
+
${expr.context === 'instance' ? contextLine : ''}
|
|
249
|
+
return (${rewrittenText});
|
|
250
|
+
}
|
|
251
|
+
`;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const propInterface = `
|
|
255
|
+
type __WrecSupportedProps = {
|
|
256
|
+
${propLines.join('\n')}
|
|
257
|
+
};
|
|
258
|
+
`;
|
|
259
|
+
|
|
260
|
+
return `${sourceFile.text}\n${propInterface}\n${helperBlocks.join('\n')}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function collectClassMethods(classNode) {
|
|
264
|
+
const methods = new Set();
|
|
265
|
+
for (const member of classNode.members) {
|
|
266
|
+
if (
|
|
267
|
+
!ts.isMethodDeclaration(member) &&
|
|
268
|
+
!ts.isGetAccessorDeclaration(member) &&
|
|
269
|
+
!ts.isSetAccessorDeclaration(member)
|
|
270
|
+
) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const name = getMemberName(member);
|
|
274
|
+
if (name) methods.add(name);
|
|
275
|
+
}
|
|
276
|
+
return methods;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function collectHelperExpressions(augmentedSourceFile) {
|
|
280
|
+
const helpers = [];
|
|
281
|
+
|
|
282
|
+
function visit(node) {
|
|
283
|
+
if (
|
|
284
|
+
ts.isFunctionDeclaration(node) &&
|
|
285
|
+
node.name?.text.startsWith('__wrec_expr_')
|
|
286
|
+
) {
|
|
287
|
+
const match = node.name.text.match(/(\d+)$/);
|
|
288
|
+
const index = match ? Number(match[1]) : -1;
|
|
289
|
+
if (index >= 0 && node.body) {
|
|
290
|
+
const statement = node.body.statements.find(ts.isReturnStatement);
|
|
291
|
+
if (statement?.expression) {
|
|
292
|
+
helpers[index] = statement.expression;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
ts.forEachChild(node, visit);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
visit(augmentedSourceFile);
|
|
300
|
+
return helpers;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function collectWrecClasses(sourceFile) {
|
|
304
|
+
const classes = [];
|
|
305
|
+
|
|
306
|
+
function visit(node) {
|
|
307
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
308
|
+
const heritage = node.heritageClauses?.find(
|
|
309
|
+
clause => clause.token === ts.SyntaxKind.ExtendsKeyword
|
|
310
|
+
);
|
|
311
|
+
const typeNode = heritage?.types[0];
|
|
312
|
+
if (
|
|
313
|
+
typeNode &&
|
|
314
|
+
ts.isIdentifier(typeNode.expression) &&
|
|
315
|
+
typeNode.expression.text === 'Wrec'
|
|
316
|
+
) {
|
|
317
|
+
classes.push(node);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
ts.forEachChild(node, visit);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
visit(sourceFile);
|
|
324
|
+
return classes;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function findDefinedTagNames(sourceFile) {
|
|
328
|
+
const tagNames = new Map();
|
|
329
|
+
|
|
330
|
+
function visit(node) {
|
|
331
|
+
if (
|
|
332
|
+
ts.isCallExpression(node) &&
|
|
333
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
334
|
+
node.expression.name.text === 'define' &&
|
|
335
|
+
ts.isIdentifier(node.expression.expression) &&
|
|
336
|
+
node.arguments.length > 0 &&
|
|
337
|
+
ts.isStringLiteral(node.arguments[0])
|
|
338
|
+
) {
|
|
339
|
+
tagNames.set(node.expression.expression.text, node.arguments[0].text);
|
|
340
|
+
}
|
|
341
|
+
ts.forEachChild(node, visit);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
visit(sourceFile);
|
|
345
|
+
return tagNames;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function collectUseStateMapErrors(classNode, supportedProps, findings) {
|
|
349
|
+
function visit(node) {
|
|
350
|
+
if (
|
|
351
|
+
ts.isCallExpression(node) &&
|
|
352
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
353
|
+
ts.isThis(node.expression.expression) &&
|
|
354
|
+
node.expression.name.text === 'useState'
|
|
355
|
+
) {
|
|
356
|
+
const mapArg = node.arguments[1];
|
|
357
|
+
if (mapArg && ts.isObjectLiteralExpression(mapArg)) {
|
|
358
|
+
for (const property of mapArg.properties) {
|
|
359
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
360
|
+
const statePath = getMemberName(property);
|
|
361
|
+
if (
|
|
362
|
+
!statePath ||
|
|
363
|
+
(!ts.isStringLiteral(property.initializer) &&
|
|
364
|
+
!ts.isNoSubstitutionTemplateLiteral(property.initializer))
|
|
365
|
+
) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const componentProp = property.initializer.text;
|
|
370
|
+
if (!supportedProps.has(componentProp)) {
|
|
371
|
+
findings.invalidUseStateMaps.push(
|
|
372
|
+
`useState maps state property "${statePath}" to missing component property "${componentProp}"`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
ts.forEachChild(node, visit);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
visit(classNode);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function createProgram(filePath, sourceText) {
|
|
386
|
+
const defaultHost = ts.createCompilerHost({}, true);
|
|
387
|
+
const compilerOptions = {
|
|
388
|
+
allowJs: true,
|
|
389
|
+
checkJs: true,
|
|
390
|
+
experimentalDecorators: true,
|
|
391
|
+
module: ts.ModuleKind.ESNext,
|
|
392
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
393
|
+
noEmit: true,
|
|
394
|
+
noImplicitAny: false,
|
|
395
|
+
skipLibCheck: true,
|
|
396
|
+
strict: true,
|
|
397
|
+
target: ts.ScriptTarget.ESNext,
|
|
398
|
+
useDefineForClassFields: true
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const host = {
|
|
402
|
+
...defaultHost,
|
|
403
|
+
fileExists(fileName) {
|
|
404
|
+
if (path.resolve(fileName) === filePath) return true;
|
|
405
|
+
return defaultHost.fileExists(fileName);
|
|
406
|
+
},
|
|
407
|
+
getSourceFile(
|
|
408
|
+
fileName,
|
|
409
|
+
languageVersion,
|
|
410
|
+
onError,
|
|
411
|
+
shouldCreateNewSourceFile
|
|
412
|
+
) {
|
|
413
|
+
if (path.resolve(fileName) === filePath) {
|
|
414
|
+
const kind = fileName.endsWith('.ts')
|
|
415
|
+
? ts.ScriptKind.TS
|
|
416
|
+
: ts.ScriptKind.JS;
|
|
417
|
+
return ts.createSourceFile(
|
|
418
|
+
fileName,
|
|
419
|
+
sourceText,
|
|
420
|
+
languageVersion,
|
|
421
|
+
true,
|
|
422
|
+
kind
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return defaultHost.getSourceFile(
|
|
426
|
+
fileName,
|
|
427
|
+
languageVersion,
|
|
428
|
+
onError,
|
|
429
|
+
shouldCreateNewSourceFile
|
|
430
|
+
);
|
|
431
|
+
},
|
|
432
|
+
readFile(fileName) {
|
|
433
|
+
if (path.resolve(fileName) === filePath) return sourceText;
|
|
434
|
+
return defaultHost.readFile(fileName);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
return ts.createProgram([filePath], compilerOptions, host);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function extractProperties(sourceFile, checker, classNode) {
|
|
442
|
+
const duplicateProperties = [];
|
|
443
|
+
let formAssociated = false;
|
|
444
|
+
const propertyEntries = [];
|
|
445
|
+
const reservedProperties = [];
|
|
446
|
+
const supportedProps = new Map();
|
|
447
|
+
const computedExprs = [];
|
|
448
|
+
let contextKeys = [];
|
|
449
|
+
|
|
450
|
+
for (const member of classNode.members) {
|
|
451
|
+
if (!isStaticMember(member)) continue;
|
|
452
|
+
if (!ts.isPropertyDeclaration(member)) continue;
|
|
453
|
+
|
|
454
|
+
const name = getMemberName(member);
|
|
455
|
+
if (!name || !member.initializer) continue;
|
|
456
|
+
|
|
457
|
+
if (
|
|
458
|
+
name === 'context' &&
|
|
459
|
+
ts.isObjectLiteralExpression(member.initializer)
|
|
460
|
+
) {
|
|
461
|
+
contextKeys = member.initializer.properties
|
|
462
|
+
.map(property => getMemberName(property))
|
|
463
|
+
.filter(Boolean);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (
|
|
468
|
+
name === 'formAssociated' &&
|
|
469
|
+
member.initializer.kind === ts.SyntaxKind.TrueKeyword
|
|
470
|
+
) {
|
|
471
|
+
formAssociated = true;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (
|
|
476
|
+
name !== 'properties' ||
|
|
477
|
+
!ts.isObjectLiteralExpression(member.initializer)
|
|
478
|
+
) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const property of member.initializer.properties) {
|
|
483
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
484
|
+
const propName = getMemberName(property);
|
|
485
|
+
if (!propName || !ts.isObjectLiteralExpression(property.initializer)) {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (supportedProps.has(propName) && !duplicateProperties.includes(propName)) {
|
|
490
|
+
duplicateProperties.push(propName);
|
|
491
|
+
}
|
|
492
|
+
if (
|
|
493
|
+
RESERVED_PROPERTY_NAMES.has(propName) &&
|
|
494
|
+
!reservedProperties.includes(propName)
|
|
495
|
+
) {
|
|
496
|
+
reservedProperties.push(propName);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const config = property.initializer;
|
|
500
|
+
propertyEntries.push({config, propName});
|
|
501
|
+
const typeProp = getObjectProperty(config, 'type');
|
|
502
|
+
const computedProp = getObjectProperty(config, 'computed');
|
|
503
|
+
|
|
504
|
+
let typeText = 'unknown';
|
|
505
|
+
let typeNode;
|
|
506
|
+
if (typeProp && ts.isPropertyAssignment(typeProp)) {
|
|
507
|
+
typeText = getPropertyTypeText(
|
|
508
|
+
checker,
|
|
509
|
+
sourceFile,
|
|
510
|
+
typeProp.initializer
|
|
511
|
+
);
|
|
512
|
+
typeNode = typeNodeFromConstructorExpression(typeProp.initializer);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
supportedProps.set(propName, {typeNode, typeText});
|
|
516
|
+
|
|
517
|
+
if (
|
|
518
|
+
computedProp &&
|
|
519
|
+
ts.isPropertyAssignment(computedProp) &&
|
|
520
|
+
(ts.isStringLiteral(computedProp.initializer) ||
|
|
521
|
+
ts.isNoSubstitutionTemplateLiteral(computedProp.initializer))
|
|
522
|
+
) {
|
|
523
|
+
computedExprs.push({
|
|
524
|
+
kind: 'computed',
|
|
525
|
+
text: computedProp.initializer.text.trim(),
|
|
526
|
+
location: sourceFile.getLineAndCharacterOfPosition(
|
|
527
|
+
computedProp.initializer.getStart(sourceFile)
|
|
528
|
+
)
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
supportedProps,
|
|
536
|
+
computedExprs,
|
|
537
|
+
contextKeys,
|
|
538
|
+
duplicateProperties,
|
|
539
|
+
formAssociated,
|
|
540
|
+
propertyEntries,
|
|
541
|
+
reservedProperties
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function extractTemplateExpressions(classNode, findings, componentPropertyMaps) {
|
|
546
|
+
const expressions = [];
|
|
547
|
+
|
|
548
|
+
for (const member of classNode.members) {
|
|
549
|
+
if (!isStaticMember(member)) continue;
|
|
550
|
+
if (!ts.isPropertyDeclaration(member)) continue;
|
|
551
|
+
|
|
552
|
+
const name = getMemberName(member);
|
|
553
|
+
if ((name !== 'html' && name !== 'css') || !member.initializer) continue;
|
|
554
|
+
if (!ts.isTaggedTemplateExpression(member.initializer)) continue;
|
|
555
|
+
|
|
556
|
+
const tag = member.initializer.tag.getText();
|
|
557
|
+
if (tag !== 'html' && tag !== 'css') continue;
|
|
558
|
+
|
|
559
|
+
const {template} = member.initializer;
|
|
560
|
+
if (ts.isTemplateExpression(template)) {
|
|
561
|
+
for (const span of template.templateSpans) {
|
|
562
|
+
const trimmed = getExpressionText(
|
|
563
|
+
member.getSourceFile(),
|
|
564
|
+
span.expression
|
|
565
|
+
);
|
|
566
|
+
if (trimmed) {
|
|
567
|
+
const location = span.expression
|
|
568
|
+
.getSourceFile()
|
|
569
|
+
.getLineAndCharacterOfPosition(span.expression.getStart());
|
|
570
|
+
expressions.push({
|
|
571
|
+
context: 'static',
|
|
572
|
+
eventHandler: false,
|
|
573
|
+
kind: tag,
|
|
574
|
+
text: trimmed,
|
|
575
|
+
location
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const rendered = getTemplateLiteralText(template);
|
|
582
|
+
|
|
583
|
+
if (tag === 'css') {
|
|
584
|
+
CSS_PROPERTY_RE.lastIndex = 0;
|
|
585
|
+
while (true) {
|
|
586
|
+
const match = CSS_PROPERTY_RE.exec(rendered);
|
|
587
|
+
if (!match) break;
|
|
588
|
+
const value = match[2]?.trim();
|
|
589
|
+
if (value && REFS_TEST_RE.test(value)) {
|
|
590
|
+
expressions.push({
|
|
591
|
+
context: 'instance',
|
|
592
|
+
eventHandler: false,
|
|
593
|
+
kind: 'css',
|
|
594
|
+
text: value,
|
|
595
|
+
location: null
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const root = parse(rendered, {comment: true});
|
|
603
|
+
walkHtmlNode(root, expressions, findings, componentPropertyMaps);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return expressions;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function fail(message) {
|
|
610
|
+
console.error(message);
|
|
611
|
+
process.exit(1);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function findWrecFiles(rootDir, onMatch) {
|
|
615
|
+
const walk = currentDir => {
|
|
616
|
+
const entries = fs
|
|
617
|
+
.readdirSync(currentDir, {withFileTypes: true})
|
|
618
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
619
|
+
|
|
620
|
+
for (const entry of entries) {
|
|
621
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
622
|
+
|
|
623
|
+
if (entry.isDirectory()) {
|
|
624
|
+
walk(fullPath);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (!entry.isFile()) continue;
|
|
629
|
+
if (!fullPath.endsWith('.js') && !fullPath.endsWith('.ts')) continue;
|
|
630
|
+
if (isWrecComponentFile(fullPath)) onMatch(fullPath);
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
walk(rootDir);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function formatReport(
|
|
638
|
+
filePath,
|
|
639
|
+
supportedProps,
|
|
640
|
+
allExpressions,
|
|
641
|
+
findings,
|
|
642
|
+
options = {}
|
|
643
|
+
) {
|
|
644
|
+
const {
|
|
645
|
+
fileLabel = filePath,
|
|
646
|
+
showFileHeader = true,
|
|
647
|
+
showDetailsForCleanFile = true,
|
|
648
|
+
showNoIssuesMessage = true
|
|
649
|
+
} = options;
|
|
650
|
+
const lines = [];
|
|
651
|
+
|
|
652
|
+
const hasIssues =
|
|
653
|
+
findings.duplicateProperties.length > 0 ||
|
|
654
|
+
findings.reservedProperties.length > 0 ||
|
|
655
|
+
findings.invalidUsedByReferences.length > 0 ||
|
|
656
|
+
findings.invalidComputedProperties.length > 0 ||
|
|
657
|
+
findings.invalidValuesConfigurations.length > 0 ||
|
|
658
|
+
findings.invalidDefaultValues.length > 0 ||
|
|
659
|
+
findings.invalidFormAssocValues.length > 0 ||
|
|
660
|
+
findings.invalidUseStateMaps.length > 0 ||
|
|
661
|
+
findings.missingFormAssociatedProperty.length > 0 ||
|
|
662
|
+
findings.missingTypeProperties.length > 0 ||
|
|
663
|
+
findings.undefinedProperties.length > 0 ||
|
|
664
|
+
findings.undefinedContextFunctions.length > 0 ||
|
|
665
|
+
findings.undefinedMethods.length > 0 ||
|
|
666
|
+
findings.incompatibleArguments.length > 0 ||
|
|
667
|
+
findings.invalidEventHandlers.length > 0 ||
|
|
668
|
+
findings.unsupportedHtmlAttributes.length > 0 ||
|
|
669
|
+
findings.unsupportedEventNames.length > 0 ||
|
|
670
|
+
findings.typeErrors.length > 0;
|
|
671
|
+
|
|
672
|
+
if (showFileHeader) lines.push(`file: ${fileLabel}`);
|
|
673
|
+
|
|
674
|
+
if (hasIssues || showDetailsForCleanFile) {
|
|
675
|
+
lines.push('properties:');
|
|
676
|
+
if (supportedProps.size === 0) {
|
|
677
|
+
lines.push(' none');
|
|
678
|
+
} else {
|
|
679
|
+
for (const [name, info] of [...supportedProps.entries()].sort(([a], [b]) =>
|
|
680
|
+
a.localeCompare(b)
|
|
681
|
+
)) {
|
|
682
|
+
lines.push(` ${name}: ${info.typeText}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
lines.push('expressions:');
|
|
687
|
+
if (allExpressions.length === 0) {
|
|
688
|
+
lines.push(' none');
|
|
689
|
+
} else {
|
|
690
|
+
allExpressions.forEach(expr => {
|
|
691
|
+
lines.push(` [${expr.kind}]${formatLocation(expr.location)} ${expr.text}`);
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (findings.duplicateProperties.length > 0) {
|
|
697
|
+
lines.push('duplicate properties:');
|
|
698
|
+
findings.duplicateProperties.forEach(name => lines.push(` ${name}`));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (findings.reservedProperties.length > 0) {
|
|
702
|
+
lines.push('reserved property names:');
|
|
703
|
+
findings.reservedProperties.forEach(name => lines.push(` ${name}`));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (findings.invalidUsedByReferences.length > 0) {
|
|
707
|
+
lines.push('invalid usedBy references:');
|
|
708
|
+
findings.invalidUsedByReferences.forEach(message =>
|
|
709
|
+
lines.push(` ${message}`)
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (findings.invalidComputedProperties.length > 0) {
|
|
714
|
+
lines.push('invalid computed properties:');
|
|
715
|
+
findings.invalidComputedProperties.forEach(message =>
|
|
716
|
+
lines.push(` ${message}`)
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (findings.invalidValuesConfigurations.length > 0) {
|
|
721
|
+
lines.push('invalid values configurations:');
|
|
722
|
+
findings.invalidValuesConfigurations.forEach(message =>
|
|
723
|
+
lines.push(` ${message}`)
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (findings.invalidDefaultValues.length > 0) {
|
|
728
|
+
lines.push('invalid default values:');
|
|
729
|
+
findings.invalidDefaultValues.forEach(message => lines.push(` ${message}`));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (findings.invalidFormAssocValues.length > 0) {
|
|
733
|
+
lines.push('invalid form-assoc values:');
|
|
734
|
+
findings.invalidFormAssocValues.forEach(message => lines.push(` ${message}`));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (findings.missingFormAssociatedProperty.length > 0) {
|
|
738
|
+
lines.push('missing formAssociated property:');
|
|
739
|
+
findings.missingFormAssociatedProperty.forEach(message =>
|
|
740
|
+
lines.push(` ${message}`)
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (findings.missingTypeProperties.length > 0) {
|
|
745
|
+
lines.push('missing type properties:');
|
|
746
|
+
findings.missingTypeProperties.forEach(message => lines.push(` ${message}`));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (findings.undefinedProperties.length > 0) {
|
|
750
|
+
lines.push('undefined properties:');
|
|
751
|
+
findings.undefinedProperties.forEach(name => lines.push(` ${name}`));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (findings.undefinedContextFunctions.length > 0) {
|
|
755
|
+
lines.push('undefined context functions:');
|
|
756
|
+
findings.undefinedContextFunctions.forEach(name => lines.push(` ${name}`));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (findings.undefinedMethods.length > 0) {
|
|
760
|
+
lines.push('undefined methods:');
|
|
761
|
+
findings.undefinedMethods.forEach(name => lines.push(` ${name}`));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (findings.invalidEventHandlers.length > 0) {
|
|
765
|
+
lines.push('invalid event handler references:');
|
|
766
|
+
findings.invalidEventHandlers.forEach(message => lines.push(` ${message}`));
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (findings.invalidUseStateMaps.length > 0) {
|
|
770
|
+
lines.push('invalid useState map entries:');
|
|
771
|
+
findings.invalidUseStateMaps.forEach(message => lines.push(` ${message}`));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (findings.incompatibleArguments.length > 0) {
|
|
775
|
+
lines.push('incompatible arguments:');
|
|
776
|
+
findings.incompatibleArguments.forEach(finding => {
|
|
777
|
+
lines.push(
|
|
778
|
+
` ${finding.methodName}: argument "${finding.argument}" has type ${finding.argumentType}, but parameter "${finding.parameterName}" expects ${finding.parameterType}`
|
|
779
|
+
);
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (findings.typeErrors.length > 0) {
|
|
784
|
+
lines.push('type errors:');
|
|
785
|
+
findings.typeErrors.forEach(finding => {
|
|
786
|
+
lines.push(` ${finding.expression}: ${finding.message}`);
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (findings.unsupportedHtmlAttributes.length > 0) {
|
|
791
|
+
lines.push('unsupported html attributes:');
|
|
792
|
+
findings.unsupportedHtmlAttributes.forEach(message =>
|
|
793
|
+
lines.push(` ${message}`)
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (findings.unsupportedEventNames.length > 0) {
|
|
798
|
+
lines.push('unsupported event names:');
|
|
799
|
+
findings.unsupportedEventNames.forEach(message => lines.push(` ${message}`));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (!hasIssues && showNoIssuesMessage) lines.push('no issues found');
|
|
803
|
+
|
|
804
|
+
return `${lines.join('\n')}\n`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function getComponentPropertyMaps(filePath, sourceText, seen = new Set()) {
|
|
808
|
+
const resolved = path.resolve(filePath);
|
|
809
|
+
if (componentPropertyCache.has(resolved)) {
|
|
810
|
+
return componentPropertyCache.get(resolved);
|
|
811
|
+
}
|
|
812
|
+
if (seen.has(resolved)) return new Map();
|
|
813
|
+
seen.add(resolved);
|
|
814
|
+
|
|
815
|
+
const text = sourceText ?? fs.readFileSync(resolved, 'utf8');
|
|
816
|
+
const program = createProgram(resolved, text);
|
|
817
|
+
const sourceFile = program.getSourceFile(resolved);
|
|
818
|
+
if (!sourceFile) return new Map();
|
|
819
|
+
|
|
820
|
+
const checker = program.getTypeChecker();
|
|
821
|
+
const tagNames = findDefinedTagNames(sourceFile);
|
|
822
|
+
const propertyMaps = new Map();
|
|
823
|
+
|
|
824
|
+
for (const classNode of collectWrecClasses(sourceFile)) {
|
|
825
|
+
const tagName = classNode.name ? tagNames.get(classNode.name.text) : undefined;
|
|
826
|
+
if (!tagName) continue;
|
|
827
|
+
const {supportedProps} = extractProperties(sourceFile, checker, classNode);
|
|
828
|
+
propertyMaps.set(tagName, new Set(supportedProps.keys()));
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
for (const statement of sourceFile.statements) {
|
|
832
|
+
if (
|
|
833
|
+
!ts.isImportDeclaration(statement) ||
|
|
834
|
+
!ts.isStringLiteral(statement.moduleSpecifier)
|
|
835
|
+
) {
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const importPath = resolveImportPath(
|
|
840
|
+
path.dirname(resolved),
|
|
841
|
+
statement.moduleSpecifier.text
|
|
842
|
+
);
|
|
843
|
+
if (!importPath) continue;
|
|
844
|
+
|
|
845
|
+
const importedMaps = getComponentPropertyMaps(importPath, undefined, seen);
|
|
846
|
+
for (const [tagName, props] of importedMaps.entries()) {
|
|
847
|
+
if (!propertyMaps.has(tagName)) propertyMaps.set(tagName, props);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
componentPropertyCache.set(resolved, propertyMaps);
|
|
852
|
+
return propertyMaps;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function findWrecClass(sourceFile, checker) {
|
|
856
|
+
let found;
|
|
857
|
+
|
|
858
|
+
function visit(node) {
|
|
859
|
+
if (found) return;
|
|
860
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
861
|
+
const heritage = node.heritageClauses?.find(
|
|
862
|
+
clause => clause.token === ts.SyntaxKind.ExtendsKeyword
|
|
863
|
+
);
|
|
864
|
+
const typeNode = heritage?.types[0];
|
|
865
|
+
if (typeNode) {
|
|
866
|
+
const baseType = checker.getTypeAtLocation(typeNode.expression);
|
|
867
|
+
const baseSymbol = baseType.symbol ?? baseType.aliasSymbol;
|
|
868
|
+
if (baseSymbol?.getName() === 'Wrec') {
|
|
869
|
+
found = node;
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
ts.forEachChild(node, visit);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
visit(sourceFile);
|
|
878
|
+
return found;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function formatLocation(location) {
|
|
882
|
+
if (!location) return '';
|
|
883
|
+
return `:${location.line + 1}:${location.character + 1}`;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function getArgPaths() {
|
|
887
|
+
const [, , filePath, ...rest] = process.argv;
|
|
888
|
+
if (rest.length > 0) {
|
|
889
|
+
fail('usage: node scripts/wrec-lint.js [file.js|file.ts]');
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
try {
|
|
893
|
+
if (filePath) return [validateFilePath(filePath)];
|
|
894
|
+
return findWrecFiles(process.cwd());
|
|
895
|
+
} catch (error) {
|
|
896
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function getExpressionText(sourceFile, expression) {
|
|
901
|
+
return expression.getText(sourceFile).trim();
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function getMemberName(node) {
|
|
905
|
+
const {name} = node;
|
|
906
|
+
if (!name) return undefined;
|
|
907
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
|
|
908
|
+
return undefined;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function getObjectProperty(objectLiteral, key) {
|
|
912
|
+
for (const property of objectLiteral.properties) {
|
|
913
|
+
if (
|
|
914
|
+
!ts.isPropertyAssignment(property) &&
|
|
915
|
+
!ts.isShorthandPropertyAssignment(property)
|
|
916
|
+
) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const name = getMemberName(property);
|
|
920
|
+
if (name === key) return property;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function getStringArrayLiteral(property) {
|
|
925
|
+
if (!property || !ts.isPropertyAssignment(property)) return undefined;
|
|
926
|
+
if (!ts.isArrayLiteralExpression(property.initializer)) return undefined;
|
|
927
|
+
|
|
928
|
+
const values = [];
|
|
929
|
+
for (const element of property.initializer.elements) {
|
|
930
|
+
if (!ts.isStringLiteral(element) && !ts.isNoSubstitutionTemplateLiteral(element)) {
|
|
931
|
+
return undefined;
|
|
932
|
+
}
|
|
933
|
+
values.push(element.text);
|
|
934
|
+
}
|
|
935
|
+
return values;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function getPropertyTypeText(checker, sourceFile, expression) {
|
|
939
|
+
const typeText = getTypeSyntaxText(sourceFile, expression);
|
|
940
|
+
if (typeText) return typeText;
|
|
941
|
+
|
|
942
|
+
const type = checker.getTypeAtLocation(expression);
|
|
943
|
+
return checker.typeToString(type);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function getParameterType(checker, parameterSymbol, location, isRestArgument) {
|
|
947
|
+
const parameterType = checker.getTypeOfSymbolAtLocation(
|
|
948
|
+
parameterSymbol,
|
|
949
|
+
location
|
|
950
|
+
);
|
|
951
|
+
if (!isRestArgument) return parameterType;
|
|
952
|
+
if (!checker.isArrayType(parameterType) && !checker.isTupleType(parameterType)) {
|
|
953
|
+
return parameterType;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const typeArguments = checker.getTypeArguments(parameterType);
|
|
957
|
+
return typeArguments[0] ?? parameterType;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function getStringOrStringArrayLiteral(property) {
|
|
961
|
+
if (!property || !ts.isPropertyAssignment(property)) return undefined;
|
|
962
|
+
|
|
963
|
+
if (
|
|
964
|
+
ts.isStringLiteral(property.initializer) ||
|
|
965
|
+
ts.isNoSubstitutionTemplateLiteral(property.initializer)
|
|
966
|
+
) {
|
|
967
|
+
return [property.initializer.text];
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return getStringArrayLiteral(property);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function getSupportedHtmlAttributes(tagName) {
|
|
974
|
+
return HTML_TAG_ATTRIBUTES.get(tagName.toLowerCase());
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function getTemplateLiteralText(template) {
|
|
978
|
+
if (ts.isNoSubstitutionTemplateLiteral(template)) return template.text;
|
|
979
|
+
|
|
980
|
+
let text = template.head.text;
|
|
981
|
+
template.templateSpans.forEach((span, index) => {
|
|
982
|
+
text += `${PLACEHOLDER_PREFIX}${index}`;
|
|
983
|
+
text += span.literal.text;
|
|
984
|
+
});
|
|
985
|
+
return text;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function getTypeSyntaxText(sourceFile, expression) {
|
|
989
|
+
const text = expression.getText(sourceFile).trim();
|
|
990
|
+
const arrayMatch = text.match(/^Array<(.+)>$/s);
|
|
991
|
+
if (arrayMatch) return `${arrayMatch[1].trim()}[]`;
|
|
992
|
+
|
|
993
|
+
if (ts.isIdentifier(expression)) {
|
|
994
|
+
switch (expression.text) {
|
|
995
|
+
case 'String':
|
|
996
|
+
return 'string';
|
|
997
|
+
case 'Number':
|
|
998
|
+
return 'number';
|
|
999
|
+
case 'Boolean':
|
|
1000
|
+
return 'boolean';
|
|
1001
|
+
case 'Array':
|
|
1002
|
+
return 'unknown[]';
|
|
1003
|
+
case 'Object':
|
|
1004
|
+
return 'object';
|
|
1005
|
+
default:
|
|
1006
|
+
return expression.getText(sourceFile);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (
|
|
1011
|
+
ts.isCallExpression(expression) &&
|
|
1012
|
+
ts.isIdentifier(expression.expression)
|
|
1013
|
+
) {
|
|
1014
|
+
if (
|
|
1015
|
+
expression.expression.text === 'Array' &&
|
|
1016
|
+
expression.typeArguments?.length === 1
|
|
1017
|
+
) {
|
|
1018
|
+
return `${expression.typeArguments[0].getText(sourceFile)}[]`;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
1023
|
+
return expression.getText(sourceFile);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return undefined;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function isImportLikeDeclaration(node) {
|
|
1030
|
+
return (
|
|
1031
|
+
ts.isImportClause(node) ||
|
|
1032
|
+
ts.isImportEqualsDeclaration(node) ||
|
|
1033
|
+
ts.isImportSpecifier(node) ||
|
|
1034
|
+
ts.isNamespaceImport(node)
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function isArithmeticOperator(kind) {
|
|
1039
|
+
return (
|
|
1040
|
+
kind === ts.SyntaxKind.AsteriskToken ||
|
|
1041
|
+
kind === ts.SyntaxKind.SlashToken ||
|
|
1042
|
+
kind === ts.SyntaxKind.PercentToken ||
|
|
1043
|
+
kind === ts.SyntaxKind.MinusToken ||
|
|
1044
|
+
kind === ts.SyntaxKind.AsteriskAsteriskToken
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function isCallCallee(node) {
|
|
1049
|
+
return ts.isCallExpression(node.parent) && node.parent.expression === node;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function isNumericLikeType(type) {
|
|
1053
|
+
const parts = type.isUnion() ? type.types : [type];
|
|
1054
|
+
return parts.every(part => {
|
|
1055
|
+
const flags = part.flags;
|
|
1056
|
+
return Boolean(
|
|
1057
|
+
flags &
|
|
1058
|
+
(ts.TypeFlags.Number |
|
|
1059
|
+
ts.TypeFlags.NumberLiteral |
|
|
1060
|
+
ts.TypeFlags.BigInt |
|
|
1061
|
+
ts.TypeFlags.BigIntLiteral |
|
|
1062
|
+
ts.TypeFlags.Any)
|
|
1063
|
+
);
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function isWrecComponentFile(filePath) {
|
|
1068
|
+
const sourceText = fs.readFileSync(filePath, 'utf8');
|
|
1069
|
+
const program = createProgram(filePath, sourceText);
|
|
1070
|
+
const sourceFile = program.getSourceFile(filePath);
|
|
1071
|
+
if (!sourceFile) return false;
|
|
1072
|
+
return Boolean(findWrecClass(sourceFile, program.getTypeChecker()));
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function isStaticMember(node) {
|
|
1076
|
+
return ts.canHaveModifiers(node)
|
|
1077
|
+
? ts
|
|
1078
|
+
.getModifiers(node)
|
|
1079
|
+
?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword)
|
|
1080
|
+
: false;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function isWrecRooted(expression) {
|
|
1084
|
+
if (ts.isIdentifier(expression) && expression.text === WREC_REF_NAME) {
|
|
1085
|
+
return true;
|
|
1086
|
+
}
|
|
1087
|
+
if (
|
|
1088
|
+
ts.isPropertyAccessExpression(expression) ||
|
|
1089
|
+
ts.isElementAccessExpression(expression)
|
|
1090
|
+
) {
|
|
1091
|
+
return isWrecRooted(expression.expression);
|
|
1092
|
+
}
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function requiresContextFunction(symbol, sourceFile) {
|
|
1097
|
+
const declarations = symbol.declarations ?? [];
|
|
1098
|
+
return declarations.some(declaration => {
|
|
1099
|
+
if (isImportLikeDeclaration(declaration)) return true;
|
|
1100
|
+
return declaration.getSourceFile() === sourceFile;
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function resolveImportPath(baseDir, importPath) {
|
|
1105
|
+
if (!importPath.startsWith('.')) return undefined;
|
|
1106
|
+
|
|
1107
|
+
const candidates = [
|
|
1108
|
+
path.resolve(baseDir, importPath),
|
|
1109
|
+
path.resolve(baseDir, `${importPath}.js`),
|
|
1110
|
+
path.resolve(baseDir, `${importPath}.ts`),
|
|
1111
|
+
path.resolve(baseDir, importPath, 'index.js'),
|
|
1112
|
+
path.resolve(baseDir, importPath, 'index.ts')
|
|
1113
|
+
];
|
|
1114
|
+
|
|
1115
|
+
return candidates.find(candidate => fs.existsSync(candidate));
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
export function lintSource(filePath, sourceText, options = {}) {
|
|
1119
|
+
const baseProgram = createProgram(filePath, sourceText);
|
|
1120
|
+
const sourceFile = baseProgram.getSourceFile(filePath);
|
|
1121
|
+
if (!sourceFile) throw new Error(`unable to parse ${filePath}`);
|
|
1122
|
+
|
|
1123
|
+
const checker = baseProgram.getTypeChecker();
|
|
1124
|
+
const classNode = findWrecClass(sourceFile, checker);
|
|
1125
|
+
if (!classNode) throw new Error('file must define a subclass of Wrec');
|
|
1126
|
+
const componentPropertyMaps = getComponentPropertyMaps(filePath, sourceText);
|
|
1127
|
+
|
|
1128
|
+
const {
|
|
1129
|
+
supportedProps,
|
|
1130
|
+
computedExprs,
|
|
1131
|
+
contextKeys,
|
|
1132
|
+
duplicateProperties,
|
|
1133
|
+
formAssociated,
|
|
1134
|
+
propertyEntries,
|
|
1135
|
+
reservedProperties
|
|
1136
|
+
} = extractProperties(sourceFile, checker, classNode);
|
|
1137
|
+
const allMethods = collectClassMethods(classNode);
|
|
1138
|
+
const findings = {
|
|
1139
|
+
duplicateProperties,
|
|
1140
|
+
incompatibleArguments: [],
|
|
1141
|
+
invalidComputedProperties: [],
|
|
1142
|
+
invalidDefaultValues: [],
|
|
1143
|
+
invalidEventHandlers: [],
|
|
1144
|
+
invalidFormAssocValues: [],
|
|
1145
|
+
invalidUseStateMaps: [],
|
|
1146
|
+
invalidUsedByReferences: [],
|
|
1147
|
+
invalidValuesConfigurations: [],
|
|
1148
|
+
missingFormAssociatedProperty: [],
|
|
1149
|
+
missingTypeProperties: [],
|
|
1150
|
+
reservedProperties,
|
|
1151
|
+
typeErrors: [],
|
|
1152
|
+
undefinedContextFunctions: [],
|
|
1153
|
+
undefinedMethods: [],
|
|
1154
|
+
undefinedProperties: [],
|
|
1155
|
+
unsupportedHtmlAttributes: [],
|
|
1156
|
+
unsupportedEventNames: []
|
|
1157
|
+
};
|
|
1158
|
+
const templateExprs = extractTemplateExpressions(
|
|
1159
|
+
classNode,
|
|
1160
|
+
findings,
|
|
1161
|
+
componentPropertyMaps
|
|
1162
|
+
);
|
|
1163
|
+
const allExpressions = [...templateExprs, ...computedExprs];
|
|
1164
|
+
|
|
1165
|
+
if (allMethods.has('formAssociatedCallback') && !formAssociated) {
|
|
1166
|
+
findings.missingFormAssociatedProperty.push(
|
|
1167
|
+
'formAssociatedCallback is defined, but static formAssociated is not true'
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const augmentedSource = buildAugmentedSource(
|
|
1172
|
+
sourceFile,
|
|
1173
|
+
classNode,
|
|
1174
|
+
supportedProps,
|
|
1175
|
+
contextKeys,
|
|
1176
|
+
allExpressions
|
|
1177
|
+
);
|
|
1178
|
+
const augmentedProgram = createProgram(filePath, augmentedSource);
|
|
1179
|
+
const augmentedSourceFile = augmentedProgram.getSourceFile(filePath);
|
|
1180
|
+
if (!augmentedSourceFile) throw new Error(`unable to analyze ${filePath}`);
|
|
1181
|
+
|
|
1182
|
+
const augmentedChecker = augmentedProgram.getTypeChecker();
|
|
1183
|
+
const augmentedClassNode = findWrecClass(
|
|
1184
|
+
augmentedSourceFile,
|
|
1185
|
+
augmentedChecker
|
|
1186
|
+
);
|
|
1187
|
+
if (!augmentedClassNode) {
|
|
1188
|
+
throw new Error('unable to find Wrec subclass after augmentation');
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const helperExpressions = collectHelperExpressions(augmentedSourceFile);
|
|
1192
|
+
|
|
1193
|
+
validatePropertyConfigs(
|
|
1194
|
+
checker,
|
|
1195
|
+
supportedProps,
|
|
1196
|
+
propertyEntries,
|
|
1197
|
+
allMethods,
|
|
1198
|
+
findings
|
|
1199
|
+
);
|
|
1200
|
+
collectUseStateMapErrors(classNode, supportedProps, findings);
|
|
1201
|
+
|
|
1202
|
+
allExpressions.forEach(expr => {
|
|
1203
|
+
if (
|
|
1204
|
+
expr.eventHandler &&
|
|
1205
|
+
IDENTIFIER_RE.test(expr.text) &&
|
|
1206
|
+
!allMethods.has(expr.text)
|
|
1207
|
+
) {
|
|
1208
|
+
uniquePush(
|
|
1209
|
+
findings.invalidEventHandlers,
|
|
1210
|
+
`"${expr.text}" is not a defined instance method`
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
helperExpressions.forEach((expressionNode, index) => {
|
|
1216
|
+
if (!expressionNode) return;
|
|
1217
|
+
analyzeExpression(
|
|
1218
|
+
expressionNode,
|
|
1219
|
+
augmentedChecker,
|
|
1220
|
+
augmentedClassNode,
|
|
1221
|
+
findings,
|
|
1222
|
+
{
|
|
1223
|
+
classMethods: allMethods,
|
|
1224
|
+
contextKeys: new Set(contextKeys),
|
|
1225
|
+
eventHandler: allExpressions[index]?.eventHandler ?? false,
|
|
1226
|
+
sourceFile: augmentedSourceFile
|
|
1227
|
+
}
|
|
1228
|
+
);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
findings.duplicateProperties.sort();
|
|
1232
|
+
findings.incompatibleArguments.sort(
|
|
1233
|
+
(a, b) =>
|
|
1234
|
+
a.methodName.localeCompare(b.methodName) ||
|
|
1235
|
+
a.parameterName.localeCompare(b.parameterName)
|
|
1236
|
+
);
|
|
1237
|
+
findings.invalidComputedProperties.sort();
|
|
1238
|
+
findings.invalidDefaultValues.sort();
|
|
1239
|
+
findings.invalidEventHandlers.sort();
|
|
1240
|
+
findings.invalidFormAssocValues.sort();
|
|
1241
|
+
findings.invalidUseStateMaps.sort();
|
|
1242
|
+
findings.invalidUsedByReferences.sort();
|
|
1243
|
+
findings.invalidValuesConfigurations.sort();
|
|
1244
|
+
findings.missingFormAssociatedProperty.sort();
|
|
1245
|
+
findings.missingTypeProperties.sort();
|
|
1246
|
+
findings.reservedProperties.sort();
|
|
1247
|
+
findings.typeErrors.sort((a, b) => a.expression.localeCompare(b.expression));
|
|
1248
|
+
findings.undefinedContextFunctions.sort();
|
|
1249
|
+
findings.undefinedMethods.sort();
|
|
1250
|
+
findings.undefinedProperties.sort();
|
|
1251
|
+
findings.unsupportedHtmlAttributes.sort();
|
|
1252
|
+
findings.unsupportedEventNames.sort();
|
|
1253
|
+
|
|
1254
|
+
return formatReport(filePath, supportedProps, allExpressions, findings, options);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
export function lintFile(filePath, options = {}) {
|
|
1258
|
+
const resolved = validateFilePath(filePath);
|
|
1259
|
+
return lintSource(resolved, fs.readFileSync(resolved, 'utf8'), options);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function main() {
|
|
1263
|
+
const [, , filePath, ...rest] = process.argv;
|
|
1264
|
+
if (rest.length > 0) {
|
|
1265
|
+
fail('usage: node scripts/wrec-lint.js [file.js|file.ts]');
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (filePath) {
|
|
1269
|
+
process.stdout.write(
|
|
1270
|
+
lintFile(validateFilePath(filePath), {
|
|
1271
|
+
showFileHeader: false
|
|
1272
|
+
})
|
|
1273
|
+
);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const rootDir = process.cwd();
|
|
1278
|
+
let previousHadIssues = false;
|
|
1279
|
+
findWrecFiles(rootDir, matchedFile => {
|
|
1280
|
+
const report = lintFile(matchedFile, {
|
|
1281
|
+
fileLabel: path.relative(rootDir, matchedFile) || path.basename(matchedFile),
|
|
1282
|
+
showDetailsForCleanFile: false,
|
|
1283
|
+
showNoIssuesMessage: false
|
|
1284
|
+
});
|
|
1285
|
+
const currentHasIssues = report.trim().includes('\n');
|
|
1286
|
+
if (previousHadIssues) process.stdout.write('\n');
|
|
1287
|
+
process.stdout.write(report);
|
|
1288
|
+
previousHadIssues = currentHasIssues;
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function toUserFacingExpression(text) {
|
|
1293
|
+
return text.replaceAll(WREC_REF_NAME, 'this');
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function typeExpressionKind(expression) {
|
|
1297
|
+
if (!expression) return undefined;
|
|
1298
|
+
if (ts.isIdentifier(expression)) return expression.text;
|
|
1299
|
+
return undefined;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function validateComputedProperty(
|
|
1303
|
+
propName,
|
|
1304
|
+
computedText,
|
|
1305
|
+
supportedProps,
|
|
1306
|
+
classMethods,
|
|
1307
|
+
findings
|
|
1308
|
+
) {
|
|
1309
|
+
for (const match of computedText.matchAll(THIS_REF_RE)) {
|
|
1310
|
+
const referencedName = match[1];
|
|
1311
|
+
if (!supportedProps.has(referencedName) && !classMethods.has(referencedName)) {
|
|
1312
|
+
findings.invalidComputedProperties.push(
|
|
1313
|
+
`property "${propName}" computed references missing property "${referencedName}"`
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
for (const match of computedText.matchAll(THIS_CALL_RE)) {
|
|
1319
|
+
const methodName = match[1];
|
|
1320
|
+
if (!classMethods.has(methodName)) {
|
|
1321
|
+
findings.invalidComputedProperties.push(
|
|
1322
|
+
`property "${propName}" computed calls non-method instance member "${methodName}"`
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function validateDefaultValue(checker, typeExpression, valueExpression) {
|
|
1329
|
+
if (!typeExpression || !valueExpression) return undefined;
|
|
1330
|
+
|
|
1331
|
+
const typeKind = typeExpressionKind(typeExpression);
|
|
1332
|
+
const valueType = checker.getTypeAtLocation(valueExpression);
|
|
1333
|
+
const typeName = typeKind ?? typeExpression.getText();
|
|
1334
|
+
const valueTypeName = checker.typeToString(valueType);
|
|
1335
|
+
|
|
1336
|
+
if (typeKind === 'String') {
|
|
1337
|
+
if (!(valueType.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral))) {
|
|
1338
|
+
return {typeName: 'string', valueTypeName};
|
|
1339
|
+
}
|
|
1340
|
+
return undefined;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (typeKind === 'Number') {
|
|
1344
|
+
if (!(valueType.flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral))) {
|
|
1345
|
+
return {typeName: 'number', valueTypeName};
|
|
1346
|
+
}
|
|
1347
|
+
return undefined;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
if (typeKind === 'Boolean') {
|
|
1351
|
+
if (!(valueType.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral))) {
|
|
1352
|
+
return {typeName: 'boolean', valueTypeName};
|
|
1353
|
+
}
|
|
1354
|
+
return undefined;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (typeKind === 'Array') {
|
|
1358
|
+
if (!checker.isArrayType(valueType) && !checker.isTupleType(valueType)) {
|
|
1359
|
+
return {typeName: 'array', valueTypeName};
|
|
1360
|
+
}
|
|
1361
|
+
return undefined;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if (typeKind === 'Object') {
|
|
1365
|
+
const isObjectLike =
|
|
1366
|
+
Boolean(valueType.flags & ts.TypeFlags.Object) &&
|
|
1367
|
+
!checker.isArrayType(valueType) &&
|
|
1368
|
+
!checker.isTupleType(valueType);
|
|
1369
|
+
if (!isObjectLike) {
|
|
1370
|
+
return {typeName: 'object', valueTypeName};
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
return undefined;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function validateFilePath(filePath) {
|
|
1378
|
+
const resolved = path.resolve(filePath);
|
|
1379
|
+
const ext = path.extname(resolved);
|
|
1380
|
+
if (ext !== '.js' && ext !== '.ts') {
|
|
1381
|
+
throw new Error('argument must be a path to a .js or .ts file');
|
|
1382
|
+
}
|
|
1383
|
+
if (!fs.existsSync(resolved)) {
|
|
1384
|
+
throw new Error(`file not found: ${resolved}`);
|
|
1385
|
+
}
|
|
1386
|
+
return resolved;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function validatePropertyConfigs(
|
|
1390
|
+
checker,
|
|
1391
|
+
supportedProps,
|
|
1392
|
+
propertyEntries,
|
|
1393
|
+
classMethods,
|
|
1394
|
+
findings
|
|
1395
|
+
) {
|
|
1396
|
+
for (const {config, propName} of propertyEntries) {
|
|
1397
|
+
const typeProp = getObjectProperty(config, 'type');
|
|
1398
|
+
const usedByProp = getObjectProperty(config, 'usedBy');
|
|
1399
|
+
const computedProp = getObjectProperty(config, 'computed');
|
|
1400
|
+
const valueProp = getObjectProperty(config, 'value');
|
|
1401
|
+
const valuesProp = getObjectProperty(config, 'values');
|
|
1402
|
+
|
|
1403
|
+
const typeExpression =
|
|
1404
|
+
typeProp && ts.isPropertyAssignment(typeProp) ? typeProp.initializer : undefined;
|
|
1405
|
+
|
|
1406
|
+
if (!typeExpression) {
|
|
1407
|
+
findings.missingTypeProperties.push(
|
|
1408
|
+
`property "${propName}" does not specify a type`
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
if (usedByProp && ts.isPropertyAssignment(usedByProp)) {
|
|
1413
|
+
const methods = getStringOrStringArrayLiteral(usedByProp);
|
|
1414
|
+
|
|
1415
|
+
if (methods) {
|
|
1416
|
+
for (const methodName of methods) {
|
|
1417
|
+
if (!classMethods.has(methodName)) {
|
|
1418
|
+
findings.invalidUsedByReferences.push(
|
|
1419
|
+
`property "${propName}" usedBy references missing method "${methodName}"`
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
if (
|
|
1427
|
+
computedProp &&
|
|
1428
|
+
ts.isPropertyAssignment(computedProp) &&
|
|
1429
|
+
(ts.isStringLiteral(computedProp.initializer) ||
|
|
1430
|
+
ts.isNoSubstitutionTemplateLiteral(computedProp.initializer))
|
|
1431
|
+
) {
|
|
1432
|
+
validateComputedProperty(
|
|
1433
|
+
propName,
|
|
1434
|
+
computedProp.initializer.text,
|
|
1435
|
+
supportedProps,
|
|
1436
|
+
classMethods,
|
|
1437
|
+
findings
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const values = getStringArrayLiteral(valuesProp);
|
|
1442
|
+
if (values) {
|
|
1443
|
+
if (typeExpressionKind(typeExpression) !== 'String') {
|
|
1444
|
+
findings.invalidValuesConfigurations.push(
|
|
1445
|
+
`property "${propName}" uses values, but its type is not String`
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (
|
|
1450
|
+
valueProp &&
|
|
1451
|
+
ts.isPropertyAssignment(valueProp) &&
|
|
1452
|
+
(ts.isStringLiteral(valueProp.initializer) ||
|
|
1453
|
+
ts.isNoSubstitutionTemplateLiteral(valueProp.initializer)) &&
|
|
1454
|
+
!values.includes(valueProp.initializer.text)
|
|
1455
|
+
) {
|
|
1456
|
+
findings.invalidDefaultValues.push(
|
|
1457
|
+
`property "${propName}" default value "${valueProp.initializer.text}" is not in values`
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
if (valueProp && ts.isPropertyAssignment(valueProp)) {
|
|
1463
|
+
const mismatch = validateDefaultValue(
|
|
1464
|
+
checker,
|
|
1465
|
+
typeExpression,
|
|
1466
|
+
valueProp.initializer
|
|
1467
|
+
);
|
|
1468
|
+
if (mismatch) {
|
|
1469
|
+
findings.invalidDefaultValues.push(
|
|
1470
|
+
`property "${propName}" default value has type ${mismatch.valueTypeName}, but declared type is ${mismatch.typeName}`
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function typeNodeFromConstructorExpression(expression) {
|
|
1478
|
+
if (ts.isIdentifier(expression)) {
|
|
1479
|
+
switch (expression.text) {
|
|
1480
|
+
case 'String':
|
|
1481
|
+
return ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
|
|
1482
|
+
case 'Number':
|
|
1483
|
+
return ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
|
|
1484
|
+
case 'Boolean':
|
|
1485
|
+
return ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
|
|
1486
|
+
case 'Object':
|
|
1487
|
+
return ts.factory.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword);
|
|
1488
|
+
default:
|
|
1489
|
+
return ts.factory.createTypeReferenceNode(expression.text);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (
|
|
1494
|
+
ts.isCallExpression(expression) &&
|
|
1495
|
+
ts.isIdentifier(expression.expression)
|
|
1496
|
+
) {
|
|
1497
|
+
const name = expression.expression.text;
|
|
1498
|
+
if (name === 'Array' && expression.typeArguments?.length === 1) {
|
|
1499
|
+
return ts.factory.createArrayTypeNode(expression.typeArguments[0]);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
1504
|
+
return ts.factory.createTypeReferenceNode(expression.getText());
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
return undefined;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function uniquePush(array, value) {
|
|
1511
|
+
if (!array.includes(value)) array.push(value);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function validateFormAssocAttribute(attrName, attrValue, findings) {
|
|
1515
|
+
if (attrName !== 'form-assoc') return;
|
|
1516
|
+
|
|
1517
|
+
const pairs = attrValue.split(',');
|
|
1518
|
+
for (const pair of pairs) {
|
|
1519
|
+
const trimmed = pair.trim();
|
|
1520
|
+
const [propName, fieldName, ...rest] = trimmed.split(':').map(part =>
|
|
1521
|
+
part.trim()
|
|
1522
|
+
);
|
|
1523
|
+
if (!trimmed || rest.length > 0 || !propName || !fieldName) {
|
|
1524
|
+
findings.invalidFormAssocValues.push(
|
|
1525
|
+
`form-assoc="${attrValue}" is invalid; expected "property:field" or a comma-separated list of them`
|
|
1526
|
+
);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function validateFormAssocPropertyMappings(
|
|
1533
|
+
node,
|
|
1534
|
+
attrName,
|
|
1535
|
+
attrValue,
|
|
1536
|
+
findings,
|
|
1537
|
+
componentPropertyMaps
|
|
1538
|
+
) {
|
|
1539
|
+
if (attrName !== 'form-assoc') return;
|
|
1540
|
+
const tagName = (node.rawTagName || node.tagName || '').toLowerCase();
|
|
1541
|
+
const supportedProps = componentPropertyMaps.get(tagName);
|
|
1542
|
+
if (!supportedProps) return;
|
|
1543
|
+
|
|
1544
|
+
const pairs = attrValue.split(',');
|
|
1545
|
+
for (const pair of pairs) {
|
|
1546
|
+
const [propName] = pair.split(':').map(part => part.trim());
|
|
1547
|
+
if (!propName) continue;
|
|
1548
|
+
if (!supportedProps.has(propName)) {
|
|
1549
|
+
findings.invalidFormAssocValues.push(
|
|
1550
|
+
`form-assoc="${attrValue}" refers to missing component property "${propName}"`
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function validateHtmlAttribute(node, attrName, findings) {
|
|
1557
|
+
if (attrName.startsWith('aria-') || attrName.startsWith('data-')) return;
|
|
1558
|
+
if (attrName.startsWith('on')) return;
|
|
1559
|
+
if (attrName === 'form-assoc') return;
|
|
1560
|
+
|
|
1561
|
+
const [baseAttrName] = attrName.split(':');
|
|
1562
|
+
if (HTML_GLOBAL_ATTRIBUTES.has(baseAttrName)) return;
|
|
1563
|
+
|
|
1564
|
+
const tagName = (node.rawTagName || node.tagName || '').toLowerCase();
|
|
1565
|
+
if (!tagName || tagName.includes('-')) return;
|
|
1566
|
+
|
|
1567
|
+
const supported = getSupportedHtmlAttributes(tagName);
|
|
1568
|
+
if (!supported) return;
|
|
1569
|
+
if (supported.has(baseAttrName)) return;
|
|
1570
|
+
|
|
1571
|
+
findings.unsupportedHtmlAttributes.push(
|
|
1572
|
+
`${tagName} attribute "${attrName}" is not supported`
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function validateValueBindingEvent(node, attrName, findings) {
|
|
1577
|
+
const [realAttrName, eventName] = attrName.split(':');
|
|
1578
|
+
if (realAttrName !== 'value' || !eventName) return;
|
|
1579
|
+
if (SUPPORTED_EVENT_NAMES.has(eventName)) return;
|
|
1580
|
+
|
|
1581
|
+
const tagName = node.rawTagName || node.tagName || 'element';
|
|
1582
|
+
findings.unsupportedEventNames.push(
|
|
1583
|
+
`${tagName} attribute "${attrName}" refers to an unsupported event name "${eventName}"`
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function walkHtmlNode(node, expressions, findings, componentPropertyMaps) {
|
|
1588
|
+
if (node.nodeType === 1) {
|
|
1589
|
+
for (const [attrName, attrValue] of Object.entries(node.attributes)) {
|
|
1590
|
+
if (!attrValue) continue;
|
|
1591
|
+
validateFormAssocAttribute(attrName, attrValue, findings);
|
|
1592
|
+
validateFormAssocPropertyMappings(
|
|
1593
|
+
node,
|
|
1594
|
+
attrName,
|
|
1595
|
+
attrValue,
|
|
1596
|
+
findings,
|
|
1597
|
+
componentPropertyMaps
|
|
1598
|
+
);
|
|
1599
|
+
validateHtmlAttribute(node, attrName, findings);
|
|
1600
|
+
validateValueBindingEvent(node, attrName, findings);
|
|
1601
|
+
if (
|
|
1602
|
+
REFS_TEST_RE.test(attrValue) ||
|
|
1603
|
+
(attrName.startsWith('on') && IDENTIFIER_RE.test(attrValue))
|
|
1604
|
+
) {
|
|
1605
|
+
expressions.push({
|
|
1606
|
+
context: 'instance',
|
|
1607
|
+
eventHandler: attrName.startsWith('on'),
|
|
1608
|
+
kind: 'html',
|
|
1609
|
+
text: attrValue.trim(),
|
|
1610
|
+
location: null
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
if (
|
|
1617
|
+
(node.nodeType === 3 || node.nodeType === 8) &&
|
|
1618
|
+
typeof node.rawText === 'string'
|
|
1619
|
+
) {
|
|
1620
|
+
const text = node.rawText.trim();
|
|
1621
|
+
if (text && REFS_TEST_RE.test(text)) {
|
|
1622
|
+
expressions.push({
|
|
1623
|
+
context: 'instance',
|
|
1624
|
+
eventHandler: false,
|
|
1625
|
+
kind: 'html',
|
|
1626
|
+
text,
|
|
1627
|
+
location: null
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
for (const child of node.childNodes ?? []) {
|
|
1633
|
+
walkHtmlNode(child, expressions, findings, componentPropertyMaps);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const isCliEntry = (() => {
|
|
1638
|
+
if (!process.argv[1]) return false;
|
|
1639
|
+
|
|
1640
|
+
try {
|
|
1641
|
+
return (
|
|
1642
|
+
fs.realpathSync(process.argv[1]) ===
|
|
1643
|
+
fs.realpathSync(fileURLToPath(import.meta.url))
|
|
1644
|
+
);
|
|
1645
|
+
} catch {
|
|
1646
|
+
return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
1647
|
+
}
|
|
1648
|
+
})();
|
|
1649
|
+
|
|
1650
|
+
if (isCliEntry) {
|
|
1651
|
+
try {
|
|
1652
|
+
main();
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
1655
|
+
}
|
|
1656
|
+
}
|