qa-flowkit 0.4.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.qa-ai/adapters/aider/.aider/README.md +25 -0
- package/.qa-ai/adapters/aider/.aider.conf.yml +6 -0
- package/.qa-ai/adapters/claude/agents/qa-workflow-orchestrator.md +18 -0
- package/.qa-ai/adapters/claude/commands/qa-add-tests.md +42 -0
- package/.qa-ai/adapters/claude/commands/qa-automation-plan.md +43 -0
- package/.qa-ai/adapters/claude/commands/qa-clean.md +42 -0
- package/.qa-ai/adapters/claude/commands/qa-config.md +51 -0
- package/.qa-ai/adapters/claude/commands/qa-coverage.md +46 -0
- package/.qa-ai/adapters/claude/commands/qa-doctor.md +11 -0
- package/.qa-ai/adapters/claude/commands/qa-full-flow.md +59 -0
- package/.qa-ai/adapters/claude/commands/qa-gate.md +36 -0
- package/.qa-ai/adapters/claude/commands/qa-help.md +30 -0
- package/.qa-ai/adapters/claude/commands/qa-init.md +70 -0
- package/.qa-ai/adapters/claude/commands/qa-status.md +56 -0
- package/.qa-ai/adapters/claude/commands/qa-update-tests.md +47 -0
- package/.qa-ai/adapters/claude/commands/qa-validate-features.md +36 -0
- package/.qa-ai/adapters/cline/.cline/README.md +25 -0
- package/.qa-ai/adapters/cline/.clinerules +9 -0
- package/.qa-ai/adapters/codex/README.md +44 -0
- package/.qa-ai/adapters/codex/prompts/implement-project.md +15 -0
- package/.qa-ai/adapters/continue/README.md +26 -0
- package/.qa-ai/adapters/continue/checks/qa-feature-conventions.md +15 -0
- package/.qa-ai/adapters/gemini/GEMINI.md +40 -0
- package/.qa-ai/adapters/generic/AGENTS.md +100 -0
- package/.qa-ai/adapters/goose/recipes/qa-flowkit.yaml +20 -0
- package/.qa-ai/adapters/opencode/README.md +57 -0
- package/.qa-ai/adapters/opencode/agents/qa-workflow.md +18 -0
- package/.qa-ai/adapters/opencode/commands/qa-add-tests.md +42 -0
- package/.qa-ai/adapters/opencode/commands/qa-automation-plan.md +43 -0
- package/.qa-ai/adapters/opencode/commands/qa-clean.md +42 -0
- package/.qa-ai/adapters/opencode/commands/qa-config.md +51 -0
- package/.qa-ai/adapters/opencode/commands/qa-coverage.md +46 -0
- package/.qa-ai/adapters/opencode/commands/qa-doctor.md +13 -0
- package/.qa-ai/adapters/opencode/commands/qa-full-flow.md +59 -0
- package/.qa-ai/adapters/opencode/commands/qa-gate.md +36 -0
- package/.qa-ai/adapters/opencode/commands/qa-help.md +30 -0
- package/.qa-ai/adapters/opencode/commands/qa-init.md +70 -0
- package/.qa-ai/adapters/opencode/commands/qa-status.md +56 -0
- package/.qa-ai/adapters/opencode/commands/qa-update-tests.md +47 -0
- package/.qa-ai/adapters/opencode/commands/qa-validate-features.md +36 -0
- package/.qa-ai/agents/README.md +39 -0
- package/.qa-ai/agents/api-testing-agent.md +73 -0
- package/.qa-ai/agents/automation-feasibility-agent.md +128 -0
- package/.qa-ai/agents/gherkin-test-design-agent.md +110 -0
- package/.qa-ai/agents/jira-task-agent.md +92 -0
- package/.qa-ai/agents/pr-agent.md +101 -0
- package/.qa-ai/agents/qa-context-intake-agent.md +75 -0
- package/.qa-ai/agents/qa-workflow-orchestrator.md +113 -0
- package/.qa-ai/agents/release-gate-agent.md +50 -0
- package/.qa-ai/agents/requirements-intake-agent.md +79 -0
- package/.qa-ai/agents/requirements-normalization-agent.md +80 -0
- package/.qa-ai/agents/specialists/available/appium.md +59 -0
- package/.qa-ai/agents/specialists/available/cypress.md +68 -0
- package/.qa-ai/agents/specialists/available/generic-test-design.md +117 -0
- package/.qa-ai/agents/specialists/available/jira.md +108 -0
- package/.qa-ai/agents/specialists/available/karate.md +97 -0
- package/.qa-ai/agents/specialists/available/playwright-api.md +87 -0
- package/.qa-ai/agents/specialists/available/playwright-ui.md +87 -0
- package/.qa-ai/agents/specialists/available/postman.md +108 -0
- package/.qa-ai/agents/specialists/available/rest-assured.md +103 -0
- package/.qa-ai/agents/specialists/available/selenium.md +91 -0
- package/.qa-ai/agents/specialists/available/testrail.md +85 -0
- package/.qa-ai/agents/specialists/available/webdriverio.md +81 -0
- package/.qa-ai/agents/test-design-system-agent.md +33 -0
- package/.qa-ai/agents/testrail-coverage-agent.md +84 -0
- package/.qa-ai/agents/testrail-sync-agent.md +96 -0
- package/.qa-ai/agents/webdriverio-implementation-agent.md +84 -0
- package/.qa-ai/presets/manual-only.yaml +65 -0
- package/.qa-ai/presets/selenium-jest-browserstack.yaml +72 -0
- package/.qa-ai/presets/webdriverio-playwright-api.yaml +85 -0
- package/.qa-ai/rules/api-testing.rules.md +7 -0
- package/.qa-ai/rules/approval.rules.md +8 -0
- package/.qa-ai/rules/automation.rules.md +7 -0
- package/.qa-ai/rules/gherkin.rules.md +12 -0
- package/.qa-ai/rules/testrail.rules.md +10 -0
- package/.qa-ai/rules/webdriverio.rules.md +9 -0
- package/.qa-ai/scripts/bootstrap-agent-adapters.mjs +127 -0
- package/.qa-ai/scripts/clean.mjs +243 -0
- package/.qa-ai/scripts/config.mjs +202 -0
- package/.qa-ai/scripts/doctor.mjs +383 -0
- package/.qa-ai/scripts/init.mjs +447 -0
- package/.qa-ai/scripts/lib/markdown-table.mjs +76 -0
- package/.qa-ai/scripts/lib/project-config.mjs +184 -0
- package/.qa-ai/scripts/lib/qa-next-steps.mjs +578 -0
- package/.qa-ai/scripts/lib/release-gate.mjs +66 -0
- package/.qa-ai/scripts/lib/test-design.mjs +92 -0
- package/.qa-ai/scripts/lib/test-management-mapping.mjs +73 -0
- package/.qa-ai/scripts/lib/utils.mjs +331 -0
- package/.qa-ai/scripts/qa-help.mjs +44 -0
- package/.qa-ai/scripts/smoke-npm-pack.mjs +187 -0
- package/.qa-ai/scripts/smoke-test.mjs +465 -0
- package/.qa-ai/scripts/sync-agent-adapters.mjs +121 -0
- package/.qa-ai/scripts/test-validators.mjs +334 -0
- package/.qa-ai/scripts/validate-active-specialists.mjs +106 -0
- package/.qa-ai/scripts/validate-features.mjs +277 -0
- package/.qa-ai/scripts/validate-release-gate.mjs +105 -0
- package/.qa-ai/scripts/validate-sync-plan.mjs +186 -0
- package/.qa-ai/scripts/validate-target.mjs +104 -0
- package/.qa-ai/scripts/validate-test-design.mjs +117 -0
- package/.qa-ai/scripts/validate-traceability.mjs +183 -0
- package/.qa-ai/templates/automation-feasibility-report.template.md +21 -0
- package/.qa-ai/templates/automation-implementation-plan.template.md +23 -0
- package/.qa-ai/templates/feature.template +13 -0
- package/.qa-ai/templates/jira-automation-task.template.md +25 -0
- package/.qa-ai/templates/pr-template.md +60 -0
- package/.qa-ai/templates/release-gate.template.yaml +16 -0
- package/.qa-ai/templates/requirement-analysis.template.md +17 -0
- package/.qa-ai/templates/test-design-proposal.template.md +26 -0
- package/.qa-ai/templates/test-design-system.template.md +15 -0
- package/.qa-ai/templates/test-management-mapping.template.json +18 -0
- package/.qa-ai/templates/testrail-coverage-analysis.template.md +17 -0
- package/.qa-ai/templates/testrail-sync-plan.template.md +22 -0
- package/.qa-ai/templates/traceability-matrix.template.md +4 -0
- package/.qa-ai/workflows/automation-analysis.md +23 -0
- package/.qa-ai/workflows/cleanup.md +52 -0
- package/.qa-ai/workflows/context-intake.md +66 -0
- package/.qa-ai/workflows/full-flow.md +55 -0
- package/.qa-ai/workflows/implementation.md +24 -0
- package/.qa-ai/workflows/intake.md +3 -0
- package/.qa-ai/workflows/pr.md +3 -0
- package/.qa-ai/workflows/release-gate.md +22 -0
- package/.qa-ai/workflows/test-design-system.md +33 -0
- package/.qa-ai/workflows/test-design.md +23 -0
- package/.qa-ai/workflows/testrail-sync.md +23 -0
- package/CHANGELOG.md +108 -0
- package/CODE_OF_CONDUCT.md +11 -0
- package/CONTRIBUTING.md +39 -0
- package/LICENSE +21 -0
- package/README.es.md +602 -0
- package/README.md +633 -0
- package/ROADMAP.md +107 -0
- package/SECURITY.md +18 -0
- package/bin/qa-flowkit.mjs +214 -0
- package/docs/qa-ai/agent-compatibility.md +100 -0
- package/docs/qa-ai/architecture.md +130 -0
- package/docs/qa-ai/backlog.md +393 -0
- package/docs/qa-ai/cleanup.md +104 -0
- package/docs/qa-ai/customizing-agents.md +148 -0
- package/docs/qa-ai/getting-started.md +385 -0
- package/docs/qa-ai/implementation-guide-for-codex.md +210 -0
- package/docs/qa-ai/npm-migration-plan.md +50 -0
- package/docs/qa-ai/open-source-release-checklist.md +17 -0
- package/docs/qa-ai/qa-help.md +76 -0
- package/docs/qa-ai/release-gate.md +60 -0
- package/docs/qa-ai/terminal-transcripts.md +316 -0
- package/docs/qa-ai/test-design-dual-mode.md +75 -0
- package/docs/qa-ai/troubleshooting.md +740 -0
- package/docs/qa-ai/workflow.md +147 -0
- package/package.json +72 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
getConfigValue,
|
|
6
|
+
listFilesRecursive,
|
|
7
|
+
loadQaAiConfig,
|
|
8
|
+
logHeader,
|
|
9
|
+
parseArgs,
|
|
10
|
+
relativeTo,
|
|
11
|
+
resolveRepoPath
|
|
12
|
+
} from './lib/utils.mjs';
|
|
13
|
+
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
const args = parseArgs(process.argv);
|
|
16
|
+
const rfPattern = /\bRF[-_ ]?[A-Z0-9]+\b/i;
|
|
17
|
+
const idPattern = /\b(?:RF|TC|TEST|QA)[-_ ]?[A-Z0-9]+\b/gi;
|
|
18
|
+
const caseIdPattern = /\b(?:TC|TEST|QA)[-_ ]?[A-Z0-9]+\b/gi;
|
|
19
|
+
|
|
20
|
+
function printHelp() {
|
|
21
|
+
console.log(`Usage: node .qa-ai/scripts/validate-features.mjs [options]
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--path <dir> Override the configured feature root
|
|
25
|
+
--gherkin-language <en|es> Override the configured Gherkin language
|
|
26
|
+
--allow-empty Return success when no .feature files exist
|
|
27
|
+
--no-duplicates Skip cross-file duplicate ID validation
|
|
28
|
+
--help Show this help
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requiredTagName(tag) {
|
|
33
|
+
const normalized = String(tag).trim();
|
|
34
|
+
if (!normalized) return '';
|
|
35
|
+
const withPrefix = normalized.startsWith('@') ? normalized : `@${normalized}`;
|
|
36
|
+
return withPrefix.replace(/:$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findLine(content, prefix) {
|
|
40
|
+
return content.split(/\r?\n/).find((line) => line.trim().toLowerCase().startsWith(prefix.toLowerCase())) || '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeLanguage(value) {
|
|
44
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
45
|
+
if (['es', 'esp', 'spa', 'spanish', 'espanol', 'espa\u00f1ol'].includes(normalized)) return 'es';
|
|
46
|
+
return 'en';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function languageRules(language) {
|
|
50
|
+
if (language === 'es') {
|
|
51
|
+
return {
|
|
52
|
+
code: 'es',
|
|
53
|
+
featurePattern: /^(?:Caracter\u00edstica|Caracteristica):/i,
|
|
54
|
+
scenarioPattern: /^(?:Escenario|Esquema del escenario):/i,
|
|
55
|
+
featurePrefixes: ['Caracter\u00edstica:', 'Caracteristica:'],
|
|
56
|
+
scenarioPrefixes: ['Escenario:', 'Esquema del escenario:'],
|
|
57
|
+
acceptancePattern: /^Criterios de aceptaci\u00f3n:/i,
|
|
58
|
+
acceptanceLabel: 'Criterios de aceptaci\u00f3n'
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
code: 'en',
|
|
63
|
+
featurePattern: /^Feature:/i,
|
|
64
|
+
scenarioPattern: /^Scenario(?: Outline)?:/i,
|
|
65
|
+
featurePrefixes: ['Feature:'],
|
|
66
|
+
scenarioPrefixes: ['Scenario:', 'Scenario Outline:'],
|
|
67
|
+
acceptancePattern: /^Acceptance Criteria:/i,
|
|
68
|
+
acceptanceLabel: 'Acceptance Criteria'
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findAnyLine(content, prefixes) {
|
|
73
|
+
for (const prefix of prefixes) {
|
|
74
|
+
const line = findLine(content, prefix);
|
|
75
|
+
if (line) return line;
|
|
76
|
+
}
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function escapeRegExp(value) {
|
|
81
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasRequiredTag(content, tagName) {
|
|
85
|
+
const pattern = new RegExp(`(?:^|\\s)${escapeRegExp(tagName)}:[^\\s]+`, 'm');
|
|
86
|
+
return pattern.test(content);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeId(value) {
|
|
90
|
+
return String(value || '').replace(/\s+/g, '-').toUpperCase();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function idsFromText(value) {
|
|
94
|
+
return [...String(value || '').matchAll(idPattern)].map((match) => normalizeId(match[0]));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function caseIdsFromText(value) {
|
|
98
|
+
return [...String(value || '').matchAll(caseIdPattern)].map((match) => normalizeId(match[0]));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function caseIdsFromTags(model) {
|
|
102
|
+
const caseTagPattern = /^@(?:id|test|case|testrail):(.+)$/i;
|
|
103
|
+
return model.tags
|
|
104
|
+
.map(({ tag }) => tag.match(caseTagPattern)?.[1])
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.map(normalizeId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseFeature(content, language) {
|
|
110
|
+
const rules = languageRules(language);
|
|
111
|
+
const model = {
|
|
112
|
+
languageLine: '',
|
|
113
|
+
featureLines: [],
|
|
114
|
+
scenarioLines: [],
|
|
115
|
+
acceptanceLines: [],
|
|
116
|
+
tags: []
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const lines = content.split(/\r?\n/);
|
|
120
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
121
|
+
const trimmed = lines[index].trim();
|
|
122
|
+
if (!trimmed) continue;
|
|
123
|
+
const line = index + 1;
|
|
124
|
+
|
|
125
|
+
if (trimmed.toLowerCase().startsWith('# language:')) {
|
|
126
|
+
model.languageLine = trimmed;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (trimmed.startsWith('@')) {
|
|
130
|
+
for (const tag of trimmed.split(/\s+/).filter(Boolean)) model.tags.push({ line, tag });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (rules.featurePattern.test(trimmed)) {
|
|
134
|
+
model.featureLines.push({ line, text: trimmed });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (rules.scenarioPattern.test(trimmed)) {
|
|
138
|
+
model.scenarioLines.push({ line, text: trimmed });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (rules.acceptancePattern.test(trimmed)) {
|
|
142
|
+
model.acceptanceLines.push({ line, text: trimmed });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return model;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hasRequiredParsedTag(model, tagName) {
|
|
150
|
+
const prefix = `${tagName}:`;
|
|
151
|
+
return model.tags.some(({ tag }) => tag.toLowerCase().startsWith(prefix.toLowerCase()) && tag.length > prefix.length);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validate(content, file, requiredTags, language) {
|
|
155
|
+
const errors = [];
|
|
156
|
+
const rules = languageRules(language);
|
|
157
|
+
const parsed = parseFeature(content, language);
|
|
158
|
+
const featureLine = parsed.featureLines[0]?.text || findAnyLine(content, rules.featurePrefixes);
|
|
159
|
+
const scenarioLine = parsed.scenarioLines[0]?.text || findAnyLine(content, rules.scenarioPrefixes);
|
|
160
|
+
const languageLine = parsed.languageLine || findLine(content, '# language:');
|
|
161
|
+
|
|
162
|
+
if (languageLine && !new RegExp(`#\\s*language:\\s*${rules.code}\\b`, 'i').test(languageLine)) {
|
|
163
|
+
errors.push(`Feature declares a Gherkin language that does not match configured language "${rules.code}".`);
|
|
164
|
+
}
|
|
165
|
+
if (rules.code === 'es' && !languageLine) {
|
|
166
|
+
errors.push('Spanish Gherkin files must declare "# language: es".');
|
|
167
|
+
}
|
|
168
|
+
if (parsed.featureLines.length !== 1) errors.push(`Expected exactly one Feature title, found ${parsed.featureLines.length}.`);
|
|
169
|
+
if (parsed.scenarioLines.length !== 1) errors.push(`Expected exactly one Scenario, found ${parsed.scenarioLines.length}.`);
|
|
170
|
+
if (parsed.acceptanceLines.length === 0) errors.push(`Missing ${rules.acceptanceLabel}.`);
|
|
171
|
+
|
|
172
|
+
for (const tag of requiredTags.map(requiredTagName).filter(Boolean)) {
|
|
173
|
+
if (!hasRequiredParsedTag(parsed, tag) && !hasRequiredTag(content, tag)) {
|
|
174
|
+
errors.push(`Missing required tag value ${tag}:<value>`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (featureLine && !rfPattern.test(featureLine)) errors.push('Feature title does not contain an RF-like ID.');
|
|
179
|
+
if (scenarioLine && !rfPattern.test(scenarioLine)) errors.push('Scenario title does not contain an RF-like ID.');
|
|
180
|
+
if (!rfPattern.test(path.basename(file))) errors.push('Feature filename does not contain an RF-like ID.');
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
errors,
|
|
184
|
+
ids: [...new Set([
|
|
185
|
+
...idsFromText(path.basename(file, '.feature')),
|
|
186
|
+
...idsFromText(featureLine),
|
|
187
|
+
...idsFromText(scenarioLine)
|
|
188
|
+
])].sort(),
|
|
189
|
+
caseIds: [...new Set([
|
|
190
|
+
...caseIdsFromText(path.basename(file, '.feature')),
|
|
191
|
+
...caseIdsFromTags(parsed)
|
|
192
|
+
])].sort()
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function duplicateIdErrors(results) {
|
|
197
|
+
const byId = new Map();
|
|
198
|
+
for (const result of results) {
|
|
199
|
+
for (const id of result.caseIds) {
|
|
200
|
+
const current = byId.get(id) || [];
|
|
201
|
+
current.push(result.file);
|
|
202
|
+
byId.set(id, current);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const errors = [];
|
|
207
|
+
for (const [id, files] of byId.entries()) {
|
|
208
|
+
const uniqueFiles = [...new Set(files)];
|
|
209
|
+
if (uniqueFiles.length > 1) {
|
|
210
|
+
errors.push(`Duplicate test case identifier ${id} appears in: ${uniqueFiles.map((file) => relativeTo(cwd, file)).join(', ')}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return errors;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function main() {
|
|
217
|
+
if (args.help) {
|
|
218
|
+
printHelp();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
logHeader('QA AI feature validator');
|
|
223
|
+
const configInfo = await loadQaAiConfig(cwd);
|
|
224
|
+
const featureRoot = args.path || getConfigValue(configInfo.data, 'gherkin.featurePath', 'features');
|
|
225
|
+
const language = normalizeLanguage(args['gherkin-language'] || args.gherkinLanguage || args.gherkin || getConfigValue(configInfo.data, 'gherkin.language', 'en'));
|
|
226
|
+
const requiredTags = getConfigValue(configInfo.data, 'gherkin.tags.required', ['priority', 'type', 'manual']);
|
|
227
|
+
const tagNames = Array.isArray(requiredTags) && requiredTags.length > 0 ? requiredTags : ['priority', 'type', 'manual'];
|
|
228
|
+
const featureRootPath = resolveRepoPath(cwd, featureRoot, { label: 'feature root' });
|
|
229
|
+
const files = await listFilesRecursive(featureRootPath, (filePath) => filePath.endsWith('.feature'));
|
|
230
|
+
|
|
231
|
+
if (files.length === 0) {
|
|
232
|
+
console.log(`No .feature files found under ${featureRoot}.`);
|
|
233
|
+
if (!args['allow-empty']) {
|
|
234
|
+
console.log('\nFAILED - no feature files found. Pass --allow-empty when this is expected.');
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let totalErrors = 0;
|
|
241
|
+
const results = [];
|
|
242
|
+
for (const file of files) {
|
|
243
|
+
const content = await fs.readFile(file, 'utf8');
|
|
244
|
+
const result = {
|
|
245
|
+
file,
|
|
246
|
+
...validate(content, file, tagNames, language)
|
|
247
|
+
};
|
|
248
|
+
results.push(result);
|
|
249
|
+
if (result.errors.length === 0) {
|
|
250
|
+
console.log(`[PASS] ${relativeTo(cwd, file)}`);
|
|
251
|
+
} else {
|
|
252
|
+
totalErrors += result.errors.length;
|
|
253
|
+
console.log(`[FAIL] ${relativeTo(cwd, file)}`);
|
|
254
|
+
for (const error of result.errors) console.log(` - ${error}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!args['no-duplicates']) {
|
|
259
|
+
const duplicateErrors = duplicateIdErrors(results);
|
|
260
|
+
if (duplicateErrors.length > 0) {
|
|
261
|
+
totalErrors += duplicateErrors.length;
|
|
262
|
+
console.log('[FAIL] Duplicate identifier validation');
|
|
263
|
+
for (const error of duplicateErrors) console.log(` - ${error}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (totalErrors > 0) {
|
|
268
|
+
console.log(`\nFAILED - ${totalErrors} validation errors.`);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
console.log('\nVALID - all feature files passed.');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
main().catch((error) => {
|
|
275
|
+
console.error(error);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { validateReleaseGateData } from './lib/release-gate.mjs';
|
|
4
|
+
import {
|
|
5
|
+
getConfigValue,
|
|
6
|
+
loadQaAiConfig,
|
|
7
|
+
logHeader,
|
|
8
|
+
parseArgs,
|
|
9
|
+
parseSimpleYaml,
|
|
10
|
+
pathExists,
|
|
11
|
+
readText,
|
|
12
|
+
resolveRepoPath
|
|
13
|
+
} from './lib/utils.mjs';
|
|
14
|
+
|
|
15
|
+
const cwd = process.cwd();
|
|
16
|
+
const args = parseArgs(process.argv);
|
|
17
|
+
|
|
18
|
+
function printHelp() {
|
|
19
|
+
console.log(`Usage: node .qa-ai/scripts/validate-release-gate.mjs [options]
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--path <file> Override release gate file path
|
|
23
|
+
--allow-missing Return success when the gate file is missing
|
|
24
|
+
--allow-pending Allow decision: PENDING (draft gates only)
|
|
25
|
+
--help Show this help
|
|
26
|
+
|
|
27
|
+
Validates qa-ai-output/release-gate.yaml shape and decision rules.
|
|
28
|
+
`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function validateReleaseGateFile(cwd, filePath, options = {}) {
|
|
32
|
+
const gatePath = resolveRepoPath(cwd, filePath, { label: 'release gate' });
|
|
33
|
+
if (!await pathExists(gatePath)) {
|
|
34
|
+
if (options.allowMissing) {
|
|
35
|
+
return { ok: true, skipped: true, path: filePath };
|
|
36
|
+
}
|
|
37
|
+
return { ok: false, errors: [`Release gate not found at ${filePath}.`] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let data;
|
|
41
|
+
try {
|
|
42
|
+
data = parseSimpleYaml(await readText(gatePath));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return { ok: false, errors: [`${filePath} is not valid YAML: ${error.message}`] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = validateReleaseGateData(data, {
|
|
48
|
+
source: filePath,
|
|
49
|
+
allowPending: Boolean(options.allowPending)
|
|
50
|
+
});
|
|
51
|
+
const errors = [...result.errors];
|
|
52
|
+
|
|
53
|
+
for (const relPath of result.evidence || []) {
|
|
54
|
+
if (!relPath || relPath.includes('..')) {
|
|
55
|
+
errors.push(`${filePath}: invalid evidence_paths entry "${relPath}".`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!await pathExists(resolveRepoPath(cwd, relPath, { label: 'evidence path' }))) {
|
|
59
|
+
errors.push(`${filePath}: evidence_paths entry not found: ${relPath}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
ok: errors.length === 0,
|
|
65
|
+
skipped: false,
|
|
66
|
+
path: filePath,
|
|
67
|
+
decision: result.decision,
|
|
68
|
+
errors
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function main() {
|
|
73
|
+
if (args.help) {
|
|
74
|
+
printHelp();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
logHeader('QA AI release gate validator');
|
|
79
|
+
const configInfo = await loadQaAiConfig(cwd);
|
|
80
|
+
const gatePath = args.path
|
|
81
|
+
|| getConfigValue(configInfo.data, 'release.gatePath', 'qa-ai-output/release-gate.yaml');
|
|
82
|
+
|
|
83
|
+
const result = await validateReleaseGateFile(cwd, gatePath, {
|
|
84
|
+
allowMissing: Boolean(args['allow-missing']),
|
|
85
|
+
allowPending: Boolean(args['allow-pending'])
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (result.skipped) {
|
|
89
|
+
console.log(`Release gate not found at ${gatePath}.`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!result.ok) {
|
|
94
|
+
for (const error of result.errors) console.log(`[FAIL] ${error}`);
|
|
95
|
+
console.log(`\nFAILED - ${result.errors.length} release gate validation error(s).`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(`[PASS] ${gatePath} decision=${result.decision}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
main().catch((error) => {
|
|
103
|
+
console.error(error);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { normalizeColumn, parseMarkdownTable } from './lib/markdown-table.mjs';
|
|
5
|
+
import { validateTestManagementMapping } from './lib/test-management-mapping.mjs';
|
|
6
|
+
import {
|
|
7
|
+
getConfigValue,
|
|
8
|
+
listFilesRecursive,
|
|
9
|
+
loadQaAiConfig,
|
|
10
|
+
logHeader,
|
|
11
|
+
parseArgs,
|
|
12
|
+
pathExists,
|
|
13
|
+
readText,
|
|
14
|
+
relativeTo,
|
|
15
|
+
resolveRepoPath
|
|
16
|
+
} from './lib/utils.mjs';
|
|
17
|
+
|
|
18
|
+
const cwd = process.cwd();
|
|
19
|
+
const args = parseArgs(process.argv);
|
|
20
|
+
const idPattern = /\b(?:RF|TC|TEST|QA)[-_ ]?[A-Z0-9]+\b/gi;
|
|
21
|
+
const writeClaimPattern = /\b(?:created|updated|deleted|synced|archived|creado|actualizado|eliminado|sincronizado|archivado)\s+(?:in|to|from|en|a|de)\s+(?:testrail|zephyr|xray|jira)\b/i;
|
|
22
|
+
const requiredColumns = ['ID', 'Proposed action', 'Approval status'];
|
|
23
|
+
const proposalPattern = /\b(?:propose|proposed|proposal|pending|review|approve|approval|required|draft|plan|planned|proponer|propuesto|pendiente|revisar|aprobar|aprobaci[o\u00f3]n|requerida|borrador|planificado)\b/i;
|
|
24
|
+
const approvalPattern = /\b(?:approval|approve|pending approval|requires approval|aprobaci[o\u00f3]n|aprobar|pendiente|requiere aprobaci[o\u00f3]n)\b/i;
|
|
25
|
+
|
|
26
|
+
function printHelp() {
|
|
27
|
+
console.log(`Usage: node .qa-ai/scripts/validate-sync-plan.mjs [options]
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--path <file> Override sync plan path
|
|
31
|
+
--features <dir> Override configured feature root
|
|
32
|
+
--allow-empty Return success when no .feature files exist
|
|
33
|
+
--allow-missing Return success when the sync plan is missing
|
|
34
|
+
--help Show this help
|
|
35
|
+
|
|
36
|
+
Validates proposal-first language, feature identifier coverage, sync-plan table shape and duplicate plan IDs.
|
|
37
|
+
`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeId(value) {
|
|
41
|
+
return String(value || '').replace(/\s+/g, '-').toUpperCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function idsFromText(value) {
|
|
45
|
+
return [...String(value || '').matchAll(idPattern)].map((match) => normalizeId(match[0]));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseSyncPlanTable(content) {
|
|
49
|
+
const table = parseMarkdownTable(content, {
|
|
50
|
+
label: 'Sync plan table',
|
|
51
|
+
requiredColumns
|
|
52
|
+
});
|
|
53
|
+
const errors = [...table.errors];
|
|
54
|
+
const rows = [];
|
|
55
|
+
for (const row of table.rows) {
|
|
56
|
+
const ids = [...new Set(idsFromText(row.cells.join(' ')))].sort();
|
|
57
|
+
const proposedAction = row.values[normalizeColumn('Proposed action')] || '';
|
|
58
|
+
const approvalStatus = row.values[normalizeColumn('Approval status')] || '';
|
|
59
|
+
|
|
60
|
+
if (ids.length === 0) errors.push(`Line ${row.line}: row must include at least one RF/test identifier.`);
|
|
61
|
+
if (proposedAction && !proposalPattern.test(proposedAction)) {
|
|
62
|
+
errors.push(`Line ${row.line}: proposed action must stay proposal-first.`);
|
|
63
|
+
}
|
|
64
|
+
if (approvalStatus && !approvalPattern.test(approvalStatus)) {
|
|
65
|
+
errors.push(`Line ${row.line}: approval status must clearly require or wait for approval.`);
|
|
66
|
+
}
|
|
67
|
+
if (writeClaimPattern.test(row.cells.join(' '))) {
|
|
68
|
+
errors.push(`Line ${row.line}: row appears to claim an external write happened; sync plans must stay proposal-first.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
rows.push({ ...row, ids });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { errors, rows, header: table.header };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function duplicatePlanErrors(rows) {
|
|
78
|
+
const byId = new Map();
|
|
79
|
+
const errors = [];
|
|
80
|
+
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
for (const id of row.ids) {
|
|
83
|
+
const current = byId.get(id) || [];
|
|
84
|
+
current.push(row.line);
|
|
85
|
+
byId.set(id, current);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const [id, lines] of byId.entries()) {
|
|
90
|
+
if (lines.length > 1) errors.push(`Identifier ${id} appears in multiple sync plan rows: ${lines.join(', ')}.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return errors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function collectFeatureIds(featureRootPath) {
|
|
97
|
+
const files = await listFilesRecursive(featureRootPath, (filePath) => filePath.endsWith('.feature'));
|
|
98
|
+
const entries = [];
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
const content = await fs.readFile(file, 'utf8');
|
|
101
|
+
entries.push({
|
|
102
|
+
file,
|
|
103
|
+
ids: [...new Set([...idsFromText(path.basename(file, '.feature')), ...idsFromText(content)])].sort()
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return entries;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function validateMappingFile(config, errors) {
|
|
110
|
+
const mappingFile = getConfigValue(config, 'testrail.mappingFile', '');
|
|
111
|
+
if (!mappingFile) return;
|
|
112
|
+
const mappingPath = resolveRepoPath(cwd, mappingFile, { label: 'test management mapping file' });
|
|
113
|
+
if (!await pathExists(mappingPath)) return;
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(await readText(mappingPath));
|
|
116
|
+
errors.push(...validateTestManagementMapping(parsed, { source: mappingFile }));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
errors.push(`${mappingFile} is not valid JSON: ${error.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function main() {
|
|
123
|
+
if (args.help) {
|
|
124
|
+
printHelp();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
logHeader('QA AI sync plan validator');
|
|
129
|
+
const configInfo = await loadQaAiConfig(cwd);
|
|
130
|
+
const featureRoot = args.features || getConfigValue(configInfo.data, 'gherkin.featurePath', 'features');
|
|
131
|
+
const syncPlanPath = args.path || getConfigValue(configInfo.data, 'testrail.syncPlanPath', 'qa-ai-output/testrail-sync-plan.md');
|
|
132
|
+
const featureRootPath = resolveRepoPath(cwd, featureRoot, { label: 'feature root' });
|
|
133
|
+
const syncPlanFilePath = resolveRepoPath(cwd, syncPlanPath, { label: 'sync plan' });
|
|
134
|
+
const features = await collectFeatureIds(featureRootPath);
|
|
135
|
+
|
|
136
|
+
if (features.length === 0) {
|
|
137
|
+
console.log(`No .feature files found under ${featureRoot}.`);
|
|
138
|
+
if (args['allow-empty']) return;
|
|
139
|
+
console.log('\nFAILED - no feature files found. Pass --allow-empty when this is expected.');
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!await pathExists(syncPlanFilePath)) {
|
|
144
|
+
console.log(`Sync plan not found at ${syncPlanPath}.`);
|
|
145
|
+
if (args['allow-missing']) return;
|
|
146
|
+
console.log('\nFAILED - create the sync plan or pass --allow-missing.');
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const content = await readText(syncPlanFilePath);
|
|
151
|
+
const normalizedContent = normalizeId(content);
|
|
152
|
+
const syncPlan = parseSyncPlanTable(content);
|
|
153
|
+
const errors = [];
|
|
154
|
+
errors.push(...syncPlan.errors);
|
|
155
|
+
errors.push(...duplicatePlanErrors(syncPlan.rows));
|
|
156
|
+
|
|
157
|
+
if (writeClaimPattern.test(content)) {
|
|
158
|
+
errors.push(`${syncPlanPath} appears to claim an external write happened; MVP sync plans must stay proposal-first.`);
|
|
159
|
+
}
|
|
160
|
+
if (!/\b(?:approval|approve|aprobaci[o\u00f3]n|aprobar)\b/i.test(content)) {
|
|
161
|
+
errors.push(`${syncPlanPath} must mention required approval before external writes.`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const feature of features) {
|
|
165
|
+
for (const id of feature.ids) {
|
|
166
|
+
if (!normalizedContent.includes(id)) {
|
|
167
|
+
errors.push(`${relativeTo(cwd, feature.file)} identifier ${id} is missing from ${syncPlanPath}.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await validateMappingFile(configInfo.data, errors);
|
|
173
|
+
|
|
174
|
+
if (errors.length > 0) {
|
|
175
|
+
for (const error of errors) console.log(`[FAIL] ${error}`);
|
|
176
|
+
console.log(`\nFAILED - ${errors.length} sync plan validation error(s).`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log(`[PASS] ${syncPlanPath} is proposal-first and covers ${features.length} feature file(s).`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
main().catch((error) => {
|
|
184
|
+
console.error(error);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { normalizeQaTrack } from './lib/qa-next-steps.mjs';
|
|
4
|
+
import { getConfigValue, loadQaAiConfig, logHeader, parseArgs } from './lib/utils.mjs';
|
|
5
|
+
|
|
6
|
+
const args = parseArgs(process.argv);
|
|
7
|
+
|
|
8
|
+
function printHelp() {
|
|
9
|
+
console.log(`Usage: node .qa-ai/scripts/validate-target.mjs [options]
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--allow-empty Pass --allow-empty to feature, traceability and sync-plan validators
|
|
13
|
+
--allow-missing Pass --allow-missing to traceability, sync-plan, active-specialist and release-gate validators
|
|
14
|
+
--no-strict-doctor Run doctor without --strict
|
|
15
|
+
--skip-release-gate Skip release gate validation (enterprise track only)
|
|
16
|
+
--skip-test-design Skip test design markdown validation
|
|
17
|
+
--allow-pending Pass --allow-pending to release gate validator
|
|
18
|
+
--help Show this help
|
|
19
|
+
|
|
20
|
+
Runs the target-repository validation pipeline:
|
|
21
|
+
doctor --strict
|
|
22
|
+
validate-features
|
|
23
|
+
validate-traceability
|
|
24
|
+
validate-sync-plan
|
|
25
|
+
validate-active-specialists
|
|
26
|
+
validate-release-gate (enterprise track only)
|
|
27
|
+
validate-test-design (standard and enterprise tracks)
|
|
28
|
+
`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function command(label, script, extraArgs = []) {
|
|
32
|
+
return { label, args: [script, ...extraArgs] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function run(commandSpec) {
|
|
36
|
+
console.log(`\n--- ${commandSpec.label} ---`);
|
|
37
|
+
const result = spawnSync(process.execPath, commandSpec.args, {
|
|
38
|
+
cwd: process.cwd(),
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
stdio: 'inherit',
|
|
41
|
+
shell: false
|
|
42
|
+
});
|
|
43
|
+
return result.status ?? 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
if (args.help) {
|
|
48
|
+
printHelp();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
logHeader('QA AI target repository validator');
|
|
53
|
+
const allowEmpty = Boolean(args['allow-empty']);
|
|
54
|
+
const allowMissing = Boolean(args['allow-missing']);
|
|
55
|
+
const strictDoctor = !args['no-strict-doctor'];
|
|
56
|
+
const configInfo = await loadQaAiConfig(process.cwd());
|
|
57
|
+
const track = normalizeQaTrack(getConfigValue(configInfo.data, 'project.qaTrack', 'standard'));
|
|
58
|
+
|
|
59
|
+
const featureArgs = allowEmpty ? ['--allow-empty'] : [];
|
|
60
|
+
const artifactArgs = [
|
|
61
|
+
...(allowEmpty ? ['--allow-empty'] : []),
|
|
62
|
+
...(allowMissing ? ['--allow-missing'] : [])
|
|
63
|
+
];
|
|
64
|
+
const activeSpecialistArgs = allowMissing ? ['--allow-missing'] : [];
|
|
65
|
+
|
|
66
|
+
const commands = [
|
|
67
|
+
command('doctor', '.qa-ai/scripts/doctor.mjs', strictDoctor ? ['--strict'] : []),
|
|
68
|
+
command('feature validation', '.qa-ai/scripts/validate-features.mjs', featureArgs),
|
|
69
|
+
command('traceability validation', '.qa-ai/scripts/validate-traceability.mjs', artifactArgs),
|
|
70
|
+
command('active specialist validation', '.qa-ai/scripts/validate-active-specialists.mjs', activeSpecialistArgs)
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
if (track !== 'quick') {
|
|
74
|
+
commands.splice(3, 0, command('sync plan validation', '.qa-ai/scripts/validate-sync-plan.mjs', artifactArgs));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (track === 'enterprise' && !args['skip-release-gate']) {
|
|
78
|
+
const gateArgs = [
|
|
79
|
+
...(allowMissing ? ['--allow-missing'] : []),
|
|
80
|
+
...(args['allow-pending'] ? ['--allow-pending'] : [])
|
|
81
|
+
];
|
|
82
|
+
commands.push(command('release gate validation', '.qa-ai/scripts/validate-release-gate.mjs', gateArgs));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (['standard', 'enterprise'].includes(track) && !args['skip-test-design']) {
|
|
86
|
+
const designArgs = allowMissing ? ['--allow-missing'] : [];
|
|
87
|
+
commands.push(command('test design validation', '.qa-ai/scripts/validate-test-design.mjs', designArgs));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const commandSpec of commands) {
|
|
91
|
+
const exitCode = run(commandSpec);
|
|
92
|
+
if (exitCode !== 0) {
|
|
93
|
+
console.log(`\nFAILED - ${commandSpec.label} failed.`);
|
|
94
|
+
process.exit(exitCode);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log('\nVALID - target repository validation passed.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
main().catch((error) => {
|
|
102
|
+
console.error(error);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
});
|