qa-engineer 0.9.1 → 0.10.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 +50 -26
- package/README.md +40 -32
- package/package.json +12 -20
- package/packages/engine/bin/qa-engine.mjs +336 -0
- package/packages/engine/lib/analysis/branding.mjs +187 -0
- package/packages/engine/lib/analysis/context.mjs +294 -0
- package/packages/{installer/lib/core/schema-validate.mjs → engine/lib/analysis/contracts.mjs} +11 -4
- 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 +90 -0
- package/packages/engine/lib/analysis/junit.mjs +126 -0
- package/packages/engine/lib/analysis/redaction.mjs +127 -0
- package/packages/engine/lib/analysis/report-html.mjs +763 -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/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/package.json +14 -0
- package/packages/installer/lib/agents/registry.mjs +46 -0
- package/packages/installer/lib/commands/doctor.mjs +7 -16
- package/packages/installer/lib/commands/install.mjs +59 -4
- package/packages/installer/lib/core/bundle.mjs +69 -92
- package/packages/installer/lib/core/config.mjs +1 -1
- package/packages/installer/lib/core/lockfile.mjs +1 -1
- package/packages/installer/lib/core/manifest.mjs +48 -54
- package/packages/installer/lib/core/validate-install.mjs +12 -19
- package/packages/installer/package.json +1 -1
- package/shared/tooling/qa-tool.mjs +125 -0
- package/skills/qa-api/SKILL.md +5 -5
- package/skills/qa-api/references/deterministic-tooling.md +34 -30
- package/skills/qa-api/references/evidence-and-reporting.md +29 -4
- package/skills/qa-api/scripts/qa-tool.mjs +126 -0
- package/skills/qa-audit/SKILL.md +5 -5
- package/skills/qa-audit/references/deterministic-tooling.md +34 -30
- package/skills/qa-audit/references/evidence-and-reporting.md +29 -4
- package/skills/qa-audit/scripts/qa-tool.mjs +126 -0
- package/skills/qa-debug/SKILL.md +6 -6
- package/skills/qa-debug/references/deterministic-tooling.md +34 -30
- package/skills/qa-debug/references/diagnostic-engine.md +3 -3
- package/skills/qa-debug/references/evidence-and-reporting.md +29 -4
- package/skills/qa-debug/scripts/qa-tool.mjs +126 -0
- package/skills/qa-explore/SKILL.md +22 -5
- package/skills/qa-explore/contracts/explore-result.schema.json +26 -2
- package/skills/qa-explore/references/deterministic-tooling.md +132 -0
- package/skills/qa-explore/references/evidence-and-reporting.md +29 -4
- package/skills/qa-explore/references/report-pipeline.md +89 -8
- package/skills/qa-explore/scripts/qa-tool.mjs +126 -0
- package/skills/qa-fix/SKILL.md +4 -4
- package/skills/qa-fix/references/deterministic-tooling.md +34 -30
- package/skills/qa-fix/references/diagnostic-engine.md +3 -3
- package/skills/qa-fix/references/evidence-and-reporting.md +29 -4
- package/skills/qa-fix/scripts/qa-tool.mjs +126 -0
- package/skills/qa-flaky/SKILL.md +4 -4
- package/skills/qa-flaky/references/deterministic-tooling.md +34 -30
- package/skills/qa-flaky/references/evidence-and-reporting.md +29 -4
- package/skills/qa-flaky/scripts/qa-tool.mjs +126 -0
- package/skills/qa-generate/references/evidence-and-reporting.md +29 -4
- package/skills/qa-init/SKILL.md +4 -4
- package/skills/qa-init/references/deterministic-tooling.md +34 -30
- package/skills/qa-init/references/evidence-and-reporting.md +29 -4
- package/skills/qa-init/scripts/qa-tool.mjs +126 -0
- package/skills/qa-report/SKILL.md +8 -7
- package/skills/qa-report/references/deterministic-tooling.md +34 -30
- package/skills/qa-report/references/diagnostic-engine.md +3 -3
- package/skills/qa-report/references/evidence-and-reporting.md +29 -4
- package/skills/qa-report/scripts/qa-tool.mjs +126 -0
- package/skills/qa-review/references/evidence-and-reporting.md +29 -4
- package/skills/qa-run/SKILL.md +6 -6
- package/skills/qa-run/references/deterministic-tooling.md +34 -30
- package/skills/qa-run/references/evidence-and-reporting.md +29 -4
- package/skills/qa-run/scripts/qa-tool.mjs +126 -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 -129
- 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 -58
- package/shared/analysis/lib/qa_analysis/junit.py +0 -80
- package/shared/analysis/lib/qa_analysis/redaction.py +0 -99
- 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,336 @@
|
|
|
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 discovery from '../lib/analysis/discovery.mjs';
|
|
29
|
+
import * as diffGuard from '../lib/analysis/diff-guard.mjs';
|
|
30
|
+
import * as redaction from '../lib/analysis/redaction.mjs';
|
|
31
|
+
import * as contracts from '../lib/analysis/contracts.mjs';
|
|
32
|
+
import * as taxonomy from '../lib/analysis/taxonomy.mjs';
|
|
33
|
+
import * as contextModule from '../lib/analysis/context.mjs';
|
|
34
|
+
import * as branding from '../lib/analysis/branding.mjs';
|
|
35
|
+
import * as reportHtml from '../lib/analysis/report-html.mjs';
|
|
36
|
+
import * as diagnostics from '../lib/diagnostics/engine.mjs';
|
|
37
|
+
import {
|
|
38
|
+
InternalContractError, validateAnalysisResult, validateExecutionResultMin, validateDiagnosis,
|
|
39
|
+
} from '../lib/diagnostics/internal-contracts.mjs';
|
|
40
|
+
import * as playwright from '../lib/frameworks/playwright.mjs';
|
|
41
|
+
import * as junitFrameworks from '../lib/frameworks/junit-frameworks.mjs';
|
|
42
|
+
|
|
43
|
+
const USAGE = `usage: qa-engine <tool> <subcommand> [args]
|
|
44
|
+
|
|
45
|
+
analysis parse artifacts, classify errors, validate contracts, diff-guard,
|
|
46
|
+
read .qa/context.md, render an HTML report, print the footer
|
|
47
|
+
diagnostics root cause, timeline, priority, repair plans, release readiness
|
|
48
|
+
playwright normalize a Playwright report or summarize a trace
|
|
49
|
+
|
|
50
|
+
analysis subcommands
|
|
51
|
+
junit <report.xml> normalized {tests, executed}
|
|
52
|
+
har <file.har> [--slow-ms N] redacted network summary
|
|
53
|
+
discover [--root DIR] [--path P ...] artifacts found, by state
|
|
54
|
+
diff-guard <diff-file> {issues, safe} — safe:false blocks a change
|
|
55
|
+
redact <file> the file's text with secrets masked
|
|
56
|
+
validate <instance.json> <schema.json> {valid, errors}; exit 1 when invalid
|
|
57
|
+
classify "<message>" [--http-status N] {classification, confidence, reason}
|
|
58
|
+
context [--root DIR] [--path P] parsed, schema-checked project context
|
|
59
|
+
report-html <result.json> [--out FILE] a self-contained HTML report
|
|
60
|
+
branding [--format html|markdown|text] the exact footer bytes for a report
|
|
61
|
+
|
|
62
|
+
diagnostics subcommands
|
|
63
|
+
diagnose --execution-result P [--analysis-result P]
|
|
64
|
+
plan-repairs --diagnosis P
|
|
65
|
+
summarize --execution-result P --diagnosis P
|
|
66
|
+
report --execution-result P [--analysis-result P] all three in one call
|
|
67
|
+
|
|
68
|
+
playwright subcommands
|
|
69
|
+
report <results.json> the same shape as analysis junit
|
|
70
|
+
trace <trace.zip> actions, errors, and a classification
|
|
71
|
+
|
|
72
|
+
examples
|
|
73
|
+
qa-engine analysis junit test-results/results.xml
|
|
74
|
+
qa-engine analysis report-html qa-artifacts/explore-result.json --out report.html
|
|
75
|
+
qa-engine diagnostics report --execution-result qa-artifacts/run.json
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
function emit(value) {
|
|
79
|
+
process.stdout.write(`${JSON.stringify(value, sortedReplacer(), 2)}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Keys are emitted in sorted order, matching the Python CLIs' `sort_keys=True`.
|
|
84
|
+
* The output is read by people and diffed in CI, so a stable order is part of it.
|
|
85
|
+
*/
|
|
86
|
+
function sortedReplacer() {
|
|
87
|
+
return function replacer(key, value) {
|
|
88
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
89
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fail(error, detail) {
|
|
94
|
+
emit({ error, detail });
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readJson(file) {
|
|
99
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Flags, parsed the way the Python argparse CLIs accepted them. */
|
|
103
|
+
function parseFlags(argv, { repeatable = [] } = {}) {
|
|
104
|
+
const flags = {};
|
|
105
|
+
const positional = [];
|
|
106
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
107
|
+
const token = argv[index];
|
|
108
|
+
if (!token.startsWith('--')) {
|
|
109
|
+
positional.push(token);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const [name, inline] = token.slice(2).split('=', 2);
|
|
113
|
+
const camel = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
114
|
+
const value = inline !== undefined ? inline : argv[++index];
|
|
115
|
+
if (repeatable.includes(camel)) {
|
|
116
|
+
flags[camel] = [...(flags[camel] ?? []), value];
|
|
117
|
+
} else {
|
|
118
|
+
flags[camel] = value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { flags, positional };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Which flags may appear more than once, per subcommand. `discover` accepts a list
|
|
125
|
+
// of explicit paths; `context` names one file. Treating `--path` as repeatable
|
|
126
|
+
// everywhere handed `context` an array and it failed on "path must be a string".
|
|
127
|
+
const REPEATABLE = { discover: ['path'] };
|
|
128
|
+
|
|
129
|
+
function analysis(argv) {
|
|
130
|
+
const [subcommand, ...rest] = argv;
|
|
131
|
+
const { flags, positional } = parseFlags(rest, { repeatable: REPEATABLE[subcommand] ?? [] });
|
|
132
|
+
|
|
133
|
+
switch (subcommand) {
|
|
134
|
+
case 'junit':
|
|
135
|
+
requirePositional(positional, 1, 'junit <report.xml>');
|
|
136
|
+
emit(junit.parseJUnit(positional[0]));
|
|
137
|
+
return 0;
|
|
138
|
+
|
|
139
|
+
case 'har':
|
|
140
|
+
requirePositional(positional, 1, 'har <file.har>');
|
|
141
|
+
emit(har.parseHar(positional[0], { slowMs: Number(flags.slowMs ?? 1000) }));
|
|
142
|
+
return 0;
|
|
143
|
+
|
|
144
|
+
case 'discover':
|
|
145
|
+
emit(discovery.discover({ root: flags.root ?? '.', explicit: flags.path ?? null }));
|
|
146
|
+
return 0;
|
|
147
|
+
|
|
148
|
+
case 'diff-guard': {
|
|
149
|
+
requirePositional(positional, 1, 'diff-guard <diff-file>');
|
|
150
|
+
const issues = diffGuard.checkDiff(fs.readFileSync(positional[0], 'utf8'));
|
|
151
|
+
emit({ issues, safe: !issues.some((issue) => issue.severity === 'high') });
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case 'redact':
|
|
156
|
+
requirePositional(positional, 1, 'redact <file>');
|
|
157
|
+
process.stdout.write(redaction.redactText(fs.readFileSync(positional[0], 'utf8')));
|
|
158
|
+
return 0;
|
|
159
|
+
|
|
160
|
+
case 'validate': {
|
|
161
|
+
requirePositional(positional, 2, 'validate <instance.json> <schema.json>');
|
|
162
|
+
const errors = contracts.validate(readJson(positional[0]), readJson(positional[1]));
|
|
163
|
+
emit({ valid: errors.length === 0, errors });
|
|
164
|
+
return errors.length === 0 ? 0 : 1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case 'classify': {
|
|
168
|
+
requirePositional(positional, 1, 'classify "<message>"');
|
|
169
|
+
const status = flags.httpStatus === undefined ? null : Number(flags.httpStatus);
|
|
170
|
+
emit(taxonomy.classify(positional[0], status));
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case 'context': {
|
|
175
|
+
const file = flags.path ?? path.join(flags.root ?? '.', '.qa', 'context.md');
|
|
176
|
+
const schema = contextSchema();
|
|
177
|
+
const parsed = contextModule.parseFile(file, { schema });
|
|
178
|
+
emit({
|
|
179
|
+
path: file,
|
|
180
|
+
context: parsed.context,
|
|
181
|
+
valid: parsed.valid,
|
|
182
|
+
errors: parsed.errors,
|
|
183
|
+
schemaChecked: schema !== null,
|
|
184
|
+
});
|
|
185
|
+
return parsed.valid ? 0 : 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case 'report-html': {
|
|
189
|
+
requirePositional(positional, 1, 'report-html <result.json>');
|
|
190
|
+
const document = reportHtml.renderFile(positional[0], { title: flags.title ?? null });
|
|
191
|
+
if (flags.out) {
|
|
192
|
+
fs.writeFileSync(flags.out, document);
|
|
193
|
+
emit({ written: flags.out, bytes: document.length });
|
|
194
|
+
} else {
|
|
195
|
+
process.stdout.write(document);
|
|
196
|
+
}
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
case 'branding':
|
|
201
|
+
// Written to stdout verbatim, not as JSON: the caller embeds these exact
|
|
202
|
+
// bytes in a rendered report.
|
|
203
|
+
if (flags.metadata !== undefined) emit(branding.metadata());
|
|
204
|
+
else process.stdout.write(branding.footer(flags.format ?? 'text'));
|
|
205
|
+
return 0;
|
|
206
|
+
|
|
207
|
+
default:
|
|
208
|
+
throw new UsageError(`unknown analysis subcommand: ${subcommand ?? '(none)'}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function diagnosticsCommand(argv) {
|
|
213
|
+
const [subcommand, ...rest] = argv;
|
|
214
|
+
const { flags } = parseFlags(rest);
|
|
215
|
+
|
|
216
|
+
const execution = () => {
|
|
217
|
+
if (!flags.executionResult) throw new UsageError('--execution-result is required');
|
|
218
|
+
return validateExecutionResultMin(readJson(flags.executionResult));
|
|
219
|
+
};
|
|
220
|
+
const analysisResult = () =>
|
|
221
|
+
flags.analysisResult ? validateAnalysisResult(readJson(flags.analysisResult)) : null;
|
|
222
|
+
// A diagnosis handed back in is held to the same seam contract it was emitted
|
|
223
|
+
// under, so a hand-edited file is refused rather than half-understood.
|
|
224
|
+
const diagnosisInput = () => {
|
|
225
|
+
if (!flags.diagnosis) throw new UsageError('--diagnosis is required');
|
|
226
|
+
return validateDiagnosis(readJson(flags.diagnosis));
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
switch (subcommand) {
|
|
230
|
+
case 'diagnose':
|
|
231
|
+
emit(diagnostics.diagnose(execution(), { analysisResult: analysisResult() }));
|
|
232
|
+
return 0;
|
|
233
|
+
|
|
234
|
+
case 'plan-repairs':
|
|
235
|
+
// Wrapped in {plans}, matching the shape skills already parse. A bare array
|
|
236
|
+
// would be tidier and would break every caller.
|
|
237
|
+
emit({ plans: diagnostics.planRepairs(diagnosisInput()) });
|
|
238
|
+
return 0;
|
|
239
|
+
|
|
240
|
+
case 'summarize': {
|
|
241
|
+
const result = execution();
|
|
242
|
+
emit(diagnostics.summarize(result, diagnosisInput()));
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case 'report': {
|
|
247
|
+
// The one-shot path: diagnose, plan, and summarize in a single call, so the
|
|
248
|
+
// common case is one command instead of three.
|
|
249
|
+
const result = execution();
|
|
250
|
+
const diagnosis = diagnostics.diagnose(result, { analysisResult: analysisResult() });
|
|
251
|
+
emit({
|
|
252
|
+
diagnosis,
|
|
253
|
+
plans: diagnostics.planRepairs(diagnosis),
|
|
254
|
+
summary: diagnostics.summarize(result, diagnosis),
|
|
255
|
+
});
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
default:
|
|
260
|
+
throw new UsageError(`unknown diagnostics subcommand: ${subcommand ?? '(none)'}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function playwrightCommand(argv) {
|
|
265
|
+
const [subcommand, ...rest] = argv;
|
|
266
|
+
const { positional } = parseFlags(rest);
|
|
267
|
+
if (subcommand === 'report') {
|
|
268
|
+
requirePositional(positional, 1, 'report <results.json>');
|
|
269
|
+
emit(playwright.parseReport(positional[0]));
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
if (subcommand === 'trace') {
|
|
273
|
+
requirePositional(positional, 1, 'trace <trace.zip>');
|
|
274
|
+
emit(playwright.analyzeTrace(positional[0]));
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
throw new UsageError(`unknown playwright subcommand: ${subcommand ?? '(none)'}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
class UsageError extends Error {}
|
|
281
|
+
|
|
282
|
+
function requirePositional(positional, count, shape) {
|
|
283
|
+
if (positional.length < count) throw new UsageError(`usage: qa-engine analysis ${shape}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The context contract, in whichever layout the engine is running from. */
|
|
287
|
+
function contextSchema() {
|
|
288
|
+
const candidates = [new URL('../lib/analysis/schemas/context.schema.json', import.meta.url)];
|
|
289
|
+
for (const candidate of candidates) {
|
|
290
|
+
const file = candidate.pathname;
|
|
291
|
+
if (fs.existsSync(file)) return readJson(file);
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function main(argv = process.argv.slice(2)) {
|
|
297
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
298
|
+
process.stdout.write(USAGE);
|
|
299
|
+
return argv.length === 0 ? 2 : 0;
|
|
300
|
+
}
|
|
301
|
+
if (argv[0] === '--version') {
|
|
302
|
+
process.stdout.write(`${engineVersion()}\n`);
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const [tool, ...rest] = argv;
|
|
307
|
+
try {
|
|
308
|
+
if (tool === 'analysis') return analysis(rest);
|
|
309
|
+
if (tool === 'diagnostics') return diagnosticsCommand(rest);
|
|
310
|
+
if (tool === 'playwright') return playwrightCommand(rest);
|
|
311
|
+
process.stderr.write(USAGE);
|
|
312
|
+
return fail('usage-error', `unknown tool: ${tool}`);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (error instanceof junit.MalformedArtifact) return fail('malformed-artifact', error.message);
|
|
315
|
+
if (error instanceof contextModule.MalformedContext) return fail('malformed-context', error.message);
|
|
316
|
+
if (error instanceof reportHtml.ReportError) return fail('report-error', error.message);
|
|
317
|
+
if (error instanceof branding.BrandingError) return fail('branding-error', error.message);
|
|
318
|
+
if (error instanceof InternalContractError) return fail('invalid-payload', error.message);
|
|
319
|
+
if (error instanceof UsageError) return fail('usage-error', error.message);
|
|
320
|
+
if (error instanceof SyntaxError) return fail('io-error', error.message);
|
|
321
|
+
if (error.code === 'ENOENT' || error.code === 'EACCES' || error.code === 'EISDIR') {
|
|
322
|
+
return fail('io-error', error.message);
|
|
323
|
+
}
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function engineVersion() {
|
|
329
|
+
try {
|
|
330
|
+
return readJson(new URL('../package.json', import.meta.url).pathname).version;
|
|
331
|
+
} catch {
|
|
332
|
+
return '0.0.0';
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
process.exitCode = main();
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Product attribution for human-readable reports.
|
|
2
|
+
//
|
|
3
|
+
// One renderer, one metadata file, three output formats. Every report the pack
|
|
4
|
+
// produces for a human ends with the same footer, and changing it means editing
|
|
5
|
+
// `branding.json` — nothing else.
|
|
6
|
+
//
|
|
7
|
+
// ## Why this is code and not a string in each skill
|
|
8
|
+
//
|
|
9
|
+
// A footer typed by a model is a footer that drifts: the tagline gains a word, the
|
|
10
|
+
// URL loses a scheme, one report says "Developed by" and the next says "Built by".
|
|
11
|
+
// Rendering it deterministically makes every report byte-identical, and makes a
|
|
12
|
+
// change to the branding a one-file edit that CI can verify
|
|
13
|
+
// (scripts/check-branding.mjs fails if any branding string is hardcoded elsewhere).
|
|
14
|
+
//
|
|
15
|
+
// ## What gets a footer, and what must not
|
|
16
|
+
//
|
|
17
|
+
// Attribution belongs on documents a person reads. It is noise — or worse, a
|
|
18
|
+
// parsing hazard — anywhere else.
|
|
19
|
+
//
|
|
20
|
+
// Branded | Not branded
|
|
21
|
+
// ---------------------------------|--------------------------------------------
|
|
22
|
+
// HTML and PDF reports | JSON and YAML artifacts under qa-artifacts/
|
|
23
|
+
// Markdown meant for people | Markdown written for a machine to read
|
|
24
|
+
// Generated documentation | CLI stdout, progress output, --json output
|
|
25
|
+
// Audit and evaluation renderings | The system under test, the user's own source
|
|
26
|
+
//
|
|
27
|
+
// The rule behind the table: **if a program will parse it, it gets no footer.** A
|
|
28
|
+
// contract artifact is an interface, and appending prose to an interface breaks it.
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
|
|
36
|
+
// One metadata file, beside this module — so it travels with the engine into a
|
|
37
|
+
// bundled skill without a second copy to keep in step.
|
|
38
|
+
const METADATA_CANDIDATES = [path.join(here, 'branding.json')];
|
|
39
|
+
|
|
40
|
+
// The visual width of the plain-text rules. Wide enough to frame the four lines,
|
|
41
|
+
// narrow enough to survive an 80-column terminal or a PDF margin.
|
|
42
|
+
const RULE_WIDTH = 60;
|
|
43
|
+
|
|
44
|
+
const ALLOWED_SCHEMES = ['https://', 'http://'];
|
|
45
|
+
|
|
46
|
+
export class BrandingError extends Error {}
|
|
47
|
+
|
|
48
|
+
/** The branding metadata. Read fresh, so a change needs no reinstall. */
|
|
49
|
+
export function metadata() {
|
|
50
|
+
const source = METADATA_CANDIDATES.find((candidate) => fs.existsSync(candidate));
|
|
51
|
+
if (!source) {
|
|
52
|
+
throw new BrandingError(
|
|
53
|
+
`could not find branding metadata (looked in: ${METADATA_CANDIDATES.join(', ')})`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let data;
|
|
58
|
+
try {
|
|
59
|
+
data = JSON.parse(fs.readFileSync(source, 'utf8'));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new BrandingError(`could not read branding metadata at ${source}: ${error.message}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const required = ['projectName', 'tagline', 'author', 'website', 'attributionPrefix', 'authorPrefix'];
|
|
65
|
+
const missing = required.filter((key) => !data[key]);
|
|
66
|
+
if (missing.length > 0) {
|
|
67
|
+
throw new BrandingError(`branding metadata is missing: ${missing.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
if (!ALLOWED_SCHEMES.some((scheme) => data.website.startsWith(scheme))) {
|
|
70
|
+
// A footer is rendered into HTML, so a non-http scheme here would be an
|
|
71
|
+
// injection vector rather than a typo.
|
|
72
|
+
throw new BrandingError(
|
|
73
|
+
`branding website must start with http:// or https://, got '${data.website}'`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** HTML-escape, matching Python's `html.escape(s, quote=True)` character for character. */
|
|
80
|
+
function escape(value) {
|
|
81
|
+
return String(value)
|
|
82
|
+
.replace(/&/g, '&')
|
|
83
|
+
.replace(/</g, '<')
|
|
84
|
+
.replace(/>/g, '>')
|
|
85
|
+
.replace(/"/g, '"')
|
|
86
|
+
.replace(/'/g, ''');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The four footer lines, in order, as plain strings. */
|
|
90
|
+
function lines(data) {
|
|
91
|
+
return [
|
|
92
|
+
`${data.attributionPrefix} ${data.projectName}`,
|
|
93
|
+
data.tagline,
|
|
94
|
+
`${data.authorPrefix} ${data.author}`,
|
|
95
|
+
data.website,
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Rule-separated plain text, for PDF and any non-markup rendering.
|
|
101
|
+
*
|
|
102
|
+
* Centering follows Python's `str.center`: the odd space goes on the right, which
|
|
103
|
+
* then falls off with the trailing trim. Getting that backwards shifts every line
|
|
104
|
+
* by a character and breaks the snapshot the branding tests pin.
|
|
105
|
+
*/
|
|
106
|
+
export function footerText({ width = RULE_WIDTH } = {}) {
|
|
107
|
+
const data = metadata();
|
|
108
|
+
const rule = '-'.repeat(width);
|
|
109
|
+
const body = lines(data)
|
|
110
|
+
.map((line) => {
|
|
111
|
+
if (line.length >= width) return line;
|
|
112
|
+
return ' '.repeat(Math.floor((width - line.length) / 2)) + line;
|
|
113
|
+
})
|
|
114
|
+
.join('\n');
|
|
115
|
+
return `${rule}\n${body}\n${rule}\n`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A thematic break and the four lines, with the site as a link. */
|
|
119
|
+
export function footerMarkdown() {
|
|
120
|
+
const data = metadata();
|
|
121
|
+
return (
|
|
122
|
+
'---\n\n' +
|
|
123
|
+
`<sub>${data.attributionPrefix} **${data.projectName}** — ` +
|
|
124
|
+
`${data.tagline}<br>\n` +
|
|
125
|
+
`${data.authorPrefix} ` +
|
|
126
|
+
`[${data.author}](${data.website})</sub>\n`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A self-contained `<footer>`: inline styles, muted, centered, small.
|
|
132
|
+
*
|
|
133
|
+
* The author's site opens in a new tab. `rel="noopener noreferrer"` is not
|
|
134
|
+
* optional — a report may be opened from anywhere, and a new tab that can reach
|
|
135
|
+
* back into `window.opener` is a real hazard.
|
|
136
|
+
*/
|
|
137
|
+
export function footerHtml({ className = 'qa-pack-attribution' } = {}) {
|
|
138
|
+
const data = metadata();
|
|
139
|
+
return (
|
|
140
|
+
`<footer class="${escape(className)}" style="margin-top:2.5rem;padding-top:1rem;` +
|
|
141
|
+
'border-top:1px solid rgba(128,128,128,0.25);font-size:0.75rem;line-height:1.6;' +
|
|
142
|
+
'color:#6b7280;text-align:center;font-family:system-ui,-apple-system,' +
|
|
143
|
+
'Segoe UI,Roboto,sans-serif;">\n' +
|
|
144
|
+
` <div>${escape(data.attributionPrefix)} <strong>${escape(data.projectName)}</strong></div>\n` +
|
|
145
|
+
` <div>${escape(data.tagline)}</div>\n` +
|
|
146
|
+
` <div>${escape(data.authorPrefix)} ` +
|
|
147
|
+
`<a href="${escape(data.website)}" target="_blank" rel="noopener noreferrer" ` +
|
|
148
|
+
'style="color:inherit;text-decoration:underline;">' +
|
|
149
|
+
`${escape(data.author)}</a></div>\n` +
|
|
150
|
+
'</footer>\n'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const RENDERERS = {
|
|
155
|
+
html: footerHtml,
|
|
156
|
+
markdown: footerMarkdown,
|
|
157
|
+
md: footerMarkdown,
|
|
158
|
+
text: footerText,
|
|
159
|
+
txt: footerText,
|
|
160
|
+
pdf: footerText, // what a PDF writer embeds when it cannot render markup
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const FORMATS = ['html', 'markdown', 'text'];
|
|
164
|
+
|
|
165
|
+
/** Render the footer in `format`. Throws BrandingError on an unknown format. */
|
|
166
|
+
export function footer(format = 'text') {
|
|
167
|
+
const renderer = RENDERERS[String(format).toLowerCase()];
|
|
168
|
+
if (!renderer) {
|
|
169
|
+
throw new BrandingError(
|
|
170
|
+
`unknown branding format '${format}'; expected one of: ${FORMATS.join(', ')}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return renderer();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Return `document` with the footer appended, idempotently.
|
|
178
|
+
*
|
|
179
|
+
* Idempotence matters: a report assembled in stages, or regenerated over its own
|
|
180
|
+
* output, must not accumulate footers.
|
|
181
|
+
*/
|
|
182
|
+
export function appendTo(document, format = 'markdown') {
|
|
183
|
+
const rendered = footer(format);
|
|
184
|
+
if (rendered.trim() && document.includes(rendered.trim())) return document;
|
|
185
|
+
const separator = document.endsWith('\n') ? '' : '\n';
|
|
186
|
+
return `${document}${separator}\n${rendered}`;
|
|
187
|
+
}
|