@provartesting/provardx-cli 1.4.7 → 1.5.0-dev.1

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 (122) hide show
  1. package/README.md +264 -9
  2. package/lib/commands/provar/automation/config/validate.js.map +1 -1
  3. package/lib/commands/provar/automation/project/validate.d.ts +14 -0
  4. package/lib/commands/provar/automation/project/validate.js +69 -0
  5. package/lib/commands/provar/automation/project/validate.js.map +1 -0
  6. package/lib/commands/provar/mcp/start.d.ts +16 -0
  7. package/lib/commands/provar/mcp/start.js +62 -0
  8. package/lib/commands/provar/mcp/start.js.map +1 -0
  9. package/lib/commands/provar/quality-hub/connect.d.ts +5 -0
  10. package/lib/commands/provar/quality-hub/connect.js +12 -0
  11. package/lib/commands/provar/quality-hub/connect.js.map +1 -0
  12. package/lib/commands/provar/quality-hub/display.d.ts +5 -0
  13. package/lib/commands/provar/quality-hub/display.js +12 -0
  14. package/lib/commands/provar/quality-hub/display.js.map +1 -0
  15. package/lib/commands/provar/quality-hub/open.d.ts +5 -0
  16. package/lib/commands/provar/quality-hub/open.js +12 -0
  17. package/lib/commands/provar/quality-hub/open.js.map +1 -0
  18. package/lib/commands/provar/quality-hub/test/run/abort.d.ts +5 -0
  19. package/lib/commands/provar/quality-hub/test/run/abort.js +12 -0
  20. package/lib/commands/provar/quality-hub/test/run/abort.js.map +1 -0
  21. package/lib/commands/provar/quality-hub/test/run/report.d.ts +5 -0
  22. package/lib/commands/provar/quality-hub/test/run/report.js +12 -0
  23. package/lib/commands/provar/quality-hub/test/run/report.js.map +1 -0
  24. package/lib/commands/provar/quality-hub/test/run.d.ts +5 -0
  25. package/lib/commands/provar/quality-hub/test/run.js +12 -0
  26. package/lib/commands/provar/quality-hub/test/run.js.map +1 -0
  27. package/lib/commands/provar/quality-hub/testcase/retrieve.d.ts +5 -0
  28. package/lib/commands/provar/quality-hub/testcase/retrieve.js +12 -0
  29. package/lib/commands/provar/quality-hub/testcase/retrieve.js.map +1 -0
  30. package/lib/mcp/licensing/algasClient.d.ts +19 -0
  31. package/lib/mcp/licensing/algasClient.js +144 -0
  32. package/lib/mcp/licensing/algasClient.js.map +1 -0
  33. package/lib/mcp/licensing/ideDetection.d.ts +34 -0
  34. package/lib/mcp/licensing/ideDetection.js +179 -0
  35. package/lib/mcp/licensing/ideDetection.js.map +1 -0
  36. package/lib/mcp/licensing/index.d.ts +5 -0
  37. package/lib/mcp/licensing/index.js +10 -0
  38. package/lib/mcp/licensing/index.js.map +1 -0
  39. package/lib/mcp/licensing/licenseCache.d.ts +20 -0
  40. package/lib/mcp/licensing/licenseCache.js +79 -0
  41. package/lib/mcp/licensing/licenseCache.js.map +1 -0
  42. package/lib/mcp/licensing/licenseError.d.ts +4 -0
  43. package/lib/mcp/licensing/licenseError.js +15 -0
  44. package/lib/mcp/licensing/licenseError.js.map +1 -0
  45. package/lib/mcp/licensing/licenseValidator.d.ts +33 -0
  46. package/lib/mcp/licensing/licenseValidator.js +103 -0
  47. package/lib/mcp/licensing/licenseValidator.js.map +1 -0
  48. package/lib/mcp/logging/logger.d.ts +7 -0
  49. package/lib/mcp/logging/logger.js +22 -0
  50. package/lib/mcp/logging/logger.js.map +1 -0
  51. package/lib/mcp/rules/page_object_validation_rules.json +344 -0
  52. package/lib/mcp/rules/provar_best_practices_rules.json +3192 -0
  53. package/lib/mcp/schemas/common.d.ts +20 -0
  54. package/lib/mcp/schemas/common.js +16 -0
  55. package/lib/mcp/schemas/common.js.map +1 -0
  56. package/lib/mcp/security/pathPolicy.d.ts +14 -0
  57. package/lib/mcp/security/pathPolicy.js +38 -0
  58. package/lib/mcp/security/pathPolicy.js.map +1 -0
  59. package/lib/mcp/server.d.ts +5 -0
  60. package/lib/mcp/server.js +59 -0
  61. package/lib/mcp/server.js.map +1 -0
  62. package/lib/mcp/tools/antTools.d.ts +21 -0
  63. package/lib/mcp/tools/antTools.js +602 -0
  64. package/lib/mcp/tools/antTools.js.map +1 -0
  65. package/lib/mcp/tools/automationTools.d.ts +14 -0
  66. package/lib/mcp/tools/automationTools.js +386 -0
  67. package/lib/mcp/tools/automationTools.js.map +1 -0
  68. package/lib/mcp/tools/bestPracticesEngine.d.ts +30 -0
  69. package/lib/mcp/tools/bestPracticesEngine.js +632 -0
  70. package/lib/mcp/tools/bestPracticesEngine.js.map +1 -0
  71. package/lib/mcp/tools/defectTools.d.ts +15 -0
  72. package/lib/mcp/tools/defectTools.js +199 -0
  73. package/lib/mcp/tools/defectTools.js.map +1 -0
  74. package/lib/mcp/tools/hierarchyValidate.d.ts +139 -0
  75. package/lib/mcp/tools/hierarchyValidate.js +540 -0
  76. package/lib/mcp/tools/hierarchyValidate.js.map +1 -0
  77. package/lib/mcp/tools/pageObjectGenerate.d.ts +3 -0
  78. package/lib/mcp/tools/pageObjectGenerate.js +153 -0
  79. package/lib/mcp/tools/pageObjectGenerate.js.map +1 -0
  80. package/lib/mcp/tools/pageObjectValidate.d.ts +18 -0
  81. package/lib/mcp/tools/pageObjectValidate.js +420 -0
  82. package/lib/mcp/tools/pageObjectValidate.js.map +1 -0
  83. package/lib/mcp/tools/projectInspect.d.ts +3 -0
  84. package/lib/mcp/tools/projectInspect.js +694 -0
  85. package/lib/mcp/tools/projectInspect.js.map +1 -0
  86. package/lib/mcp/tools/projectValidateFromPath.d.ts +3 -0
  87. package/lib/mcp/tools/projectValidateFromPath.js +153 -0
  88. package/lib/mcp/tools/projectValidateFromPath.js.map +1 -0
  89. package/lib/mcp/tools/propertiesTools.d.ts +7 -0
  90. package/lib/mcp/tools/propertiesTools.js +314 -0
  91. package/lib/mcp/tools/propertiesTools.js.map +1 -0
  92. package/lib/mcp/tools/qualityHubTools.d.ts +8 -0
  93. package/lib/mcp/tools/qualityHubTools.js +178 -0
  94. package/lib/mcp/tools/qualityHubTools.js.map +1 -0
  95. package/lib/mcp/tools/rcaTools.d.ts +4 -0
  96. package/lib/mcp/tools/rcaTools.js +620 -0
  97. package/lib/mcp/tools/rcaTools.js.map +1 -0
  98. package/lib/mcp/tools/sfSpawn.d.ts +28 -0
  99. package/lib/mcp/tools/sfSpawn.js +50 -0
  100. package/lib/mcp/tools/sfSpawn.js.map +1 -0
  101. package/lib/mcp/tools/testCaseGenerate.d.ts +3 -0
  102. package/lib/mcp/tools/testCaseGenerate.js +221 -0
  103. package/lib/mcp/tools/testCaseGenerate.js.map +1 -0
  104. package/lib/mcp/tools/testCaseValidate.d.ts +20 -0
  105. package/lib/mcp/tools/testCaseValidate.js +227 -0
  106. package/lib/mcp/tools/testCaseValidate.js.map +1 -0
  107. package/lib/mcp/tools/testPlanTools.d.ts +6 -0
  108. package/lib/mcp/tools/testPlanTools.js +311 -0
  109. package/lib/mcp/tools/testPlanTools.js.map +1 -0
  110. package/lib/mcp/tools/testPlanValidate.d.ts +2 -0
  111. package/lib/mcp/tools/testPlanValidate.js +75 -0
  112. package/lib/mcp/tools/testPlanValidate.js.map +1 -0
  113. package/lib/mcp/tools/testSuiteValidate.d.ts +2 -0
  114. package/lib/mcp/tools/testSuiteValidate.js +63 -0
  115. package/lib/mcp/tools/testSuiteValidate.js.map +1 -0
  116. package/lib/services/projectValidation.d.ts +119 -0
  117. package/lib/services/projectValidation.js +678 -0
  118. package/lib/services/projectValidation.js.map +1 -0
  119. package/messages/sf.provar.automation.project.validate.md +52 -0
  120. package/messages/sf.provar.mcp.start.md +74 -0
  121. package/oclif.manifest.json +1298 -1
  122. package/package.json +46 -23
@@ -0,0 +1,678 @@
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 { validateProject, buildHierarchySummary, } from '../mcp/tools/hierarchyValidate.js';
11
+ // ── Public error type ─────────────────────────────────────────────────────────
12
+ export class ProjectValidationError extends Error {
13
+ code;
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.code = code;
17
+ this.name = 'ProjectValidationError';
18
+ }
19
+ }
20
+ // ── Quality tier / grade helpers ──────────────────────────────────────────────
21
+ export function toQualityTier(score) {
22
+ if (score >= 95)
23
+ return 'S';
24
+ if (score >= 85)
25
+ return 'A';
26
+ if (score >= 75)
27
+ return 'B';
28
+ if (score >= 65)
29
+ return 'C';
30
+ return 'D';
31
+ }
32
+ export function toQualityGrade(score) {
33
+ if (score >= 95)
34
+ return 'Excellent';
35
+ if (score >= 90)
36
+ return 'Great';
37
+ if (score >= 80)
38
+ return 'Good';
39
+ if (score >= 70)
40
+ return 'Fair';
41
+ return 'Poor';
42
+ }
43
+ export function toTitleCase(s) {
44
+ return s.charAt(0).toUpperCase() + s.slice(1);
45
+ }
46
+ // ── Project root auto-detection ───────────────────────────────────────────────
47
+ /**
48
+ * Given a path that may be a Provar workspace root (containing one or more project
49
+ * sub-directories) or the project root itself (contains .testproject), return the
50
+ * resolved project root.
51
+ *
52
+ * Detection order:
53
+ * 1. `.testproject` file present at given path → it is the project root
54
+ * 2. Exactly one sub-directory contains a `.testproject` → use that
55
+ * 3. Multiple sub-directories contain `.testproject` → return all candidates so the
56
+ * caller can surface a clear error rather than guessing
57
+ */
58
+ export function resolveProjectRoot(givenPath) {
59
+ if (fs.existsSync(path.join(givenPath, '.testproject'))) {
60
+ return { root: givenPath, candidates: [] };
61
+ }
62
+ // Scan one level deep
63
+ const candidates = [];
64
+ try {
65
+ for (const entry of fs.readdirSync(givenPath, { withFileTypes: true })) {
66
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules')
67
+ continue;
68
+ const sub = path.join(givenPath, entry.name);
69
+ if (fs.existsSync(path.join(sub, '.testproject')))
70
+ candidates.push(sub);
71
+ }
72
+ }
73
+ catch { /* skip */ }
74
+ if (candidates.length === 1)
75
+ return { root: candidates[0], candidates: [] };
76
+ return { root: givenPath, candidates }; // caller handles 0 or multiple
77
+ }
78
+ // ── Project context reader (from .testproject) ────────────────────────────────
79
+ export function readProjectContext(projectPath) {
80
+ const testProjectPath = path.join(projectPath, '.testproject');
81
+ const projectName = path.basename(projectPath);
82
+ if (!fs.existsSync(testProjectPath)) {
83
+ return { projectName, context: {} };
84
+ }
85
+ let content;
86
+ try {
87
+ content = fs.readFileSync(testProjectPath, 'utf-8');
88
+ }
89
+ catch {
90
+ return { projectName, context: {} };
91
+ }
92
+ // Extract environment names
93
+ const envPattern = /<environment\s[^>]*\bname="([^"]+)"/g;
94
+ const environments = [];
95
+ let m;
96
+ while ((m = envPattern.exec(content)) !== null)
97
+ environments.push(m[1]);
98
+ // Extract connection names
99
+ const connPattern = /<connection\s[^>]*\bname="([^"]+)"/g;
100
+ const connectionNames = [];
101
+ while ((m = connPattern.exec(content)) !== null)
102
+ connectionNames.push(m[1]);
103
+ // Check secrets encryption status
104
+ let secretsPasswordSet = false;
105
+ let unencryptedSecretCount = 0;
106
+ const secretsPathMatch = content.match(/<secureStoragePath>([^<]+)<\/secureStoragePath>/);
107
+ const secretsRelPath = secretsPathMatch?.[1]?.trim() ?? '.secrets';
108
+ const secretsFullPath = path.resolve(path.join(projectPath, secretsRelPath));
109
+ const projectPathResolved = path.resolve(projectPath);
110
+ // Bounds check: only read secrets file if it's within the project directory
111
+ const secretsInBounds = secretsFullPath === projectPathResolved || secretsFullPath.startsWith(projectPathResolved + path.sep);
112
+ if (secretsInBounds && fs.existsSync(secretsFullPath)) {
113
+ try {
114
+ const secretsContent = fs.readFileSync(secretsFullPath, 'utf-8');
115
+ secretsPasswordSet = secretsContent.includes('Encryptor.check=');
116
+ for (const line of secretsContent.split('\n')) {
117
+ const trimmed = line.trim();
118
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!'))
119
+ continue;
120
+ if (trimmed.startsWith('Encryptor.check='))
121
+ continue;
122
+ const eqIdx = trimmed.indexOf('=');
123
+ if (eqIdx === -1)
124
+ continue;
125
+ const value = trimmed.slice(eqIdx + 1).trim();
126
+ if (value && !value.startsWith('ENC1('))
127
+ unencryptedSecretCount++;
128
+ }
129
+ }
130
+ catch { /* skip */ }
131
+ }
132
+ return {
133
+ projectName,
134
+ context: {
135
+ environments: environments.length ? environments : undefined,
136
+ connection_names: connectionNames.length ? connectionNames : undefined,
137
+ secretsPasswordSet,
138
+ unencrypted_secret_count: unencryptedSecretCount,
139
+ },
140
+ };
141
+ }
142
+ /**
143
+ * Internal: reads a .testinstance file exactly once and returns everything
144
+ * needed both for building TestCaseInput and for accumulating covered paths.
145
+ * Callers that only need TestCaseInput should use resolveTestInstance().
146
+ */
147
+ function resolveTestInstanceFull(instancePath, projectPath) {
148
+ try {
149
+ const content = fs.readFileSync(instancePath, 'utf-8');
150
+ const pathMatch = content.match(/testCasePath=["']([^"']+)["']/);
151
+ const testCaseId = content.match(/testCaseId=["']([^"']+)["']/)?.[1] ?? null;
152
+ if (!pathMatch?.[1])
153
+ return { testCase: null, testCasePath: null, testCaseId };
154
+ const testCasePath = pathMatch[1].replace(/\\/g, '/');
155
+ const tcFullPath = path.resolve(path.join(projectPath, testCasePath));
156
+ const projResolved = path.resolve(projectPath);
157
+ let xml_content;
158
+ // Bounds check: only read test case files within the project directory
159
+ const tcInBounds = tcFullPath === projResolved || tcFullPath.startsWith(projResolved + path.sep);
160
+ // Derive name from the bounds-checked resolved path to prevent injection via crafted testCasePath
161
+ const tcName = tcInBounds
162
+ ? path.basename(tcFullPath, '.testcase')
163
+ : path.basename(testCasePath, '.testcase');
164
+ if (tcInBounds && fs.existsSync(tcFullPath)) {
165
+ try {
166
+ xml_content = fs.readFileSync(tcFullPath, 'utf-8');
167
+ }
168
+ catch { /* xml_content stays undefined */ }
169
+ }
170
+ return { testCase: { name: tcName, xml_content }, testCasePath, testCaseId };
171
+ }
172
+ catch {
173
+ return { testCase: null, testCasePath: null, testCaseId: null };
174
+ }
175
+ }
176
+ export function resolveTestInstance(instancePath, projectPath) {
177
+ return resolveTestInstanceFull(instancePath, projectPath).testCase;
178
+ }
179
+ /** Max suite nesting depth — mirrors the guard in projectInspect.ts. */
180
+ const MAX_SUITE_DEPTH = 10;
181
+ /** Accumulates a covered path (and its UUID fallback) into the provided Set. */
182
+ function accumulateCoveredPath(testCasePath, testCaseId, coveredPaths, idMap) {
183
+ if (testCasePath)
184
+ coveredPaths.add(testCasePath);
185
+ if (testCaseId) {
186
+ const resolved = idMap.get(testCaseId);
187
+ if (resolved)
188
+ coveredPaths.add(resolved);
189
+ }
190
+ }
191
+ export function readSuiteDirectory(dirPath, name, projectPath, depth = 0, coveredPaths, idMap) {
192
+ const testCases = [];
193
+ const testSuites = [];
194
+ if (depth > MAX_SUITE_DEPTH)
195
+ return { name, test_cases: testCases, test_suites: testSuites };
196
+ try {
197
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
198
+ for (const entry of entries) {
199
+ if (entry.name === 'node_modules')
200
+ continue;
201
+ const fullPath = path.join(dirPath, entry.name);
202
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
203
+ testSuites.push(readSuiteDirectory(fullPath, entry.name, projectPath, depth + 1, coveredPaths, idMap));
204
+ }
205
+ else if (entry.name.endsWith('.testinstance')) {
206
+ const { testCase, testCasePath, testCaseId } = resolveTestInstanceFull(fullPath, projectPath);
207
+ if (testCase)
208
+ testCases.push(testCase);
209
+ if (coveredPaths && idMap)
210
+ accumulateCoveredPath(testCasePath, testCaseId, coveredPaths, idMap);
211
+ }
212
+ }
213
+ }
214
+ catch { /* skip */ }
215
+ return { name, test_cases: testCases, test_suites: testSuites };
216
+ }
217
+ export function readPlanDirectory(planPath, name, projectPath, coveredPaths, idMap) {
218
+ const testCases = [];
219
+ const testSuites = [];
220
+ try {
221
+ const entries = fs.readdirSync(planPath, { withFileTypes: true });
222
+ for (const entry of entries) {
223
+ if (entry.name === 'node_modules')
224
+ continue;
225
+ const fullPath = path.join(planPath, entry.name);
226
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
227
+ testSuites.push(readSuiteDirectory(fullPath, entry.name, projectPath, 0, coveredPaths, idMap));
228
+ }
229
+ else if (entry.name.endsWith('.testinstance')) {
230
+ const { testCase, testCasePath, testCaseId } = resolveTestInstanceFull(fullPath, projectPath);
231
+ if (testCase)
232
+ testCases.push(testCase);
233
+ if (coveredPaths && idMap)
234
+ accumulateCoveredPath(testCasePath, testCaseId, coveredPaths, idMap);
235
+ }
236
+ }
237
+ }
238
+ catch { /* skip */ }
239
+ return { name, test_cases: testCases, test_suites: testSuites };
240
+ }
241
+ export function readPlansDir(projectPath) {
242
+ const plansDir = path.join(projectPath, 'plans');
243
+ const coveredPaths = new Set();
244
+ if (!fs.existsSync(plansDir))
245
+ return { plans: [], coveredPaths };
246
+ // Build UUID→path map once so the plan walk can resolve testCaseId fallbacks
247
+ // without a separate pass over the tests/ directory later.
248
+ const idMap = buildTestCaseIdMap(projectPath);
249
+ const plans = [];
250
+ try {
251
+ const entries = fs.readdirSync(plansDir, { withFileTypes: true });
252
+ for (const entry of entries) {
253
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules')
254
+ continue;
255
+ const planPath = path.join(plansDir, entry.name);
256
+ plans.push(readPlanDirectory(planPath, entry.name, projectPath, coveredPaths, idMap));
257
+ }
258
+ }
259
+ catch { /* skip */ }
260
+ return { plans, coveredPaths };
261
+ }
262
+ /**
263
+ * Builds a map of testcase UUID (registryId / id / guid) → project-relative path.
264
+ * Used as a fallback when testCasePath in a .testinstance file doesn't match
265
+ * the on-disk relative path exactly (e.g. different path separators, moved files).
266
+ */
267
+ function buildTestCaseIdMap(projectPath) {
268
+ const testsDir = path.join(projectPath, 'tests');
269
+ const idMap = new Map();
270
+ if (!fs.existsSync(testsDir))
271
+ return idMap;
272
+ function walk(dir) {
273
+ try {
274
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
275
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
276
+ continue;
277
+ const fullPath = path.join(dir, entry.name);
278
+ if (entry.isDirectory()) {
279
+ walk(fullPath);
280
+ }
281
+ else if (entry.name.endsWith('.testcase')) {
282
+ try {
283
+ const content = fs.readFileSync(fullPath, 'utf-8');
284
+ const rel = path.relative(projectPath, fullPath).replace(/\\/g, '/');
285
+ for (const attr of ['registryId', 'id', 'guid']) {
286
+ const m = content.match(new RegExp(`${attr}=["']([^"']+)["']`));
287
+ if (m?.[1] && !idMap.has(m[1]))
288
+ idMap.set(m[1], rel);
289
+ }
290
+ }
291
+ catch { /* skip */ }
292
+ }
293
+ }
294
+ }
295
+ catch { /* skip */ }
296
+ }
297
+ walk(testsDir);
298
+ return idMap;
299
+ }
300
+ /**
301
+ * Collects all .testcase file basenames (without extension) found under tests/.
302
+ * Includes callable tests (visibility="Internal") that have no plan instances,
303
+ * so that checkCaseCalls can distinguish genuine missing-callee errors from
304
+ * valid callable references.
305
+ */
306
+ export function collectAllTestCaseNames(projectPath) {
307
+ const testsDir = path.join(projectPath, 'tests');
308
+ if (!fs.existsSync(testsDir))
309
+ return [];
310
+ const names = [];
311
+ function walk(dir) {
312
+ try {
313
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
314
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
315
+ continue;
316
+ if (entry.isDirectory()) {
317
+ walk(path.join(dir, entry.name));
318
+ }
319
+ else if (entry.name.endsWith('.testcase'))
320
+ names.push(path.basename(entry.name, '.testcase'));
321
+ }
322
+ }
323
+ catch { /* skip */ }
324
+ }
325
+ walk(testsDir);
326
+ return names;
327
+ }
328
+ /**
329
+ * @deprecated Covered paths are now computed as a byproduct of readPlansDir().
330
+ * Use the coveredPaths returned by readPlansDir() instead.
331
+ */
332
+ export function collectCoveredPathsFromDisk(projectPath) {
333
+ const plansDir = path.join(projectPath, 'plans');
334
+ const covered = new Set();
335
+ if (!fs.existsSync(plansDir))
336
+ return covered;
337
+ // UUID fallback: testCaseId in .testinstance → registryId/id/guid in .testcase
338
+ const idMap = buildTestCaseIdMap(projectPath);
339
+ function walk(dir) {
340
+ try {
341
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
342
+ if (entry.name === 'node_modules')
343
+ continue;
344
+ const fullPath = path.join(dir, entry.name);
345
+ if (entry.isDirectory()) {
346
+ walk(fullPath);
347
+ }
348
+ else if (entry.name.endsWith('.testinstance')) {
349
+ try {
350
+ const content = fs.readFileSync(fullPath, 'utf-8');
351
+ // Primary: path-based match
352
+ const pathM = content.match(/testCasePath=["']([^"']+)["']/);
353
+ if (pathM?.[1])
354
+ covered.add(pathM[1].replace(/\\/g, '/'));
355
+ // Fallback: UUID match via testCaseId → testcase registryId/id/guid
356
+ const idM = content.match(/testCaseId=["']([^"']+)["']/);
357
+ if (idM?.[1]) {
358
+ const resolvedPath = idMap.get(idM[1]);
359
+ if (resolvedPath)
360
+ covered.add(resolvedPath);
361
+ }
362
+ }
363
+ catch { /* skip */ }
364
+ }
365
+ }
366
+ }
367
+ catch { /* skip */ }
368
+ }
369
+ walk(plansDir);
370
+ return covered;
371
+ }
372
+ export function findUncoveredTestCases(projectPath, coveredPaths) {
373
+ const testsDir = path.join(projectPath, 'tests');
374
+ if (!fs.existsSync(testsDir))
375
+ return [];
376
+ const uncovered = [];
377
+ function walk(dir) {
378
+ try {
379
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
380
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
381
+ continue;
382
+ const fullPath = path.join(dir, entry.name);
383
+ if (entry.isDirectory()) {
384
+ walk(fullPath);
385
+ }
386
+ else if (entry.name.endsWith('.testcase')) {
387
+ const rel = path.relative(projectPath, fullPath).replace(/\\/g, '/');
388
+ if (!coveredPaths.has(rel))
389
+ uncovered.push(rel);
390
+ }
391
+ }
392
+ }
393
+ catch { /* skip */ }
394
+ }
395
+ walk(testsDir);
396
+ return uncovered.sort();
397
+ }
398
+ function hierarchyViolationToQh(v, num) {
399
+ return {
400
+ number: num,
401
+ ruleId: v.rule_id,
402
+ ruleName: v.name,
403
+ ruleDescription: v.description,
404
+ category: v.category,
405
+ severity: toTitleCase(v.severity),
406
+ weight: v.weight,
407
+ message: v.message,
408
+ recommendation: v.recommendation,
409
+ appliesTo: v.applies_to.join(';'),
410
+ };
411
+ }
412
+ function tcIssuesToQhViolations(tc) {
413
+ const violations = [];
414
+ let num = 1;
415
+ for (const issue of tc.issues) {
416
+ violations.push({
417
+ number: num++,
418
+ ruleId: issue.rule_id,
419
+ ruleName: issue.rule_id,
420
+ ruleDescription: '',
421
+ category: 'Validation',
422
+ severity: issue.severity === 'ERROR' ? 'Major' : issue.severity === 'WARNING' ? 'Minor' : 'Info',
423
+ weight: issue.severity === 'ERROR' ? 5 : issue.severity === 'WARNING' ? 2 : 1,
424
+ message: issue.message,
425
+ recommendation: issue.suggestion ?? '',
426
+ appliesTo: issue.applies_to,
427
+ });
428
+ }
429
+ for (const bp of (tc.best_practices_violations ?? [])) {
430
+ violations.push({
431
+ number: num++,
432
+ ruleId: bp.rule_id,
433
+ ruleName: bp.name,
434
+ ruleDescription: bp.description,
435
+ category: bp.category,
436
+ severity: toTitleCase(bp.severity),
437
+ weight: bp.weight,
438
+ message: bp.message,
439
+ recommendation: bp.recommendation,
440
+ appliesTo: bp.applies_to.join(';'),
441
+ });
442
+ }
443
+ return violations;
444
+ }
445
+ function tcTotalViolations(tc) {
446
+ return tc.issues.length + (tc.best_practices_violations?.length ?? 0);
447
+ }
448
+ function addSuiteSection(sections, suite) {
449
+ sections.push({
450
+ level: 'Test Suite',
451
+ contextName: suite.name,
452
+ qualityScore: suite.quality_score,
453
+ qualityTier: toQualityTier(suite.quality_score),
454
+ totalViolations: suite.violations.length,
455
+ violations: suite.violations.map((v, i) => hierarchyViolationToQh(v, i + 1)),
456
+ });
457
+ for (const child of suite.test_suites)
458
+ addSuiteSection(sections, child);
459
+ for (const tc of suite.test_cases) {
460
+ const total = tcTotalViolations(tc);
461
+ if (total > 0) {
462
+ sections.push({
463
+ level: 'Test Case',
464
+ contextName: tc.name,
465
+ qualityScore: tc.quality_score,
466
+ qualityTier: toQualityTier(tc.quality_score),
467
+ totalViolations: total,
468
+ violations: tcIssuesToQhViolations(tc),
469
+ });
470
+ }
471
+ }
472
+ }
473
+ function addPlanSections(sections, plan) {
474
+ sections.push({
475
+ level: 'Test Plan',
476
+ contextName: plan.name,
477
+ qualityScore: plan.quality_score,
478
+ qualityTier: toQualityTier(plan.quality_score),
479
+ totalViolations: plan.violations.length,
480
+ violations: plan.violations.map((v, i) => hierarchyViolationToQh(v, i + 1)),
481
+ });
482
+ for (const suite of plan.test_suites)
483
+ addSuiteSection(sections, suite);
484
+ for (const tc of plan.test_cases) {
485
+ const total = tcTotalViolations(tc);
486
+ if (total > 0) {
487
+ sections.push({
488
+ level: 'Test Case',
489
+ contextName: tc.name,
490
+ qualityScore: tc.quality_score,
491
+ qualityTier: toQualityTier(tc.quality_score),
492
+ totalViolations: total,
493
+ violations: tcIssuesToQhViolations(tc),
494
+ });
495
+ }
496
+ }
497
+ }
498
+ function flattenToSections(result) {
499
+ const sections = [];
500
+ sections.push({
501
+ level: 'Project',
502
+ contextName: result.name,
503
+ qualityScore: result.quality_score,
504
+ qualityTier: toQualityTier(result.quality_score),
505
+ totalViolations: result.violations.length,
506
+ violations: result.violations.map((v, i) => hierarchyViolationToQh(v, i + 1)),
507
+ });
508
+ for (const suite of result.test_suites)
509
+ addSuiteSection(sections, suite);
510
+ for (const tc of result.test_cases) {
511
+ const total = tcTotalViolations(tc);
512
+ if (total > 0) {
513
+ sections.push({
514
+ level: 'Test Case',
515
+ contextName: tc.name,
516
+ qualityScore: tc.quality_score,
517
+ qualityTier: toQualityTier(tc.quality_score),
518
+ totalViolations: total,
519
+ violations: tcIssuesToQhViolations(tc),
520
+ });
521
+ }
522
+ }
523
+ for (const plan of result.test_plans)
524
+ addPlanSections(sections, plan);
525
+ return sections;
526
+ }
527
+ export function buildQhReport(result, projectName) {
528
+ const now = new Date();
529
+ const summary = buildHierarchySummary(result);
530
+ return {
531
+ reportInfo: {
532
+ name: `VR-LOCAL-${now.toISOString().replace(/[:.]/g, '-').slice(0, 19)}`,
533
+ generatedAt: now.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
534
+ exportedAt: now.toISOString(),
535
+ source: 'provar-mcp-local',
536
+ },
537
+ summary: {
538
+ qualityScore: result.quality_score,
539
+ qualityGrade: toQualityGrade(result.quality_score),
540
+ totalViolations: summary.total_violations,
541
+ criticalViolations: summary.violations_by_severity.critical,
542
+ majorViolations: summary.violations_by_severity.major,
543
+ minorViolations: summary.violations_by_severity.minor,
544
+ infoViolations: summary.violations_by_severity.info,
545
+ },
546
+ context: {
547
+ validationLevel: 'Project',
548
+ testProjectName: projectName,
549
+ },
550
+ sections: flattenToSections(result),
551
+ };
552
+ }
553
+ export function saveResults(projectPath, resultsDir, report, projectName) {
554
+ const targetDir = resultsDir
555
+ ? path.resolve(resultsDir)
556
+ : path.join(projectPath, 'provardx', 'validation');
557
+ fs.mkdirSync(targetDir, { recursive: true });
558
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
559
+ const safeName = projectName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
560
+ const fileName = `${timestamp}-${safeName}.json`;
561
+ const filePath = path.join(targetDir, fileName);
562
+ fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf-8');
563
+ // Return absolute path when results_dir is provided (avoids ugly ../../../../ relative traversal),
564
+ // relative path otherwise (matches project-relative convention).
565
+ if (resultsDir)
566
+ return filePath.replace(/\\/g, '/');
567
+ return path.relative(projectPath, filePath).replace(/\\/g, '/');
568
+ }
569
+ // ── Main exported function ────────────────────────────────────────────────────
570
+ /**
571
+ * Validate a Provar project from a path on disk.
572
+ *
573
+ * Throws ProjectValidationError for user-facing problems (path not found,
574
+ * ambiguous project, not a Provar project directory). Lets unexpected I/O
575
+ * errors propagate as-is.
576
+ */
577
+ export function validateProjectFromPath(options) {
578
+ const { project_path, quality_threshold, save_results, results_dir } = options;
579
+ const resolved = path.resolve(project_path);
580
+ if (!fs.existsSync(resolved)) {
581
+ throw new ProjectValidationError('PATH_NOT_FOUND', `Project path does not exist: ${resolved}`);
582
+ }
583
+ const { root: projectRoot, candidates } = resolveProjectRoot(resolved);
584
+ if (candidates.length > 1) {
585
+ throw new ProjectValidationError('AMBIGUOUS_PROJECT', `Multiple Provar projects found under "${resolved}". Specify the exact project directory: ${candidates.map((c) => path.basename(c)).join(', ')}`);
586
+ }
587
+ if (!fs.existsSync(path.join(projectRoot, '.testproject'))) {
588
+ throw new ProjectValidationError('NOT_A_PROJECT', `No Provar project found at "${projectRoot}". Ensure the path points to a directory containing a .testproject file.`);
589
+ }
590
+ const threshold = quality_threshold ?? 80;
591
+ // 1. Read project context from .testproject
592
+ const { projectName, context } = readProjectContext(projectRoot);
593
+ // 2. Read plan hierarchy from plans/ directory; covered paths are computed
594
+ // as a byproduct of the walk — no second traversal needed.
595
+ const { plans: testPlans, coveredPaths } = readPlansDir(projectRoot);
596
+ // 3. Validate
597
+ const input = {
598
+ name: projectName,
599
+ test_plans: testPlans,
600
+ test_suites: [],
601
+ test_cases: [],
602
+ project_context: context,
603
+ all_disk_test_case_names: collectAllTestCaseNames(projectRoot),
604
+ };
605
+ const result = validateProject(input, threshold);
606
+ const summary = buildHierarchySummary(result);
607
+ // 4. Find uncovered test cases and compute accurate disk counts
608
+ // coveredPaths was already built during step 2 — no second directory walk.
609
+ const uncoveredTestCases = findUncoveredTestCases(projectRoot, coveredPaths);
610
+ // Count only covered references where the .testcase file actually exists on disk
611
+ const coveredOnDisk = [...coveredPaths].filter((rel) => fs.existsSync(path.join(projectRoot, rel))).length;
612
+ // 5. Build detailed plan results
613
+ const plans = result.test_plans.map((p) => ({
614
+ name: p.name,
615
+ quality_score: p.quality_score,
616
+ violations: p.violations,
617
+ suites: p.test_suites.map((s) => ({
618
+ name: s.name,
619
+ quality_score: s.quality_score,
620
+ violations: s.violations,
621
+ test_cases: s.test_cases.map((tc) => ({
622
+ name: tc.name,
623
+ quality_score: tc.quality_score,
624
+ quality_tier: toQualityTier(tc.quality_score),
625
+ status: tc.status,
626
+ is_valid: tc.is_valid,
627
+ step_count: tc.step_count,
628
+ error_count: tc.error_count,
629
+ warning_count: tc.warning_count,
630
+ issues: tc.issues,
631
+ })),
632
+ child_suites: s.test_suites.map((cs) => ({
633
+ name: cs.name,
634
+ quality_score: cs.quality_score,
635
+ violations: cs.violations,
636
+ test_case_count: cs.test_cases.length,
637
+ })),
638
+ })),
639
+ unplanned_test_cases: p.test_cases.map((tc) => ({
640
+ name: tc.name,
641
+ quality_score: tc.quality_score,
642
+ status: tc.status,
643
+ error_count: tc.error_count,
644
+ issues: tc.issues,
645
+ })),
646
+ }));
647
+ // 6. Optionally save QH report
648
+ let savedTo = null;
649
+ let saveError;
650
+ if (save_results !== false) {
651
+ const report = buildQhReport(result, projectName);
652
+ try {
653
+ savedTo = saveResults(projectRoot, results_dir, report, projectName);
654
+ }
655
+ catch (err) {
656
+ saveError = err.message;
657
+ }
658
+ }
659
+ return {
660
+ project_path: projectRoot,
661
+ project_name: projectName,
662
+ quality_score: result.quality_score,
663
+ quality_tier: toQualityTier(result.quality_score),
664
+ quality_grade: toQualityGrade(result.quality_score),
665
+ summary,
666
+ project_violations: result.violations,
667
+ plans,
668
+ coverage: {
669
+ total_test_cases_on_disk: coveredOnDisk + uncoveredTestCases.length,
670
+ covered_by_plans: coveredOnDisk,
671
+ uncovered_count: uncoveredTestCases.length,
672
+ uncovered_test_cases: uncoveredTestCases,
673
+ },
674
+ saved_to: savedTo,
675
+ save_error: saveError,
676
+ };
677
+ }
678
+ //# sourceMappingURL=projectValidation.js.map