qa-engineer 0.9.2 → 0.11.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 (147) hide show
  1. package/COMPATIBILITY.md +26 -12
  2. package/README.md +182 -84
  3. package/package.json +11 -20
  4. package/packages/engine/bin/qa-engine.mjs +560 -0
  5. package/packages/engine/lib/analysis/branding.mjs +187 -0
  6. package/packages/engine/lib/analysis/context.mjs +294 -0
  7. package/packages/engine/lib/analysis/contracts.mjs +149 -0
  8. package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
  9. package/packages/engine/lib/analysis/discovery.mjs +220 -0
  10. package/packages/engine/lib/analysis/evidence.mjs +116 -0
  11. package/packages/engine/lib/analysis/har.mjs +124 -0
  12. package/packages/engine/lib/analysis/junit.mjs +126 -0
  13. package/packages/engine/lib/analysis/network.mjs +237 -0
  14. package/packages/engine/lib/analysis/redaction.mjs +127 -0
  15. package/packages/engine/lib/analysis/report-html.mjs +76 -0
  16. package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
  17. package/packages/engine/lib/analysis/xml.mjs +153 -0
  18. package/packages/engine/lib/analysis/zip.mjs +107 -0
  19. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  20. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  21. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  22. package/packages/engine/lib/diagnostics/engine.mjs +165 -0
  23. package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
  24. package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
  25. package/packages/engine/lib/diagnostics/repair.mjs +73 -0
  26. package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
  27. package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
  28. package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
  29. package/packages/engine/lib/frameworks/playwright.mjs +158 -0
  30. package/packages/engine/lib/report/components/charts.mjs +424 -0
  31. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  32. package/packages/engine/lib/report/components/findings.mjs +258 -0
  33. package/packages/engine/lib/report/components/nav.mjs +99 -0
  34. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  35. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  36. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  37. package/packages/engine/lib/report/core/model.mjs +270 -0
  38. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  39. package/packages/engine/lib/report/core/sections.mjs +978 -0
  40. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  41. package/packages/engine/lib/report/export/html.mjs +183 -0
  42. package/packages/engine/lib/report/export/machine.mjs +290 -0
  43. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  44. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  45. package/packages/engine/lib/report/theme/css.mjs +529 -0
  46. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  47. package/packages/engine/lib/report/version.mjs +78 -0
  48. package/packages/engine/package.json +14 -0
  49. package/packages/installer/lib/agents/targets.mjs +90 -0
  50. package/packages/installer/lib/agents/user-level.mjs +80 -0
  51. package/packages/installer/lib/cli/flags.mjs +20 -2
  52. package/packages/installer/lib/commands/doctor.mjs +13 -20
  53. package/packages/installer/lib/commands/install.mjs +160 -91
  54. package/packages/installer/lib/commands/repair.mjs +10 -6
  55. package/packages/installer/lib/commands/self-test.mjs +4 -3
  56. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  57. package/packages/installer/lib/commands/update.mjs +9 -4
  58. package/packages/installer/lib/commands/verify.mjs +13 -12
  59. package/packages/installer/lib/constants.mjs +13 -0
  60. package/packages/installer/lib/core/bundle.mjs +69 -92
  61. package/packages/installer/lib/core/conflict.mjs +5 -4
  62. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  63. package/packages/installer/lib/core/integrity.mjs +59 -0
  64. package/packages/installer/lib/core/lockfile.mjs +19 -3
  65. package/packages/installer/lib/core/manifest.mjs +48 -57
  66. package/packages/installer/lib/core/plan.mjs +213 -0
  67. package/packages/installer/lib/core/qa-home.mjs +145 -0
  68. package/packages/installer/lib/core/scope.mjs +274 -0
  69. package/packages/installer/lib/core/validate-install.mjs +49 -31
  70. package/packages/installer/package.json +1 -1
  71. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  72. package/shared/tooling/qa-tool.mjs +161 -0
  73. package/skills/qa-api/SKILL.md +5 -5
  74. package/skills/qa-api/references/deterministic-tooling.md +34 -32
  75. package/skills/qa-api/references/evidence-and-reporting.md +5 -5
  76. package/skills/qa-api/scripts/qa-tool.mjs +162 -0
  77. package/skills/qa-audit/SKILL.md +5 -5
  78. package/skills/qa-audit/references/deterministic-tooling.md +34 -32
  79. package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
  80. package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
  81. package/skills/qa-debug/SKILL.md +6 -6
  82. package/skills/qa-debug/references/deterministic-tooling.md +34 -32
  83. package/skills/qa-debug/references/diagnostic-engine.md +3 -3
  84. package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
  85. package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
  86. package/skills/qa-explore/SKILL.md +31 -15
  87. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  88. package/skills/qa-explore/references/api-replay.md +40 -1
  89. package/skills/qa-explore/references/deterministic-tooling.md +34 -32
  90. package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
  91. package/skills/qa-explore/references/report-pipeline.md +266 -96
  92. package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
  93. package/skills/qa-fix/SKILL.md +4 -4
  94. package/skills/qa-fix/references/deterministic-tooling.md +34 -32
  95. package/skills/qa-fix/references/diagnostic-engine.md +3 -3
  96. package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
  97. package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
  98. package/skills/qa-flaky/SKILL.md +4 -4
  99. package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
  100. package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
  101. package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
  102. package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
  103. package/skills/qa-init/SKILL.md +4 -4
  104. package/skills/qa-init/references/deterministic-tooling.md +34 -32
  105. package/skills/qa-init/references/evidence-and-reporting.md +5 -5
  106. package/skills/qa-init/scripts/qa-tool.mjs +162 -0
  107. package/skills/qa-report/SKILL.md +7 -7
  108. package/skills/qa-report/references/deterministic-tooling.md +34 -32
  109. package/skills/qa-report/references/diagnostic-engine.md +3 -3
  110. package/skills/qa-report/references/evidence-and-reporting.md +5 -5
  111. package/skills/qa-report/scripts/qa-tool.mjs +162 -0
  112. package/skills/qa-review/references/evidence-and-reporting.md +5 -5
  113. package/skills/qa-run/SKILL.md +6 -6
  114. package/skills/qa-run/references/deterministic-tooling.md +34 -32
  115. package/skills/qa-run/references/evidence-and-reporting.md +5 -5
  116. package/skills/qa-run/scripts/qa-tool.mjs +162 -0
  117. package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
  118. package/shared/analysis/lib/qa_analysis/branding.py +0 -175
  119. package/shared/analysis/lib/qa_analysis/cli.py +0 -144
  120. package/shared/analysis/lib/qa_analysis/context.py +0 -233
  121. package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
  122. package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
  123. package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
  124. package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
  125. package/shared/analysis/lib/qa_analysis/har.py +0 -87
  126. package/shared/analysis/lib/qa_analysis/junit.py +0 -104
  127. package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
  128. package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
  129. package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
  130. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
  131. package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
  132. package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
  133. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
  134. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
  135. package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
  136. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
  137. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
  138. package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
  139. package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
  140. package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
  141. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
  142. package/shared/tooling/qa_tool.py +0 -127
  143. /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
  144. /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
  145. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
  146. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
  147. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
@@ -0,0 +1,560 @@
1
+ #!/usr/bin/env node
2
+ // The deterministic engine's command line — how a skill reaches it.
3
+ //
4
+ // One entry point for all three tool groups, replacing three Python CLIs and a
5
+ // launcher. Every subcommand writes JSON to stdout, so an agent never has to invent
6
+ // glue code, and the shapes are checked rather than assumed.
7
+ //
8
+ // qa-engine analysis <subcommand> [args]
9
+ // qa-engine diagnostics <subcommand> [args]
10
+ // qa-engine playwright <subcommand> [args]
11
+ //
12
+ // Exit codes are the contract, not an implementation detail. A skill's fallback
13
+ // logic reads them:
14
+ //
15
+ // 0 success
16
+ // 1 a document was read fine but failed its contract (`validate`, `context`)
17
+ // 2 unreadable input, malformed JSON, a failed seam contract, or bad usage
18
+ //
19
+ // Nothing here reasons: it reads files, calls the engine, prints the result. That
20
+ // separation is the whole architecture — deterministic code owns the facts, the
21
+ // model owns the explanation.
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+
26
+ import * as junit from '../lib/analysis/junit.mjs';
27
+ import * as har from '../lib/analysis/har.mjs';
28
+ import * as network from '../lib/analysis/network.mjs';
29
+ import * as discovery from '../lib/analysis/discovery.mjs';
30
+ import * as diffGuard from '../lib/analysis/diff-guard.mjs';
31
+ import * as redaction from '../lib/analysis/redaction.mjs';
32
+ import * as contracts from '../lib/analysis/contracts.mjs';
33
+ import * as taxonomy from '../lib/analysis/taxonomy.mjs';
34
+ import * as contextModule from '../lib/analysis/context.mjs';
35
+ import * as branding from '../lib/analysis/branding.mjs';
36
+ import * as reportHtml from '../lib/analysis/report-html.mjs';
37
+ import * as markdownExport from '../lib/report/export/markdown.mjs';
38
+ import * as machineExport from '../lib/report/export/machine.mjs';
39
+ import * as bundleExport from '../lib/report/export/bundle.mjs';
40
+ import * as artifacts from '../lib/artifacts/manager.mjs';
41
+ import { SchemaError } from '../lib/report/core/normalize.mjs';
42
+ import * as diagnostics from '../lib/diagnostics/engine.mjs';
43
+ import {
44
+ InternalContractError, validateAnalysisResult, validateExecutionResultMin, validateDiagnosis,
45
+ } from '../lib/diagnostics/internal-contracts.mjs';
46
+ import * as playwright from '../lib/frameworks/playwright.mjs';
47
+ import * as junitFrameworks from '../lib/frameworks/junit-frameworks.mjs';
48
+
49
+ const USAGE = `usage: qa-engine <tool> <subcommand> [args]
50
+
51
+ analysis parse artifacts, classify errors, validate contracts, diff-guard,
52
+ read .qa/context.md, render a report, print the footer
53
+ artifacts check that the evidence a result points at is really on disk
54
+ diagnostics root cause, timeline, priority, repair plans, release readiness
55
+ playwright normalize a Playwright report or summarize a trace
56
+
57
+ analysis subcommands
58
+ junit <report.xml> normalized {tests, executed}
59
+ har <file.har> [--slow-ms N] redacted network summary
60
+ network <file.har> [--slow-ms N] the report's network block: totals,
61
+ [--large-bytes N] per-endpoint timings, and an issue flag
62
+ discover [--root DIR] [--path P ...] artifacts found, by state
63
+ diff-guard <diff-file> {issues, safe} — safe:false blocks a change
64
+ redact <file> the file's text with secrets masked
65
+ validate <instance.json> <schema.json> {valid, errors}; exit 1 when invalid
66
+ classify "<message>" [--http-status N] {classification, confidence, reason}
67
+ context [--root DIR] [--path P] parsed, schema-checked project context
68
+ report-html <result.json> [--out FILE] a self-contained HTML report
69
+ [--embed] [--mode M] --embed inlines images as data URIs
70
+ M: full executive developer artifact
71
+ (artifact = body only, for an embedding host)
72
+ report-export <result.json> --format F F: html markdown sarif junit csv json bundle
73
+ [--out FILE] [--embed]
74
+ [--mode M]
75
+ report-bundle <result.json> [--out DIR] a portable folder — index.html + assets/ —
76
+ [--zip] [--zip-out FILE] with every link verified to resolve inside
77
+ [--mode M] [--force] it. The canonical local output.
78
+ report-schema [--out FILE] the canonical producer-neutral contract
79
+ report-versions schema / theme / renderer versions in force
80
+ branding [--format html|markdown|text] the exact footer bytes for a report
81
+
82
+ artifacts subcommands
83
+ verify <result.json> [--base-dir D] {ok, stats, missing}; exit 1 when a file
84
+ [--out-dir D] a finding points at is not there
85
+ scan <directory> [--root D] every file found, with size and type
86
+ hash <file> the file's SHA-256
87
+
88
+ diagnostics subcommands
89
+ diagnose --execution-result P [--analysis-result P]
90
+ plan-repairs --diagnosis P
91
+ summarize --execution-result P --diagnosis P
92
+ report --execution-result P [--analysis-result P] all three in one call
93
+
94
+ playwright subcommands
95
+ report <results.json> the same shape as analysis junit
96
+ trace <trace.zip> actions, errors, and a classification
97
+
98
+ examples
99
+ qa-engine analysis junit test-results/results.xml
100
+ qa-engine artifacts verify qa-artifacts/explore-R/explore-result.json
101
+ qa-engine analysis report-html qa-artifacts/explore-R/explore-result.json \\
102
+ --out qa-artifacts/explore-R/explore-report.html
103
+ qa-engine analysis report-export qa-artifacts/explore-R/explore-result.json \\
104
+ --format sarif --out qa-artifacts/explore-R/findings.sarif
105
+ qa-engine diagnostics report --execution-result qa-artifacts/run.json
106
+ `;
107
+
108
+ function emit(value) {
109
+ process.stdout.write(`${JSON.stringify(value, sortedReplacer(), 2)}\n`);
110
+ }
111
+
112
+ /**
113
+ * Keys are emitted in sorted order, matching the Python CLIs' `sort_keys=True`.
114
+ * The output is read by people and diffed in CI, so a stable order is part of it.
115
+ */
116
+ function sortedReplacer() {
117
+ return function replacer(key, value) {
118
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
119
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
120
+ };
121
+ }
122
+
123
+ function fail(error, detail) {
124
+ emit({ error, detail });
125
+ return 2;
126
+ }
127
+
128
+ function readJson(file) {
129
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
130
+ }
131
+
132
+ /**
133
+ * Flags, parsed the way the Python argparse CLIs accepted them.
134
+ *
135
+ * `boolean` names matter: without them a switch consumes the token after it, so
136
+ * `--embed --out report.html` set `embed` to `"--out"` and left the output path as a
137
+ * stray positional. The report then went to stdout and the flag did nothing — a
138
+ * failure that looks like the feature is broken rather than the parser.
139
+ */
140
+ function parseFlags(argv, { repeatable = [], boolean = [] } = {}) {
141
+ const flags = {};
142
+ const positional = [];
143
+ for (let index = 0; index < argv.length; index += 1) {
144
+ const token = argv[index];
145
+ if (!token.startsWith('--')) {
146
+ positional.push(token);
147
+ continue;
148
+ }
149
+ const [name, inline] = token.slice(2).split('=', 2);
150
+ const camel = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
151
+ if (boolean.includes(camel)) {
152
+ flags[camel] = inline === undefined ? true : inline !== 'false' && inline !== '0';
153
+ continue;
154
+ }
155
+ const value = inline !== undefined ? inline : argv[++index];
156
+ if (repeatable.includes(camel)) {
157
+ flags[camel] = [...(flags[camel] ?? []), value];
158
+ } else {
159
+ flags[camel] = value;
160
+ }
161
+ }
162
+ return { flags, positional };
163
+ }
164
+
165
+ // Which flags may appear more than once, per subcommand. `discover` accepts a list
166
+ // of explicit paths; `context` names one file. Treating `--path` as repeatable
167
+ // everywhere handed `context` an array and it failed on "path must be a string".
168
+ const REPEATABLE = { discover: ['path'] };
169
+
170
+ // Switches that take no value. Declared per subcommand so `--embed --out f.html` reads
171
+ // as two flags rather than one flag whose value is the word "--out".
172
+ const BOOLEAN = {
173
+ 'report-html': ['embed'],
174
+ 'report-export': ['embed'],
175
+ // `--zip` is a switch and `--zip-out` carries the path. One flag doing both would
176
+ // need a third parser mode that guesses from the next token, and a guess here writes
177
+ // the archive to a filename taken from an unrelated flag.
178
+ 'report-bundle': ['force', 'zip', 'markdown'],
179
+ branding: ['metadata'],
180
+ };
181
+
182
+ function analysis(argv) {
183
+ const [subcommand, ...rest] = argv;
184
+ const { flags, positional } = parseFlags(rest, {
185
+ repeatable: REPEATABLE[subcommand] ?? [],
186
+ boolean: BOOLEAN[subcommand] ?? [],
187
+ });
188
+
189
+ switch (subcommand) {
190
+ case 'junit':
191
+ requirePositional(positional, 1, 'junit <report.xml>');
192
+ emit(junit.parseJUnit(positional[0]));
193
+ return 0;
194
+
195
+ case 'har':
196
+ requirePositional(positional, 1, 'har <file.har>');
197
+ emit(har.parseHar(positional[0], { slowMs: Number(flags.slowMs ?? 1000) }));
198
+ return 0;
199
+
200
+ case 'network': {
201
+ // The report contract's `network` block, ready to paste into a result and
202
+ // validate. `har` gives the raw entries; this gives the counted, flagged,
203
+ // report-shaped answer, so no skill has to count requests by eye.
204
+ requirePositional(positional, 1, 'network <file.har>');
205
+ const options = {};
206
+ if (flags.slowMs !== undefined) options.slowMs = Number(flags.slowMs);
207
+ if (flags.largeBytes !== undefined) options.largeBytes = Number(flags.largeBytes);
208
+ emit(network.analyzeHar(positional[0], options));
209
+ return 0;
210
+ }
211
+
212
+ case 'discover':
213
+ emit(discovery.discover({ root: flags.root ?? '.', explicit: flags.path ?? null }));
214
+ return 0;
215
+
216
+ case 'diff-guard': {
217
+ requirePositional(positional, 1, 'diff-guard <diff-file>');
218
+ const issues = diffGuard.checkDiff(fs.readFileSync(positional[0], 'utf8'));
219
+ emit({ issues, safe: !issues.some((issue) => issue.severity === 'high') });
220
+ return 0;
221
+ }
222
+
223
+ case 'redact':
224
+ requirePositional(positional, 1, 'redact <file>');
225
+ process.stdout.write(redaction.redactText(fs.readFileSync(positional[0], 'utf8')));
226
+ return 0;
227
+
228
+ case 'validate': {
229
+ requirePositional(positional, 2, 'validate <instance.json> <schema.json>');
230
+ const errors = contracts.validate(readJson(positional[0]), readJson(positional[1]));
231
+ emit({ valid: errors.length === 0, errors });
232
+ return errors.length === 0 ? 0 : 1;
233
+ }
234
+
235
+ case 'classify': {
236
+ requirePositional(positional, 1, 'classify "<message>"');
237
+ const status = flags.httpStatus === undefined ? null : Number(flags.httpStatus);
238
+ emit(taxonomy.classify(positional[0], status));
239
+ return 0;
240
+ }
241
+
242
+ case 'context': {
243
+ const file = flags.path ?? path.join(flags.root ?? '.', '.qa', 'context.md');
244
+ const schema = contextSchema();
245
+ const parsed = contextModule.parseFile(file, { schema });
246
+ emit({
247
+ path: file,
248
+ context: parsed.context,
249
+ valid: parsed.valid,
250
+ errors: parsed.errors,
251
+ schemaChecked: schema !== null,
252
+ });
253
+ return parsed.valid ? 0 : 1;
254
+ }
255
+
256
+ case 'report-html': {
257
+ requirePositional(positional, 1, 'report-html <result.json>');
258
+ // The output path is handed to the renderer, not just used to write the file:
259
+ // every evidence href is computed relative to it. Rendering to stdout falls back
260
+ // to the result's own directory, which is where a report normally lands.
261
+ const outPath = flags.out ? path.resolve(flags.out) : null;
262
+ const document = reportHtml.renderFile(positional[0], {
263
+ title: flags.title ?? null,
264
+ outPath,
265
+ // --embed inlines every image as a data URI, producing one file that survives
266
+ // being forwarded without its screenshots folder.
267
+ embed: flags.embed === true,
268
+ // --mode picks the audience: full, executive, developer, or artifact (the
269
+ // body-only rendering a host page supplies its own <head> for).
270
+ mode: flags.mode ?? 'full',
271
+ });
272
+ if (outPath) {
273
+ fs.writeFileSync(outPath, document);
274
+ emit({ written: flags.out, bytes: document.length });
275
+ } else {
276
+ process.stdout.write(document);
277
+ }
278
+ return 0;
279
+ }
280
+
281
+ case 'report-export': {
282
+ requirePositional(positional, 1, 'report-export <result.json> --format <format>');
283
+ return reportExport(positional[0], flags);
284
+ }
285
+
286
+ case 'report-bundle': {
287
+ requirePositional(positional, 1, 'report-bundle <result.json> [--out DIR]');
288
+ const file = path.resolve(positional[0]);
289
+ const summary = bundleExport.writeBundle(readJson(file), {
290
+ resultPath: file,
291
+ outDir: flags.out ? path.resolve(flags.out) : undefined,
292
+ mode: flags.mode ?? 'full',
293
+ zip: flags.zipOut ?? flags.zip === true,
294
+ markdown: flags.markdown !== false,
295
+ force: flags.force === true,
296
+ });
297
+ emit(summary);
298
+ return 0;
299
+ }
300
+
301
+ case 'report-schema': {
302
+ // Any agent can fetch the canonical contract and validate against it before it
303
+ // hands anything over. Emitting it from the engine rather than documenting it in
304
+ // prose is what keeps the schema an agent writes to and the schema the renderer
305
+ // reads the same object.
306
+ const schema = readJson(
307
+ new URL('../lib/report/schemas/qa-report.schema.json', import.meta.url).pathname,
308
+ );
309
+ if (flags.out) {
310
+ fs.writeFileSync(flags.out, `${JSON.stringify(schema, null, 2)}\n`);
311
+ emit({ written: flags.out });
312
+ } else {
313
+ process.stdout.write(`${JSON.stringify(schema, null, 2)}\n`);
314
+ }
315
+ return 0;
316
+ }
317
+
318
+ case 'report-versions':
319
+ // What this renderer is: schema it reads, theme it paints, code that did it.
320
+ emit({
321
+ ...reportHtml.versionStamp(),
322
+ supportedContracts: reportHtml.supportedContracts(),
323
+ supportedModes: reportHtml.supportedModes(),
324
+ });
325
+ return 0;
326
+
327
+ case 'branding':
328
+ // Written to stdout verbatim, not as JSON: the caller embeds these exact
329
+ // bytes in a rendered report.
330
+ if (flags.metadata !== undefined) emit(branding.metadata());
331
+ else process.stdout.write(branding.footer(flags.format ?? 'text'));
332
+ return 0;
333
+
334
+ default:
335
+ throw new UsageError(`unknown analysis subcommand: ${subcommand ?? '(none)'}`);
336
+ }
337
+ }
338
+
339
+ // Every non-HTML rendering of a validated result. Text formats go to stdout or a file
340
+ // verbatim; JSON formats are emitted through the same sorted serializer as everything
341
+ // else, so a diff of two runs is a diff of the findings and not of key ordering.
342
+ const EXPORT_FORMATS = {
343
+ html: { text: true, render: (result, options) => reportHtml.render(result, options) },
344
+ markdown: { text: true, render: (result, options) => markdownExport.renderMarkdown(result, options) },
345
+ md: { text: true, render: (result, options) => markdownExport.renderMarkdown(result, options) },
346
+ sarif: { text: false, render: (result, options) => machineExport.renderSarif(result, options) },
347
+ junit: { text: true, render: (result, options) => machineExport.renderJUnit(result, options) },
348
+ csv: { text: true, render: (result, options) => machineExport.renderCsv(result, options) },
349
+ json: { text: false, render: (result) => result },
350
+ bundle: { text: false, render: (result, options) => machineExport.bundleManifest(result, options) },
351
+ };
352
+
353
+ function reportExport(file, flags) {
354
+ const format = String(flags.format ?? 'markdown').toLowerCase();
355
+ const exporter = EXPORT_FORMATS[format];
356
+ if (!exporter) {
357
+ throw new UsageError(
358
+ `unknown export format '${format}'; expected one of: ${Object.keys(EXPORT_FORMATS).sort().join(', ')}`,
359
+ );
360
+ }
361
+
362
+ const result = readJson(file);
363
+ const outPath = flags.out ? path.resolve(flags.out) : null;
364
+ const rendered = exporter.render(result, {
365
+ resultPath: path.resolve(file),
366
+ outPath,
367
+ embed: flags.embed === true,
368
+ mode: flags.mode ?? 'full',
369
+ });
370
+ const body = exporter.text ? rendered : `${JSON.stringify(rendered, sortedReplacer(), 2)}\n`;
371
+
372
+ if (outPath) {
373
+ fs.writeFileSync(outPath, body);
374
+ emit({ written: flags.out, format, bytes: body.length });
375
+ } else {
376
+ process.stdout.write(body);
377
+ }
378
+ return 0;
379
+ }
380
+
381
+ /**
382
+ * The artifact tool group: does the evidence this result points at actually exist?
383
+ *
384
+ * Separate from `analysis` because it answers a question about the *filesystem* rather
385
+ * than about a document, and because a skill runs it at a different moment — after
386
+ * capture and before rendering, as the gate that stops a report full of broken images
387
+ * from being written at all.
388
+ */
389
+ function artifactsCommand(argv) {
390
+ const [subcommand, ...rest] = argv;
391
+ const { flags, positional } = parseFlags(rest);
392
+
393
+ switch (subcommand) {
394
+ case 'verify': {
395
+ requirePositional(positional, 1, 'verify <result.json>');
396
+ const file = path.resolve(positional[0]);
397
+ const report = artifacts.verify(readJson(file), {
398
+ baseDir: flags.baseDir ? path.resolve(flags.baseDir) : path.dirname(file),
399
+ outDir: flags.outDir ? path.resolve(flags.outDir) : undefined,
400
+ });
401
+ emit(report);
402
+ // Exit 1, matching `validate`: the document was readable and did not hold up.
403
+ return report.ok ? 0 : 1;
404
+ }
405
+
406
+ case 'scan': {
407
+ requirePositional(positional, 1, 'scan <directory>');
408
+ const found = artifacts.scan(positional[0], { root: flags.root ?? positional[0] });
409
+ emit({
410
+ directory: positional[0],
411
+ count: found.length,
412
+ empty: found.filter((entry) => entry.empty).length,
413
+ bytes: found.reduce((sum, entry) => sum + entry.bytes, 0),
414
+ files: found,
415
+ });
416
+ return 0;
417
+ }
418
+
419
+ case 'hash': {
420
+ requirePositional(positional, 1, 'hash <file>');
421
+ emit({ path: positional[0], sha256: artifacts.hashFile(path.resolve(positional[0])) });
422
+ return 0;
423
+ }
424
+
425
+ default:
426
+ throw new UsageError(`unknown artifacts subcommand: ${subcommand ?? '(none)'}`);
427
+ }
428
+ }
429
+
430
+ function diagnosticsCommand(argv) {
431
+ const [subcommand, ...rest] = argv;
432
+ const { flags } = parseFlags(rest);
433
+
434
+ const execution = () => {
435
+ if (!flags.executionResult) throw new UsageError('--execution-result is required');
436
+ return validateExecutionResultMin(readJson(flags.executionResult));
437
+ };
438
+ const analysisResult = () =>
439
+ flags.analysisResult ? validateAnalysisResult(readJson(flags.analysisResult)) : null;
440
+ // A diagnosis handed back in is held to the same seam contract it was emitted
441
+ // under, so a hand-edited file is refused rather than half-understood.
442
+ const diagnosisInput = () => {
443
+ if (!flags.diagnosis) throw new UsageError('--diagnosis is required');
444
+ return validateDiagnosis(readJson(flags.diagnosis));
445
+ };
446
+
447
+ switch (subcommand) {
448
+ case 'diagnose':
449
+ emit(diagnostics.diagnose(execution(), { analysisResult: analysisResult() }));
450
+ return 0;
451
+
452
+ case 'plan-repairs':
453
+ // Wrapped in {plans}, matching the shape skills already parse. A bare array
454
+ // would be tidier and would break every caller.
455
+ emit({ plans: diagnostics.planRepairs(diagnosisInput()) });
456
+ return 0;
457
+
458
+ case 'summarize': {
459
+ const result = execution();
460
+ emit(diagnostics.summarize(result, diagnosisInput()));
461
+ return 0;
462
+ }
463
+
464
+ case 'report': {
465
+ // The one-shot path: diagnose, plan, and summarize in a single call, so the
466
+ // common case is one command instead of three.
467
+ const result = execution();
468
+ const diagnosis = diagnostics.diagnose(result, { analysisResult: analysisResult() });
469
+ emit({
470
+ diagnosis,
471
+ plans: diagnostics.planRepairs(diagnosis),
472
+ summary: diagnostics.summarize(result, diagnosis),
473
+ });
474
+ return 0;
475
+ }
476
+
477
+ default:
478
+ throw new UsageError(`unknown diagnostics subcommand: ${subcommand ?? '(none)'}`);
479
+ }
480
+ }
481
+
482
+ function playwrightCommand(argv) {
483
+ const [subcommand, ...rest] = argv;
484
+ const { positional } = parseFlags(rest);
485
+ if (subcommand === 'report') {
486
+ requirePositional(positional, 1, 'report <results.json>');
487
+ emit(playwright.parseReport(positional[0]));
488
+ return 0;
489
+ }
490
+ if (subcommand === 'trace') {
491
+ requirePositional(positional, 1, 'trace <trace.zip>');
492
+ emit(playwright.analyzeTrace(positional[0]));
493
+ return 0;
494
+ }
495
+ throw new UsageError(`unknown playwright subcommand: ${subcommand ?? '(none)'}`);
496
+ }
497
+
498
+ class UsageError extends Error {}
499
+
500
+ function requirePositional(positional, count, shape) {
501
+ if (positional.length < count) throw new UsageError(`usage: qa-engine analysis ${shape}`);
502
+ }
503
+
504
+ /** The context contract, in whichever layout the engine is running from. */
505
+ function contextSchema() {
506
+ const candidates = [new URL('../lib/analysis/schemas/context.schema.json', import.meta.url)];
507
+ for (const candidate of candidates) {
508
+ const file = candidate.pathname;
509
+ if (fs.existsSync(file)) return readJson(file);
510
+ }
511
+ return null;
512
+ }
513
+
514
+ export function main(argv = process.argv.slice(2)) {
515
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
516
+ process.stdout.write(USAGE);
517
+ return argv.length === 0 ? 2 : 0;
518
+ }
519
+ if (argv[0] === '--version') {
520
+ process.stdout.write(`${engineVersion()}\n`);
521
+ return 0;
522
+ }
523
+
524
+ const [tool, ...rest] = argv;
525
+ try {
526
+ if (tool === 'analysis') return analysis(rest);
527
+ if (tool === 'artifacts') return artifactsCommand(rest);
528
+ if (tool === 'diagnostics') return diagnosticsCommand(rest);
529
+ if (tool === 'playwright') return playwrightCommand(rest);
530
+ process.stderr.write(USAGE);
531
+ return fail('usage-error', `unknown tool: ${tool}`);
532
+ } catch (error) {
533
+ if (error instanceof junit.MalformedArtifact) return fail('malformed-artifact', error.message);
534
+ if (error instanceof contextModule.MalformedContext) return fail('malformed-context', error.message);
535
+ if (error instanceof reportHtml.ReportError) return fail('report-error', error.message);
536
+ // The non-HTML exporters go through the normalizer directly, so its refusals
537
+ // surface here rather than wrapped as a ReportError.
538
+ if (error instanceof SchemaError) return fail('schema-error', error.message);
539
+ if (error instanceof bundleExport.BundleError) return fail('bundle-error', error.message);
540
+ if (error instanceof artifacts.ArtifactError) return fail('artifact-error', error.message);
541
+ if (error instanceof branding.BrandingError) return fail('branding-error', error.message);
542
+ if (error instanceof InternalContractError) return fail('invalid-payload', error.message);
543
+ if (error instanceof UsageError) return fail('usage-error', error.message);
544
+ if (error instanceof SyntaxError) return fail('io-error', error.message);
545
+ if (error.code === 'ENOENT' || error.code === 'EACCES' || error.code === 'EISDIR') {
546
+ return fail('io-error', error.message);
547
+ }
548
+ throw error;
549
+ }
550
+ }
551
+
552
+ function engineVersion() {
553
+ try {
554
+ return readJson(new URL('../package.json', import.meta.url).pathname).version;
555
+ } catch {
556
+ return '0.0.0';
557
+ }
558
+ }
559
+
560
+ process.exitCode = main();