@memberjunction/react-test-harness 2.75.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.
@@ -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,417 @@ 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
+ 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 'no-use-state':
215
+ case 'full-state-ownership':
78
216
  suggestions.push({
79
217
  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'
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-data-fetching':
253
+ case 'no-use-reducer':
85
254
  suggestions.push({
86
255
  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'
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-async-effects':
281
+ case 'no-data-prop':
92
282
  suggestions.push({
93
283
  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'
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 'must-use-update-state':
304
+ case 'saved-user-settings-pattern':
99
305
  suggestions.push({
100
306
  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
- }
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 'sync-user-state':
334
+ case 'pass-standard-props':
112
335
  suggestions.push({
113
336
  violation: violation.rule,
114
- suggestion: 'Call callbacks?.UpdateUserState in your updateState function',
115
- example: 'Add: if (callbacks?.UpdateUserState) callbacks.UpdateUserState(newState);'
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 'spread-user-state':
354
+ case 'no-child-implementation':
119
355
  suggestions.push({
120
356
  violation: violation.rule,
121
- suggestion: 'Include ...userState in your initial state',
122
- example: 'const [state, setState] = useState({ /* your fields */, ...userState });'
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 'no-child-implementation':
361
+ case 'undefined-component-usage':
126
362
  suggestions.push({
127
363
  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'
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
- ComponentLinter.childComponentRules = [
496
+ // Universal rules that apply to all components with SavedUserSettings pattern
497
+ ComponentLinter.universalComponentRules = [
139
498
  // State Management Rules
140
499
  {
141
- name: 'no-use-state',
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
- 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
- });
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: `Child component "${componentName}" uses useReducer at line ${path.node.loc?.start.line}. Child components must be purely controlled.`,
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-fetching',
617
+ name: 'no-data-prop',
193
618
  test: (ast, componentName) => {
194
619
  const violations = [];
195
620
  (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
- });
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
- // 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
- });
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: 'no-async-effects',
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 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;
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: 'must-use-update-state',
707
+ name: 'pass-standard-props',
297
708
  test: (ast, componentName) => {
298
709
  const violations = [];
299
- let hasUpdateState = false;
710
+ const requiredProps = ['styles', 'utilities', 'components'];
300
711
  (0, traverse_1.default)(ast, {
301
- VariableDeclarator(path) {
302
- if (t.isIdentifier(path.node.id) && path.node.id.name === 'updateState') {
303
- hasUpdateState = true;
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 && path.node.id.name === 'updateState') {
308
- hasUpdateState = true;
752
+ if (path.node.id) {
753
+ declaredFunctions.push(path.node.id.name);
309
754
  }
310
755
  }
311
756
  });
312
- if (!hasUpdateState) {
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: 'must-use-update-state',
762
+ rule: 'no-child-implementation',
315
763
  severity: 'error',
316
764
  line: 1,
317
765
  column: 0,
318
- message: `Root component "${componentName}" must have an updateState function that syncs with callbacks.UpdateUserState.`,
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: 'sync-user-state',
773
+ name: 'undefined-component-usage',
326
774
  test: (ast, componentName) => {
327
775
  const violations = [];
328
- let hasUserStateSync = false;
776
+ const componentsFromProps = new Set();
777
+ const componentsUsedInJSX = new Set();
778
+ let hasComponentsProp = false;
329
779
  (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;
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
- // 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;
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 (!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
- });
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: 'spread-user-state',
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
- 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
- }
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: 'no-child-implementation',
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
- FunctionDeclaration(path) {
408
- if (path.node.id) {
409
- declaredFunctions.push(path.node.id.name);
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
  }