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,109 @@
|
|
|
1
|
+
// The canonical QA failure taxonomy and its deterministic classifier.
|
|
2
|
+
//
|
|
3
|
+
// Classification maps observed signals to one of a closed set of failure classes.
|
|
4
|
+
// It is rule-based and conservative: when no rule matches with sufficient signal,
|
|
5
|
+
// the result is UNKNOWN. Unknown is preferable to incorrect.
|
|
6
|
+
//
|
|
7
|
+
// Ported from qa_analysis/taxonomy.py with the rule order preserved exactly —
|
|
8
|
+
// order *is* the logic here (see the note on the first two rules) — and held to
|
|
9
|
+
// identical output by scripts/check-engine-parity.mjs.
|
|
10
|
+
|
|
11
|
+
export const ASSERTION = 'assertion-failure';
|
|
12
|
+
export const LOCATOR = 'locator-failure';
|
|
13
|
+
export const TIMEOUT = 'timeout';
|
|
14
|
+
export const NETWORK = 'network';
|
|
15
|
+
export const AUTH = 'authentication';
|
|
16
|
+
export const AUTHORIZATION = 'authorization';
|
|
17
|
+
export const ENVIRONMENT = 'environment';
|
|
18
|
+
export const CONFIGURATION = 'configuration';
|
|
19
|
+
export const INFRASTRUCTURE = 'infrastructure';
|
|
20
|
+
export const TEST_DATA = 'test-data';
|
|
21
|
+
export const APPLICATION_BUG = 'application-bug';
|
|
22
|
+
export const FRAMEWORK = 'framework-failure';
|
|
23
|
+
export const FLAKY = 'flaky';
|
|
24
|
+
export const UNKNOWN = 'unknown';
|
|
25
|
+
|
|
26
|
+
export const CLASSES = new Set([
|
|
27
|
+
ASSERTION, LOCATOR, TIMEOUT, NETWORK, AUTH, AUTHORIZATION, ENVIRONMENT,
|
|
28
|
+
CONFIGURATION, INFRASTRUCTURE, TEST_DATA, APPLICATION_BUG, FRAMEWORK,
|
|
29
|
+
FLAKY, UNKNOWN,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// Ordered rules: [classification, pattern, confidence, reason].
|
|
33
|
+
//
|
|
34
|
+
// Order encodes priority, and the first two rules exist because modern runners
|
|
35
|
+
// print a timeout budget in *every* assertion failure. Playwright's message for a
|
|
36
|
+
// plain text mismatch ends with "Timeout: 5000ms" even though nothing timed out,
|
|
37
|
+
// so a naive timeout rule captures assertion and locator failures alike and sends
|
|
38
|
+
// the reader to raise a timeout — the one action guaranteed not to help. The
|
|
39
|
+
// patterns below are derived from real captured runner output.
|
|
40
|
+
//
|
|
41
|
+
// The discriminators, from that output:
|
|
42
|
+
// element missing -> "Error: element(s) not found" (no Received: value)
|
|
43
|
+
// value mismatch -> "Expected: X" + "Received: Y" (element was resolved)
|
|
44
|
+
// real timeout -> a timeout with neither of the above
|
|
45
|
+
const RULES = [
|
|
46
|
+
[LOCATOR, /(no such element|element(?:\(s\))?\s+not\s+(?:found|visible|attached)|locator.*(?:resolved to 0|not found)|waiting for (?:locator|selector)|unable to locate element)/i, 0.8,
|
|
47
|
+
'Error indicates the target element could not be found or resolved.'],
|
|
48
|
+
// A concrete expected-vs-received comparison is an assertion result, not a
|
|
49
|
+
// time budget: the runner resolved the target and compared values.
|
|
50
|
+
[ASSERTION, /expected:.*received:/is, 0.8,
|
|
51
|
+
'Error shows a concrete expected-versus-received comparison, so the assertion did not hold.'],
|
|
52
|
+
[TIMEOUT, /(timeout|timed out|exceeded .*ms|deadline exceeded)/i, 0.75,
|
|
53
|
+
'Error indicates an operation exceeded its time budget.'],
|
|
54
|
+
[AUTH, /(401 unauthorized|authentication failed|invalid (?:credentials|token)|login failed|not authenticated)/i, 0.85,
|
|
55
|
+
'Error indicates an authentication failure — the identity could not be established.'],
|
|
56
|
+
[AUTHORIZATION, /(403 forbidden|permission denied|access denied|not authorized|forbidden\b|insufficient (?:permission|privilege))/i, 0.85,
|
|
57
|
+
'Error indicates an authorization failure — the identity lacked permission.'],
|
|
58
|
+
[NETWORK, /(ECONNREFUSED| ENOTFOUND|net::ERR|connection (?:refused|reset)|5\d\d (?:internal server error|bad gateway|service unavailable)|fetch failed)/i, 0.8,
|
|
59
|
+
'Error indicates a network or upstream service failure.'],
|
|
60
|
+
[ASSERTION, /(assertion|expect(?:ed)?\b|toBe|toEqual|toHaveText|toBeVisible|AssertionError|expected .* (?:to|but))/i, 0.7,
|
|
61
|
+
'Error indicates an assertion did not hold.'],
|
|
62
|
+
[TEST_DATA, /(duplicate key|constraint violation|no rows|seed data|fixture .*(?:missing|not found)|invalid test data)/i, 0.7,
|
|
63
|
+
'Error indicates a test-data problem.'],
|
|
64
|
+
[CONFIGURATION, /(cannot find module|config(?:uration)? (?:error|not found)|missing (?:config|environment variable)|unknown option)/i, 0.7,
|
|
65
|
+
'Error indicates a configuration problem.'],
|
|
66
|
+
[ENVIRONMENT, /(base ?url|env(?:ironment)? .*(?:not set|missing)|ECONNREFUSED .*localhost|dev server)/i, 0.6,
|
|
67
|
+
'Error indicates an environment problem such as a missing base URL or unreachable local service.'],
|
|
68
|
+
[INFRASTRUCTURE, /(out of memory|OOM|disk (?:full|space)|browser .*(?:crash|closed unexpectedly)|worker .*(?:died|crashed))/i, 0.7,
|
|
69
|
+
'Error indicates an infrastructure problem such as a crash or resource exhaustion.'],
|
|
70
|
+
[FRAMEWORK, /(internal error|unexpected error in (?:playwright|selenium|cypress|webdriver)|driver .*mismatch)/i, 0.6,
|
|
71
|
+
'Error indicates a fault in the test framework or driver itself.'],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Classify a failure from its error message and optional HTTP status.
|
|
76
|
+
*
|
|
77
|
+
* Returns `{classification, confidence, reason}`. Never guesses: an unrecognized
|
|
78
|
+
* signal yields UNKNOWN at low confidence with an honest reason.
|
|
79
|
+
*/
|
|
80
|
+
export function classify(message, httpStatus = null) {
|
|
81
|
+
const text = message || '';
|
|
82
|
+
|
|
83
|
+
// A concrete HTTP status is stronger evidence than message text.
|
|
84
|
+
if (httpStatus !== null && httpStatus !== undefined) {
|
|
85
|
+
if (httpStatus === 401) {
|
|
86
|
+
return { classification: AUTH, confidence: 0.9, reason: 'HTTP 401 indicates an authentication failure.' };
|
|
87
|
+
}
|
|
88
|
+
if (httpStatus === 403) {
|
|
89
|
+
return { classification: AUTHORIZATION, confidence: 0.9, reason: 'HTTP 403 indicates an authorization failure.' };
|
|
90
|
+
}
|
|
91
|
+
if (httpStatus >= 500 && httpStatus <= 599) {
|
|
92
|
+
return {
|
|
93
|
+
classification: NETWORK,
|
|
94
|
+
confidence: 0.85,
|
|
95
|
+
reason: `HTTP ${httpStatus} indicates an upstream server failure.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const [classification, pattern, confidence, reason] of RULES) {
|
|
101
|
+
if (pattern.test(text)) return { classification, confidence, reason };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
classification: UNKNOWN,
|
|
106
|
+
confidence: 0.2,
|
|
107
|
+
reason: 'No classification rule matched the available signals; manual review needed.',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// A dependency-free XML reader for the subset the pack's artifacts use.
|
|
2
|
+
//
|
|
3
|
+
// Node has no built-in XML parser, and taking one as a dependency is ruled out:
|
|
4
|
+
// this code is bundled into skills that run in other people's repositories, where
|
|
5
|
+
// every dependency is attack surface and an install burden (ADR-0009). So the
|
|
6
|
+
// subset we actually need is parsed here — element tags, attributes, text,
|
|
7
|
+
// nesting, comments, CDATA, and the five predefined entities.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately NOT supported, because no artifact the pack reads uses them:
|
|
10
|
+
// namespaces (a prefixed tag is treated as an opaque name), DTD entity
|
|
11
|
+
// definitions, and processing instructions beyond being skipped. Anything
|
|
12
|
+
// unsupported raises — the parser never guesses its way past a document it does
|
|
13
|
+
// not understand, because a fabricated parse becomes a fabricated test result.
|
|
14
|
+
|
|
15
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
16
|
+
|
|
17
|
+
export class XmlError extends Error {}
|
|
18
|
+
|
|
19
|
+
function decode(text) {
|
|
20
|
+
if (!text.includes('&')) return text;
|
|
21
|
+
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
|
|
22
|
+
if (body[0] === '#') {
|
|
23
|
+
const code = body[1] === 'x' || body[1] === 'X'
|
|
24
|
+
? parseInt(body.slice(2), 16)
|
|
25
|
+
: parseInt(body.slice(1), 10);
|
|
26
|
+
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff
|
|
27
|
+
? String.fromCodePoint(code)
|
|
28
|
+
: whole;
|
|
29
|
+
}
|
|
30
|
+
return Object.prototype.hasOwnProperty.call(ENTITIES, body) ? ENTITIES[body] : whole;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse an XML document into a node tree.
|
|
36
|
+
*
|
|
37
|
+
* A node is `{ tag, attrs, children, text }`. Text is the concatenation of the
|
|
38
|
+
* element's own character data, matching what `xml.etree` exposes closely enough
|
|
39
|
+
* for the artifacts the pack reads (which never mix text and children
|
|
40
|
+
* meaningfully).
|
|
41
|
+
*
|
|
42
|
+
* @param {string} source
|
|
43
|
+
* @returns {{tag: string, attrs: Record<string,string>, children: object[], text: string}}
|
|
44
|
+
*/
|
|
45
|
+
export function parseXml(source) {
|
|
46
|
+
if (typeof source !== 'string') throw new XmlError('XML source must be a string');
|
|
47
|
+
|
|
48
|
+
let cursor = 0;
|
|
49
|
+
const root = { tag: null, attrs: {}, children: [], text: '' };
|
|
50
|
+
const stack = [root];
|
|
51
|
+
const top = () => stack[stack.length - 1];
|
|
52
|
+
|
|
53
|
+
// Reported against the position of the offending markup, not the cursor: when a
|
|
54
|
+
// mismatched close tag is found, the cursor still sits at the end of the last
|
|
55
|
+
// *good* tag, so using it points a reader at the wrong line of a large report.
|
|
56
|
+
const fail = (why, at = cursor) => {
|
|
57
|
+
const line = source.slice(0, at).split('\n').length;
|
|
58
|
+
throw new XmlError(`${why} (line ${line})`);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
while (cursor < source.length) {
|
|
62
|
+
const open = source.indexOf('<', cursor);
|
|
63
|
+
if (open === -1) {
|
|
64
|
+
top().text += decode(source.slice(cursor));
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
if (open > cursor) top().text += decode(source.slice(cursor, open));
|
|
68
|
+
|
|
69
|
+
if (source.startsWith('<?', open)) {
|
|
70
|
+
const end = source.indexOf('?>', open);
|
|
71
|
+
if (end === -1) fail('unterminated processing instruction', open);
|
|
72
|
+
cursor = end + 2;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (source.startsWith('<!--', open)) {
|
|
76
|
+
const end = source.indexOf('-->', open);
|
|
77
|
+
if (end === -1) fail('unterminated comment', open);
|
|
78
|
+
cursor = end + 3;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (source.startsWith('<![CDATA[', open)) {
|
|
82
|
+
const end = source.indexOf(']]>', open);
|
|
83
|
+
if (end === -1) fail('unterminated CDATA section', open);
|
|
84
|
+
// CDATA is literal: no entity decoding.
|
|
85
|
+
top().text += source.slice(open + 9, end);
|
|
86
|
+
cursor = end + 3;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (source.startsWith('<!', open)) {
|
|
90
|
+
const end = source.indexOf('>', open);
|
|
91
|
+
if (end === -1) fail('unterminated declaration', open);
|
|
92
|
+
cursor = end + 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const close = source.indexOf('>', open);
|
|
97
|
+
if (close === -1) fail('unterminated tag', open);
|
|
98
|
+
const raw = source.slice(open + 1, close);
|
|
99
|
+
if (raw.length === 0) fail('empty tag', open);
|
|
100
|
+
|
|
101
|
+
if (raw[0] === '/') {
|
|
102
|
+
const name = raw.slice(1).trim();
|
|
103
|
+
if (stack.length === 1) fail(`closing tag </${name}> with no open element`, open);
|
|
104
|
+
const node = stack.pop();
|
|
105
|
+
if (node.tag !== name) fail(`expected </${node.tag}> but found </${name}>`, open);
|
|
106
|
+
cursor = close + 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const selfClosing = raw.endsWith('/');
|
|
111
|
+
const body = selfClosing ? raw.slice(0, -1) : raw;
|
|
112
|
+
const firstSpace = body.search(/\s/);
|
|
113
|
+
const tag = (firstSpace === -1 ? body : body.slice(0, firstSpace)).trim();
|
|
114
|
+
if (!tag) fail('empty tag name', open);
|
|
115
|
+
|
|
116
|
+
const attrs = {};
|
|
117
|
+
if (firstSpace !== -1) {
|
|
118
|
+
const attrSource = body.slice(firstSpace);
|
|
119
|
+
// Quoted values only. An unquoted attribute value is not valid XML, and
|
|
120
|
+
// accepting one would mean guessing where it ends.
|
|
121
|
+
const pattern = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
122
|
+
let match;
|
|
123
|
+
let consumed = 0;
|
|
124
|
+
while ((match = pattern.exec(attrSource)) !== null) {
|
|
125
|
+
attrs[match[1]] = decode(match[3] ?? match[4] ?? '');
|
|
126
|
+
consumed = match.index + match[0].length;
|
|
127
|
+
}
|
|
128
|
+
if (attrSource.slice(consumed).trim().length > 0) {
|
|
129
|
+
fail(`unparsable attributes in <${tag}>: ${attrSource.slice(consumed).trim()}`, open);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const node = { tag, attrs, children: [], text: '' };
|
|
134
|
+
top().children.push(node);
|
|
135
|
+
if (!selfClosing) stack.push(node);
|
|
136
|
+
cursor = close + 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (stack.length !== 1) fail(`unclosed element <${top().tag}>`);
|
|
140
|
+
if (root.children.length === 0) throw new XmlError('document has no root element');
|
|
141
|
+
if (root.children.length > 1) throw new XmlError('document must have exactly one root element');
|
|
142
|
+
return root.children[0];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** First child element with this tag, or null — `Element.find` in xml.etree. */
|
|
146
|
+
export function find(node, tag) {
|
|
147
|
+
return node.children.find((child) => child.tag === tag) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Every child element with this tag — `Element.findall` in xml.etree. */
|
|
151
|
+
export function findAll(node, tag) {
|
|
152
|
+
return node.children.filter((child) => child.tag === tag);
|
|
153
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// A dependency-free ZIP reader for the entries the pack needs to read.
|
|
2
|
+
//
|
|
3
|
+
// Node has no archive API, and a dependency is ruled out for code that runs in
|
|
4
|
+
// other people's repositories (ADR-0009). Playwright writes its trace as a zip of
|
|
5
|
+
// newline-delimited JSON, so the central directory is walked here and each entry
|
|
6
|
+
// inflated with the built-in `zlib`.
|
|
7
|
+
//
|
|
8
|
+
// Supported: stored (method 0) and deflated (method 8) entries — the only two a
|
|
9
|
+
// zip writer produces in practice, and the only two Playwright emits. Anything
|
|
10
|
+
// else raises by name rather than returning empty bytes, because a silently empty
|
|
11
|
+
// trace reads as "nothing happened" instead of "could not be read".
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import zlib from 'node:zlib';
|
|
15
|
+
|
|
16
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
17
|
+
const CENTRAL_SIGNATURE = 0x02014b50;
|
|
18
|
+
const MAX_COMMENT = 0xffff;
|
|
19
|
+
|
|
20
|
+
export class ZipError extends Error {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every entry in the archive: `[{name, method, compressedSize, size, offset}]`.
|
|
24
|
+
*
|
|
25
|
+
* Read from the central directory, which is authoritative — the per-entry local
|
|
26
|
+
* headers may carry zeroed sizes when the writer streamed the file.
|
|
27
|
+
*/
|
|
28
|
+
export function listEntries(buffer) {
|
|
29
|
+
const eocd = findEndOfCentralDirectory(buffer);
|
|
30
|
+
const count = buffer.readUInt16LE(eocd + 10);
|
|
31
|
+
let cursor = buffer.readUInt32LE(eocd + 16);
|
|
32
|
+
const entries = [];
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < count; index += 1) {
|
|
35
|
+
if (cursor + 46 > buffer.length || buffer.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) {
|
|
36
|
+
throw new ZipError(`central directory entry ${index} is malformed`);
|
|
37
|
+
}
|
|
38
|
+
const method = buffer.readUInt16LE(cursor + 10);
|
|
39
|
+
const compressedSize = buffer.readUInt32LE(cursor + 20);
|
|
40
|
+
const size = buffer.readUInt32LE(cursor + 24);
|
|
41
|
+
const nameLength = buffer.readUInt16LE(cursor + 28);
|
|
42
|
+
const extraLength = buffer.readUInt16LE(cursor + 30);
|
|
43
|
+
const commentLength = buffer.readUInt16LE(cursor + 32);
|
|
44
|
+
const offset = buffer.readUInt32LE(cursor + 42);
|
|
45
|
+
const name = buffer.subarray(cursor + 46, cursor + 46 + nameLength).toString('utf8');
|
|
46
|
+
entries.push({ name, method, compressedSize, size, offset });
|
|
47
|
+
cursor += 46 + nameLength + extraLength + commentLength;
|
|
48
|
+
}
|
|
49
|
+
return entries;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The bytes of one entry, inflated if it was deflated. */
|
|
53
|
+
export function readEntry(buffer, entry) {
|
|
54
|
+
if (entry.offset + 30 > buffer.length) {
|
|
55
|
+
throw new ZipError(`entry ${entry.name} points past the end of the archive`);
|
|
56
|
+
}
|
|
57
|
+
const nameLength = buffer.readUInt16LE(entry.offset + 26);
|
|
58
|
+
const extraLength = buffer.readUInt16LE(entry.offset + 28);
|
|
59
|
+
const start = entry.offset + 30 + nameLength + extraLength;
|
|
60
|
+
const raw = buffer.subarray(start, start + entry.compressedSize);
|
|
61
|
+
|
|
62
|
+
if (entry.method === 0) return raw;
|
|
63
|
+
if (entry.method === 8) {
|
|
64
|
+
try {
|
|
65
|
+
return zlib.inflateRawSync(raw);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new ZipError(`entry ${entry.name} could not be inflated: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw new ZipError(`entry ${entry.name} uses unsupported compression method ${entry.method}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Is this a readable ZIP archive? Locates the end-of-central-directory record. */
|
|
74
|
+
export function isZip(buffer) {
|
|
75
|
+
try {
|
|
76
|
+
findEndOfCentralDirectory(buffer);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Open a zip file and hand back `{entries, read}`. */
|
|
84
|
+
export function openZip(path) {
|
|
85
|
+
let buffer;
|
|
86
|
+
try {
|
|
87
|
+
buffer = fs.readFileSync(path);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new ZipError(`could not read ${path}: ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
const entries = listEntries(buffer);
|
|
92
|
+
return { entries, read: (entry) => readEntry(buffer, entry) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The record is last in the file, possibly followed by a comment, so the tail is
|
|
97
|
+
* scanned backwards. Trusting the first four bytes instead would accept a
|
|
98
|
+
* truncated archive whose header still reads `PK\x03\x04`.
|
|
99
|
+
*/
|
|
100
|
+
function findEndOfCentralDirectory(buffer) {
|
|
101
|
+
if (buffer.length < 22) throw new ZipError('too short to be a zip archive');
|
|
102
|
+
const earliest = Math.max(0, buffer.length - 22 - MAX_COMMENT);
|
|
103
|
+
for (let cursor = buffer.length - 22; cursor >= earliest; cursor -= 1) {
|
|
104
|
+
if (buffer.readUInt32LE(cursor) === EOCD_SIGNATURE) return cursor;
|
|
105
|
+
}
|
|
106
|
+
throw new ZipError('no end-of-central-directory record: not a zip archive');
|
|
107
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The diagnostic engine — the one place failure reasoning happens.
|
|
2
|
+
//
|
|
3
|
+
// Orchestrates the analysis platform and the diagnostics modules into a single
|
|
4
|
+
// diagnosis: per-failure root cause, prioritization, a reconstructed timeline, and
|
|
5
|
+
// ranked recommendations. qa-debug presents the diagnosis; qa-fix turns it into
|
|
6
|
+
// repair plans; qa-report aggregates diagnoses. None of them re-implements this.
|
|
7
|
+
|
|
8
|
+
import * as taxonomy from '../analysis/taxonomy.mjs';
|
|
9
|
+
|
|
10
|
+
import { analyze } from './root-cause.mjs';
|
|
11
|
+
import { prioritize } from './prioritization.mjs';
|
|
12
|
+
import { planRepair } from './repair.mjs';
|
|
13
|
+
import { buildTimeline } from './timeline.mjs';
|
|
14
|
+
import { validateDiagnosis } from './internal-contracts.mjs';
|
|
15
|
+
|
|
16
|
+
const PRIORITY_RANK = { P1: 3, P2: 2, P3: 1 };
|
|
17
|
+
|
|
18
|
+
// Classifications that block a release when they are the cause of a failure.
|
|
19
|
+
const RELEASE_BLOCKING = new Set([
|
|
20
|
+
taxonomy.APPLICATION_BUG, taxonomy.NETWORK, taxonomy.INFRASTRUCTURE,
|
|
21
|
+
taxonomy.AUTH, taxonomy.AUTHORIZATION,
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Produce a full diagnosis from the available results.
|
|
26
|
+
*
|
|
27
|
+
* `{entries, timeline, recommendations}`, where each entry combines a root cause
|
|
28
|
+
* with its prioritization and affected tests.
|
|
29
|
+
*/
|
|
30
|
+
export function diagnose(executionResult, { analysisResult = null } = {}) {
|
|
31
|
+
const signals = deriveSignals(executionResult, analysisResult);
|
|
32
|
+
const findings = (analysisResult ?? {}).findings ?? [];
|
|
33
|
+
|
|
34
|
+
const entries = signals.map((signal) => {
|
|
35
|
+
const rootCause = analyze(signal);
|
|
36
|
+
const blocking = RELEASE_BLOCKING.has(rootCause.classification);
|
|
37
|
+
return {
|
|
38
|
+
rootCause,
|
|
39
|
+
priority: prioritize(rootCause, { blocking }),
|
|
40
|
+
affectedTests: signal.affectedTests ?? [],
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Highest priority first, then highest confidence. Python's `sort(reverse=True)`
|
|
45
|
+
// on a tuple key is a stable descending sort, so equal keys keep insertion order
|
|
46
|
+
// — reproduced by comparing in reverse and falling back to the original index.
|
|
47
|
+
const decorated = entries.map((entry, index) => ({ entry, index }));
|
|
48
|
+
decorated.sort((a, b) => {
|
|
49
|
+
const rank = (PRIORITY_RANK[b.entry.priority.priority] ?? 0) - (PRIORITY_RANK[a.entry.priority.priority] ?? 0);
|
|
50
|
+
if (rank !== 0) return rank;
|
|
51
|
+
const confidence = b.entry.rootCause.confidence - a.entry.rootCause.confidence;
|
|
52
|
+
if (confidence !== 0) return confidence;
|
|
53
|
+
// Insertion order for ties. Python's `sort(reverse=True)` is stable, so equal
|
|
54
|
+
// keys keep the order they arrived in — reversing the tie-break too would put
|
|
55
|
+
// two equally-ranked locator failures back to front.
|
|
56
|
+
return a.index - b.index;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const diagnosis = {
|
|
60
|
+
entries: decorated.map(({ entry }) => entry),
|
|
61
|
+
timeline: buildTimeline(executionResult, findings),
|
|
62
|
+
recommendations: recommendations(decorated.map(({ entry }) => entry)),
|
|
63
|
+
};
|
|
64
|
+
// Mechanical seam enforcement: a diagnosis must match the internal contract.
|
|
65
|
+
return validateDiagnosis(diagnosis);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Turn a diagnosis into repair plans (for qa-fix).
|
|
70
|
+
*
|
|
71
|
+
* One plan per entry; non-repairable causes yield an escalation plan. Never
|
|
72
|
+
* produces code.
|
|
73
|
+
*/
|
|
74
|
+
export function planRepairs(diagnosis) {
|
|
75
|
+
return diagnosis.entries.map((entry) => ({
|
|
76
|
+
...planRepair(entry.rootCause, entry.affectedTests),
|
|
77
|
+
priority: entry.priority.priority,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Aggregate a diagnosis for qa-report: totals, breakdown by classification, the
|
|
83
|
+
* top-priority findings, and a deterministic release-readiness call.
|
|
84
|
+
*/
|
|
85
|
+
export function summarize(executionResult, diagnosis) {
|
|
86
|
+
const tests = (executionResult ?? {}).tests ?? {};
|
|
87
|
+
const byClassification = {};
|
|
88
|
+
for (const entry of diagnosis.entries) {
|
|
89
|
+
const cls = entry.rootCause.classification;
|
|
90
|
+
byClassification[cls] = (byClassification[cls] ?? 0) + 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
totals: tests,
|
|
95
|
+
byClassification,
|
|
96
|
+
topPriority: diagnosis.entries.filter((entry) => entry.priority.priority === 'P1'),
|
|
97
|
+
releaseReadiness: releaseReadiness(executionResult, diagnosis),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Derive failure signals, preferring the analysis platform's findings. */
|
|
102
|
+
function deriveSignals(executionResult, analysisResult) {
|
|
103
|
+
const findings = (analysisResult ?? {}).findings ?? [];
|
|
104
|
+
if (findings.length > 0) {
|
|
105
|
+
return findings.map((finding) => ({
|
|
106
|
+
message: finding.reason ?? '',
|
|
107
|
+
classification: finding.classification,
|
|
108
|
+
confidence: finding.confidence,
|
|
109
|
+
reason: finding.reason,
|
|
110
|
+
httpStatus: finding.httpStatus,
|
|
111
|
+
retries: finding.retries ?? 0,
|
|
112
|
+
finalStatus: finding.finalStatus,
|
|
113
|
+
evidence: finding.evidence ?? [],
|
|
114
|
+
affectedTests: finding.affectedTests ?? [],
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const signals = [];
|
|
119
|
+
for (const test of (executionResult ?? {}).executed ?? []) {
|
|
120
|
+
if (test.status === 'failed' || test.status === 'flaky') {
|
|
121
|
+
signals.push({
|
|
122
|
+
message: test.message ?? '',
|
|
123
|
+
retries: test.retries ?? 0,
|
|
124
|
+
finalStatus: test.status,
|
|
125
|
+
affectedTests: [test.title ?? ''],
|
|
126
|
+
evidence: [{
|
|
127
|
+
type: 'junit',
|
|
128
|
+
description: `${test.status}: ${test.title ?? ''}`,
|
|
129
|
+
source: test.file ?? 'execution-result',
|
|
130
|
+
}],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return signals;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Ranked, de-duplicated recommendations — highest priority first. */
|
|
138
|
+
function recommendations(entries) {
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
const ranked = [];
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
const action = entry.rootCause.recommendation;
|
|
143
|
+
if (seen.has(action)) continue;
|
|
144
|
+
seen.add(action);
|
|
145
|
+
ranked.push({
|
|
146
|
+
action,
|
|
147
|
+
priority: entry.priority.priority,
|
|
148
|
+
owner: entry.rootCause.ownership,
|
|
149
|
+
classification: entry.rootCause.classification,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return ranked;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function releaseReadiness(executionResult, diagnosis) {
|
|
156
|
+
const failed = ((executionResult ?? {}).tests ?? {}).failed ?? 0;
|
|
157
|
+
if (failed === 0 && diagnosis.entries.length === 0) return 'ready';
|
|
158
|
+
const classes = new Set(diagnosis.entries.map((entry) => entry.rootCause.classification));
|
|
159
|
+
for (const blocking of RELEASE_BLOCKING) {
|
|
160
|
+
if (classes.has(blocking)) return 'not-ready';
|
|
161
|
+
}
|
|
162
|
+
// `classes <= {UNKNOWN}` in Python: every class is unknown (or there are none).
|
|
163
|
+
if ([...classes].every((cls) => cls === taxonomy.UNKNOWN)) return 'insufficient-data';
|
|
164
|
+
return 'ready-with-risks';
|
|
165
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Validate internal Analysis → Diagnostics seam payloads.
|
|
2
|
+
//
|
|
3
|
+
// Uses the dependency-free contract validator against the schemas under
|
|
4
|
+
// shared/diagnostics/schemas/internal/.
|
|
5
|
+
//
|
|
6
|
+
// The schemas must be reachable in both layouts this code runs in: beside the
|
|
7
|
+
// engine when it ships inside the package, and at the canonical repository path
|
|
8
|
+
// during development. Missing schemas raise InternalContractError naming the
|
|
9
|
+
// locations tried, rather than a bare ENOENT from deep inside a diagnosis.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
import { validate } from '../analysis/contracts.mjs';
|
|
16
|
+
|
|
17
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
const SCHEMA_DIRS = [path.join(here, 'schemas', 'internal')];
|
|
20
|
+
|
|
21
|
+
export class InternalContractError extends Error {}
|
|
22
|
+
|
|
23
|
+
/** The directory holding the internal schemas, whichever layout applies. */
|
|
24
|
+
export function schemaDir() {
|
|
25
|
+
for (const candidate of SCHEMA_DIRS) {
|
|
26
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
|
|
27
|
+
}
|
|
28
|
+
throw new InternalContractError(
|
|
29
|
+
`internal schemas not found; tried: ${SCHEMA_DIRS.join(', ')}. ` +
|
|
30
|
+
'A package that ships the engine must carry diagnostics/schemas/internal/.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function load(name) {
|
|
35
|
+
const file = path.join(schemaDir(), name);
|
|
36
|
+
if (!fs.existsSync(file)) throw new InternalContractError(`internal schema missing: ${file}`);
|
|
37
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function require_(payload, schemaName) {
|
|
41
|
+
const errors = validate(payload, load(schemaName));
|
|
42
|
+
if (errors.length > 0) {
|
|
43
|
+
throw new InternalContractError(`${schemaName}: ${errors.join('; ')}`);
|
|
44
|
+
}
|
|
45
|
+
return payload;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const validateAnalysisResult = (payload) => require_(payload, 'analysis-result.schema.json');
|
|
49
|
+
export const validateExecutionResultMin = (payload) => require_(payload, 'execution-result-min.schema.json');
|
|
50
|
+
export const validateDiagnosis = (payload) => require_(payload, 'diagnosis.schema.json');
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Deterministic finding prioritization.
|
|
2
|
+
//
|
|
3
|
+
// Assigns every finding a severity, priority, the three impacts, an owner, and an
|
|
4
|
+
// estimated effort — by a fixed algorithm, so the same finding always ranks the
|
|
5
|
+
// same way. Priority is not a feeling; it is a function of severity, confidence,
|
|
6
|
+
// and business impact.
|
|
7
|
+
|
|
8
|
+
import * as taxonomy from '../analysis/taxonomy.mjs';
|
|
9
|
+
|
|
10
|
+
// Base severity per classification (before confidence adjustment).
|
|
11
|
+
const SEVERITY = {
|
|
12
|
+
[taxonomy.APPLICATION_BUG]: 'high',
|
|
13
|
+
[taxonomy.INFRASTRUCTURE]: 'high',
|
|
14
|
+
[taxonomy.NETWORK]: 'high',
|
|
15
|
+
[taxonomy.AUTH]: 'high',
|
|
16
|
+
[taxonomy.AUTHORIZATION]: 'high',
|
|
17
|
+
[taxonomy.ASSERTION]: 'medium',
|
|
18
|
+
[taxonomy.LOCATOR]: 'medium',
|
|
19
|
+
[taxonomy.TIMEOUT]: 'medium',
|
|
20
|
+
[taxonomy.TEST_DATA]: 'medium',
|
|
21
|
+
[taxonomy.CONFIGURATION]: 'medium',
|
|
22
|
+
[taxonomy.ENVIRONMENT]: 'medium',
|
|
23
|
+
[taxonomy.FRAMEWORK]: 'medium',
|
|
24
|
+
[taxonomy.FLAKY]: 'medium',
|
|
25
|
+
[taxonomy.UNKNOWN]: 'low',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Where the impact predominantly lands, per classification.
|
|
29
|
+
const IMPACT = {
|
|
30
|
+
[taxonomy.APPLICATION_BUG]: { business: 'high', technical: 'high', testing: 'low' },
|
|
31
|
+
[taxonomy.NETWORK]: { business: 'high', technical: 'high', testing: 'medium' },
|
|
32
|
+
[taxonomy.AUTH]: { business: 'high', technical: 'medium', testing: 'medium' },
|
|
33
|
+
[taxonomy.AUTHORIZATION]: { business: 'high', technical: 'medium', testing: 'medium' },
|
|
34
|
+
[taxonomy.INFRASTRUCTURE]: { business: 'medium', technical: 'high', testing: 'high' },
|
|
35
|
+
[taxonomy.LOCATOR]: { business: 'low', technical: 'low', testing: 'high' },
|
|
36
|
+
[taxonomy.ASSERTION]: { business: 'medium', technical: 'medium', testing: 'medium' },
|
|
37
|
+
[taxonomy.TIMEOUT]: { business: 'low', technical: 'medium', testing: 'high' },
|
|
38
|
+
[taxonomy.TEST_DATA]: { business: 'low', technical: 'low', testing: 'high' },
|
|
39
|
+
[taxonomy.CONFIGURATION]: { business: 'low', technical: 'medium', testing: 'high' },
|
|
40
|
+
[taxonomy.ENVIRONMENT]: { business: 'low', technical: 'medium', testing: 'high' },
|
|
41
|
+
[taxonomy.FRAMEWORK]: { business: 'low', technical: 'medium', testing: 'high' },
|
|
42
|
+
[taxonomy.FLAKY]: { business: 'low', technical: 'low', testing: 'high' },
|
|
43
|
+
[taxonomy.UNKNOWN]: { business: 'low', technical: 'low', testing: 'medium' },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Rough effort to resolve, per classification.
|
|
47
|
+
const EFFORT = {
|
|
48
|
+
[taxonomy.LOCATOR]: 'low',
|
|
49
|
+
[taxonomy.ASSERTION]: 'low',
|
|
50
|
+
[taxonomy.CONFIGURATION]: 'low',
|
|
51
|
+
[taxonomy.ENVIRONMENT]: 'low',
|
|
52
|
+
[taxonomy.TEST_DATA]: 'medium',
|
|
53
|
+
[taxonomy.TIMEOUT]: 'medium',
|
|
54
|
+
[taxonomy.FLAKY]: 'medium',
|
|
55
|
+
[taxonomy.AUTH]: 'medium',
|
|
56
|
+
[taxonomy.AUTHORIZATION]: 'medium',
|
|
57
|
+
[taxonomy.FRAMEWORK]: 'medium',
|
|
58
|
+
[taxonomy.NETWORK]: 'high',
|
|
59
|
+
[taxonomy.INFRASTRUCTURE]: 'high',
|
|
60
|
+
[taxonomy.APPLICATION_BUG]: 'external',
|
|
61
|
+
[taxonomy.UNKNOWN]: 'unknown',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const RANK = { low: 1, medium: 2, high: 3 };
|
|
65
|
+
const PRIORITY = { 1: 'P3', 2: 'P2', 3: 'P1', 4: 'P1' };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Prioritize a root cause.
|
|
69
|
+
*
|
|
70
|
+
* Priority derives from severity, business impact, and confidence, and is escalated
|
|
71
|
+
* one step when the failure blocks a release.
|
|
72
|
+
*/
|
|
73
|
+
export function prioritize(rootCause, { blocking = false } = {}) {
|
|
74
|
+
const classification = rootCause.classification;
|
|
75
|
+
const confidence = 'confidence' in rootCause && rootCause.confidence !== undefined
|
|
76
|
+
? rootCause.confidence
|
|
77
|
+
: 0.5;
|
|
78
|
+
const severity = SEVERITY[classification] ?? 'low';
|
|
79
|
+
const impact = IMPACT[classification] ?? IMPACT[taxonomy.UNKNOWN];
|
|
80
|
+
|
|
81
|
+
// Priority score: severity and business impact drive it; low confidence holds it
|
|
82
|
+
// back (an uncertain finding should not top the queue).
|
|
83
|
+
let score = RANK[severity];
|
|
84
|
+
if (impact.business === 'high') score += 1;
|
|
85
|
+
if (confidence < 0.5) score -= 1;
|
|
86
|
+
if (blocking) score += 1;
|
|
87
|
+
score = Math.max(1, Math.min(4, score));
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
severity,
|
|
91
|
+
priority: PRIORITY[score],
|
|
92
|
+
businessImpact: impact.business,
|
|
93
|
+
technicalImpact: impact.technical,
|
|
94
|
+
testingImpact: impact.testing,
|
|
95
|
+
confidence,
|
|
96
|
+
owner: rootCause.ownership ?? 'needs-triage',
|
|
97
|
+
estimatedEffort: EFFORT[classification] ?? 'unknown',
|
|
98
|
+
};
|
|
99
|
+
}
|