qa-engineer 0.9.2 → 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 +26 -12
- package/README.md +20 -30
- package/package.json +11 -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/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 +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/commands/doctor.mjs +7 -16
- package/packages/installer/lib/commands/install.mjs +28 -0
- package/packages/installer/lib/core/bundle.mjs +69 -92
- package/packages/installer/lib/core/manifest.mjs +48 -57
- 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 -32
- package/skills/qa-api/references/evidence-and-reporting.md +5 -5
- 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 -32
- package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
- 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 -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 +126 -0
- package/skills/qa-explore/SKILL.md +6 -6
- 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 +3 -3
- 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 -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 +126 -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 +126 -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 +126 -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 +126 -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 +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 -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,116 @@
|
|
|
1
|
+
// The evidence and finding model shared by every analyzer.
|
|
2
|
+
//
|
|
3
|
+
// Every finding an analyzer produces carries the same structure, so downstream
|
|
4
|
+
// skills (qa-debug, qa-report, qa-fix) consume one shape regardless of which
|
|
5
|
+
// analyzer or framework produced it. Text fields are redacted at construction.
|
|
6
|
+
//
|
|
7
|
+
// Ported from qa_analysis/evidence.py. Python used dataclasses with a `to_dict()`;
|
|
8
|
+
// here each factory returns the serialized shape directly, because that shape —
|
|
9
|
+
// not the object — is what every consumer actually uses, and one representation
|
|
10
|
+
// cannot drift from the other.
|
|
11
|
+
|
|
12
|
+
import { redactText } from './redaction.mjs';
|
|
13
|
+
|
|
14
|
+
export const EVIDENCE_TYPES = new Set([
|
|
15
|
+
'trace', 'har', 'junit', 'report', 'console', 'network', 'stdout',
|
|
16
|
+
'stderr', 'screenshot', 'video', 'log', 'file', 'diff',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** ISO 8601 UTC timestamp. Isolated so tests can substitute it. */
|
|
20
|
+
export function utcNow() {
|
|
21
|
+
return new Date().toISOString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One observation supporting a finding. Excerpts are redacted here, at
|
|
26
|
+
* construction, so no caller can forget to do it.
|
|
27
|
+
*/
|
|
28
|
+
export function evidence({ type, description, source, excerpt = '' }) {
|
|
29
|
+
if (!EVIDENCE_TYPES.has(type)) {
|
|
30
|
+
throw new Error(`unknown evidence type: ${type}`);
|
|
31
|
+
}
|
|
32
|
+
const redacted = redactText(excerpt);
|
|
33
|
+
const entry = { type, description, source };
|
|
34
|
+
if (redacted) entry.excerpt = redacted;
|
|
35
|
+
return entry;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A single diagnostic conclusion, traceable to a specific artifact.
|
|
40
|
+
*
|
|
41
|
+
* Carries everything the evidence model requires: the artifact and location it
|
|
42
|
+
* came from, when, why, the supporting evidence, a calibrated confidence, the
|
|
43
|
+
* affected tests, related artifacts, and recommended actions.
|
|
44
|
+
*/
|
|
45
|
+
export function finding({
|
|
46
|
+
classification,
|
|
47
|
+
reason,
|
|
48
|
+
artifact: artifactPath,
|
|
49
|
+
location,
|
|
50
|
+
confidence = null,
|
|
51
|
+
timestamp = undefined,
|
|
52
|
+
evidence: items = [],
|
|
53
|
+
affectedTests = [],
|
|
54
|
+
relatedArtifacts = [],
|
|
55
|
+
recommendations = [],
|
|
56
|
+
}) {
|
|
57
|
+
const result = {
|
|
58
|
+
classification,
|
|
59
|
+
reason,
|
|
60
|
+
artifact: artifactPath,
|
|
61
|
+
location,
|
|
62
|
+
timestamp: timestamp ?? utcNow(),
|
|
63
|
+
evidence: items,
|
|
64
|
+
affectedTests,
|
|
65
|
+
relatedArtifacts,
|
|
66
|
+
recommendations,
|
|
67
|
+
};
|
|
68
|
+
if (confidence !== null && confidence !== undefined) result.confidence = confidence;
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The envelope an analyzer emits: findings plus the artifacts it examined.
|
|
74
|
+
*
|
|
75
|
+
* A downstream skill wraps this in its own output contract; on its own it is the
|
|
76
|
+
* deterministic, machine-readable result of one analysis.
|
|
77
|
+
*/
|
|
78
|
+
export function analyzerOutput({
|
|
79
|
+
analyzer,
|
|
80
|
+
findings = [],
|
|
81
|
+
artifacts = [],
|
|
82
|
+
warnings = [],
|
|
83
|
+
generatedAt = undefined,
|
|
84
|
+
}) {
|
|
85
|
+
return {
|
|
86
|
+
analyzer,
|
|
87
|
+
generatedAt: generatedAt ?? utcNow(),
|
|
88
|
+
findings,
|
|
89
|
+
artifacts,
|
|
90
|
+
warnings,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A discovered artifact, in the common model shared with the execution engine. */
|
|
95
|
+
export function artifact({
|
|
96
|
+
type,
|
|
97
|
+
location,
|
|
98
|
+
framework = 'unknown',
|
|
99
|
+
ownership = 'qa-analysis',
|
|
100
|
+
timestamp = undefined,
|
|
101
|
+
mediaType = '',
|
|
102
|
+
testRef = '',
|
|
103
|
+
present = true,
|
|
104
|
+
}) {
|
|
105
|
+
const entry = {
|
|
106
|
+
type,
|
|
107
|
+
location,
|
|
108
|
+
framework,
|
|
109
|
+
timestamp: timestamp ?? utcNow(),
|
|
110
|
+
ownership,
|
|
111
|
+
present,
|
|
112
|
+
};
|
|
113
|
+
if (mediaType) entry.mediaType = mediaType;
|
|
114
|
+
if (testRef) entry.testRef = testRef;
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// HAR (HTTP Archive) parser.
|
|
2
|
+
//
|
|
3
|
+
// Framework-agnostic: a HAR is a standard JSON format, whoever produced it.
|
|
4
|
+
// Extracts request outcomes, flags failures and slow calls, and redacts headers
|
|
5
|
+
// and credentialed URLs before anything is exposed. A malformed HAR raises.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
|
|
9
|
+
import { redactText, redactHeaders } from './redaction.mjs';
|
|
10
|
+
import { MalformedArtifact } from './junit.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse HAR JSON into a redacted network summary.
|
|
14
|
+
*
|
|
15
|
+
* `{entries, failures, slow, redacted: true}`, where each entry is
|
|
16
|
+
* `{method, url, status, durationMs, requestHeaders, responseHeaders}`. Failures
|
|
17
|
+
* are entries with status >= 400 or status 0 (no response at all).
|
|
18
|
+
*/
|
|
19
|
+
export function parseHarData(data, { slowMs = 1000, label = '<input>' } = {}) {
|
|
20
|
+
const rawEntries = data?.log?.entries;
|
|
21
|
+
if (!Array.isArray(rawEntries)) {
|
|
22
|
+
throw new MalformedArtifact(`not a HAR document at ${label}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const entries = rawEntries.map((item) => {
|
|
26
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
27
|
+
throw new MalformedArtifact(`HAR log.entries contains a non-object at ${label}`);
|
|
28
|
+
}
|
|
29
|
+
const request = item.request ?? {};
|
|
30
|
+
const response = item.response ?? {};
|
|
31
|
+
return {
|
|
32
|
+
method: request.method ?? '',
|
|
33
|
+
// Redaction strips any credentials embedded in the URL.
|
|
34
|
+
url: redactText(request.url ?? ''),
|
|
35
|
+
status: toInt(response.status),
|
|
36
|
+
durationMs: toMillis(item?.time, label),
|
|
37
|
+
requestHeaders: redactHeaders(request.headers ?? []),
|
|
38
|
+
responseHeaders: redactHeaders(response.headers ?? []),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
entries,
|
|
44
|
+
failures: entries.filter((e) => e.status === 0 || e.status >= 400),
|
|
45
|
+
slow: entries.filter((e) => e.durationMs >= slowMs),
|
|
46
|
+
redacted: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read and parse a HAR file from disk. */
|
|
51
|
+
export function parseHar(path, { slowMs = 1000 } = {}) {
|
|
52
|
+
let data;
|
|
53
|
+
try {
|
|
54
|
+
data = JSON.parse(fs.readFileSync(path, 'utf8'));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new MalformedArtifact(`could not parse HAR at ${path}: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
return parseHarData(data, { slowMs, label: path });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A status that is absent or unreadable is 0 — "no response", which is a failure. */
|
|
62
|
+
function toInt(value) {
|
|
63
|
+
const number = Number(value);
|
|
64
|
+
return Number.isFinite(number) ? Math.trunc(number) : 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* HAR `time` is milliseconds already, rounded to a whole number.
|
|
69
|
+
*
|
|
70
|
+
* A value that is present but not a number raises rather than becoming 0: the
|
|
71
|
+
* same rule as the JUnit parser, and for the same reason — a fabricated duration
|
|
72
|
+
* makes a malformed document look like a clean measurement.
|
|
73
|
+
*/
|
|
74
|
+
function toMillis(raw, label) {
|
|
75
|
+
if (raw === undefined || raw === null || raw === '') return 0;
|
|
76
|
+
const number = Number(raw);
|
|
77
|
+
if (!Number.isFinite(number)) {
|
|
78
|
+
throw new MalformedArtifact(`entry time='${raw}' is not a number at ${label}`);
|
|
79
|
+
}
|
|
80
|
+
return roundHalfToEven(number);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Python's rounding rule, so a `.5` boundary lands on the same integer. */
|
|
84
|
+
function roundHalfToEven(value) {
|
|
85
|
+
const floor = Math.floor(value);
|
|
86
|
+
const remainder = value - floor;
|
|
87
|
+
if (remainder > 0.5) return floor + 1;
|
|
88
|
+
if (remainder < 0.5) return floor;
|
|
89
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
90
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// JUnit XML parser.
|
|
2
|
+
//
|
|
3
|
+
// Framework-agnostic: Playwright, Selenium, Cypress, WebdriverIO, and most unit
|
|
4
|
+
// runners emit JUnit XML, so this one parser normalizes them all into the pack's
|
|
5
|
+
// per-test result shape. This is the concrete proof that different frameworks
|
|
6
|
+
// share one contract — only where the file lives differs, not how it is read.
|
|
7
|
+
//
|
|
8
|
+
// Parses deterministically; a malformed document raises rather than guessing.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
|
|
12
|
+
import { parseXml, find, findAll, XmlError } from './xml.mjs';
|
|
13
|
+
import { redactText } from './redaction.mjs';
|
|
14
|
+
|
|
15
|
+
/** Raised when an artifact cannot be parsed. Never swallowed into a guess. */
|
|
16
|
+
export class MalformedArtifact extends Error {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse JUnit XML text into a normalized result.
|
|
20
|
+
*
|
|
21
|
+
* `{tests: {total, passed, failed, skipped}, executed: [...]}`, where each
|
|
22
|
+
* executed entry is `{title, file, status, durationMs, message?}`.
|
|
23
|
+
*/
|
|
24
|
+
export function parseJUnitText(source, label = '<input>') {
|
|
25
|
+
let root;
|
|
26
|
+
try {
|
|
27
|
+
root = parseXml(source);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof XmlError) {
|
|
30
|
+
throw new MalformedArtifact(`could not parse JUnit XML at ${label}: ${error.message}`);
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Accept either a <testsuites> root or a single <testsuite> root.
|
|
36
|
+
let suites;
|
|
37
|
+
if (root.tag === 'testsuites') suites = findAll(root, 'testsuite');
|
|
38
|
+
else if (root.tag === 'testsuite') suites = [root];
|
|
39
|
+
else throw new MalformedArtifact(`not a JUnit document (root <${root.tag}>) at ${label}`);
|
|
40
|
+
|
|
41
|
+
const executed = [];
|
|
42
|
+
for (const suite of suites) {
|
|
43
|
+
for (const testcase of findAll(suite, 'testcase')) {
|
|
44
|
+
const failure = find(testcase, 'failure');
|
|
45
|
+
const error = find(testcase, 'error');
|
|
46
|
+
const skipped = find(testcase, 'skipped');
|
|
47
|
+
|
|
48
|
+
let status;
|
|
49
|
+
let message = '';
|
|
50
|
+
if (failure !== null || error !== null) {
|
|
51
|
+
status = 'failed';
|
|
52
|
+
const node = failure !== null ? failure : error;
|
|
53
|
+
message = redactText((node.attrs.message || node.text || '').trim());
|
|
54
|
+
} else if (skipped !== null) {
|
|
55
|
+
status = 'skipped';
|
|
56
|
+
message = redactText((skipped.attrs.message || '').trim());
|
|
57
|
+
} else {
|
|
58
|
+
status = 'passed';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const entry = {
|
|
62
|
+
title: testcase.attrs.name ?? '',
|
|
63
|
+
file: testcase.attrs.classname ?? '',
|
|
64
|
+
status,
|
|
65
|
+
durationMs: durationMs(testcase.attrs.time, label),
|
|
66
|
+
};
|
|
67
|
+
if (message) entry.message = message;
|
|
68
|
+
executed.push(entry);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
tests: {
|
|
74
|
+
total: executed.length,
|
|
75
|
+
passed: executed.filter((e) => e.status === 'passed').length,
|
|
76
|
+
failed: executed.filter((e) => e.status === 'failed').length,
|
|
77
|
+
skipped: executed.filter((e) => e.status === 'skipped').length,
|
|
78
|
+
},
|
|
79
|
+
executed,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `time` in seconds becomes whole milliseconds.
|
|
85
|
+
*
|
|
86
|
+
* Two subtleties, both pinned by the parity corpus:
|
|
87
|
+
*
|
|
88
|
+
* 1. An absent or empty attribute is zero; a value that is present but not a
|
|
89
|
+
* finite number raises. Guessing zero there would turn a malformed document
|
|
90
|
+
* into a plausible result, which is the one thing this parser must not do.
|
|
91
|
+
* 2. Python's `round()` rounds half to *even* and JavaScript's `Math.round`
|
|
92
|
+
* rounds half *up*, so `time="0.0005"` gives 0 in Python and 1 here unless
|
|
93
|
+
* the tie is broken the same way. Runners do emit such values.
|
|
94
|
+
*/
|
|
95
|
+
function durationMs(raw, label) {
|
|
96
|
+
if (raw === undefined || raw === null || raw === '') return 0;
|
|
97
|
+
// Reject what parseFloat would happily truncate ("1.2.3", "5s", "0x10").
|
|
98
|
+
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(String(raw).trim())) {
|
|
99
|
+
throw new MalformedArtifact(`testcase time='${raw}' is not a number at ${label}`);
|
|
100
|
+
}
|
|
101
|
+
const seconds = Number(raw);
|
|
102
|
+
if (!Number.isFinite(seconds)) {
|
|
103
|
+
throw new MalformedArtifact(`testcase time='${raw}' is not a finite number at ${label}`);
|
|
104
|
+
}
|
|
105
|
+
return roundHalfToEven(seconds * 1000);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Python's rounding rule, so a `.5` boundary lands on the same integer. */
|
|
109
|
+
function roundHalfToEven(value) {
|
|
110
|
+
const floor = Math.floor(value);
|
|
111
|
+
const remainder = value - floor;
|
|
112
|
+
if (remainder > 0.5) return floor + 1;
|
|
113
|
+
if (remainder < 0.5) return floor;
|
|
114
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Read and parse a JUnit XML file from disk. */
|
|
118
|
+
export function parseJUnit(path) {
|
|
119
|
+
let source;
|
|
120
|
+
try {
|
|
121
|
+
source = fs.readFileSync(path, 'utf8');
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new MalformedArtifact(`could not parse JUnit XML at ${path}: ${error.message}`);
|
|
124
|
+
}
|
|
125
|
+
return parseJUnitText(source, path);
|
|
126
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Credential, secret, token, and PII redaction.
|
|
2
|
+
//
|
|
3
|
+
// Every analyzer runs artifact text through redaction before it appears in a
|
|
4
|
+
// finding, a report, or stdout. Redaction happens as evidence is captured, not
|
|
5
|
+
// after — a secret must never reach a model's context or a log.
|
|
6
|
+
//
|
|
7
|
+
// Ported from qa_analysis/redaction.py, rule for rule and in the same order, and
|
|
8
|
+
// held to identical output by scripts/check-engine-parity.mjs. Python's inline
|
|
9
|
+
// `(?i)` / `(?im)` flags become JavaScript flag letters; every rule carries `g`
|
|
10
|
+
// because Python's `re.sub` replaces all occurrences.
|
|
11
|
+
|
|
12
|
+
export class RedactionError extends Error {}
|
|
13
|
+
|
|
14
|
+
// Ordered. Order matters: match structured, high-signal secrets (JWTs, provider
|
|
15
|
+
// keys) before generic fallbacks.
|
|
16
|
+
const RULES = [
|
|
17
|
+
{ name: 'jwt', pattern: /eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g },
|
|
18
|
+
{ name: 'aws-access-key', pattern: /AKIA[0-9A-Z]{16}/g },
|
|
19
|
+
{ name: 'github-token', pattern: /gh[pousr]_[A-Za-z0-9]{20,}/g },
|
|
20
|
+
{ name: 'slack-token', pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
21
|
+
{ name: 'openai-key', pattern: /sk-[A-Za-z0-9]{20,}/g },
|
|
22
|
+
{ name: 'bearer', pattern: /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi },
|
|
23
|
+
// Sensitive header lines: keep the header name, mask the value.
|
|
24
|
+
//
|
|
25
|
+
// The value is `[^\r\n]+` rather than `.+$`, and the indent is `[ \t]*` rather
|
|
26
|
+
// than `\s*`, because `$` and `\s` do not mean the same thing in Python and
|
|
27
|
+
// JavaScript on CRLF text: Python's `.+$` swallowed the carriage return and
|
|
28
|
+
// rewrote the line ending to LF. Anchoring on a negated class removes the
|
|
29
|
+
// difference and stops the redactor corrupting a document it only meant to mask.
|
|
30
|
+
{
|
|
31
|
+
name: 'auth-header',
|
|
32
|
+
pattern: /^([ \t]*(?:authorization|proxy-authorization)[ \t]*[:=][ \t]*)[^\r\n]+/gim,
|
|
33
|
+
replacement: '$1[REDACTED:auth-header]',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'cookie-header',
|
|
37
|
+
pattern: /^([ \t]*(?:set-cookie|cookie)[ \t]*[:=][ \t]*)[^\r\n]+/gim,
|
|
38
|
+
replacement: '$1[REDACTED:cookie-header]',
|
|
39
|
+
},
|
|
40
|
+
// Secret-like assignments: key=value / "key": "value".
|
|
41
|
+
{
|
|
42
|
+
name: 'assigned-secret',
|
|
43
|
+
pattern: /(\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*["']?)([^\s"'&]{4,})/gi,
|
|
44
|
+
replacement: '$1[REDACTED:secret]',
|
|
45
|
+
},
|
|
46
|
+
// Credentials in URLs and query strings.
|
|
47
|
+
{
|
|
48
|
+
name: 'url-credential',
|
|
49
|
+
pattern: /(:\/\/[^:/@\s]+:)([^@/\s]+)(@)/gi,
|
|
50
|
+
replacement: '$1[REDACTED:credential]$3',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'query-secret',
|
|
54
|
+
pattern: /([?&](?:password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)=)([^&\s#]+)/gi,
|
|
55
|
+
replacement: '$1[REDACTED:secret]',
|
|
56
|
+
},
|
|
57
|
+
// PII: email addresses.
|
|
58
|
+
{ name: 'email', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const SENSITIVE_HEADERS = new Set([
|
|
62
|
+
'authorization', 'proxy-authorization', 'cookie', 'set-cookie',
|
|
63
|
+
'x-api-key', 'x-auth-token', 'api-key', 'x-csrf-token',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** Return text with every recognized secret or PII value masked. */
|
|
67
|
+
export function redactText(text) {
|
|
68
|
+
if (!text) return text;
|
|
69
|
+
let result = String(text);
|
|
70
|
+
for (const rule of RULES) {
|
|
71
|
+
// A fresh regex per call: `g` regexes carry lastIndex, and a module-level
|
|
72
|
+
// pattern reused across calls would skip matches unpredictably.
|
|
73
|
+
const pattern = new RegExp(rule.pattern.source, rule.pattern.flags);
|
|
74
|
+
result = result.replace(pattern, rule.replacement ?? `[REDACTED:${rule.name}]`);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Return `[{type, start, end}]` for secrets found — never the values.
|
|
81
|
+
*
|
|
82
|
+
* Used to decide whether an artifact is safe to expose without surfacing the
|
|
83
|
+
* secret itself.
|
|
84
|
+
*/
|
|
85
|
+
export function detectSecrets(text) {
|
|
86
|
+
const findings = [];
|
|
87
|
+
if (!text) return findings;
|
|
88
|
+
const source = String(text);
|
|
89
|
+
for (const rule of RULES) {
|
|
90
|
+
const pattern = new RegExp(rule.pattern.source, rule.pattern.flags);
|
|
91
|
+
for (const match of source.matchAll(pattern)) {
|
|
92
|
+
findings.push({ type: rule.name, start: match.index, end: match.index + match[0].length });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Stable sort by position, so two secrets at the same offset keep rule order.
|
|
96
|
+
findings.sort((a, b) => a.start - b.start);
|
|
97
|
+
return findings;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Mask the values of sensitive HTTP headers.
|
|
102
|
+
*
|
|
103
|
+
* Accepts an object, or an array of `{name, value}` entries (the HAR shape), and
|
|
104
|
+
* returns the same shape with sensitive values replaced.
|
|
105
|
+
*/
|
|
106
|
+
export function redactHeaders(headers) {
|
|
107
|
+
const mask = (name, value) =>
|
|
108
|
+
SENSITIVE_HEADERS.has(String(name).trim().toLowerCase())
|
|
109
|
+
? '[REDACTED:header]'
|
|
110
|
+
: redactText(value);
|
|
111
|
+
|
|
112
|
+
if (Array.isArray(headers)) {
|
|
113
|
+
return headers.map((entry) =>
|
|
114
|
+
entry && typeof entry === 'object' && 'name' in entry
|
|
115
|
+
? { ...entry, value: mask(String(entry.name), String(entry.value ?? '')) }
|
|
116
|
+
: entry,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (headers && typeof headers === 'object') {
|
|
120
|
+
return Object.fromEntries(
|
|
121
|
+
Object.entries(headers).map(([key, value]) => [key, mask(key, String(value))]),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return headers;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const RULE_NAMES = RULES.map((rule) => rule.name);
|