@memberjunction/react-test-harness 2.75.0 → 2.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/component-linter.d.ts +4 -4
- package/dist/lib/component-linter.d.ts.map +1 -1
- package/dist/lib/component-linter.js +1030 -199
- package/dist/lib/component-linter.js.map +1 -1
- package/dist/lib/component-runner.d.ts +16 -2
- package/dist/lib/component-runner.d.ts.map +1 -1
- package/dist/lib/component-runner.js +289 -110
- package/dist/lib/component-runner.js.map +1 -1
- package/dist/lib/test-harness.js +1 -1
- package/dist/lib/test-harness.js.map +1 -1
- package/package.json +3 -3
|
@@ -31,24 +31,28 @@ const parser = __importStar(require("@babel/parser"));
|
|
|
31
31
|
const traverse_1 = __importDefault(require("@babel/traverse"));
|
|
32
32
|
const t = __importStar(require("@babel/types"));
|
|
33
33
|
class ComponentLinter {
|
|
34
|
-
static async lintComponent(code,
|
|
34
|
+
static async lintComponent(code, componentName, componentSpec) {
|
|
35
35
|
try {
|
|
36
36
|
const ast = parser.parse(code, {
|
|
37
37
|
sourceType: 'module',
|
|
38
38
|
plugins: ['jsx', 'typescript'],
|
|
39
39
|
errorRecovery: true
|
|
40
40
|
});
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
: this.childComponentRules;
|
|
41
|
+
// Use universal rules for all components in the new pattern
|
|
42
|
+
const rules = this.universalComponentRules;
|
|
44
43
|
const violations = [];
|
|
45
44
|
// Run each rule
|
|
46
45
|
for (const rule of rules) {
|
|
47
46
|
const ruleViolations = rule.test(ast, componentName);
|
|
48
47
|
violations.push(...ruleViolations);
|
|
49
48
|
}
|
|
49
|
+
// Add data requirements validation if componentSpec is provided
|
|
50
|
+
if (componentSpec?.dataRequirements?.entities) {
|
|
51
|
+
const dataViolations = this.validateDataRequirements(ast, componentSpec);
|
|
52
|
+
violations.push(...dataViolations);
|
|
53
|
+
}
|
|
50
54
|
// Generate fix suggestions
|
|
51
|
-
const suggestions = this.generateFixSuggestions(violations
|
|
55
|
+
const suggestions = this.generateFixSuggestions(violations);
|
|
52
56
|
return {
|
|
53
57
|
success: violations.filter(v => v.severity === 'error').length === 0,
|
|
54
58
|
violations,
|
|
@@ -70,63 +74,624 @@ class ComponentLinter {
|
|
|
70
74
|
};
|
|
71
75
|
}
|
|
72
76
|
}
|
|
73
|
-
static
|
|
77
|
+
static validateDataRequirements(ast, componentSpec) {
|
|
78
|
+
const violations = [];
|
|
79
|
+
// Extract entity names from dataRequirements
|
|
80
|
+
const requiredEntities = new Set();
|
|
81
|
+
const requiredQueries = new Set();
|
|
82
|
+
// Map to track allowed fields per entity
|
|
83
|
+
const entityFieldsMap = new Map();
|
|
84
|
+
if (componentSpec.dataRequirements?.entities) {
|
|
85
|
+
for (const entity of componentSpec.dataRequirements.entities) {
|
|
86
|
+
if (entity.name) {
|
|
87
|
+
requiredEntities.add(entity.name);
|
|
88
|
+
entityFieldsMap.set(entity.name, {
|
|
89
|
+
displayFields: new Set(entity.displayFields || []),
|
|
90
|
+
filterFields: new Set(entity.filterFields || []),
|
|
91
|
+
sortFields: new Set(entity.sortFields || [])
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (componentSpec.dataRequirements?.queries) {
|
|
97
|
+
for (const query of componentSpec.dataRequirements.queries) {
|
|
98
|
+
if (query.name) {
|
|
99
|
+
requiredQueries.add(query.name);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Also check child components' dataRequirements
|
|
104
|
+
if (componentSpec.dependencies) {
|
|
105
|
+
for (const dep of componentSpec.dependencies) {
|
|
106
|
+
if (dep.dataRequirements?.entities) {
|
|
107
|
+
for (const entity of dep.dataRequirements.entities) {
|
|
108
|
+
if (entity.name) {
|
|
109
|
+
requiredEntities.add(entity.name);
|
|
110
|
+
// Merge fields if entity already exists
|
|
111
|
+
const existing = entityFieldsMap.get(entity.name);
|
|
112
|
+
if (existing) {
|
|
113
|
+
(entity.displayFields || []).forEach((f) => existing.displayFields.add(f));
|
|
114
|
+
(entity.filterFields || []).forEach((f) => existing.filterFields.add(f));
|
|
115
|
+
(entity.sortFields || []).forEach((f) => existing.sortFields.add(f));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
entityFieldsMap.set(entity.name, {
|
|
119
|
+
displayFields: new Set(entity.displayFields || []),
|
|
120
|
+
filterFields: new Set(entity.filterFields || []),
|
|
121
|
+
sortFields: new Set(entity.sortFields || [])
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (dep.dataRequirements?.queries) {
|
|
128
|
+
for (const query of dep.dataRequirements.queries) {
|
|
129
|
+
if (query.name) {
|
|
130
|
+
requiredQueries.add(query.name);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Find all RunView, RunViews, and RunQuery calls in the code
|
|
137
|
+
(0, traverse_1.default)(ast, {
|
|
138
|
+
CallExpression(path) {
|
|
139
|
+
// Check for utilities.rv.RunView or utilities.rv.RunViews pattern
|
|
140
|
+
if (t.isMemberExpression(path.node.callee) &&
|
|
141
|
+
t.isMemberExpression(path.node.callee.object) &&
|
|
142
|
+
t.isIdentifier(path.node.callee.object.object) &&
|
|
143
|
+
path.node.callee.object.object.name === 'utilities' &&
|
|
144
|
+
t.isIdentifier(path.node.callee.object.property) &&
|
|
145
|
+
path.node.callee.object.property.name === 'rv' &&
|
|
146
|
+
t.isIdentifier(path.node.callee.property) &&
|
|
147
|
+
(path.node.callee.property.name === 'RunView' || path.node.callee.property.name === 'RunViews')) {
|
|
148
|
+
// For RunViews, it might be an array of configs
|
|
149
|
+
const configs = path.node.callee.property.name === 'RunViews' &&
|
|
150
|
+
path.node.arguments.length > 0 &&
|
|
151
|
+
t.isArrayExpression(path.node.arguments[0])
|
|
152
|
+
? path.node.arguments[0].elements.filter(e => t.isObjectExpression(e))
|
|
153
|
+
: path.node.arguments.length > 0 && t.isObjectExpression(path.node.arguments[0])
|
|
154
|
+
? [path.node.arguments[0]]
|
|
155
|
+
: [];
|
|
156
|
+
// Check each config object
|
|
157
|
+
for (const configObj of configs) {
|
|
158
|
+
if (t.isObjectExpression(configObj)) {
|
|
159
|
+
// Find EntityName property
|
|
160
|
+
for (const prop of configObj.properties) {
|
|
161
|
+
if (t.isObjectProperty(prop) &&
|
|
162
|
+
t.isIdentifier(prop.key) &&
|
|
163
|
+
prop.key.name === 'EntityName' &&
|
|
164
|
+
t.isStringLiteral(prop.value)) {
|
|
165
|
+
const usedEntity = prop.value.value;
|
|
166
|
+
// Check if this entity is in the required entities
|
|
167
|
+
if (requiredEntities.size > 0 && !requiredEntities.has(usedEntity)) {
|
|
168
|
+
// Try to find the closest match
|
|
169
|
+
const possibleMatches = Array.from(requiredEntities).filter(e => e.toLowerCase().includes(usedEntity.toLowerCase()) ||
|
|
170
|
+
usedEntity.toLowerCase().includes(e.toLowerCase()));
|
|
171
|
+
violations.push({
|
|
172
|
+
rule: 'entity-name-mismatch',
|
|
173
|
+
severity: 'error',
|
|
174
|
+
line: prop.value.loc?.start.line || 0,
|
|
175
|
+
column: prop.value.loc?.start.column || 0,
|
|
176
|
+
message: `Entity "${usedEntity}" not found in dataRequirements. ${possibleMatches.length > 0
|
|
177
|
+
? `Did you mean "${possibleMatches[0]}"?`
|
|
178
|
+
: `Available entities: ${Array.from(requiredEntities).join(', ')}`}`,
|
|
179
|
+
code: `EntityName: "${usedEntity}"`
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
// Entity is valid, now check fields
|
|
184
|
+
const entityFields = entityFieldsMap.get(usedEntity);
|
|
185
|
+
if (entityFields) {
|
|
186
|
+
// Check Fields array
|
|
187
|
+
const fieldsProperty = configObj.properties.find(p => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === 'Fields');
|
|
188
|
+
if (fieldsProperty && t.isObjectProperty(fieldsProperty) && t.isArrayExpression(fieldsProperty.value)) {
|
|
189
|
+
for (const fieldElement of fieldsProperty.value.elements) {
|
|
190
|
+
if (t.isStringLiteral(fieldElement)) {
|
|
191
|
+
const fieldName = fieldElement.value;
|
|
192
|
+
// Check for SQL functions
|
|
193
|
+
if (/COUNT\s*\(|SUM\s*\(|AVG\s*\(|MAX\s*\(|MIN\s*\(/i.test(fieldName)) {
|
|
194
|
+
violations.push({
|
|
195
|
+
rule: 'runview-sql-function',
|
|
196
|
+
severity: 'error',
|
|
197
|
+
line: fieldElement.loc?.start.line || 0,
|
|
198
|
+
column: fieldElement.loc?.start.column || 0,
|
|
199
|
+
message: `RunView does not support SQL aggregations. Use RunQuery for aggregations or fetch raw data and aggregate in JavaScript.`,
|
|
200
|
+
code: fieldName
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
// Check if field is in allowed fields
|
|
205
|
+
const isAllowed = entityFields.displayFields.has(fieldName) ||
|
|
206
|
+
entityFields.filterFields.has(fieldName) ||
|
|
207
|
+
entityFields.sortFields.has(fieldName);
|
|
208
|
+
if (!isAllowed) {
|
|
209
|
+
violations.push({
|
|
210
|
+
rule: 'field-not-in-requirements',
|
|
211
|
+
severity: 'error',
|
|
212
|
+
line: fieldElement.loc?.start.line || 0,
|
|
213
|
+
column: fieldElement.loc?.start.column || 0,
|
|
214
|
+
message: `Field "${fieldName}" not found in dataRequirements for entity "${usedEntity}". Available fields: ${[...entityFields.displayFields, ...entityFields.filterFields, ...entityFields.sortFields].join(', ')}`,
|
|
215
|
+
code: fieldName
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Check OrderBy field
|
|
223
|
+
const orderByProperty = configObj.properties.find(p => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === 'OrderBy');
|
|
224
|
+
if (orderByProperty && t.isObjectProperty(orderByProperty) && t.isStringLiteral(orderByProperty.value)) {
|
|
225
|
+
const orderByValue = orderByProperty.value.value;
|
|
226
|
+
// Extract field name from OrderBy (e.g., "AccountName ASC" -> "AccountName")
|
|
227
|
+
const orderByField = orderByValue.split(/\s+/)[0];
|
|
228
|
+
if (!entityFields.sortFields.has(orderByField)) {
|
|
229
|
+
violations.push({
|
|
230
|
+
rule: 'orderby-field-not-sortable',
|
|
231
|
+
severity: 'error',
|
|
232
|
+
line: orderByProperty.value.loc?.start.line || 0,
|
|
233
|
+
column: orderByProperty.value.loc?.start.column || 0,
|
|
234
|
+
message: `OrderBy field "${orderByField}" not in sortFields for entity "${usedEntity}". Available sort fields: ${[...entityFields.sortFields].join(', ')}`,
|
|
235
|
+
code: orderByValue
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Check for utilities.rv.RunQuery pattern
|
|
247
|
+
if (t.isMemberExpression(path.node.callee) &&
|
|
248
|
+
t.isMemberExpression(path.node.callee.object) &&
|
|
249
|
+
t.isIdentifier(path.node.callee.object.object) &&
|
|
250
|
+
path.node.callee.object.object.name === 'utilities' &&
|
|
251
|
+
t.isIdentifier(path.node.callee.object.property) &&
|
|
252
|
+
path.node.callee.object.property.name === 'rv' &&
|
|
253
|
+
t.isIdentifier(path.node.callee.property) &&
|
|
254
|
+
path.node.callee.property.name === 'RunQuery') {
|
|
255
|
+
// Check the first argument (should be an object with QueryName)
|
|
256
|
+
if (path.node.arguments.length > 0 && t.isObjectExpression(path.node.arguments[0])) {
|
|
257
|
+
const configObj = path.node.arguments[0];
|
|
258
|
+
// Find QueryName property
|
|
259
|
+
for (const prop of configObj.properties) {
|
|
260
|
+
if (t.isObjectProperty(prop) &&
|
|
261
|
+
t.isIdentifier(prop.key) &&
|
|
262
|
+
prop.key.name === 'QueryName' &&
|
|
263
|
+
t.isStringLiteral(prop.value)) {
|
|
264
|
+
const usedQuery = prop.value.value;
|
|
265
|
+
// Check if this query is in the required queries
|
|
266
|
+
if (requiredQueries.size > 0 && !requiredQueries.has(usedQuery)) {
|
|
267
|
+
// Try to find the closest match
|
|
268
|
+
const possibleMatches = Array.from(requiredQueries).filter(q => q.toLowerCase().includes(usedQuery.toLowerCase()) ||
|
|
269
|
+
usedQuery.toLowerCase().includes(q.toLowerCase()));
|
|
270
|
+
violations.push({
|
|
271
|
+
rule: 'query-name-mismatch',
|
|
272
|
+
severity: 'error',
|
|
273
|
+
line: prop.value.loc?.start.line || 0,
|
|
274
|
+
column: prop.value.loc?.start.column || 0,
|
|
275
|
+
message: `Query "${usedQuery}" not found in dataRequirements. ${possibleMatches.length > 0
|
|
276
|
+
? `Did you mean "${possibleMatches[0]}"?`
|
|
277
|
+
: requiredQueries.size > 0
|
|
278
|
+
? `Available queries: ${Array.from(requiredQueries).join(', ')}`
|
|
279
|
+
: `No queries defined in dataRequirements`}`,
|
|
280
|
+
code: `QueryName: "${usedQuery}"`
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
return violations;
|
|
290
|
+
}
|
|
291
|
+
static getFunctionName(path) {
|
|
292
|
+
const node = path.node;
|
|
293
|
+
// Check for named function
|
|
294
|
+
if (t.isFunctionDeclaration(node) && node.id) {
|
|
295
|
+
return node.id.name;
|
|
296
|
+
}
|
|
297
|
+
// Check for arrow function assigned to variable
|
|
298
|
+
if (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node)) {
|
|
299
|
+
const parent = path.parent;
|
|
300
|
+
if (t.isVariableDeclarator(parent) && t.isIdentifier(parent.id)) {
|
|
301
|
+
return parent.id.name;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Check for function assigned as property
|
|
305
|
+
if (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node)) {
|
|
306
|
+
const parent = path.parent;
|
|
307
|
+
if (t.isObjectProperty(parent) && t.isIdentifier(parent.key)) {
|
|
308
|
+
return parent.key.name;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
static generateFixSuggestions(violations) {
|
|
74
314
|
const suggestions = [];
|
|
75
315
|
for (const violation of violations) {
|
|
76
316
|
switch (violation.rule) {
|
|
77
|
-
case '
|
|
317
|
+
case 'full-state-ownership':
|
|
78
318
|
suggestions.push({
|
|
79
319
|
violation: violation.rule,
|
|
80
|
-
suggestion: '
|
|
81
|
-
example:
|
|
320
|
+
suggestion: 'Components must own ALL their state - use useState with SavedUserSettings override pattern',
|
|
321
|
+
example: `// ✅ CORRECT - Full state ownership with fallback pattern:
|
|
322
|
+
function Component({
|
|
323
|
+
items,
|
|
324
|
+
customers,
|
|
325
|
+
selectedId: selectedIdProp, // Props can provide defaults
|
|
326
|
+
filters: filtersProp,
|
|
327
|
+
savedUserSettings, // Saved settings override props
|
|
328
|
+
onSaveUserSettings
|
|
329
|
+
}) {
|
|
330
|
+
// Component owns ALL state - savedUserSettings overrides props
|
|
331
|
+
const [selectedId, setSelectedId] = useState(
|
|
332
|
+
savedUserSettings?.selectedId ?? selectedIdProp ?? null
|
|
333
|
+
);
|
|
334
|
+
const [filters, setFilters] = useState(
|
|
335
|
+
savedUserSettings?.filters ?? filtersProp ?? {}
|
|
336
|
+
);
|
|
337
|
+
const [searchDraft, setSearchDraft] = useState(''); // Ephemeral - no prop/save
|
|
338
|
+
|
|
339
|
+
// Handle selection and save preference
|
|
340
|
+
const handleSelect = (id) => {
|
|
341
|
+
setSelectedId(id);
|
|
342
|
+
onSaveUserSettings?.({
|
|
343
|
+
...savedUserSettings,
|
|
344
|
+
selectedId: id
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Priority order:
|
|
349
|
+
// 1. savedUserSettings (user's saved preference)
|
|
350
|
+
// 2. props (parent's suggestion/default)
|
|
351
|
+
// 3. component default
|
|
352
|
+
}`
|
|
82
353
|
});
|
|
83
354
|
break;
|
|
84
|
-
case 'no-
|
|
355
|
+
case 'no-use-reducer':
|
|
85
356
|
suggestions.push({
|
|
86
357
|
violation: violation.rule,
|
|
87
|
-
suggestion: '
|
|
88
|
-
example:
|
|
358
|
+
suggestion: 'Use useState for state management, not useReducer',
|
|
359
|
+
example: `// Instead of:
|
|
360
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
|
361
|
+
|
|
362
|
+
// Use useState:
|
|
363
|
+
function Component({ savedUserSettings, onSaveUserSettings }) {
|
|
364
|
+
const [selectedId, setSelectedId] = useState(
|
|
365
|
+
savedUserSettings?.selectedId
|
|
366
|
+
);
|
|
367
|
+
const [filters, setFilters] = useState(
|
|
368
|
+
savedUserSettings?.filters || {}
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
// Handle actions directly
|
|
372
|
+
const handleAction = (action) => {
|
|
373
|
+
switch(action.type) {
|
|
374
|
+
case 'SELECT':
|
|
375
|
+
setSelectedId(action.payload);
|
|
376
|
+
onSaveUserSettings?.({ ...savedUserSettings, selectedId: action.payload });
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}`
|
|
89
381
|
});
|
|
90
382
|
break;
|
|
91
|
-
case 'no-
|
|
383
|
+
case 'no-data-prop':
|
|
92
384
|
suggestions.push({
|
|
93
385
|
violation: violation.rule,
|
|
94
|
-
suggestion: '
|
|
95
|
-
example:
|
|
386
|
+
suggestion: 'Replace generic data prop with specific named props',
|
|
387
|
+
example: `// Instead of:
|
|
388
|
+
function Component({ data, savedUserSettings, onSaveUserSettings }) {
|
|
389
|
+
return <div>{data.items.map(...)}</div>;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Use specific props:
|
|
393
|
+
function Component({ items, customers, savedUserSettings, onSaveUserSettings }) {
|
|
394
|
+
// Component owns its state
|
|
395
|
+
const [selectedItemId, setSelectedItemId] = useState(
|
|
396
|
+
savedUserSettings?.selectedItemId
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
return <div>{items.map(...)}</div>;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Load data using utilities:
|
|
403
|
+
const result = await utilities.rv.RunView({ entityName: 'Items' });`
|
|
96
404
|
});
|
|
97
405
|
break;
|
|
98
|
-
case '
|
|
406
|
+
case 'saved-user-settings-pattern':
|
|
99
407
|
suggestions.push({
|
|
100
408
|
violation: violation.rule,
|
|
101
|
-
suggestion: '
|
|
102
|
-
example:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
409
|
+
suggestion: 'Only save important user preferences, not ephemeral UI state',
|
|
410
|
+
example: `// ✅ SAVE these (important preferences):
|
|
411
|
+
- Selected items/tabs: selectedCustomerId, activeTab
|
|
412
|
+
- Sort preferences: sortBy, sortDirection
|
|
413
|
+
- Filter selections: activeFilters
|
|
414
|
+
- View preferences: viewMode, pageSize
|
|
415
|
+
|
|
416
|
+
// ❌ DON'T SAVE these (ephemeral UI):
|
|
417
|
+
- Hover states: hoveredItemId
|
|
418
|
+
- Dropdown states: isDropdownOpen
|
|
419
|
+
- Text being typed: searchDraft (save on submit)
|
|
420
|
+
- Loading states: isLoading
|
|
421
|
+
|
|
422
|
+
// Example:
|
|
423
|
+
const handleHover = (id) => {
|
|
424
|
+
setHoveredId(id); // Just local state
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const handleSelect = (id) => {
|
|
428
|
+
setSelectedId(id);
|
|
429
|
+
onSaveUserSettings?.({ // Save important preference
|
|
430
|
+
...savedUserSettings,
|
|
431
|
+
selectedId: id
|
|
432
|
+
});
|
|
108
433
|
};`
|
|
109
434
|
});
|
|
110
435
|
break;
|
|
111
|
-
case '
|
|
436
|
+
case 'pass-standard-props':
|
|
112
437
|
suggestions.push({
|
|
113
438
|
violation: violation.rule,
|
|
114
|
-
suggestion: '
|
|
115
|
-
example:
|
|
439
|
+
suggestion: 'Always pass standard props to all components',
|
|
440
|
+
example: `// Always include these props when calling components:
|
|
441
|
+
<ChildComponent
|
|
442
|
+
items={items} // Data props
|
|
443
|
+
|
|
444
|
+
// Settings persistence
|
|
445
|
+
savedUserSettings={savedUserSettings?.childComponent}
|
|
446
|
+
onSaveUserSettings={handleChildSettings}
|
|
447
|
+
|
|
448
|
+
// Standard props
|
|
449
|
+
styles={styles}
|
|
450
|
+
utilities={utilities}
|
|
451
|
+
components={components}
|
|
452
|
+
callbacks={callbacks}
|
|
453
|
+
/>`
|
|
116
454
|
});
|
|
117
455
|
break;
|
|
118
|
-
case '
|
|
456
|
+
case 'no-child-implementation':
|
|
119
457
|
suggestions.push({
|
|
120
458
|
violation: violation.rule,
|
|
121
|
-
suggestion: '
|
|
122
|
-
example: '
|
|
459
|
+
suggestion: 'Remove child component implementations. Only the root component function should be in this file',
|
|
460
|
+
example: 'Move child component functions to separate generation requests'
|
|
123
461
|
});
|
|
124
462
|
break;
|
|
125
|
-
case '
|
|
463
|
+
case 'undefined-component-usage':
|
|
126
464
|
suggestions.push({
|
|
127
465
|
violation: violation.rule,
|
|
128
|
-
suggestion: '
|
|
129
|
-
example:
|
|
466
|
+
suggestion: 'Ensure all components destructured from the components prop are defined in the component spec dependencies',
|
|
467
|
+
example: `// Component spec should include all referenced components:
|
|
468
|
+
{
|
|
469
|
+
"name": "MyComponent",
|
|
470
|
+
"code": "...",
|
|
471
|
+
"dependencies": [
|
|
472
|
+
{
|
|
473
|
+
"name": "ModelTreeView",
|
|
474
|
+
"code": "function ModelTreeView({ ... }) { ... }"
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
"name": "PromptTable",
|
|
478
|
+
"code": "function PromptTable({ ... }) { ... }"
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
"name": "FilterPanel",
|
|
482
|
+
"code": "function FilterPanel({ ... }) { ... }"
|
|
483
|
+
}
|
|
484
|
+
// Add ALL components referenced in the root component
|
|
485
|
+
]
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Then in your component:
|
|
489
|
+
const { ModelTreeView, PromptTable, FilterPanel } = components;
|
|
490
|
+
// All these will be available`
|
|
491
|
+
});
|
|
492
|
+
break;
|
|
493
|
+
case 'unsafe-array-access':
|
|
494
|
+
suggestions.push({
|
|
495
|
+
violation: violation.rule,
|
|
496
|
+
suggestion: 'Always check array bounds before accessing elements',
|
|
497
|
+
example: `// ❌ UNSAFE:
|
|
498
|
+
const firstItem = items[0].name;
|
|
499
|
+
const total = data[0].reduce((sum, item) => sum + item.value, 0);
|
|
500
|
+
|
|
501
|
+
// ✅ SAFE:
|
|
502
|
+
const firstItem = items.length > 0 ? items[0].name : 'No items';
|
|
503
|
+
const total = data.length > 0
|
|
504
|
+
? data[0].reduce((sum, item) => sum + item.value, 0)
|
|
505
|
+
: 0;
|
|
506
|
+
|
|
507
|
+
// ✅ BETTER - Use optional chaining:
|
|
508
|
+
const firstItem = items[0]?.name || 'No items';
|
|
509
|
+
const total = data[0]?.reduce((sum, item) => sum + item.value, 0) || 0;`
|
|
510
|
+
});
|
|
511
|
+
break;
|
|
512
|
+
case 'array-reduce-safety':
|
|
513
|
+
suggestions.push({
|
|
514
|
+
violation: violation.rule,
|
|
515
|
+
suggestion: 'Always provide an initial value for reduce() or check array length',
|
|
516
|
+
example: `// ❌ UNSAFE:
|
|
517
|
+
const sum = numbers.reduce((a, b) => a + b); // Fails on empty array
|
|
518
|
+
const total = data[0].reduce((sum, item) => sum + item.value); // Multiple issues
|
|
519
|
+
|
|
520
|
+
// ✅ SAFE:
|
|
521
|
+
const sum = numbers.reduce((a, b) => a + b, 0); // Initial value
|
|
522
|
+
const total = data.length > 0 && data[0]
|
|
523
|
+
? data[0].reduce((sum, item) => sum + item.value, 0)
|
|
524
|
+
: 0;
|
|
525
|
+
|
|
526
|
+
// ✅ ALSO SAFE:
|
|
527
|
+
const sum = numbers.length > 0
|
|
528
|
+
? numbers.reduce((a, b) => a + b)
|
|
529
|
+
: 0;`
|
|
530
|
+
});
|
|
531
|
+
break;
|
|
532
|
+
case 'entity-name-mismatch':
|
|
533
|
+
suggestions.push({
|
|
534
|
+
violation: violation.rule,
|
|
535
|
+
suggestion: 'Use the exact entity name from dataRequirements in RunView calls',
|
|
536
|
+
example: `// The component spec defines the entities to use:
|
|
537
|
+
// dataRequirements: {
|
|
538
|
+
// entities: [
|
|
539
|
+
// { name: "MJ: AI Prompt Runs", ... }
|
|
540
|
+
// ]
|
|
541
|
+
// }
|
|
542
|
+
|
|
543
|
+
// ❌ WRONG - Missing prefix or incorrect name:
|
|
544
|
+
await utilities.rv.RunView({
|
|
545
|
+
EntityName: "AI Prompt Runs", // Missing "MJ:" prefix
|
|
546
|
+
Fields: ["RunAt", "Success"]
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// ✅ CORRECT - Use exact name from dataRequirements:
|
|
550
|
+
await utilities.rv.RunView({
|
|
551
|
+
EntityName: "MJ: AI Prompt Runs", // Matches dataRequirements
|
|
552
|
+
Fields: ["RunAt", "Success"]
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// Also works with RunViews (parallel execution):
|
|
556
|
+
await utilities.rv.RunViews([
|
|
557
|
+
{ EntityName: "MJ: AI Prompt Runs", Fields: ["RunAt"] },
|
|
558
|
+
{ EntityName: "MJ: Users", Fields: ["Name", "Email"] }
|
|
559
|
+
]);
|
|
560
|
+
|
|
561
|
+
// The linter validates that all entity names in RunView/RunViews calls
|
|
562
|
+
// match those declared in the component spec's dataRequirements`
|
|
563
|
+
});
|
|
564
|
+
break;
|
|
565
|
+
case 'query-name-mismatch':
|
|
566
|
+
suggestions.push({
|
|
567
|
+
violation: violation.rule,
|
|
568
|
+
suggestion: 'Use the exact query name from dataRequirements in RunQuery calls',
|
|
569
|
+
example: `// The component spec defines the queries to use:
|
|
570
|
+
// dataRequirements: {
|
|
571
|
+
// queries: [
|
|
572
|
+
// { name: "User Activity Summary", ... }
|
|
573
|
+
// ]
|
|
574
|
+
// }
|
|
575
|
+
|
|
576
|
+
// ❌ WRONG - Incorrect query name:
|
|
577
|
+
await utilities.rv.RunQuery({
|
|
578
|
+
QueryName: "UserActivitySummary", // Wrong name format
|
|
579
|
+
Parameters: { startDate, endDate }
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// ✅ CORRECT - Use exact name from dataRequirements:
|
|
583
|
+
await utilities.rv.RunQuery({
|
|
584
|
+
QueryName: "User Activity Summary", // Matches dataRequirements
|
|
585
|
+
Parameters: { startDate, endDate }
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// The linter validates that all query names in RunQuery calls
|
|
589
|
+
// match those declared in the component spec's dataRequirements.queries`
|
|
590
|
+
});
|
|
591
|
+
break;
|
|
592
|
+
case 'runview-sql-function':
|
|
593
|
+
suggestions.push({
|
|
594
|
+
violation: violation.rule,
|
|
595
|
+
suggestion: 'RunView does not support SQL aggregations. Use RunQuery or aggregate in JavaScript.',
|
|
596
|
+
example: `// ❌ WRONG - SQL functions in RunView:
|
|
597
|
+
await utilities.rv.RunView({
|
|
598
|
+
EntityName: 'Accounts',
|
|
599
|
+
Fields: ['COUNT(*) as Total', 'SUM(Revenue) as TotalRevenue']
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// ✅ OPTION 1 - Use a pre-defined query:
|
|
603
|
+
await utilities.rq.RunQuery({
|
|
604
|
+
QueryName: 'Account Summary Statistics'
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// ✅ OPTION 2 - Fetch raw data and aggregate in JavaScript:
|
|
608
|
+
const result = await utilities.rv.RunView({
|
|
609
|
+
EntityName: 'Accounts',
|
|
610
|
+
Fields: ['ID', 'Revenue']
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
if (result?.Success) {
|
|
614
|
+
const total = result.Results.length;
|
|
615
|
+
const totalRevenue = result.Results.reduce((sum, acc) => sum + (acc.Revenue || 0), 0);
|
|
616
|
+
}`
|
|
617
|
+
});
|
|
618
|
+
break;
|
|
619
|
+
case 'field-not-in-requirements':
|
|
620
|
+
suggestions.push({
|
|
621
|
+
violation: violation.rule,
|
|
622
|
+
suggestion: 'Only use fields that are defined in dataRequirements for the entity',
|
|
623
|
+
example: `// Check your dataRequirements to see allowed fields:
|
|
624
|
+
// dataRequirements: {
|
|
625
|
+
// entities: [{
|
|
626
|
+
// name: "Accounts",
|
|
627
|
+
// displayFields: ["ID", "AccountName", "Industry"],
|
|
628
|
+
// filterFields: ["IsActive", "AccountType"],
|
|
629
|
+
// sortFields: ["AccountName", "CreatedDate"]
|
|
630
|
+
// }]
|
|
631
|
+
// }
|
|
632
|
+
|
|
633
|
+
// ❌ WRONG - Using undefined field:
|
|
634
|
+
await utilities.rv.RunView({
|
|
635
|
+
EntityName: 'Accounts',
|
|
636
|
+
Fields: ['ID', 'AccountName', 'RandomField'] // RandomField not in requirements
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// ✅ CORRECT - Only use defined fields:
|
|
640
|
+
await utilities.rv.RunView({
|
|
641
|
+
EntityName: 'Accounts',
|
|
642
|
+
Fields: ['ID', 'AccountName', 'Industry'] // All from displayFields
|
|
643
|
+
});`
|
|
644
|
+
});
|
|
645
|
+
break;
|
|
646
|
+
case 'orderby-field-not-sortable':
|
|
647
|
+
suggestions.push({
|
|
648
|
+
violation: violation.rule,
|
|
649
|
+
suggestion: 'OrderBy fields must be in the sortFields array for the entity',
|
|
650
|
+
example: `// ❌ WRONG - Sorting by non-sortable field:
|
|
651
|
+
await utilities.rv.RunView({
|
|
652
|
+
EntityName: 'Accounts',
|
|
653
|
+
OrderBy: 'Industry ASC' // Industry not in sortFields
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// ✅ CORRECT - Use fields from sortFields:
|
|
657
|
+
await utilities.rv.RunView({
|
|
658
|
+
EntityName: 'Accounts',
|
|
659
|
+
OrderBy: 'AccountName ASC' // AccountName is in sortFields
|
|
660
|
+
});`
|
|
661
|
+
});
|
|
662
|
+
break;
|
|
663
|
+
case 'parent-event-callback-usage':
|
|
664
|
+
suggestions.push({
|
|
665
|
+
violation: violation.rule,
|
|
666
|
+
suggestion: 'Components must invoke parent event callbacks when state changes',
|
|
667
|
+
example: `// ❌ WRONG - Only updating internal state:
|
|
668
|
+
function ChildComponent({ onSelectAccount, savedUserSettings, onSaveUserSettings }) {
|
|
669
|
+
const [selectedAccountId, setSelectedAccountId] = useState(savedUserSettings?.selectedAccountId);
|
|
670
|
+
|
|
671
|
+
const handleSelectAccount = (accountId) => {
|
|
672
|
+
setSelectedAccountId(accountId); // Updates internal state
|
|
673
|
+
onSaveUserSettings?.({ ...savedUserSettings, selectedAccountId: accountId }); // Saves settings
|
|
674
|
+
// MISSING: Parent is never notified!
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ✅ CORRECT - Update state AND invoke parent callback:
|
|
679
|
+
function ChildComponent({ onSelectAccount, savedUserSettings, onSaveUserSettings }) {
|
|
680
|
+
const [selectedAccountId, setSelectedAccountId] = useState(savedUserSettings?.selectedAccountId);
|
|
681
|
+
|
|
682
|
+
const handleSelectAccount = (accountId) => {
|
|
683
|
+
// 1. Update internal state
|
|
684
|
+
setSelectedAccountId(accountId);
|
|
685
|
+
|
|
686
|
+
// 2. Invoke parent's event callback
|
|
687
|
+
if (onSelectAccount) {
|
|
688
|
+
onSelectAccount(accountId);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// 3. Save user preference
|
|
692
|
+
onSaveUserSettings?.({ ...savedUserSettings, selectedAccountId: accountId });
|
|
693
|
+
};
|
|
694
|
+
}`
|
|
130
695
|
});
|
|
131
696
|
break;
|
|
132
697
|
}
|
|
@@ -135,31 +700,97 @@ class ComponentLinter {
|
|
|
135
700
|
}
|
|
136
701
|
}
|
|
137
702
|
exports.ComponentLinter = ComponentLinter;
|
|
138
|
-
|
|
703
|
+
// Universal rules that apply to all components with SavedUserSettings pattern
|
|
704
|
+
ComponentLinter.universalComponentRules = [
|
|
139
705
|
// State Management Rules
|
|
140
706
|
{
|
|
141
|
-
name: '
|
|
707
|
+
name: 'full-state-ownership',
|
|
142
708
|
test: (ast, componentName) => {
|
|
143
709
|
const violations = [];
|
|
710
|
+
let hasStateFromProps = false;
|
|
711
|
+
let hasSavedUserSettings = false;
|
|
712
|
+
let usesUseState = false;
|
|
713
|
+
const stateProps = [];
|
|
714
|
+
// First pass: check if component expects state from props and uses useState
|
|
144
715
|
(0, traverse_1.default)(ast, {
|
|
716
|
+
FunctionDeclaration(path) {
|
|
717
|
+
if (path.node.id && path.node.id.name === componentName && path.node.params[0]) {
|
|
718
|
+
const param = path.node.params[0];
|
|
719
|
+
if (t.isObjectPattern(param)) {
|
|
720
|
+
for (const prop of param.properties) {
|
|
721
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
722
|
+
const propName = prop.key.name;
|
|
723
|
+
// Check for state-like props
|
|
724
|
+
const statePatterns = ['selectedId', 'selectedItemId', 'filters', 'sortBy', 'sortField', 'currentPage', 'activeTab'];
|
|
725
|
+
if (statePatterns.some(pattern => propName.includes(pattern))) {
|
|
726
|
+
hasStateFromProps = true;
|
|
727
|
+
stateProps.push(propName);
|
|
728
|
+
}
|
|
729
|
+
if (propName === 'savedUserSettings') {
|
|
730
|
+
hasSavedUserSettings = true;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
// Also check arrow functions
|
|
738
|
+
VariableDeclarator(path) {
|
|
739
|
+
if (t.isIdentifier(path.node.id) && path.node.id.name === componentName) {
|
|
740
|
+
const init = path.node.init;
|
|
741
|
+
if (t.isArrowFunctionExpression(init) && init.params[0]) {
|
|
742
|
+
const param = init.params[0];
|
|
743
|
+
if (t.isObjectPattern(param)) {
|
|
744
|
+
for (const prop of param.properties) {
|
|
745
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
746
|
+
const propName = prop.key.name;
|
|
747
|
+
const statePatterns = ['selectedId', 'selectedItemId', 'filters', 'sortBy', 'sortField', 'currentPage', 'activeTab'];
|
|
748
|
+
if (statePatterns.some(pattern => propName.includes(pattern))) {
|
|
749
|
+
hasStateFromProps = true;
|
|
750
|
+
stateProps.push(propName);
|
|
751
|
+
}
|
|
752
|
+
if (propName === 'savedUserSettings') {
|
|
753
|
+
hasSavedUserSettings = true;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
// Check for useState usage
|
|
145
762
|
CallExpression(path) {
|
|
146
763
|
const callee = path.node.callee;
|
|
147
|
-
// Check for React.useState or just useState
|
|
148
764
|
if ((t.isIdentifier(callee) && callee.name === 'useState') ||
|
|
149
765
|
(t.isMemberExpression(callee) &&
|
|
150
766
|
t.isIdentifier(callee.object) && callee.object.name === 'React' &&
|
|
151
767
|
t.isIdentifier(callee.property) && callee.property.name === 'useState')) {
|
|
152
|
-
|
|
153
|
-
rule: 'no-use-state',
|
|
154
|
-
severity: 'error',
|
|
155
|
-
line: path.node.loc?.start.line || 0,
|
|
156
|
-
column: path.node.loc?.start.column || 0,
|
|
157
|
-
message: `Child component "${componentName}" uses useState at line ${path.node.loc?.start.line}. Child components must be purely controlled - receive state via props instead.`,
|
|
158
|
-
code: path.toString()
|
|
159
|
-
});
|
|
768
|
+
usesUseState = true;
|
|
160
769
|
}
|
|
161
770
|
}
|
|
162
771
|
});
|
|
772
|
+
// Updated logic: It's OK to have state props if:
|
|
773
|
+
// 1. Component also has savedUserSettings (for persistence)
|
|
774
|
+
// 2. Component uses useState (manages state internally)
|
|
775
|
+
// The pattern is: useState(savedUserSettings?.value ?? propValue ?? defaultValue)
|
|
776
|
+
if (hasStateFromProps && !hasSavedUserSettings) {
|
|
777
|
+
violations.push({
|
|
778
|
+
rule: 'full-state-ownership',
|
|
779
|
+
severity: 'error',
|
|
780
|
+
line: 1,
|
|
781
|
+
column: 0,
|
|
782
|
+
message: `Component "${componentName}" receives state props (${stateProps.join(', ')}) but no savedUserSettings. Add savedUserSettings prop to enable state persistence.`
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
if (hasStateFromProps && hasSavedUserSettings && !usesUseState) {
|
|
786
|
+
violations.push({
|
|
787
|
+
rule: 'full-state-ownership',
|
|
788
|
+
severity: 'error',
|
|
789
|
+
line: 1,
|
|
790
|
+
column: 0,
|
|
791
|
+
message: `Component "${componentName}" receives state props but doesn't use useState. Components must manage state internally with useState, using savedUserSettings for persistence and props as fallback defaults.`
|
|
792
|
+
});
|
|
793
|
+
}
|
|
163
794
|
return violations;
|
|
164
795
|
}
|
|
165
796
|
},
|
|
@@ -179,7 +810,7 @@ ComponentLinter.childComponentRules = [
|
|
|
179
810
|
severity: 'error',
|
|
180
811
|
line: path.node.loc?.start.line || 0,
|
|
181
812
|
column: path.node.loc?.start.column || 0,
|
|
182
|
-
message: `
|
|
813
|
+
message: `Component "${componentName}" uses useReducer at line ${path.node.loc?.start.line}. Components should manage state with useState and persist important settings with onSaveUserSettings.`,
|
|
183
814
|
code: path.toString()
|
|
184
815
|
});
|
|
185
816
|
}
|
|
@@ -188,41 +819,53 @@ ComponentLinter.childComponentRules = [
|
|
|
188
819
|
return violations;
|
|
189
820
|
}
|
|
190
821
|
},
|
|
822
|
+
// New rules for the controlled component pattern
|
|
191
823
|
{
|
|
192
|
-
name: 'no-data-
|
|
824
|
+
name: 'no-data-prop',
|
|
193
825
|
test: (ast, componentName) => {
|
|
194
826
|
const violations = [];
|
|
195
827
|
(0, traverse_1.default)(ast, {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
828
|
+
// Check function parameters for 'data' prop
|
|
829
|
+
FunctionDeclaration(path) {
|
|
830
|
+
if (path.node.id && path.node.id.name === componentName && path.node.params[0]) {
|
|
831
|
+
const param = path.node.params[0];
|
|
832
|
+
if (t.isObjectPattern(param)) {
|
|
833
|
+
for (const prop of param.properties) {
|
|
834
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'data') {
|
|
835
|
+
violations.push({
|
|
836
|
+
rule: 'no-data-prop',
|
|
837
|
+
severity: 'error',
|
|
838
|
+
line: prop.loc?.start.line || 0,
|
|
839
|
+
column: prop.loc?.start.column || 0,
|
|
840
|
+
message: `Component "${componentName}" accepts generic 'data' prop. Use specific props like 'items', 'customers', etc. instead.`,
|
|
841
|
+
code: 'data prop in component signature'
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
213
846
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
847
|
+
},
|
|
848
|
+
// Also check arrow functions
|
|
849
|
+
VariableDeclarator(path) {
|
|
850
|
+
if (t.isIdentifier(path.node.id) && path.node.id.name === componentName) {
|
|
851
|
+
const init = path.node.init;
|
|
852
|
+
if (t.isArrowFunctionExpression(init) && init.params[0]) {
|
|
853
|
+
const param = init.params[0];
|
|
854
|
+
if (t.isObjectPattern(param)) {
|
|
855
|
+
for (const prop of param.properties) {
|
|
856
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'data') {
|
|
857
|
+
violations.push({
|
|
858
|
+
rule: 'no-data-prop',
|
|
859
|
+
severity: 'error',
|
|
860
|
+
line: prop.loc?.start.line || 0,
|
|
861
|
+
column: prop.loc?.start.column || 0,
|
|
862
|
+
message: `Component "${componentName}" accepts generic 'data' prop. Use specific props like 'items', 'customers', etc. instead.`,
|
|
863
|
+
code: 'data prop in component signature'
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
226
869
|
}
|
|
227
870
|
}
|
|
228
871
|
});
|
|
@@ -230,58 +873,35 @@ ComponentLinter.childComponentRules = [
|
|
|
230
873
|
}
|
|
231
874
|
},
|
|
232
875
|
{
|
|
233
|
-
name: '
|
|
876
|
+
name: 'saved-user-settings-pattern',
|
|
234
877
|
test: (ast, componentName) => {
|
|
235
878
|
const violations = [];
|
|
879
|
+
// Check for improper onSaveUserSettings usage
|
|
236
880
|
(0, traverse_1.default)(ast, {
|
|
237
881
|
CallExpression(path) {
|
|
238
882
|
const callee = path.node.callee;
|
|
239
|
-
// Check for
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
t.isIdentifier(innerCallee.property) &&
|
|
260
|
-
(innerCallee.property.name === 'then' || innerCallee.property.name === 'catch'))) {
|
|
261
|
-
hasAsync = true;
|
|
883
|
+
// Check for onSaveUserSettings calls
|
|
884
|
+
if (t.isMemberExpression(callee) &&
|
|
885
|
+
t.isIdentifier(callee.object) && callee.object.name === 'onSaveUserSettings') {
|
|
886
|
+
// Check if saving ephemeral state
|
|
887
|
+
if (path.node.arguments.length > 0) {
|
|
888
|
+
const arg = path.node.arguments[0];
|
|
889
|
+
if (t.isObjectExpression(arg)) {
|
|
890
|
+
for (const prop of arg.properties) {
|
|
891
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
892
|
+
const key = prop.key.name;
|
|
893
|
+
const ephemeralPatterns = ['hover', 'dropdown', 'modal', 'loading', 'typing', 'draft'];
|
|
894
|
+
if (ephemeralPatterns.some(pattern => key.toLowerCase().includes(pattern))) {
|
|
895
|
+
violations.push({
|
|
896
|
+
rule: 'saved-user-settings-pattern',
|
|
897
|
+
severity: 'warning',
|
|
898
|
+
line: prop.loc?.start.line || 0,
|
|
899
|
+
column: prop.loc?.start.column || 0,
|
|
900
|
+
message: `Saving ephemeral UI state "${key}" to savedUserSettings. Only save important user preferences.`
|
|
901
|
+
});
|
|
902
|
+
}
|
|
262
903
|
}
|
|
263
|
-
},
|
|
264
|
-
AwaitExpression() {
|
|
265
|
-
hasAsync = true;
|
|
266
|
-
},
|
|
267
|
-
FunctionDeclaration(innerPath) {
|
|
268
|
-
if (innerPath.node.async)
|
|
269
|
-
hasAsync = true;
|
|
270
|
-
},
|
|
271
|
-
ArrowFunctionExpression(innerPath) {
|
|
272
|
-
if (innerPath.node.async)
|
|
273
|
-
hasAsync = true;
|
|
274
904
|
}
|
|
275
|
-
}, path.scope, path.state, path.parentPath);
|
|
276
|
-
if (hasAsync) {
|
|
277
|
-
violations.push({
|
|
278
|
-
rule: 'no-async-effects',
|
|
279
|
-
severity: 'error',
|
|
280
|
-
line: path.node.loc?.start.line || 0,
|
|
281
|
-
column: path.node.loc?.start.column || 0,
|
|
282
|
-
message: `Child component "${componentName}" has async operations in useEffect at line ${path.node.loc?.start.line}. Data should be loaded by the root component and passed as props.`,
|
|
283
|
-
code: path.toString().substring(0, 100) + '...'
|
|
284
|
-
});
|
|
285
905
|
}
|
|
286
906
|
}
|
|
287
907
|
}
|
|
@@ -289,138 +909,349 @@ ComponentLinter.childComponentRules = [
|
|
|
289
909
|
});
|
|
290
910
|
return violations;
|
|
291
911
|
}
|
|
292
|
-
}
|
|
293
|
-
];
|
|
294
|
-
ComponentLinter.rootComponentRules = [
|
|
912
|
+
},
|
|
295
913
|
{
|
|
296
|
-
name: '
|
|
914
|
+
name: 'pass-standard-props',
|
|
297
915
|
test: (ast, componentName) => {
|
|
298
916
|
const violations = [];
|
|
299
|
-
|
|
917
|
+
const requiredProps = ['styles', 'utilities', 'components'];
|
|
300
918
|
(0, traverse_1.default)(ast, {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
919
|
+
JSXElement(path) {
|
|
920
|
+
const openingElement = path.node.openingElement;
|
|
921
|
+
// Check if this looks like a component (capitalized name)
|
|
922
|
+
if (t.isJSXIdentifier(openingElement.name) && /^[A-Z]/.test(openingElement.name.name)) {
|
|
923
|
+
const componentBeingCalled = openingElement.name.name;
|
|
924
|
+
const passedProps = new Set();
|
|
925
|
+
// Collect all props being passed
|
|
926
|
+
for (const attr of openingElement.attributes) {
|
|
927
|
+
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name)) {
|
|
928
|
+
passedProps.add(attr.name.name);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// Check if required props are missing
|
|
932
|
+
const missingProps = requiredProps.filter(prop => !passedProps.has(prop));
|
|
933
|
+
if (missingProps.length > 0 && passedProps.size > 0) {
|
|
934
|
+
// Only report if some props are passed (to avoid false positives on non-component JSX)
|
|
935
|
+
violations.push({
|
|
936
|
+
rule: 'pass-standard-props',
|
|
937
|
+
severity: 'error',
|
|
938
|
+
line: openingElement.loc?.start.line || 0,
|
|
939
|
+
column: openingElement.loc?.start.column || 0,
|
|
940
|
+
message: `Component "${componentBeingCalled}" is missing required props: ${missingProps.join(', ')}. All components must receive styles, utilities, and components props.`,
|
|
941
|
+
code: `<${componentBeingCalled} ... />`
|
|
942
|
+
});
|
|
943
|
+
}
|
|
304
944
|
}
|
|
305
|
-
}
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
return violations;
|
|
948
|
+
}
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
name: 'no-child-implementation',
|
|
952
|
+
test: (ast, componentName) => {
|
|
953
|
+
const violations = [];
|
|
954
|
+
const rootFunctionName = componentName;
|
|
955
|
+
const declaredFunctions = [];
|
|
956
|
+
// First pass: collect all function declarations
|
|
957
|
+
(0, traverse_1.default)(ast, {
|
|
306
958
|
FunctionDeclaration(path) {
|
|
307
|
-
if (path.node.id
|
|
308
|
-
|
|
959
|
+
if (path.node.id) {
|
|
960
|
+
declaredFunctions.push(path.node.id.name);
|
|
309
961
|
}
|
|
310
962
|
}
|
|
311
963
|
});
|
|
312
|
-
|
|
964
|
+
// If there are multiple function declarations and they look like components
|
|
965
|
+
// (start with capital letter), it's likely implementing children
|
|
966
|
+
const componentFunctions = declaredFunctions.filter(name => name !== rootFunctionName && /^[A-Z]/.test(name));
|
|
967
|
+
if (componentFunctions.length > 0) {
|
|
313
968
|
violations.push({
|
|
314
|
-
rule: '
|
|
969
|
+
rule: 'no-child-implementation',
|
|
315
970
|
severity: 'error',
|
|
316
971
|
line: 1,
|
|
317
972
|
column: 0,
|
|
318
|
-
message: `Root component
|
|
973
|
+
message: `Root component file contains child component implementations: ${componentFunctions.join(', ')}. Root should only reference child components, not implement them.`,
|
|
319
974
|
});
|
|
320
975
|
}
|
|
321
976
|
return violations;
|
|
322
977
|
}
|
|
323
978
|
},
|
|
324
979
|
{
|
|
325
|
-
name: '
|
|
980
|
+
name: 'undefined-component-usage',
|
|
326
981
|
test: (ast, componentName) => {
|
|
327
982
|
const violations = [];
|
|
328
|
-
|
|
983
|
+
const componentsFromProps = new Set();
|
|
984
|
+
const componentsUsedInJSX = new Set();
|
|
985
|
+
let hasComponentsProp = false;
|
|
329
986
|
(0, traverse_1.default)(ast, {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
987
|
+
// First, find what's destructured from the components prop
|
|
988
|
+
VariableDeclarator(path) {
|
|
989
|
+
if (t.isObjectPattern(path.node.id) && t.isIdentifier(path.node.init)) {
|
|
990
|
+
// Check if destructuring from 'components'
|
|
991
|
+
if (path.node.init.name === 'components') {
|
|
992
|
+
hasComponentsProp = true;
|
|
993
|
+
for (const prop of path.node.id.properties) {
|
|
994
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
995
|
+
componentsFromProps.add(prop.key.name);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
338
998
|
}
|
|
339
999
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
1000
|
+
},
|
|
1001
|
+
// Also check object destructuring in function parameters
|
|
1002
|
+
FunctionDeclaration(path) {
|
|
1003
|
+
if (path.node.id && path.node.id.name === componentName && path.node.params[0]) {
|
|
1004
|
+
const param = path.node.params[0];
|
|
1005
|
+
if (t.isObjectPattern(param)) {
|
|
1006
|
+
for (const prop of param.properties) {
|
|
1007
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'components') {
|
|
1008
|
+
hasComponentsProp = true;
|
|
1009
|
+
// Look for nested destructuring like { components: { A, B } }
|
|
1010
|
+
if (t.isObjectPattern(prop.value)) {
|
|
1011
|
+
for (const innerProp of prop.value.properties) {
|
|
1012
|
+
if (t.isObjectProperty(innerProp) && t.isIdentifier(innerProp.key)) {
|
|
1013
|
+
componentsFromProps.add(innerProp.key.name);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
},
|
|
1022
|
+
// Track JSX element usage
|
|
1023
|
+
JSXElement(path) {
|
|
1024
|
+
const openingElement = path.node.openingElement;
|
|
1025
|
+
if (t.isJSXIdentifier(openingElement.name) && /^[A-Z]/.test(openingElement.name.name)) {
|
|
1026
|
+
const componentName = openingElement.name.name;
|
|
1027
|
+
// Only track if it's from our destructured components
|
|
1028
|
+
if (componentsFromProps.has(componentName)) {
|
|
1029
|
+
componentsUsedInJSX.add(componentName);
|
|
345
1030
|
}
|
|
346
1031
|
}
|
|
347
1032
|
}
|
|
348
1033
|
});
|
|
349
|
-
if
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
1034
|
+
// Only check if we found a components prop
|
|
1035
|
+
if (hasComponentsProp && componentsFromProps.size > 0) {
|
|
1036
|
+
// Find components that are destructured but never used
|
|
1037
|
+
const unusedComponents = Array.from(componentsFromProps).filter(comp => !componentsUsedInJSX.has(comp));
|
|
1038
|
+
if (unusedComponents.length > 0) {
|
|
1039
|
+
violations.push({
|
|
1040
|
+
rule: 'undefined-component-usage',
|
|
1041
|
+
severity: 'warning',
|
|
1042
|
+
line: 1,
|
|
1043
|
+
column: 0,
|
|
1044
|
+
message: `Component destructures ${unusedComponents.join(', ')} from components prop but never uses them. These may be missing from the component spec's dependencies array.`
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
357
1047
|
}
|
|
358
1048
|
return violations;
|
|
359
1049
|
}
|
|
360
1050
|
},
|
|
361
1051
|
{
|
|
362
|
-
name: '
|
|
1052
|
+
name: 'unsafe-array-access',
|
|
363
1053
|
test: (ast, componentName) => {
|
|
364
1054
|
const violations = [];
|
|
365
|
-
let hasUserStateSpread = false;
|
|
366
1055
|
(0, traverse_1.default)(ast, {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
1056
|
+
MemberExpression(path) {
|
|
1057
|
+
// Check for array[index] patterns
|
|
1058
|
+
if (t.isNumericLiteral(path.node.property) ||
|
|
1059
|
+
(t.isIdentifier(path.node.property) && path.node.computed && /^\d+$/.test(path.node.property.name))) {
|
|
1060
|
+
// Look for patterns like: someArray[0].method()
|
|
1061
|
+
const parent = path.parent;
|
|
1062
|
+
if (t.isMemberExpression(parent) && parent.object === path.node) {
|
|
1063
|
+
const code = path.toString();
|
|
1064
|
+
// Check if it's an array access followed by a method call
|
|
1065
|
+
if (/\[\d+\]\.\w+/.test(code)) {
|
|
1066
|
+
violations.push({
|
|
1067
|
+
rule: 'unsafe-array-access',
|
|
1068
|
+
severity: 'error',
|
|
1069
|
+
line: path.node.loc?.start.line || 0,
|
|
1070
|
+
column: path.node.loc?.start.column || 0,
|
|
1071
|
+
message: `Unsafe array access: ${code}. Check array bounds before accessing elements.`,
|
|
1072
|
+
code: code
|
|
1073
|
+
});
|
|
382
1074
|
}
|
|
383
1075
|
}
|
|
384
1076
|
}
|
|
385
1077
|
}
|
|
386
1078
|
});
|
|
387
|
-
if (!hasUserStateSpread) {
|
|
388
|
-
violations.push({
|
|
389
|
-
rule: 'spread-user-state',
|
|
390
|
-
severity: 'error',
|
|
391
|
-
line: 1,
|
|
392
|
-
column: 0,
|
|
393
|
-
message: `Root component "${componentName}" must spread ...userState in initial state to preserve user preferences.`,
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
1079
|
return violations;
|
|
397
1080
|
}
|
|
398
1081
|
},
|
|
399
1082
|
{
|
|
400
|
-
name: '
|
|
1083
|
+
name: 'array-reduce-safety',
|
|
401
1084
|
test: (ast, componentName) => {
|
|
402
1085
|
const violations = [];
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
1086
|
+
(0, traverse_1.default)(ast, {
|
|
1087
|
+
CallExpression(path) {
|
|
1088
|
+
// Check for .reduce() calls
|
|
1089
|
+
if (t.isMemberExpression(path.node.callee) &&
|
|
1090
|
+
t.isIdentifier(path.node.callee.property) &&
|
|
1091
|
+
path.node.callee.property.name === 'reduce') {
|
|
1092
|
+
// Check if the array might be empty
|
|
1093
|
+
const arrayExpression = path.node.callee.object;
|
|
1094
|
+
const code = path.toString();
|
|
1095
|
+
// Look for patterns that suggest no safety check
|
|
1096
|
+
const hasInitialValue = path.node.arguments.length > 1;
|
|
1097
|
+
if (!hasInitialValue) {
|
|
1098
|
+
violations.push({
|
|
1099
|
+
rule: 'array-reduce-safety',
|
|
1100
|
+
severity: 'warning',
|
|
1101
|
+
line: path.node.loc?.start.line || 0,
|
|
1102
|
+
column: path.node.loc?.start.column || 0,
|
|
1103
|
+
message: `reduce() without initial value may fail on empty arrays: ${code}`,
|
|
1104
|
+
code: code.substring(0, 100) + (code.length > 100 ? '...' : '')
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
// Check for reduce on array access like arr[0].reduce()
|
|
1108
|
+
if (t.isMemberExpression(arrayExpression) &&
|
|
1109
|
+
(t.isNumericLiteral(arrayExpression.property) ||
|
|
1110
|
+
(t.isIdentifier(arrayExpression.property) && arrayExpression.computed))) {
|
|
1111
|
+
violations.push({
|
|
1112
|
+
rule: 'array-reduce-safety',
|
|
1113
|
+
severity: 'error',
|
|
1114
|
+
line: path.node.loc?.start.line || 0,
|
|
1115
|
+
column: path.node.loc?.start.column || 0,
|
|
1116
|
+
message: `reduce() on array element access is unsafe: ${code}`,
|
|
1117
|
+
code: code.substring(0, 100) + (code.length > 100 ? '...' : '')
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
return violations;
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
name: 'parent-event-callback-usage',
|
|
1128
|
+
test: (ast, componentName) => {
|
|
1129
|
+
const violations = [];
|
|
1130
|
+
const eventCallbacks = new Map();
|
|
1131
|
+
const callbackInvocations = new Set();
|
|
1132
|
+
const stateUpdateHandlers = new Map(); // handler -> state updates
|
|
1133
|
+
// First pass: collect event callback props (onSelect, onChange, etc.)
|
|
406
1134
|
(0, traverse_1.default)(ast, {
|
|
407
1135
|
FunctionDeclaration(path) {
|
|
408
|
-
if (path.node.id) {
|
|
409
|
-
|
|
1136
|
+
if (path.node.id && path.node.id.name === componentName && path.node.params[0]) {
|
|
1137
|
+
const param = path.node.params[0];
|
|
1138
|
+
if (t.isObjectPattern(param)) {
|
|
1139
|
+
for (const prop of param.properties) {
|
|
1140
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
1141
|
+
const propName = prop.key.name;
|
|
1142
|
+
// Check for event callback patterns
|
|
1143
|
+
if (/^on[A-Z]/.test(propName) &&
|
|
1144
|
+
propName !== 'onSaveUserSettings' &&
|
|
1145
|
+
!propName.includes('StateChanged')) {
|
|
1146
|
+
eventCallbacks.set(propName, {
|
|
1147
|
+
line: prop.loc?.start.line || 0,
|
|
1148
|
+
column: prop.loc?.start.column || 0
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
},
|
|
1156
|
+
// Also check arrow function components
|
|
1157
|
+
VariableDeclarator(path) {
|
|
1158
|
+
if (t.isIdentifier(path.node.id) && path.node.id.name === componentName) {
|
|
1159
|
+
const init = path.node.init;
|
|
1160
|
+
if (t.isArrowFunctionExpression(init) && init.params[0]) {
|
|
1161
|
+
const param = init.params[0];
|
|
1162
|
+
if (t.isObjectPattern(param)) {
|
|
1163
|
+
for (const prop of param.properties) {
|
|
1164
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
1165
|
+
const propName = prop.key.name;
|
|
1166
|
+
if (/^on[A-Z]/.test(propName) &&
|
|
1167
|
+
propName !== 'onSaveUserSettings' &&
|
|
1168
|
+
!propName.includes('StateChanged')) {
|
|
1169
|
+
eventCallbacks.set(propName, {
|
|
1170
|
+
line: prop.loc?.start.line || 0,
|
|
1171
|
+
column: prop.loc?.start.column || 0
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
410
1178
|
}
|
|
411
1179
|
}
|
|
412
1180
|
});
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
1181
|
+
// Second pass: check if callbacks are invoked in event handlers
|
|
1182
|
+
(0, traverse_1.default)(ast, {
|
|
1183
|
+
CallExpression(path) {
|
|
1184
|
+
// Check for callback invocations
|
|
1185
|
+
if (t.isIdentifier(path.node.callee)) {
|
|
1186
|
+
const callbackName = path.node.callee.name;
|
|
1187
|
+
if (eventCallbacks.has(callbackName)) {
|
|
1188
|
+
callbackInvocations.add(callbackName);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
// Check for state updates (setSelectedId, setFilters, etc.)
|
|
1192
|
+
if (t.isIdentifier(path.node.callee) && /^set[A-Z]/.test(path.node.callee.name)) {
|
|
1193
|
+
// Find the containing function
|
|
1194
|
+
let containingFunction = path.getFunctionParent();
|
|
1195
|
+
if (containingFunction) {
|
|
1196
|
+
const funcName = ComponentLinter.getFunctionName(containingFunction);
|
|
1197
|
+
if (funcName) {
|
|
1198
|
+
if (!stateUpdateHandlers.has(funcName)) {
|
|
1199
|
+
stateUpdateHandlers.set(funcName, []);
|
|
1200
|
+
}
|
|
1201
|
+
stateUpdateHandlers.get(funcName).push(path.node.callee.name);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
},
|
|
1206
|
+
// Check conditional callback invocations
|
|
1207
|
+
IfStatement(path) {
|
|
1208
|
+
if (t.isBlockStatement(path.node.consequent)) {
|
|
1209
|
+
// Check if the condition tests for callback existence
|
|
1210
|
+
if (t.isIdentifier(path.node.test)) {
|
|
1211
|
+
const callbackName = path.node.test.name;
|
|
1212
|
+
if (eventCallbacks.has(callbackName)) {
|
|
1213
|
+
// Check if callback is invoked in the block
|
|
1214
|
+
let hasInvocation = false;
|
|
1215
|
+
path.traverse({
|
|
1216
|
+
CallExpression(innerPath) {
|
|
1217
|
+
if (t.isIdentifier(innerPath.node.callee) &&
|
|
1218
|
+
innerPath.node.callee.name === callbackName) {
|
|
1219
|
+
hasInvocation = true;
|
|
1220
|
+
callbackInvocations.add(callbackName);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
// Check for unused callbacks that have related state updates
|
|
1230
|
+
for (const [callbackName, location] of eventCallbacks) {
|
|
1231
|
+
if (!callbackInvocations.has(callbackName)) {
|
|
1232
|
+
// Try to find related state update handlers
|
|
1233
|
+
const relatedHandlers = [];
|
|
1234
|
+
const expectedStateName = callbackName.replace(/^on/, '').replace(/Change$|Select$/, '');
|
|
1235
|
+
for (const [handlerName, stateUpdates] of stateUpdateHandlers) {
|
|
1236
|
+
for (const stateUpdate of stateUpdates) {
|
|
1237
|
+
if (stateUpdate.toLowerCase().includes(expectedStateName.toLowerCase()) ||
|
|
1238
|
+
handlerName.toLowerCase().includes(expectedStateName.toLowerCase())) {
|
|
1239
|
+
relatedHandlers.push(handlerName);
|
|
1240
|
+
break;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (relatedHandlers.length > 0) {
|
|
1245
|
+
violations.push({
|
|
1246
|
+
rule: 'parent-event-callback-usage',
|
|
1247
|
+
severity: 'error',
|
|
1248
|
+
line: location.line,
|
|
1249
|
+
column: location.column,
|
|
1250
|
+
message: `Component receives '${callbackName}' event callback but never invokes it. Found state updates in ${relatedHandlers.join(', ')} but parent is not notified.`,
|
|
1251
|
+
code: `Missing: if (${callbackName}) ${callbackName}(...)`
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
424
1255
|
}
|
|
425
1256
|
return violations;
|
|
426
1257
|
}
|