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.
Files changed (109) hide show
  1. package/COMPATIBILITY.md +50 -26
  2. package/README.md +40 -32
  3. package/package.json +12 -20
  4. package/packages/engine/bin/qa-engine.mjs +336 -0
  5. package/packages/engine/lib/analysis/branding.mjs +187 -0
  6. package/packages/engine/lib/analysis/context.mjs +294 -0
  7. package/packages/{installer/lib/core/schema-validate.mjs → engine/lib/analysis/contracts.mjs} +11 -4
  8. package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
  9. package/packages/engine/lib/analysis/discovery.mjs +220 -0
  10. package/packages/engine/lib/analysis/evidence.mjs +116 -0
  11. package/packages/engine/lib/analysis/har.mjs +90 -0
  12. package/packages/engine/lib/analysis/junit.mjs +126 -0
  13. package/packages/engine/lib/analysis/redaction.mjs +127 -0
  14. package/packages/engine/lib/analysis/report-html.mjs +763 -0
  15. package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
  16. package/packages/engine/lib/analysis/xml.mjs +153 -0
  17. package/packages/engine/lib/analysis/zip.mjs +107 -0
  18. package/packages/engine/lib/diagnostics/engine.mjs +165 -0
  19. package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
  20. package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
  21. package/packages/engine/lib/diagnostics/repair.mjs +73 -0
  22. package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
  23. package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
  24. package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
  25. package/packages/engine/lib/frameworks/playwright.mjs +158 -0
  26. package/packages/engine/package.json +14 -0
  27. package/packages/installer/lib/agents/registry.mjs +46 -0
  28. package/packages/installer/lib/commands/doctor.mjs +7 -16
  29. package/packages/installer/lib/commands/install.mjs +59 -4
  30. package/packages/installer/lib/core/bundle.mjs +69 -92
  31. package/packages/installer/lib/core/config.mjs +1 -1
  32. package/packages/installer/lib/core/lockfile.mjs +1 -1
  33. package/packages/installer/lib/core/manifest.mjs +48 -54
  34. package/packages/installer/lib/core/validate-install.mjs +12 -19
  35. package/packages/installer/package.json +1 -1
  36. package/shared/tooling/qa-tool.mjs +125 -0
  37. package/skills/qa-api/SKILL.md +5 -5
  38. package/skills/qa-api/references/deterministic-tooling.md +34 -30
  39. package/skills/qa-api/references/evidence-and-reporting.md +29 -4
  40. package/skills/qa-api/scripts/qa-tool.mjs +126 -0
  41. package/skills/qa-audit/SKILL.md +5 -5
  42. package/skills/qa-audit/references/deterministic-tooling.md +34 -30
  43. package/skills/qa-audit/references/evidence-and-reporting.md +29 -4
  44. package/skills/qa-audit/scripts/qa-tool.mjs +126 -0
  45. package/skills/qa-debug/SKILL.md +6 -6
  46. package/skills/qa-debug/references/deterministic-tooling.md +34 -30
  47. package/skills/qa-debug/references/diagnostic-engine.md +3 -3
  48. package/skills/qa-debug/references/evidence-and-reporting.md +29 -4
  49. package/skills/qa-debug/scripts/qa-tool.mjs +126 -0
  50. package/skills/qa-explore/SKILL.md +22 -5
  51. package/skills/qa-explore/contracts/explore-result.schema.json +26 -2
  52. package/skills/qa-explore/references/deterministic-tooling.md +132 -0
  53. package/skills/qa-explore/references/evidence-and-reporting.md +29 -4
  54. package/skills/qa-explore/references/report-pipeline.md +89 -8
  55. package/skills/qa-explore/scripts/qa-tool.mjs +126 -0
  56. package/skills/qa-fix/SKILL.md +4 -4
  57. package/skills/qa-fix/references/deterministic-tooling.md +34 -30
  58. package/skills/qa-fix/references/diagnostic-engine.md +3 -3
  59. package/skills/qa-fix/references/evidence-and-reporting.md +29 -4
  60. package/skills/qa-fix/scripts/qa-tool.mjs +126 -0
  61. package/skills/qa-flaky/SKILL.md +4 -4
  62. package/skills/qa-flaky/references/deterministic-tooling.md +34 -30
  63. package/skills/qa-flaky/references/evidence-and-reporting.md +29 -4
  64. package/skills/qa-flaky/scripts/qa-tool.mjs +126 -0
  65. package/skills/qa-generate/references/evidence-and-reporting.md +29 -4
  66. package/skills/qa-init/SKILL.md +4 -4
  67. package/skills/qa-init/references/deterministic-tooling.md +34 -30
  68. package/skills/qa-init/references/evidence-and-reporting.md +29 -4
  69. package/skills/qa-init/scripts/qa-tool.mjs +126 -0
  70. package/skills/qa-report/SKILL.md +8 -7
  71. package/skills/qa-report/references/deterministic-tooling.md +34 -30
  72. package/skills/qa-report/references/diagnostic-engine.md +3 -3
  73. package/skills/qa-report/references/evidence-and-reporting.md +29 -4
  74. package/skills/qa-report/scripts/qa-tool.mjs +126 -0
  75. package/skills/qa-review/references/evidence-and-reporting.md +29 -4
  76. package/skills/qa-run/SKILL.md +6 -6
  77. package/skills/qa-run/references/deterministic-tooling.md +34 -30
  78. package/skills/qa-run/references/evidence-and-reporting.md +29 -4
  79. package/skills/qa-run/scripts/qa-tool.mjs +126 -0
  80. package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
  81. package/shared/analysis/lib/qa_analysis/branding.py +0 -175
  82. package/shared/analysis/lib/qa_analysis/cli.py +0 -129
  83. package/shared/analysis/lib/qa_analysis/context.py +0 -233
  84. package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
  85. package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
  86. package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
  87. package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
  88. package/shared/analysis/lib/qa_analysis/har.py +0 -58
  89. package/shared/analysis/lib/qa_analysis/junit.py +0 -80
  90. package/shared/analysis/lib/qa_analysis/redaction.py +0 -99
  91. package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
  92. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
  93. package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
  94. package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
  95. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
  96. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
  97. package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
  98. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
  99. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
  100. package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
  101. package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
  102. package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
  103. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
  104. package/shared/tooling/qa_tool.py +0 -127
  105. /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
  106. /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
  107. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
  108. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
  109. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
@@ -0,0 +1,294 @@
1
+ // Deterministic parsing of `.qa/context.md`.
2
+ //
3
+ // `qa-init` writes the project profile as a Markdown file whose frontmatter holds
4
+ // the machine-readable facts, and every other skill reads it. The contract for
5
+ // those facts is a JSON Schema (shared/analysis/schemas/context.schema.json) — but
6
+ // nothing could check a *real* `.qa/context.md` against it, because the engine
7
+ // takes no dependencies and the frontmatter is YAML. Validation ran against a
8
+ // hand-written JSON fixture instead, so the contract was unenforced exactly where
9
+ // it mattered.
10
+ //
11
+ // This closes that gap without adding a dependency or changing the file format: it
12
+ // parses the **explicit subset** of YAML the context contract uses, then hands the
13
+ // result to the contract validator.
14
+ //
15
+ // ## The supported subset
16
+ //
17
+ // Deliberately small, and everything outside it is an error rather than a guess:
18
+ //
19
+ // - `key: value` mappings, nested by two-space indentation
20
+ // - block sequences (`- item`), including nested under a key
21
+ // - flow collections only when empty: `[]` and `{}`
22
+ // - scalars: double- or single-quoted strings, bare strings, integers, floats,
23
+ // `true`/`false`, `null`/`~`/empty
24
+ // - `#` comments on their own line or after a value
25
+ // - the leading/trailing `---` fences
26
+ //
27
+ // Not supported, and rejected loudly: anchors/aliases, multi-line block scalars
28
+ // (`|`, `>`), non-empty flow collections, multi-document streams, and tabs for
29
+ // indentation. A generator that needs one of those has outgrown the contract, and
30
+ // the right response is to change the contract deliberately — not to have a parser
31
+ // quietly misread it.
32
+
33
+ import fs from 'node:fs';
34
+
35
+ import { validate } from './contracts.mjs';
36
+
37
+ const FENCE = '---';
38
+ const TRUE = new Set(['true', 'True', 'TRUE']);
39
+ const FALSE = new Set(['false', 'False', 'FALSE']);
40
+ const NULL = new Set(['null', 'Null', 'NULL', '~', '']);
41
+ const UNSUPPORTED_SCALARS = new Set(['|', '>']);
42
+ const INT = /^-?\d+$/;
43
+ const FLOAT = /^-?\d+\.\d+$/;
44
+
45
+ export class MalformedContext extends Error {}
46
+
47
+ /** Return `{frontmatter, body}`. Throws if the fences are missing. */
48
+ export function splitFrontmatter(text) {
49
+ const lines = String(text).split('\n');
50
+ let start = null;
51
+ for (const [index, line] of lines.entries()) {
52
+ if (line.trim() === '') continue;
53
+ if (trimEnd(line) === FENCE) start = index;
54
+ break;
55
+ }
56
+ if (start === null) {
57
+ throw new MalformedContext(
58
+ "no frontmatter: the file must open with a '---' fence " +
59
+ '(see the qa-init context template)',
60
+ );
61
+ }
62
+ for (let index = start + 1; index < lines.length; index += 1) {
63
+ if (trimEnd(lines[index]) === FENCE) {
64
+ return {
65
+ frontmatter: lines.slice(start + 1, index).join('\n'),
66
+ body: lines.slice(index + 1).join('\n'),
67
+ };
68
+ }
69
+ }
70
+ throw new MalformedContext("unterminated frontmatter: no closing '---' fence");
71
+ }
72
+
73
+ /** Remove a trailing `#` comment that is not inside quotes. */
74
+ function stripComment(value) {
75
+ let quote = null;
76
+ for (let index = 0; index < value.length; index += 1) {
77
+ const char = value[index];
78
+ if (quote) {
79
+ if (char === quote) quote = null;
80
+ } else if (char === "'" || char === '"') {
81
+ quote = char;
82
+ } else if (char === '#' && (index === 0 || value[index - 1] === ' ' || value[index - 1] === '\t')) {
83
+ return value.slice(0, index);
84
+ }
85
+ }
86
+ return value;
87
+ }
88
+
89
+ function scalar(raw, lineNumber) {
90
+ const text = stripComment(raw).trim();
91
+ const first = text.slice(0, 1);
92
+ if (UNSUPPORTED_SCALARS.has(first)) {
93
+ throw new MalformedContext(
94
+ `line ${lineNumber}: block scalars ('|', '>') are outside the supported subset`,
95
+ );
96
+ }
97
+ if (first === '&' || first === '*') {
98
+ throw new MalformedContext(
99
+ `line ${lineNumber}: anchors and aliases are outside the supported subset`,
100
+ );
101
+ }
102
+ if (text.length >= 2 && text[0] === text[text.length - 1] && (text[0] === "'" || text[0] === '"')) {
103
+ return text.slice(1, -1);
104
+ }
105
+ if (text === '[]') return [];
106
+ if (text === '{}') return {};
107
+ if (text.startsWith('[') || text.startsWith('{')) {
108
+ throw new MalformedContext(
109
+ `line ${lineNumber}: only empty flow collections ('[]', '{}') are supported; ` +
110
+ 'use a block sequence or mapping',
111
+ );
112
+ }
113
+ if (TRUE.has(text)) return true;
114
+ if (FALSE.has(text)) return false;
115
+ if (NULL.has(text)) return null;
116
+ if (INT.test(text)) return Number.parseInt(text, 10);
117
+ if (FLOAT.test(text)) return Number.parseFloat(text);
118
+ return text;
119
+ }
120
+
121
+ /**
122
+ * A line must sit at exactly the indent of the container it lands in.
123
+ *
124
+ * Without this, a document YAML itself rejects was silently misread. Given
125
+ *
126
+ * list:
127
+ * - one
128
+ * key: inside a sequence
129
+ *
130
+ * PyYAML reports "expected <block end>, but found '?'" — a mapping key cannot
131
+ * share indentation with a sequence entry in the same block. The parser closed the
132
+ * sequence and put `key` in the *root* mapping, two levels out from where it was
133
+ * written, turning an invalid file into a plausible one.
134
+ */
135
+ function requireIndentMatches(stack, indent, lineNumber) {
136
+ if (stack[stack.length - 1][0] !== indent) {
137
+ throw new MalformedContext(
138
+ `line ${lineNumber}: indentation ${indent} matches no open block ` +
139
+ `(the enclosing block starts at column ${stack[stack.length - 1][0]})`,
140
+ );
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Parse the supported YAML subset into an object.
146
+ *
147
+ * The parser keeps a stack of `[keyIndent, container]` frames. `keyIndent` is the
148
+ * column at which that container's own entries start, so a line's indent alone
149
+ * decides which container it belongs to: pop every frame deeper than the line, and
150
+ * the top of the stack is the target.
151
+ */
152
+ export function parseFrontmatter(text) {
153
+ const root = {};
154
+ const stack = [[0, root]];
155
+ let pending = null; // [key, indent] — a key whose value is a block that follows
156
+
157
+ const resolvePendingAsNull = () => {
158
+ // A key with no value and no nested block is a null field.
159
+ if (pending !== null) {
160
+ stack[stack.length - 1][1][pending[0]] = null;
161
+ pending = null;
162
+ }
163
+ };
164
+
165
+ const lines = String(text).split('\n');
166
+ for (let index = 0; index < lines.length; index += 1) {
167
+ const rawLine = lines[index];
168
+ const number = index + 1;
169
+ const leading = rawLine.slice(0, rawLine.length - trimStart(rawLine).length);
170
+ if (leading.includes('\t')) {
171
+ throw new MalformedContext(`line ${number}: tab indentation is not supported`);
172
+ }
173
+ const stripped = rawLine.trim();
174
+ if (!stripped || stripped.startsWith('#')) continue;
175
+ if (stripped === FENCE) {
176
+ throw new MalformedContext(`line ${number}: unexpected '---' inside frontmatter`);
177
+ }
178
+
179
+ const indent = rawLine.length - trimStartSpaces(rawLine).length;
180
+
181
+ // --- sequence item ---
182
+ if (stripped.startsWith('- ') || stripped === '-') {
183
+ const item = scalar(stripped.length > 1 ? stripped.slice(2) : '', number);
184
+ if (pending !== null) {
185
+ // A sequence may sit at the key's indent or deeper.
186
+ const parent = stack[stack.length - 1][1];
187
+ parent[pending[0]] = [];
188
+ stack.push([indent, parent[pending[0]]]);
189
+ pending = null;
190
+ }
191
+ while (stack.length > 1 && stack[stack.length - 1][0] > indent) stack.pop();
192
+ const container = stack[stack.length - 1][1];
193
+ if (!Array.isArray(container)) {
194
+ throw new MalformedContext(`line ${number}: sequence item outside a sequence`);
195
+ }
196
+ requireIndentMatches(stack, indent, number);
197
+ container.push(item);
198
+ continue;
199
+ }
200
+
201
+ // --- mapping entry ---
202
+ if (!stripped.includes(':')) {
203
+ throw new MalformedContext(
204
+ `line ${number}: expected 'key: value' or '- item', got '${stripped}'`,
205
+ );
206
+ }
207
+
208
+ if (pending !== null) {
209
+ if (indent > pending[1]) {
210
+ // The pending key opens a nested mapping at this indent.
211
+ const parent = stack[stack.length - 1][1];
212
+ parent[pending[0]] = {};
213
+ stack.push([indent, parent[pending[0]]]);
214
+ pending = null;
215
+ } else {
216
+ resolvePendingAsNull();
217
+ }
218
+ }
219
+
220
+ // Leave the frames this line does not belong to. A sequence frame at the same
221
+ // indent as a key also ends here (`packages:` / `- a` / `ci:`).
222
+ while (
223
+ stack.length > 1 &&
224
+ (stack[stack.length - 1][0] > indent ||
225
+ (Array.isArray(stack[stack.length - 1][1]) && stack[stack.length - 1][0] >= indent))
226
+ ) {
227
+ stack.pop();
228
+ }
229
+
230
+ const separator = stripped.indexOf(':');
231
+ const key = stripped.slice(0, separator).trim();
232
+ const value = stripped.slice(separator + 1);
233
+ if (!key) throw new MalformedContext(`line ${number}: empty key`);
234
+ const container = stack[stack.length - 1][1];
235
+ if (Array.isArray(container)) {
236
+ throw new MalformedContext(`line ${number}: mapping key inside a sequence`);
237
+ }
238
+ requireIndentMatches(stack, indent, number);
239
+
240
+ if (stripComment(value).trim() === '') {
241
+ pending = [key, indent]; // a block or a null follows
242
+ } else {
243
+ container[key] = scalar(value, number);
244
+ }
245
+ }
246
+
247
+ resolvePendingAsNull();
248
+ return root;
249
+ }
250
+
251
+ /**
252
+ * Parse `.qa/context.md` text and, when a schema is given, validate it.
253
+ *
254
+ * A parse failure throws: it is not a validation result. "This file is not
255
+ * parseable" and "this file is parseable but wrong" are different answers, and
256
+ * collapsing them would report a broken file as a merely invalid one.
257
+ */
258
+ export function parse(text, { schema = null } = {}) {
259
+ const { frontmatter, body } = splitFrontmatter(text);
260
+ const context = parseFrontmatter(frontmatter);
261
+ const result = { context, body, valid: true, errors: [] };
262
+ if (schema !== null) {
263
+ const errors = validate(context, schema);
264
+ result.valid = errors.length === 0;
265
+ result.errors = errors;
266
+ }
267
+ return result;
268
+ }
269
+
270
+ /** Read and parse a `.qa/context.md` file. */
271
+ export function parseFile(path, { schema = null } = {}) {
272
+ let text;
273
+ try {
274
+ text = fs.readFileSync(path, 'utf8');
275
+ } catch (error) {
276
+ throw new MalformedContext(`could not read ${path}: ${error.message}`);
277
+ }
278
+ return parse(text, { schema });
279
+ }
280
+
281
+ // Python's str.rstrip()/lstrip() strip all whitespace; JavaScript's trimEnd and
282
+ // trimStart do the same, but the indent calculation needs spaces only — a line
283
+ // indented with a form feed is not indented two columns.
284
+ function trimEnd(value) {
285
+ return value.replace(/\s+$/, '');
286
+ }
287
+
288
+ function trimStart(value) {
289
+ return value.replace(/^\s+/, '');
290
+ }
291
+
292
+ function trimStartSpaces(value) {
293
+ return value.replace(/^ +/, '');
294
+ }
@@ -1,8 +1,15 @@
1
1
  // A dependency-free validator for the JSON Schema subset this project uses.
2
- // It is the JavaScript twin of the Python contract validator in
3
- // shared/analysis/lib/qa_analysis/contracts.py: same supported subset, same
4
- // semantics, so a document that passes one passes the other. That promise is
5
- // tested, not asserted tests/parity/validator-cases.json runs through both.
2
+ //
3
+ // This began as the JavaScript *twin* of a Python validator, kept in step by a
4
+ // shared corpus. The move to a Node-only engine (ADR-0012) made it the single
5
+ // implementation: the installer's config and lockfile checks and the analysis
6
+ // engine's contract checks now go through this one function, rather than two
7
+ // copies that agreed by test.
8
+ //
9
+ // The corpus outlived the twin. packages/engine/test/corpus/validator-cases.json
10
+ // still runs every case here, because it encodes which subset of JSON Schema the
11
+ // pack's contracts may use — and that is worth pinning whether or not a second
12
+ // validator exists to compare against.
6
13
  //
7
14
  // Anything outside the subset is a programming error in the schema, reported
8
15
  // rather than silently ignored. `allOf`/`if`/`then`/`else` are in the subset