@memberjunction/react-test-harness 2.74.0 → 2.76.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/README.md +177 -39
- package/dist/cli/index.js +0 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/component-linter.d.ts +3 -4
- package/dist/lib/component-linter.d.ts.map +1 -1
- package/dist/lib/component-linter.js +692 -201
- package/dist/lib/component-linter.js.map +1 -1
- package/dist/lib/component-runner.d.ts +19 -8
- package/dist/lib/component-runner.d.ts.map +1 -1
- package/dist/lib/component-runner.js +330 -140
- package/dist/lib/component-runner.js.map +1 -1
- package/dist/lib/test-harness.d.ts +3 -12
- package/dist/lib/test-harness.d.ts.map +1 -1
- package/dist/lib/test-harness.js +21 -66
- 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,417 @@ 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
|
+
if (componentSpec.dataRequirements?.entities) {
|
|
83
|
+
for (const entity of componentSpec.dataRequirements.entities) {
|
|
84
|
+
if (entity.name) {
|
|
85
|
+
requiredEntities.add(entity.name);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (componentSpec.dataRequirements?.queries) {
|
|
90
|
+
for (const query of componentSpec.dataRequirements.queries) {
|
|
91
|
+
if (query.name) {
|
|
92
|
+
requiredQueries.add(query.name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Also check child components' dataRequirements
|
|
97
|
+
if (componentSpec.dependencies) {
|
|
98
|
+
for (const dep of componentSpec.dependencies) {
|
|
99
|
+
if (dep.dataRequirements?.entities) {
|
|
100
|
+
for (const entity of dep.dataRequirements.entities) {
|
|
101
|
+
if (entity.name) {
|
|
102
|
+
requiredEntities.add(entity.name);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (dep.dataRequirements?.queries) {
|
|
107
|
+
for (const query of dep.dataRequirements.queries) {
|
|
108
|
+
if (query.name) {
|
|
109
|
+
requiredQueries.add(query.name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Find all RunView, RunViews, and RunQuery calls in the code
|
|
116
|
+
(0, traverse_1.default)(ast, {
|
|
117
|
+
CallExpression(path) {
|
|
118
|
+
// Check for utilities.rv.RunView or utilities.rv.RunViews pattern
|
|
119
|
+
if (t.isMemberExpression(path.node.callee) &&
|
|
120
|
+
t.isMemberExpression(path.node.callee.object) &&
|
|
121
|
+
t.isIdentifier(path.node.callee.object.object) &&
|
|
122
|
+
path.node.callee.object.object.name === 'utilities' &&
|
|
123
|
+
t.isIdentifier(path.node.callee.object.property) &&
|
|
124
|
+
path.node.callee.object.property.name === 'rv' &&
|
|
125
|
+
t.isIdentifier(path.node.callee.property) &&
|
|
126
|
+
(path.node.callee.property.name === 'RunView' || path.node.callee.property.name === 'RunViews')) {
|
|
127
|
+
// For RunViews, it might be an array of configs
|
|
128
|
+
const configs = path.node.callee.property.name === 'RunViews' &&
|
|
129
|
+
path.node.arguments.length > 0 &&
|
|
130
|
+
t.isArrayExpression(path.node.arguments[0])
|
|
131
|
+
? path.node.arguments[0].elements.filter(e => t.isObjectExpression(e))
|
|
132
|
+
: path.node.arguments.length > 0 && t.isObjectExpression(path.node.arguments[0])
|
|
133
|
+
? [path.node.arguments[0]]
|
|
134
|
+
: [];
|
|
135
|
+
// Check each config object
|
|
136
|
+
for (const configObj of configs) {
|
|
137
|
+
if (t.isObjectExpression(configObj)) {
|
|
138
|
+
// Find EntityName property
|
|
139
|
+
for (const prop of configObj.properties) {
|
|
140
|
+
if (t.isObjectProperty(prop) &&
|
|
141
|
+
t.isIdentifier(prop.key) &&
|
|
142
|
+
prop.key.name === 'EntityName' &&
|
|
143
|
+
t.isStringLiteral(prop.value)) {
|
|
144
|
+
const usedEntity = prop.value.value;
|
|
145
|
+
// Check if this entity is in the required entities
|
|
146
|
+
if (requiredEntities.size > 0 && !requiredEntities.has(usedEntity)) {
|
|
147
|
+
// Try to find the closest match
|
|
148
|
+
const possibleMatches = Array.from(requiredEntities).filter(e => e.toLowerCase().includes(usedEntity.toLowerCase()) ||
|
|
149
|
+
usedEntity.toLowerCase().includes(e.toLowerCase()));
|
|
150
|
+
violations.push({
|
|
151
|
+
rule: 'entity-name-mismatch',
|
|
152
|
+
severity: 'error',
|
|
153
|
+
line: prop.value.loc?.start.line || 0,
|
|
154
|
+
column: prop.value.loc?.start.column || 0,
|
|
155
|
+
message: `Entity "${usedEntity}" not found in dataRequirements. ${possibleMatches.length > 0
|
|
156
|
+
? `Did you mean "${possibleMatches[0]}"?`
|
|
157
|
+
: `Available entities: ${Array.from(requiredEntities).join(', ')}`}`,
|
|
158
|
+
code: `EntityName: "${usedEntity}"`
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Check for utilities.rv.RunQuery pattern
|
|
167
|
+
if (t.isMemberExpression(path.node.callee) &&
|
|
168
|
+
t.isMemberExpression(path.node.callee.object) &&
|
|
169
|
+
t.isIdentifier(path.node.callee.object.object) &&
|
|
170
|
+
path.node.callee.object.object.name === 'utilities' &&
|
|
171
|
+
t.isIdentifier(path.node.callee.object.property) &&
|
|
172
|
+
path.node.callee.object.property.name === 'rv' &&
|
|
173
|
+
t.isIdentifier(path.node.callee.property) &&
|
|
174
|
+
path.node.callee.property.name === 'RunQuery') {
|
|
175
|
+
// Check the first argument (should be an object with QueryName)
|
|
176
|
+
if (path.node.arguments.length > 0 && t.isObjectExpression(path.node.arguments[0])) {
|
|
177
|
+
const configObj = path.node.arguments[0];
|
|
178
|
+
// Find QueryName property
|
|
179
|
+
for (const prop of configObj.properties) {
|
|
180
|
+
if (t.isObjectProperty(prop) &&
|
|
181
|
+
t.isIdentifier(prop.key) &&
|
|
182
|
+
prop.key.name === 'QueryName' &&
|
|
183
|
+
t.isStringLiteral(prop.value)) {
|
|
184
|
+
const usedQuery = prop.value.value;
|
|
185
|
+
// Check if this query is in the required queries
|
|
186
|
+
if (requiredQueries.size > 0 && !requiredQueries.has(usedQuery)) {
|
|
187
|
+
// Try to find the closest match
|
|
188
|
+
const possibleMatches = Array.from(requiredQueries).filter(q => q.toLowerCase().includes(usedQuery.toLowerCase()) ||
|
|
189
|
+
usedQuery.toLowerCase().includes(q.toLowerCase()));
|
|
190
|
+
violations.push({
|
|
191
|
+
rule: 'query-name-mismatch',
|
|
192
|
+
severity: 'error',
|
|
193
|
+
line: prop.value.loc?.start.line || 0,
|
|
194
|
+
column: prop.value.loc?.start.column || 0,
|
|
195
|
+
message: `Query "${usedQuery}" not found in dataRequirements. ${possibleMatches.length > 0
|
|
196
|
+
? `Did you mean "${possibleMatches[0]}"?`
|
|
197
|
+
: requiredQueries.size > 0
|
|
198
|
+
? `Available queries: ${Array.from(requiredQueries).join(', ')}`
|
|
199
|
+
: `No queries defined in dataRequirements`}`,
|
|
200
|
+
code: `QueryName: "${usedQuery}"`
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
return violations;
|
|
210
|
+
}
|
|
211
|
+
static generateFixSuggestions(violations) {
|
|
74
212
|
const suggestions = [];
|
|
75
213
|
for (const violation of violations) {
|
|
76
214
|
switch (violation.rule) {
|
|
77
|
-
case '
|
|
215
|
+
case 'full-state-ownership':
|
|
78
216
|
suggestions.push({
|
|
79
217
|
violation: violation.rule,
|
|
80
|
-
suggestion: '
|
|
81
|
-
example:
|
|
218
|
+
suggestion: 'Components must own ALL their state - use useState with SavedUserSettings override pattern',
|
|
219
|
+
example: `// ✅ CORRECT - Full state ownership with fallback pattern:
|
|
220
|
+
function Component({
|
|
221
|
+
items,
|
|
222
|
+
customers,
|
|
223
|
+
selectedId: selectedIdProp, // Props can provide defaults
|
|
224
|
+
filters: filtersProp,
|
|
225
|
+
savedUserSettings, // Saved settings override props
|
|
226
|
+
onSaveUserSettings
|
|
227
|
+
}) {
|
|
228
|
+
// Component owns ALL state - savedUserSettings overrides props
|
|
229
|
+
const [selectedId, setSelectedId] = useState(
|
|
230
|
+
savedUserSettings?.selectedId ?? selectedIdProp ?? null
|
|
231
|
+
);
|
|
232
|
+
const [filters, setFilters] = useState(
|
|
233
|
+
savedUserSettings?.filters ?? filtersProp ?? {}
|
|
234
|
+
);
|
|
235
|
+
const [searchDraft, setSearchDraft] = useState(''); // Ephemeral - no prop/save
|
|
236
|
+
|
|
237
|
+
// Handle selection and save preference
|
|
238
|
+
const handleSelect = (id) => {
|
|
239
|
+
setSelectedId(id);
|
|
240
|
+
onSaveUserSettings?.({
|
|
241
|
+
...savedUserSettings,
|
|
242
|
+
selectedId: id
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Priority order:
|
|
247
|
+
// 1. savedUserSettings (user's saved preference)
|
|
248
|
+
// 2. props (parent's suggestion/default)
|
|
249
|
+
// 3. component default
|
|
250
|
+
}`
|
|
82
251
|
});
|
|
83
252
|
break;
|
|
84
|
-
case 'no-
|
|
253
|
+
case 'no-use-reducer':
|
|
85
254
|
suggestions.push({
|
|
86
255
|
violation: violation.rule,
|
|
87
|
-
suggestion: '
|
|
88
|
-
example:
|
|
256
|
+
suggestion: 'Use useState for state management, not useReducer',
|
|
257
|
+
example: `// Instead of:
|
|
258
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
|
259
|
+
|
|
260
|
+
// Use useState:
|
|
261
|
+
function Component({ savedUserSettings, onSaveUserSettings }) {
|
|
262
|
+
const [selectedId, setSelectedId] = useState(
|
|
263
|
+
savedUserSettings?.selectedId
|
|
264
|
+
);
|
|
265
|
+
const [filters, setFilters] = useState(
|
|
266
|
+
savedUserSettings?.filters || {}
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Handle actions directly
|
|
270
|
+
const handleAction = (action) => {
|
|
271
|
+
switch(action.type) {
|
|
272
|
+
case 'SELECT':
|
|
273
|
+
setSelectedId(action.payload);
|
|
274
|
+
onSaveUserSettings?.({ ...savedUserSettings, selectedId: action.payload });
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}`
|
|
89
279
|
});
|
|
90
280
|
break;
|
|
91
|
-
case 'no-
|
|
281
|
+
case 'no-data-prop':
|
|
92
282
|
suggestions.push({
|
|
93
283
|
violation: violation.rule,
|
|
94
|
-
suggestion: '
|
|
95
|
-
example:
|
|
284
|
+
suggestion: 'Replace generic data prop with specific named props',
|
|
285
|
+
example: `// Instead of:
|
|
286
|
+
function Component({ data, savedUserSettings, onSaveUserSettings }) {
|
|
287
|
+
return <div>{data.items.map(...)}</div>;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Use specific props:
|
|
291
|
+
function Component({ items, customers, savedUserSettings, onSaveUserSettings }) {
|
|
292
|
+
// Component owns its state
|
|
293
|
+
const [selectedItemId, setSelectedItemId] = useState(
|
|
294
|
+
savedUserSettings?.selectedItemId
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
return <div>{items.map(...)}</div>;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Load data using utilities:
|
|
301
|
+
const result = await utilities.rv.RunView({ entityName: 'Items' });`
|
|
96
302
|
});
|
|
97
303
|
break;
|
|
98
|
-
case '
|
|
304
|
+
case 'saved-user-settings-pattern':
|
|
99
305
|
suggestions.push({
|
|
100
306
|
violation: violation.rule,
|
|
101
|
-
suggestion: '
|
|
102
|
-
example:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
307
|
+
suggestion: 'Only save important user preferences, not ephemeral UI state',
|
|
308
|
+
example: `// ✅ SAVE these (important preferences):
|
|
309
|
+
- Selected items/tabs: selectedCustomerId, activeTab
|
|
310
|
+
- Sort preferences: sortBy, sortDirection
|
|
311
|
+
- Filter selections: activeFilters
|
|
312
|
+
- View preferences: viewMode, pageSize
|
|
313
|
+
|
|
314
|
+
// ❌ DON'T SAVE these (ephemeral UI):
|
|
315
|
+
- Hover states: hoveredItemId
|
|
316
|
+
- Dropdown states: isDropdownOpen
|
|
317
|
+
- Text being typed: searchDraft (save on submit)
|
|
318
|
+
- Loading states: isLoading
|
|
319
|
+
|
|
320
|
+
// Example:
|
|
321
|
+
const handleHover = (id) => {
|
|
322
|
+
setHoveredId(id); // Just local state
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const handleSelect = (id) => {
|
|
326
|
+
setSelectedId(id);
|
|
327
|
+
onSaveUserSettings?.({ // Save important preference
|
|
328
|
+
...savedUserSettings,
|
|
329
|
+
selectedId: id
|
|
330
|
+
});
|
|
108
331
|
};`
|
|
109
332
|
});
|
|
110
333
|
break;
|
|
111
|
-
case '
|
|
334
|
+
case 'pass-standard-props':
|
|
112
335
|
suggestions.push({
|
|
113
336
|
violation: violation.rule,
|
|
114
|
-
suggestion: '
|
|
115
|
-
example:
|
|
337
|
+
suggestion: 'Always pass standard props to all components',
|
|
338
|
+
example: `// Always include these props when calling components:
|
|
339
|
+
<ChildComponent
|
|
340
|
+
items={items} // Data props
|
|
341
|
+
|
|
342
|
+
// Settings persistence
|
|
343
|
+
savedUserSettings={savedUserSettings?.childComponent}
|
|
344
|
+
onSaveUserSettings={handleChildSettings}
|
|
345
|
+
|
|
346
|
+
// Standard props
|
|
347
|
+
styles={styles}
|
|
348
|
+
utilities={utilities}
|
|
349
|
+
components={components}
|
|
350
|
+
callbacks={callbacks}
|
|
351
|
+
/>`
|
|
116
352
|
});
|
|
117
353
|
break;
|
|
118
|
-
case '
|
|
354
|
+
case 'no-child-implementation':
|
|
119
355
|
suggestions.push({
|
|
120
356
|
violation: violation.rule,
|
|
121
|
-
suggestion: '
|
|
122
|
-
example: '
|
|
357
|
+
suggestion: 'Remove child component implementations. Only the root component function should be in this file',
|
|
358
|
+
example: 'Move child component functions to separate generation requests'
|
|
123
359
|
});
|
|
124
360
|
break;
|
|
125
|
-
case '
|
|
361
|
+
case 'undefined-component-usage':
|
|
126
362
|
suggestions.push({
|
|
127
363
|
violation: violation.rule,
|
|
128
|
-
suggestion: '
|
|
129
|
-
example:
|
|
364
|
+
suggestion: 'Ensure all components destructured from the components prop are defined in the component spec dependencies',
|
|
365
|
+
example: `// Component spec should include all referenced components:
|
|
366
|
+
{
|
|
367
|
+
"name": "MyComponent",
|
|
368
|
+
"code": "...",
|
|
369
|
+
"dependencies": [
|
|
370
|
+
{
|
|
371
|
+
"name": "ModelTreeView",
|
|
372
|
+
"code": "function ModelTreeView({ ... }) { ... }"
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
"name": "PromptTable",
|
|
376
|
+
"code": "function PromptTable({ ... }) { ... }"
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
"name": "FilterPanel",
|
|
380
|
+
"code": "function FilterPanel({ ... }) { ... }"
|
|
381
|
+
}
|
|
382
|
+
// Add ALL components referenced in the root component
|
|
383
|
+
]
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Then in your component:
|
|
387
|
+
const { ModelTreeView, PromptTable, FilterPanel } = components;
|
|
388
|
+
// All these will be available`
|
|
389
|
+
});
|
|
390
|
+
break;
|
|
391
|
+
case 'unsafe-array-access':
|
|
392
|
+
suggestions.push({
|
|
393
|
+
violation: violation.rule,
|
|
394
|
+
suggestion: 'Always check array bounds before accessing elements',
|
|
395
|
+
example: `// ❌ UNSAFE:
|
|
396
|
+
const firstItem = items[0].name;
|
|
397
|
+
const total = data[0].reduce((sum, item) => sum + item.value, 0);
|
|
398
|
+
|
|
399
|
+
// ✅ SAFE:
|
|
400
|
+
const firstItem = items.length > 0 ? items[0].name : 'No items';
|
|
401
|
+
const total = data.length > 0
|
|
402
|
+
? data[0].reduce((sum, item) => sum + item.value, 0)
|
|
403
|
+
: 0;
|
|
404
|
+
|
|
405
|
+
// ✅ BETTER - Use optional chaining:
|
|
406
|
+
const firstItem = items[0]?.name || 'No items';
|
|
407
|
+
const total = data[0]?.reduce((sum, item) => sum + item.value, 0) || 0;`
|
|
408
|
+
});
|
|
409
|
+
break;
|
|
410
|
+
case 'array-reduce-safety':
|
|
411
|
+
suggestions.push({
|
|
412
|
+
violation: violation.rule,
|
|
413
|
+
suggestion: 'Always provide an initial value for reduce() or check array length',
|
|
414
|
+
example: `// ❌ UNSAFE:
|
|
415
|
+
const sum = numbers.reduce((a, b) => a + b); // Fails on empty array
|
|
416
|
+
const total = data[0].reduce((sum, item) => sum + item.value); // Multiple issues
|
|
417
|
+
|
|
418
|
+
// ✅ SAFE:
|
|
419
|
+
const sum = numbers.reduce((a, b) => a + b, 0); // Initial value
|
|
420
|
+
const total = data.length > 0 && data[0]
|
|
421
|
+
? data[0].reduce((sum, item) => sum + item.value, 0)
|
|
422
|
+
: 0;
|
|
423
|
+
|
|
424
|
+
// ✅ ALSO SAFE:
|
|
425
|
+
const sum = numbers.length > 0
|
|
426
|
+
? numbers.reduce((a, b) => a + b)
|
|
427
|
+
: 0;`
|
|
428
|
+
});
|
|
429
|
+
break;
|
|
430
|
+
case 'entity-name-mismatch':
|
|
431
|
+
suggestions.push({
|
|
432
|
+
violation: violation.rule,
|
|
433
|
+
suggestion: 'Use the exact entity name from dataRequirements in RunView calls',
|
|
434
|
+
example: `// The component spec defines the entities to use:
|
|
435
|
+
// dataRequirements: {
|
|
436
|
+
// entities: [
|
|
437
|
+
// { name: "MJ: AI Prompt Runs", ... }
|
|
438
|
+
// ]
|
|
439
|
+
// }
|
|
440
|
+
|
|
441
|
+
// ❌ WRONG - Missing prefix or incorrect name:
|
|
442
|
+
await utilities.rv.RunView({
|
|
443
|
+
EntityName: "AI Prompt Runs", // Missing "MJ:" prefix
|
|
444
|
+
Fields: ["RunAt", "Success"]
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// ✅ CORRECT - Use exact name from dataRequirements:
|
|
448
|
+
await utilities.rv.RunView({
|
|
449
|
+
EntityName: "MJ: AI Prompt Runs", // Matches dataRequirements
|
|
450
|
+
Fields: ["RunAt", "Success"]
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Also works with RunViews (parallel execution):
|
|
454
|
+
await utilities.rv.RunViews([
|
|
455
|
+
{ EntityName: "MJ: AI Prompt Runs", Fields: ["RunAt"] },
|
|
456
|
+
{ EntityName: "MJ: Users", Fields: ["Name", "Email"] }
|
|
457
|
+
]);
|
|
458
|
+
|
|
459
|
+
// The linter validates that all entity names in RunView/RunViews calls
|
|
460
|
+
// match those declared in the component spec's dataRequirements`
|
|
461
|
+
});
|
|
462
|
+
break;
|
|
463
|
+
case 'query-name-mismatch':
|
|
464
|
+
suggestions.push({
|
|
465
|
+
violation: violation.rule,
|
|
466
|
+
suggestion: 'Use the exact query name from dataRequirements in RunQuery calls',
|
|
467
|
+
example: `// The component spec defines the queries to use:
|
|
468
|
+
// dataRequirements: {
|
|
469
|
+
// queries: [
|
|
470
|
+
// { name: "User Activity Summary", ... }
|
|
471
|
+
// ]
|
|
472
|
+
// }
|
|
473
|
+
|
|
474
|
+
// ❌ WRONG - Incorrect query name:
|
|
475
|
+
await utilities.rv.RunQuery({
|
|
476
|
+
QueryName: "UserActivitySummary", // Wrong name format
|
|
477
|
+
Parameters: { startDate, endDate }
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ✅ CORRECT - Use exact name from dataRequirements:
|
|
481
|
+
await utilities.rv.RunQuery({
|
|
482
|
+
QueryName: "User Activity Summary", // Matches dataRequirements
|
|
483
|
+
Parameters: { startDate, endDate }
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// The linter validates that all query names in RunQuery calls
|
|
487
|
+
// match those declared in the component spec's dataRequirements.queries`
|
|
130
488
|
});
|
|
131
489
|
break;
|
|
132
490
|
}
|
|
@@ -135,31 +493,97 @@ class ComponentLinter {
|
|
|
135
493
|
}
|
|
136
494
|
}
|
|
137
495
|
exports.ComponentLinter = ComponentLinter;
|
|
138
|
-
|
|
496
|
+
// Universal rules that apply to all components with SavedUserSettings pattern
|
|
497
|
+
ComponentLinter.universalComponentRules = [
|
|
139
498
|
// State Management Rules
|
|
140
499
|
{
|
|
141
|
-
name: '
|
|
500
|
+
name: 'full-state-ownership',
|
|
142
501
|
test: (ast, componentName) => {
|
|
143
502
|
const violations = [];
|
|
503
|
+
let hasStateFromProps = false;
|
|
504
|
+
let hasSavedUserSettings = false;
|
|
505
|
+
let usesUseState = false;
|
|
506
|
+
const stateProps = [];
|
|
507
|
+
// First pass: check if component expects state from props and uses useState
|
|
144
508
|
(0, traverse_1.default)(ast, {
|
|
509
|
+
FunctionDeclaration(path) {
|
|
510
|
+
if (path.node.id && path.node.id.name === componentName && path.node.params[0]) {
|
|
511
|
+
const param = path.node.params[0];
|
|
512
|
+
if (t.isObjectPattern(param)) {
|
|
513
|
+
for (const prop of param.properties) {
|
|
514
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
515
|
+
const propName = prop.key.name;
|
|
516
|
+
// Check for state-like props
|
|
517
|
+
const statePatterns = ['selectedId', 'selectedItemId', 'filters', 'sortBy', 'sortField', 'currentPage', 'activeTab'];
|
|
518
|
+
if (statePatterns.some(pattern => propName.includes(pattern))) {
|
|
519
|
+
hasStateFromProps = true;
|
|
520
|
+
stateProps.push(propName);
|
|
521
|
+
}
|
|
522
|
+
if (propName === 'savedUserSettings') {
|
|
523
|
+
hasSavedUserSettings = true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
// Also check arrow functions
|
|
531
|
+
VariableDeclarator(path) {
|
|
532
|
+
if (t.isIdentifier(path.node.id) && path.node.id.name === componentName) {
|
|
533
|
+
const init = path.node.init;
|
|
534
|
+
if (t.isArrowFunctionExpression(init) && init.params[0]) {
|
|
535
|
+
const param = init.params[0];
|
|
536
|
+
if (t.isObjectPattern(param)) {
|
|
537
|
+
for (const prop of param.properties) {
|
|
538
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
539
|
+
const propName = prop.key.name;
|
|
540
|
+
const statePatterns = ['selectedId', 'selectedItemId', 'filters', 'sortBy', 'sortField', 'currentPage', 'activeTab'];
|
|
541
|
+
if (statePatterns.some(pattern => propName.includes(pattern))) {
|
|
542
|
+
hasStateFromProps = true;
|
|
543
|
+
stateProps.push(propName);
|
|
544
|
+
}
|
|
545
|
+
if (propName === 'savedUserSettings') {
|
|
546
|
+
hasSavedUserSettings = true;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
// Check for useState usage
|
|
145
555
|
CallExpression(path) {
|
|
146
556
|
const callee = path.node.callee;
|
|
147
|
-
// Check for React.useState or just useState
|
|
148
557
|
if ((t.isIdentifier(callee) && callee.name === 'useState') ||
|
|
149
558
|
(t.isMemberExpression(callee) &&
|
|
150
559
|
t.isIdentifier(callee.object) && callee.object.name === 'React' &&
|
|
151
560
|
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
|
-
});
|
|
561
|
+
usesUseState = true;
|
|
160
562
|
}
|
|
161
563
|
}
|
|
162
564
|
});
|
|
565
|
+
// Updated logic: It's OK to have state props if:
|
|
566
|
+
// 1. Component also has savedUserSettings (for persistence)
|
|
567
|
+
// 2. Component uses useState (manages state internally)
|
|
568
|
+
// The pattern is: useState(savedUserSettings?.value ?? propValue ?? defaultValue)
|
|
569
|
+
if (hasStateFromProps && !hasSavedUserSettings) {
|
|
570
|
+
violations.push({
|
|
571
|
+
rule: 'full-state-ownership',
|
|
572
|
+
severity: 'error',
|
|
573
|
+
line: 1,
|
|
574
|
+
column: 0,
|
|
575
|
+
message: `Component "${componentName}" receives state props (${stateProps.join(', ')}) but no savedUserSettings. Add savedUserSettings prop to enable state persistence.`
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
if (hasStateFromProps && hasSavedUserSettings && !usesUseState) {
|
|
579
|
+
violations.push({
|
|
580
|
+
rule: 'full-state-ownership',
|
|
581
|
+
severity: 'error',
|
|
582
|
+
line: 1,
|
|
583
|
+
column: 0,
|
|
584
|
+
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.`
|
|
585
|
+
});
|
|
586
|
+
}
|
|
163
587
|
return violations;
|
|
164
588
|
}
|
|
165
589
|
},
|
|
@@ -179,7 +603,7 @@ ComponentLinter.childComponentRules = [
|
|
|
179
603
|
severity: 'error',
|
|
180
604
|
line: path.node.loc?.start.line || 0,
|
|
181
605
|
column: path.node.loc?.start.column || 0,
|
|
182
|
-
message: `
|
|
606
|
+
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
607
|
code: path.toString()
|
|
184
608
|
});
|
|
185
609
|
}
|
|
@@ -188,41 +612,53 @@ ComponentLinter.childComponentRules = [
|
|
|
188
612
|
return violations;
|
|
189
613
|
}
|
|
190
614
|
},
|
|
615
|
+
// New rules for the controlled component pattern
|
|
191
616
|
{
|
|
192
|
-
name: 'no-data-
|
|
617
|
+
name: 'no-data-prop',
|
|
193
618
|
test: (ast, componentName) => {
|
|
194
619
|
const violations = [];
|
|
195
620
|
(0, traverse_1.default)(ast, {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
621
|
+
// Check function parameters for 'data' prop
|
|
622
|
+
FunctionDeclaration(path) {
|
|
623
|
+
if (path.node.id && path.node.id.name === componentName && path.node.params[0]) {
|
|
624
|
+
const param = path.node.params[0];
|
|
625
|
+
if (t.isObjectPattern(param)) {
|
|
626
|
+
for (const prop of param.properties) {
|
|
627
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'data') {
|
|
628
|
+
violations.push({
|
|
629
|
+
rule: 'no-data-prop',
|
|
630
|
+
severity: 'error',
|
|
631
|
+
line: prop.loc?.start.line || 0,
|
|
632
|
+
column: prop.loc?.start.column || 0,
|
|
633
|
+
message: `Component "${componentName}" accepts generic 'data' prop. Use specific props like 'items', 'customers', etc. instead.`,
|
|
634
|
+
code: 'data prop in component signature'
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
213
639
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
640
|
+
},
|
|
641
|
+
// Also check arrow functions
|
|
642
|
+
VariableDeclarator(path) {
|
|
643
|
+
if (t.isIdentifier(path.node.id) && path.node.id.name === componentName) {
|
|
644
|
+
const init = path.node.init;
|
|
645
|
+
if (t.isArrowFunctionExpression(init) && init.params[0]) {
|
|
646
|
+
const param = init.params[0];
|
|
647
|
+
if (t.isObjectPattern(param)) {
|
|
648
|
+
for (const prop of param.properties) {
|
|
649
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'data') {
|
|
650
|
+
violations.push({
|
|
651
|
+
rule: 'no-data-prop',
|
|
652
|
+
severity: 'error',
|
|
653
|
+
line: prop.loc?.start.line || 0,
|
|
654
|
+
column: prop.loc?.start.column || 0,
|
|
655
|
+
message: `Component "${componentName}" accepts generic 'data' prop. Use specific props like 'items', 'customers', etc. instead.`,
|
|
656
|
+
code: 'data prop in component signature'
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
226
662
|
}
|
|
227
663
|
}
|
|
228
664
|
});
|
|
@@ -230,58 +666,35 @@ ComponentLinter.childComponentRules = [
|
|
|
230
666
|
}
|
|
231
667
|
},
|
|
232
668
|
{
|
|
233
|
-
name: '
|
|
669
|
+
name: 'saved-user-settings-pattern',
|
|
234
670
|
test: (ast, componentName) => {
|
|
235
671
|
const violations = [];
|
|
672
|
+
// Check for improper onSaveUserSettings usage
|
|
236
673
|
(0, traverse_1.default)(ast, {
|
|
237
674
|
CallExpression(path) {
|
|
238
675
|
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;
|
|
676
|
+
// Check for onSaveUserSettings calls
|
|
677
|
+
if (t.isMemberExpression(callee) &&
|
|
678
|
+
t.isIdentifier(callee.object) && callee.object.name === 'onSaveUserSettings') {
|
|
679
|
+
// Check if saving ephemeral state
|
|
680
|
+
if (path.node.arguments.length > 0) {
|
|
681
|
+
const arg = path.node.arguments[0];
|
|
682
|
+
if (t.isObjectExpression(arg)) {
|
|
683
|
+
for (const prop of arg.properties) {
|
|
684
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
685
|
+
const key = prop.key.name;
|
|
686
|
+
const ephemeralPatterns = ['hover', 'dropdown', 'modal', 'loading', 'typing', 'draft'];
|
|
687
|
+
if (ephemeralPatterns.some(pattern => key.toLowerCase().includes(pattern))) {
|
|
688
|
+
violations.push({
|
|
689
|
+
rule: 'saved-user-settings-pattern',
|
|
690
|
+
severity: 'warning',
|
|
691
|
+
line: prop.loc?.start.line || 0,
|
|
692
|
+
column: prop.loc?.start.column || 0,
|
|
693
|
+
message: `Saving ephemeral UI state "${key}" to savedUserSettings. Only save important user preferences.`
|
|
694
|
+
});
|
|
695
|
+
}
|
|
262
696
|
}
|
|
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
697
|
}
|
|
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
698
|
}
|
|
286
699
|
}
|
|
287
700
|
}
|
|
@@ -289,139 +702,217 @@ ComponentLinter.childComponentRules = [
|
|
|
289
702
|
});
|
|
290
703
|
return violations;
|
|
291
704
|
}
|
|
292
|
-
}
|
|
293
|
-
];
|
|
294
|
-
ComponentLinter.rootComponentRules = [
|
|
705
|
+
},
|
|
295
706
|
{
|
|
296
|
-
name: '
|
|
707
|
+
name: 'pass-standard-props',
|
|
297
708
|
test: (ast, componentName) => {
|
|
298
709
|
const violations = [];
|
|
299
|
-
|
|
710
|
+
const requiredProps = ['styles', 'utilities', 'components'];
|
|
300
711
|
(0, traverse_1.default)(ast, {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
712
|
+
JSXElement(path) {
|
|
713
|
+
const openingElement = path.node.openingElement;
|
|
714
|
+
// Check if this looks like a component (capitalized name)
|
|
715
|
+
if (t.isJSXIdentifier(openingElement.name) && /^[A-Z]/.test(openingElement.name.name)) {
|
|
716
|
+
const componentBeingCalled = openingElement.name.name;
|
|
717
|
+
const passedProps = new Set();
|
|
718
|
+
// Collect all props being passed
|
|
719
|
+
for (const attr of openingElement.attributes) {
|
|
720
|
+
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name)) {
|
|
721
|
+
passedProps.add(attr.name.name);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// Check if required props are missing
|
|
725
|
+
const missingProps = requiredProps.filter(prop => !passedProps.has(prop));
|
|
726
|
+
if (missingProps.length > 0 && passedProps.size > 0) {
|
|
727
|
+
// Only report if some props are passed (to avoid false positives on non-component JSX)
|
|
728
|
+
violations.push({
|
|
729
|
+
rule: 'pass-standard-props',
|
|
730
|
+
severity: 'error',
|
|
731
|
+
line: openingElement.loc?.start.line || 0,
|
|
732
|
+
column: openingElement.loc?.start.column || 0,
|
|
733
|
+
message: `Component "${componentBeingCalled}" is missing required props: ${missingProps.join(', ')}. All components must receive styles, utilities, and components props.`,
|
|
734
|
+
code: `<${componentBeingCalled} ... />`
|
|
735
|
+
});
|
|
736
|
+
}
|
|
304
737
|
}
|
|
305
|
-
}
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
return violations;
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
name: 'no-child-implementation',
|
|
745
|
+
test: (ast, componentName) => {
|
|
746
|
+
const violations = [];
|
|
747
|
+
const rootFunctionName = componentName;
|
|
748
|
+
const declaredFunctions = [];
|
|
749
|
+
// First pass: collect all function declarations
|
|
750
|
+
(0, traverse_1.default)(ast, {
|
|
306
751
|
FunctionDeclaration(path) {
|
|
307
|
-
if (path.node.id
|
|
308
|
-
|
|
752
|
+
if (path.node.id) {
|
|
753
|
+
declaredFunctions.push(path.node.id.name);
|
|
309
754
|
}
|
|
310
755
|
}
|
|
311
756
|
});
|
|
312
|
-
|
|
757
|
+
// If there are multiple function declarations and they look like components
|
|
758
|
+
// (start with capital letter), it's likely implementing children
|
|
759
|
+
const componentFunctions = declaredFunctions.filter(name => name !== rootFunctionName && /^[A-Z]/.test(name));
|
|
760
|
+
if (componentFunctions.length > 0) {
|
|
313
761
|
violations.push({
|
|
314
|
-
rule: '
|
|
762
|
+
rule: 'no-child-implementation',
|
|
315
763
|
severity: 'error',
|
|
316
764
|
line: 1,
|
|
317
765
|
column: 0,
|
|
318
|
-
message: `Root component
|
|
766
|
+
message: `Root component file contains child component implementations: ${componentFunctions.join(', ')}. Root should only reference child components, not implement them.`,
|
|
319
767
|
});
|
|
320
768
|
}
|
|
321
769
|
return violations;
|
|
322
770
|
}
|
|
323
771
|
},
|
|
324
772
|
{
|
|
325
|
-
name: '
|
|
773
|
+
name: 'undefined-component-usage',
|
|
326
774
|
test: (ast, componentName) => {
|
|
327
775
|
const violations = [];
|
|
328
|
-
|
|
776
|
+
const componentsFromProps = new Set();
|
|
777
|
+
const componentsUsedInJSX = new Set();
|
|
778
|
+
let hasComponentsProp = false;
|
|
329
779
|
(0, traverse_1.default)(ast, {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
780
|
+
// First, find what's destructured from the components prop
|
|
781
|
+
VariableDeclarator(path) {
|
|
782
|
+
if (t.isObjectPattern(path.node.id) && t.isIdentifier(path.node.init)) {
|
|
783
|
+
// Check if destructuring from 'components'
|
|
784
|
+
if (path.node.init.name === 'components') {
|
|
785
|
+
hasComponentsProp = true;
|
|
786
|
+
for (const prop of path.node.id.properties) {
|
|
787
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
788
|
+
componentsFromProps.add(prop.key.name);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
// Also check object destructuring in function parameters
|
|
795
|
+
FunctionDeclaration(path) {
|
|
796
|
+
if (path.node.id && path.node.id.name === componentName && path.node.params[0]) {
|
|
797
|
+
const param = path.node.params[0];
|
|
798
|
+
if (t.isObjectPattern(param)) {
|
|
799
|
+
for (const prop of param.properties) {
|
|
800
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'components') {
|
|
801
|
+
hasComponentsProp = true;
|
|
802
|
+
// Look for nested destructuring like { components: { A, B } }
|
|
803
|
+
if (t.isObjectPattern(prop.value)) {
|
|
804
|
+
for (const innerProp of prop.value.properties) {
|
|
805
|
+
if (t.isObjectProperty(innerProp) && t.isIdentifier(innerProp.key)) {
|
|
806
|
+
componentsFromProps.add(innerProp.key.name);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
338
812
|
}
|
|
339
813
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
814
|
+
},
|
|
815
|
+
// Track JSX element usage
|
|
816
|
+
JSXElement(path) {
|
|
817
|
+
const openingElement = path.node.openingElement;
|
|
818
|
+
if (t.isJSXIdentifier(openingElement.name) && /^[A-Z]/.test(openingElement.name.name)) {
|
|
819
|
+
const componentName = openingElement.name.name;
|
|
820
|
+
// Only track if it's from our destructured components
|
|
821
|
+
if (componentsFromProps.has(componentName)) {
|
|
822
|
+
componentsUsedInJSX.add(componentName);
|
|
345
823
|
}
|
|
346
824
|
}
|
|
347
825
|
}
|
|
348
826
|
});
|
|
349
|
-
if
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
827
|
+
// Only check if we found a components prop
|
|
828
|
+
if (hasComponentsProp && componentsFromProps.size > 0) {
|
|
829
|
+
// Find components that are destructured but never used
|
|
830
|
+
const unusedComponents = Array.from(componentsFromProps).filter(comp => !componentsUsedInJSX.has(comp));
|
|
831
|
+
if (unusedComponents.length > 0) {
|
|
832
|
+
violations.push({
|
|
833
|
+
rule: 'undefined-component-usage',
|
|
834
|
+
severity: 'warning',
|
|
835
|
+
line: 1,
|
|
836
|
+
column: 0,
|
|
837
|
+
message: `Component destructures ${unusedComponents.join(', ')} from components prop but never uses them. These may be missing from the component spec's dependencies array.`
|
|
838
|
+
});
|
|
839
|
+
}
|
|
357
840
|
}
|
|
358
841
|
return violations;
|
|
359
842
|
}
|
|
360
843
|
},
|
|
361
844
|
{
|
|
362
|
-
name: '
|
|
845
|
+
name: 'unsafe-array-access',
|
|
363
846
|
test: (ast, componentName) => {
|
|
364
847
|
const violations = [];
|
|
365
|
-
let hasUserStateSpread = false;
|
|
366
848
|
(0, traverse_1.default)(ast, {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
849
|
+
MemberExpression(path) {
|
|
850
|
+
// Check for array[index] patterns
|
|
851
|
+
if (t.isNumericLiteral(path.node.property) ||
|
|
852
|
+
(t.isIdentifier(path.node.property) && path.node.computed && /^\d+$/.test(path.node.property.name))) {
|
|
853
|
+
// Look for patterns like: someArray[0].method()
|
|
854
|
+
const parent = path.parent;
|
|
855
|
+
if (t.isMemberExpression(parent) && parent.object === path.node) {
|
|
856
|
+
const code = path.toString();
|
|
857
|
+
// Check if it's an array access followed by a method call
|
|
858
|
+
if (/\[\d+\]\.\w+/.test(code)) {
|
|
859
|
+
violations.push({
|
|
860
|
+
rule: 'unsafe-array-access',
|
|
861
|
+
severity: 'error',
|
|
862
|
+
line: path.node.loc?.start.line || 0,
|
|
863
|
+
column: path.node.loc?.start.column || 0,
|
|
864
|
+
message: `Unsafe array access: ${code}. Check array bounds before accessing elements.`,
|
|
865
|
+
code: code
|
|
866
|
+
});
|
|
382
867
|
}
|
|
383
868
|
}
|
|
384
869
|
}
|
|
385
870
|
}
|
|
386
871
|
});
|
|
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
872
|
return violations;
|
|
397
873
|
}
|
|
398
874
|
},
|
|
399
875
|
{
|
|
400
|
-
name: '
|
|
876
|
+
name: 'array-reduce-safety',
|
|
401
877
|
test: (ast, componentName) => {
|
|
402
878
|
const violations = [];
|
|
403
|
-
const rootFunctionName = componentName;
|
|
404
|
-
const declaredFunctions = [];
|
|
405
|
-
// First pass: collect all function declarations
|
|
406
879
|
(0, traverse_1.default)(ast, {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
880
|
+
CallExpression(path) {
|
|
881
|
+
// Check for .reduce() calls
|
|
882
|
+
if (t.isMemberExpression(path.node.callee) &&
|
|
883
|
+
t.isIdentifier(path.node.callee.property) &&
|
|
884
|
+
path.node.callee.property.name === 'reduce') {
|
|
885
|
+
// Check if the array might be empty
|
|
886
|
+
const arrayExpression = path.node.callee.object;
|
|
887
|
+
const code = path.toString();
|
|
888
|
+
// Look for patterns that suggest no safety check
|
|
889
|
+
const hasInitialValue = path.node.arguments.length > 1;
|
|
890
|
+
if (!hasInitialValue) {
|
|
891
|
+
violations.push({
|
|
892
|
+
rule: 'array-reduce-safety',
|
|
893
|
+
severity: 'warning',
|
|
894
|
+
line: path.node.loc?.start.line || 0,
|
|
895
|
+
column: path.node.loc?.start.column || 0,
|
|
896
|
+
message: `reduce() without initial value may fail on empty arrays: ${code}`,
|
|
897
|
+
code: code.substring(0, 100) + (code.length > 100 ? '...' : '')
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
// Check for reduce on array access like arr[0].reduce()
|
|
901
|
+
if (t.isMemberExpression(arrayExpression) &&
|
|
902
|
+
(t.isNumericLiteral(arrayExpression.property) ||
|
|
903
|
+
(t.isIdentifier(arrayExpression.property) && arrayExpression.computed))) {
|
|
904
|
+
violations.push({
|
|
905
|
+
rule: 'array-reduce-safety',
|
|
906
|
+
severity: 'error',
|
|
907
|
+
line: path.node.loc?.start.line || 0,
|
|
908
|
+
column: path.node.loc?.start.column || 0,
|
|
909
|
+
message: `reduce() on array element access is unsafe: ${code}`,
|
|
910
|
+
code: code.substring(0, 100) + (code.length > 100 ? '...' : '')
|
|
911
|
+
});
|
|
912
|
+
}
|
|
410
913
|
}
|
|
411
914
|
}
|
|
412
915
|
});
|
|
413
|
-
// If there are multiple function declarations and they look like components
|
|
414
|
-
// (start with capital letter), it's likely implementing children
|
|
415
|
-
const componentFunctions = declaredFunctions.filter(name => name !== rootFunctionName && /^[A-Z]/.test(name));
|
|
416
|
-
if (componentFunctions.length > 0) {
|
|
417
|
-
violations.push({
|
|
418
|
-
rule: 'no-child-implementation',
|
|
419
|
-
severity: 'error',
|
|
420
|
-
line: 1,
|
|
421
|
-
column: 0,
|
|
422
|
-
message: `Root component file contains child component implementations: ${componentFunctions.join(', ')}. Root should only reference child components, not implement them.`,
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
916
|
return violations;
|
|
426
917
|
}
|
|
427
918
|
}
|