@memberjunction/react-test-harness 2.82.0 → 2.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +14 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/lib/component-linter.d.ts +6 -1
- package/dist/lib/component-linter.d.ts.map +1 -1
- package/dist/lib/component-linter.js +383 -87
- package/dist/lib/component-linter.js.map +1 -1
- package/dist/lib/component-runner.d.ts +5 -5
- package/dist/lib/component-runner.d.ts.map +1 -1
- package/dist/lib/component-runner.js +26 -7
- package/dist/lib/component-runner.js.map +1 -1
- package/package.json +3 -3
|
@@ -64,12 +64,23 @@ class ComponentLinter {
|
|
|
64
64
|
const dataViolations = this.validateDataRequirements(ast, componentSpec);
|
|
65
65
|
violations.push(...dataViolations);
|
|
66
66
|
}
|
|
67
|
+
// Deduplicate violations - keep only unique rule+message combinations
|
|
68
|
+
const uniqueViolations = this.deduplicateViolations(violations);
|
|
69
|
+
// Count violations by severity
|
|
70
|
+
const criticalCount = uniqueViolations.filter(v => v.severity === 'critical').length;
|
|
71
|
+
const highCount = uniqueViolations.filter(v => v.severity === 'high').length;
|
|
72
|
+
const mediumCount = uniqueViolations.filter(v => v.severity === 'medium').length;
|
|
73
|
+
const lowCount = uniqueViolations.filter(v => v.severity === 'low').length;
|
|
67
74
|
// Generate fix suggestions
|
|
68
|
-
const suggestions = this.generateFixSuggestions(
|
|
75
|
+
const suggestions = this.generateFixSuggestions(uniqueViolations);
|
|
69
76
|
return {
|
|
70
|
-
success:
|
|
71
|
-
violations,
|
|
72
|
-
suggestions
|
|
77
|
+
success: criticalCount === 0 && highCount === 0, // Only fail on critical/high
|
|
78
|
+
violations: uniqueViolations,
|
|
79
|
+
suggestions,
|
|
80
|
+
criticalCount,
|
|
81
|
+
highCount,
|
|
82
|
+
mediumCount,
|
|
83
|
+
lowCount
|
|
73
84
|
};
|
|
74
85
|
}
|
|
75
86
|
catch (error) {
|
|
@@ -78,7 +89,7 @@ class ComponentLinter {
|
|
|
78
89
|
success: false,
|
|
79
90
|
violations: [{
|
|
80
91
|
rule: 'parse-error',
|
|
81
|
-
severity: '
|
|
92
|
+
severity: 'critical',
|
|
82
93
|
line: 0,
|
|
83
94
|
column: 0,
|
|
84
95
|
message: `Failed to parse component: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
@@ -183,7 +194,7 @@ class ComponentLinter {
|
|
|
183
194
|
usedEntity.toLowerCase().includes(e.toLowerCase()));
|
|
184
195
|
violations.push({
|
|
185
196
|
rule: 'entity-name-mismatch',
|
|
186
|
-
severity: '
|
|
197
|
+
severity: 'critical',
|
|
187
198
|
line: prop.value.loc?.start.line || 0,
|
|
188
199
|
column: prop.value.loc?.start.column || 0,
|
|
189
200
|
message: `Entity "${usedEntity}" not found in dataRequirements. ${possibleMatches.length > 0
|
|
@@ -206,7 +217,7 @@ class ComponentLinter {
|
|
|
206
217
|
if (/COUNT\s*\(|SUM\s*\(|AVG\s*\(|MAX\s*\(|MIN\s*\(/i.test(fieldName)) {
|
|
207
218
|
violations.push({
|
|
208
219
|
rule: 'runview-sql-function',
|
|
209
|
-
severity: '
|
|
220
|
+
severity: 'critical',
|
|
210
221
|
line: fieldElement.loc?.start.line || 0,
|
|
211
222
|
column: fieldElement.loc?.start.column || 0,
|
|
212
223
|
message: `RunView does not support SQL aggregations. Use RunQuery for aggregations or fetch raw data and aggregate in JavaScript.`,
|
|
@@ -221,7 +232,7 @@ class ComponentLinter {
|
|
|
221
232
|
if (!isAllowed) {
|
|
222
233
|
violations.push({
|
|
223
234
|
rule: 'field-not-in-requirements',
|
|
224
|
-
severity: '
|
|
235
|
+
severity: 'critical',
|
|
225
236
|
line: fieldElement.loc?.start.line || 0,
|
|
226
237
|
column: fieldElement.loc?.start.column || 0,
|
|
227
238
|
message: `Field "${fieldName}" not found in dataRequirements for entity "${usedEntity}". Available fields: ${[...entityFields.displayFields, ...entityFields.filterFields, ...entityFields.sortFields].join(', ')}`,
|
|
@@ -241,7 +252,7 @@ class ComponentLinter {
|
|
|
241
252
|
if (!entityFields.sortFields.has(orderByField)) {
|
|
242
253
|
violations.push({
|
|
243
254
|
rule: 'orderby-field-not-sortable',
|
|
244
|
-
severity: '
|
|
255
|
+
severity: 'critical',
|
|
245
256
|
line: orderByProperty.value.loc?.start.line || 0,
|
|
246
257
|
column: orderByProperty.value.loc?.start.column || 0,
|
|
247
258
|
message: `OrderBy field "${orderByField}" not in sortFields for entity "${usedEntity}". Available sort fields: ${[...entityFields.sortFields].join(', ')}`,
|
|
@@ -282,7 +293,7 @@ class ComponentLinter {
|
|
|
282
293
|
usedQuery.toLowerCase().includes(q.toLowerCase()));
|
|
283
294
|
violations.push({
|
|
284
295
|
rule: 'query-name-mismatch',
|
|
285
|
-
severity: '
|
|
296
|
+
severity: 'critical',
|
|
286
297
|
line: prop.value.loc?.start.line || 0,
|
|
287
298
|
column: prop.value.loc?.start.column || 0,
|
|
288
299
|
message: `Query "${usedQuery}" not found in dataRequirements. ${possibleMatches.length > 0
|
|
@@ -323,6 +334,27 @@ class ComponentLinter {
|
|
|
323
334
|
}
|
|
324
335
|
return null;
|
|
325
336
|
}
|
|
337
|
+
static deduplicateViolations(violations) {
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
const unique = [];
|
|
340
|
+
for (const violation of violations) {
|
|
341
|
+
// Create a key from the complete violation details (case-insensitive for message)
|
|
342
|
+
const key = `${violation.rule}:${violation.severity}:${violation.line}:${violation.column}:${violation.message.toLowerCase()}`;
|
|
343
|
+
if (!seen.has(key)) {
|
|
344
|
+
seen.add(key);
|
|
345
|
+
unique.push(violation);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Sort by severity (critical > high > medium > low) and then by line number
|
|
349
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
350
|
+
unique.sort((a, b) => {
|
|
351
|
+
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity];
|
|
352
|
+
if (severityDiff !== 0)
|
|
353
|
+
return severityDiff;
|
|
354
|
+
return a.line - b.line;
|
|
355
|
+
});
|
|
356
|
+
return unique;
|
|
357
|
+
}
|
|
326
358
|
static generateFixSuggestions(violations) {
|
|
327
359
|
const suggestions = [];
|
|
328
360
|
for (const violation of violations) {
|
|
@@ -330,36 +362,66 @@ class ComponentLinter {
|
|
|
330
362
|
case 'full-state-ownership':
|
|
331
363
|
suggestions.push({
|
|
332
364
|
violation: violation.rule,
|
|
333
|
-
suggestion: 'Components must manage ALL their own state internally.
|
|
334
|
-
example: `// ❌ WRONG -
|
|
335
|
-
function
|
|
336
|
-
//
|
|
365
|
+
suggestion: 'Components must manage ALL their own state internally. Use proper naming conventions for initialization.',
|
|
366
|
+
example: `// ❌ WRONG - Controlled state props:
|
|
367
|
+
function PaginationControls({ currentPage, filters, sortBy, onPageChange }) {
|
|
368
|
+
// These props suggest parent controls the state - WRONG!
|
|
337
369
|
}
|
|
338
370
|
|
|
339
|
-
//
|
|
340
|
-
function Component({
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
savedUserSettings?.sortBy || 'name'
|
|
371
|
+
// ❌ WRONG - State props without handlers (still controlled):
|
|
372
|
+
function Component({ selectedId, activeTab }) {
|
|
373
|
+
// Parent is managing this component's state
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ✅ CORRECT - Using initialization props:
|
|
377
|
+
function PaginationControls({ initialPage, defaultPageSize, onPageChange, savedUserSettings, onSaveUserSettings }) {
|
|
378
|
+
// Component owns ALL its state, initialized from props
|
|
379
|
+
const [currentPage, setCurrentPage] = useState(
|
|
380
|
+
savedUserSettings?.currentPage || initialPage || 1
|
|
350
381
|
);
|
|
382
|
+
const [pageSize] = useState(defaultPageSize || 10);
|
|
351
383
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
384
|
+
const handlePageChange = (page) => {
|
|
385
|
+
setCurrentPage(page); // Update internal state
|
|
386
|
+
onPageChange?.(page); // Notify parent if needed
|
|
355
387
|
onSaveUserSettings?.({
|
|
356
388
|
...savedUserSettings,
|
|
357
|
-
|
|
389
|
+
currentPage: page
|
|
358
390
|
});
|
|
359
391
|
};
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ✅ CORRECT - Configuration props are allowed:
|
|
395
|
+
function DataTable({
|
|
396
|
+
items, // Data prop - allowed
|
|
397
|
+
pageSize, // Configuration - allowed
|
|
398
|
+
maxItems, // Configuration - allowed
|
|
399
|
+
initialSortBy, // Initialization - allowed
|
|
400
|
+
defaultFilters, // Initialization - allowed
|
|
401
|
+
onSelectionChange, // Event handler - allowed
|
|
402
|
+
savedUserSettings,
|
|
403
|
+
onSaveUserSettings
|
|
404
|
+
}) {
|
|
405
|
+
// Component manages its own state
|
|
406
|
+
const [sortBy, setSortBy] = useState(initialSortBy || 'name');
|
|
407
|
+
const [filters, setFilters] = useState(defaultFilters || {});
|
|
408
|
+
const [selectedItems, setSelectedItems] = useState(
|
|
409
|
+
savedUserSettings?.selectedItems || []
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Naming conventions:
|
|
414
|
+
// ✅ ALLOWED props:
|
|
415
|
+
// - initial* (initialPage, initialValue, initialSelection)
|
|
416
|
+
// - default* (defaultTab, defaultSortBy, defaultFilters)
|
|
417
|
+
// - Configuration (pageSize, maxItems, minValue, disabled)
|
|
418
|
+
// - Data props (items, options, data, rows, columns)
|
|
419
|
+
// - Event handlers (onChange, onSelect, onPageChange)
|
|
420
|
+
|
|
421
|
+
// ❌ DISALLOWED props (suggest controlled component):
|
|
422
|
+
// - Direct state names (currentPage, selectedId, activeTab)
|
|
423
|
+
// - State without 'initial'/'default' prefix (sortBy, filters, searchTerm)
|
|
424
|
+
// - Controlled patterns (value + onChange, checked + onChange)`
|
|
363
425
|
});
|
|
364
426
|
break;
|
|
365
427
|
case 'no-use-reducer':
|
|
@@ -862,6 +924,40 @@ const sortedData = useMemo(() => {
|
|
|
862
924
|
}, [data, sortBy, sortDirection]);`
|
|
863
925
|
});
|
|
864
926
|
break;
|
|
927
|
+
case 'runview-runquery-valid-properties':
|
|
928
|
+
suggestions.push({
|
|
929
|
+
violation: violation.rule,
|
|
930
|
+
suggestion: 'Use only valid properties for RunView/RunViews and RunQuery',
|
|
931
|
+
example: `// ❌ WRONG - Invalid properties on RunView:
|
|
932
|
+
await utilities.rv.RunView({
|
|
933
|
+
EntityName: 'MJ: AI Prompt Runs',
|
|
934
|
+
Parameters: { startDate, endDate }, // INVALID!
|
|
935
|
+
GroupBy: 'Status' // INVALID!
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
// ✅ CORRECT - Use ExtraFilter for WHERE clauses:
|
|
939
|
+
await utilities.rv.RunView({
|
|
940
|
+
EntityName: 'MJ: AI Prompt Runs',
|
|
941
|
+
ExtraFilter: \`RunAt >= '\${startDate.toISOString()}' AND RunAt <= '\${endDate.toISOString()}'\`,
|
|
942
|
+
OrderBy: 'RunAt DESC',
|
|
943
|
+
Fields: ['RunAt', 'Status', 'Success']
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// ✅ For aggregations, use RunQuery with a pre-defined query:
|
|
947
|
+
await utilities.rq.RunQuery({
|
|
948
|
+
QueryName: 'Prompt Run Summary',
|
|
949
|
+
Parameters: { startDate, endDate } // Parameters ARE valid for RunQuery
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// Valid RunView properties:
|
|
953
|
+
// - EntityName (required)
|
|
954
|
+
// - ExtraFilter, OrderBy, Fields, MaxRows, StartRow, ResultType (optional)
|
|
955
|
+
|
|
956
|
+
// Valid RunQuery properties:
|
|
957
|
+
// - QueryName (required)
|
|
958
|
+
// - CategoryName, CategoryID, Parameters (optional)`
|
|
959
|
+
});
|
|
960
|
+
break;
|
|
865
961
|
case 'root-component-props-restriction':
|
|
866
962
|
suggestions.push({
|
|
867
963
|
violation: violation.rule,
|
|
@@ -913,11 +1009,11 @@ ComponentLinter.universalComponentRules = [
|
|
|
913
1009
|
appliesTo: 'all',
|
|
914
1010
|
test: (ast, componentName) => {
|
|
915
1011
|
const violations = [];
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
const
|
|
920
|
-
// First pass:
|
|
1012
|
+
const controlledStateProps = [];
|
|
1013
|
+
const initializationProps = [];
|
|
1014
|
+
const eventHandlers = [];
|
|
1015
|
+
const acceptedProps = new Map();
|
|
1016
|
+
// First pass: collect all props
|
|
921
1017
|
(0, traverse_1.default)(ast, {
|
|
922
1018
|
FunctionDeclaration(path) {
|
|
923
1019
|
if (path.node.id && path.node.id.name === componentName && path.node.params[0]) {
|
|
@@ -926,14 +1022,13 @@ ComponentLinter.universalComponentRules = [
|
|
|
926
1022
|
for (const prop of param.properties) {
|
|
927
1023
|
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
928
1024
|
const propName = prop.key.name;
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
hasSavedUserSettings = true;
|
|
1025
|
+
acceptedProps.set(propName, {
|
|
1026
|
+
line: prop.loc?.start.line || 0,
|
|
1027
|
+
column: prop.loc?.start.column || 0
|
|
1028
|
+
});
|
|
1029
|
+
// Categorize props
|
|
1030
|
+
if (/^on[A-Z]/.test(propName) && propName !== 'onSaveUserSettings') {
|
|
1031
|
+
eventHandlers.push(propName);
|
|
937
1032
|
}
|
|
938
1033
|
}
|
|
939
1034
|
}
|
|
@@ -950,42 +1045,119 @@ ComponentLinter.universalComponentRules = [
|
|
|
950
1045
|
for (const prop of param.properties) {
|
|
951
1046
|
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
952
1047
|
const propName = prop.key.name;
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
if (propName
|
|
959
|
-
|
|
1048
|
+
acceptedProps.set(propName, {
|
|
1049
|
+
line: prop.loc?.start.line || 0,
|
|
1050
|
+
column: prop.loc?.start.column || 0
|
|
1051
|
+
});
|
|
1052
|
+
// Categorize props
|
|
1053
|
+
if (/^on[A-Z]/.test(propName) && propName !== 'onSaveUserSettings') {
|
|
1054
|
+
eventHandlers.push(propName);
|
|
960
1055
|
}
|
|
961
1056
|
}
|
|
962
1057
|
}
|
|
963
1058
|
}
|
|
964
1059
|
}
|
|
965
1060
|
}
|
|
966
|
-
},
|
|
967
|
-
// Check for useState usage
|
|
968
|
-
CallExpression(path) {
|
|
969
|
-
const callee = path.node.callee;
|
|
970
|
-
if ((t.isIdentifier(callee) && callee.name === 'useState') ||
|
|
971
|
-
(t.isMemberExpression(callee) &&
|
|
972
|
-
t.isIdentifier(callee.object) && callee.object.name === 'React' &&
|
|
973
|
-
t.isIdentifier(callee.property) && callee.property.name === 'useState')) {
|
|
974
|
-
usesUseState = true;
|
|
975
|
-
}
|
|
976
1061
|
}
|
|
977
1062
|
});
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
|
|
1063
|
+
// Analyze props for controlled component patterns
|
|
1064
|
+
const controlledPatterns = [
|
|
1065
|
+
{ stateProp: 'value', handler: 'onChange' },
|
|
1066
|
+
{ stateProp: 'checked', handler: 'onChange' },
|
|
1067
|
+
{ stateProp: 'selectedId', handler: 'onSelect' },
|
|
1068
|
+
{ stateProp: 'selectedIds', handler: 'onSelectionChange' },
|
|
1069
|
+
{ stateProp: 'selectedItems', handler: 'onSelectionChange' },
|
|
1070
|
+
{ stateProp: 'activeTab', handler: 'onTabChange' },
|
|
1071
|
+
{ stateProp: 'currentPage', handler: 'onPageChange' },
|
|
1072
|
+
{ stateProp: 'expanded', handler: 'onExpand' },
|
|
1073
|
+
{ stateProp: 'collapsed', handler: 'onCollapse' },
|
|
1074
|
+
{ stateProp: 'open', handler: 'onOpenChange' },
|
|
1075
|
+
{ stateProp: 'visible', handler: 'onVisibilityChange' }
|
|
1076
|
+
];
|
|
1077
|
+
// Check each accepted prop
|
|
1078
|
+
for (const [propName, location] of acceptedProps) {
|
|
1079
|
+
// Skip standard props
|
|
1080
|
+
if (['utilities', 'styles', 'components', 'callbacks', 'savedUserSettings', 'onSaveUserSettings'].includes(propName)) {
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
// Skip data props (arrays, objects that are clearly data)
|
|
1084
|
+
if (propName.endsWith('Data') || propName === 'items' || propName === 'options' ||
|
|
1085
|
+
propName === 'rows' || propName === 'columns' || propName === 'records') {
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
// Check if it's an initialization prop (allowed)
|
|
1089
|
+
if (propName.startsWith('initial') || propName.startsWith('default')) {
|
|
1090
|
+
initializationProps.push(propName);
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
// Check if it's a configuration prop (allowed)
|
|
1094
|
+
// Be generous - configuration props are those that configure behavior, not manage state
|
|
1095
|
+
if (propName.endsWith('Config') || propName.endsWith('Settings') || propName.endsWith('Options') ||
|
|
1096
|
+
propName === 'pageSize' || propName === 'maxItems' || propName === 'minValue' || propName === 'maxValue' ||
|
|
1097
|
+
propName === 'placeholder' || propName === 'label' || propName === 'title' || propName === 'disabled' ||
|
|
1098
|
+
propName === 'readonly' || propName === 'required' || propName === 'className' || propName === 'style' ||
|
|
1099
|
+
// Sort/filter configuration when not paired with state management handlers
|
|
1100
|
+
((propName.includes('sort') || propName.includes('Sort') ||
|
|
1101
|
+
propName.includes('filter') || propName.includes('Filter') ||
|
|
1102
|
+
propName === 'orderBy' || propName === 'groupBy') &&
|
|
1103
|
+
!acceptedProps.has('onSortChange') && !acceptedProps.has('onFilterChange') &&
|
|
1104
|
+
!acceptedProps.has('onChange'))) {
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
// Check for controlled component pattern (both state prop and handler present)
|
|
1108
|
+
let isControlled = false;
|
|
1109
|
+
for (const pattern of controlledPatterns) {
|
|
1110
|
+
if (propName === pattern.stateProp) {
|
|
1111
|
+
// Check if the corresponding handler exists
|
|
1112
|
+
if (acceptedProps.has(pattern.handler) || eventHandlers.includes(pattern.handler)) {
|
|
1113
|
+
controlledStateProps.push(propName);
|
|
1114
|
+
isControlled = true;
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
// If state prop exists without handler, it's still problematic
|
|
1118
|
+
// unless it's clearly for initialization
|
|
1119
|
+
if (!propName.startsWith('initial') && !propName.startsWith('default')) {
|
|
1120
|
+
controlledStateProps.push(propName);
|
|
1121
|
+
isControlled = true;
|
|
1122
|
+
break;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
// Check for state-like props that aren't initialization
|
|
1127
|
+
if (!isControlled) {
|
|
1128
|
+
const statePatterns = [
|
|
1129
|
+
'selectedId', 'selectedItemId', 'selectedItem', 'selection',
|
|
1130
|
+
'filters', 'sortBy', 'sortField', 'sortDirection', 'orderBy',
|
|
1131
|
+
'currentPage', 'pageNumber', 'page',
|
|
1132
|
+
'activeTab', 'activeIndex', 'activeKey',
|
|
1133
|
+
'expandedItems', 'collapsedItems',
|
|
1134
|
+
'searchTerm', 'searchQuery', 'query',
|
|
1135
|
+
'formData', 'formValues', 'values'
|
|
1136
|
+
];
|
|
1137
|
+
for (const pattern of statePatterns) {
|
|
1138
|
+
if (propName === pattern || propName.toLowerCase() === pattern.toLowerCase()) {
|
|
1139
|
+
// This is a state prop without proper initialization naming
|
|
1140
|
+
controlledStateProps.push(propName);
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
// Generate violations for controlled state props
|
|
1147
|
+
if (controlledStateProps.length > 0) {
|
|
981
1148
|
violations.push({
|
|
982
1149
|
rule: 'full-state-ownership',
|
|
983
|
-
severity: '
|
|
1150
|
+
severity: 'critical', // This is critical as it breaks the architecture
|
|
984
1151
|
line: 1,
|
|
985
1152
|
column: 0,
|
|
986
|
-
message: `Component "${componentName}"
|
|
1153
|
+
message: `Component "${componentName}" accepts controlled state props (${controlledStateProps.join(', ')}) instead of managing state internally. Use 'initial*' or 'default*' prefixes for initialization props (e.g., 'initialPage' instead of 'currentPage'). Each component must manage ALL its own state.`
|
|
987
1154
|
});
|
|
988
1155
|
}
|
|
1156
|
+
// Add warning for initialization props (informational)
|
|
1157
|
+
if (initializationProps.length > 0 && violations.length === 0) {
|
|
1158
|
+
// This is actually OK, but we can log it for awareness
|
|
1159
|
+
// Don't create a violation, just note it's using the correct pattern
|
|
1160
|
+
}
|
|
989
1161
|
return violations;
|
|
990
1162
|
}
|
|
991
1163
|
},
|
|
@@ -1003,7 +1175,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1003
1175
|
t.isIdentifier(callee.property) && callee.property.name === 'useReducer')) {
|
|
1004
1176
|
violations.push({
|
|
1005
1177
|
rule: 'no-use-reducer',
|
|
1006
|
-
severity: '
|
|
1178
|
+
severity: 'high', // High but not critical - it's a pattern violation
|
|
1007
1179
|
line: path.node.loc?.start.line || 0,
|
|
1008
1180
|
column: path.node.loc?.start.column || 0,
|
|
1009
1181
|
message: `Component "${componentName}" uses useReducer at line ${path.node.loc?.start.line}. Components should manage state with useState and persist important settings with onSaveUserSettings.`,
|
|
@@ -1031,7 +1203,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1031
1203
|
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'data') {
|
|
1032
1204
|
violations.push({
|
|
1033
1205
|
rule: 'no-data-prop',
|
|
1034
|
-
severity: '
|
|
1206
|
+
severity: 'medium', // It's a pattern issue, not critical
|
|
1035
1207
|
line: prop.loc?.start.line || 0,
|
|
1036
1208
|
column: prop.loc?.start.column || 0,
|
|
1037
1209
|
message: `Component "${componentName}" accepts generic 'data' prop. Use specific props like 'items', 'customers', etc. instead.`,
|
|
@@ -1053,7 +1225,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1053
1225
|
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'data') {
|
|
1054
1226
|
violations.push({
|
|
1055
1227
|
rule: 'no-data-prop',
|
|
1056
|
-
severity: '
|
|
1228
|
+
severity: 'critical',
|
|
1057
1229
|
line: prop.loc?.start.line || 0,
|
|
1058
1230
|
column: prop.loc?.start.column || 0,
|
|
1059
1231
|
message: `Component "${componentName}" accepts generic 'data' prop. Use specific props like 'items', 'customers', etc. instead.`,
|
|
@@ -1092,7 +1264,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1092
1264
|
if (ephemeralPatterns.some(pattern => key.toLowerCase().includes(pattern))) {
|
|
1093
1265
|
violations.push({
|
|
1094
1266
|
rule: 'saved-user-settings-pattern',
|
|
1095
|
-
severity: '
|
|
1267
|
+
severity: 'medium', // Pattern issue but not breaking
|
|
1096
1268
|
line: prop.loc?.start.line || 0,
|
|
1097
1269
|
column: prop.loc?.start.column || 0,
|
|
1098
1270
|
message: `Saving ephemeral UI state "${key}" to savedUserSettings. Only save important user preferences.`
|
|
@@ -1133,7 +1305,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1133
1305
|
// Only report if some props are passed (to avoid false positives on non-component JSX)
|
|
1134
1306
|
violations.push({
|
|
1135
1307
|
rule: 'pass-standard-props',
|
|
1136
|
-
severity: '
|
|
1308
|
+
severity: 'critical',
|
|
1137
1309
|
line: openingElement.loc?.start.line || 0,
|
|
1138
1310
|
column: openingElement.loc?.start.column || 0,
|
|
1139
1311
|
message: `Component "${componentBeingCalled}" is missing required props: ${missingProps.join(', ')}. All components must receive styles, utilities, and components props.`,
|
|
@@ -1167,7 +1339,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1167
1339
|
if (componentFunctions.length > 0) {
|
|
1168
1340
|
violations.push({
|
|
1169
1341
|
rule: 'no-child-implementation',
|
|
1170
|
-
severity: '
|
|
1342
|
+
severity: 'critical',
|
|
1171
1343
|
line: 1,
|
|
1172
1344
|
column: 0,
|
|
1173
1345
|
message: `Root component file contains child component implementations: ${componentFunctions.join(', ')}. Root should only reference child components, not implement them.`,
|
|
@@ -1239,7 +1411,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1239
1411
|
if (unusedComponents.length > 0) {
|
|
1240
1412
|
violations.push({
|
|
1241
1413
|
rule: 'undefined-component-usage',
|
|
1242
|
-
severity: '
|
|
1414
|
+
severity: 'low',
|
|
1243
1415
|
line: 1,
|
|
1244
1416
|
column: 0,
|
|
1245
1417
|
message: `Component destructures ${unusedComponents.join(', ')} from components prop but never uses them. These may be missing from the component spec's dependencies array.`
|
|
@@ -1267,7 +1439,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1267
1439
|
if (/\[\d+\]\.\w+/.test(code)) {
|
|
1268
1440
|
violations.push({
|
|
1269
1441
|
rule: 'unsafe-array-access',
|
|
1270
|
-
severity: '
|
|
1442
|
+
severity: 'critical',
|
|
1271
1443
|
line: path.node.loc?.start.line || 0,
|
|
1272
1444
|
column: path.node.loc?.start.column || 0,
|
|
1273
1445
|
message: `Unsafe array access: ${code}. Check array bounds before accessing elements.`,
|
|
@@ -1300,7 +1472,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1300
1472
|
if (!hasInitialValue) {
|
|
1301
1473
|
violations.push({
|
|
1302
1474
|
rule: 'array-reduce-safety',
|
|
1303
|
-
severity: '
|
|
1475
|
+
severity: 'low',
|
|
1304
1476
|
line: path.node.loc?.start.line || 0,
|
|
1305
1477
|
column: path.node.loc?.start.column || 0,
|
|
1306
1478
|
message: `reduce() without initial value may fail on empty arrays: ${code}`,
|
|
@@ -1313,7 +1485,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1313
1485
|
(t.isIdentifier(arrayExpression.property) && arrayExpression.computed))) {
|
|
1314
1486
|
violations.push({
|
|
1315
1487
|
rule: 'array-reduce-safety',
|
|
1316
|
-
severity: '
|
|
1488
|
+
severity: 'critical',
|
|
1317
1489
|
line: path.node.loc?.start.line || 0,
|
|
1318
1490
|
column: path.node.loc?.start.column || 0,
|
|
1319
1491
|
message: `reduce() on array element access is unsafe: ${code}`,
|
|
@@ -1448,7 +1620,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1448
1620
|
if (relatedHandlers.length > 0) {
|
|
1449
1621
|
violations.push({
|
|
1450
1622
|
rule: 'parent-event-callback-usage',
|
|
1451
|
-
severity: '
|
|
1623
|
+
severity: 'critical',
|
|
1452
1624
|
line: location.line,
|
|
1453
1625
|
column: location.column,
|
|
1454
1626
|
message: `Component receives '${callbackName}' event callback but never invokes it. Found state updates in ${relatedHandlers.join(', ')} but parent is not notified.`,
|
|
@@ -1565,7 +1737,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1565
1737
|
const transformedName = Array.from(transformation.transformedProps).find(t => t.toLowerCase() === accessedProp.toLowerCase());
|
|
1566
1738
|
violations.push({
|
|
1567
1739
|
rule: 'property-name-consistency',
|
|
1568
|
-
severity: '
|
|
1740
|
+
severity: 'critical',
|
|
1569
1741
|
line: transformation.location.line,
|
|
1570
1742
|
column: transformation.location.column,
|
|
1571
1743
|
message: `Property name mismatch: data transformed with different casing. Accessing '${accessedProp}' but property was transformed to '${transformedName || 'different name'}'`,
|
|
@@ -1603,7 +1775,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1603
1775
|
if (!hasDebounce && !hasTimeout) {
|
|
1604
1776
|
violations.push({
|
|
1605
1777
|
rule: 'noisy-settings-updates',
|
|
1606
|
-
severity: '
|
|
1778
|
+
severity: 'critical',
|
|
1607
1779
|
line: path.node.loc?.start.line || 0,
|
|
1608
1780
|
column: path.node.loc?.start.column || 0,
|
|
1609
1781
|
message: `Saving settings on every change/keystroke. Save on blur, submit, or after debouncing.`
|
|
@@ -1639,7 +1811,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1639
1811
|
if (hasSetState && hasPropDeps && !bodyString.includes('async')) {
|
|
1640
1812
|
violations.push({
|
|
1641
1813
|
rule: 'prop-state-sync',
|
|
1642
|
-
severity: '
|
|
1814
|
+
severity: 'critical',
|
|
1643
1815
|
line: path.node.loc?.start.line || 0,
|
|
1644
1816
|
column: path.node.loc?.start.column || 0,
|
|
1645
1817
|
message: 'Syncing props to internal state with useEffect creates dual state management',
|
|
@@ -1693,7 +1865,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1693
1865
|
if (!funcName || funcName === componentName) {
|
|
1694
1866
|
violations.push({
|
|
1695
1867
|
rule: 'performance-memoization',
|
|
1696
|
-
severity: '
|
|
1868
|
+
severity: 'low', // Just a suggestion, not mandatory
|
|
1697
1869
|
line: path.node.loc?.start.line || 0,
|
|
1698
1870
|
column: path.node.loc?.start.column || 0,
|
|
1699
1871
|
message: `Expensive ${method} operation without memoization. Consider using useMemo.`,
|
|
@@ -1717,7 +1889,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1717
1889
|
if (!hasVariables || hasVariables.length < 3) { // Allow some property names
|
|
1718
1890
|
violations.push({
|
|
1719
1891
|
rule: 'performance-memoization',
|
|
1720
|
-
severity: '
|
|
1892
|
+
severity: 'low', // Just a suggestion
|
|
1721
1893
|
line: path.node.loc?.start.line || 0,
|
|
1722
1894
|
column: path.node.loc?.start.column || 0,
|
|
1723
1895
|
message: 'Static array/object recreated on every render. Consider using useMemo.',
|
|
@@ -1755,7 +1927,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1755
1927
|
if (childPatterns.some(pattern => pattern.test(stateName))) {
|
|
1756
1928
|
violations.push({
|
|
1757
1929
|
rule: 'child-state-management',
|
|
1758
|
-
severity: '
|
|
1930
|
+
severity: 'critical',
|
|
1759
1931
|
line: path.node.loc?.start.line || 0,
|
|
1760
1932
|
column: path.node.loc?.start.column || 0,
|
|
1761
1933
|
message: `Component trying to manage child component state: ${stateName}. Child components manage their own state!`,
|
|
@@ -1790,7 +1962,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1790
1962
|
funcName.includes('handleSort') || funcName.includes('handleFilter'))) {
|
|
1791
1963
|
violations.push({
|
|
1792
1964
|
rule: 'server-reload-on-client-operation',
|
|
1793
|
-
severity: '
|
|
1965
|
+
severity: 'critical',
|
|
1794
1966
|
line: path.node.loc?.start.line || 0,
|
|
1795
1967
|
column: path.node.loc?.start.column || 0,
|
|
1796
1968
|
message: 'Reloading data from server on sort/filter. Use useMemo for client-side operations.',
|
|
@@ -1804,6 +1976,130 @@ ComponentLinter.universalComponentRules = [
|
|
|
1804
1976
|
return violations;
|
|
1805
1977
|
}
|
|
1806
1978
|
},
|
|
1979
|
+
{
|
|
1980
|
+
name: 'runview-runquery-valid-properties',
|
|
1981
|
+
appliesTo: 'all',
|
|
1982
|
+
test: (ast, componentName) => {
|
|
1983
|
+
const violations = [];
|
|
1984
|
+
// Valid properties for RunView/RunViews
|
|
1985
|
+
const validRunViewProps = new Set([
|
|
1986
|
+
'EntityName', 'ExtraFilter', 'OrderBy', 'Fields',
|
|
1987
|
+
'MaxRows', 'StartRow', 'ResultType'
|
|
1988
|
+
]);
|
|
1989
|
+
// Valid properties for RunQuery
|
|
1990
|
+
const validRunQueryProps = new Set([
|
|
1991
|
+
'QueryName', 'CategoryName', 'CategoryID', 'Parameters'
|
|
1992
|
+
]);
|
|
1993
|
+
(0, traverse_1.default)(ast, {
|
|
1994
|
+
CallExpression(path) {
|
|
1995
|
+
const callee = path.node.callee;
|
|
1996
|
+
// Check for utilities.rv.RunView or utilities.rv.RunViews
|
|
1997
|
+
if (t.isMemberExpression(callee) &&
|
|
1998
|
+
t.isMemberExpression(callee.object) &&
|
|
1999
|
+
t.isIdentifier(callee.object.object) &&
|
|
2000
|
+
callee.object.object.name === 'utilities' &&
|
|
2001
|
+
t.isIdentifier(callee.object.property) &&
|
|
2002
|
+
callee.object.property.name === 'rv' &&
|
|
2003
|
+
t.isIdentifier(callee.property)) {
|
|
2004
|
+
const methodName = callee.property.name;
|
|
2005
|
+
if (methodName === 'RunView' || methodName === 'RunViews') {
|
|
2006
|
+
// Get the config object(s)
|
|
2007
|
+
let configs = [];
|
|
2008
|
+
if (methodName === 'RunViews' && path.node.arguments[0]) {
|
|
2009
|
+
// RunViews takes an array of configs
|
|
2010
|
+
if (t.isArrayExpression(path.node.arguments[0])) {
|
|
2011
|
+
configs = path.node.arguments[0].elements
|
|
2012
|
+
.filter((e) => t.isObjectExpression(e));
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
else if (methodName === 'RunView' && path.node.arguments[0]) {
|
|
2016
|
+
// RunView takes a single config
|
|
2017
|
+
if (t.isObjectExpression(path.node.arguments[0])) {
|
|
2018
|
+
configs = [path.node.arguments[0]];
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
// Check each config for invalid properties
|
|
2022
|
+
for (const config of configs) {
|
|
2023
|
+
for (const prop of config.properties) {
|
|
2024
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
2025
|
+
const propName = prop.key.name;
|
|
2026
|
+
if (!validRunViewProps.has(propName)) {
|
|
2027
|
+
// Special error messages for common mistakes
|
|
2028
|
+
let message = `Invalid property '${propName}' on ${methodName}. Valid properties: ${Array.from(validRunViewProps).join(', ')}`;
|
|
2029
|
+
let fix = `Remove '${propName}' property`;
|
|
2030
|
+
if (propName === 'Parameters') {
|
|
2031
|
+
message = `${methodName} does not support 'Parameters'. Use 'ExtraFilter' for WHERE clauses.`;
|
|
2032
|
+
fix = `Replace 'Parameters' with 'ExtraFilter' and format as SQL WHERE clause`;
|
|
2033
|
+
}
|
|
2034
|
+
else if (propName === 'GroupBy') {
|
|
2035
|
+
message = `${methodName} does not support 'GroupBy'. Use RunQuery with a pre-defined query for aggregations.`;
|
|
2036
|
+
fix = `Remove 'GroupBy' and use RunQuery instead for aggregated data`;
|
|
2037
|
+
}
|
|
2038
|
+
else if (propName === 'Having') {
|
|
2039
|
+
message = `${methodName} does not support 'Having'. Use RunQuery with a pre-defined query.`;
|
|
2040
|
+
fix = `Remove 'Having' and use RunQuery instead`;
|
|
2041
|
+
}
|
|
2042
|
+
violations.push({
|
|
2043
|
+
rule: 'runview-runquery-valid-properties',
|
|
2044
|
+
severity: 'critical',
|
|
2045
|
+
line: prop.loc?.start.line || 0,
|
|
2046
|
+
column: prop.loc?.start.column || 0,
|
|
2047
|
+
message,
|
|
2048
|
+
code: `${propName}: ...`
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
// Check for utilities.rq.RunQuery
|
|
2057
|
+
if (t.isMemberExpression(callee) &&
|
|
2058
|
+
t.isMemberExpression(callee.object) &&
|
|
2059
|
+
t.isIdentifier(callee.object.object) &&
|
|
2060
|
+
callee.object.object.name === 'utilities' &&
|
|
2061
|
+
t.isIdentifier(callee.object.property) &&
|
|
2062
|
+
callee.object.property.name === 'rq' &&
|
|
2063
|
+
t.isIdentifier(callee.property) &&
|
|
2064
|
+
callee.property.name === 'RunQuery') {
|
|
2065
|
+
if (path.node.arguments[0] && t.isObjectExpression(path.node.arguments[0])) {
|
|
2066
|
+
const config = path.node.arguments[0];
|
|
2067
|
+
for (const prop of config.properties) {
|
|
2068
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
2069
|
+
const propName = prop.key.name;
|
|
2070
|
+
if (!validRunQueryProps.has(propName)) {
|
|
2071
|
+
let message = `Invalid property '${propName}' on RunQuery. Valid properties: ${Array.from(validRunQueryProps).join(', ')}`;
|
|
2072
|
+
let fix = `Remove '${propName}' property`;
|
|
2073
|
+
if (propName === 'ExtraFilter') {
|
|
2074
|
+
message = `RunQuery does not support 'ExtraFilter'. WHERE clauses should be in the pre-defined query or passed as Parameters.`;
|
|
2075
|
+
fix = `Remove 'ExtraFilter'. Add WHERE logic to the query definition or pass as Parameters`;
|
|
2076
|
+
}
|
|
2077
|
+
else if (propName === 'Fields') {
|
|
2078
|
+
message = `RunQuery does not support 'Fields'. The query definition determines returned fields.`;
|
|
2079
|
+
fix = `Remove 'Fields'. Modify the query definition to return desired fields`;
|
|
2080
|
+
}
|
|
2081
|
+
else if (propName === 'OrderBy') {
|
|
2082
|
+
message = `RunQuery does not support 'OrderBy'. ORDER BY should be in the query definition.`;
|
|
2083
|
+
fix = `Remove 'OrderBy'. Add ORDER BY to the query definition`;
|
|
2084
|
+
}
|
|
2085
|
+
violations.push({
|
|
2086
|
+
rule: 'runview-runquery-valid-properties',
|
|
2087
|
+
severity: 'critical',
|
|
2088
|
+
line: prop.loc?.start.line || 0,
|
|
2089
|
+
column: prop.loc?.start.column || 0,
|
|
2090
|
+
message,
|
|
2091
|
+
code: `${propName}: ...`
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
return violations;
|
|
2101
|
+
}
|
|
2102
|
+
},
|
|
1807
2103
|
{
|
|
1808
2104
|
name: 'root-component-props-restriction',
|
|
1809
2105
|
appliesTo: 'root',
|
|
@@ -1834,7 +2130,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1834
2130
|
if (invalidProps.length > 0) {
|
|
1835
2131
|
violations.push({
|
|
1836
2132
|
rule: 'root-component-props-restriction',
|
|
1837
|
-
severity: '
|
|
2133
|
+
severity: 'critical',
|
|
1838
2134
|
line: path.node.loc?.start.line || 0,
|
|
1839
2135
|
column: path.node.loc?.start.column || 0,
|
|
1840
2136
|
message: `Component "${componentName}" accepts non-standard props: ${invalidProps.join(', ')}. Root components can only accept standard props: ${Array.from(standardProps).join(', ')}. Load data internally using utilities.rv.RunView().`
|
|
@@ -1864,7 +2160,7 @@ ComponentLinter.universalComponentRules = [
|
|
|
1864
2160
|
if (invalidProps.length > 0) {
|
|
1865
2161
|
violations.push({
|
|
1866
2162
|
rule: 'root-component-props-restriction',
|
|
1867
|
-
severity: '
|
|
2163
|
+
severity: 'critical',
|
|
1868
2164
|
line: path.node.loc?.start.line || 0,
|
|
1869
2165
|
column: path.node.loc?.start.column || 0,
|
|
1870
2166
|
message: `Component "${componentName}" accepts non-standard props: ${invalidProps.join(', ')}. Root components can only accept standard props: ${Array.from(standardProps).join(', ')}. Load data internally using utilities.rv.RunView().`
|