@vertaaux/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/MIGRATION.md +239 -0
  3. package/README.md +34 -16
  4. package/dist/app/interactive-app.d.ts +101 -0
  5. package/dist/app/interactive-app.d.ts.map +1 -0
  6. package/dist/app/interactive-app.js +309 -0
  7. package/dist/app/layout/canvas.d.ts +23 -0
  8. package/dist/app/layout/canvas.d.ts.map +1 -0
  9. package/dist/app/layout/canvas.js +36 -0
  10. package/dist/app/layout/footer.d.ts +31 -0
  11. package/dist/app/layout/footer.d.ts.map +1 -0
  12. package/dist/app/layout/footer.js +41 -0
  13. package/dist/app/layout/header.d.ts +20 -0
  14. package/dist/app/layout/header.d.ts.map +1 -0
  15. package/dist/app/layout/header.js +27 -0
  16. package/dist/app/menu/categories.d.ts +20 -0
  17. package/dist/app/menu/categories.d.ts.map +1 -0
  18. package/dist/app/menu/categories.js +181 -0
  19. package/dist/app/menu/filter.d.ts +17 -0
  20. package/dist/app/menu/filter.d.ts.map +1 -0
  21. package/dist/app/menu/filter.js +33 -0
  22. package/dist/app/menu/menu-view.d.ts +35 -0
  23. package/dist/app/menu/menu-view.d.ts.map +1 -0
  24. package/dist/app/menu/menu-view.js +230 -0
  25. package/dist/app/menu/recent.d.ts +24 -0
  26. package/dist/app/menu/recent.d.ts.map +1 -0
  27. package/dist/app/menu/recent.js +49 -0
  28. package/dist/app/types.d.ts +43 -0
  29. package/dist/app/types.d.ts.map +1 -0
  30. package/dist/app/types.js +7 -0
  31. package/dist/app/views/command-runner.d.ts +36 -0
  32. package/dist/app/views/command-runner.d.ts.map +1 -0
  33. package/dist/app/views/command-runner.js +372 -0
  34. package/dist/app/views/help-overlay.d.ts +21 -0
  35. package/dist/app/views/help-overlay.d.ts.map +1 -0
  36. package/dist/app/views/help-overlay.js +45 -0
  37. package/dist/auth/ci-token.d.ts +8 -2
  38. package/dist/auth/ci-token.d.ts.map +1 -1
  39. package/dist/auth/ci-token.js +15 -30
  40. package/dist/auth/device-flow.d.ts +2 -1
  41. package/dist/auth/device-flow.d.ts.map +1 -1
  42. package/dist/auth/device-flow.js +13 -10
  43. package/dist/auth/token-store.d.ts.map +1 -1
  44. package/dist/auth/token-store.js +12 -2
  45. package/dist/baseline/diff.d.ts +2 -2
  46. package/dist/baseline/diff.d.ts.map +1 -1
  47. package/dist/baseline/diff.js +15 -34
  48. package/dist/commands/a11y.d.ts +9 -0
  49. package/dist/commands/a11y.d.ts.map +1 -0
  50. package/dist/commands/a11y.js +76 -0
  51. package/dist/commands/audit/artifacts.d.ts +27 -0
  52. package/dist/commands/audit/artifacts.d.ts.map +1 -0
  53. package/dist/commands/audit/artifacts.js +158 -0
  54. package/dist/commands/audit/ci-detection.d.ts +18 -0
  55. package/dist/commands/audit/ci-detection.d.ts.map +1 -0
  56. package/dist/commands/audit/ci-detection.js +71 -0
  57. package/dist/commands/audit/explain.d.ts +11 -0
  58. package/dist/commands/audit/explain.d.ts.map +1 -0
  59. package/dist/commands/audit/explain.js +45 -0
  60. package/dist/commands/audit/filters.d.ts +17 -0
  61. package/dist/commands/audit/filters.d.ts.map +1 -0
  62. package/dist/commands/audit/filters.js +40 -0
  63. package/dist/commands/audit/index.d.ts +18 -0
  64. package/dist/commands/audit/index.d.ts.map +1 -0
  65. package/dist/commands/audit/index.js +564 -0
  66. package/dist/commands/audit/output.d.ts +32 -0
  67. package/dist/commands/audit/output.d.ts.map +1 -0
  68. package/dist/commands/audit/output.js +130 -0
  69. package/dist/commands/audit/policy.d.ts +19 -0
  70. package/dist/commands/audit/policy.d.ts.map +1 -0
  71. package/dist/commands/audit/policy.js +102 -0
  72. package/dist/commands/audit/scoring.d.ts +23 -0
  73. package/dist/commands/audit/scoring.d.ts.map +1 -0
  74. package/dist/commands/audit/scoring.js +70 -0
  75. package/dist/commands/audit/types.d.ts +88 -0
  76. package/dist/commands/audit/types.d.ts.map +1 -0
  77. package/dist/commands/audit/types.js +8 -0
  78. package/dist/commands/audit.d.ts +2 -60
  79. package/dist/commands/audit.d.ts.map +1 -1
  80. package/dist/commands/audit.js +2 -1097
  81. package/dist/commands/baseline.d.ts +1 -0
  82. package/dist/commands/baseline.d.ts.map +1 -1
  83. package/dist/commands/baseline.js +205 -121
  84. package/dist/commands/comment.d.ts +22 -0
  85. package/dist/commands/comment.d.ts.map +1 -1
  86. package/dist/commands/comment.js +122 -58
  87. package/dist/commands/compare.d.ts +17 -0
  88. package/dist/commands/compare.d.ts.map +1 -1
  89. package/dist/commands/compare.js +287 -180
  90. package/dist/commands/diff.d.ts +5 -0
  91. package/dist/commands/diff.d.ts.map +1 -1
  92. package/dist/commands/diff.js +168 -141
  93. package/dist/commands/doc.d.ts +10 -0
  94. package/dist/commands/doc.d.ts.map +1 -1
  95. package/dist/commands/doc.js +134 -76
  96. package/dist/commands/doctor.d.ts +2 -0
  97. package/dist/commands/doctor.d.ts.map +1 -1
  98. package/dist/commands/doctor.js +164 -17
  99. package/dist/commands/download.d.ts +10 -0
  100. package/dist/commands/download.d.ts.map +1 -1
  101. package/dist/commands/download.js +169 -112
  102. package/dist/commands/explain.d.ts +5 -0
  103. package/dist/commands/explain.d.ts.map +1 -1
  104. package/dist/commands/explain.js +241 -155
  105. package/dist/commands/fix-all.d.ts +25 -0
  106. package/dist/commands/fix-all.d.ts.map +1 -0
  107. package/dist/commands/fix-all.js +206 -0
  108. package/dist/commands/fix-plan.d.ts +9 -0
  109. package/dist/commands/fix-plan.d.ts.map +1 -1
  110. package/dist/commands/fix-plan.js +152 -89
  111. package/dist/commands/fix.d.ts +17 -0
  112. package/dist/commands/fix.d.ts.map +1 -0
  113. package/dist/commands/fix.js +111 -0
  114. package/dist/commands/init.d.ts +11 -0
  115. package/dist/commands/init.d.ts.map +1 -1
  116. package/dist/commands/init.js +94 -42
  117. package/dist/commands/login.d.ts +18 -0
  118. package/dist/commands/login.d.ts.map +1 -1
  119. package/dist/commands/login.js +263 -92
  120. package/dist/commands/patch-review.d.ts +11 -0
  121. package/dist/commands/patch-review.d.ts.map +1 -1
  122. package/dist/commands/patch-review.js +159 -97
  123. package/dist/commands/policy.d.ts +31 -0
  124. package/dist/commands/policy.d.ts.map +1 -1
  125. package/dist/commands/policy.js +269 -124
  126. package/dist/commands/release-notes.d.ts +10 -0
  127. package/dist/commands/release-notes.d.ts.map +1 -1
  128. package/dist/commands/release-notes.js +127 -73
  129. package/dist/commands/scan.d.ts +13 -0
  130. package/dist/commands/scan.d.ts.map +1 -0
  131. package/dist/commands/scan.js +133 -0
  132. package/dist/commands/status.d.ts +9 -0
  133. package/dist/commands/status.d.ts.map +1 -0
  134. package/dist/commands/status.js +81 -0
  135. package/dist/commands/suggest.d.ts +10 -0
  136. package/dist/commands/suggest.d.ts.map +1 -1
  137. package/dist/commands/suggest.js +153 -82
  138. package/dist/commands/triage.d.ts +35 -0
  139. package/dist/commands/triage.d.ts.map +1 -1
  140. package/dist/commands/triage.js +206 -81
  141. package/dist/commands/upload.d.ts +9 -0
  142. package/dist/commands/upload.d.ts.map +1 -1
  143. package/dist/commands/upload.js +140 -101
  144. package/dist/commands/verify.d.ts +13 -0
  145. package/dist/commands/verify.d.ts.map +1 -0
  146. package/dist/commands/verify.js +118 -0
  147. package/dist/index.d.ts +3 -2
  148. package/dist/index.d.ts.map +1 -1
  149. package/dist/index.js +125 -990
  150. package/dist/interactive/fix-wizard.d.ts +3 -0
  151. package/dist/interactive/fix-wizard.d.ts.map +1 -1
  152. package/dist/interactive/fix-wizard.js +130 -112
  153. package/dist/interactive/init-wizard.d.ts +3 -1
  154. package/dist/interactive/init-wizard.d.ts.map +1 -1
  155. package/dist/interactive/init-wizard.js +207 -138
  156. package/dist/interactive/prompts.d.ts +7 -3
  157. package/dist/interactive/prompts.d.ts.map +1 -1
  158. package/dist/interactive/prompts.js +44 -23
  159. package/dist/output/envelope.d.ts +2 -0
  160. package/dist/output/envelope.d.ts.map +1 -1
  161. package/dist/output/envelope.js +18 -2
  162. package/dist/output/factory.d.ts +2 -1
  163. package/dist/output/factory.d.ts.map +1 -1
  164. package/dist/output/html.d.ts +2 -1
  165. package/dist/output/html.d.ts.map +1 -1
  166. package/dist/output/html.js +3 -2
  167. package/dist/output/human.d.ts +2 -1
  168. package/dist/output/human.d.ts.map +1 -1
  169. package/dist/output/human.js +3 -2
  170. package/dist/output/json.d.ts +2 -1
  171. package/dist/output/json.d.ts.map +1 -1
  172. package/dist/output/junit.d.ts +2 -1
  173. package/dist/output/junit.d.ts.map +1 -1
  174. package/dist/output/sarif.d.ts +2 -1
  175. package/dist/output/sarif.d.ts.map +1 -1
  176. package/dist/types.d.ts +74 -0
  177. package/dist/types.d.ts.map +1 -0
  178. package/dist/types.js +5 -0
  179. package/dist/ui/banner.d.ts +34 -0
  180. package/dist/ui/banner.d.ts.map +1 -1
  181. package/dist/ui/banner.js +97 -5
  182. package/dist/ui/diagnostics.d.ts +9 -4
  183. package/dist/ui/diagnostics.d.ts.map +1 -1
  184. package/dist/ui/diagnostics.js +32 -82
  185. package/dist/ui/strings.d.ts +373 -0
  186. package/dist/ui/strings.d.ts.map +1 -0
  187. package/dist/ui/strings.js +499 -0
  188. package/dist/ui/table.d.ts +0 -2
  189. package/dist/ui/table.d.ts.map +1 -1
  190. package/dist/ui/table.js +3 -4
  191. package/dist/utils/api-client.d.ts +46 -0
  192. package/dist/utils/api-client.d.ts.map +1 -0
  193. package/dist/utils/api-client.js +170 -0
  194. package/dist/utils/client.d.ts +29 -18
  195. package/dist/utils/client.d.ts.map +1 -1
  196. package/dist/utils/client.js +102 -12
  197. package/dist/utils/formatters.d.ts +38 -0
  198. package/dist/utils/formatters.d.ts.map +1 -0
  199. package/dist/utils/formatters.js +277 -0
  200. package/dist/utils/url-classify.d.ts.map +1 -1
  201. package/dist/utils/url-classify.js +24 -3
  202. package/node_modules/@vertaaux/tui/dist/index.cjs +713 -20
  203. package/node_modules/@vertaaux/tui/dist/index.cjs.map +1 -1
  204. package/node_modules/@vertaaux/tui/dist/index.d.cts +361 -4
  205. package/node_modules/@vertaaux/tui/dist/index.d.ts +361 -4
  206. package/node_modules/@vertaaux/tui/dist/index.js +689 -21
  207. package/node_modules/@vertaaux/tui/dist/index.js.map +1 -1
  208. package/package.json +13 -5
  209. package/dist/commands/client.d.ts +0 -14
  210. package/dist/commands/client.d.ts.map +0 -1
  211. package/dist/commands/client.js +0 -362
  212. package/dist/commands/drift.d.ts +0 -15
  213. package/dist/commands/drift.d.ts.map +0 -1
  214. package/dist/commands/drift.js +0 -309
  215. package/dist/commands/protect.d.ts +0 -16
  216. package/dist/commands/protect.d.ts.map +0 -1
  217. package/dist/commands/protect.js +0 -323
  218. package/dist/commands/report.d.ts +0 -15
  219. package/dist/commands/report.d.ts.map +0 -1
  220. package/dist/commands/report.js +0 -214
  221. package/dist/policy/sync.d.ts +0 -67
  222. package/dist/policy/sync.d.ts.map +0 -1
  223. package/dist/policy/sync.js +0 -147
@@ -1,1097 +1,2 @@
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 { getVersion } from "../ui/banner.js";
18
- import { createSpinner, updateSpinner, succeedSpinner, failSpinner, } from "../ui/spinner.js";
19
- import { createRenderer, createKeyboardHandler, AuditPhase, phaseIndex, phaseTotal, } from "@vertaaux/tui";
20
- import { runFixWizard } from "../interactive/fix-wizard.js";
21
- import { isInteractive } from "../interactive/prompts.js";
22
- import { evaluateQualityGate, DEFAULT_QUALITY_GATE_CONFIG, } from "../quality-gate/index.js";
23
- import { loadBaseline } from "../baseline/manager.js";
24
- import { getChangedRoutes, detectBaseBranch, getBudgetConfig, } from "../ci/index.js";
25
- import { loadPolicy, resolveBranchPolicy, } from "../policy/index.js";
26
- import { detectMonorepo, getAuditableApps, generateMatrixConfig, aggregateResults, formatAggregatedResults, } from "../monorepo/index.js";
27
- import { createLogger } from "../utils/logger.js";
28
- import { validateBranchName, assertPathContainment } from "../utils/sanitize.js";
29
- import { isLocalUrl } from "../utils/url-classify.js";
30
- import { captureLocalPage } from "../utils/local-capture.js";
31
- import chalk from "chalk";
32
- import semver from "semver";
33
- // Artifact directory
34
- const ARTIFACTS_DIR = ".vertaaux/artifacts";
35
- // CLI version for policy version requirements (read from package.json)
36
- const CLI_VERSION = getVersion();
37
- /**
38
- * Detect current branch from CI environment or git.
39
- */
40
- function detectCurrentBranch() {
41
- // GitHub Actions
42
- if (process.env.GITHUB_HEAD_REF) {
43
- return process.env.GITHUB_HEAD_REF;
44
- }
45
- if (process.env.GITHUB_REF_NAME) {
46
- return process.env.GITHUB_REF_NAME;
47
- }
48
- // GitLab CI
49
- if (process.env.CI_COMMIT_REF_NAME) {
50
- return process.env.CI_COMMIT_REF_NAME;
51
- }
52
- // Azure DevOps
53
- if (process.env.BUILD_SOURCEBRANCHNAME) {
54
- return process.env.BUILD_SOURCEBRANCHNAME;
55
- }
56
- // CircleCI
57
- if (process.env.CIRCLE_BRANCH) {
58
- return process.env.CIRCLE_BRANCH;
59
- }
60
- // Jenkins
61
- if (process.env.GIT_BRANCH) {
62
- // Jenkins often includes origin/, remove it
63
- return process.env.GIT_BRANCH.replace(/^origin\//, "");
64
- }
65
- // Generic CI
66
- if (process.env.BRANCH_NAME) {
67
- return process.env.BRANCH_NAME;
68
- }
69
- // Default
70
- return "";
71
- }
72
- /**
73
- * Detect PR labels from CI environment variables.
74
- *
75
- * Supports:
76
- * - GitHub Actions: GITHUB_EVENT_PATH contains event JSON with labels
77
- * - GitLab CI: CI_MERGE_REQUEST_LABELS is comma-separated
78
- */
79
- function detectPRLabels() {
80
- // GitHub Actions: GITHUB_EVENT_PATH contains event JSON with labels
81
- if (process.env.GITHUB_EVENT_PATH) {
82
- try {
83
- const eventContent = fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf-8");
84
- const event = JSON.parse(eventContent);
85
- const labels = event.pull_request?.labels;
86
- if (Array.isArray(labels)) {
87
- return labels.map((l) => l.name || "").filter(Boolean);
88
- }
89
- }
90
- catch {
91
- // Ignore errors reading event file
92
- }
93
- }
94
- // GitLab CI: CI_MERGE_REQUEST_LABELS is comma-separated
95
- if (process.env.CI_MERGE_REQUEST_LABELS) {
96
- return process.env.CI_MERGE_REQUEST_LABELS.split(",")
97
- .map((l) => l.trim())
98
- .filter(Boolean);
99
- }
100
- return [];
101
- }
102
- /**
103
- * Build quality gate configuration from options and config.
104
- */
105
- function buildQualityGateConfig(config, options) {
106
- // Start with defaults
107
- const gateConfig = { ...DEFAULT_QUALITY_GATE_CONFIG };
108
- // Apply config file settings
109
- const configGate = config.qualityGate;
110
- if (configGate) {
111
- if (configGate.failOn)
112
- gateConfig.failOn = configGate.failOn;
113
- if (configGate.thresholds) {
114
- gateConfig.thresholds = { ...gateConfig.thresholds, ...configGate.thresholds };
115
- }
116
- if (configGate.maxNew) {
117
- gateConfig.maxNew = { ...gateConfig.maxNew, ...configGate.maxNew };
118
- }
119
- if (configGate.failOnExisting !== undefined) {
120
- gateConfig.failOnExisting = configGate.failOnExisting;
121
- }
122
- if (configGate.bypassLabels) {
123
- gateConfig.bypassLabels = configGate.bypassLabels;
124
- }
125
- }
126
- // Apply CLI flags (highest precedence)
127
- if (options.failOn) {
128
- gateConfig.failOn = options.failOn;
129
- }
130
- if (options.threshold !== undefined) {
131
- gateConfig.thresholds.overall = options.threshold;
132
- }
133
- if (options.maxNewErrors !== undefined) {
134
- gateConfig.maxNew.error = options.maxNewErrors;
135
- }
136
- if (options.maxNewWarnings !== undefined) {
137
- gateConfig.maxNew.warning = options.maxNewWarnings;
138
- }
139
- if (options.failOnExisting !== undefined) {
140
- gateConfig.failOnExisting = options.failOnExisting;
141
- }
142
- if (options.bypassLabels) {
143
- gateConfig.bypassLabels = options.bypassLabels.split(",").map((l) => l.trim());
144
- }
145
- return gateConfig;
146
- }
147
- /**
148
- * Normalize issues from various API response formats.
149
- */
150
- function normalizeIssues(issues) {
151
- if (Array.isArray(issues))
152
- return issues;
153
- if (issues && typeof issues === "object") {
154
- const values = Object.values(issues);
155
- return values.flatMap((value) => Array.isArray(value) ? value : []);
156
- }
157
- return [];
158
- }
159
- /**
160
- * Filter issues by severity.
161
- */
162
- function filterBySeverity(issues, severityFilter) {
163
- const allowed = new Set(severityFilter.split(",").map((s) => s.trim().toLowerCase()));
164
- // Map common aliases
165
- if (allowed.has("error"))
166
- allowed.add("critical");
167
- if (allowed.has("warning"))
168
- allowed.add("serious");
169
- return issues.filter((issue) => {
170
- const sev = issue.severity?.toLowerCase() || "info";
171
- return allowed.has(sev);
172
- });
173
- }
174
- /**
175
- * Filter issues by category.
176
- */
177
- function filterByCategory(issues, categoryFilter) {
178
- const allowed = new Set(categoryFilter.split(",").map((c) => c.trim().toLowerCase()));
179
- return issues.filter((issue) => {
180
- const cat = issue.category?.toLowerCase() || "";
181
- return allowed.has(cat) || Array.from(allowed).some((a) => cat.includes(a));
182
- });
183
- }
184
- /**
185
- * Write output to file. Returns the resolved path if written to file, undefined otherwise.
186
- */
187
- function writeOutputToFile(content, outputPath, defaultPath) {
188
- // Determine the final output path
189
- const finalPath = outputPath || defaultPath;
190
- if (finalPath) {
191
- // Write to file
192
- const resolvedPath = path.resolve(process.cwd(), finalPath);
193
- // Ensure directory exists
194
- const dir = path.dirname(resolvedPath);
195
- if (!fs.existsSync(dir)) {
196
- fs.mkdirSync(dir, { recursive: true });
197
- }
198
- fs.writeFileSync(resolvedPath, content, "utf-8");
199
- return resolvedPath;
200
- }
201
- return undefined;
202
- }
203
- /**
204
- * Get default output path based on format.
205
- * Returns undefined for formats that should go to stdout by default.
206
- */
207
- function getDefaultOutputPath(format) {
208
- switch (format) {
209
- case "html":
210
- return "vertaaux-report.html";
211
- default:
212
- return undefined;
213
- }
214
- }
215
- /**
216
- * Save repro artifacts from API response.
217
- */
218
- function saveReproArtifacts(jobId, response, options, quiet) {
219
- const hasArtifactOptions = options.saveTrace || options.saveHar || options.screenshots || options.domSnapshots;
220
- if (!hasArtifactOptions)
221
- return;
222
- // Create artifacts directory
223
- const artifactsPath = path.resolve(process.cwd(), ARTIFACTS_DIR, jobId);
224
- // Check if API response includes artifact data
225
- const responseAny = response;
226
- const artifacts = responseAny.artifacts;
227
- if (!artifacts) {
228
- if (!quiet) {
229
- console.error("Note: Repro artifacts were requested but not available in API response.");
230
- console.error("Artifact capture may require a 'deep' mode audit or premium plan.");
231
- }
232
- return;
233
- }
234
- // Ensure directory exists
235
- if (!fs.existsSync(artifactsPath)) {
236
- fs.mkdirSync(artifactsPath, { recursive: true });
237
- }
238
- const saved = [];
239
- // Save trace file if available and requested
240
- if (options.saveTrace && artifacts.trace) {
241
- const tracePath = path.join(artifactsPath, "trace.zip");
242
- fs.writeFileSync(tracePath, Buffer.from(artifacts.trace, "base64"));
243
- saved.push("trace.zip");
244
- }
245
- // Save HAR file if available and requested
246
- if (options.saveHar && artifacts.har) {
247
- const harPath = path.join(artifactsPath, "network.har");
248
- fs.writeFileSync(harPath, typeof artifacts.har === "string" ? artifacts.har : JSON.stringify(artifacts.har, null, 2));
249
- saved.push("network.har");
250
- }
251
- // Save screenshots if available and requested
252
- if (options.screenshots && artifacts.screenshots) {
253
- const screenshots = artifacts.screenshots;
254
- for (const screenshot of screenshots) {
255
- const screenshotName = screenshot.name || "screenshot.png";
256
- let screenshotPath;
257
- try {
258
- screenshotPath = assertPathContainment(screenshotName, artifactsPath);
259
- }
260
- catch {
261
- console.error(`Security: Rejected screenshot "${screenshotName}" -- path traversal outside artifacts directory.`);
262
- continue;
263
- }
264
- fs.writeFileSync(screenshotPath, Buffer.from(screenshot.data, "base64"));
265
- saved.push(screenshotName);
266
- }
267
- }
268
- // Save DOM snapshots if available and requested
269
- if (options.domSnapshots && artifacts.domSnapshots) {
270
- const snapshots = artifacts.domSnapshots;
271
- for (const snapshot of snapshots) {
272
- const snapshotName = snapshot.name || "snapshot.html";
273
- let snapshotPath;
274
- try {
275
- snapshotPath = assertPathContainment(snapshotName, artifactsPath);
276
- }
277
- catch {
278
- console.error(`Security: Rejected snapshot "${snapshotName}" -- path traversal outside artifacts directory.`);
279
- continue;
280
- }
281
- fs.writeFileSync(snapshotPath, snapshot.html);
282
- saved.push(snapshotName);
283
- }
284
- }
285
- if (saved.length > 0 && !quiet) {
286
- console.error(`Artifacts saved to: ${artifactsPath}`);
287
- console.error(` - ${saved.join("\n - ")}`);
288
- }
289
- }
290
- /**
291
- * Count issues by severity level.
292
- */
293
- function countIssuesBySeverity(issues) {
294
- const counts = { error: 0, warning: 0, info: 0 };
295
- for (const issue of issues) {
296
- const sev = (issue.severity || "info").toLowerCase();
297
- if (sev === "error" || sev === "critical") {
298
- counts.error++;
299
- }
300
- else if (sev === "warning" || sev === "serious") {
301
- counts.warning++;
302
- }
303
- else {
304
- counts.info++;
305
- }
306
- }
307
- return counts;
308
- }
309
- /**
310
- * Save CI artifact bundle for GitHub Actions upload.
311
- *
312
- * Creates a complete evidence bundle:
313
- * - results.json: Full audit results
314
- * - results.sarif: SARIF for Code Scanning
315
- * - report.html: HTML report for viewing
316
- * - manifest.json: Metadata about the bundle
317
- */
318
- function saveArtifactBundle(jobId, result, issues, exitCode, quiet) {
319
- const artifactsPath = path.resolve(process.cwd(), ARTIFACTS_DIR, jobId);
320
- // Ensure directory exists
321
- if (!fs.existsSync(artifactsPath)) {
322
- fs.mkdirSync(artifactsPath, { recursive: true });
323
- }
324
- const files = [];
325
- // 1. Save JSON results
326
- const jsonPath = path.join(artifactsPath, "results.json");
327
- fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2), "utf-8");
328
- files.push("results.json");
329
- // 2. Save SARIF for Code Scanning
330
- const sarifContent = formatSarif(result, {
331
- workingDirectory: process.cwd(),
332
- });
333
- const sarifPath = path.join(artifactsPath, "results.sarif");
334
- fs.writeFileSync(sarifPath, sarifContent, "utf-8");
335
- files.push("results.sarif");
336
- // 3. Save HTML report
337
- const htmlContent = formatAuditHtml(result, {
338
- interactive: true,
339
- });
340
- const htmlPath = path.join(artifactsPath, "report.html");
341
- fs.writeFileSync(htmlPath, htmlContent, "utf-8");
342
- files.push("report.html");
343
- // 4. Save manifest
344
- const manifest = {
345
- job_id: jobId,
346
- timestamp: new Date().toISOString(),
347
- files,
348
- audit_url: result.url,
349
- issue_count: countIssuesBySeverity(issues),
350
- exit_code: exitCode,
351
- };
352
- const manifestPath = path.join(artifactsPath, "manifest.json");
353
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
354
- files.push("manifest.json");
355
- if (!quiet) {
356
- console.error(`Artifacts saved to: ${artifactsPath}`);
357
- console.error(` - ${files.join("\n - ")}`);
358
- }
359
- return artifactsPath;
360
- }
361
- /**
362
- * Output results in fire-and-forget mode (--no-wait).
363
- */
364
- function outputFireAndForget(created, format, formatter, options, quiet) {
365
- if (format === "json") {
366
- if (options.output) {
367
- const output = JSON.stringify(createEnvelope(created, "audit"), null, 2);
368
- const filePath = writeOutputToFile(output, options.output);
369
- if (filePath && !quiet) {
370
- console.error(`Report written to: ${filePath}`);
371
- }
372
- }
373
- else {
374
- writeJsonOutput(created, "audit");
375
- }
376
- }
377
- else {
378
- const output = formatter.formatResult(created);
379
- const defaultPath = getDefaultOutputPath(format);
380
- const filePath = writeOutputToFile(output, options.output, defaultPath);
381
- if (filePath && !quiet) {
382
- console.error(`Report written to: ${filePath}`);
383
- }
384
- else if (!filePath) {
385
- writeStdout(output);
386
- }
387
- }
388
- }
389
- /**
390
- * Load and resolve a policy file with branch-specific overrides.
391
- */
392
- async function loadAndResolvePolicy(options, quiet) {
393
- let resolvedPolicy = null;
394
- let policyPath = null;
395
- try {
396
- const policyResult = await loadPolicy(options.policy ? path.dirname(options.policy) : undefined);
397
- if (options.policy) {
398
- const { loadPolicyFile } = await import("../policy/index.js");
399
- resolvedPolicy = await loadPolicyFile(options.policy);
400
- policyPath = options.policy;
401
- }
402
- else if (policyResult.path) {
403
- resolvedPolicy = policyResult.policy;
404
- policyPath = policyResult.path;
405
- }
406
- if (resolvedPolicy) {
407
- const currentBranch = detectCurrentBranch();
408
- if (currentBranch) {
409
- resolvedPolicy = resolveBranchPolicy(resolvedPolicy, currentBranch);
410
- }
411
- if (!quiet && policyPath) {
412
- console.error(chalk.dim(`Using policy: ${policyPath}`));
413
- }
414
- if (resolvedPolicy.required_version) {
415
- if (!semver.satisfies(CLI_VERSION, resolvedPolicy.required_version)) {
416
- console.error(chalk.red(`Policy requires CLI version ${resolvedPolicy.required_version}, ` +
417
- `but current version is ${CLI_VERSION}`));
418
- process.exit(ExitCode.ERROR);
419
- }
420
- }
421
- }
422
- }
423
- catch (error) {
424
- if (options.policy) {
425
- throw error;
426
- }
427
- if (!quiet) {
428
- console.error(chalk.yellow(`Warning: Failed to load policy: ${error instanceof Error ? error.message : String(error)}`));
429
- }
430
- }
431
- return { policy: resolvedPolicy, path: policyPath };
432
- }
433
- /**
434
- * Run the inline AI explanation for audit issues.
435
- */
436
- async function runInlineExplanation(issues, result, targetUrl, options, config) {
437
- try {
438
- const explainBase = resolveApiBase(options.base);
439
- const explainKey = getApiKey(config.apiKey);
440
- const explainIssues = issues.map((i) => ({
441
- id: i.id || null,
442
- title: i.title || i.description || null,
443
- description: i.description || null,
444
- severity: i.severity || null,
445
- category: i.category || null,
446
- selector: i.selector || null,
447
- wcag_reference: i.wcag_reference || null,
448
- recommendation: i.recommendation || i.recommended_fix || null,
449
- }));
450
- const explainPayload = {
451
- job_id: result.job_id || null,
452
- url: targetUrl || null,
453
- scores: result.scores || null,
454
- issues: explainIssues,
455
- };
456
- const explainSpinner = createSpinner("Generating AI explanation...");
457
- const explainResponse = await apiRequest(explainBase, "/cli/ai/explain", { method: "POST", body: { audit: explainPayload } }, explainKey);
458
- succeedSpinner(explainSpinner, "Explanation ready");
459
- console.error("");
460
- console.error(chalk.bold("AI Explanation"));
461
- console.error(chalk.dim("─".repeat(40)));
462
- for (const bullet of explainResponse.data.summary) {
463
- console.error(` ${chalk.cyan("*")} ${bullet}`);
464
- }
465
- }
466
- catch (explainErr) {
467
- console.error(chalk.dim(`\n(AI explanation unavailable: ${explainErr instanceof Error ? explainErr.message : String(explainErr)})`));
468
- }
469
- }
470
- /**
471
- * Print quality gate result to stderr.
472
- */
473
- function outputQualityGateResult(gateResult) {
474
- console.error("");
475
- if (gateResult.bypassed) {
476
- console.error(chalk.yellow(`Quality gate bypassed: ${gateResult.bypassReason}`));
477
- }
478
- else if (gateResult.passed) {
479
- console.error(chalk.green("Quality gate: PASSED"));
480
- }
481
- else {
482
- console.error(chalk.red("Quality gate: FAILED"));
483
- for (const violation of gateResult.violations) {
484
- console.error(chalk.red(` - ${violation.message}`));
485
- }
486
- }
487
- console.error("");
488
- console.error(`New issues: ${gateResult.summary.newIssues.error} errors, ` +
489
- `${gateResult.summary.newIssues.warning} warnings, ` +
490
- `${gateResult.summary.newIssues.info} info`);
491
- if (gateResult.summary.fixedIssues > 0) {
492
- console.error(chalk.green(`Fixed: ${gateResult.summary.fixedIssues} issues`));
493
- }
494
- if (gateResult.summary.existingIssues > 0) {
495
- console.error(chalk.dim(`Existing (baselined): ${gateResult.summary.existingIssues} issues`));
496
- }
497
- }
498
- /**
499
- * Write formatted audit results to output or stdout.
500
- */
501
- function outputFormattedResults(filteredResult, format, formatter, groupBy, options, quiet) {
502
- const formatOptions = {
503
- human: {
504
- groupBy,
505
- showScores: true,
506
- showSummary: true,
507
- },
508
- };
509
- if (format === "json") {
510
- if (options.output) {
511
- const jsonStr = JSON.stringify(createEnvelope(filteredResult, "audit"), null, 2);
512
- const filePath = writeOutputToFile(jsonStr, options.output);
513
- if (filePath && !quiet) {
514
- console.error(`Report written to: ${filePath}`);
515
- }
516
- }
517
- else {
518
- writeJsonOutput(filteredResult, "audit");
519
- }
520
- }
521
- else {
522
- const output = formatter.formatResult(filteredResult, formatOptions);
523
- const defaultPath = getDefaultOutputPath(format);
524
- const filePath = writeOutputToFile(output, options.output, defaultPath);
525
- if (filePath && !quiet) {
526
- console.error(`Report written to: ${filePath}`);
527
- }
528
- else if (!filePath) {
529
- writeStdout(output);
530
- }
531
- }
532
- }
533
- /**
534
- * Execute the audit command.
535
- */
536
- async function executeAudit(targetUrl, options, config) {
537
- // Resolve options with precedence: flags > env > config > defaults
538
- const mode = options.mode || config.mode || "basic";
539
- const timeout = options.timeout || config.timeout || 60000;
540
- const interval = options.interval || config.interval || 5000;
541
- const wait = options.wait ?? true; // Default to waiting for completion
542
- const quiet = options.quiet ?? false;
543
- const interactive = options.interactive ?? false;
544
- // Interactive mode validation
545
- if (interactive) {
546
- if (!wait) {
547
- throw new Error("--interactive requires --wait (audit must complete before interactive mode)");
548
- }
549
- if (!isInteractive()) {
550
- throw new Error("Interactive mode requires a terminal. Use --format json in CI or piped environments.");
551
- }
552
- }
553
- // Resolve API settings
554
- const base = resolveApiBase(options.base);
555
- const apiKey = getApiKey(config.apiKey);
556
- // Resolve output format using per-command registry
557
- // --json flag is a shorthand for --format json (convenient for piping)
558
- const machineMode = options.machine || false;
559
- const explicitFormat = options.json ? "json" : (options.format || config.output?.format);
560
- const validatedFormat = resolveCommandFormat("audit", explicitFormat, machineMode);
561
- const format = validatedFormat;
562
- const formatter = createOutput(format);
563
- const groupBy = options.groupBy || config.output?.groupBy || "severity";
564
- // Determine UI mode: dashboard (full-screen) vs spinner (inline)
565
- const useDashboard = wait && !quiet && !machineMode && options.dashboard !== false;
566
- const useSpinner = wait && isTTY() && !quiet && !useDashboard;
567
- // Create dashboard renderer or fallback spinner
568
- let renderer = null;
569
- let keyboard = null;
570
- let aborted = false;
571
- const spinner = useSpinner
572
- ? createSpinner(`Auditing ${targetUrl}...`)
573
- : null;
574
- if (useDashboard) {
575
- renderer = createRenderer("auto");
576
- keyboard = createKeyboardHandler();
577
- keyboard.on("quit", () => {
578
- aborted = true;
579
- renderer?.dispose();
580
- keyboard?.dispose();
581
- process.stderr.write("\nAudit aborted by user.\n");
582
- process.exitCode = ExitCode.ERROR;
583
- });
584
- keyboard.start();
585
- }
586
- const auditStartTime = Date.now();
587
- try {
588
- // Start spinner (dashboard renders on first update)
589
- spinner?.start();
590
- // Route based on URL type: local URLs are captured client-side
591
- let created;
592
- if (isLocalUrl(targetUrl)) {
593
- // Local URL — capture HTML on this machine, send to /analyze
594
- if (!quiet) {
595
- console.error(chalk.cyan("Local URL detected — capturing page locally..."));
596
- console.error(chalk.dim("Note: Local audits use static analysis. Some checks " +
597
- "(keyboard nav, visual hierarchy) are unavailable."));
598
- }
599
- if (spinner) {
600
- updateSpinner(spinner, "Fetching local page...", 10, 100);
601
- }
602
- let captured;
603
- try {
604
- captured = await captureLocalPage(targetUrl, { timeoutMs: timeout });
605
- }
606
- catch (captureErr) {
607
- throw new Error(`Cannot reach ${targetUrl}. Ensure your local server is running.\n` +
608
- ` ${captureErr instanceof Error ? captureErr.message : String(captureErr)}`);
609
- }
610
- if (spinner) {
611
- updateSpinner(spinner, "Analyzing captured page...", 40, 100);
612
- }
613
- created = await apiRequest(base, "/analyze", {
614
- method: "POST",
615
- body: { html: captured.html, url: targetUrl, mode },
616
- }, apiKey);
617
- }
618
- else {
619
- // Public URL — send to cloud API for full audit
620
- created = await apiRequest(base, "/audit", {
621
- method: "POST",
622
- body: { url: targetUrl, mode },
623
- }, apiKey);
624
- }
625
- // If not waiting, just output the job info
626
- if (!wait) {
627
- spinner?.stop();
628
- renderer?.dispose();
629
- keyboard?.dispose();
630
- outputFireAndForget(created, format, formatter, options, quiet);
631
- return;
632
- }
633
- // Determine if the server returned results synchronously (status 200)
634
- // or queued the job for background processing (status 202, legacy servers)
635
- const isSyncResponse = created.status === "completed" && created.scores;
636
- let result;
637
- if (created.status === "failed") {
638
- // Server ran the audit synchronously but it failed
639
- throw new Error(created.error || "Audit failed on server");
640
- }
641
- if (isSyncResponse) {
642
- // Server already ran the audit — use the response directly
643
- result = created;
644
- }
645
- else {
646
- // Legacy/async path: poll for completion
647
- if (!created.job_id) {
648
- throw new Error("Audit response missing job_id");
649
- }
650
- result = await waitForAudit(base, created.job_id, timeout, interval, apiKey, (progress, status) => {
651
- if (aborted)
652
- return;
653
- if (renderer) {
654
- const phase = mapStatusToPhase(status);
655
- const state = {
656
- phase,
657
- phaseIndex: phaseIndex(phase),
658
- phaseTotal: phaseTotal(),
659
- url: targetUrl,
660
- mode,
661
- progress: { audit: progress },
662
- totals: { audit: 100 },
663
- issueCount: 0,
664
- scorePreview: null,
665
- verbose: false,
666
- elapsed: Date.now() - auditStartTime,
667
- };
668
- renderer.update(state);
669
- }
670
- if (spinner) {
671
- updateSpinner(spinner, `Auditing ${targetUrl}`, progress, 100);
672
- }
673
- });
674
- }
675
- // Finish dashboard or spinner
676
- if (renderer) {
677
- const overallScore = getOverallScoreFromResult(result);
678
- const summaryResult = {
679
- url: targetUrl,
680
- mode,
681
- overallScore: overallScore ?? 0,
682
- scores: extractNumericScores(result.scores),
683
- issueCount: countTotalIssues(result.issues),
684
- passed: (overallScore ?? 0) >= 70,
685
- elapsed: Date.now() - auditStartTime,
686
- };
687
- renderer.finish(summaryResult);
688
- keyboard?.dispose();
689
- }
690
- if (spinner) {
691
- succeedSpinner(spinner, `Audit complete: ${targetUrl}`);
692
- }
693
- // Save repro artifacts if requested
694
- if (created.job_id) {
695
- saveReproArtifacts(created.job_id, result, options, quiet);
696
- }
697
- // Apply filters to issues
698
- let issues = normalizeIssues(result.issues);
699
- if (options.severity) {
700
- issues = filterBySeverity(issues, options.severity);
701
- }
702
- if (options.category) {
703
- issues = filterByCategory(issues, options.category);
704
- }
705
- // Create filtered result for output
706
- const filteredResult = {
707
- ...result,
708
- issues,
709
- };
710
- // Load and apply policy (CICD-17)
711
- await loadAndResolvePolicy(options, quiet);
712
- // Build quality gate config and evaluate
713
- const gateConfig = buildQualityGateConfig(config, options);
714
- // Load baseline if provided
715
- const baselinePath = options.baseline || config.baseline?.path;
716
- const baseline = baselinePath ? await loadBaseline(baselinePath) : null;
717
- // Detect PR labels for bypass check
718
- const prLabels = detectPRLabels();
719
- // Evaluate quality gate
720
- const gateResult = evaluateQualityGate({
721
- auditResult: {
722
- issues: issues, // Use the normalized issues array
723
- scores: result.scores,
724
- },
725
- baseline,
726
- config: gateConfig,
727
- labels: prLabels,
728
- });
729
- // Use quality gate exit code
730
- const exitCode = gateResult.exitCode;
731
- // Format and output results
732
- outputFormattedResults(filteredResult, format, formatter, groupBy, options, quiet);
733
- // Inline AI explanation (--explain flag, PROG-04)
734
- if (options.explain && issues.length > 0) {
735
- await runInlineExplanation(issues, result, targetUrl, options, config);
736
- }
737
- // Output quality gate result
738
- if (!quiet) {
739
- outputQualityGateResult(gateResult);
740
- }
741
- // Save CI artifact bundle if requested
742
- if (options.uploadArtifacts && created.job_id) {
743
- const artifactJobId = options.jobId || created.job_id;
744
- saveArtifactBundle(artifactJobId, result, issues, exitCode, quiet);
745
- }
746
- // Interactive mode: run fix wizard if there are issues
747
- if (interactive && issues.length > 0) {
748
- await runFixWizard(issues, {
749
- jobId: result.job_id,
750
- url: targetUrl,
751
- base: options.base,
752
- config,
753
- });
754
- }
755
- // Set exit code
756
- if (exitCode !== ExitCode.SUCCESS) {
757
- process.exitCode = exitCode;
758
- }
759
- }
760
- catch (error) {
761
- // Stop dashboard or spinner with failure
762
- renderer?.dispose();
763
- keyboard?.dispose();
764
- if (spinner) {
765
- failSpinner(spinner, `Audit failed: ${error instanceof Error ? error.message : String(error)}`);
766
- }
767
- throw error;
768
- }
769
- }
770
- /**
771
- * Map API audit status to TUI phase name.
772
- */
773
- function mapStatusToPhase(status) {
774
- switch (status) {
775
- case "queued":
776
- case "pending":
777
- return AuditPhase.Connecting;
778
- case "crawling":
779
- return AuditPhase.Crawling;
780
- case "running":
781
- case "analyzing":
782
- return AuditPhase.Analyzing;
783
- case "scoring":
784
- return AuditPhase.Scoring;
785
- case "completed":
786
- return AuditPhase.Done;
787
- case "failed":
788
- return AuditPhase.Failed;
789
- default:
790
- return AuditPhase.Analyzing;
791
- }
792
- }
793
- /**
794
- * Extract overall score from audit result.
795
- */
796
- function getOverallScoreFromResult(result) {
797
- if (!result.scores)
798
- return null;
799
- const scores = result.scores;
800
- const direct = scores.overall ?? scores.ux ?? scores.total;
801
- if (typeof direct === "number" && Number.isFinite(direct))
802
- return direct;
803
- const numeric = Object.values(scores)
804
- .filter((v) => typeof v === "number" && Number.isFinite(v));
805
- if (numeric.length === 0)
806
- return null;
807
- return Math.round(numeric.reduce((a, b) => a + b, 0) / numeric.length);
808
- }
809
- /**
810
- * Extract numeric scores from result scores object.
811
- */
812
- function extractNumericScores(scores) {
813
- if (!scores)
814
- return {};
815
- const result = {};
816
- for (const [key, value] of Object.entries(scores)) {
817
- if (typeof value === "number" && key !== "overall") {
818
- result[key] = value;
819
- }
820
- }
821
- return result;
822
- }
823
- /**
824
- * Count total issues from various result formats.
825
- */
826
- function countTotalIssues(issues) {
827
- if (Array.isArray(issues))
828
- return issues.length;
829
- if (issues && typeof issues === "object") {
830
- return Object.values(issues)
831
- .flatMap((v) => (Array.isArray(v) ? v : []))
832
- .length;
833
- }
834
- return 0;
835
- }
836
- /**
837
- * Register the audit command with the Commander program.
838
- */
839
- export function registerAuditCommand(program) {
840
- program
841
- .command("audit [url]")
842
- .description("Run UX and accessibility audit. Localhost and private URLs are " +
843
- "captured locally and analyzed via static HTML analysis.")
844
- .option("-u, --url <url>", "URL to audit")
845
- .option("--repo <repo>", "GitHub repository to audit (owner/repo)")
846
- .option("--storybook <url>", "Storybook URL to audit")
847
- .option("--routes <routes>", "Comma-separated list of routes to audit")
848
- .option("--auth-profile <profile>", "Authentication profile for protected pages")
849
- .option("--mode <mode>", "Audit depth: basic|standard|deep", parseMode, "basic")
850
- .option("--format <format>", "Output format: json|sarif|junit|html|human (default: human in terminal, auto-detected in CI)")
851
- .option("--json", "Shorthand for --format json (convenient for piping)")
852
- .option("-o, --output <path>", "Output file path")
853
- .option("--group-by <field>", "Group issues by: severity|category|route", parseGroupBy)
854
- .option("--wait", "Wait for audit completion (default)")
855
- .option("--no-wait", "Don't wait for audit completion")
856
- .option("--severity <levels>", "Filter issues by severity: error|warning|info (comma-separated)")
857
- .option("--category <categories>", "Filter issues by category (comma-separated)")
858
- .option("--fail-on <severity>", "Exit 1 if new issues at or above severity: error|warning|info|none", parseFailOn)
859
- .option("--threshold <score>", "Exit 3 if overall score below threshold (0-100)", parseThreshold)
860
- .option("--max-new-errors <n>", "Maximum allowed new error-severity issues (default: 0)", (v) => validateNumeric(v, "max-new-errors", { min: 0, integer: true }))
861
- .option("--max-new-warnings <n>", "Maximum allowed new warning-severity issues (default: unlimited)", (v) => validateNumeric(v, "max-new-warnings", { min: 0, integer: true }))
862
- .option("--fail-on-existing", "Also fail on existing issues (legacy mode)")
863
- .option("--bypass-labels <labels>", "Comma-separated PR labels that bypass quality gate")
864
- .option("--baseline <path>", "Path to baseline file for new issue detection")
865
- .option("--timeout <ms>", "Wait timeout in milliseconds (1-300000)", parseTimeout)
866
- .option("--interval <ms>", "Poll interval in milliseconds (1-300000)", parseInterval)
867
- .option("--interactive", "Step through issues interactively (requires --wait)")
868
- // Repro artifact options (CLI-17)
869
- .option("--save-trace", "Save Playwright trace for debugging")
870
- .option("--save-har", "Save HAR network log")
871
- .option("--screenshots", "Save page screenshots")
872
- .option("--dom-snapshots", "Save DOM snapshots")
873
- // Performance options (CLI-18)
874
- .option("--concurrency <n>", "Number of concurrent audits (1-50, default: 3)", parseConcurrency)
875
- .option("--cache", "Enable route caching to speed up repeated audits")
876
- // CI artifact bundling (CICD-08)
877
- .option("--upload-artifacts", "Save all outputs to .vertaaux/artifacts/ for CI upload")
878
- .option("--job-id <id>", "Job identifier for artifact directory naming")
879
- // Incremental mode (CICD-07)
880
- .option("--incremental", "Only audit routes changed in PR")
881
- .option("--base-branch <branch>", "Base branch for comparison (default: auto-detect)")
882
- // Budget mode (CICD-13)
883
- .option("--budget <mode>", "Budget mode: quick|standard|full", parseBudget)
884
- // Monorepo options (CICD-16)
885
- .option("--workspace <name>", "Audit specific workspace in monorepo")
886
- .option("--all-workspaces", "Audit all workspaces in monorepo")
887
- .option("--parallel", "Run workspace audits in parallel")
888
- .option("--detect-matrix", "Output CI matrix config for monorepo (JSON)")
889
- // Caching options (CICD-14)
890
- .option("--no-cache", "Disable route caching")
891
- .option("--cache-dir <path>", "Custom cache directory")
892
- // Logging options (CICD-18)
893
- .option("--json-logs", "Output structured JSON logs for CI")
894
- // Policy options (CICD-17)
895
- .option("--policy <file>", "Path to policy file (default: auto-detect vertaa.policy.yml)")
896
- .option("--explain", "Append AI explanation to audit results")
897
- .action(async (urlArg, cmdOptions, command) => {
898
- try {
899
- // Initialize structured logger
900
- const logger = createLogger({
901
- json: cmdOptions.jsonLogs || false,
902
- level: cmdOptions.quiet ? "error" : "info",
903
- });
904
- // Load config (supports --config global option)
905
- const globalOpts = command.optsWithGlobals();
906
- const config = await resolveConfig(globalOpts.config);
907
- // Propagate global --machine flag to command options
908
- cmdOptions.machine = globalOpts.machine || false;
909
- // Handle monorepo detection and matrix output (CICD-16)
910
- if (cmdOptions.detectMatrix || cmdOptions.allWorkspaces || cmdOptions.workspace) {
911
- const monorepo = await detectMonorepo();
912
- if (monorepo.type === "none") {
913
- if (cmdOptions.detectMatrix) {
914
- // Output empty matrix for non-monorepo
915
- process.stdout.write(JSON.stringify({ include: [] }) + "\n");
916
- process.exit(ExitCode.SUCCESS);
917
- }
918
- // Not a monorepo, continue with normal audit
919
- }
920
- else {
921
- logger.info(`Detected ${monorepo.type} monorepo`, {
922
- workspaces: monorepo.workspaces.length,
923
- });
924
- const apps = getAuditableApps(monorepo);
925
- logger.info(`Found ${apps.length} auditable apps`, {
926
- apps: apps.map((a) => a.name),
927
- });
928
- // Output matrix config for CI
929
- if (cmdOptions.detectMatrix) {
930
- const matrix = generateMatrixConfig(apps);
931
- process.stdout.write(JSON.stringify(matrix) + "\n");
932
- process.exit(ExitCode.SUCCESS);
933
- }
934
- // Audit specific workspace
935
- if (cmdOptions.workspace) {
936
- const workspace = monorepo.workspaces.find((w) => w.name === cmdOptions.workspace);
937
- if (!workspace) {
938
- console.error(`Error: Workspace "${cmdOptions.workspace}" not found`);
939
- console.error(`Available: ${monorepo.workspaces.map((w) => w.name).join(", ")}`);
940
- process.exit(ExitCode.ERROR);
941
- }
942
- // Use workspace URL or infer from type
943
- if (workspace.url) {
944
- urlArg = workspace.url;
945
- logger.info(`Auditing workspace`, { workspace: workspace.name, url: urlArg });
946
- }
947
- }
948
- // Audit all workspaces
949
- if (cmdOptions.allWorkspaces) {
950
- console.error(chalk.dim(`Auditing ${apps.length} workspaces in ${monorepo.type} monorepo`));
951
- const results = [];
952
- const startTime = Date.now();
953
- if (cmdOptions.parallel) {
954
- // Parallel execution
955
- const promises = apps.map(async (app) => {
956
- const wsStartTime = Date.now();
957
- logger.info(`Starting audit`, { workspace: app.name });
958
- try {
959
- // Note: In production, this would call executeAudit
960
- // For now, we return a placeholder result
961
- return {
962
- workspace: app,
963
- auditResult: { url: app.url, issues: [], status: "complete" },
964
- duration: Date.now() - wsStartTime,
965
- };
966
- }
967
- catch (error) {
968
- return {
969
- workspace: app,
970
- auditResult: null,
971
- error: error instanceof Error ? error : new Error(String(error)),
972
- duration: Date.now() - wsStartTime,
973
- };
974
- }
975
- });
976
- results.push(...(await Promise.all(promises)));
977
- }
978
- else {
979
- // Sequential execution
980
- for (const app of apps) {
981
- const wsStartTime = Date.now();
982
- logger.info(`Auditing workspace`, { workspace: app.name });
983
- try {
984
- results.push({
985
- workspace: app,
986
- auditResult: { url: app.url, issues: [], status: "complete" },
987
- duration: Date.now() - wsStartTime,
988
- });
989
- }
990
- catch (error) {
991
- results.push({
992
- workspace: app,
993
- auditResult: null,
994
- error: error instanceof Error ? error : new Error(String(error)),
995
- duration: Date.now() - wsStartTime,
996
- });
997
- }
998
- }
999
- }
1000
- // Aggregate and output results
1001
- const aggregated = aggregateResults(results);
1002
- aggregated.totalDuration = Date.now() - startTime;
1003
- if (cmdOptions.json || cmdOptions.format === "json") {
1004
- writeJsonOutput(aggregated, "audit");
1005
- }
1006
- else {
1007
- writeStdout(formatAggregatedResults(aggregated));
1008
- }
1009
- // Exit with error if any failed
1010
- if (aggregated.failedAudits > 0) {
1011
- process.exit(ExitCode.ERROR);
1012
- }
1013
- if (aggregated.issuesBySeverity.error > 0) {
1014
- process.exit(ExitCode.ISSUES_FOUND);
1015
- }
1016
- process.exit(ExitCode.SUCCESS);
1017
- }
1018
- }
1019
- }
1020
- // Handle incremental mode (CICD-07)
1021
- if (cmdOptions.incremental) {
1022
- const baseBranch = validateBranchName(cmdOptions.baseBranch || detectBaseBranch());
1023
- const changedResult = await getChangedRoutes({
1024
- baseBranch,
1025
- routePatterns: [], // Use default patterns
1026
- });
1027
- if (!changedResult.hasChanges) {
1028
- console.error(chalk.green("No relevant route changes detected. Skipping audit."));
1029
- process.exit(ExitCode.SUCCESS);
1030
- }
1031
- // Log which routes will be audited
1032
- console.error(chalk.dim(`Incremental mode: Auditing ${changedResult.routes.length} changed routes`));
1033
- for (const route of changedResult.routes) {
1034
- console.error(chalk.dim(` - ${route}`));
1035
- }
1036
- // If routes were detected, use them instead of URL argument
1037
- if (!config.defaultUrl) {
1038
- console.error("Error: --incremental requires defaultUrl in config to construct full URLs.");
1039
- process.exit(ExitCode.ERROR);
1040
- }
1041
- // Set routes option for executeAudit
1042
- cmdOptions.routes = changedResult.routes.join(",");
1043
- }
1044
- // Handle budget mode (CICD-13)
1045
- if (cmdOptions.budget) {
1046
- const budgetConfig = getBudgetConfig(cmdOptions.budget);
1047
- // Apply budget constraints to options
1048
- if (!cmdOptions.concurrency) {
1049
- cmdOptions.concurrency = budgetConfig.concurrency;
1050
- }
1051
- if (!cmdOptions.timeout) {
1052
- cmdOptions.timeout = budgetConfig.maxTime;
1053
- }
1054
- console.error(chalk.dim(`Budget mode: ${cmdOptions.budget} (max ${budgetConfig.maxPages} pages, ${budgetConfig.maxTime / 1000}s timeout, ${budgetConfig.concurrency} concurrent)`));
1055
- }
1056
- // Resolve target URL
1057
- // Priority: positional > --url > --repo > --storybook > --routes > config default
1058
- let targetUrl;
1059
- if (urlArg) {
1060
- targetUrl = urlArg;
1061
- }
1062
- else if (cmdOptions.url) {
1063
- targetUrl = cmdOptions.url;
1064
- }
1065
- else if (cmdOptions.repo) {
1066
- // For repo audits, construct a special URL or handle differently
1067
- // For now, we'll just pass it as a marker
1068
- targetUrl = `repo:${cmdOptions.repo}`;
1069
- }
1070
- else if (cmdOptions.storybook) {
1071
- targetUrl = cmdOptions.storybook;
1072
- }
1073
- else if (cmdOptions.routes) {
1074
- // Routes require a base URL from config
1075
- if (!config.defaultUrl) {
1076
- console.error("Error: --routes requires defaultUrl in config or a base URL.");
1077
- process.exit(ExitCode.ERROR);
1078
- }
1079
- // For routes, we'll handle them as comma-separated paths
1080
- const routes = cmdOptions.routes.split(",").map((r) => r.trim());
1081
- targetUrl = `${config.defaultUrl}${routes[0]}`; // First route for now
1082
- }
1083
- else if (config.defaultUrl) {
1084
- targetUrl = config.defaultUrl;
1085
- }
1086
- if (!targetUrl) {
1087
- console.error("Error: URL is required. Provide as argument, --url flag, or defaultUrl in config.");
1088
- process.exit(ExitCode.ERROR);
1089
- }
1090
- await executeAudit(targetUrl, cmdOptions, config);
1091
- }
1092
- catch (error) {
1093
- console.error("Error:", error instanceof Error ? error.message : String(error));
1094
- process.exit(ExitCode.ERROR);
1095
- }
1096
- });
1097
- }
1
+ // cli/src/commands/audit.ts — barrel re-export
2
+ export { registerAuditCommand } from "./audit/index.js";