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.
@@ -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
+ }