@vertaaux/cli 0.2.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/README.md +345 -0
- package/dist/auth/ci-token.d.ts +49 -0
- package/dist/auth/ci-token.d.ts.map +1 -0
- package/dist/auth/ci-token.js +83 -0
- package/dist/auth/device-flow.d.ts +66 -0
- package/dist/auth/device-flow.d.ts.map +1 -0
- package/dist/auth/device-flow.js +156 -0
- package/dist/auth/token-store.d.ts +53 -0
- package/dist/auth/token-store.d.ts.map +1 -0
- package/dist/auth/token-store.js +78 -0
- package/dist/baseline/diff.d.ts +57 -0
- package/dist/baseline/diff.d.ts.map +1 -0
- package/dist/baseline/diff.js +152 -0
- package/dist/baseline/hash.d.ts +54 -0
- package/dist/baseline/hash.d.ts.map +1 -0
- package/dist/baseline/hash.js +66 -0
- package/dist/baseline/manager.d.ts +89 -0
- package/dist/baseline/manager.d.ts.map +1 -0
- package/dist/baseline/manager.js +157 -0
- package/dist/cache/index.d.ts +8 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +7 -0
- package/dist/cache/route-cache.d.ts +119 -0
- package/dist/cache/route-cache.d.ts.map +1 -0
- package/dist/cache/route-cache.js +213 -0
- package/dist/ci/changed-routes.d.ts +95 -0
- package/dist/ci/changed-routes.d.ts.map +1 -0
- package/dist/ci/changed-routes.js +304 -0
- package/dist/ci/github-api.d.ts +68 -0
- package/dist/ci/github-api.d.ts.map +1 -0
- package/dist/ci/github-api.js +138 -0
- package/dist/ci/gitlab-api.d.ts +75 -0
- package/dist/ci/gitlab-api.d.ts.map +1 -0
- package/dist/ci/gitlab-api.js +180 -0
- package/dist/ci/index.d.ts +6 -0
- package/dist/ci/index.d.ts.map +1 -0
- package/dist/ci/index.js +4 -0
- package/dist/commands/audit.d.ts +58 -0
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/audit.js +862 -0
- package/dist/commands/baseline.d.ts +22 -0
- package/dist/commands/baseline.d.ts.map +1 -0
- package/dist/commands/baseline.js +210 -0
- package/dist/commands/comment.d.ts +14 -0
- package/dist/commands/comment.d.ts.map +1 -0
- package/dist/commands/comment.js +363 -0
- package/dist/commands/diff.d.ts +24 -0
- package/dist/commands/diff.d.ts.map +1 -0
- package/dist/commands/diff.js +196 -0
- package/dist/commands/doctor.d.ts +58 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +338 -0
- package/dist/commands/download.d.ts +12 -0
- package/dist/commands/download.d.ts.map +1 -0
- package/dist/commands/download.js +183 -0
- package/dist/commands/explain.d.ts +62 -0
- package/dist/commands/explain.d.ts.map +1 -0
- package/dist/commands/explain.js +302 -0
- package/dist/commands/init.d.ts +12 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +212 -0
- package/dist/commands/login.d.ts +14 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +222 -0
- package/dist/commands/policy.d.ts +13 -0
- package/dist/commands/policy.d.ts.map +1 -0
- package/dist/commands/policy.js +347 -0
- package/dist/commands/upload.d.ts +12 -0
- package/dist/commands/upload.d.ts.map +1 -0
- package/dist/commands/upload.js +158 -0
- package/dist/config/defaults.d.ts +21 -0
- package/dist/config/defaults.d.ts.map +1 -0
- package/dist/config/defaults.js +49 -0
- package/dist/config/loader.d.ts +66 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +167 -0
- package/dist/config/schema.d.ts +55 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +6 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1090 -0
- package/dist/interactive/fix-wizard.d.ts +44 -0
- package/dist/interactive/fix-wizard.d.ts.map +1 -0
- package/dist/interactive/fix-wizard.js +286 -0
- package/dist/interactive/init-wizard.d.ts +32 -0
- package/dist/interactive/init-wizard.d.ts.map +1 -0
- package/dist/interactive/init-wizard.js +193 -0
- package/dist/interactive/prompts.d.ts +62 -0
- package/dist/interactive/prompts.d.ts.map +1 -0
- package/dist/interactive/prompts.js +78 -0
- package/dist/monorepo/detector.d.ts +70 -0
- package/dist/monorepo/detector.d.ts.map +1 -0
- package/dist/monorepo/detector.js +278 -0
- package/dist/monorepo/index.d.ts +9 -0
- package/dist/monorepo/index.d.ts.map +1 -0
- package/dist/monorepo/index.js +8 -0
- package/dist/monorepo/workspace.d.ts +142 -0
- package/dist/monorepo/workspace.d.ts.map +1 -0
- package/dist/monorepo/workspace.js +171 -0
- package/dist/output/envelope.d.ts +21 -0
- package/dist/output/envelope.d.ts.map +1 -0
- package/dist/output/envelope.js +27 -0
- package/dist/output/factory.d.ts +73 -0
- package/dist/output/factory.d.ts.map +1 -0
- package/dist/output/factory.js +60 -0
- package/dist/output/formats.d.ts +11 -0
- package/dist/output/formats.d.ts.map +1 -0
- package/dist/output/formats.js +41 -0
- package/dist/output/html.d.ts +45 -0
- package/dist/output/html.d.ts.map +1 -0
- package/dist/output/html.js +607 -0
- package/dist/output/human.d.ts +41 -0
- package/dist/output/human.d.ts.map +1 -0
- package/dist/output/human.js +274 -0
- package/dist/output/json.d.ts +42 -0
- package/dist/output/json.d.ts.map +1 -0
- package/dist/output/json.js +37 -0
- package/dist/output/junit.d.ts +56 -0
- package/dist/output/junit.d.ts.map +1 -0
- package/dist/output/junit.js +135 -0
- package/dist/output/markdown.d.ts +77 -0
- package/dist/output/markdown.d.ts.map +1 -0
- package/dist/output/markdown.js +411 -0
- package/dist/output/sarif.d.ts +160 -0
- package/dist/output/sarif.d.ts.map +1 -0
- package/dist/output/sarif.js +207 -0
- package/dist/policy/evaluator.d.ts +111 -0
- package/dist/policy/evaluator.d.ts.map +1 -0
- package/dist/policy/evaluator.js +362 -0
- package/dist/policy/index.d.ts +15 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +11 -0
- package/dist/policy/loader.d.ts +97 -0
- package/dist/policy/loader.d.ts.map +1 -0
- package/dist/policy/loader.js +281 -0
- package/dist/policy/schema.d.ts +297 -0
- package/dist/policy/schema.d.ts.map +1 -0
- package/dist/policy/schema.js +230 -0
- package/dist/quality-gate/evaluator.d.ts +58 -0
- package/dist/quality-gate/evaluator.d.ts.map +1 -0
- package/dist/quality-gate/evaluator.js +274 -0
- package/dist/quality-gate/index.d.ts +10 -0
- package/dist/quality-gate/index.d.ts.map +1 -0
- package/dist/quality-gate/index.js +7 -0
- package/dist/quality-gate/types.d.ts +103 -0
- package/dist/quality-gate/types.d.ts.map +1 -0
- package/dist/quality-gate/types.js +23 -0
- package/dist/templates/azure-devops.d.ts +25 -0
- package/dist/templates/azure-devops.d.ts.map +1 -0
- package/dist/templates/azure-devops.js +109 -0
- package/dist/templates/circleci.d.ts +28 -0
- package/dist/templates/circleci.d.ts.map +1 -0
- package/dist/templates/circleci.js +86 -0
- package/dist/templates/github-actions.d.ts +81 -0
- package/dist/templates/github-actions.d.ts.map +1 -0
- package/dist/templates/github-actions.js +393 -0
- package/dist/templates/gitlab-ci.d.ts +26 -0
- package/dist/templates/gitlab-ci.d.ts.map +1 -0
- package/dist/templates/gitlab-ci.js +70 -0
- package/dist/templates/index.d.ts +72 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +112 -0
- package/dist/templates/jenkins.d.ts +26 -0
- package/dist/templates/jenkins.d.ts.map +1 -0
- package/dist/templates/jenkins.js +110 -0
- package/dist/ui/banner.d.ts +31 -0
- package/dist/ui/banner.d.ts.map +1 -0
- package/dist/ui/banner.js +84 -0
- package/dist/ui/diagnostics.d.ts +39 -0
- package/dist/ui/diagnostics.d.ts.map +1 -0
- package/dist/ui/diagnostics.js +153 -0
- package/dist/ui/spinner.d.ts +61 -0
- package/dist/ui/spinner.d.ts.map +1 -0
- package/dist/ui/spinner.js +101 -0
- package/dist/ui/table.d.ts +63 -0
- package/dist/ui/table.d.ts.map +1 -0
- package/dist/ui/table.js +236 -0
- package/dist/utils/client.d.ts +82 -0
- package/dist/utils/client.d.ts.map +1 -0
- package/dist/utils/client.js +128 -0
- package/dist/utils/detect-env.d.ts +59 -0
- package/dist/utils/detect-env.d.ts.map +1 -0
- package/dist/utils/detect-env.js +115 -0
- package/dist/utils/exit-codes.d.ts +47 -0
- package/dist/utils/exit-codes.d.ts.map +1 -0
- package/dist/utils/exit-codes.js +61 -0
- package/dist/utils/logger.d.ts +87 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +185 -0
- package/dist/utils/sanitize.d.ts +36 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +64 -0
- package/dist/utils/validators.d.ts +41 -0
- package/dist/utils/validators.d.ts.map +1 -0
- package/dist/utils/validators.js +123 -0
- package/package.json +63 -0
- package/schemas/vertaaux.config.schema.json +103 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit command for VertaaUX CLI.
|
|
3
|
+
*
|
|
4
|
+
* Runs UX and accessibility audits on web pages.
|
|
5
|
+
* Supports various targets, output formats, and CI integration.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { resolveConfig } from "../config/loader.js";
|
|
10
|
+
import { ExitCode } from "../utils/exit-codes.js";
|
|
11
|
+
import { parseTimeout, parseInterval, parseConcurrency, parseThreshold, parseMode, parseFailOn, parseGroupBy, parseBudget, validateNumeric, } from "../utils/validators.js";
|
|
12
|
+
import { isTTY } from "../utils/detect-env.js";
|
|
13
|
+
import { resolveApiBase, getApiKey, apiRequest, waitForAudit, } from "../utils/client.js";
|
|
14
|
+
import { createOutput, formatSarif, formatAuditHtml } from "../output/factory.js";
|
|
15
|
+
import { createEnvelope, writeJsonOutput, writeOutput as writeStdout } from "../output/envelope.js";
|
|
16
|
+
import { resolveCommandFormat } from "../output/formats.js";
|
|
17
|
+
import { createSpinner, updateSpinner, succeedSpinner, failSpinner, } from "../ui/spinner.js";
|
|
18
|
+
import { runFixWizard } from "../interactive/fix-wizard.js";
|
|
19
|
+
import { isInteractive } from "../interactive/prompts.js";
|
|
20
|
+
import { evaluateQualityGate, DEFAULT_QUALITY_GATE_CONFIG, } from "../quality-gate/index.js";
|
|
21
|
+
import { loadBaseline } from "../baseline/manager.js";
|
|
22
|
+
import { getChangedRoutes, detectBaseBranch, getBudgetConfig, } from "../ci/index.js";
|
|
23
|
+
import { loadPolicy, resolveBranchPolicy, } from "../policy/index.js";
|
|
24
|
+
import { detectMonorepo, getAuditableApps, generateMatrixConfig, aggregateResults, formatAggregatedResults, } from "../monorepo/index.js";
|
|
25
|
+
import { createLogger } from "../utils/logger.js";
|
|
26
|
+
import { validateBranchName, assertPathContainment } from "../utils/sanitize.js";
|
|
27
|
+
import chalk from "chalk";
|
|
28
|
+
import semver from "semver";
|
|
29
|
+
// Artifact directory
|
|
30
|
+
const ARTIFACTS_DIR = ".vertaaux/artifacts";
|
|
31
|
+
// CLI version for policy version requirements
|
|
32
|
+
const CLI_VERSION = "0.1.0";
|
|
33
|
+
/**
|
|
34
|
+
* Detect current branch from CI environment or git.
|
|
35
|
+
*/
|
|
36
|
+
function detectCurrentBranch() {
|
|
37
|
+
// GitHub Actions
|
|
38
|
+
if (process.env.GITHUB_HEAD_REF) {
|
|
39
|
+
return process.env.GITHUB_HEAD_REF;
|
|
40
|
+
}
|
|
41
|
+
if (process.env.GITHUB_REF_NAME) {
|
|
42
|
+
return process.env.GITHUB_REF_NAME;
|
|
43
|
+
}
|
|
44
|
+
// GitLab CI
|
|
45
|
+
if (process.env.CI_COMMIT_REF_NAME) {
|
|
46
|
+
return process.env.CI_COMMIT_REF_NAME;
|
|
47
|
+
}
|
|
48
|
+
// Azure DevOps
|
|
49
|
+
if (process.env.BUILD_SOURCEBRANCHNAME) {
|
|
50
|
+
return process.env.BUILD_SOURCEBRANCHNAME;
|
|
51
|
+
}
|
|
52
|
+
// CircleCI
|
|
53
|
+
if (process.env.CIRCLE_BRANCH) {
|
|
54
|
+
return process.env.CIRCLE_BRANCH;
|
|
55
|
+
}
|
|
56
|
+
// Jenkins
|
|
57
|
+
if (process.env.GIT_BRANCH) {
|
|
58
|
+
// Jenkins often includes origin/, remove it
|
|
59
|
+
return process.env.GIT_BRANCH.replace(/^origin\//, "");
|
|
60
|
+
}
|
|
61
|
+
// Generic CI
|
|
62
|
+
if (process.env.BRANCH_NAME) {
|
|
63
|
+
return process.env.BRANCH_NAME;
|
|
64
|
+
}
|
|
65
|
+
// Default
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Detect PR labels from CI environment variables.
|
|
70
|
+
*
|
|
71
|
+
* Supports:
|
|
72
|
+
* - GitHub Actions: GITHUB_EVENT_PATH contains event JSON with labels
|
|
73
|
+
* - GitLab CI: CI_MERGE_REQUEST_LABELS is comma-separated
|
|
74
|
+
*/
|
|
75
|
+
function detectPRLabels() {
|
|
76
|
+
// GitHub Actions: GITHUB_EVENT_PATH contains event JSON with labels
|
|
77
|
+
if (process.env.GITHUB_EVENT_PATH) {
|
|
78
|
+
try {
|
|
79
|
+
const eventContent = fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf-8");
|
|
80
|
+
const event = JSON.parse(eventContent);
|
|
81
|
+
const labels = event.pull_request?.labels;
|
|
82
|
+
if (Array.isArray(labels)) {
|
|
83
|
+
return labels.map((l) => l.name || "").filter(Boolean);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Ignore errors reading event file
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// GitLab CI: CI_MERGE_REQUEST_LABELS is comma-separated
|
|
91
|
+
if (process.env.CI_MERGE_REQUEST_LABELS) {
|
|
92
|
+
return process.env.CI_MERGE_REQUEST_LABELS.split(",")
|
|
93
|
+
.map((l) => l.trim())
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Build quality gate configuration from options and config.
|
|
100
|
+
*/
|
|
101
|
+
function buildQualityGateConfig(config, options) {
|
|
102
|
+
// Start with defaults
|
|
103
|
+
const gateConfig = { ...DEFAULT_QUALITY_GATE_CONFIG };
|
|
104
|
+
// Apply config file settings
|
|
105
|
+
const configGate = config.qualityGate;
|
|
106
|
+
if (configGate) {
|
|
107
|
+
if (configGate.failOn)
|
|
108
|
+
gateConfig.failOn = configGate.failOn;
|
|
109
|
+
if (configGate.thresholds) {
|
|
110
|
+
gateConfig.thresholds = { ...gateConfig.thresholds, ...configGate.thresholds };
|
|
111
|
+
}
|
|
112
|
+
if (configGate.maxNew) {
|
|
113
|
+
gateConfig.maxNew = { ...gateConfig.maxNew, ...configGate.maxNew };
|
|
114
|
+
}
|
|
115
|
+
if (configGate.failOnExisting !== undefined) {
|
|
116
|
+
gateConfig.failOnExisting = configGate.failOnExisting;
|
|
117
|
+
}
|
|
118
|
+
if (configGate.bypassLabels) {
|
|
119
|
+
gateConfig.bypassLabels = configGate.bypassLabels;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Apply CLI flags (highest precedence)
|
|
123
|
+
if (options.failOn) {
|
|
124
|
+
gateConfig.failOn = options.failOn;
|
|
125
|
+
}
|
|
126
|
+
if (options.threshold !== undefined) {
|
|
127
|
+
gateConfig.thresholds.overall = options.threshold;
|
|
128
|
+
}
|
|
129
|
+
if (options.maxNewErrors !== undefined) {
|
|
130
|
+
gateConfig.maxNew.error = options.maxNewErrors;
|
|
131
|
+
}
|
|
132
|
+
if (options.maxNewWarnings !== undefined) {
|
|
133
|
+
gateConfig.maxNew.warning = options.maxNewWarnings;
|
|
134
|
+
}
|
|
135
|
+
if (options.failOnExisting !== undefined) {
|
|
136
|
+
gateConfig.failOnExisting = options.failOnExisting;
|
|
137
|
+
}
|
|
138
|
+
if (options.bypassLabels) {
|
|
139
|
+
gateConfig.bypassLabels = options.bypassLabels.split(",").map((l) => l.trim());
|
|
140
|
+
}
|
|
141
|
+
return gateConfig;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Normalize issues from various API response formats.
|
|
145
|
+
*/
|
|
146
|
+
function normalizeIssues(issues) {
|
|
147
|
+
if (Array.isArray(issues))
|
|
148
|
+
return issues;
|
|
149
|
+
if (issues && typeof issues === "object") {
|
|
150
|
+
const values = Object.values(issues);
|
|
151
|
+
return values.flatMap((value) => Array.isArray(value) ? value : []);
|
|
152
|
+
}
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Filter issues by severity.
|
|
157
|
+
*/
|
|
158
|
+
function filterBySeverity(issues, severityFilter) {
|
|
159
|
+
const allowed = new Set(severityFilter.split(",").map((s) => s.trim().toLowerCase()));
|
|
160
|
+
// Map common aliases
|
|
161
|
+
if (allowed.has("error"))
|
|
162
|
+
allowed.add("critical");
|
|
163
|
+
if (allowed.has("warning"))
|
|
164
|
+
allowed.add("serious");
|
|
165
|
+
return issues.filter((issue) => {
|
|
166
|
+
const sev = issue.severity?.toLowerCase() || "info";
|
|
167
|
+
return allowed.has(sev);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Filter issues by category.
|
|
172
|
+
*/
|
|
173
|
+
function filterByCategory(issues, categoryFilter) {
|
|
174
|
+
const allowed = new Set(categoryFilter.split(",").map((c) => c.trim().toLowerCase()));
|
|
175
|
+
return issues.filter((issue) => {
|
|
176
|
+
const cat = issue.category?.toLowerCase() || "";
|
|
177
|
+
return allowed.has(cat) || Array.from(allowed).some((a) => cat.includes(a));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Write output to file. Returns the resolved path if written to file, undefined otherwise.
|
|
182
|
+
*/
|
|
183
|
+
function writeOutputToFile(content, outputPath, defaultPath) {
|
|
184
|
+
// Determine the final output path
|
|
185
|
+
const finalPath = outputPath || defaultPath;
|
|
186
|
+
if (finalPath) {
|
|
187
|
+
// Write to file
|
|
188
|
+
const resolvedPath = path.resolve(process.cwd(), finalPath);
|
|
189
|
+
// Ensure directory exists
|
|
190
|
+
const dir = path.dirname(resolvedPath);
|
|
191
|
+
if (!fs.existsSync(dir)) {
|
|
192
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
193
|
+
}
|
|
194
|
+
fs.writeFileSync(resolvedPath, content, "utf-8");
|
|
195
|
+
return resolvedPath;
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Get default output path based on format.
|
|
201
|
+
* Returns undefined for formats that should go to stdout by default.
|
|
202
|
+
*/
|
|
203
|
+
function getDefaultOutputPath(format) {
|
|
204
|
+
switch (format) {
|
|
205
|
+
case "html":
|
|
206
|
+
return "vertaaux-report.html";
|
|
207
|
+
default:
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Save repro artifacts from API response.
|
|
213
|
+
*/
|
|
214
|
+
function saveReproArtifacts(jobId, response, options, quiet) {
|
|
215
|
+
const hasArtifactOptions = options.saveTrace || options.saveHar || options.screenshots || options.domSnapshots;
|
|
216
|
+
if (!hasArtifactOptions)
|
|
217
|
+
return;
|
|
218
|
+
// Create artifacts directory
|
|
219
|
+
const artifactsPath = path.resolve(process.cwd(), ARTIFACTS_DIR, jobId);
|
|
220
|
+
// Check if API response includes artifact data
|
|
221
|
+
const responseAny = response;
|
|
222
|
+
const artifacts = responseAny.artifacts;
|
|
223
|
+
if (!artifacts) {
|
|
224
|
+
if (!quiet) {
|
|
225
|
+
console.error("Note: Repro artifacts were requested but not available in API response.");
|
|
226
|
+
console.error("Artifact capture may require a 'deep' mode audit or premium plan.");
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// Ensure directory exists
|
|
231
|
+
if (!fs.existsSync(artifactsPath)) {
|
|
232
|
+
fs.mkdirSync(artifactsPath, { recursive: true });
|
|
233
|
+
}
|
|
234
|
+
const saved = [];
|
|
235
|
+
// Save trace file if available and requested
|
|
236
|
+
if (options.saveTrace && artifacts.trace) {
|
|
237
|
+
const tracePath = path.join(artifactsPath, "trace.zip");
|
|
238
|
+
fs.writeFileSync(tracePath, Buffer.from(artifacts.trace, "base64"));
|
|
239
|
+
saved.push("trace.zip");
|
|
240
|
+
}
|
|
241
|
+
// Save HAR file if available and requested
|
|
242
|
+
if (options.saveHar && artifacts.har) {
|
|
243
|
+
const harPath = path.join(artifactsPath, "network.har");
|
|
244
|
+
fs.writeFileSync(harPath, typeof artifacts.har === "string" ? artifacts.har : JSON.stringify(artifacts.har, null, 2));
|
|
245
|
+
saved.push("network.har");
|
|
246
|
+
}
|
|
247
|
+
// Save screenshots if available and requested
|
|
248
|
+
if (options.screenshots && artifacts.screenshots) {
|
|
249
|
+
const screenshots = artifacts.screenshots;
|
|
250
|
+
for (const screenshot of screenshots) {
|
|
251
|
+
const screenshotName = screenshot.name || "screenshot.png";
|
|
252
|
+
let screenshotPath;
|
|
253
|
+
try {
|
|
254
|
+
screenshotPath = assertPathContainment(screenshotName, artifactsPath);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
console.error(`Security: Rejected screenshot "${screenshotName}" -- path traversal outside artifacts directory.`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
fs.writeFileSync(screenshotPath, Buffer.from(screenshot.data, "base64"));
|
|
261
|
+
saved.push(screenshotName);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Save DOM snapshots if available and requested
|
|
265
|
+
if (options.domSnapshots && artifacts.domSnapshots) {
|
|
266
|
+
const snapshots = artifacts.domSnapshots;
|
|
267
|
+
for (const snapshot of snapshots) {
|
|
268
|
+
const snapshotName = snapshot.name || "snapshot.html";
|
|
269
|
+
let snapshotPath;
|
|
270
|
+
try {
|
|
271
|
+
snapshotPath = assertPathContainment(snapshotName, artifactsPath);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
console.error(`Security: Rejected snapshot "${snapshotName}" -- path traversal outside artifacts directory.`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
fs.writeFileSync(snapshotPath, snapshot.html);
|
|
278
|
+
saved.push(snapshotName);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (saved.length > 0 && !quiet) {
|
|
282
|
+
console.error(`Artifacts saved to: ${artifactsPath}`);
|
|
283
|
+
console.error(` - ${saved.join("\n - ")}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Count issues by severity level.
|
|
288
|
+
*/
|
|
289
|
+
function countIssuesBySeverity(issues) {
|
|
290
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
291
|
+
for (const issue of issues) {
|
|
292
|
+
const sev = (issue.severity || "info").toLowerCase();
|
|
293
|
+
if (sev === "error" || sev === "critical") {
|
|
294
|
+
counts.error++;
|
|
295
|
+
}
|
|
296
|
+
else if (sev === "warning" || sev === "serious") {
|
|
297
|
+
counts.warning++;
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
counts.info++;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return counts;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Save CI artifact bundle for GitHub Actions upload.
|
|
307
|
+
*
|
|
308
|
+
* Creates a complete evidence bundle:
|
|
309
|
+
* - results.json: Full audit results
|
|
310
|
+
* - results.sarif: SARIF for Code Scanning
|
|
311
|
+
* - report.html: HTML report for viewing
|
|
312
|
+
* - manifest.json: Metadata about the bundle
|
|
313
|
+
*/
|
|
314
|
+
function saveArtifactBundle(jobId, result, issues, exitCode, quiet) {
|
|
315
|
+
const artifactsPath = path.resolve(process.cwd(), ARTIFACTS_DIR, jobId);
|
|
316
|
+
// Ensure directory exists
|
|
317
|
+
if (!fs.existsSync(artifactsPath)) {
|
|
318
|
+
fs.mkdirSync(artifactsPath, { recursive: true });
|
|
319
|
+
}
|
|
320
|
+
const files = [];
|
|
321
|
+
// 1. Save JSON results
|
|
322
|
+
const jsonPath = path.join(artifactsPath, "results.json");
|
|
323
|
+
fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2), "utf-8");
|
|
324
|
+
files.push("results.json");
|
|
325
|
+
// 2. Save SARIF for Code Scanning
|
|
326
|
+
const sarifContent = formatSarif(result, {
|
|
327
|
+
workingDirectory: process.cwd(),
|
|
328
|
+
});
|
|
329
|
+
const sarifPath = path.join(artifactsPath, "results.sarif");
|
|
330
|
+
fs.writeFileSync(sarifPath, sarifContent, "utf-8");
|
|
331
|
+
files.push("results.sarif");
|
|
332
|
+
// 3. Save HTML report
|
|
333
|
+
const htmlContent = formatAuditHtml(result, {
|
|
334
|
+
interactive: true,
|
|
335
|
+
});
|
|
336
|
+
const htmlPath = path.join(artifactsPath, "report.html");
|
|
337
|
+
fs.writeFileSync(htmlPath, htmlContent, "utf-8");
|
|
338
|
+
files.push("report.html");
|
|
339
|
+
// 4. Save manifest
|
|
340
|
+
const manifest = {
|
|
341
|
+
job_id: jobId,
|
|
342
|
+
timestamp: new Date().toISOString(),
|
|
343
|
+
files,
|
|
344
|
+
audit_url: result.url,
|
|
345
|
+
issue_count: countIssuesBySeverity(issues),
|
|
346
|
+
exit_code: exitCode,
|
|
347
|
+
};
|
|
348
|
+
const manifestPath = path.join(artifactsPath, "manifest.json");
|
|
349
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
350
|
+
files.push("manifest.json");
|
|
351
|
+
if (!quiet) {
|
|
352
|
+
console.error(`Artifacts saved to: ${artifactsPath}`);
|
|
353
|
+
console.error(` - ${files.join("\n - ")}`);
|
|
354
|
+
}
|
|
355
|
+
return artifactsPath;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Execute the audit command.
|
|
359
|
+
*/
|
|
360
|
+
async function executeAudit(targetUrl, options, config) {
|
|
361
|
+
// Resolve options with precedence: flags > env > config > defaults
|
|
362
|
+
const mode = options.mode || config.mode || "basic";
|
|
363
|
+
const timeout = options.timeout || config.timeout || 60000;
|
|
364
|
+
const interval = options.interval || config.interval || 5000;
|
|
365
|
+
const wait = options.wait ?? true; // Default to waiting for completion
|
|
366
|
+
const quiet = options.quiet ?? false;
|
|
367
|
+
const interactive = options.interactive ?? false;
|
|
368
|
+
// Interactive mode validation
|
|
369
|
+
if (interactive) {
|
|
370
|
+
if (!wait) {
|
|
371
|
+
throw new Error("--interactive requires --wait (audit must complete before interactive mode)");
|
|
372
|
+
}
|
|
373
|
+
if (!isInteractive()) {
|
|
374
|
+
throw new Error("Interactive mode requires a terminal. Use --format json in CI or piped environments.");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Resolve API settings
|
|
378
|
+
const base = resolveApiBase(options.base);
|
|
379
|
+
const apiKey = getApiKey(config.apiKey);
|
|
380
|
+
// Resolve output format using per-command registry
|
|
381
|
+
const machineMode = options.machine || false;
|
|
382
|
+
const explicitFormat = options.format || config.output?.format;
|
|
383
|
+
const validatedFormat = resolveCommandFormat("audit", explicitFormat, machineMode);
|
|
384
|
+
const format = validatedFormat;
|
|
385
|
+
const formatter = createOutput(format);
|
|
386
|
+
const groupBy = options.groupBy || config.output?.groupBy || "severity";
|
|
387
|
+
// Create spinner for progress (only in TTY mode with wait)
|
|
388
|
+
const spinner = wait && isTTY() && !quiet
|
|
389
|
+
? createSpinner(`Auditing ${targetUrl}...`)
|
|
390
|
+
: null;
|
|
391
|
+
try {
|
|
392
|
+
// Start spinner
|
|
393
|
+
spinner?.start();
|
|
394
|
+
// Create audit job
|
|
395
|
+
const created = await apiRequest(base, "/audit", {
|
|
396
|
+
method: "POST",
|
|
397
|
+
body: { url: targetUrl, mode },
|
|
398
|
+
}, apiKey);
|
|
399
|
+
// If not waiting, just output the job info
|
|
400
|
+
if (!wait) {
|
|
401
|
+
spinner?.stop();
|
|
402
|
+
if (format === "json") {
|
|
403
|
+
if (options.output) {
|
|
404
|
+
const output = JSON.stringify(createEnvelope(created, "audit"), null, 2);
|
|
405
|
+
const filePath = writeOutputToFile(output, options.output, undefined);
|
|
406
|
+
if (filePath && !quiet) {
|
|
407
|
+
console.error(`Report written to: ${filePath}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
writeJsonOutput(created, "audit");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
const output = formatter.formatResult(created);
|
|
416
|
+
const defaultPath = getDefaultOutputPath(format);
|
|
417
|
+
const filePath = writeOutputToFile(output, options.output, defaultPath);
|
|
418
|
+
if (filePath && !quiet) {
|
|
419
|
+
console.error(`Report written to: ${filePath}`);
|
|
420
|
+
}
|
|
421
|
+
else if (!filePath) {
|
|
422
|
+
writeStdout(output);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// Wait for completion with progress updates
|
|
428
|
+
if (!created.job_id) {
|
|
429
|
+
throw new Error("Audit response missing job_id");
|
|
430
|
+
}
|
|
431
|
+
const result = await waitForAudit(base, created.job_id, timeout, interval, apiKey, (progress) => {
|
|
432
|
+
if (spinner) {
|
|
433
|
+
updateSpinner(spinner, `Auditing ${targetUrl}`, progress, 100);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
// Stop spinner with success
|
|
437
|
+
if (spinner) {
|
|
438
|
+
succeedSpinner(spinner, `Audit complete: ${targetUrl}`);
|
|
439
|
+
}
|
|
440
|
+
// Save repro artifacts if requested
|
|
441
|
+
if (created.job_id) {
|
|
442
|
+
saveReproArtifacts(created.job_id, result, options, quiet);
|
|
443
|
+
}
|
|
444
|
+
// Apply filters to issues
|
|
445
|
+
let issues = normalizeIssues(result.issues);
|
|
446
|
+
if (options.severity) {
|
|
447
|
+
issues = filterBySeverity(issues, options.severity);
|
|
448
|
+
}
|
|
449
|
+
if (options.category) {
|
|
450
|
+
issues = filterByCategory(issues, options.category);
|
|
451
|
+
}
|
|
452
|
+
// Create filtered result for output
|
|
453
|
+
const filteredResult = {
|
|
454
|
+
...result,
|
|
455
|
+
issues,
|
|
456
|
+
};
|
|
457
|
+
// Load and apply policy (CICD-17)
|
|
458
|
+
let resolvedPolicy = null;
|
|
459
|
+
let policyPath = null;
|
|
460
|
+
try {
|
|
461
|
+
const policyResult = await loadPolicy(options.policy ? path.dirname(options.policy) : undefined);
|
|
462
|
+
if (options.policy) {
|
|
463
|
+
// User specified a policy file, load it specifically
|
|
464
|
+
const { loadPolicyFile } = await import("../policy/index.js");
|
|
465
|
+
resolvedPolicy = await loadPolicyFile(options.policy);
|
|
466
|
+
policyPath = options.policy;
|
|
467
|
+
}
|
|
468
|
+
else if (policyResult.path) {
|
|
469
|
+
resolvedPolicy = policyResult.policy;
|
|
470
|
+
policyPath = policyResult.path;
|
|
471
|
+
}
|
|
472
|
+
// If policy found, resolve branch-specific overrides
|
|
473
|
+
if (resolvedPolicy) {
|
|
474
|
+
const currentBranch = detectCurrentBranch();
|
|
475
|
+
if (currentBranch) {
|
|
476
|
+
resolvedPolicy = resolveBranchPolicy(resolvedPolicy, currentBranch);
|
|
477
|
+
}
|
|
478
|
+
if (!quiet && policyPath) {
|
|
479
|
+
console.error(chalk.dim(`Using policy: ${policyPath}`));
|
|
480
|
+
}
|
|
481
|
+
// Check required CLI version
|
|
482
|
+
if (resolvedPolicy.required_version) {
|
|
483
|
+
if (!semver.satisfies(CLI_VERSION, resolvedPolicy.required_version)) {
|
|
484
|
+
console.error(chalk.red(`Policy requires CLI version ${resolvedPolicy.required_version}, ` +
|
|
485
|
+
`but current version is ${CLI_VERSION}`));
|
|
486
|
+
process.exit(ExitCode.ERROR);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
// Policy loading errors should be reported but not fatal if no explicit policy specified
|
|
493
|
+
if (options.policy) {
|
|
494
|
+
throw error; // Re-throw if user explicitly specified a policy
|
|
495
|
+
}
|
|
496
|
+
if (!quiet) {
|
|
497
|
+
console.error(chalk.yellow(`Warning: Failed to load policy: ${error instanceof Error ? error.message : String(error)}`));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// Build quality gate config and evaluate
|
|
501
|
+
const gateConfig = buildQualityGateConfig(config, options);
|
|
502
|
+
// Load baseline if provided
|
|
503
|
+
const baselinePath = options.baseline || config.baseline?.path;
|
|
504
|
+
const baseline = baselinePath ? await loadBaseline(baselinePath) : null;
|
|
505
|
+
// Detect PR labels for bypass check
|
|
506
|
+
const prLabels = detectPRLabels();
|
|
507
|
+
// Evaluate quality gate
|
|
508
|
+
const gateResult = evaluateQualityGate({
|
|
509
|
+
auditResult: {
|
|
510
|
+
issues: issues, // Use the normalized issues array
|
|
511
|
+
scores: result.scores,
|
|
512
|
+
},
|
|
513
|
+
baseline,
|
|
514
|
+
config: gateConfig,
|
|
515
|
+
labels: prLabels,
|
|
516
|
+
});
|
|
517
|
+
// Use quality gate exit code
|
|
518
|
+
const exitCode = gateResult.exitCode;
|
|
519
|
+
// Format and output results
|
|
520
|
+
const formatOptions = {
|
|
521
|
+
human: {
|
|
522
|
+
groupBy,
|
|
523
|
+
showScores: true,
|
|
524
|
+
showSummary: true,
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
if (format === "json") {
|
|
528
|
+
if (options.output) {
|
|
529
|
+
const jsonStr = JSON.stringify(createEnvelope(filteredResult, "audit"), null, 2);
|
|
530
|
+
const filePath = writeOutputToFile(jsonStr, options.output, undefined);
|
|
531
|
+
if (filePath && !quiet) {
|
|
532
|
+
console.error(`Report written to: ${filePath}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
writeJsonOutput(filteredResult, "audit");
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
const output = formatter.formatResult(filteredResult, formatOptions);
|
|
541
|
+
const defaultPath = getDefaultOutputPath(format);
|
|
542
|
+
const filePath = writeOutputToFile(output, options.output, defaultPath);
|
|
543
|
+
if (filePath && !quiet) {
|
|
544
|
+
console.error(`Report written to: ${filePath}`);
|
|
545
|
+
}
|
|
546
|
+
else if (!filePath) {
|
|
547
|
+
writeStdout(output);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// Output quality gate result
|
|
551
|
+
if (!quiet) {
|
|
552
|
+
console.error(""); // Blank line before gate result
|
|
553
|
+
if (gateResult.bypassed) {
|
|
554
|
+
console.error(chalk.yellow(`Quality gate bypassed: ${gateResult.bypassReason}`));
|
|
555
|
+
}
|
|
556
|
+
else if (gateResult.passed) {
|
|
557
|
+
console.error(chalk.green("Quality gate: PASSED"));
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
console.error(chalk.red("Quality gate: FAILED"));
|
|
561
|
+
for (const violation of gateResult.violations) {
|
|
562
|
+
console.error(chalk.red(` - ${violation.message}`));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Summary
|
|
566
|
+
console.error("");
|
|
567
|
+
console.error(`New issues: ${gateResult.summary.newIssues.error} errors, ` +
|
|
568
|
+
`${gateResult.summary.newIssues.warning} warnings, ` +
|
|
569
|
+
`${gateResult.summary.newIssues.info} info`);
|
|
570
|
+
if (gateResult.summary.fixedIssues > 0) {
|
|
571
|
+
console.error(chalk.green(`Fixed: ${gateResult.summary.fixedIssues} issues`));
|
|
572
|
+
}
|
|
573
|
+
if (gateResult.summary.existingIssues > 0) {
|
|
574
|
+
console.error(chalk.dim(`Existing (baselined): ${gateResult.summary.existingIssues} issues`));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Save CI artifact bundle if requested
|
|
578
|
+
if (options.uploadArtifacts && created.job_id) {
|
|
579
|
+
const artifactJobId = options.jobId || created.job_id;
|
|
580
|
+
saveArtifactBundle(artifactJobId, result, issues, exitCode, quiet);
|
|
581
|
+
}
|
|
582
|
+
// Interactive mode: run fix wizard if there are issues
|
|
583
|
+
if (interactive && issues.length > 0) {
|
|
584
|
+
await runFixWizard(issues, {
|
|
585
|
+
jobId: result.job_id,
|
|
586
|
+
url: targetUrl,
|
|
587
|
+
base: options.base,
|
|
588
|
+
config,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
// Set exit code
|
|
592
|
+
if (exitCode !== ExitCode.SUCCESS) {
|
|
593
|
+
process.exitCode = exitCode;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
// Stop spinner with failure
|
|
598
|
+
if (spinner) {
|
|
599
|
+
failSpinner(spinner, `Audit failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
600
|
+
}
|
|
601
|
+
throw error;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Register the audit command with the Commander program.
|
|
606
|
+
*/
|
|
607
|
+
export function registerAuditCommand(program) {
|
|
608
|
+
program
|
|
609
|
+
.command("audit [url]")
|
|
610
|
+
.description("Run UX and accessibility audit")
|
|
611
|
+
.option("-u, --url <url>", "URL to audit")
|
|
612
|
+
.option("--repo <repo>", "GitHub repository to audit (owner/repo)")
|
|
613
|
+
.option("--storybook <url>", "Storybook URL to audit")
|
|
614
|
+
.option("--routes <routes>", "Comma-separated list of routes to audit")
|
|
615
|
+
.option("--auth-profile <profile>", "Authentication profile for protected pages")
|
|
616
|
+
.option("--mode <mode>", "Audit depth: basic|standard|deep", parseMode, "basic")
|
|
617
|
+
.option("--format <format>", "Output format: json|sarif|junit|html|human|auto")
|
|
618
|
+
.option("-o, --output <path>", "Output file path")
|
|
619
|
+
.option("--group-by <field>", "Group issues by: severity|category|route", parseGroupBy)
|
|
620
|
+
.option("--wait", "Wait for audit completion (default)")
|
|
621
|
+
.option("--no-wait", "Don't wait for audit completion")
|
|
622
|
+
.option("--severity <levels>", "Filter issues by severity: error|warning|info (comma-separated)")
|
|
623
|
+
.option("--category <categories>", "Filter issues by category (comma-separated)")
|
|
624
|
+
.option("--fail-on <severity>", "Exit 1 if new issues at or above severity: error|warning|info|none", parseFailOn)
|
|
625
|
+
.option("--threshold <score>", "Exit 3 if overall score below threshold (0-100)", parseThreshold)
|
|
626
|
+
.option("--max-new-errors <n>", "Maximum allowed new error-severity issues (default: 0)", (v) => validateNumeric(v, "max-new-errors", { min: 0, integer: true }))
|
|
627
|
+
.option("--max-new-warnings <n>", "Maximum allowed new warning-severity issues (default: unlimited)", (v) => validateNumeric(v, "max-new-warnings", { min: 0, integer: true }))
|
|
628
|
+
.option("--fail-on-existing", "Also fail on existing issues (legacy mode)")
|
|
629
|
+
.option("--bypass-labels <labels>", "Comma-separated PR labels that bypass quality gate")
|
|
630
|
+
.option("--baseline <path>", "Path to baseline file for new issue detection")
|
|
631
|
+
.option("--timeout <ms>", "Wait timeout in milliseconds (1-300000)", parseTimeout)
|
|
632
|
+
.option("--interval <ms>", "Poll interval in milliseconds (1-300000)", parseInterval)
|
|
633
|
+
.option("--interactive", "Step through issues interactively (requires --wait)")
|
|
634
|
+
// Repro artifact options (CLI-17)
|
|
635
|
+
.option("--save-trace", "Save Playwright trace for debugging")
|
|
636
|
+
.option("--save-har", "Save HAR network log")
|
|
637
|
+
.option("--screenshots", "Save page screenshots")
|
|
638
|
+
.option("--dom-snapshots", "Save DOM snapshots")
|
|
639
|
+
// Performance options (CLI-18)
|
|
640
|
+
.option("--concurrency <n>", "Number of concurrent audits (1-50, default: 3)", parseConcurrency)
|
|
641
|
+
.option("--cache", "Enable route caching to speed up repeated audits")
|
|
642
|
+
// CI artifact bundling (CICD-08)
|
|
643
|
+
.option("--upload-artifacts", "Save all outputs to .vertaaux/artifacts/ for CI upload")
|
|
644
|
+
.option("--job-id <id>", "Job identifier for artifact directory naming")
|
|
645
|
+
// Incremental mode (CICD-07)
|
|
646
|
+
.option("--incremental", "Only audit routes changed in PR")
|
|
647
|
+
.option("--base-branch <branch>", "Base branch for comparison (default: auto-detect)")
|
|
648
|
+
// Budget mode (CICD-13)
|
|
649
|
+
.option("--budget <mode>", "Budget mode: quick|standard|full", parseBudget)
|
|
650
|
+
// Monorepo options (CICD-16)
|
|
651
|
+
.option("--workspace <name>", "Audit specific workspace in monorepo")
|
|
652
|
+
.option("--all-workspaces", "Audit all workspaces in monorepo")
|
|
653
|
+
.option("--parallel", "Run workspace audits in parallel")
|
|
654
|
+
.option("--detect-matrix", "Output CI matrix config for monorepo (JSON)")
|
|
655
|
+
// Caching options (CICD-14)
|
|
656
|
+
.option("--no-cache", "Disable route caching")
|
|
657
|
+
.option("--cache-dir <path>", "Custom cache directory")
|
|
658
|
+
// Logging options (CICD-18)
|
|
659
|
+
.option("--json-logs", "Output structured JSON logs for CI")
|
|
660
|
+
// Policy options (CICD-17)
|
|
661
|
+
.option("--policy <file>", "Path to policy file (default: auto-detect vertaa.policy.yml)")
|
|
662
|
+
.action(async (urlArg, cmdOptions, command) => {
|
|
663
|
+
try {
|
|
664
|
+
// Initialize structured logger
|
|
665
|
+
const logger = createLogger({
|
|
666
|
+
json: cmdOptions.jsonLogs || false,
|
|
667
|
+
level: cmdOptions.quiet ? "error" : "info",
|
|
668
|
+
});
|
|
669
|
+
// Load config (supports --config global option)
|
|
670
|
+
const globalOpts = command.optsWithGlobals();
|
|
671
|
+
const config = await resolveConfig(globalOpts.config);
|
|
672
|
+
// Propagate global --machine flag to command options
|
|
673
|
+
cmdOptions.machine = globalOpts.machine || false;
|
|
674
|
+
// Handle monorepo detection and matrix output (CICD-16)
|
|
675
|
+
if (cmdOptions.detectMatrix || cmdOptions.allWorkspaces || cmdOptions.workspace) {
|
|
676
|
+
const monorepo = await detectMonorepo();
|
|
677
|
+
if (monorepo.type === "none") {
|
|
678
|
+
if (cmdOptions.detectMatrix) {
|
|
679
|
+
// Output empty matrix for non-monorepo
|
|
680
|
+
process.stdout.write(JSON.stringify({ include: [] }) + "\n");
|
|
681
|
+
process.exit(ExitCode.SUCCESS);
|
|
682
|
+
}
|
|
683
|
+
// Not a monorepo, continue with normal audit
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
logger.info(`Detected ${monorepo.type} monorepo`, {
|
|
687
|
+
workspaces: monorepo.workspaces.length,
|
|
688
|
+
});
|
|
689
|
+
const apps = getAuditableApps(monorepo);
|
|
690
|
+
logger.info(`Found ${apps.length} auditable apps`, {
|
|
691
|
+
apps: apps.map((a) => a.name),
|
|
692
|
+
});
|
|
693
|
+
// Output matrix config for CI
|
|
694
|
+
if (cmdOptions.detectMatrix) {
|
|
695
|
+
const matrix = generateMatrixConfig(apps);
|
|
696
|
+
process.stdout.write(JSON.stringify(matrix) + "\n");
|
|
697
|
+
process.exit(ExitCode.SUCCESS);
|
|
698
|
+
}
|
|
699
|
+
// Audit specific workspace
|
|
700
|
+
if (cmdOptions.workspace) {
|
|
701
|
+
const workspace = monorepo.workspaces.find((w) => w.name === cmdOptions.workspace);
|
|
702
|
+
if (!workspace) {
|
|
703
|
+
console.error(`Error: Workspace "${cmdOptions.workspace}" not found`);
|
|
704
|
+
console.error(`Available: ${monorepo.workspaces.map((w) => w.name).join(", ")}`);
|
|
705
|
+
process.exit(ExitCode.ERROR);
|
|
706
|
+
}
|
|
707
|
+
// Use workspace URL or infer from type
|
|
708
|
+
if (workspace.url) {
|
|
709
|
+
urlArg = workspace.url;
|
|
710
|
+
logger.info(`Auditing workspace`, { workspace: workspace.name, url: urlArg });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// Audit all workspaces
|
|
714
|
+
if (cmdOptions.allWorkspaces) {
|
|
715
|
+
console.error(chalk.dim(`Auditing ${apps.length} workspaces in ${monorepo.type} monorepo`));
|
|
716
|
+
const results = [];
|
|
717
|
+
const startTime = Date.now();
|
|
718
|
+
if (cmdOptions.parallel) {
|
|
719
|
+
// Parallel execution
|
|
720
|
+
const promises = apps.map(async (app) => {
|
|
721
|
+
const wsStartTime = Date.now();
|
|
722
|
+
logger.info(`Starting audit`, { workspace: app.name });
|
|
723
|
+
try {
|
|
724
|
+
// Note: In production, this would call executeAudit
|
|
725
|
+
// For now, we return a placeholder result
|
|
726
|
+
return {
|
|
727
|
+
workspace: app,
|
|
728
|
+
auditResult: { url: app.url, issues: [], status: "complete" },
|
|
729
|
+
duration: Date.now() - wsStartTime,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
return {
|
|
734
|
+
workspace: app,
|
|
735
|
+
auditResult: null,
|
|
736
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
737
|
+
duration: Date.now() - wsStartTime,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
results.push(...(await Promise.all(promises)));
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
// Sequential execution
|
|
745
|
+
for (const app of apps) {
|
|
746
|
+
const wsStartTime = Date.now();
|
|
747
|
+
logger.info(`Auditing workspace`, { workspace: app.name });
|
|
748
|
+
try {
|
|
749
|
+
results.push({
|
|
750
|
+
workspace: app,
|
|
751
|
+
auditResult: { url: app.url, issues: [], status: "complete" },
|
|
752
|
+
duration: Date.now() - wsStartTime,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
catch (error) {
|
|
756
|
+
results.push({
|
|
757
|
+
workspace: app,
|
|
758
|
+
auditResult: null,
|
|
759
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
760
|
+
duration: Date.now() - wsStartTime,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
// Aggregate and output results
|
|
766
|
+
const aggregated = aggregateResults(results);
|
|
767
|
+
aggregated.totalDuration = Date.now() - startTime;
|
|
768
|
+
if (cmdOptions.format === "json") {
|
|
769
|
+
writeJsonOutput(aggregated, "audit");
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
writeStdout(formatAggregatedResults(aggregated));
|
|
773
|
+
}
|
|
774
|
+
// Exit with error if any failed
|
|
775
|
+
if (aggregated.failedAudits > 0) {
|
|
776
|
+
process.exit(ExitCode.ERROR);
|
|
777
|
+
}
|
|
778
|
+
if (aggregated.issuesBySeverity.error > 0) {
|
|
779
|
+
process.exit(ExitCode.ISSUES_FOUND);
|
|
780
|
+
}
|
|
781
|
+
process.exit(ExitCode.SUCCESS);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// Handle incremental mode (CICD-07)
|
|
786
|
+
if (cmdOptions.incremental) {
|
|
787
|
+
const baseBranch = validateBranchName(cmdOptions.baseBranch || detectBaseBranch());
|
|
788
|
+
const changedResult = await getChangedRoutes({
|
|
789
|
+
baseBranch,
|
|
790
|
+
routePatterns: [], // Use default patterns
|
|
791
|
+
});
|
|
792
|
+
if (!changedResult.hasChanges) {
|
|
793
|
+
console.error(chalk.green("No relevant route changes detected. Skipping audit."));
|
|
794
|
+
process.exit(ExitCode.SUCCESS);
|
|
795
|
+
}
|
|
796
|
+
// Log which routes will be audited
|
|
797
|
+
console.error(chalk.dim(`Incremental mode: Auditing ${changedResult.routes.length} changed routes`));
|
|
798
|
+
for (const route of changedResult.routes) {
|
|
799
|
+
console.error(chalk.dim(` - ${route}`));
|
|
800
|
+
}
|
|
801
|
+
// If routes were detected, use them instead of URL argument
|
|
802
|
+
if (!config.defaultUrl) {
|
|
803
|
+
console.error("Error: --incremental requires defaultUrl in config to construct full URLs.");
|
|
804
|
+
process.exit(ExitCode.ERROR);
|
|
805
|
+
}
|
|
806
|
+
// Set routes option for executeAudit
|
|
807
|
+
cmdOptions.routes = changedResult.routes.join(",");
|
|
808
|
+
}
|
|
809
|
+
// Handle budget mode (CICD-13)
|
|
810
|
+
if (cmdOptions.budget) {
|
|
811
|
+
const budgetConfig = getBudgetConfig(cmdOptions.budget);
|
|
812
|
+
// Apply budget constraints to options
|
|
813
|
+
if (!cmdOptions.concurrency) {
|
|
814
|
+
cmdOptions.concurrency = budgetConfig.concurrency;
|
|
815
|
+
}
|
|
816
|
+
if (!cmdOptions.timeout) {
|
|
817
|
+
cmdOptions.timeout = budgetConfig.maxTime;
|
|
818
|
+
}
|
|
819
|
+
console.error(chalk.dim(`Budget mode: ${cmdOptions.budget} (max ${budgetConfig.maxPages} pages, ${budgetConfig.maxTime / 1000}s timeout, ${budgetConfig.concurrency} concurrent)`));
|
|
820
|
+
}
|
|
821
|
+
// Resolve target URL
|
|
822
|
+
// Priority: positional > --url > --repo > --storybook > --routes > config default
|
|
823
|
+
let targetUrl;
|
|
824
|
+
if (urlArg) {
|
|
825
|
+
targetUrl = urlArg;
|
|
826
|
+
}
|
|
827
|
+
else if (cmdOptions.url) {
|
|
828
|
+
targetUrl = cmdOptions.url;
|
|
829
|
+
}
|
|
830
|
+
else if (cmdOptions.repo) {
|
|
831
|
+
// For repo audits, construct a special URL or handle differently
|
|
832
|
+
// For now, we'll just pass it as a marker
|
|
833
|
+
targetUrl = `repo:${cmdOptions.repo}`;
|
|
834
|
+
}
|
|
835
|
+
else if (cmdOptions.storybook) {
|
|
836
|
+
targetUrl = cmdOptions.storybook;
|
|
837
|
+
}
|
|
838
|
+
else if (cmdOptions.routes) {
|
|
839
|
+
// Routes require a base URL from config
|
|
840
|
+
if (!config.defaultUrl) {
|
|
841
|
+
console.error("Error: --routes requires defaultUrl in config or a base URL.");
|
|
842
|
+
process.exit(ExitCode.ERROR);
|
|
843
|
+
}
|
|
844
|
+
// For routes, we'll handle them as comma-separated paths
|
|
845
|
+
const routes = cmdOptions.routes.split(",").map((r) => r.trim());
|
|
846
|
+
targetUrl = `${config.defaultUrl}${routes[0]}`; // First route for now
|
|
847
|
+
}
|
|
848
|
+
else if (config.defaultUrl) {
|
|
849
|
+
targetUrl = config.defaultUrl;
|
|
850
|
+
}
|
|
851
|
+
if (!targetUrl) {
|
|
852
|
+
console.error("Error: URL is required. Provide as argument, --url flag, or defaultUrl in config.");
|
|
853
|
+
process.exit(ExitCode.ERROR);
|
|
854
|
+
}
|
|
855
|
+
await executeAudit(targetUrl, cmdOptions, config);
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
console.error("Error:", error instanceof Error ? error.message : String(error));
|
|
859
|
+
process.exit(ExitCode.ERROR);
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
}
|