wrec 0.34.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "wrec",
3
3
  "description": "a library that greatly simplifies building web components",
4
4
  "author": "R. Mark Volkmann",
5
- "version": "0.34.0",
5
+ "version": "0.35.1",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -29,6 +29,7 @@
29
29
  }
30
30
  },
31
31
  "bin": {
32
+ "wrec-declare": "./scripts/declare.js",
32
33
  "wrec-lint": "./scripts/lint.js",
33
34
  "wrec-scaffold": "./scripts/scaffold.js",
34
35
  "wrec-usedby": "./scripts/used-by.js"
@@ -36,6 +37,7 @@
36
37
  "files": [
37
38
  "dist",
38
39
  "scripts/ast-utils.js",
40
+ "scripts/declare.js",
39
41
  "scripts/lint.js",
40
42
  "scripts/scaffold.js",
41
43
  "scripts/template.ts",
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+
3
+ // This script inspects a given Wrec component source file and
4
+ // generates TypeScript `declare` statements for the properties
5
+ // found in the component's `static properties` declaration.
6
+ //
7
+ // To run this, enter `npx wrec-declare [--dry] {file-path}`
8
+ //
9
+ // Include the --dry flag to report whether changes are needed
10
+ // without writing the modified source back to the file.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import ts from 'typescript';
15
+ import {
16
+ collectWrecClasses,
17
+ getMemberName,
18
+ hasStaticModifier
19
+ } from './ast-utils.js';
20
+
21
+ const cwd = process.cwd();
22
+ const DECLARE_TYPE_MAP = new Map([
23
+ ['Array', 'unknown[]'],
24
+ ['Boolean', 'boolean'],
25
+ ['Number', 'number'],
26
+ ['Object', 'object'],
27
+ ['String', 'string']
28
+ ]);
29
+
30
+ // Analyzes a parsed source file and returns any proposed `declare` edits.
31
+ function analyzeSourceFile(sourceFile) {
32
+ const edits = [];
33
+ const classNodes = collectWrecClasses(sourceFile);
34
+
35
+ for (const classNode of classNodes) {
36
+ const propertiesMember = getLastPropertiesDeclaration(classNode);
37
+ if (!propertiesMember) continue;
38
+
39
+ const declareLines = [];
40
+ for (const property of propertiesMember.initializer.properties) {
41
+ if (!ts.isPropertyAssignment(property)) continue;
42
+
43
+ const propName = getMemberName(property);
44
+ if (!propName || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propName)) continue;
45
+
46
+ const declareType = getDeclareType(property.initializer);
47
+ if (!declareType) continue;
48
+ declareLines.push(createDeclareLine(propName, declareType));
49
+ }
50
+
51
+ const start = propertiesMember.end;
52
+ const end = findDeclareBlockEnd(classNode, propertiesMember, sourceFile);
53
+ const nextText = buildDeclareBlock(
54
+ sourceFile,
55
+ classNode,
56
+ propertiesMember,
57
+ declareLines
58
+ );
59
+ const currentText = sourceFile.text.slice(start, end);
60
+
61
+ if (nextText !== currentText) {
62
+ edits.push({end, start, text: nextText});
63
+ }
64
+ }
65
+
66
+ if (classNodes.length === 0) {
67
+ return {changed: false, foundWrecSubclass: false, text: sourceFile.text};
68
+ }
69
+
70
+ if (edits.length === 0) {
71
+ return {changed: false, foundWrecSubclass: true, text: sourceFile.text};
72
+ }
73
+
74
+ let nextSource = sourceFile.text;
75
+ edits.sort((a, b) => b.start - a.start);
76
+
77
+ for (const edit of edits) {
78
+ nextSource =
79
+ nextSource.slice(0, edit.start) + edit.text + nextSource.slice(edit.end);
80
+ }
81
+
82
+ return {changed: true, foundWrecSubclass: true, text: nextSource};
83
+ }
84
+
85
+ // Builds the `declare` block that should appear after `static properties`.
86
+ function buildDeclareBlock(sourceFile, classNode, propertiesMember, declareLines) {
87
+ const {text} = sourceFile;
88
+ const memberIndent = getIndent(text, propertiesMember.getStart(sourceFile));
89
+ const startIndex = classNode.members.indexOf(propertiesMember) + 1;
90
+ let nextMember = null;
91
+
92
+ for (let index = startIndex; index < classNode.members.length; index += 1) {
93
+ const member = classNode.members[index];
94
+ if (isDeclarePropertyDeclaration(member)) continue;
95
+
96
+ nextMember = member;
97
+ break;
98
+ }
99
+
100
+ const nextIndent = nextMember
101
+ ? getIndent(text, nextMember.getStart(sourceFile))
102
+ : '';
103
+
104
+ if (declareLines.length === 0) {
105
+ return nextMember ? `\n\n${nextIndent}` : '\n';
106
+ }
107
+
108
+ const content = declareLines
109
+ .map(line => `${memberIndent}${line}`)
110
+ .join('\n');
111
+ return nextMember ? `\n${content}\n\n${nextIndent}` : `\n${content}\n`;
112
+ }
113
+
114
+ // Creates a single `declare` statement for a component property.
115
+ function createDeclareLine(propName, declareType) {
116
+ return `declare ${propName}: ${declareType};`;
117
+ }
118
+
119
+ // Determines what changes, if any, should be made in a source file.
120
+ export function evaluateSourceFile(filePath, options = {}) {
121
+ const {dry = false} = options;
122
+ const absFilePath = path.resolve(cwd, filePath);
123
+ validateFile(absFilePath);
124
+
125
+ const text = fs.readFileSync(absFilePath, 'utf8');
126
+ const result = evaluateSourceText(absFilePath, text);
127
+
128
+ if (!result.foundWrecSubclass) {
129
+ throw new Error('No class extending Wrec was found.');
130
+ }
131
+
132
+ if (!dry && result.changed) {
133
+ fs.writeFileSync(absFilePath, result.text);
134
+ }
135
+
136
+ return result;
137
+ }
138
+
139
+ // Determines what changes, if any, should be made in source file text.
140
+ export function evaluateSourceText(filePath, text) {
141
+ const scriptKind = filePath.endsWith('.ts')
142
+ ? ts.ScriptKind.TS
143
+ : ts.ScriptKind.JS;
144
+ const sourceFile = ts.createSourceFile(
145
+ filePath,
146
+ text,
147
+ ts.ScriptTarget.Latest,
148
+ true,
149
+ scriptKind
150
+ );
151
+ return analyzeSourceFile(sourceFile);
152
+ }
153
+
154
+ // Finds the end of the contiguous `declare` block after `static properties`.
155
+ function findDeclareBlockEnd(classNode, propertiesMember, sourceFile) {
156
+ const startIndex = classNode.members.indexOf(propertiesMember) + 1;
157
+
158
+ for (let index = startIndex; index < classNode.members.length; index += 1) {
159
+ const member = classNode.members[index];
160
+ if (isDeclarePropertyDeclaration(member)) continue;
161
+ return member.getStart(sourceFile);
162
+ }
163
+
164
+ return classNode.end - 1;
165
+ }
166
+
167
+ // Gets the TypeScript type string for a property configuration object.
168
+ function getDeclareType(initializer) {
169
+ if (!ts.isObjectLiteralExpression(initializer)) return null;
170
+
171
+ for (const property of initializer.properties) {
172
+ if (
173
+ !ts.isPropertyAssignment(property) ||
174
+ getMemberName(property) !== 'type' ||
175
+ !ts.isIdentifier(property.initializer)
176
+ ) {
177
+ continue;
178
+ }
179
+
180
+ return DECLARE_TYPE_MAP.get(property.initializer.text) ?? null;
181
+ }
182
+
183
+ return null;
184
+ }
185
+
186
+ // Gets the leading indentation in the line that contains a given position.
187
+ function getIndent(text, pos) {
188
+ const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
189
+ const match = /^[ \t]*/.exec(text.slice(lineStart));
190
+ return match ? match[0] : '';
191
+ }
192
+
193
+ // Gets the last `static properties = { ... }` declaration in a class.
194
+ function getLastPropertiesDeclaration(classNode) {
195
+ let propertiesMember = null;
196
+
197
+ for (const member of classNode.members) {
198
+ if (
199
+ ts.isPropertyDeclaration(member) &&
200
+ hasStaticModifier(member) &&
201
+ getMemberName(member) === 'properties' &&
202
+ member.initializer &&
203
+ ts.isObjectLiteralExpression(member.initializer)
204
+ ) {
205
+ propertiesMember = member;
206
+ }
207
+ }
208
+
209
+ return propertiesMember;
210
+ }
211
+
212
+ // Determines whether a class member is a `declare` property declaration.
213
+ function isDeclarePropertyDeclaration(member) {
214
+ return (
215
+ ts.isPropertyDeclaration(member) &&
216
+ ts.canHaveModifiers(member) &&
217
+ ts
218
+ .getModifiers(member)
219
+ ?.some(mod => mod.kind === ts.SyntaxKind.DeclareKeyword) === true
220
+ );
221
+ }
222
+
223
+ // Handles CLI arguments and runs the script.
224
+ function main() {
225
+ const args = process.argv.slice(2);
226
+ const unknownFlags = args.filter(
227
+ arg => arg.startsWith('--') && arg !== '--dry'
228
+ );
229
+ if (unknownFlags.length > 0) {
230
+ throw new Error(`unknown option: ${unknownFlags[0]}`);
231
+ }
232
+
233
+ const inputPaths = args.filter(arg => !arg.startsWith('--'));
234
+ if (inputPaths.length !== 1) {
235
+ throw new Error('Specify a single source file');
236
+ }
237
+
238
+ const dry = args.includes('--dry');
239
+ const result = evaluateSourceFile(inputPaths[0], {dry});
240
+
241
+ if (dry) {
242
+ if (result.changed) process.exit(1);
243
+ console.info('no changes needed');
244
+ return;
245
+ }
246
+
247
+ if (result.changed) {
248
+ console.info('updated source file');
249
+ } else {
250
+ console.info('no changes needed');
251
+ }
252
+ }
253
+
254
+ // Validates that a source file exists and has a supported extension.
255
+ function validateFile(absFilePath) {
256
+ if (!fs.existsSync(absFilePath)) throw new Error('File not found');
257
+
258
+ const stat = fs.statSync(absFilePath);
259
+ if (!stat.isFile()) throw new Error('Not a file');
260
+
261
+ if (!/\.(js|ts)$/.test(absFilePath) || /\.d\.ts$/.test(absFilePath)) {
262
+ throw new Error('Unsupported file type');
263
+ }
264
+ }
265
+
266
+ // Runs the CLI when this module is executed directly.
267
+ if (import.meta.main) {
268
+ try {
269
+ main();
270
+ } catch (error) {
271
+ console.error(error instanceof Error ? error.message : String(error));
272
+ process.exit(1);
273
+ }
274
+ }
package/scripts/lint.js CHANGED
@@ -119,6 +119,13 @@ const THIS_CALL_RE = /this\.([A-Za-z_$][\w$]*)\s*\(/g;
119
119
  const THIS_REF_RE = /this\.([A-Za-z_$][\w$]*)(\.[A-Za-z_$][\w$]*)*/g;
120
120
  const PLACEHOLDER_PREFIX = '__WREC_PLACEHOLDER__';
121
121
  const RESERVED_PROPERTY_NAMES = new Set(['class', 'style']);
122
+ const SUPPORTED_PROPERTY_TYPE_NAMES = new Set([
123
+ 'Array',
124
+ 'Boolean',
125
+ 'Number',
126
+ 'Object',
127
+ 'String'
128
+ ]);
122
129
  const GETTER_PREFIX = 'get ';
123
130
  const SUPPORTED_EVENT_NAMES = new Set([
124
131
  'blur',
@@ -444,6 +451,21 @@ function collectSupportedPropertyNames(classNode) {
444
451
  return supportedProps;
445
452
  }
446
453
 
454
+ // Collects declared TypeScript property types from a component class.
455
+ function collectDeclaredPropertyTypes(classNode) {
456
+ const declaredProps = new Map();
457
+
458
+ for (const member of classNode.members) {
459
+ if (!isDeclarePropertyDeclaration(member)) continue;
460
+
461
+ const propName = getMemberName(member);
462
+ if (!propName || !member.type) continue;
463
+ declaredProps.set(propName, member.type);
464
+ }
465
+
466
+ return declaredProps;
467
+ }
468
+
447
469
  // Validates that useState mappings point at existing component properties.
448
470
  function collectUseStateMapErrors(classNode, supportedProps, findings) {
449
471
  // Walks the class body looking for useState calls with mapping objects.
@@ -806,27 +828,29 @@ function formatReport(
806
828
 
807
829
  const hasIssues =
808
830
  findings.duplicateProperties.length > 0 ||
809
- findings.reservedProperties.length > 0 ||
810
- findings.invalidUsedByReferences.length > 0 ||
831
+ findings.extraArguments.length > 0 ||
832
+ findings.incompatibleArguments.length > 0 ||
833
+ findings.incompatibleDeclareTypes.length > 0 ||
811
834
  findings.invalidCheckedBindings.length > 0 ||
812
835
  findings.invalidComputedProperties.length > 0 ||
813
- findings.invalidRefAttributes.length > 0 ||
814
- findings.invalidValuesConfigurations.length > 0 ||
815
836
  findings.invalidDefaultValues.length > 0 ||
837
+ findings.invalidEventHandlers.length > 0 ||
816
838
  findings.invalidFormAssocValues.length > 0 ||
839
+ findings.invalidHtmlNesting.length > 0 ||
840
+ findings.invalidRefAttributes.length > 0 ||
841
+ findings.invalidTypeProperties.length > 0 ||
842
+ findings.invalidUsedByReferences.length > 0 ||
817
843
  findings.invalidUseStateMaps.length > 0 ||
844
+ findings.invalidValuesConfigurations.length > 0 ||
818
845
  findings.missingFormAssociatedProperty.length > 0 ||
819
846
  findings.missingTypeProperties.length > 0 ||
820
- findings.undefinedProperties.length > 0 ||
847
+ findings.reservedProperties.length > 0 ||
848
+ findings.typeErrors.length > 0 ||
821
849
  findings.undefinedContextFunctions.length > 0 ||
822
850
  findings.undefinedMethods.length > 0 ||
823
- findings.extraArguments.length > 0 ||
824
- findings.incompatibleArguments.length > 0 ||
825
- findings.invalidEventHandlers.length > 0 ||
826
- findings.invalidHtmlNesting.length > 0 ||
827
- findings.unsupportedHtmlAttributes.length > 0 ||
851
+ findings.undefinedProperties.length > 0 ||
828
852
  findings.unsupportedEventNames.length > 0 ||
829
- findings.typeErrors.length > 0;
853
+ findings.unsupportedHtmlAttributes.length > 0;
830
854
 
831
855
  if (showFileHeader) lines.push(`file: ${fileLabel}`);
832
856
 
@@ -859,21 +883,31 @@ function formatReport(
859
883
  findings.duplicateProperties.forEach(name => lines.push(` ${name}`));
860
884
  }
861
885
 
862
- if (findings.reservedProperties.length > 0) {
863
- lines.push('reserved property names:');
864
- findings.reservedProperties.forEach(name => lines.push(` ${name}`));
886
+ if (findings.extraArguments.length > 0) {
887
+ lines.push('extra arguments:');
888
+ findings.extraArguments.forEach(finding => {
889
+ lines.push(
890
+ ` ${finding.methodName}: argument ${finding.argumentIndex} ` +
891
+ `"${finding.argument}" exceeds the ` +
892
+ `${finding.parameterCount}-parameter signature`
893
+ );
894
+ });
865
895
  }
866
896
 
867
- if (findings.invalidUsedByReferences.length > 0) {
868
- lines.push('invalid usedBy references:');
869
- findings.invalidUsedByReferences.forEach(message =>
870
- lines.push(` ${message}`)
871
- );
897
+ if (findings.incompatibleArguments.length > 0) {
898
+ lines.push('incompatible arguments:');
899
+ findings.incompatibleArguments.forEach(finding => {
900
+ lines.push(
901
+ ` ${finding.methodName}: argument "${finding.argument}" ` +
902
+ `has type ${finding.argumentType}, but parameter ` +
903
+ `"${finding.parameterName}" expects ${finding.parameterType}`
904
+ );
905
+ });
872
906
  }
873
907
 
874
- if (findings.invalidComputedProperties.length > 0) {
875
- lines.push('invalid computed properties:');
876
- findings.invalidComputedProperties.forEach(message =>
908
+ if (findings.incompatibleDeclareTypes.length > 0) {
909
+ lines.push('incompatible declare types:');
910
+ findings.incompatibleDeclareTypes.forEach(message =>
877
911
  lines.push(` ${message}`)
878
912
  );
879
913
  }
@@ -885,23 +919,23 @@ function formatReport(
885
919
  );
886
920
  }
887
921
 
888
- if (findings.invalidRefAttributes.length > 0) {
889
- lines.push('invalid ref attributes:');
890
- findings.invalidRefAttributes.forEach(message =>
922
+ if (findings.invalidComputedProperties.length > 0) {
923
+ lines.push('invalid computed properties:');
924
+ findings.invalidComputedProperties.forEach(message =>
891
925
  lines.push(` ${message}`)
892
926
  );
893
927
  }
894
928
 
895
- if (findings.invalidValuesConfigurations.length > 0) {
896
- lines.push('invalid values configurations:');
897
- findings.invalidValuesConfigurations.forEach(message =>
929
+ if (findings.invalidDefaultValues.length > 0) {
930
+ lines.push('invalid default values:');
931
+ findings.invalidDefaultValues.forEach(message =>
898
932
  lines.push(` ${message}`)
899
933
  );
900
934
  }
901
935
 
902
- if (findings.invalidDefaultValues.length > 0) {
903
- lines.push('invalid default values:');
904
- findings.invalidDefaultValues.forEach(message =>
936
+ if (findings.invalidEventHandlers.length > 0) {
937
+ lines.push('invalid event handler references:');
938
+ findings.invalidEventHandlers.forEach(message =>
905
939
  lines.push(` ${message}`)
906
940
  );
907
941
  }
@@ -913,38 +947,28 @@ function formatReport(
913
947
  );
914
948
  }
915
949
 
916
- if (findings.missingFormAssociatedProperty.length > 0) {
917
- lines.push('missing formAssociated property:');
918
- findings.missingFormAssociatedProperty.forEach(message =>
919
- lines.push(` ${message}`)
920
- );
950
+ if (findings.invalidHtmlNesting.length > 0) {
951
+ lines.push('invalid html nesting:');
952
+ findings.invalidHtmlNesting.forEach(message => lines.push(` ${message}`));
921
953
  }
922
954
 
923
- if (findings.missingTypeProperties.length > 0) {
924
- lines.push('missing type properties:');
925
- findings.missingTypeProperties.forEach(message =>
955
+ if (findings.invalidRefAttributes.length > 0) {
956
+ lines.push('invalid ref attributes:');
957
+ findings.invalidRefAttributes.forEach(message =>
926
958
  lines.push(` ${message}`)
927
959
  );
928
960
  }
929
961
 
930
- if (findings.undefinedProperties.length > 0) {
931
- lines.push('undefined properties:');
932
- findings.undefinedProperties.forEach(name => lines.push(` ${name}`));
933
- }
934
-
935
- if (findings.undefinedContextFunctions.length > 0) {
936
- lines.push('undefined context functions:');
937
- findings.undefinedContextFunctions.forEach(name => lines.push(` ${name}`));
938
- }
939
-
940
- if (findings.undefinedMethods.length > 0) {
941
- lines.push('undefined methods:');
942
- findings.undefinedMethods.forEach(name => lines.push(` ${name}`));
962
+ if (findings.invalidTypeProperties.length > 0) {
963
+ lines.push('invalid type properties:');
964
+ findings.invalidTypeProperties.forEach(message =>
965
+ lines.push(` ${message}`)
966
+ );
943
967
  }
944
968
 
945
- if (findings.invalidEventHandlers.length > 0) {
946
- lines.push('invalid event handler references:');
947
- findings.invalidEventHandlers.forEach(message =>
969
+ if (findings.invalidUsedByReferences.length > 0) {
970
+ lines.push('invalid usedBy references:');
971
+ findings.invalidUsedByReferences.forEach(message =>
948
972
  lines.push(` ${message}`)
949
973
  );
950
974
  }
@@ -954,31 +978,30 @@ function formatReport(
954
978
  findings.invalidUseStateMaps.forEach(message => lines.push(` ${message}`));
955
979
  }
956
980
 
957
- if (findings.invalidHtmlNesting.length > 0) {
958
- lines.push('invalid html nesting:');
959
- findings.invalidHtmlNesting.forEach(message => lines.push(` ${message}`));
981
+ if (findings.invalidValuesConfigurations.length > 0) {
982
+ lines.push('invalid values configurations:');
983
+ findings.invalidValuesConfigurations.forEach(message =>
984
+ lines.push(` ${message}`)
985
+ );
960
986
  }
961
987
 
962
- if (findings.extraArguments.length > 0) {
963
- lines.push('extra arguments:');
964
- findings.extraArguments.forEach(finding => {
965
- lines.push(
966
- ` ${finding.methodName}: argument ${finding.argumentIndex} ` +
967
- `"${finding.argument}" exceeds the ` +
968
- `${finding.parameterCount}-parameter signature`
969
- );
970
- });
988
+ if (findings.missingFormAssociatedProperty.length > 0) {
989
+ lines.push('missing formAssociated property:');
990
+ findings.missingFormAssociatedProperty.forEach(message =>
991
+ lines.push(` ${message}`)
992
+ );
971
993
  }
972
994
 
973
- if (findings.incompatibleArguments.length > 0) {
974
- lines.push('incompatible arguments:');
975
- findings.incompatibleArguments.forEach(finding => {
976
- lines.push(
977
- ` ${finding.methodName}: argument "${finding.argument}" ` +
978
- `has type ${finding.argumentType}, but parameter ` +
979
- `"${finding.parameterName}" expects ${finding.parameterType}`
980
- );
981
- });
995
+ if (findings.missingTypeProperties.length > 0) {
996
+ lines.push('missing type properties:');
997
+ findings.missingTypeProperties.forEach(message =>
998
+ lines.push(` ${message}`)
999
+ );
1000
+ }
1001
+
1002
+ if (findings.reservedProperties.length > 0) {
1003
+ lines.push('reserved property names:');
1004
+ findings.reservedProperties.forEach(name => lines.push(` ${name}`));
982
1005
  }
983
1006
 
984
1007
  if (findings.typeErrors.length > 0) {
@@ -988,11 +1011,19 @@ function formatReport(
988
1011
  });
989
1012
  }
990
1013
 
991
- if (findings.unsupportedHtmlAttributes.length > 0) {
992
- lines.push('unsupported html attributes:');
993
- findings.unsupportedHtmlAttributes.forEach(message =>
994
- lines.push(` ${message}`)
995
- );
1014
+ if (findings.undefinedContextFunctions.length > 0) {
1015
+ lines.push('undefined context functions:');
1016
+ findings.undefinedContextFunctions.forEach(name => lines.push(` ${name}`));
1017
+ }
1018
+
1019
+ if (findings.undefinedMethods.length > 0) {
1020
+ lines.push('undefined methods:');
1021
+ findings.undefinedMethods.forEach(name => lines.push(` ${name}`));
1022
+ }
1023
+
1024
+ if (findings.undefinedProperties.length > 0) {
1025
+ lines.push('undefined properties:');
1026
+ findings.undefinedProperties.forEach(name => lines.push(` ${name}`));
996
1027
  }
997
1028
 
998
1029
  if (findings.unsupportedEventNames.length > 0) {
@@ -1002,6 +1033,13 @@ function formatReport(
1002
1033
  );
1003
1034
  }
1004
1035
 
1036
+ if (findings.unsupportedHtmlAttributes.length > 0) {
1037
+ lines.push('unsupported html attributes:');
1038
+ findings.unsupportedHtmlAttributes.forEach(message =>
1039
+ lines.push(` ${message}`)
1040
+ );
1041
+ }
1042
+
1005
1043
  if (!hasIssues && showNoIssuesMessage) lines.push('no issues found');
1006
1044
 
1007
1045
  return `${lines.join('\n')}\n`;
@@ -1151,6 +1189,11 @@ function getPropertyTypeText(checker, sourceFile, expression) {
1151
1189
  return checker.typeToString(type);
1152
1190
  }
1153
1191
 
1192
+ // Gets the TypeScript type text for a declared property member.
1193
+ function getPropertyTypeTextFromNode(sourceFile, typeNode) {
1194
+ return typeNode.getText(sourceFile).trim();
1195
+ }
1196
+
1154
1197
  // Returns an array of string literal values from a property when possible.
1155
1198
  function getStringArrayLiteral(property) {
1156
1199
  if (!property || !ts.isPropertyAssignment(property)) return undefined;
@@ -1260,6 +1303,17 @@ function isCallCallee(node) {
1260
1303
  return ts.isCallExpression(node.parent) && node.parent.expression === node;
1261
1304
  }
1262
1305
 
1306
+ // Returns whether a class member is a declared property declaration.
1307
+ function isDeclarePropertyDeclaration(member) {
1308
+ return (
1309
+ ts.isPropertyDeclaration(member) &&
1310
+ ts.canHaveModifiers(member) &&
1311
+ ts
1312
+ .getModifiers(member)
1313
+ ?.some(mod => mod.kind === ts.SyntaxKind.DeclareKeyword)
1314
+ );
1315
+ }
1316
+
1263
1317
  // Returns whether a reference refers to a getter method.
1264
1318
  function isGetterReference(reference) {
1265
1319
  return reference.startsWith(GETTER_PREFIX);
@@ -1347,12 +1401,14 @@ export function lintSource(filePath, sourceText, options = {}) {
1347
1401
  propertyEntries,
1348
1402
  reservedProperties
1349
1403
  } = extractProperties(sourceFile, checker, classNode);
1404
+ const declaredPropertyTypes = collectDeclaredPropertyTypes(classNode);
1350
1405
  const getterNames = collectGetterNames(classNode);
1351
1406
  const allMethods = collectClassMethods(classNode);
1352
1407
  const findings = {
1353
1408
  duplicateProperties,
1354
1409
  extraArguments: [],
1355
1410
  incompatibleArguments: [],
1411
+ incompatibleDeclareTypes: [],
1356
1412
  invalidCheckedBindings: [],
1357
1413
  invalidComputedProperties: [],
1358
1414
  invalidDefaultValues: [],
@@ -1360,8 +1416,9 @@ export function lintSource(filePath, sourceText, options = {}) {
1360
1416
  invalidFormAssocValues: [],
1361
1417
  invalidHtmlNesting: [],
1362
1418
  invalidRefAttributes: [],
1363
- invalidUseStateMaps: [],
1419
+ invalidTypeProperties: [],
1364
1420
  invalidUsedByReferences: [],
1421
+ invalidUseStateMaps: [],
1365
1422
  invalidValuesConfigurations: [],
1366
1423
  missingFormAssociatedProperty: [],
1367
1424
  missingTypeProperties: [],
@@ -1370,8 +1427,8 @@ export function lintSource(filePath, sourceText, options = {}) {
1370
1427
  undefinedContextFunctions: [],
1371
1428
  undefinedMethods: [],
1372
1429
  undefinedProperties: [],
1373
- unsupportedHtmlAttributes: [],
1374
- unsupportedEventNames: []
1430
+ unsupportedEventNames: [],
1431
+ unsupportedHtmlAttributes: []
1375
1432
  };
1376
1433
  const templateExprs = extractTemplateExpressions(
1377
1434
  classNode,
@@ -1414,6 +1471,8 @@ export function lintSource(filePath, sourceText, options = {}) {
1414
1471
 
1415
1472
  validatePropertyConfigs(
1416
1473
  checker,
1474
+ sourceFile,
1475
+ declaredPropertyTypes,
1417
1476
  supportedProps,
1418
1477
  propertyEntries,
1419
1478
  getterNames,
@@ -1457,6 +1516,7 @@ export function lintSource(filePath, sourceText, options = {}) {
1457
1516
  a.methodName.localeCompare(b.methodName) ||
1458
1517
  a.parameterName.localeCompare(b.parameterName)
1459
1518
  );
1519
+ findings.incompatibleDeclareTypes.sort();
1460
1520
  findings.invalidCheckedBindings.sort();
1461
1521
  findings.invalidComputedProperties.sort();
1462
1522
  findings.invalidDefaultValues.sort();
@@ -1464,8 +1524,9 @@ export function lintSource(filePath, sourceText, options = {}) {
1464
1524
  findings.invalidFormAssocValues.sort();
1465
1525
  findings.invalidHtmlNesting.sort();
1466
1526
  findings.invalidRefAttributes.sort();
1467
- findings.invalidUseStateMaps.sort();
1527
+ findings.invalidTypeProperties.sort();
1468
1528
  findings.invalidUsedByReferences.sort();
1529
+ findings.invalidUseStateMaps.sort();
1469
1530
  findings.invalidValuesConfigurations.sort();
1470
1531
  findings.missingFormAssociatedProperty.sort();
1471
1532
  findings.missingTypeProperties.sort();
@@ -1474,8 +1535,8 @@ export function lintSource(filePath, sourceText, options = {}) {
1474
1535
  findings.undefinedContextFunctions.sort();
1475
1536
  findings.undefinedMethods.sort();
1476
1537
  findings.undefinedProperties.sort();
1477
- findings.unsupportedHtmlAttributes.sort();
1478
1538
  findings.unsupportedEventNames.sort();
1539
+ findings.unsupportedHtmlAttributes.sort();
1479
1540
 
1480
1541
  return formatReport(
1481
1542
  filePath,
@@ -1848,6 +1909,8 @@ function validateHtmlNesting(node, findings) {
1848
1909
  // Validates all configured component property metadata entries.
1849
1910
  function validatePropertyConfigs(
1850
1911
  checker,
1912
+ sourceFile,
1913
+ declaredPropertyTypes,
1851
1914
  supportedProps,
1852
1915
  propertyEntries,
1853
1916
  getterNames,
@@ -1855,9 +1918,10 @@ function validatePropertyConfigs(
1855
1918
  findings
1856
1919
  ) {
1857
1920
  for (const {config, propName} of propertyEntries) {
1921
+ const computedProp = getObjectProperty(config, 'computed');
1922
+ const declaredTypeNode = declaredPropertyTypes.get(propName);
1858
1923
  const typeProp = getObjectProperty(config, 'type');
1859
1924
  const usedByProp = getObjectProperty(config, 'usedBy');
1860
- const computedProp = getObjectProperty(config, 'computed');
1861
1925
  const valueProp = getObjectProperty(config, 'value');
1862
1926
  const valuesProp = getObjectProperty(config, 'values');
1863
1927
 
@@ -1870,6 +1934,24 @@ function validatePropertyConfigs(
1870
1934
  findings.missingTypeProperties.push(
1871
1935
  `property "${propName}" does not specify a type`
1872
1936
  );
1937
+ } else if (
1938
+ !SUPPORTED_PROPERTY_TYPE_NAMES.has(typeExpressionKind(typeExpression))
1939
+ ) {
1940
+ findings.invalidTypeProperties.push(
1941
+ `property "${propName}" type must be one of ` +
1942
+ 'Boolean, Number, String, Object, or Array'
1943
+ );
1944
+ } else if (declaredTypeNode) {
1945
+ const runtimeType = checker.getTypeAtLocation(typeExpression);
1946
+ const declaredType = checker.getTypeFromTypeNode(declaredTypeNode);
1947
+ if (!checker.isTypeAssignableTo(runtimeType, declaredType)) {
1948
+ findings.incompatibleDeclareTypes.push(
1949
+ `property "${propName}" declare type ` +
1950
+ `"${getPropertyTypeTextFromNode(sourceFile, declaredTypeNode)}" ` +
1951
+ `is not compatible with static properties type ` +
1952
+ `"${getPropertyConfigTypeName(typeExpressionKind(typeExpression))}"`
1953
+ );
1954
+ }
1873
1955
  }
1874
1956
 
1875
1957
  if (usedByProp && ts.isPropertyAssignment(usedByProp)) {