@provartesting/provardx-cli 1.5.3 → 1.6.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.
Files changed (47) hide show
  1. package/README.md +5 -37
  2. package/lib/commands/provar/automation/project/validate.js +5 -3
  3. package/lib/commands/provar/automation/project/validate.js.map +1 -1
  4. package/lib/mcp/docs/PROVAR_TEST_STEP_REFERENCE.md +13 -1
  5. package/lib/mcp/docs/VALIDATION_RULE_REGISTRY.md +225 -0
  6. package/lib/mcp/rules/comparisonTypeSets.d.ts +21 -0
  7. package/lib/mcp/rules/comparisonTypeSets.js +45 -0
  8. package/lib/mcp/rules/comparisonTypeSets.js.map +1 -0
  9. package/lib/mcp/rules/provar_best_practices_rules.json +119 -8
  10. package/lib/mcp/rules/provar_layer1_rules.json +151 -0
  11. package/lib/mcp/rules/provar_test_step_schema.json +3005 -0
  12. package/lib/mcp/server.d.ts +15 -0
  13. package/lib/mcp/server.js +64 -1
  14. package/lib/mcp/server.js.map +1 -1
  15. package/lib/mcp/tools/automationTools.js +38 -1
  16. package/lib/mcp/tools/automationTools.js.map +1 -1
  17. package/lib/mcp/tools/bestPracticesEngine.js +1068 -6
  18. package/lib/mcp/tools/bestPracticesEngine.js.map +1 -1
  19. package/lib/mcp/tools/hierarchyValidate.d.ts +2 -1
  20. package/lib/mcp/tools/hierarchyValidate.js +7 -1
  21. package/lib/mcp/tools/hierarchyValidate.js.map +1 -1
  22. package/lib/mcp/tools/projectValidateFromPath.js +1 -2
  23. package/lib/mcp/tools/projectValidateFromPath.js.map +1 -1
  24. package/lib/mcp/tools/sfSpawn.d.ts +23 -0
  25. package/lib/mcp/tools/sfSpawn.js +72 -1
  26. package/lib/mcp/tools/sfSpawn.js.map +1 -1
  27. package/lib/mcp/tools/testCaseGenerate.js +146 -12
  28. package/lib/mcp/tools/testCaseGenerate.js.map +1 -1
  29. package/lib/mcp/tools/testCaseValidate.d.ts +46 -0
  30. package/lib/mcp/tools/testCaseValidate.js +307 -41
  31. package/lib/mcp/tools/testCaseValidate.js.map +1 -1
  32. package/lib/mcp/tools/testPlanValidate.js +3 -2
  33. package/lib/mcp/tools/testPlanValidate.js.map +1 -1
  34. package/lib/mcp/tools/testSuiteValidate.js +3 -2
  35. package/lib/mcp/tools/testSuiteValidate.js.map +1 -1
  36. package/lib/mcp/utils/qualityThreshold.d.ts +8 -0
  37. package/lib/mcp/utils/qualityThreshold.js +42 -0
  38. package/lib/mcp/utils/qualityThreshold.js.map +1 -0
  39. package/lib/mcp/utils/testCaseId.d.ts +23 -0
  40. package/lib/mcp/utils/testCaseId.js +156 -0
  41. package/lib/mcp/utils/testCaseId.js.map +1 -0
  42. package/lib/services/projectValidation.js +2 -1
  43. package/lib/services/projectValidation.js.map +1 -1
  44. package/messages/sf.provar.automation.project.validate.md +1 -1
  45. package/messages/sf.provar.mcp.start.md +1 -0
  46. package/oclif.manifest.json +4 -4
  47. package/package.json +3 -2
@@ -538,7 +538,9 @@ function validateDetectDuplicatesLiterals(tc, rule) {
538
538
  continue;
539
539
  if (valElem['@_class'] !== 'value')
540
540
  continue; // skip variables/compounds
541
- const text = (valElem['#text'] ?? '').trim();
541
+ // Read through nodeText: fast-xml-parser yields a NUMBER for a numeric tag
542
+ // value (e.g. <value>123</value>), so a bare `.trim()` would throw.
543
+ const text = nodeText(valElem);
542
544
  if (!text || text.length <= 3)
543
545
  continue;
544
546
  if (DEFAULT_LITERAL_VALUES.has(text))
@@ -850,11 +852,7 @@ function validateUiActionNestingStructure(tc, rule) {
850
852
  const tid = call['@_testItemId'];
851
853
  const shortApi = apiId.split('.').pop() ?? apiId;
852
854
  const tidSuffix = tid ? ` (testItemId=${tid})` : '';
853
- const requiredContainer = verdict.includes('UiWithRow')
854
- ? 'UiWithRow'
855
- : verdict.includes('UiWithScreen')
856
- ? 'UiWithScreen'
857
- : 'UiWithScreen';
855
+ const requiredContainer = verdict.includes('UiWithRow') ? 'UiWithRow' : 'UiWithScreen';
858
856
  const message = `${shortApi} '${title}' is ${verdict} - must be nested inside a parent ` +
859
857
  `${requiredContainer}'s <clauses><clause name="substeps"><steps> block${tidSuffix}`;
860
858
  violations.push(makeViolation(rule, message));
@@ -979,6 +977,1041 @@ function validateNitroxVariantArgRequired(tc, rule) {
979
977
  }
980
978
  return violations;
981
979
  }
980
+ /**
981
+ * Value `class` attributes that, when present on a condition/expression argument,
982
+ * are themselves meaningful content — comparison and boolean-logic operators that
983
+ * Provar emits for `If`/`DoWhile`/`WaitFor` conditions (e.g. `{Count(Rows) > 0}`
984
+ * is stored as `<value class="gt">`). Mirrors the backend operator allow-list.
985
+ */
986
+ const MEANINGFUL_VALUE_OPERATOR_CLASSES = new Set([
987
+ 'gt',
988
+ 'lt',
989
+ 'eq',
990
+ 'ne',
991
+ 'ge',
992
+ 'le',
993
+ 'and',
994
+ 'or',
995
+ 'not',
996
+ ]);
997
+ /**
998
+ * True when an `<argument>` carries a *meaningful* value, mirroring the Quality
999
+ * Hub `MustContainArgumentValidator` content checks exactly. A `variable` value
1000
+ * counts only if it references a `<path>` (or has non-empty text) — a bare
1001
+ * `<value class="variable"/>` is effectively empty; `funcCall` and the comparison
1002
+ * / logic operator classes always count; `compound` counts only if `<parts>` has
1003
+ * children; any other (simple) value counts only if it has non-empty text. An
1004
+ * `<argument>` with no `<value>` child, or an empty `<value/>`, is NOT meaningful
1005
+ * and is treated as a missing required argument.
1006
+ */
1007
+ function argumentHasMeaningfulValue(arg) {
1008
+ for (const value of toArr(arg['value'])) {
1009
+ if (value == null)
1010
+ continue;
1011
+ if (typeof value === 'string') {
1012
+ if (value.trim().length > 0)
1013
+ return true;
1014
+ continue;
1015
+ }
1016
+ if (typeof value !== 'object')
1017
+ continue;
1018
+ const v = value;
1019
+ const vClass = v['@_class'] ?? '';
1020
+ // nodeText coerces a numeric #text to string first — a bare `.trim()` throws on it.
1021
+ const text = nodeText(v);
1022
+ if (vClass === 'variable') {
1023
+ if (v['path'] != null || text.length > 0)
1024
+ return true;
1025
+ continue; // bare <value class="variable"/> — not meaningful
1026
+ }
1027
+ if (vClass === 'funcCall' || MEANINGFUL_VALUE_OPERATOR_CLASSES.has(vClass))
1028
+ return true;
1029
+ if (vClass === 'compound') {
1030
+ const parts = v['parts'];
1031
+ if (parts && typeof parts === 'object' && Object.keys(parts).some((k) => !k.startsWith('@_')))
1032
+ return true;
1033
+ continue;
1034
+ }
1035
+ if (text.length > 0)
1036
+ return true;
1037
+ }
1038
+ return false;
1039
+ }
1040
+ /** Find an `<argument id=…>` for a call, tolerating both the `<arguments>` wrapper and direct children. */
1041
+ function findArgumentById(call, argId) {
1042
+ return getCallArguments(call).find((a) => a['@_id'] === argId) ?? getArguments(call).find((a) => a['@_id'] === argId);
1043
+ }
1044
+ /** Human-readable step label for a violation message: `'<title|name>' (testItemId=N)`. */
1045
+ function stepLabel(call) {
1046
+ const label = call['@_title'] ?? call['@_name'] ?? '(unnamed)';
1047
+ const tid = call['@_testItemId'];
1048
+ return `'${label}'${tid ? ` (testItemId=${tid})` : ''}`;
1049
+ }
1050
+ /**
1051
+ * True when `call` satisfies a `mustContainArgument` requirement — either the
1052
+ * argument is present with a meaningful value, or (for `If`/`DoWhile` conditions)
1053
+ * the legacy condition-in-title format is used.
1054
+ */
1055
+ function callSatisfiesRequiredArg(call, requiredArg, conditionInTitleAllowed) {
1056
+ const arg = findArgumentById(call, requiredArg);
1057
+ if (arg && argumentHasMeaningfulValue(arg))
1058
+ return true;
1059
+ if (!arg && conditionInTitleAllowed) {
1060
+ const title = call['@_title'] ?? '';
1061
+ if (title.includes('{') && title.includes('}'))
1062
+ return true; // legacy condition-in-title format
1063
+ }
1064
+ return false;
1065
+ }
1066
+ /**
1067
+ * mustContainArgument — every apiCall whose apiId equals `check.apiId` must carry a
1068
+ * populated `<argument id="check.argument">`. Faithful TypeScript port of the
1069
+ * Quality Hub `MustContainArgumentValidator`, so the local (offline) result and
1070
+ * the back-end agree: present-AND-non-empty semantics via
1071
+ * {@link argumentHasMeaningfulValue} (an absent argument OR an empty
1072
+ * `<argument/>`/`<value/>` is a violation); exact apiId match (no substring /
1073
+ * variant widening); the legacy exception where `If`/`DoWhile` may carry the
1074
+ * condition in the step `title` (`If: {expr}`) instead of a `condition` argument;
1075
+ * disabled steps are NOT skipped (a missing required argument is load/exec
1076
+ * blocking regardless of the disabled flag); and one violation per rule (the
1077
+ * back-end returns the first offender), so the weighted-deduction score stays in
1078
+ * parity with the Lambda. The message still names every offending step without
1079
+ * inflating `count`.
1080
+ */
1081
+ function validateMustContainArgument(tc, rule) {
1082
+ const targetApiId = rule.check['apiId'] ?? '';
1083
+ const requiredArg = rule.check['argument'] ?? '';
1084
+ if (!targetApiId || !requiredArg)
1085
+ return null;
1086
+ const conditionInTitleAllowed = requiredArg === 'condition' &&
1087
+ (targetApiId === 'com.provar.plugins.bundled.apis.If' ||
1088
+ targetApiId === 'com.provar.plugins.bundled.apis.control.DoWhile');
1089
+ const offending = [];
1090
+ for (const call of getAllApiCalls(tc)) {
1091
+ if (call['@_apiId'] !== targetApiId)
1092
+ continue;
1093
+ if (callSatisfiesRequiredArg(call, requiredArg, conditionInTitleAllowed))
1094
+ continue;
1095
+ offending.push(stepLabel(call));
1096
+ }
1097
+ if (!offending.length)
1098
+ return null;
1099
+ const apiName = targetApiId.split('.').pop() ?? targetApiId;
1100
+ let msg = `${apiName} step missing required '${requiredArg}' argument: ${offending.slice(0, 2).join(', ')}`;
1101
+ if (offending.length > 2)
1102
+ msg += ` (and ${offending.length - 2} more)`;
1103
+ // Intentionally no `count`: the back-end reports a single violation per rule, so
1104
+ // omitting count keeps the weighted-deduction score in parity with the Lambda.
1105
+ return makeViolation(rule, msg);
1106
+ }
1107
+ // ── Render / load-blocking validators (Tier 2) ───────────────────────────────
1108
+ // Faithful ports of the Quality Hub XMLRendering / InvalidValueClass /
1109
+ // DateValueClassFormat / ApexConnect* / SetValuesInvalidElements validators.
1110
+ // These check types map 1:1 to load-blocking rules (mostly `critical`) that
1111
+ // stop a test case rendering or loading in the Provar IDE. Each returns a
1112
+ // single BPViolation per rule (the back-end reports the first offender and sets
1113
+ // `count = len(offenders)` only when > 1), so the weighted-deduction score stays
1114
+ // in parity with the Lambda.
1115
+ const APEX_CONNECT_API_ID = 'com.provar.plugins.forcedotcom.core.testapis.ApexConnect';
1116
+ const SETVALUES_API_ID = 'com.provar.plugins.bundled.apis.control.SetValues';
1117
+ /**
1118
+ * Recursively collect every element that appears under `tag` anywhere in the
1119
+ * subtree (the fast-xml-parser equivalent of ElementTree's `.//tag`). Returns
1120
+ * the raw values (object, string, or — for repeated tags — each array member);
1121
+ * callers filter to objects when they need attributes. Mirrors the back-end's
1122
+ * descendant search so nested-step double-counting matches exactly.
1123
+ */
1124
+ function collectElementsByTag(node, tag) {
1125
+ const out = [];
1126
+ function walk(n) {
1127
+ if (!n || typeof n !== 'object')
1128
+ return;
1129
+ if (Array.isArray(n)) {
1130
+ for (const item of n)
1131
+ walk(item);
1132
+ return;
1133
+ }
1134
+ for (const [k, v] of Object.entries(n)) {
1135
+ if (k.startsWith('@_') || k === '#text')
1136
+ continue;
1137
+ if (k === tag)
1138
+ for (const item of toArr(v))
1139
+ out.push(item);
1140
+ walk(v);
1141
+ }
1142
+ }
1143
+ walk(node);
1144
+ return out;
1145
+ }
1146
+ /** Object-form `<value>` descendants of a node (string-only text values are skipped). */
1147
+ function collectValueElements(node) {
1148
+ return collectElementsByTag(node, 'value').filter((v) => v != null && typeof v === 'object');
1149
+ }
1150
+ /**
1151
+ * Trimmed text of an element's `#text`, coercing to string first. fast-xml-parser
1152
+ * parses numeric tag text to a `number` (e.g. an epoch-millis date), so a raw
1153
+ * `.trim()` on `#text` would throw — always read element text through here.
1154
+ */
1155
+ function nodeText(node) {
1156
+ const t = node['#text'];
1157
+ return t == null ? '' : String(t).trim();
1158
+ }
1159
+ /** Step context used in load-blocking violation messages, mirroring the back-end defaults. */
1160
+ function stepContext(call) {
1161
+ const apiId = call['@_apiId'] ?? '';
1162
+ const apiName = apiId ? apiId.split('.').pop() ?? apiId : 'Unknown';
1163
+ const title = call['@_title'] ?? apiName;
1164
+ const tid = call['@_testItemId'] ?? 'N/A';
1165
+ return { apiName, title, tid };
1166
+ }
1167
+ /**
1168
+ * Every `<value>` element within an apiCall, tagged with its parent `<argument id>`.
1169
+ * Replicates the back-end's "find the first enclosing argument" lookup so violation
1170
+ * messages name the right argument. Values not inside any argument get `unknown`.
1171
+ */
1172
+ function getStepValueElements(call) {
1173
+ const argOf = new Map();
1174
+ for (const arg of collectElementsByTag(call, 'argument')) {
1175
+ if (!arg || typeof arg !== 'object')
1176
+ continue;
1177
+ const id = arg['@_id'] ?? 'unknown';
1178
+ for (const v of collectValueElements(arg))
1179
+ if (!argOf.has(v))
1180
+ argOf.set(v, id);
1181
+ }
1182
+ return collectValueElements(call).map((value) => ({ value, argId: argOf.get(value) ?? 'unknown' }));
1183
+ }
1184
+ /** Trimmed text of an argument's direct `<value>` child (handles string and object forms). */
1185
+ function directValueText(arg) {
1186
+ const v = Array.isArray(arg['value']) ? arg['value'][0] : arg['value'];
1187
+ if (v == null)
1188
+ return '';
1189
+ if (typeof v === 'string')
1190
+ return v.trim();
1191
+ if (typeof v !== 'object')
1192
+ return String(v).trim();
1193
+ return nodeText(v);
1194
+ }
1195
+ // RENDER-CASE-001 — the valueClass values that actually exist. This validator only
1196
+ // inspects the `valueClass` attribute, and a full-corpus scan (AllPOCProjects) shows
1197
+ // exactly SIX distinct valueClass values: string, boolean, decimal, id, date, dateTime
1198
+ // — matching the back-end's VALID_VALUE_CLASSES. (The earlier list also carried
1199
+ // `class="..."` tokens — variable/compound/funcCall/value/valueList/operators — and
1200
+ // `integer`; none of those ever appear as a valueClass, so they were dead entries, and
1201
+ // `id` — a real corpus valueClass — was missing. Coordinated with the QH back-end.)
1202
+ const VALUE_CLASS_CASING_VALID = new Set([
1203
+ 'string',
1204
+ 'boolean',
1205
+ 'decimal',
1206
+ 'id',
1207
+ 'date',
1208
+ 'datetime',
1209
+ ]);
1210
+ // Canonical Provar spelling for valueClasses whose correct form is NOT all-lowercase.
1211
+ // The corpus uses camelCase `dateTime` exclusively (lowercase `datetime` never appears),
1212
+ // so the casing check must expect `dateTime`; every other valueClass is all-lowercase.
1213
+ const VALUE_CLASS_CANONICAL_CASE = { datetime: 'dateTime' };
1214
+ /** RENDER-CASE-001 — a known valueClass spelled with wrong case (e.g. `Boolean` → `boolean`). */
1215
+ function validateValueClassCasing(tc, rule) {
1216
+ const offenders = [];
1217
+ for (const v of collectValueElements(tc)) {
1218
+ const vc = v['@_valueClass'];
1219
+ if (!vc)
1220
+ continue;
1221
+ const lower = vc.toLowerCase();
1222
+ if (!VALUE_CLASS_CASING_VALID.has(lower))
1223
+ continue;
1224
+ const expected = VALUE_CLASS_CANONICAL_CASE[lower] ?? lower;
1225
+ if (vc !== expected)
1226
+ offenders.push({ valueClass: vc, expected });
1227
+ }
1228
+ if (!offenders.length)
1229
+ return null;
1230
+ const MAX = 5;
1231
+ const reported = Math.min(offenders.length, MAX);
1232
+ let msg = offenders
1233
+ .slice(0, 3)
1234
+ .map((o) => `valueClass='${o.valueClass}' should be '${o.expected}'`)
1235
+ .join('; ');
1236
+ if (reported > 3)
1237
+ msg += ` (+${reported - 3} more)`;
1238
+ if (offenders.length > MAX)
1239
+ msg += ` (total: ${offenders.length})`;
1240
+ return makeViolation(rule, msg, reported);
1241
+ }
1242
+ const BOOLEAN_CASING_BAD = new Set(['True', 'False', 'TRUE', 'FALSE']);
1243
+ /** RENDER-BOOL-001 — `<value valueClass="boolean">` text must be lowercase `true`/`false`. */
1244
+ function validateBooleanCasing(tc, rule) {
1245
+ const offenders = [];
1246
+ for (const v of collectValueElements(tc)) {
1247
+ if (v['@_valueClass'] !== 'boolean')
1248
+ continue;
1249
+ const text = nodeText(v);
1250
+ if (BOOLEAN_CASING_BAD.has(text))
1251
+ offenders.push(text);
1252
+ }
1253
+ if (!offenders.length)
1254
+ return null;
1255
+ const MAX = 5;
1256
+ const reported = Math.min(offenders.length, MAX);
1257
+ let msg = `Boolean values must be lowercase: ${offenders
1258
+ .slice(0, 3)
1259
+ .map((t) => `'${t}' should be '${t.toLowerCase()}'`)
1260
+ .join('; ')}`;
1261
+ if (reported > 3)
1262
+ msg += ` (+${reported - 3} more)`;
1263
+ if (offenders.length > MAX)
1264
+ msg += ` (total: ${offenders.length})`;
1265
+ return makeViolation(rule, msg, reported);
1266
+ }
1267
+ // VALUE-CLASS-001 — the back-end HARDCODES these sets and ignores the rule JSON's
1268
+ // validClasses list, so we mirror the hardcoded sets (note `invalid` and
1269
+ // `namedValues` are accepted, and `dateTime` is camelCase) to stay score-exact.
1270
+ const VALID_VALUE_ELEMENT_CLASSES = new Set([
1271
+ 'value',
1272
+ 'variable',
1273
+ 'compound',
1274
+ 'funcCall',
1275
+ 'valueList',
1276
+ 'namedValues',
1277
+ 'uiWait',
1278
+ 'uiLocator',
1279
+ 'uiTarget',
1280
+ 'uiInteraction',
1281
+ 'restTarget',
1282
+ 'excelTarget',
1283
+ 'csvTarget',
1284
+ 'url',
1285
+ 'template',
1286
+ 'add',
1287
+ 'sub',
1288
+ 'mult',
1289
+ 'div',
1290
+ 'eq',
1291
+ 'ne',
1292
+ 'gt',
1293
+ 'lt',
1294
+ 'ge',
1295
+ 'le',
1296
+ 'and',
1297
+ 'or',
1298
+ 'match',
1299
+ 'invalid',
1300
+ ]);
1301
+ const VALID_VALUE_CLASS_TYPES = new Set([
1302
+ 'string',
1303
+ 'boolean',
1304
+ 'decimal',
1305
+ 'id',
1306
+ 'date',
1307
+ 'dateTime',
1308
+ ]);
1309
+ /** Classify one `<value>` for VALUE-CLASS-001, or `null` when it is valid / unattributed. */
1310
+ function classifyInvalidValueClass(v) {
1311
+ const cls = v['@_class'] ?? '';
1312
+ if (!cls)
1313
+ return null;
1314
+ if (!VALID_VALUE_ELEMENT_CLASSES.has(cls))
1315
+ return { kind: 'class', bad: cls };
1316
+ const vc = v['@_valueClass'] ?? '';
1317
+ if (cls === 'value' && vc && !VALID_VALUE_CLASS_TYPES.has(vc))
1318
+ return { kind: 'valueClass', bad: vc };
1319
+ return null;
1320
+ }
1321
+ /** VALUE-CLASS-001 — `<value class="…">` must use a valid class (and valueClass when class="value"). */
1322
+ function validateInvalidValueClass(tc, rule) {
1323
+ const offenders = [];
1324
+ for (const call of getAllApiCalls(tc)) {
1325
+ const ctx = stepContext(call);
1326
+ for (const { value, argId } of getStepValueElements(call)) {
1327
+ const c = classifyInvalidValueClass(value);
1328
+ if (c)
1329
+ offenders.push({ argId, ctx, kind: c.kind, bad: c.bad });
1330
+ }
1331
+ }
1332
+ if (!offenders.length)
1333
+ return null;
1334
+ const f = offenders[0];
1335
+ const message = f.kind === 'class'
1336
+ ? `Step '${f.ctx.title}' has invalid class="${f.bad}" in argument '${f.argId}'. Valid class values: value, ` +
1337
+ 'variable, compound, funcCall, valueList, uiWait, uiLocator, uiTarget, etc. For empty arguments, omit ' +
1338
+ `<value> entirely: <argument id="${f.argId}"/> (testItemId=${f.ctx.tid})`
1339
+ : `Step '${f.ctx.title}' has invalid valueClass="${f.bad}" in argument '${f.argId}'. Valid valueClass values: ` +
1340
+ `string, boolean, decimal, id, date, dateTime. (testItemId=${f.ctx.tid})`;
1341
+ return makeViolation(rule, message, offenders.length);
1342
+ }
1343
+ /** RENDER-DATE-VALUECLASS-001 — `valueClass="date"|"dateTime"` text must be an epoch-millis integer. */
1344
+ function validateDateValueClassFormat(tc, rule) {
1345
+ const offenders = [];
1346
+ for (const call of getAllApiCalls(tc)) {
1347
+ const ctx = stepContext(call);
1348
+ for (const { value, argId } of getStepValueElements(call)) {
1349
+ if (value['@_class'] !== 'value')
1350
+ continue;
1351
+ const vc = value['@_valueClass'] ?? '';
1352
+ if (vc !== 'date' && vc !== 'dateTime')
1353
+ continue;
1354
+ const text = nodeText(value);
1355
+ if (!text || /^\d+$/.test(text))
1356
+ continue;
1357
+ offenders.push({ argId, ctx, vc, text: text.slice(0, 50) });
1358
+ }
1359
+ }
1360
+ if (!offenders.length)
1361
+ return null;
1362
+ const f = offenders[0];
1363
+ const message = `Step '${f.ctx.title}' uses valueClass='${f.vc}' with invalid string value '${f.text}' in argument ` +
1364
+ `'${f.argId}'. valueClass='${f.vc}' requires an epoch timestamp (milliseconds), not a date string. This ` +
1365
+ `causes test case loading failures in Provar. (testItemId=${f.ctx.tid})`;
1366
+ return makeViolation(rule, message, offenders.length);
1367
+ }
1368
+ /** Return the ApexConnect calls in a test case (exact apiId match, nested-aware). */
1369
+ function getApexConnectCalls(tc) {
1370
+ return getAllApiCalls(tc).filter((c) => c['@_apiId'] === APEX_CONNECT_API_ID);
1371
+ }
1372
+ /** APEX-REUSE-CONN-001 — ApexConnect `reuseConnectionName` must be blank. */
1373
+ function validateApexConnectReuseConnection(tc, rule) {
1374
+ const offenders = [];
1375
+ for (const call of getApexConnectCalls(tc)) {
1376
+ const arg = getCallArguments(call).find((a) => a['@_id'] === 'reuseConnectionName');
1377
+ if (!arg)
1378
+ continue;
1379
+ const text = directValueText(arg);
1380
+ if (text)
1381
+ offenders.push({ ctx: stepContext(call), value: text });
1382
+ }
1383
+ if (!offenders.length)
1384
+ return null;
1385
+ const f = offenders[0];
1386
+ const message = `ApexConnect step '${f.ctx.title}' has non-empty reuseConnectionName value '${f.value}'. The ` +
1387
+ `reuseConnectionName argument should be left blank: <argument id="reuseConnectionName"/> (testItemId=${f.ctx.tid})`;
1388
+ return makeViolation(rule, message, offenders.length);
1389
+ }
1390
+ const APEX_CONNECT_VALID_ARGS_DEFAULT = [
1391
+ 'connectionName',
1392
+ 'resultName',
1393
+ 'resultScope',
1394
+ 'uiApplicationName',
1395
+ 'quickUiLogin',
1396
+ 'closeAllPrimaryTabs',
1397
+ 'reuseConnectionName',
1398
+ 'alreadyOpenBehaviour',
1399
+ 'autoCleanup',
1400
+ 'cleanupConnectionName',
1401
+ 'logFileLocation',
1402
+ 'connectionId',
1403
+ 'enableObjectIdLogging',
1404
+ 'privateBrowsingMode',
1405
+ 'lightningMode',
1406
+ 'username',
1407
+ 'password',
1408
+ 'securityToken',
1409
+ 'environment',
1410
+ 'webBrowser',
1411
+ ];
1412
+ /** APEX-CONNECT-ARGS-001 — every ApexConnect `<argument id>` must be in the valid whitelist. */
1413
+ function validateApexConnectValidArguments(tc, rule) {
1414
+ const validIds = new Set(rule.check['validArgumentIds'] ?? APEX_CONNECT_VALID_ARGS_DEFAULT);
1415
+ const offenders = [];
1416
+ for (const call of getApexConnectCalls(tc)) {
1417
+ if (!call['arguments'] || typeof call['arguments'] !== 'object')
1418
+ continue;
1419
+ for (const arg of getCallArguments(call)) {
1420
+ const id = arg['@_id'];
1421
+ if (id && !validIds.has(id))
1422
+ offenders.push({ ctx: stepContext(call), id });
1423
+ }
1424
+ }
1425
+ if (!offenders.length)
1426
+ return null;
1427
+ const f = offenders[0];
1428
+ const message = `ApexConnect step '${f.ctx.title}' uses invalid argument ID(s): ${offenders.map((o) => o.id).join(', ')}. ` +
1429
+ 'Only the documented ApexConnect argument IDs are valid (connectionName, resultName, resultScope, …, ' +
1430
+ 'webBrowser). Leave unused arguments empty (e.g. <argument id="username"/>) rather than inventing IDs. ' +
1431
+ `(testItemId=${f.ctx.tid})`;
1432
+ return makeViolation(rule, message, offenders.length);
1433
+ }
1434
+ /** APEX-CONNECT-CONNID-001 — `connectionId` value must use `valueClass="id"`, not string/other. */
1435
+ function validateApexConnectConnectionIdValueClass(tc, rule) {
1436
+ const offenders = [];
1437
+ for (const call of getApexConnectCalls(tc)) {
1438
+ const arg = getCallArguments(call).find((a) => a['@_id'] === 'connectionId');
1439
+ if (!arg)
1440
+ continue;
1441
+ const ve = Array.isArray(arg['value']) ? arg['value'][0] : arg['value'];
1442
+ if (!ve || typeof ve !== 'object')
1443
+ continue;
1444
+ const v = ve;
1445
+ const vc = v['@_valueClass'] ?? '';
1446
+ if (v['@_class'] !== 'value' || !vc || vc === 'id')
1447
+ continue;
1448
+ offenders.push({ ctx: stepContext(call), wrong: vc, value: nodeText(v) });
1449
+ }
1450
+ if (!offenders.length)
1451
+ return null;
1452
+ const f = offenders[0];
1453
+ const message = `ApexConnect step '${f.ctx.title}' uses incorrect valueClass='${f.wrong}' for connectionId argument. The ` +
1454
+ "connectionId must use valueClass='id' with a GUID value, NOT valueClass='string'. Current value: " +
1455
+ `'${f.value}'. If you have no specific connection GUID, leave it empty: <argument id="connectionId"/> ` +
1456
+ `(testItemId=${f.ctx.tid})`;
1457
+ return makeViolation(rule, message, offenders.length);
1458
+ }
1459
+ /** Tags found directly under a `<namedValues>` container that are not the allowed `namedValue`. */
1460
+ function namedValuesInvalidChildren(nv) {
1461
+ const bad = [];
1462
+ for (const [k, v] of Object.entries(nv)) {
1463
+ if (k.startsWith('@_') || k === '#text' || k === 'namedValue')
1464
+ continue;
1465
+ const instances = toArr(v).length; // one entry per element instance (mirrors the back-end count)
1466
+ for (let i = 0; i < instances; i++)
1467
+ bad.push(k);
1468
+ }
1469
+ return bad;
1470
+ }
1471
+ /** SETVALUES-INVALID-ELEMENT-001 — reject hallucinated `<namedValueSet>`/`<name>` and bad namedValues children. */
1472
+ function validateSetValuesInvalidElements(tc, rule) {
1473
+ const invalidElements = rule.check['invalidElements'] ?? ['namedValueSet', 'name'];
1474
+ let count = 0;
1475
+ let first = null;
1476
+ for (const call of getAllApiCalls(tc)) {
1477
+ if (call['@_apiId'] !== SETVALUES_API_ID)
1478
+ continue;
1479
+ const ctx = stepContext(call);
1480
+ for (const elem of invalidElements) {
1481
+ if (collectElementsByTag(call, elem).length) {
1482
+ count++;
1483
+ first ??= { ctx, elem };
1484
+ }
1485
+ }
1486
+ for (const nv of collectElementsByTag(call, 'namedValues')) {
1487
+ if (!nv || typeof nv !== 'object')
1488
+ continue;
1489
+ for (const childTag of namedValuesInvalidChildren(nv)) {
1490
+ count++;
1491
+ first ??= { ctx, elem: childTag, context: 'inside <namedValues>' };
1492
+ }
1493
+ }
1494
+ }
1495
+ if (!first)
1496
+ return null;
1497
+ const ctxStr = first.context ? ` ${first.context}` : '';
1498
+ const message = `SetValues step '${first.ctx.title}' contains invalid element <${first.elem}>${ctxStr}. SetValues must use ` +
1499
+ '<namedValues mutable="Mutable"> with <namedValue name="valuePath|value|valueScope"> children. Do not use ' +
1500
+ `<namedValueSet> or <name> elements. (testItemId=${first.ctx.tid})`;
1501
+ return makeViolation(rule, message, count);
1502
+ }
1503
+ // ── Back-end-only rules (Tier 4) ─────────────────────────────────────────────
1504
+ // Ports of seven Quality Hub validators that existed only in the back-end rule
1505
+ // set. All seven rules are severity=major / weight=5. Score parity: six emit a
1506
+ // single violation (the back-end returns the first offender; `count` is set only
1507
+ // for the two UI-locator checks, and only when >1 offender); `varStringLiteral`
1508
+ // emits ONE violation per offending value (the back-end returns a list, scored
1509
+ // linearly) — do not collapse it.
1510
+ const ASSERT_VALUES_API_ID = 'com.provar.plugins.bundled.apis.AssertValues';
1511
+ const DB_CONNECT_API_ID = 'com.provar.plugins.bundled.apis.db.DbConnect';
1512
+ const UI_DO_ACTION_API_ID = 'com.provar.plugins.forcedotcom.core.ui.UiDoAction';
1513
+ // DB operation steps whose dbConnectionName must reference a DbConnect resultName
1514
+ // (both the modern `db.*` and legacy `data.*` namespaces, mirroring the back-end).
1515
+ const DB_OPERATION_API_IDS = new Set([
1516
+ 'com.provar.plugins.bundled.apis.db.SqlQuery',
1517
+ 'com.provar.plugins.bundled.apis.db.DbRead',
1518
+ 'com.provar.plugins.bundled.apis.db.DbInsert',
1519
+ 'com.provar.plugins.bundled.apis.db.DbUpdate',
1520
+ 'com.provar.plugins.bundled.apis.db.DbDelete',
1521
+ 'com.provar.plugins.bundled.apis.data.SqlQuery',
1522
+ 'com.provar.plugins.bundled.apis.data.DbRead',
1523
+ 'com.provar.plugins.bundled.apis.data.DbInsert',
1524
+ 'com.provar.plugins.bundled.apis.data.DbUpdate',
1525
+ 'com.provar.plugins.bundled.apis.data.DbDelete',
1526
+ ]);
1527
+ /** Escape a literal for safe embedding in a RegExp (mirrors Python's re.escape). */
1528
+ function escapeRegExp(s) {
1529
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1530
+ }
1531
+ /**
1532
+ * Resolve an argument's text value, mirroring the back-end `get_argument_value`:
1533
+ * `variable` → first `<path>` element name, `compound` → concatenated `<parts>`
1534
+ * text, otherwise the element text. Wrapper-aware (handles both the `<arguments>`
1535
+ * wrapper and direct `<argument>` children) via {@link findArgumentById}.
1536
+ */
1537
+ function resolvedArgText(call, argId) {
1538
+ const arg = findArgumentById(call, argId);
1539
+ if (!arg)
1540
+ return '';
1541
+ const raw = arg['value'];
1542
+ const ve = Array.isArray(raw) ? raw[0] : raw;
1543
+ if (ve == null)
1544
+ return '';
1545
+ if (typeof ve === 'string')
1546
+ return ve.trim();
1547
+ if (typeof ve !== 'object')
1548
+ return String(ve).trim();
1549
+ const v = ve;
1550
+ const cls = v['@_class'];
1551
+ if (cls === 'variable') {
1552
+ const firstPath = toArr(v['path'])[0];
1553
+ return (firstPath?.['@_element'] ?? '').trim();
1554
+ }
1555
+ if (cls === 'compound') {
1556
+ const partsNode = v['parts']?.['value'];
1557
+ return toArr(partsNode)
1558
+ .filter((p) => p && typeof p === 'object')
1559
+ .map((p) => nodeText(p))
1560
+ .join('');
1561
+ }
1562
+ return nodeText(v);
1563
+ }
1564
+ // Matches a bare variable token only: `{Name}` or `{Obj.Field}`. The character
1565
+ // class `[\w.]` excludes `:`, so binding-style expressions such as
1566
+ // `{targetUrl:object}` never match — they are inherently safe and need no
1567
+ // argument-name exemption.
1568
+ const VAR_LITERAL_PATTERN = /^\{[\w.]+\}$/;
1569
+ /**
1570
+ * VAR-STRING-LITERAL-001 — a `{Var}`/`{Obj.Field}` token stored as
1571
+ * `class="value" valueClass="string"` instead of `class="variable"`. Provar does
1572
+ * not resolve it at runtime, so the API silently receives the literal text. Emits
1573
+ * ONE violation per offending value (the back-end returns a list).
1574
+ *
1575
+ * Local correction (PDX-508): the back-end exempts `sfUiTargetObjectId` /
1576
+ * `sfUiTargetResultName` from this check, but field evidence shows a bare
1577
+ * `{Variable}` in those UI-target args is NOT interpolated — it lands in the URL
1578
+ * as `%7B…%7D` and the step hard-fails (a load/exec stopper, not a warning). We
1579
+ * therefore do NOT exempt those args. Binding-style `{ns:key}` expressions stay
1580
+ * safe because {@link VAR_LITERAL_PATTERN} already excludes them (the colon). A
1581
+ * matching change is queued for the Quality Hub back-end so the score parity is
1582
+ * restored once both ship.
1583
+ */
1584
+ function validateVarStringLiteral(tc, rule) {
1585
+ const violations = [];
1586
+ for (const call of getAllApiCalls(tc)) {
1587
+ const ctx = stepContext(call);
1588
+ for (const { value } of getStepValueElements(call)) {
1589
+ if (value['@_class'] !== 'value' || value['@_valueClass'] !== 'string')
1590
+ continue;
1591
+ const text = nodeText(value);
1592
+ if (!VAR_LITERAL_PATTERN.test(text))
1593
+ continue;
1594
+ violations.push(makeViolation(rule, `Argument value "${text}" looks like a variable reference but is stored as a plain string — Provar ` +
1595
+ 'will not resolve it at runtime. Use <value class="variable"> instead ' +
1596
+ `(step '${ctx.title}', testItemId=${ctx.tid})`));
1597
+ }
1598
+ }
1599
+ return violations;
1600
+ }
1601
+ /** CONN-DB-002 — every DB operation's dbConnectionName must match a DbConnect resultName in the test. */
1602
+ function validateDbConnectResultNameMismatch(tc, rule) {
1603
+ const calls = getAllApiCalls(tc);
1604
+ const resultNames = new Set();
1605
+ for (const call of calls) {
1606
+ if (call['@_apiId'] !== DB_CONNECT_API_ID)
1607
+ continue;
1608
+ const rn = resolvedArgText(call, 'resultName');
1609
+ if (rn)
1610
+ resultNames.add(rn);
1611
+ }
1612
+ if (!resultNames.size)
1613
+ return null; // no DbConnect resultName — CONN-DB-001 covers a missing DbConnect
1614
+ const offenders = [];
1615
+ for (const call of calls) {
1616
+ if (!DB_OPERATION_API_IDS.has(call['@_apiId']))
1617
+ continue;
1618
+ const ref = resolvedArgText(call, 'dbConnectionName');
1619
+ if (!ref || resultNames.has(ref))
1620
+ continue;
1621
+ offenders.push(`'${stepContext(call).title.slice(0, 40)}' uses dbConnectionName='${ref}'`);
1622
+ }
1623
+ if (!offenders.length)
1624
+ return null;
1625
+ const names = [...resultNames].sort().join(', ');
1626
+ return makeViolation(rule, `DbConnect resultName does not match dbConnectionName in ${offenders.length} DB operation(s): ` +
1627
+ `${offenders[0]} but DbConnect resultName(s) are: ${names}`);
1628
+ }
1629
+ /** The `<value>` children of a SetValues `<namedValue name="value">` (the assigned-value slot). */
1630
+ function getSetValuesValueElements(call) {
1631
+ const out = [];
1632
+ for (const nv of collectElementsByTag(call, 'namedValue')) {
1633
+ if (!nv || typeof nv !== 'object' || nv['@_name'] !== 'value')
1634
+ continue;
1635
+ for (const v of toArr(nv['value'])) {
1636
+ if (v && typeof v === 'object')
1637
+ out.push(v);
1638
+ }
1639
+ }
1640
+ return out;
1641
+ }
1642
+ const SETVALUES_FUNC_EXPR = /\{[A-Za-z][A-Za-z0-9]*\s*\([^)]*\)\s*\}/;
1643
+ const SETVALUES_ZERO_IDX = /\{[^}]*\[0\][^}]*\}/;
1644
+ /** First SetValues `valueClass="string"` value whose text matches `re` (string-template anti-patterns). */
1645
+ function firstSetValuesStringMatch(tc, re) {
1646
+ for (const call of getAllApiCalls(tc)) {
1647
+ if (call['@_apiId'] !== SETVALUES_API_ID)
1648
+ continue;
1649
+ for (const ve of getSetValuesValueElements(call)) {
1650
+ if (ve['@_class'] !== 'value' || ve['@_valueClass'] !== 'string')
1651
+ continue;
1652
+ const text = nodeText(ve);
1653
+ if (re.test(text))
1654
+ return { ctx: stepContext(call), text };
1655
+ }
1656
+ }
1657
+ return null;
1658
+ }
1659
+ /** SETVALUES-FUNC-STR-001 — SetValues uses `{Func(args)}` string interpolation instead of a `funcCall` value. */
1660
+ function validateSetValuesFuncCallString(tc, rule) {
1661
+ const hit = firstSetValuesStringMatch(tc, SETVALUES_FUNC_EXPR);
1662
+ if (!hit)
1663
+ return null;
1664
+ return makeViolation(rule, 'SetValues uses string interpolation for a function call — the value will not be evaluated: ' +
1665
+ `'${hit.ctx.title.slice(0, 50)}' value='${hit.text.slice(0, 60)}' (testItemId=${hit.ctx.tid})`);
1666
+ }
1667
+ /** SETVALUES-ZERO-IDX-001 — SetValues string template uses a 0 index (templates are 1-indexed). */
1668
+ function validateSetValuesZeroIndexString(tc, rule) {
1669
+ const hit = firstSetValuesStringMatch(tc, SETVALUES_ZERO_IDX);
1670
+ if (!hit)
1671
+ return null;
1672
+ return makeViolation(rule, 'SetValues string expression uses a [0] index — Provar string templates are 1-indexed, causing an ' +
1673
+ `out-of-bounds error at runtime: '${hit.ctx.title.slice(0, 50)}' value='${hit.text.slice(0, 60)}' — use ` +
1674
+ `[1] for the first item (testItemId=${hit.ctx.tid})`);
1675
+ }
1676
+ const ASSERT_WHOLE_EXPR = /^\s*\{[^{}]+\}\s*$/;
1677
+ /** The direct `<value>` element child of an argument (wrapper-aware), or undefined. */
1678
+ function directArgValueElement(call, argId) {
1679
+ const arg = findArgumentById(call, argId);
1680
+ if (!arg)
1681
+ return undefined;
1682
+ const raw = arg['value'];
1683
+ const ve = Array.isArray(raw) ? raw[0] : raw;
1684
+ return ve && typeof ve === 'object' ? ve : undefined;
1685
+ }
1686
+ /** ASSERT-STR-VAR-001 — AssertValues references a variable via a `{Var}` string literal instead of `class="variable"`. */
1687
+ function validateAssertValuesStringExpr(tc, rule) {
1688
+ for (const call of getAllApiCalls(tc)) {
1689
+ if (call['@_apiId'] !== ASSERT_VALUES_API_ID)
1690
+ continue;
1691
+ for (const argId of ['expectedValue', 'actualValue']) {
1692
+ const v = directArgValueElement(call, argId);
1693
+ if (!v || v['@_class'] !== 'value' || v['@_valueClass'] !== 'string')
1694
+ continue;
1695
+ const text = nodeText(v);
1696
+ if (!ASSERT_WHOLE_EXPR.test(text))
1697
+ continue;
1698
+ const ctx = stepContext(call);
1699
+ return makeViolation(rule, 'AssertValues uses a string literal to reference a variable — the assertion compares the literal text, ' +
1700
+ `not the variable value: '${ctx.title.slice(0, 50)}' ${argId}='${text.slice(0, 60)}' should use ` +
1701
+ `<value class="variable"> (testItemId=${ctx.tid})`);
1702
+ }
1703
+ }
1704
+ return null;
1705
+ }
1706
+ /** The `uri` of a UiDoAction's `locator` argument value (`class="uiLocator"`), or '' if absent. */
1707
+ function getUiDoActionLocatorUri(call) {
1708
+ const arg = getCallArguments(call).find((a) => a['@_id'] === 'locator');
1709
+ if (!arg)
1710
+ return '';
1711
+ for (const v of toArr(arg['value'])) {
1712
+ if (v && typeof v === 'object' && v['@_class'] === 'uiLocator') {
1713
+ return v['@_uri'] ?? '';
1714
+ }
1715
+ }
1716
+ return '';
1717
+ }
1718
+ // Standard SF flow buttons whose locator name must use the corpus-validated casing/path.
1719
+ const UI_WRONG_BUTTONS = [
1720
+ ['Cancel', "use 'name=cancel' (lowercase)"],
1721
+ ['continue', "the Continue button on record type selection screens uses 'name=save&path=selectRecordType'"],
1722
+ ['Continue', "the Continue button on record type selection screens uses 'name=save&path=selectRecordType'"],
1723
+ ];
1724
+ /** UI-LOCATOR-BUTTON-CASING-001 — Cancel/Continue flow buttons must use the correct locator name. */
1725
+ function validateUiLocatorButtonCasing(tc, rule) {
1726
+ const offenders = [];
1727
+ for (const call of getAllApiCalls(tc)) {
1728
+ if (call['@_apiId'] !== UI_DO_ACTION_API_ID)
1729
+ continue;
1730
+ const uri = getUiDoActionLocatorUri(call);
1731
+ if (!uri)
1732
+ continue;
1733
+ for (const [wrong, explanation] of UI_WRONG_BUTTONS) {
1734
+ if (new RegExp(`name=${escapeRegExp(wrong)}(&|$)`).test(uri)) {
1735
+ offenders.push({ ctx: stepContext(call), wrong, explanation, uri });
1736
+ break; // only report the first match per step (mirrors the back-end)
1737
+ }
1738
+ }
1739
+ }
1740
+ if (!offenders.length)
1741
+ return null;
1742
+ const f = offenders[0];
1743
+ return makeViolation(rule, `Step '${f.ctx.title}' uses incorrect button name 'name=${f.wrong}': ${f.explanation}. Incorrect button ` +
1744
+ `names cause Provar to show 'Not Available' and fail at runtime. Current URI: ${f.uri.slice(0, 120)} ` +
1745
+ `(testItemId=${f.ctx.tid})`, offenders.length);
1746
+ }
1747
+ const UI_RECORDTYPE_WRONG = /name=recordType(Id)?(&|$)/;
1748
+ /** UI-LOCATOR-RECORDTYPE-001 — the Record Type picker locator must use `name=RecordType`, not `name=recordType(Id)`. */
1749
+ function validateUiLocatorRecordTypeField(tc, rule) {
1750
+ const offenders = [];
1751
+ for (const call of getAllApiCalls(tc)) {
1752
+ if (call['@_apiId'] !== UI_DO_ACTION_API_ID)
1753
+ continue;
1754
+ const uri = getUiDoActionLocatorUri(call);
1755
+ if (!uri || !UI_RECORDTYPE_WRONG.test(uri))
1756
+ continue;
1757
+ offenders.push({ ctx: stepContext(call), uri });
1758
+ }
1759
+ if (!offenders.length)
1760
+ return null;
1761
+ const f = offenders[0];
1762
+ return makeViolation(rule, `Step '${f.ctx.title}' uses an incorrect Record Type field locator. The Record Type picker must use ` +
1763
+ "'name=RecordType' (not 'name=recordTypeId' or 'name=recordType') with 'field=RecordTypeId' in the binding. " +
1764
+ `Current URI: ${f.uri.slice(0, 150)} (testItemId=${f.ctx.tid})`, offenders.length);
1765
+ }
1766
+ // ── Structural / load-affecting check types (Tier 5) ─────────────────────────
1767
+ // Faithful ports of nine Quality Hub structural validators. Six emit a single
1768
+ // first-offender violation with no `count`; validFuncCallId, the two UiAssert
1769
+ // structure checks, and bindingParameterOrder collect every offender and emit
1770
+ // one violation carrying `count` (capped at 5 for funcCall), matching the
1771
+ // back-end so the weighted-deduction score stays in parity with the Lambda.
1772
+ const UI_ASSERT_API_ID = 'com.provar.plugins.forcedotcom.core.ui.UiAssert';
1773
+ // FUNCCALL-VALID-001 — Provar's built-in funcCall ids (exact back-end whitelist, 20 entries).
1774
+ const VALID_FUNCCALL_IDS = new Set([
1775
+ 'TestCaseName',
1776
+ 'TestCasePath',
1777
+ 'TestCaseOutcome',
1778
+ 'TestCaseSuccessful',
1779
+ 'TestCaseErrors',
1780
+ 'TestRunErrors',
1781
+ 'StringReplace',
1782
+ 'StringTrim',
1783
+ 'StringNormalize',
1784
+ 'DateAdd',
1785
+ 'DateFormat',
1786
+ 'DateParse',
1787
+ 'Count',
1788
+ 'Round',
1789
+ 'NumberFormat',
1790
+ 'Not',
1791
+ 'IsSorted',
1792
+ 'GetEnvironmentVariable',
1793
+ 'GetSelectedEnvironment',
1794
+ 'UniqueId',
1795
+ ]);
1796
+ /** FUNCCALL-VALID-001 — every `<value class="funcCall">` `id` must be a real Provar built-in function. */
1797
+ function validateValidFuncCallId(tc, rule) {
1798
+ const offenders = [];
1799
+ for (const v of collectValueElements(tc)) {
1800
+ if (v['@_class'] !== 'funcCall')
1801
+ continue;
1802
+ const id = v['@_id'] ?? '';
1803
+ if (id && !VALID_FUNCCALL_IDS.has(id))
1804
+ offenders.push(id);
1805
+ }
1806
+ if (!offenders.length)
1807
+ return null;
1808
+ const MAX = 5;
1809
+ let msg = `Unknown funcCall id(s) — these functions do not exist in Provar: ${offenders
1810
+ .slice(0, 3)
1811
+ .map((id) => `'${id}'`)
1812
+ .join(', ')}`;
1813
+ if (offenders.length > 3)
1814
+ msg += ` (+${offenders.length - 3} more)`;
1815
+ msg +=
1816
+ '. Valid functions include Count, DateAdd, DateFormat, Round, StringReplace, UniqueId, etc. ' +
1817
+ 'For string concatenation use <value class="compound"><parts>…</parts></value>.';
1818
+ return makeViolation(rule, msg, Math.min(offenders.length, MAX));
1819
+ }
1820
+ // RENDER-ROOT-001 — the only attributes allowed on the root <testCase> element.
1821
+ const VALID_ROOT_ATTRS = new Set([
1822
+ 'guid',
1823
+ 'id',
1824
+ 'name',
1825
+ 'visibility',
1826
+ 'registryId',
1827
+ 'failureBehaviour',
1828
+ ]);
1829
+ /** RENDER-ROOT-001 — the root `<testCase>` element must not carry unknown attributes. */
1830
+ function validateRootAttributes(tc, rule) {
1831
+ const unknown = Object.keys(tc)
1832
+ .filter((k) => k.startsWith('@_'))
1833
+ .map((k) => k.slice(2))
1834
+ .filter((a) => !VALID_ROOT_ATTRS.has(a));
1835
+ if (!unknown.length)
1836
+ return null;
1837
+ return makeViolation(rule, `Unknown root attributes: ${unknown.join(', ')}`);
1838
+ }
1839
+ /** SETVALUES-STRUCTURE-001 — every SetValues step must contain a `<namedValues>` container. */
1840
+ function validateSetValuesStructure(tc, rule) {
1841
+ for (const call of getAllApiCalls(tc)) {
1842
+ if (call['@_apiId'] !== SETVALUES_API_ID)
1843
+ continue;
1844
+ // Data-driven SetValues pull their values from an external source declared in
1845
+ // <parameterValueSources> (e.g. an Excel/CSV binding) and legitimately carry an
1846
+ // empty <value class="valueList"/> with no inline <namedValues>. Not a defect.
1847
+ if (call['parameterValueSources'] != null)
1848
+ continue;
1849
+ if (collectElementsByTag(call, 'namedValues').length)
1850
+ continue;
1851
+ return makeViolation(rule, `SetValues step missing <namedValues> container (testItemId=${stepContext(call).tid})`);
1852
+ }
1853
+ return null;
1854
+ }
1855
+ /** SETVALUES-NAME-001 — every `<namedValue>` in a SetValues step must carry a `name` attribute. */
1856
+ function validateNamedValueName(tc, rule) {
1857
+ for (const call of getAllApiCalls(tc)) {
1858
+ if (call['@_apiId'] !== SETVALUES_API_ID)
1859
+ continue;
1860
+ for (const nv of collectElementsByTag(call, 'namedValue')) {
1861
+ if (!nv || typeof nv !== 'object' || nv['@_name'])
1862
+ continue;
1863
+ return makeViolation(rule, `namedValue in SetValues step missing name attribute (testItemId=${stepContext(call).tid})`);
1864
+ }
1865
+ }
1866
+ return null;
1867
+ }
1868
+ // The QEditor SetValues form stores each assignment as a triple of namedValue slots:
1869
+ // `valuePath` (target field), `value` (data), `valueScope`. Any of these may be left
1870
+ // empty — a blank `value` sets the field to blank, and a wholly-blank row is an unused
1871
+ // row Provar simply ignores. So an empty structural slot is NOT a "missing value" defect.
1872
+ const SETVALUES_BLANKABLE_SLOTS = new Set(['valuePath', 'value', 'valueScope']);
1873
+ /** SETVALUES-VALUE-001 — every `<namedValue>` in a SetValues step must contain a child `<value>`. */
1874
+ function validateNamedValueValue(tc, rule) {
1875
+ for (const call of getAllApiCalls(tc)) {
1876
+ if (call['@_apiId'] !== SETVALUES_API_ID)
1877
+ continue;
1878
+ for (const nv of collectElementsByTag(call, 'namedValue')) {
1879
+ if (!nv || typeof nv !== 'object')
1880
+ continue;
1881
+ const node = nv;
1882
+ if (node['value'] != null)
1883
+ continue;
1884
+ // A blank structural slot (valuePath/value/valueScope) is valid (empty value /
1885
+ // unused row). Only a non-standard namedValue missing its value is a real defect.
1886
+ if (SETVALUES_BLANKABLE_SLOTS.has(node['@_name'] ?? ''))
1887
+ continue;
1888
+ return makeViolation(rule, `namedValue in SetValues step missing value element (testItemId=${stepContext(call).tid})`);
1889
+ }
1890
+ }
1891
+ return null;
1892
+ }
1893
+ /** UI-ASSERT-STRUCT-002 — UiAssert steps must not contain a (hallucinated) `<generatedParameters>` element. */
1894
+ function validateUiAssertHallucinatedGeneratedParameters(tc, rule) {
1895
+ const offenders = [];
1896
+ for (const call of getAllApiCalls(tc)) {
1897
+ if (call['@_apiId'] !== UI_ASSERT_API_ID || call['generatedParameters'] == null)
1898
+ continue;
1899
+ offenders.push(stepContext(call));
1900
+ }
1901
+ if (!offenders.length)
1902
+ return null;
1903
+ const f = offenders[0];
1904
+ return makeViolation(rule, `UiAssert step '${f.title}' contains a hallucinated <generatedParameters> element — UiAssert steps never ` +
1905
+ `contain generatedParameters; remove the entire section (testItemId=${f.tid})`, offenders.length);
1906
+ }
1907
+ // UI-ASSERT-STRUCT-001 — arguments every UiAssert step must declare (even if empty).
1908
+ const UI_ASSERT_REQUIRED_ARGS = [
1909
+ 'fieldAssertions',
1910
+ 'columnAssertions',
1911
+ 'pageAssertions',
1912
+ 'resultScope',
1913
+ 'captureAfter',
1914
+ 'beforeWait',
1915
+ 'autoRetry',
1916
+ ];
1917
+ /** UI-ASSERT-STRUCT-001 — a UiAssert step is missing one or more of its required arguments. */
1918
+ function validateUiAssertMissingArguments(tc, rule) {
1919
+ const offenders = [];
1920
+ for (const call of getAllApiCalls(tc)) {
1921
+ if (call['@_apiId'] !== UI_ASSERT_API_ID)
1922
+ continue;
1923
+ const argsNode = call['arguments'];
1924
+ let missing;
1925
+ if (!argsNode || typeof argsNode !== 'object') {
1926
+ missing = [...UI_ASSERT_REQUIRED_ARGS];
1927
+ }
1928
+ else {
1929
+ const existing = new Set();
1930
+ for (const a of getCallArguments(call)) {
1931
+ const id = a['@_id'];
1932
+ if (id)
1933
+ existing.add(id);
1934
+ }
1935
+ missing = UI_ASSERT_REQUIRED_ARGS.filter((r) => !existing.has(r));
1936
+ }
1937
+ if (missing.length)
1938
+ offenders.push({ ctx: stepContext(call), missing });
1939
+ }
1940
+ if (!offenders.length)
1941
+ return null;
1942
+ const f = offenders[0];
1943
+ return makeViolation(rule, `UiAssert step '${f.ctx.title}' is missing required arguments: ${f.missing.join(', ')}. All UiAssert steps ` +
1944
+ 'must include fieldAssertions, columnAssertions, pageAssertions, resultScope, captureAfter, beforeWait, and ' +
1945
+ `autoRetry (even if empty) (testItemId=${f.ctx.tid})`, offenders.length);
1946
+ }
1947
+ // UI-BINDING-ORDER-001 — binding URIs must list object= before action=/field= (percent-encoded).
1948
+ const BINDING_WRONG_ACTION_FIRST = /object%3Faction%3D[^%]+%26object%3D/;
1949
+ const BINDING_WRONG_FIELD_FIRST = /object%3Ffield%3D[^%]+%26object%3D/;
1950
+ const BINDING_ACTION_EXTRACT = /action%3D([^%&]+)%26object%3D([^%&"]+)/;
1951
+ const BINDING_FIELD_EXTRACT = /field%3D([^%&]+)%26object%3D([^%&"]+)/;
1952
+ /** Classify a binding URI's parameter order, returning the wrong/correct pair or null when fine. */
1953
+ function classifyBindingOrder(uri) {
1954
+ if (BINDING_WRONG_ACTION_FIRST.test(uri)) {
1955
+ const m = BINDING_ACTION_EXTRACT.exec(uri);
1956
+ if (m) {
1957
+ const o = m[2].replace(/&amp;/g, '').replace(/&/g, '');
1958
+ return { wrong: `action=${m[1]}&object=${o}`, correct: `object=${o}&action=${m[1]}` };
1959
+ }
1960
+ }
1961
+ else if (BINDING_WRONG_FIELD_FIRST.test(uri)) {
1962
+ const m = BINDING_FIELD_EXTRACT.exec(uri);
1963
+ if (m) {
1964
+ const o = m[2].replace(/&amp;/g, '').replace(/&/g, '');
1965
+ return { wrong: `field=${m[1]}&object=${o}`, correct: `object=${o}&field=${m[1]}` };
1966
+ }
1967
+ }
1968
+ return null;
1969
+ }
1970
+ /** UI-BINDING-ORDER-001 — a `uiLocator` binding lists object= after action=/field= (non-standard order). */
1971
+ function validateBindingParameterOrder(tc, rule) {
1972
+ const seen = new Set();
1973
+ const offenders = [];
1974
+ for (const call of getAllApiCalls(tc)) {
1975
+ const ctx = stepContext(call);
1976
+ for (const v of collectValueElements(call)) {
1977
+ if (v['@_class'] !== 'uiLocator' || seen.has(v))
1978
+ continue;
1979
+ seen.add(v);
1980
+ const uri = v['@_uri'] ?? '';
1981
+ if (!uri || !uri.includes('binding='))
1982
+ continue;
1983
+ const verdict = classifyBindingOrder(uri);
1984
+ if (verdict)
1985
+ offenders.push({ ctx, ...verdict });
1986
+ }
1987
+ }
1988
+ if (!offenders.length)
1989
+ return null;
1990
+ const f = offenders[0];
1991
+ return makeViolation(rule, `UI binding in step '${f.ctx.title}' lists parameters in a non-standard order: found '${f.wrong}', the ` +
1992
+ `corpus-majority convention lists object= first ('${f.correct}'). (testItemId=${f.ctx.tid})`, offenders.length);
1993
+ }
1994
+ // UI-CONN-LITERAL-001 — UI step types whose uiConnectionName must be a literal, not a variable.
1995
+ const UI_CONN_LITERAL_APIS = new Set([
1996
+ 'com.provar.plugins.forcedotcom.core.ui.UiWithScreen',
1997
+ 'com.provar.plugins.forcedotcom.core.ui.UiDoAction',
1998
+ 'com.provar.plugins.forcedotcom.core.ui.UiAssert',
1999
+ 'com.provar.plugins.forcedotcom.core.ui.UiWithRow',
2000
+ ]);
2001
+ /** UI-CONN-LITERAL-001 — a UI step's `uiConnectionName` uses a `class="variable"` value instead of a literal. */
2002
+ function validateUiConnectionNameLiteral(tc, rule) {
2003
+ for (const call of getAllApiCalls(tc)) {
2004
+ if (!UI_CONN_LITERAL_APIS.has(call['@_apiId']))
2005
+ continue;
2006
+ const v = directArgValueElement(call, 'uiConnectionName');
2007
+ if (!v || v['@_class'] !== 'variable')
2008
+ continue;
2009
+ const ctx = stepContext(call);
2010
+ return makeViolation(rule, `UI step '${ctx.title}' uses a variable reference for uiConnectionName; it must be a literal string ` +
2011
+ `(testItemId=${ctx.tid})`);
2012
+ }
2013
+ return null;
2014
+ }
982
2015
  const VALIDATOR_REGISTRY = {
983
2016
  unknownApiId: validateUnknownApiId,
984
2017
  validIdentifier: validateValidIdentifier,
@@ -991,6 +2024,33 @@ const VALIDATOR_REGISTRY = {
991
2024
  detectDuplicatesLiterals: validateDetectDuplicatesLiterals,
992
2025
  uniqueResultNames: validateUniqueResultNames,
993
2026
  uiWithScreenTarget: validateUiWithScreenTarget,
2027
+ mustContainArgument: validateMustContainArgument,
2028
+ // Tier 2 — render / load-blocking check types (ports of the QH load-blocking validators)
2029
+ valueClassCasing: validateValueClassCasing,
2030
+ booleanCasing: validateBooleanCasing,
2031
+ invalidValueClass: validateInvalidValueClass,
2032
+ dateValueClassFormat: validateDateValueClassFormat,
2033
+ apexConnectReuseConnection: validateApexConnectReuseConnection,
2034
+ apexConnectValidArguments: validateApexConnectValidArguments,
2035
+ apexConnectConnectionIdValueClass: validateApexConnectConnectionIdValueClass,
2036
+ setValuesInvalidElements: validateSetValuesInvalidElements,
2037
+ // Tier 4 — back-end-only rules (single-violation ports)
2038
+ dbConnectResultNameMismatch: validateDbConnectResultNameMismatch,
2039
+ setValuesFuncCallString: validateSetValuesFuncCallString,
2040
+ setValuesZeroIndexString: validateSetValuesZeroIndexString,
2041
+ assertValuesStringExpr: validateAssertValuesStringExpr,
2042
+ uiLocatorButtonCasing: validateUiLocatorButtonCasing,
2043
+ uiLocatorRecordTypeField: validateUiLocatorRecordTypeField,
2044
+ // Tier 5 — structural / load-affecting check types
2045
+ validFuncCallId: validateValidFuncCallId,
2046
+ rootAttributes: validateRootAttributes,
2047
+ setValuesStructure: validateSetValuesStructure,
2048
+ namedValueName: validateNamedValueName,
2049
+ namedValueValue: validateNamedValueValue,
2050
+ uiAssertHallucinatedGeneratedParameters: validateUiAssertHallucinatedGeneratedParameters,
2051
+ uiAssertMissingArguments: validateUiAssertMissingArguments,
2052
+ bindingParameterOrder: validateBindingParameterOrder,
2053
+ uiConnectionNameLiteral: validateUiConnectionNameLiteral,
994
2054
  // 'regex' is dispatched separately (needs metadata)
995
2055
  // 'uiActionNestingStructure' is dispatched separately (emits one violation per offending step)
996
2056
  };
@@ -998,6 +2058,8 @@ const MULTI_VALIDATOR_REGISTRY = {
998
2058
  uiActionNestingStructure: validateUiActionNestingStructure,
999
2059
  nitroxConnectInvalidArgs: validateNitroxConnectInvalidArgs,
1000
2060
  nitroxVariantArgRequired: validateNitroxVariantArgRequired,
2061
+ // Tier 4 — emits one violation per offending value (back-end returns a list)
2062
+ varStringLiteral: validateVarStringLiteral,
1001
2063
  };
1002
2064
  // ── XML parser (shared settings) ─────────────────────────────────────────────
1003
2065
  const XML_PARSER = new XMLParser({