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.
- package/COMPATIBILITY.md +26 -12
- package/README.md +182 -84
- package/package.json +11 -20
- package/packages/engine/bin/qa-engine.mjs +560 -0
- package/packages/engine/lib/analysis/branding.mjs +187 -0
- package/packages/engine/lib/analysis/context.mjs +294 -0
- package/packages/engine/lib/analysis/contracts.mjs +149 -0
- package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
- package/packages/engine/lib/analysis/discovery.mjs +220 -0
- package/packages/engine/lib/analysis/evidence.mjs +116 -0
- package/packages/engine/lib/analysis/har.mjs +124 -0
- package/packages/engine/lib/analysis/junit.mjs +126 -0
- package/packages/engine/lib/analysis/network.mjs +237 -0
- package/packages/engine/lib/analysis/redaction.mjs +127 -0
- package/packages/engine/lib/analysis/report-html.mjs +76 -0
- package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
- package/packages/engine/lib/analysis/xml.mjs +153 -0
- package/packages/engine/lib/analysis/zip.mjs +107 -0
- package/packages/engine/lib/artifacts/manager.mjs +453 -0
- package/packages/engine/lib/artifacts/mime.mjs +109 -0
- package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
- package/packages/engine/lib/diagnostics/engine.mjs +165 -0
- package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
- package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
- package/packages/engine/lib/diagnostics/repair.mjs +73 -0
- package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
- package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
- package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
- package/packages/engine/lib/frameworks/playwright.mjs +158 -0
- package/packages/engine/lib/report/components/charts.mjs +424 -0
- package/packages/engine/lib/report/components/evidence.mjs +207 -0
- package/packages/engine/lib/report/components/findings.mjs +258 -0
- package/packages/engine/lib/report/components/nav.mjs +99 -0
- package/packages/engine/lib/report/components/primitives.mjs +246 -0
- package/packages/engine/lib/report/components/runtime.mjs +246 -0
- package/packages/engine/lib/report/components/timeline.mjs +65 -0
- package/packages/engine/lib/report/core/model.mjs +270 -0
- package/packages/engine/lib/report/core/normalize.mjs +226 -0
- package/packages/engine/lib/report/core/sections.mjs +978 -0
- package/packages/engine/lib/report/export/bundle.mjs +293 -0
- package/packages/engine/lib/report/export/html.mjs +183 -0
- package/packages/engine/lib/report/export/machine.mjs +290 -0
- package/packages/engine/lib/report/export/markdown.mjs +323 -0
- package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
- package/packages/engine/lib/report/theme/css.mjs +529 -0
- package/packages/engine/lib/report/theme/tokens.mjs +137 -0
- package/packages/engine/lib/report/version.mjs +78 -0
- package/packages/engine/package.json +14 -0
- package/packages/installer/lib/agents/targets.mjs +90 -0
- package/packages/installer/lib/agents/user-level.mjs +80 -0
- package/packages/installer/lib/cli/flags.mjs +20 -2
- package/packages/installer/lib/commands/doctor.mjs +13 -20
- package/packages/installer/lib/commands/install.mjs +160 -91
- package/packages/installer/lib/commands/repair.mjs +10 -6
- package/packages/installer/lib/commands/self-test.mjs +4 -3
- package/packages/installer/lib/commands/uninstall.mjs +14 -8
- package/packages/installer/lib/commands/update.mjs +9 -4
- package/packages/installer/lib/commands/verify.mjs +13 -12
- package/packages/installer/lib/constants.mjs +13 -0
- package/packages/installer/lib/core/bundle.mjs +69 -92
- package/packages/installer/lib/core/conflict.mjs +5 -4
- package/packages/installer/lib/core/fs-safe.mjs +146 -6
- package/packages/installer/lib/core/integrity.mjs +59 -0
- package/packages/installer/lib/core/lockfile.mjs +19 -3
- package/packages/installer/lib/core/manifest.mjs +48 -57
- package/packages/installer/lib/core/plan.mjs +213 -0
- package/packages/installer/lib/core/qa-home.mjs +145 -0
- package/packages/installer/lib/core/scope.mjs +274 -0
- package/packages/installer/lib/core/validate-install.mjs +49 -31
- package/packages/installer/package.json +1 -1
- package/packages/installer/schemas/qa-lock.schema.json +119 -21
- package/shared/tooling/qa-tool.mjs +161 -0
- package/skills/qa-api/SKILL.md +5 -5
- package/skills/qa-api/references/deterministic-tooling.md +34 -32
- package/skills/qa-api/references/evidence-and-reporting.md +5 -5
- package/skills/qa-api/scripts/qa-tool.mjs +162 -0
- package/skills/qa-audit/SKILL.md +5 -5
- package/skills/qa-audit/references/deterministic-tooling.md +34 -32
- package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
- package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
- package/skills/qa-debug/SKILL.md +6 -6
- package/skills/qa-debug/references/deterministic-tooling.md +34 -32
- package/skills/qa-debug/references/diagnostic-engine.md +3 -3
- package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
- package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
- package/skills/qa-explore/SKILL.md +31 -15
- package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
- package/skills/qa-explore/references/api-replay.md +40 -1
- package/skills/qa-explore/references/deterministic-tooling.md +34 -32
- package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
- package/skills/qa-explore/references/report-pipeline.md +266 -96
- package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
- package/skills/qa-fix/SKILL.md +4 -4
- package/skills/qa-fix/references/deterministic-tooling.md +34 -32
- package/skills/qa-fix/references/diagnostic-engine.md +3 -3
- package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
- package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
- package/skills/qa-flaky/SKILL.md +4 -4
- package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
- package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
- package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
- package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/SKILL.md +4 -4
- package/skills/qa-init/references/deterministic-tooling.md +34 -32
- package/skills/qa-init/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/scripts/qa-tool.mjs +162 -0
- package/skills/qa-report/SKILL.md +7 -7
- package/skills/qa-report/references/deterministic-tooling.md +34 -32
- package/skills/qa-report/references/diagnostic-engine.md +3 -3
- package/skills/qa-report/references/evidence-and-reporting.md +5 -5
- package/skills/qa-report/scripts/qa-tool.mjs +162 -0
- package/skills/qa-review/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/SKILL.md +6 -6
- package/skills/qa-run/references/deterministic-tooling.md +34 -32
- package/skills/qa-run/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/scripts/qa-tool.mjs +162 -0
- package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
- package/shared/analysis/lib/qa_analysis/branding.py +0 -175
- package/shared/analysis/lib/qa_analysis/cli.py +0 -144
- package/shared/analysis/lib/qa_analysis/context.py +0 -233
- package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
- package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
- package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
- package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
- package/shared/analysis/lib/qa_analysis/har.py +0 -87
- package/shared/analysis/lib/qa_analysis/junit.py +0 -104
- package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
- package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
- package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
- package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
- package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
- package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
- package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
- package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
- package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
- package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
- package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
- package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
- package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
- package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
- package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
- package/shared/tooling/qa_tool.py +0 -127
- /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
- /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
- /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();
|