@provartesting/provardx-cli 1.5.0-dev.2 → 1.5.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 (129) hide show
  1. package/README.md +163 -12
  2. package/bin/mcp-start.js +74 -0
  3. package/lib/commands/provar/auth/clear.d.ts +7 -0
  4. package/lib/commands/provar/auth/clear.js +36 -0
  5. package/lib/commands/provar/auth/clear.js.map +1 -0
  6. package/lib/commands/provar/auth/login.d.ts +10 -0
  7. package/lib/commands/provar/auth/login.js +90 -0
  8. package/lib/commands/provar/auth/login.js.map +1 -0
  9. package/lib/commands/provar/auth/rotate.d.ts +7 -0
  10. package/lib/commands/provar/auth/rotate.js +42 -0
  11. package/lib/commands/provar/auth/rotate.js.map +1 -0
  12. package/lib/commands/provar/auth/status.d.ts +7 -0
  13. package/lib/commands/provar/auth/status.js +107 -0
  14. package/lib/commands/provar/auth/status.js.map +1 -0
  15. package/lib/commands/provar/mcp/start.d.ts +2 -0
  16. package/lib/commands/provar/mcp/start.js +14 -1
  17. package/lib/commands/provar/mcp/start.js.map +1 -1
  18. package/lib/mcp/docs/NITROX_CATALOG_SOURCE.json +6 -0
  19. package/lib/mcp/docs/NITROX_COMPONENT_CATALOG.md +2001 -0
  20. package/lib/mcp/docs/PROVAR_TEST_STEP_REFERENCE.md +1430 -0
  21. package/lib/mcp/docs/PROVAR_TOOL_GUIDE.md +175 -0
  22. package/lib/mcp/licensing/algasClient.js +14 -5
  23. package/lib/mcp/licensing/algasClient.js.map +1 -1
  24. package/lib/mcp/licensing/ideDetection.d.ts +0 -12
  25. package/lib/mcp/licensing/ideDetection.js +1 -73
  26. package/lib/mcp/licensing/ideDetection.js.map +1 -1
  27. package/lib/mcp/licensing/licenseCache.js +7 -1
  28. package/lib/mcp/licensing/licenseCache.js.map +1 -1
  29. package/lib/mcp/licensing/licenseValidator.d.ts +3 -3
  30. package/lib/mcp/licensing/licenseValidator.js +11 -4
  31. package/lib/mcp/licensing/licenseValidator.js.map +1 -1
  32. package/lib/mcp/prompts/guidePrompts.d.ts +4 -0
  33. package/lib/mcp/prompts/guidePrompts.js +324 -0
  34. package/lib/mcp/prompts/guidePrompts.js.map +1 -0
  35. package/lib/mcp/prompts/index.d.ts +2 -0
  36. package/lib/mcp/prompts/index.js +23 -0
  37. package/lib/mcp/prompts/index.js.map +1 -0
  38. package/lib/mcp/prompts/loopPrompts.d.ts +6 -0
  39. package/lib/mcp/prompts/loopPrompts.js +435 -0
  40. package/lib/mcp/prompts/loopPrompts.js.map +1 -0
  41. package/lib/mcp/prompts/migrationPrompts.d.ts +4 -0
  42. package/lib/mcp/prompts/migrationPrompts.js +207 -0
  43. package/lib/mcp/prompts/migrationPrompts.js.map +1 -0
  44. package/lib/mcp/rules/provar_best_practices_rules.json +256 -544
  45. package/lib/mcp/security/pathPolicy.d.ts +5 -0
  46. package/lib/mcp/security/pathPolicy.js +58 -3
  47. package/lib/mcp/security/pathPolicy.js.map +1 -1
  48. package/lib/mcp/server.d.ts +17 -0
  49. package/lib/mcp/server.js +151 -6
  50. package/lib/mcp/server.js.map +1 -1
  51. package/lib/mcp/tools/antTools.d.ts +15 -0
  52. package/lib/mcp/tools/antTools.js +347 -170
  53. package/lib/mcp/tools/antTools.js.map +1 -1
  54. package/lib/mcp/tools/automationTools.d.ts +18 -8
  55. package/lib/mcp/tools/automationTools.js +332 -176
  56. package/lib/mcp/tools/automationTools.js.map +1 -1
  57. package/lib/mcp/tools/bestPracticesEngine.js +161 -23
  58. package/lib/mcp/tools/bestPracticesEngine.js.map +1 -1
  59. package/lib/mcp/tools/connectionTools.d.ts +4 -0
  60. package/lib/mcp/tools/connectionTools.js +172 -0
  61. package/lib/mcp/tools/connectionTools.js.map +1 -0
  62. package/lib/mcp/tools/defectTools.d.ts +1 -1
  63. package/lib/mcp/tools/defectTools.js +56 -50
  64. package/lib/mcp/tools/defectTools.js.map +1 -1
  65. package/lib/mcp/tools/hierarchyValidate.d.ts +1 -1
  66. package/lib/mcp/tools/hierarchyValidate.js +127 -42
  67. package/lib/mcp/tools/hierarchyValidate.js.map +1 -1
  68. package/lib/mcp/tools/nitroXTools.d.ts +23 -0
  69. package/lib/mcp/tools/nitroXTools.js +823 -0
  70. package/lib/mcp/tools/nitroXTools.js.map +1 -0
  71. package/lib/mcp/tools/pageObjectGenerate.js +132 -57
  72. package/lib/mcp/tools/pageObjectGenerate.js.map +1 -1
  73. package/lib/mcp/tools/pageObjectValidate.js +136 -46
  74. package/lib/mcp/tools/pageObjectValidate.js.map +1 -1
  75. package/lib/mcp/tools/projectInspect.js +51 -30
  76. package/lib/mcp/tools/projectInspect.js.map +1 -1
  77. package/lib/mcp/tools/projectValidateFromPath.js +70 -49
  78. package/lib/mcp/tools/projectValidateFromPath.js.map +1 -1
  79. package/lib/mcp/tools/propertiesTools.d.ts +2 -0
  80. package/lib/mcp/tools/propertiesTools.js +332 -78
  81. package/lib/mcp/tools/propertiesTools.js.map +1 -1
  82. package/lib/mcp/tools/qualityHubApiTools.d.ts +3 -0
  83. package/lib/mcp/tools/qualityHubApiTools.js +138 -0
  84. package/lib/mcp/tools/qualityHubApiTools.js.map +1 -0
  85. package/lib/mcp/tools/qualityHubTools.js +219 -70
  86. package/lib/mcp/tools/qualityHubTools.js.map +1 -1
  87. package/lib/mcp/tools/rcaTools.d.ts +3 -2
  88. package/lib/mcp/tools/rcaTools.js +189 -56
  89. package/lib/mcp/tools/rcaTools.js.map +1 -1
  90. package/lib/mcp/tools/sfSpawn.d.ts +25 -3
  91. package/lib/mcp/tools/sfSpawn.js +154 -6
  92. package/lib/mcp/tools/sfSpawn.js.map +1 -1
  93. package/lib/mcp/tools/testCaseGenerate.js +226 -78
  94. package/lib/mcp/tools/testCaseGenerate.js.map +1 -1
  95. package/lib/mcp/tools/testCaseStepTools.d.ts +4 -0
  96. package/lib/mcp/tools/testCaseStepTools.js +226 -0
  97. package/lib/mcp/tools/testCaseStepTools.js.map +1 -0
  98. package/lib/mcp/tools/testCaseValidate.d.ts +11 -0
  99. package/lib/mcp/tools/testCaseValidate.js +300 -44
  100. package/lib/mcp/tools/testCaseValidate.js.map +1 -1
  101. package/lib/mcp/tools/testPlanTools.d.ts +1 -0
  102. package/lib/mcp/tools/testPlanTools.js +299 -59
  103. package/lib/mcp/tools/testPlanTools.js.map +1 -1
  104. package/lib/mcp/tools/testPlanValidate.js +56 -18
  105. package/lib/mcp/tools/testPlanValidate.js.map +1 -1
  106. package/lib/mcp/tools/testSuiteValidate.js +37 -11
  107. package/lib/mcp/tools/testSuiteValidate.js.map +1 -1
  108. package/lib/mcp/update/updateChecker.d.ts +14 -0
  109. package/lib/mcp/update/updateChecker.js +228 -0
  110. package/lib/mcp/update/updateChecker.js.map +1 -0
  111. package/lib/services/auth/credentials.d.ts +21 -0
  112. package/lib/services/auth/credentials.js +75 -0
  113. package/lib/services/auth/credentials.js.map +1 -0
  114. package/lib/services/auth/loginFlow.d.ts +68 -0
  115. package/lib/services/auth/loginFlow.js +216 -0
  116. package/lib/services/auth/loginFlow.js.map +1 -0
  117. package/lib/services/projectValidation.d.ts +5 -2
  118. package/lib/services/projectValidation.js +83 -31
  119. package/lib/services/projectValidation.js.map +1 -1
  120. package/lib/services/qualityHub/client.d.ts +161 -0
  121. package/lib/services/qualityHub/client.js +226 -0
  122. package/lib/services/qualityHub/client.js.map +1 -0
  123. package/messages/sf.provar.auth.clear.md +16 -0
  124. package/messages/sf.provar.auth.login.md +31 -0
  125. package/messages/sf.provar.auth.rotate.md +23 -0
  126. package/messages/sf.provar.auth.status.md +16 -0
  127. package/messages/sf.provar.mcp.start.md +83 -48
  128. package/oclif.manifest.json +325 -28
  129. package/package.json +23 -12
@@ -0,0 +1,823 @@
1
+ /*
2
+ * Copyright (c) 2024 Provar Limited.
3
+ * All rights reserved.
4
+ * Licensed under the BSD 3-Clause license.
5
+ * For full license text, see LICENSE.md file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
+ */
7
+ /* eslint-disable camelcase */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+ import { randomUUID } from 'node:crypto';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { Ajv2020 } from 'ajv/dist/2020.js';
14
+ import { z } from 'zod';
15
+ import { assertPathAllowed, PathPolicyError } from '../security/pathPolicy.js';
16
+ import { makeError, makeRequestId } from '../schemas/common.js';
17
+ import { log } from '../logging/logger.js';
18
+ function isObj(v) {
19
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
20
+ }
21
+ // ── AJV Schema Validator ──────────────────────────────────────────────────────
22
+ const RULES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'rules');
23
+ let cachedFactComponentValidator;
24
+ function getFactComponentValidator() {
25
+ if (cachedFactComponentValidator !== undefined)
26
+ return cachedFactComponentValidator;
27
+ const schemaPath = path.join(RULES_DIR, 'FactComponent.schema.json');
28
+ try {
29
+ // Fix known broken $ref in the bundled schema (#/defs/ → #/$defs/)
30
+ const patched = fs.readFileSync(schemaPath, 'utf-8').replace(/"#\/defs\//g, '"#/$defs/');
31
+ const schema = JSON.parse(patched);
32
+ const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
33
+ cachedFactComponentValidator = ajv.compile(schema);
34
+ }
35
+ catch (e) {
36
+ log('warn', 'provar_nitrox_validate: FactComponent schema unavailable, using hardcoded rules only', {
37
+ error: String(e),
38
+ });
39
+ cachedFactComponentValidator = null;
40
+ }
41
+ return cachedFactComponentValidator;
42
+ }
43
+ function ajvErrorToIssue(err) {
44
+ const keyword = err.keyword.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase();
45
+ const instancePath = err.instancePath;
46
+ // For additionalProperties/required, instancePath points to the parent object;
47
+ // the actual property name is in err.params.
48
+ const params = err.params;
49
+ const extraProp = err.keyword === 'additionalProperties' ? params['additionalProperty'] : undefined;
50
+ const missingProp = err.keyword === 'required' ? params['missingProperty'] : undefined;
51
+ const leafProp = extraProp ?? missingProp;
52
+ const basePath = instancePath ? instancePath.replace(/^\//, '').replace(/\//g, '.') : 'root';
53
+ const appliesTo = leafProp ? (basePath === 'root' ? leafProp : `${basePath}.${leafProp}`) : basePath;
54
+ const pathParts = instancePath.split('/').filter(Boolean);
55
+ const severity = ['REQUIRED', 'TYPE'].includes(keyword) ? 'ERROR' : 'WARNING';
56
+ const issue = {
57
+ rule_id: `NX_SCHEMA_${keyword}`,
58
+ severity,
59
+ message: `Schema: ${instancePath || 'root'} — ${err.message ?? 'validation failed'}`,
60
+ applies_to: appliesTo,
61
+ };
62
+ issue.field = leafProp ?? (pathParts.length > 0 ? pathParts[pathParts.length - 1] : undefined);
63
+ return issue;
64
+ }
65
+ // ── Directory Utilities ───────────────────────────────────────────────────────
66
+ const SKIP_DIRS = new Set(['node_modules', '.git']);
67
+ /**
68
+ * Recursively walk directories looking for .testproject marker files.
69
+ * Skips node_modules, .git, and hidden dirs (names starting with '.').
70
+ */
71
+ function findProvarProjects(roots, maxDepth) {
72
+ const projects = [];
73
+ function walk(dir, depth) {
74
+ if (depth > maxDepth)
75
+ return;
76
+ if (fs.existsSync(path.join(dir, '.testproject'))) {
77
+ projects.push(dir);
78
+ return; // don't recurse into a found project
79
+ }
80
+ let entries;
81
+ try {
82
+ entries = fs.readdirSync(dir, { withFileTypes: true });
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ for (const entry of entries) {
88
+ if (!entry.isDirectory())
89
+ continue;
90
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name))
91
+ continue;
92
+ walk(path.join(dir, entry.name), depth + 1);
93
+ }
94
+ }
95
+ for (const root of roots) {
96
+ try {
97
+ if (!fs.existsSync(root))
98
+ continue;
99
+ walk(root, 0);
100
+ }
101
+ catch {
102
+ // Root inaccessible — skip gracefully
103
+ }
104
+ }
105
+ return projects;
106
+ }
107
+ /** Collect all *.po.json files under a directory, recursively. */
108
+ function collectPoJsonFiles(dir) {
109
+ const files = [];
110
+ function walk(d) {
111
+ let entries;
112
+ try {
113
+ entries = fs.readdirSync(d, { withFileTypes: true });
114
+ }
115
+ catch {
116
+ return;
117
+ }
118
+ for (const entry of entries) {
119
+ if (entry.isDirectory()) {
120
+ walk(path.join(d, entry.name));
121
+ }
122
+ else if (entry.isFile() && entry.name.endsWith('.po.json')) {
123
+ files.push(path.join(d, entry.name));
124
+ }
125
+ }
126
+ }
127
+ walk(dir);
128
+ return files;
129
+ }
130
+ // ── NitroX Validator ─────────────────────────────────────────────────────────
131
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
132
+ const VALID_COMPARISON_TYPES = ['equals', 'starts-with', 'contains'];
133
+ const INTERACTION_NAME_RE = /^[A-Za-z0-9\s]*$/;
134
+ /** Validate root-level scalar properties (NX001, NX002, NX003, NX010). */
135
+ function validateRootProperties(obj, issues) {
136
+ // NX001: componentId must be present and a valid UUID
137
+ if (obj['componentId'] === undefined || obj['componentId'] === null) {
138
+ issues.push({
139
+ rule_id: 'NX001',
140
+ severity: 'ERROR',
141
+ message: 'componentId is required.',
142
+ applies_to: 'root',
143
+ field: 'componentId',
144
+ });
145
+ }
146
+ else if (typeof obj['componentId'] !== 'string' || !UUID_RE.test(obj['componentId'])) {
147
+ issues.push({
148
+ rule_id: 'NX001',
149
+ severity: 'ERROR',
150
+ message: `componentId must be a valid UUID, got: "${String(obj['componentId'])}".`,
151
+ applies_to: 'root',
152
+ field: 'componentId',
153
+ });
154
+ }
155
+ // NX002: Root (no parentId) requires name, type, pageStructureElement, fieldDetailsElement
156
+ const hasParentId = obj['parentId'] !== undefined && obj['parentId'] !== null;
157
+ if (!hasParentId) {
158
+ for (const field of ['name', 'type', 'pageStructureElement', 'fieldDetailsElement']) {
159
+ if (obj[field] === undefined || obj[field] === null) {
160
+ issues.push({
161
+ rule_id: 'NX002',
162
+ severity: 'ERROR',
163
+ message: `Root component requires "${field}".`,
164
+ applies_to: 'root',
165
+ field,
166
+ suggestion: `Add a "${field}" property to the root component object.`,
167
+ });
168
+ }
169
+ }
170
+ }
171
+ // NX003: tagName must not contain whitespace
172
+ if (typeof obj['tagName'] === 'string' && /\s/.test(obj['tagName'])) {
173
+ issues.push({
174
+ rule_id: 'NX003',
175
+ severity: 'ERROR',
176
+ message: 'tagName should not contain spaces.',
177
+ applies_to: 'root',
178
+ field: 'tagName',
179
+ suggestion: 'Remove whitespace from tagName.',
180
+ });
181
+ }
182
+ // NX010: bodyTagName (if present) must not contain whitespace
183
+ if (typeof obj['bodyTagName'] === 'string' && /\s/.test(obj['bodyTagName'])) {
184
+ issues.push({
185
+ rule_id: 'NX010',
186
+ severity: 'INFO',
187
+ message: 'bodyTagName should not contain spaces.',
188
+ applies_to: 'root',
189
+ field: 'bodyTagName',
190
+ suggestion: 'Remove whitespace from bodyTagName.',
191
+ });
192
+ }
193
+ }
194
+ /** Validate a parsed NitroX .po.json against hardcoded NX rules and the FactComponent JSON schema. */
195
+ export function validateNitroXContent(obj, schemaOverride) {
196
+ const issues = [];
197
+ validateRootProperties(obj, issues);
198
+ if (Array.isArray(obj['parameters'])) {
199
+ for (const param of obj['parameters']) {
200
+ if (isObj(param))
201
+ validateParameter(param, 'root', issues);
202
+ }
203
+ }
204
+ if (Array.isArray(obj['interactions'])) {
205
+ for (const interaction of obj['interactions']) {
206
+ if (isObj(interaction))
207
+ validateInteraction(interaction, 'root', issues);
208
+ }
209
+ }
210
+ if (Array.isArray(obj['selectors'])) {
211
+ for (const sel of obj['selectors']) {
212
+ if (isObj(sel))
213
+ validateSelector(sel, issues);
214
+ }
215
+ }
216
+ if (Array.isArray(obj['elements'])) {
217
+ for (const el of obj['elements']) {
218
+ if (isObj(el))
219
+ validateElement(el, issues);
220
+ }
221
+ }
222
+ // AJV schema validation runs additively alongside NX001–NX010
223
+ const validator = schemaOverride === undefined ? getFactComponentValidator() : schemaOverride;
224
+ if (validator) {
225
+ validator(obj);
226
+ for (const err of validator.errors ?? []) {
227
+ issues.push(ajvErrorToIssue(err));
228
+ }
229
+ }
230
+ const errorCount = issues.filter((i) => i.severity === 'ERROR').length;
231
+ const warningCount = issues.filter((i) => i.severity === 'WARNING').length;
232
+ const infoCount = issues.filter((i) => i.severity === 'INFO').length;
233
+ const score = Math.max(0, 100 - 20 * errorCount - 5 * warningCount - 1 * infoCount);
234
+ return { valid: errorCount === 0, score, issue_count: issues.length, issues };
235
+ }
236
+ function validateElement(el, issues) {
237
+ // NX007: Element should have type
238
+ if (!el['type']) {
239
+ issues.push({
240
+ rule_id: 'NX007',
241
+ severity: 'WARNING',
242
+ message: 'Element is missing required "type".',
243
+ applies_to: 'element',
244
+ suggestion: 'Add a "type" field to the element (e.g. "content" or "component::UUID").',
245
+ });
246
+ }
247
+ if (Array.isArray(el['selectors'])) {
248
+ for (const sel of el['selectors']) {
249
+ if (isObj(sel))
250
+ validateSelector(sel, issues);
251
+ }
252
+ }
253
+ if (Array.isArray(el['interactions'])) {
254
+ for (const interaction of el['interactions']) {
255
+ if (isObj(interaction))
256
+ validateInteraction(interaction, 'element', issues);
257
+ }
258
+ }
259
+ if (Array.isArray(el['parameters'])) {
260
+ for (const param of el['parameters']) {
261
+ if (isObj(param))
262
+ validateParameter(param, 'element', issues);
263
+ }
264
+ }
265
+ if (Array.isArray(el['elements'])) {
266
+ for (const nested of el['elements']) {
267
+ if (isObj(nested))
268
+ validateElement(nested, issues);
269
+ }
270
+ }
271
+ }
272
+ function validateInteraction(interaction, context, issues) {
273
+ // NX004: required fields
274
+ for (const field of ['defaultInteraction', 'interactionType', 'name', 'testStepTitlePattern', 'title']) {
275
+ if (interaction[field] === undefined || interaction[field] === null) {
276
+ issues.push({
277
+ rule_id: 'NX004',
278
+ severity: 'ERROR',
279
+ message: `Interaction in ${context} missing required field "${field}".`,
280
+ applies_to: 'interaction',
281
+ field,
282
+ });
283
+ }
284
+ }
285
+ if (!Array.isArray(interaction['implementations']) || interaction['implementations'].length === 0) {
286
+ issues.push({
287
+ rule_id: 'NX004',
288
+ severity: 'ERROR',
289
+ message: `Interaction in ${context} must have at least one implementation.`,
290
+ applies_to: 'interaction',
291
+ field: 'implementations',
292
+ });
293
+ }
294
+ else {
295
+ for (const impl of interaction['implementations']) {
296
+ if (isObj(impl))
297
+ validateImplementation(impl, context, issues);
298
+ }
299
+ }
300
+ // NX009: name should match ^[A-Za-z0-9\s]*$
301
+ if (typeof interaction['name'] === 'string' && !INTERACTION_NAME_RE.test(interaction['name'])) {
302
+ issues.push({
303
+ rule_id: 'NX009',
304
+ severity: 'INFO',
305
+ message: `Interaction name "${interaction['name']}" should contain only alphanumeric characters and spaces.`,
306
+ applies_to: 'interaction',
307
+ field: 'name',
308
+ suggestion: 'Remove special characters from the interaction name.',
309
+ });
310
+ }
311
+ }
312
+ function validateImplementation(impl, context, issues) {
313
+ // NX005: must have javaScriptSnippet
314
+ if (!impl['javaScriptSnippet']) {
315
+ issues.push({
316
+ rule_id: 'NX005',
317
+ severity: 'ERROR',
318
+ message: `Implementation in ${context} missing required "javaScriptSnippet".`,
319
+ applies_to: 'implementation',
320
+ field: 'javaScriptSnippet',
321
+ });
322
+ }
323
+ }
324
+ function validateSelector(sel, issues) {
325
+ // NX006: must have xpath
326
+ if (!sel['xpath']) {
327
+ issues.push({
328
+ rule_id: 'NX006',
329
+ severity: 'ERROR',
330
+ message: 'Selector missing required "xpath".',
331
+ applies_to: 'selector',
332
+ field: 'xpath',
333
+ suggestion: 'Add an "xpath" property to the selector.',
334
+ });
335
+ }
336
+ }
337
+ function validateParameter(param, context, issues) {
338
+ // NX008: comparisonType must be one of valid enum values
339
+ if (param['comparisonType'] !== undefined && !VALID_COMPARISON_TYPES.includes(String(param['comparisonType']))) {
340
+ issues.push({
341
+ rule_id: 'NX008',
342
+ severity: 'WARNING',
343
+ message: `Parameter in ${context} has invalid comparisonType "${String(param['comparisonType'])}". Must be one of: ${VALID_COMPARISON_TYPES.join(', ')}.`,
344
+ applies_to: 'parameter',
345
+ field: 'comparisonType',
346
+ suggestion: `Use one of: ${VALID_COMPARISON_TYPES.join(', ')}`,
347
+ });
348
+ }
349
+ }
350
+ function buildNitroXJson(input) {
351
+ const result = {
352
+ componentId: randomUUID(),
353
+ name: input.name,
354
+ tagName: input.tag_name,
355
+ type: input.type,
356
+ pageStructureElement: input.page_structure_element,
357
+ fieldDetailsElement: input.field_details_element,
358
+ };
359
+ if (input.parameters && input.parameters.length > 0) {
360
+ result['parameters'] = input.parameters.map((p, i) => ({
361
+ name: p.name,
362
+ value: p.value,
363
+ ...(p.comparisonType !== undefined && { comparisonType: p.comparisonType }),
364
+ ...(p.default !== undefined && { default: p.default }),
365
+ index: i,
366
+ }));
367
+ }
368
+ if (input.elements && input.elements.length > 0) {
369
+ result['elements'] = input.elements.map((el) => buildElement(el));
370
+ }
371
+ return result;
372
+ }
373
+ function buildElement(el) {
374
+ const element = {
375
+ componentId: randomUUID(),
376
+ type: el.type_ref,
377
+ label: el.label,
378
+ };
379
+ if (el.tag_name) {
380
+ element['elementTagName'] = el.tag_name;
381
+ }
382
+ if (el.parameters && el.parameters.length > 0) {
383
+ element['parameters'] = el.parameters.map((p, i) => ({
384
+ name: p.name,
385
+ value: p.value,
386
+ ...(p.comparisonType !== undefined && { comparisonType: p.comparisonType }),
387
+ ...(p.default !== undefined && { default: p.default }),
388
+ index: i,
389
+ }));
390
+ }
391
+ if (el.selector_xpath) {
392
+ element['selectors'] = [{ xpath: el.selector_xpath }];
393
+ }
394
+ return element;
395
+ }
396
+ // ── RFC 7396 Merge-Patch ──────────────────────────────────────────────────────
397
+ function applyMergePatch(target, patch) {
398
+ const result = { ...target };
399
+ for (const [key, value] of Object.entries(patch)) {
400
+ if (value === null) {
401
+ delete result[key];
402
+ }
403
+ else if (isObj(value) && isObj(result[key])) {
404
+ result[key] = applyMergePatch(result[key], value);
405
+ }
406
+ else {
407
+ result[key] = value;
408
+ }
409
+ }
410
+ return result;
411
+ }
412
+ // ── Tool Registrations ────────────────────────────────────────────────────────
413
+ export function registerNitroXDiscover(server) {
414
+ server.registerTool('provar_nitrox_discover', {
415
+ title: 'Discover NitroX Components',
416
+ description: [
417
+ 'Discover Provar projects containing NitroX (Hybrid Model) page objects.',
418
+ 'Scans directories for .testproject marker files, then inventories nitroX/ and nitroXPackages/ directories.',
419
+ "NitroX is Provar's Hybrid Model for locators — component-based page objects for LWC,",
420
+ 'Screen Flow, Industry Components, Experience Cloud, and HTML5 components.',
421
+ 'Results provide file paths and package info for use with provar_nitrox_read, validate, and generate.',
422
+ ].join(' '),
423
+ inputSchema: {
424
+ search_roots: z
425
+ .array(z.string())
426
+ .optional()
427
+ .describe('Directories to scan (default: cwd; if empty, falls back to ~/git and ~/Provar)'),
428
+ max_depth: z
429
+ .number()
430
+ .int()
431
+ .min(1)
432
+ .max(20)
433
+ .default(6)
434
+ .describe('Maximum directory depth for .testproject search'),
435
+ include_packages: z
436
+ .boolean()
437
+ .default(true)
438
+ .describe('Include nitroXPackages/ package.json metadata in results'),
439
+ },
440
+ }, ({ search_roots, max_depth, include_packages }) => {
441
+ const requestId = makeRequestId();
442
+ log('info', 'provar_nitrox_discover', { requestId, search_roots, max_depth });
443
+ try {
444
+ let roots = search_roots && search_roots.length > 0 ? search_roots : [process.cwd()];
445
+ let projects = findProvarProjects(roots, max_depth);
446
+ // If no .testproject found in cwd, widen to home-dir defaults
447
+ if (projects.length === 0 && (!search_roots || search_roots.length === 0)) {
448
+ const fallbackRoots = [path.join(os.homedir(), 'git'), path.join(os.homedir(), 'Provar')];
449
+ const fallbackProjects = findProvarProjects(fallbackRoots, max_depth);
450
+ if (fallbackProjects.length > 0) {
451
+ projects = fallbackProjects;
452
+ roots = fallbackRoots;
453
+ }
454
+ }
455
+ const projectResults = projects.map((projectPath) => {
456
+ const nitroxDir = path.join(projectPath, 'nitroX');
457
+ const packagesDir = path.join(projectPath, 'nitroXPackages');
458
+ const hasNitrox = fs.existsSync(nitroxDir);
459
+ const hasPackages = fs.existsSync(packagesDir);
460
+ const nitroxFiles = hasNitrox ? collectPoJsonFiles(nitroxDir) : [];
461
+ let packages = [];
462
+ if (include_packages && hasPackages) {
463
+ try {
464
+ packages = fs
465
+ .readdirSync(packagesDir, { withFileTypes: true })
466
+ .filter((e) => e.isDirectory())
467
+ .map((e) => {
468
+ const pkgDir = path.join(packagesDir, e.name);
469
+ const pkgJson = path.join(pkgDir, 'package.json');
470
+ if (!fs.existsSync(pkgJson))
471
+ return { path: pkgDir };
472
+ try {
473
+ const parsed = JSON.parse(fs.readFileSync(pkgJson, 'utf-8'));
474
+ return { path: pkgDir, name: String(parsed['name'] ?? '') };
475
+ }
476
+ catch {
477
+ return { path: pkgDir, error: 'invalid JSON' };
478
+ }
479
+ });
480
+ }
481
+ catch {
482
+ // packagesDir inaccessible — return empty packages
483
+ }
484
+ }
485
+ return {
486
+ project_path: projectPath,
487
+ nitrox_dir: hasNitrox ? nitroxDir : null,
488
+ nitrox_file_count: nitroxFiles.length,
489
+ nitrox_files: nitroxFiles,
490
+ packages_dir: hasPackages ? packagesDir : null,
491
+ packages,
492
+ };
493
+ });
494
+ const result = { requestId, projects: projectResults, searched_roots: roots };
495
+ return {
496
+ content: [{ type: 'text', text: JSON.stringify(result) }],
497
+ structuredContent: result,
498
+ };
499
+ }
500
+ catch (err) {
501
+ const error = err;
502
+ const errResult = makeError('DISCOVER_ERROR', error.message, requestId, false);
503
+ log('error', 'provar_nitrox_discover failed', { requestId, error: error.message });
504
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(errResult) }] };
505
+ }
506
+ });
507
+ }
508
+ export function registerNitroXRead(server, config) {
509
+ server.registerTool('provar_nitrox_read', {
510
+ title: 'Read NitroX Files',
511
+ description: [
512
+ 'Read one or more NitroX .po.json (Hybrid Model page object) files and return their parsed content.',
513
+ 'Use this to load examples before generating or validating.',
514
+ "Provide file_paths for specific files, or project_path to read all .po.json files from a project's nitroX/ directory.",
515
+ ].join(' '),
516
+ inputSchema: {
517
+ file_paths: z.array(z.string()).optional().describe('Specific .po.json file paths to read'),
518
+ project_path: z
519
+ .string()
520
+ .optional()
521
+ .describe('Provar project path — reads all .po.json files from nitroX/ directory'),
522
+ max_files: z
523
+ .number()
524
+ .int()
525
+ .min(1)
526
+ .max(100)
527
+ .default(20)
528
+ .describe('Maximum number of files to return (prevents context overflow)'),
529
+ },
530
+ }, ({ file_paths, project_path, max_files }) => {
531
+ const requestId = makeRequestId();
532
+ log('info', 'provar_nitrox_read', {
533
+ requestId,
534
+ file_count: file_paths?.length,
535
+ project_path,
536
+ });
537
+ try {
538
+ if (!file_paths?.length && !project_path) {
539
+ const err = makeError('MISSING_INPUT', 'Provide either file_paths or project_path.', requestId);
540
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
541
+ }
542
+ let targets = [];
543
+ if (file_paths?.length) {
544
+ targets = file_paths;
545
+ }
546
+ else if (project_path) {
547
+ assertPathAllowed(project_path, config.allowedPaths);
548
+ const resolved = path.resolve(project_path);
549
+ if (!fs.existsSync(resolved)) {
550
+ const err = makeError('FILE_NOT_FOUND', `Project path not found: ${resolved}`, requestId);
551
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
552
+ }
553
+ const nitroxDir = path.join(resolved, 'nitroX');
554
+ if (!fs.existsSync(nitroxDir)) {
555
+ const err = makeError('FILE_NOT_FOUND', `No nitroX/ directory found in: ${resolved}`, requestId);
556
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
557
+ }
558
+ targets = collectPoJsonFiles(nitroxDir);
559
+ }
560
+ const truncated = targets.length > max_files;
561
+ const toRead = targets.slice(0, max_files);
562
+ const files = toRead.map((filePath) => {
563
+ const resolved = path.resolve(filePath);
564
+ try {
565
+ assertPathAllowed(resolved, config.allowedPaths);
566
+ }
567
+ catch (e) {
568
+ const policyErr = e;
569
+ return { file_path: resolved, error: policyErr.message, content: null, size_bytes: 0 };
570
+ }
571
+ if (!fs.existsSync(resolved)) {
572
+ return { file_path: resolved, error: 'FILE_NOT_FOUND', content: null, size_bytes: 0 };
573
+ }
574
+ try {
575
+ const raw = fs.readFileSync(resolved, 'utf-8');
576
+ const content = JSON.parse(raw);
577
+ return { file_path: resolved, content, size_bytes: raw.length };
578
+ }
579
+ catch {
580
+ return { file_path: resolved, error: 'PARSE_ERROR', content: null, size_bytes: 0 };
581
+ }
582
+ });
583
+ const result = { requestId, files, truncated, total_found: targets.length };
584
+ return {
585
+ content: [{ type: 'text', text: JSON.stringify(result) }],
586
+ structuredContent: result,
587
+ };
588
+ }
589
+ catch (err) {
590
+ const error = err;
591
+ const errResult = makeError(error instanceof PathPolicyError ? error.code : 'READ_ERROR', error.message, requestId, false);
592
+ log('error', 'provar_nitrox_read failed', { requestId, error: error.message });
593
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(errResult) }] };
594
+ }
595
+ });
596
+ }
597
+ export function registerNitroXValidate(server, config) {
598
+ server.registerTool('provar_nitrox_validate', {
599
+ title: 'Validate NitroX Component',
600
+ description: [
601
+ 'Validate a NitroX .po.json (Hybrid Model component page object) against schema rules.',
602
+ 'Works for any NitroX-mapped component type: LWC, Screen Flow, Industry Components, Experience Cloud, HTML5.',
603
+ 'Runs two validation passes sequentially: hardcoded semantic rules (NX001–NX010) then JSON schema validation (NX_SCHEMA_* rule IDs).',
604
+ 'Schema issues catch structural errors not covered by NX rules: wrong property types, extra properties, enum violations.',
605
+ 'Returns a quality score (0–100) and a combined list of issues with rule IDs, severity, and suggestions.',
606
+ 'Score formula: 100 − (20 × errors) − (5 × warnings) − (1 × infos).',
607
+ ].join(' '),
608
+ inputSchema: {
609
+ content: z.string().optional().describe('JSON string of the .po.json content to validate'),
610
+ file_path: z.string().optional().describe('Path to a .po.json file to validate'),
611
+ },
612
+ }, ({ content, file_path }) => {
613
+ const requestId = makeRequestId();
614
+ log('info', 'provar_nitrox_validate', { requestId, has_content: !!content, file_path });
615
+ try {
616
+ let source = content;
617
+ if (!source && file_path) {
618
+ assertPathAllowed(file_path, config.allowedPaths);
619
+ const resolved = path.resolve(file_path);
620
+ if (!fs.existsSync(resolved)) {
621
+ const err = makeError('FILE_NOT_FOUND', `File not found: ${resolved}`, requestId);
622
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
623
+ }
624
+ source = fs.readFileSync(resolved, 'utf-8');
625
+ }
626
+ if (!source) {
627
+ const err = makeError('MISSING_INPUT', 'Provide either content or file_path.', requestId);
628
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
629
+ }
630
+ let parsed;
631
+ try {
632
+ parsed = JSON.parse(source);
633
+ }
634
+ catch {
635
+ const err = makeError('NX000', 'Invalid JSON: could not parse content.', requestId);
636
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
637
+ }
638
+ if (!isObj(parsed)) {
639
+ const err = makeError('NX000', 'Content must be a JSON object.', requestId);
640
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
641
+ }
642
+ const validation = validateNitroXContent(parsed);
643
+ const result = { requestId, ...validation };
644
+ return {
645
+ content: [{ type: 'text', text: JSON.stringify(result) }],
646
+ structuredContent: result,
647
+ };
648
+ }
649
+ catch (err) {
650
+ const error = err;
651
+ const errResult = makeError(error instanceof PathPolicyError ? error.code : 'VALIDATE_ERROR', error.message, requestId, false);
652
+ log('error', 'provar_nitrox_validate failed', { requestId, error: error.message });
653
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(errResult) }] };
654
+ }
655
+ });
656
+ }
657
+ const ParameterInputSchema = z.object({
658
+ name: z.string().describe('Parameter/qualifier name'),
659
+ value: z.string().describe('Parameter value'),
660
+ comparisonType: z.enum(['equals', 'starts-with', 'contains']).optional(),
661
+ default: z.boolean().optional().describe('Whether this is the default parameter value'),
662
+ });
663
+ const ElementInputSchema = z.object({
664
+ label: z.string().describe('Human-readable element label'),
665
+ type_ref: z.string().describe('Component type reference (e.g. "component::UUID" or "content")'),
666
+ tag_name: z.string().optional().describe('Optional HTML/LWC tag name override'),
667
+ parameters: z.array(ParameterInputSchema).optional(),
668
+ selector_xpath: z.string().optional().describe('XPath selector for this element'),
669
+ });
670
+ export function registerNitroXGenerate(server, config) {
671
+ server.registerTool('provar_nitrox_generate', {
672
+ title: 'Generate NitroX Components',
673
+ description: [
674
+ 'Generate a new NitroX .po.json (Hybrid Model page object) from a component description.',
675
+ "Applicable to any component type supported by Provar's Hybrid Model:",
676
+ 'LWC, Screen Flow, Industry Components, Experience Cloud, HTML5.',
677
+ 'Read the provar-nitrox-component-catalog resource first to understand available component types,',
678
+ 'tagName conventions, interaction titles, and attribute patterns from shipped base packages.',
679
+ 'All componentId fields are assigned fresh UUIDs. Returns JSON content;',
680
+ 'writes to disk only when dry_run=false.',
681
+ ].join(' '),
682
+ inputSchema: {
683
+ name: z.string().describe('Path-like component name, e.g. /com/force/myapp/ButtonComponent'),
684
+ tag_name: z.string().describe('LWC or HTML tag name, e.g. lightning-button or c-my-component'),
685
+ type: z.enum(['Block', 'Page']).default('Block').describe('Component type'),
686
+ page_structure_element: z.boolean().default(true).describe('Whether this is a page structure element'),
687
+ field_details_element: z.boolean().default(false).describe('Whether this is a field details element'),
688
+ parameters: z.array(ParameterInputSchema).optional().describe('Component parameters/qualifiers'),
689
+ elements: z.array(ElementInputSchema).optional().describe('Child elements'),
690
+ output_path: z.string().optional().describe('File path to write (requires dry_run=false)'),
691
+ overwrite: z.boolean().default(false).describe('Overwrite if output_path already exists'),
692
+ dry_run: z.boolean().default(true).describe('Return JSON without writing to disk (default)'),
693
+ },
694
+ }, (input) => {
695
+ const requestId = makeRequestId();
696
+ log('info', 'provar_nitrox_generate', { requestId, name: input.name, dry_run: input.dry_run });
697
+ try {
698
+ const generated = buildNitroXJson({
699
+ name: input.name,
700
+ tag_name: input.tag_name,
701
+ type: input.type,
702
+ page_structure_element: input.page_structure_element,
703
+ field_details_element: input.field_details_element,
704
+ parameters: input.parameters,
705
+ elements: input.elements,
706
+ });
707
+ const content = JSON.stringify(generated, null, 2);
708
+ let filePath;
709
+ let written = false;
710
+ if (input.output_path && !input.dry_run) {
711
+ filePath = path.resolve(input.output_path);
712
+ assertPathAllowed(filePath, config.allowedPaths);
713
+ if (fs.existsSync(filePath) && !input.overwrite) {
714
+ const err = makeError('FILE_EXISTS', `File already exists: ${filePath}. Set overwrite=true to replace.`, requestId);
715
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
716
+ }
717
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
718
+ fs.writeFileSync(filePath, content, 'utf-8');
719
+ written = true;
720
+ log('info', 'provar_nitrox_generate: wrote file', { requestId, filePath });
721
+ }
722
+ const result = { requestId, content, file_path: filePath, written, dry_run: input.dry_run };
723
+ return {
724
+ content: [{ type: 'text', text: JSON.stringify(result) }],
725
+ structuredContent: result,
726
+ };
727
+ }
728
+ catch (err) {
729
+ const error = err;
730
+ const errResult = makeError(error instanceof PathPolicyError ? error.code : error.code ?? 'GENERATE_ERROR', error.message, requestId, false);
731
+ log('error', 'provar_nitrox_generate failed', { requestId, error: error.message });
732
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(errResult) }] };
733
+ }
734
+ });
735
+ }
736
+ export function registerNitroXPatch(server, config) {
737
+ server.registerTool('provar_nitrox_patch', {
738
+ title: 'Patch NitroX Component',
739
+ description: [
740
+ 'Apply a JSON merge-patch (RFC 7396) to an existing NitroX .po.json file.',
741
+ 'Reads the file, merges the patch (null values remove keys, other values replace or recurse into objects),',
742
+ 'optionally validates the merged result, and writes back.',
743
+ 'Use dry_run=true (default) to preview the merged output without writing.',
744
+ ].join(' '),
745
+ inputSchema: {
746
+ file_path: z.string().describe('Path to the existing .po.json file to patch'),
747
+ patch: z
748
+ .record(z.unknown())
749
+ .describe('JSON merge-patch to apply (RFC 7396: null removes key, any other value replaces)'),
750
+ dry_run: z.boolean().default(true).describe('Return merged result without writing to disk (default)'),
751
+ validate_after: z
752
+ .boolean()
753
+ .default(true)
754
+ .describe('Run NX validation on merged result; blocks write if errors found'),
755
+ },
756
+ }, ({ file_path, patch, dry_run, validate_after }) => {
757
+ const requestId = makeRequestId();
758
+ log('info', 'provar_nitrox_patch', { requestId, file_path, dry_run });
759
+ try {
760
+ assertPathAllowed(file_path, config.allowedPaths);
761
+ const resolved = path.resolve(file_path);
762
+ if (!fs.existsSync(resolved)) {
763
+ const err = makeError('FILE_NOT_FOUND', `File not found: ${resolved}`, requestId);
764
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
765
+ }
766
+ let original;
767
+ try {
768
+ original = JSON.parse(fs.readFileSync(resolved, 'utf-8'));
769
+ }
770
+ catch {
771
+ const err = makeError('PARSE_ERROR', 'File contains invalid JSON.', requestId);
772
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
773
+ }
774
+ if (!isObj(original)) {
775
+ const err = makeError('PARSE_ERROR', 'File content must be a JSON object.', requestId);
776
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
777
+ }
778
+ const merged = applyMergePatch(original, patch);
779
+ const content = JSON.stringify(merged, null, 2);
780
+ let validation;
781
+ if (validate_after) {
782
+ validation = validateNitroXContent(merged);
783
+ if (!dry_run && !validation.valid) {
784
+ const errCount = validation.issues.filter((i) => i.severity === 'ERROR').length;
785
+ const err = makeError('VALIDATION_FAILED', `Patched content has ${errCount} error(s). Fix issues or set validate_after=false to skip.`, requestId, false, { validation });
786
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
787
+ }
788
+ }
789
+ let written = false;
790
+ if (!dry_run) {
791
+ fs.writeFileSync(resolved, content, 'utf-8');
792
+ written = true;
793
+ log('info', 'provar_nitrox_patch: wrote file', { requestId, filePath: resolved });
794
+ }
795
+ const result = {
796
+ requestId,
797
+ content,
798
+ file_path: resolved,
799
+ written,
800
+ dry_run,
801
+ ...(validation !== undefined && { validation }),
802
+ };
803
+ return {
804
+ content: [{ type: 'text', text: JSON.stringify(result) }],
805
+ structuredContent: result,
806
+ };
807
+ }
808
+ catch (err) {
809
+ const error = err;
810
+ const errResult = makeError(error instanceof PathPolicyError ? error.code : error.code ?? 'PATCH_ERROR', error.message, requestId, false);
811
+ log('error', 'provar_nitrox_patch failed', { requestId, error: error.message });
812
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(errResult) }] };
813
+ }
814
+ });
815
+ }
816
+ export function registerAllNitroXTools(server, config) {
817
+ registerNitroXDiscover(server);
818
+ registerNitroXRead(server, config);
819
+ registerNitroXValidate(server, config);
820
+ registerNitroXGenerate(server, config);
821
+ registerNitroXPatch(server, config);
822
+ }
823
+ //# sourceMappingURL=nitroXTools.js.map