@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.
@@ -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, componentType, componentName) {
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
- const rules = componentType === 'root'
42
- ? this.rootComponentRules
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, componentType);
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 generateFixSuggestions(violations, componentType) {
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 'no-use-state':
317
+ case 'full-state-ownership':
78
318
  suggestions.push({
79
319
  violation: violation.rule,
80
- suggestion: 'Remove useState and accept the value as a prop from the parent component',
81
- example: 'Replace: const [value, setValue] = useState(initialValue);\nWith: Accept "value" and "onValueChange" as props'
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-data-fetching':
355
+ case 'no-use-reducer':
85
356
  suggestions.push({
86
357
  violation: violation.rule,
87
- suggestion: 'Move data fetching to the root component and pass data as props',
88
- example: 'Remove utilities.rv.RunView() calls and accept the data as props instead'
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-async-effects':
383
+ case 'no-data-prop':
92
384
  suggestions.push({
93
385
  violation: violation.rule,
94
- suggestion: 'Remove async operations from useEffect. Let the root component handle data loading',
95
- example: 'Remove the useEffect with async operations and accept loading/error states as props'
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 'must-use-update-state':
406
+ case 'saved-user-settings-pattern':
99
407
  suggestions.push({
100
408
  violation: violation.rule,
101
- suggestion: 'Add an updateState function that syncs with callbacks',
102
- example: `const updateState = (updates) => {
103
- const newState = { ...state, ...updates };
104
- setState(newState);
105
- if (callbacks?.UpdateUserState) {
106
- callbacks.UpdateUserState(newState);
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 'sync-user-state':
436
+ case 'pass-standard-props':
112
437
  suggestions.push({
113
438
  violation: violation.rule,
114
- suggestion: 'Call callbacks?.UpdateUserState in your updateState function',
115
- example: 'Add: if (callbacks?.UpdateUserState) callbacks.UpdateUserState(newState);'
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 'spread-user-state':
456
+ case 'no-child-implementation':
119
457
  suggestions.push({
120
458
  violation: violation.rule,
121
- suggestion: 'Include ...userState in your initial state',
122
- example: 'const [state, setState] = useState({ /* your fields */, ...userState });'
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 'no-child-implementation':
463
+ case 'undefined-component-usage':
126
464
  suggestions.push({
127
465
  violation: violation.rule,
128
- suggestion: 'Remove child component implementations. Only the root component function should be in this file',
129
- example: 'Move child component functions to separate generation requests'
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
- ComponentLinter.childComponentRules = [
703
+ // Universal rules that apply to all components with SavedUserSettings pattern
704
+ ComponentLinter.universalComponentRules = [
139
705
  // State Management Rules
140
706
  {
141
- name: 'no-use-state',
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
- violations.push({
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: `Child component "${componentName}" uses useReducer at line ${path.node.loc?.start.line}. Child components must be purely controlled.`,
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-fetching',
824
+ name: 'no-data-prop',
193
825
  test: (ast, componentName) => {
194
826
  const violations = [];
195
827
  (0, traverse_1.default)(ast, {
196
- MemberExpression(path) {
197
- const object = path.node.object;
198
- const property = path.node.property;
199
- // Check for utilities.rv.RunView or utilities.rv.RunQuery
200
- if (t.isMemberExpression(object) &&
201
- t.isIdentifier(object.object) && object.object.name === 'utilities' &&
202
- t.isIdentifier(object.property) && object.property.name === 'rv' &&
203
- t.isIdentifier(property) &&
204
- (property.name === 'RunView' || property.name === 'RunQuery' || property.name === 'RunViews')) {
205
- violations.push({
206
- rule: 'no-data-fetching',
207
- severity: 'error',
208
- line: path.node.loc?.start.line || 0,
209
- column: path.node.loc?.start.column || 0,
210
- message: `Child component "${componentName}" fetches data at line ${path.node.loc?.start.line}. Only root components should load data.`,
211
- code: path.toString()
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
- // Check for utilities.md operations
215
- if (t.isMemberExpression(object) &&
216
- t.isIdentifier(object.object) && object.object.name === 'utilities' &&
217
- t.isIdentifier(object.property) && object.property.name === 'md') {
218
- violations.push({
219
- rule: 'no-data-fetching',
220
- severity: 'error',
221
- line: path.node.loc?.start.line || 0,
222
- column: path.node.loc?.start.column || 0,
223
- message: `Child component "${componentName}" accesses entity operations at line ${path.node.loc?.start.line}. Only root components should manage entities.`,
224
- code: path.toString()
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: 'no-async-effects',
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 useEffect
240
- if ((t.isIdentifier(callee) && callee.name === 'useEffect') ||
241
- (t.isMemberExpression(callee) &&
242
- t.isIdentifier(callee.object) && callee.object.name === 'React' &&
243
- t.isIdentifier(callee.property) && callee.property.name === 'useEffect')) {
244
- // Check if the effect function contains async operations
245
- const effectFn = path.node.arguments[0];
246
- if (t.isArrowFunctionExpression(effectFn) || t.isFunctionExpression(effectFn)) {
247
- let hasAsync = false;
248
- // Check if the effect function itself is async
249
- if (effectFn.async) {
250
- hasAsync = true;
251
- }
252
- // Traverse the effect function body to look for async patterns
253
- (0, traverse_1.default)(effectFn, {
254
- CallExpression(innerPath) {
255
- const innerCallee = innerPath.node.callee;
256
- // Check for async patterns
257
- if ((t.isIdentifier(innerCallee) && innerCallee.name === 'fetch') ||
258
- (t.isMemberExpression(innerCallee) &&
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: 'must-use-update-state',
914
+ name: 'pass-standard-props',
297
915
  test: (ast, componentName) => {
298
916
  const violations = [];
299
- let hasUpdateState = false;
917
+ const requiredProps = ['styles', 'utilities', 'components'];
300
918
  (0, traverse_1.default)(ast, {
301
- VariableDeclarator(path) {
302
- if (t.isIdentifier(path.node.id) && path.node.id.name === 'updateState') {
303
- hasUpdateState = true;
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 && path.node.id.name === 'updateState') {
308
- hasUpdateState = true;
959
+ if (path.node.id) {
960
+ declaredFunctions.push(path.node.id.name);
309
961
  }
310
962
  }
311
963
  });
312
- if (!hasUpdateState) {
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: 'must-use-update-state',
969
+ rule: 'no-child-implementation',
315
970
  severity: 'error',
316
971
  line: 1,
317
972
  column: 0,
318
- message: `Root component "${componentName}" must have an updateState function that syncs with callbacks.UpdateUserState.`,
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: 'sync-user-state',
980
+ name: 'undefined-component-usage',
326
981
  test: (ast, componentName) => {
327
982
  const violations = [];
328
- let hasUserStateSync = false;
983
+ const componentsFromProps = new Set();
984
+ const componentsUsedInJSX = new Set();
985
+ let hasComponentsProp = false;
329
986
  (0, traverse_1.default)(ast, {
330
- CallExpression(path) {
331
- const callee = path.node.callee;
332
- // Look for callbacks?.UpdateUserState or callbacks.UpdateUserState
333
- if (t.isMemberExpression(callee)) {
334
- // Check for callbacks.UpdateUserState
335
- if (t.isIdentifier(callee.object) && callee.object.name === 'callbacks' &&
336
- t.isIdentifier(callee.property) && callee.property.name === 'UpdateUserState') {
337
- hasUserStateSync = true;
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
- // Check for callbacks?.UpdateUserState (optional chaining)
341
- else if (t.isOptionalMemberExpression(callee)) {
342
- if (t.isIdentifier(callee.object) && callee.object.name === 'callbacks' &&
343
- t.isIdentifier(callee.property) && callee.property.name === 'UpdateUserState') {
344
- hasUserStateSync = true;
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 (!hasUserStateSync) {
350
- violations.push({
351
- rule: 'sync-user-state',
352
- severity: 'error',
353
- line: 1,
354
- column: 0,
355
- message: `Root component "${componentName}" must call callbacks?.UpdateUserState to sync state changes.`,
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: 'spread-user-state',
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
- CallExpression(path) {
368
- const callee = path.node.callee;
369
- // Check for useState call
370
- if ((t.isIdentifier(callee) && callee.name === 'useState') ||
371
- (t.isMemberExpression(callee) &&
372
- t.isIdentifier(callee.object) && callee.object.name === 'React' &&
373
- t.isIdentifier(callee.property) && callee.property.name === 'useState')) {
374
- // Check if initial state includes ...userState
375
- const initialState = path.node.arguments[0];
376
- if (t.isObjectExpression(initialState)) {
377
- for (const prop of initialState.properties) {
378
- if (t.isSpreadElement(prop) && t.isIdentifier(prop.argument) && prop.argument.name === 'userState') {
379
- hasUserStateSpread = true;
380
- break;
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: 'no-child-implementation',
1083
+ name: 'array-reduce-safety',
401
1084
  test: (ast, componentName) => {
402
1085
  const violations = [];
403
- const rootFunctionName = componentName;
404
- const declaredFunctions = [];
405
- // First pass: collect all function declarations
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
- declaredFunctions.push(path.node.id.name);
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
- // 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
- });
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
  }