qa-engineer 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/COMPATIBILITY.md +26 -12
  2. package/README.md +182 -84
  3. package/package.json +11 -20
  4. package/packages/engine/bin/qa-engine.mjs +560 -0
  5. package/packages/engine/lib/analysis/branding.mjs +187 -0
  6. package/packages/engine/lib/analysis/context.mjs +294 -0
  7. package/packages/engine/lib/analysis/contracts.mjs +149 -0
  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 +124 -0
  12. package/packages/engine/lib/analysis/junit.mjs +126 -0
  13. package/packages/engine/lib/analysis/network.mjs +237 -0
  14. package/packages/engine/lib/analysis/redaction.mjs +127 -0
  15. package/packages/engine/lib/analysis/report-html.mjs +76 -0
  16. package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
  17. package/packages/engine/lib/analysis/xml.mjs +153 -0
  18. package/packages/engine/lib/analysis/zip.mjs +107 -0
  19. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  20. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  21. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  22. package/packages/engine/lib/diagnostics/engine.mjs +165 -0
  23. package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
  24. package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
  25. package/packages/engine/lib/diagnostics/repair.mjs +73 -0
  26. package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
  27. package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
  28. package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
  29. package/packages/engine/lib/frameworks/playwright.mjs +158 -0
  30. package/packages/engine/lib/report/components/charts.mjs +424 -0
  31. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  32. package/packages/engine/lib/report/components/findings.mjs +258 -0
  33. package/packages/engine/lib/report/components/nav.mjs +99 -0
  34. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  35. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  36. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  37. package/packages/engine/lib/report/core/model.mjs +270 -0
  38. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  39. package/packages/engine/lib/report/core/sections.mjs +978 -0
  40. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  41. package/packages/engine/lib/report/export/html.mjs +183 -0
  42. package/packages/engine/lib/report/export/machine.mjs +290 -0
  43. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  44. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  45. package/packages/engine/lib/report/theme/css.mjs +529 -0
  46. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  47. package/packages/engine/lib/report/version.mjs +78 -0
  48. package/packages/engine/package.json +14 -0
  49. package/packages/installer/lib/agents/targets.mjs +90 -0
  50. package/packages/installer/lib/agents/user-level.mjs +80 -0
  51. package/packages/installer/lib/cli/flags.mjs +20 -2
  52. package/packages/installer/lib/commands/doctor.mjs +13 -20
  53. package/packages/installer/lib/commands/install.mjs +160 -91
  54. package/packages/installer/lib/commands/repair.mjs +10 -6
  55. package/packages/installer/lib/commands/self-test.mjs +4 -3
  56. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  57. package/packages/installer/lib/commands/update.mjs +9 -4
  58. package/packages/installer/lib/commands/verify.mjs +13 -12
  59. package/packages/installer/lib/constants.mjs +13 -0
  60. package/packages/installer/lib/core/bundle.mjs +69 -92
  61. package/packages/installer/lib/core/conflict.mjs +5 -4
  62. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  63. package/packages/installer/lib/core/integrity.mjs +59 -0
  64. package/packages/installer/lib/core/lockfile.mjs +19 -3
  65. package/packages/installer/lib/core/manifest.mjs +48 -57
  66. package/packages/installer/lib/core/plan.mjs +213 -0
  67. package/packages/installer/lib/core/qa-home.mjs +145 -0
  68. package/packages/installer/lib/core/scope.mjs +274 -0
  69. package/packages/installer/lib/core/validate-install.mjs +49 -31
  70. package/packages/installer/package.json +1 -1
  71. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  72. package/shared/tooling/qa-tool.mjs +161 -0
  73. package/skills/qa-api/SKILL.md +5 -5
  74. package/skills/qa-api/references/deterministic-tooling.md +34 -32
  75. package/skills/qa-api/references/evidence-and-reporting.md +5 -5
  76. package/skills/qa-api/scripts/qa-tool.mjs +162 -0
  77. package/skills/qa-audit/SKILL.md +5 -5
  78. package/skills/qa-audit/references/deterministic-tooling.md +34 -32
  79. package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
  80. package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
  81. package/skills/qa-debug/SKILL.md +6 -6
  82. package/skills/qa-debug/references/deterministic-tooling.md +34 -32
  83. package/skills/qa-debug/references/diagnostic-engine.md +3 -3
  84. package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
  85. package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
  86. package/skills/qa-explore/SKILL.md +31 -15
  87. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  88. package/skills/qa-explore/references/api-replay.md +40 -1
  89. package/skills/qa-explore/references/deterministic-tooling.md +34 -32
  90. package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
  91. package/skills/qa-explore/references/report-pipeline.md +266 -96
  92. package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
  93. package/skills/qa-fix/SKILL.md +4 -4
  94. package/skills/qa-fix/references/deterministic-tooling.md +34 -32
  95. package/skills/qa-fix/references/diagnostic-engine.md +3 -3
  96. package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
  97. package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
  98. package/skills/qa-flaky/SKILL.md +4 -4
  99. package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
  100. package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
  101. package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
  102. package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
  103. package/skills/qa-init/SKILL.md +4 -4
  104. package/skills/qa-init/references/deterministic-tooling.md +34 -32
  105. package/skills/qa-init/references/evidence-and-reporting.md +5 -5
  106. package/skills/qa-init/scripts/qa-tool.mjs +162 -0
  107. package/skills/qa-report/SKILL.md +7 -7
  108. package/skills/qa-report/references/deterministic-tooling.md +34 -32
  109. package/skills/qa-report/references/diagnostic-engine.md +3 -3
  110. package/skills/qa-report/references/evidence-and-reporting.md +5 -5
  111. package/skills/qa-report/scripts/qa-tool.mjs +162 -0
  112. package/skills/qa-review/references/evidence-and-reporting.md +5 -5
  113. package/skills/qa-run/SKILL.md +6 -6
  114. package/skills/qa-run/references/deterministic-tooling.md +34 -32
  115. package/skills/qa-run/references/evidence-and-reporting.md +5 -5
  116. package/skills/qa-run/scripts/qa-tool.mjs +162 -0
  117. package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
  118. package/shared/analysis/lib/qa_analysis/branding.py +0 -175
  119. package/shared/analysis/lib/qa_analysis/cli.py +0 -144
  120. package/shared/analysis/lib/qa_analysis/context.py +0 -233
  121. package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
  122. package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
  123. package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
  124. package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
  125. package/shared/analysis/lib/qa_analysis/har.py +0 -87
  126. package/shared/analysis/lib/qa_analysis/junit.py +0 -104
  127. package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
  128. package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
  129. package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
  130. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
  131. package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
  132. package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
  133. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
  134. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
  135. package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
  136. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
  137. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
  138. package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
  139. package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
  140. package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
  141. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
  142. package/shared/tooling/qa_tool.py +0 -127
  143. /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
  144. /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
  145. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
  146. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
  147. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
@@ -17,7 +17,7 @@ resolves its own location. There is nothing to set up and no shell features are
17
17
  involved:
18
18
 
19
19
  ```bash
20
- python3 <skill-dir>/scripts/qa_tool.py <tool> <subcommand> [args]
20
+ node <skill-dir>/scripts/qa-tool.mjs <tool> <subcommand> [args]
21
21
  ```
22
22
 
23
23
  `<skill-dir>` is wherever the host installed this skill — usually
@@ -25,19 +25,21 @@ python3 <skill-dir>/scripts/qa_tool.py <tool> <subcommand> [args]
25
25
  whichever exists.
26
26
 
27
27
  ```bash
28
- python3 .agents/skills/qa-run/scripts/qa_tool.py analysis junit test-results/results.xml
28
+ node .agents/skills/qa-run/scripts/qa-tool.mjs analysis junit test-results/results.xml
29
29
  ```
30
30
 
31
- That line is identical in bash, zsh, PowerShell, and cmd.exe. **On Windows, use
32
- `python` if `python3` is not on PATH** that is the only platform difference.
31
+ That line is identical in bash, zsh, PowerShell, and cmd.exe, with no platform
32
+ difference at all: it runs under the same Node the user already had to install the
33
+ pack.
33
34
 
34
- An earlier version of this contract used a shell recipe
35
- (`QA_LIB="$(ls -d … | head -1)"` with a `PYTHONPATH=` prefix). It was POSIX-only,
36
- so on Windows every deterministic call failed, each skill fell back to its manual
37
- path, and the user silently got guesswork while believing the tooling had run.
38
- Never reintroduce a shell-dependent invocation.
35
+ Two earlier versions of this contract failed the same way. The first was a shell
36
+ recipe (`QA_LIB="$(ls -d … | head -1)"` with a `PYTHONPATH=` prefix), POSIX-only, so
37
+ on Windows every deterministic call failed and each skill fell back to guesswork
38
+ while appearing to have run its tooling. The second was portable but needed a Python
39
+ interpreter the user never agreed to install. Never reintroduce a shell-dependent
40
+ invocation, and never add a runtime the install did not already require.
39
41
 
40
- If `qa_tool.py` is missing, the engine is not installed: say so, recommend
42
+ If `qa-tool.mjs` is missing, the engine is not installed: say so, recommend
41
43
  `qa repair`, use the skill's documented fallback, and mark the result degraded.
42
44
 
43
45
  Every tool writes JSON to stdout. Exit `0` means success; exit `1` means an
@@ -45,35 +47,35 @@ invalid contract; exit `2` means unreadable input, a malformed artifact, or a
45
47
  payload that failed its seam contract, and the JSON body carries `error` and
46
48
  `detail`. Treat a non-zero exit as missing evidence, never as a value to guess.
47
49
 
48
- Standard-library Python 3.8+ only — nothing to install.
50
+ Dependency-free Node — nothing to install beyond the Node that ran `npx`.
49
51
 
50
- ## 2. Analysis core — `qa_tool.py analysis`
52
+ ## 2. Analysis core — `qa-tool.mjs analysis`
51
53
 
52
54
  Framework-agnostic parsing, redaction, and validation.
53
55
 
54
56
  | Subcommand | Invocation | Returns |
55
57
  | --- | --- | --- |
56
- | `junit` | `python3 <skill-dir>/scripts/qa_tool.py analysis junit <report.xml>` | `{tests: {...}, executed: [...]}` normalized counts and per-test outcomes |
57
- | `har` | `python3 <skill-dir>/scripts/qa_tool.py analysis har <file.har> [--slow-ms N]` | Redacted request/response summary, failures, slow calls |
58
- | `discover` | `python3 <skill-dir>/scripts/qa_tool.py analysis discover [--root DIR] [--path P]` | Artifacts found, by type, with presence flags |
59
- | `diff-guard` | `python3 <skill-dir>/scripts/qa_tool.py analysis diff-guard <diff-file>` | `{issues: [...], safe: bool}` — `safe:false` blocks the change |
60
- | `redact` | `python3 <skill-dir>/scripts/qa_tool.py analysis redact <file>` | The file's text with credentials masked |
61
- | `validate` | `python3 <skill-dir>/scripts/qa_tool.py analysis validate <instance.json> <schema.json>` | `{valid: bool, errors: [...]}`; exit 1 when invalid |
62
- | `classify` | `python3 <skill-dir>/scripts/qa_tool.py analysis classify "<error message>" [--http-status N]` | `{classification, confidence, reason}` from the shared taxonomy |
63
- | `context` | `python3 <skill-dir>/scripts/qa_tool.py analysis context [--root DIR] [--path .qa/context.md]` | The parsed, schema-validated project context as JSON |
64
- | `report-html` | `python3 <skill-dir>/scripts/qa_tool.py analysis report-html <result.json> [--out report.html]` | The result rendered as one self-contained HTML report — every required field, footer included. Run it instead of writing HTML |
65
- | `branding` | `python3 <skill-dir>/scripts/qa_tool.py analysis branding --format markdown\|html\|text` | The exact attribution footer bytes for a **rendered** report (never for a JSON artifact) |
66
-
67
- ## 3. Diagnostic engine — `qa_tool.py diagnostics`
58
+ | `junit` | `node <skill-dir>/scripts/qa-tool.mjs analysis junit <report.xml>` | `{tests: {...}, executed: [...]}` normalized counts and per-test outcomes |
59
+ | `har` | `node <skill-dir>/scripts/qa-tool.mjs analysis har <file.har> [--slow-ms N]` | Redacted request/response summary, failures, slow calls |
60
+ | `discover` | `node <skill-dir>/scripts/qa-tool.mjs analysis discover [--root DIR] [--path P]` | Artifacts found, by type, with presence flags |
61
+ | `diff-guard` | `node <skill-dir>/scripts/qa-tool.mjs analysis diff-guard <diff-file>` | `{issues: [...], safe: bool}` — `safe:false` blocks the change |
62
+ | `redact` | `node <skill-dir>/scripts/qa-tool.mjs analysis redact <file>` | The file's text with credentials masked |
63
+ | `validate` | `node <skill-dir>/scripts/qa-tool.mjs analysis validate <instance.json> <schema.json>` | `{valid: bool, errors: [...]}`; exit 1 when invalid |
64
+ | `classify` | `node <skill-dir>/scripts/qa-tool.mjs analysis classify "<error message>" [--http-status N]` | `{classification, confidence, reason}` from the shared taxonomy |
65
+ | `context` | `node <skill-dir>/scripts/qa-tool.mjs analysis context [--root DIR] [--path .qa/context.md]` | The parsed, schema-validated project context as JSON |
66
+ | `report-html` | `node <skill-dir>/scripts/qa-tool.mjs analysis report-html <result.json> [--out report.html]` | The result rendered as one self-contained HTML report — every required field, footer included. Run it instead of writing HTML |
67
+ | `branding` | `node <skill-dir>/scripts/qa-tool.mjs analysis branding --format markdown\|html\|text` | The exact attribution footer bytes for a **rendered** report (never for a JSON artifact) |
68
+
69
+ ## 3. Diagnostic engine — `qa-tool.mjs diagnostics`
68
70
 
69
71
  One engine, consumed by the diagnostic skills. Reasoning lives here once.
70
72
 
71
73
  | Subcommand | Invocation | Returns |
72
74
  | --- | --- | --- |
73
- | `diagnose` | `python3 <skill-dir>/scripts/qa_tool.py diagnostics diagnose --execution-result <path> [--analysis-result <path>]` | `{entries: [...], timeline: [...], recommendations: [...]}` |
74
- | `plan-repairs` | `python3 <skill-dir>/scripts/qa_tool.py diagnostics plan-repairs --diagnosis <path>` | `{plans: [...]}` — one plan per entry, escalations included |
75
- | `summarize` | `python3 <skill-dir>/scripts/qa_tool.py diagnostics summarize --execution-result <path> --diagnosis <path>` | `{totals, byClassification, topPriority, releaseReadiness}` |
76
- | `report` | `python3 <skill-dir>/scripts/qa_tool.py diagnostics report --execution-result <path> [--analysis-result <path>]` | `{diagnosis, plans, summary}` — all three in one call |
75
+ | `diagnose` | `node <skill-dir>/scripts/qa-tool.mjs diagnostics diagnose --execution-result <path> [--analysis-result <path>]` | `{entries: [...], timeline: [...], recommendations: [...]}` |
76
+ | `plan-repairs` | `node <skill-dir>/scripts/qa-tool.mjs diagnostics plan-repairs --diagnosis <path>` | `{plans: [...]}` — one plan per entry, escalations included |
77
+ | `summarize` | `node <skill-dir>/scripts/qa-tool.mjs diagnostics summarize --execution-result <path> --diagnosis <path>` | `{totals, byClassification, topPriority, releaseReadiness}` |
78
+ | `report` | `node <skill-dir>/scripts/qa-tool.mjs diagnostics report --execution-result <path> [--analysis-result <path>]` | `{diagnosis, plans, summary}` — all three in one call |
77
79
 
78
80
  **Inputs.** `--execution-result` takes a `qa-run` execution result, or the minimal
79
81
  subset (`tests` counts plus `executed[]` entries carrying `status`).
@@ -101,7 +103,7 @@ contract names:
101
103
 
102
104
  This mapping is not busywork: the strictness is what stops a skill from shipping a
103
105
  result whose shape nobody checked. Validate before completion —
104
- `python3 <skill-dir>/scripts/qa_tool.py analysis validate <result.json> <schema.json>` — and fix the
106
+ `node <skill-dir>/scripts/qa-tool.mjs analysis validate <result.json> <schema.json>` — and fix the
105
107
  result, never the claim.
106
108
 
107
109
  ## 4. Framework adapters
@@ -111,11 +113,11 @@ a `--framework` flag.
111
113
 
112
114
  | Adapter | Invocation | Returns |
113
115
  | --- | --- | --- |
114
- | Playwright report | `python3 <skill-dir>/scripts/qa_tool.py playwright report <results.json>` | The same `{tests, executed}` shape as `junit` |
115
- | Playwright trace | `python3 <skill-dir>/scripts/qa_tool.py playwright trace <trace.zip>` | Actions, console/network counts, errors, classification |
116
+ | Playwright report | `node <skill-dir>/scripts/qa-tool.mjs playwright report <results.json>` | The same `{tests, executed}` shape as `junit` |
117
+ | Playwright trace | `node <skill-dir>/scripts/qa-tool.mjs playwright trace <trace.zip>` | Actions, console/network counts, errors, classification |
116
118
 
117
119
  For Selenium, Cypress, and WebdriverIO, normalize through
118
- `qa_tool.py analysis junit` — those adapters have no richer artifact than JUnit, and
120
+ `qa-tool.mjs analysis junit` — those adapters have no richer artifact than JUnit, and
119
121
  the skill says so rather than implying trace-grade depth.
120
122
 
121
123
  ## 5. Reporting what ran
@@ -33,7 +33,7 @@ Any skill that reaches a conclusion — a detected fact, a chosen strategy, a cl
33
33
  supports, the HTML report is generated from that artifact:
34
34
 
35
35
  ```bash
36
- python3 <SKILL_DIR>/scripts/qa_tool.py analysis report-html <result.json> --out <report.html>
36
+ node <SKILL_DIR>/scripts/qa-tool.mjs analysis report-html <result.json> --out <report.html>
37
37
  ```
38
38
 
39
39
  The reason is not tidiness. A hand-written report is a second, lossy copy of the
@@ -48,7 +48,7 @@ now**, **what should happen instead**, how to reproduce it, the fix direction, a
48
48
  every evidence entry — plus the attribution footer, in one self-contained file with
49
49
  no external assets.
50
50
 
51
- Run `qa_tool.py analysis report-html --help` for the supported contracts. For an
51
+ Run `qa-tool.mjs analysis report-html --help` for the supported contracts. For an
52
52
  artifact it does not support, write the HTML by hand from the contract's fields —
53
53
  and render **every** field a reader needs, in that order.
54
54
 
@@ -60,9 +60,9 @@ bundled analysis toolkit, so every report is identical and a wording change is a
60
60
  one-file edit. `report-html` already embeds it; for any other rendering, ask for it:
61
61
 
62
62
  ```bash
63
- python3 <SKILL_DIR>/scripts/qa_tool.py analysis branding --format markdown
64
- python3 <SKILL_DIR>/scripts/qa_tool.py analysis branding --format html
65
- python3 <SKILL_DIR>/scripts/qa_tool.py analysis branding --format text
63
+ node <SKILL_DIR>/scripts/qa-tool.mjs analysis branding --format markdown
64
+ node <SKILL_DIR>/scripts/qa-tool.mjs analysis branding --format html
65
+ node <SKILL_DIR>/scripts/qa-tool.mjs analysis branding --format text
66
66
  ```
67
67
 
68
68
  Append the output as the last element of the rendered document: `html` inside
@@ -0,0 +1,162 @@
1
+ // synced-from: shared/tooling/qa-tool.mjs — do not edit; edit the source and run: node scripts/sync-shared.mjs --write
2
+ // The launcher every skill invokes to reach the deterministic engine.
3
+ //
4
+ // A skill's SKILL.md documents exactly one command shape:
5
+ //
6
+ // node <SKILL_DIR>/scripts/qa-tool.mjs <tool> <subcommand> [args]
7
+ //
8
+ // and this file finds the engine, wherever it happens to be. That indirection
9
+ // exists because the pack is installed three different ways and the engine lands in
10
+ // a different place each time:
11
+ //
12
+ // 1. `qa install` bundles the engine into the skill, at ./lib/. Offline, fastest,
13
+ // and pinned to the version that was installed.
14
+ // 2. `npx skills add <owner>/<repo>` — or any generic file copier — copies the
15
+ // skill directory out of git and bundles nothing. The engine is then resolved
16
+ // from node_modules if the project happens to depend on the pack.
17
+ // 3. Neither: fall back to `npx qa-engineer`, which fetches the published package
18
+ // on first use and is served from the npm cache afterwards.
19
+ //
20
+ // Because this file is committed rather than generated, path 2 works at all — which
21
+ // is what makes the pack installable by the wider Agent Skills ecosystem. The
22
+ // command a skill runs never changes; only where the engine came from does.
23
+ //
24
+ // Exit codes pass through unchanged: 0 success, 1 an invalid contract, 2 unreadable
25
+ // input or bad usage.
26
+ //
27
+ // No shebang: the synced copies carry a provenance marker on line one, which would
28
+ // sit above it and stop the kernel seeing it anyway. Every documented invocation is
29
+ // `node qa-tool.mjs …`, which needs none.
30
+
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+ import { spawnSync } from 'node:child_process';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const here = path.dirname(fileURLToPath(import.meta.url));
38
+
39
+ const USAGE = `usage: node qa-tool.mjs <tool> <subcommand> [args]
40
+
41
+ analysis parse artifacts, classify errors, validate contracts, diff-guard,
42
+ read .qa/context.md, render an HTML report, print the footer
43
+ diagnostics root cause, timeline, priority, repair plans, release readiness
44
+ playwright normalize a Playwright report or summarize a trace
45
+
46
+ --where print how the engine was resolved, and stop
47
+
48
+ examples:
49
+ node qa-tool.mjs analysis junit test-results/results.xml
50
+ node qa-tool.mjs analysis report-html qa-artifacts/explore-result.json --out report.html
51
+ node qa-tool.mjs diagnostics report --execution-result qa-artifacts/run.json
52
+ `;
53
+
54
+ /**
55
+ * Where the engine is, and how we found it.
56
+ *
57
+ * Ordered by cost: a bundled copy needs no resolution and no network, a shared copy
58
+ * needs one stat per ancestor, a node_modules copy needs no network, and npx needs both
59
+ * on first use. Reporting *which* one answered matters when a skill degrades — "the
60
+ * engine is missing" and "the engine is being fetched" are different problems.
61
+ *
62
+ * The shared lookup is what lets one skill directory serve a project, a workspace, and a
63
+ * machine-wide install without knowing which installed it. Walking up for
64
+ * `.qa-engineer/engine` finds a workspace install from a skill inside the repository,
65
+ * and finds a global install from a skill linked into an agent's user-level directory —
66
+ * `~/.claude/skills/qa-explore` walks up to `~`, where `~/.qa-engineer/engine` is.
67
+ */
68
+ function resolveEngine() {
69
+ const bundled = path.join(here, 'lib', 'bin', 'qa-engine.mjs');
70
+ if (fs.existsSync(bundled)) return { kind: 'bundled', command: [process.execPath, bundled] };
71
+
72
+ for (const root of candidateSharedRoots()) {
73
+ const shared = path.join(root, 'bin', 'qa-engine.mjs');
74
+ if (fs.existsSync(shared)) return { kind: 'shared', command: [process.execPath, shared] };
75
+ }
76
+
77
+ for (const base of candidateModuleRoots()) {
78
+ const installed = path.join(base, 'qa-engineer', 'packages', 'engine', 'bin', 'qa-engine.mjs');
79
+ if (fs.existsSync(installed)) {
80
+ return { kind: 'node_modules', command: [process.execPath, installed] };
81
+ }
82
+ }
83
+
84
+ return {
85
+ kind: 'npx',
86
+ command: [npxCommand(), '--yes', 'qa-engineer', 'engine'],
87
+ };
88
+ }
89
+
90
+ /** Shared engine directories worth checking, most specific first. */
91
+ function candidateSharedRoots() {
92
+ const roots = [];
93
+
94
+ // An explicit home wins over anything discovered, so a user who moved the install can
95
+ // rely on it rather than on whatever the walk happens to find first.
96
+ const override = process.env.QA_ENGINEER_HOME;
97
+ if (override && override.trim()) roots.push(path.join(path.resolve(override.trim()), 'engine'));
98
+
99
+ let dir = here;
100
+ for (let depth = 0; depth < 12; depth += 1) {
101
+ roots.push(path.join(dir, '.qa-engineer', 'engine'));
102
+ const parent = path.dirname(dir);
103
+ if (parent === dir) break;
104
+ dir = parent;
105
+ }
106
+
107
+ // The default machine home, for the case where the skill lives outside it entirely.
108
+ const home = os.homedir();
109
+ if (home) roots.push(path.join(home, '.qa-engineer', 'engine'));
110
+
111
+ return roots;
112
+ }
113
+
114
+ /** node_modules directories worth checking, nearest first. */
115
+ function candidateModuleRoots() {
116
+ const roots = [];
117
+ let dir = here;
118
+ for (let depth = 0; depth < 12; depth += 1) {
119
+ roots.push(path.join(dir, 'node_modules'));
120
+ const parent = path.dirname(dir);
121
+ if (parent === dir) break;
122
+ dir = parent;
123
+ }
124
+ if (process.cwd() !== here) roots.push(path.join(process.cwd(), 'node_modules'));
125
+ return roots;
126
+ }
127
+
128
+ // `npx` is a shell script on POSIX and a .cmd shim on Windows; spawnSync needs the
129
+ // exact name, and `shell: true` would put user-supplied arguments through a shell.
130
+ function npxCommand() {
131
+ return process.platform === 'win32' ? 'npx.cmd' : 'npx';
132
+ }
133
+
134
+ function main(argv) {
135
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
136
+ process.stdout.write(USAGE);
137
+ return argv.length === 0 ? 2 : 0;
138
+ }
139
+
140
+ const engine = resolveEngine();
141
+
142
+ if (argv[0] === '--where') {
143
+ process.stdout.write(`${JSON.stringify({ resolved: engine.kind, command: engine.command }, null, 2)}\n`);
144
+ return 0;
145
+ }
146
+
147
+ const [program, ...prefix] = engine.command;
148
+ const run = spawnSync(program, [...prefix, ...argv], { stdio: 'inherit' });
149
+
150
+ if (run.error) {
151
+ // Say which path was tried and what to do, because a skill's fallback prose
152
+ // cannot diagnose this and the user is the one who has to fix it.
153
+ const advice = engine.kind === 'npx'
154
+ ? 'the engine is not bundled and npx is unavailable — run `npx qa-engineer install` in this project, or install Node 18+'
155
+ : `could not execute ${program}`;
156
+ process.stderr.write(`qa-tool: ${advice}\n${run.error.message}\n`);
157
+ return 2;
158
+ }
159
+ return run.status ?? 2;
160
+ }
161
+
162
+ process.exitCode = main(process.argv.slice(2));
@@ -1,11 +0,0 @@
1
- """qa_analysis — the pack's deterministic analysis core.
2
-
3
- Framework-agnostic tooling shared by every diagnostic skill: redaction,
4
- the evidence and finding model, the failure taxonomy, artifact discovery,
5
- format parsers (JUnit, HAR), contract validation, and the diff guard.
6
-
7
- Standard library only. Knows nothing about any test framework; framework
8
- specifics live in shared/frameworks/<name>/lib and depend on this package.
9
- """
10
-
11
- __version__ = "0.1.0"
@@ -1,175 +0,0 @@
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 parsing
18
- hazard — anywhere else.
19
-
20
- | Branded | Not branded |
21
- | --- | --- |
22
- | HTML reports | JSON and YAML artifacts (`qa-artifacts/*.json`) |
23
- | PDF reports | CLI stdout, progress output, `--json` output |
24
- | Rendered Markdown reports meant for people | Markdown written for machine consumption |
25
- | Generated documentation | Log files, API responses |
26
- | Evaluation and audit report renderings | The system under test, and anything in a user's own source tree |
27
-
28
- The rule behind the table: **if a program will parse it, it gets no footer.** A
29
- contract artifact is an interface, and appending prose to an interface breaks it.
30
-
31
- ## Formats
32
-
33
- - `footer_html()` — a `<footer>` element with inline styling (so a standalone
34
- report file needs no external stylesheet) and the author's site as a link that
35
- opens in a new tab with `rel="noopener noreferrer"`.
36
- - `footer_markdown()` — a thematic break, the four lines, the site as a link.
37
- - `footer_text()` — rule-separated plain text for PDF writers and terminals-of-last-resort.
38
- A PDF library that supports hyperlinks should link the URL; one that does not
39
- renders this as-is, which is why the URL appears in full rather than as anchor text.
40
-
41
- Standard library only, like the rest of `qa_analysis`.
42
- """
43
-
44
- import html
45
- import json
46
- import pathlib
47
-
48
- _METADATA_PATH = pathlib.Path(__file__).resolve().parent / "branding.json"
49
-
50
- # The visual width of the plain-text rules. Wide enough to frame the four lines,
51
- # narrow enough to survive an 80-column terminal or a PDF margin.
52
- _RULE_WIDTH = 60
53
-
54
- _ALLOWED_SCHEMES = ("https://", "http://")
55
-
56
-
57
- class BrandingError(ValueError):
58
- """Raised when the branding metadata is missing or unusable."""
59
-
60
-
61
- def metadata():
62
- """The branding metadata, as a dict. Read fresh so a change needs no reinstall."""
63
- try:
64
- with open(_METADATA_PATH, "r", encoding="utf-8") as handle:
65
- data = json.load(handle)
66
- except (OSError, json.JSONDecodeError) as exc:
67
- raise BrandingError(f"could not read branding metadata at {_METADATA_PATH}: {exc}") from exc
68
-
69
- required = ("projectName", "tagline", "author", "website", "attributionPrefix", "authorPrefix")
70
- missing = [key for key in required if not data.get(key)]
71
- if missing:
72
- raise BrandingError(f"branding metadata is missing: {', '.join(missing)}")
73
- if not data["website"].startswith(_ALLOWED_SCHEMES):
74
- # A footer is rendered into HTML, so a non-http scheme here would be an
75
- # injection vector rather than a typo.
76
- raise BrandingError(
77
- f"branding website must start with http:// or https://, got {data['website']!r}"
78
- )
79
- return data
80
-
81
-
82
- def _lines(data):
83
- """The four footer lines, in order, as plain strings."""
84
- return [
85
- f"{data['attributionPrefix']} {data['projectName']}",
86
- data["tagline"],
87
- f"{data['authorPrefix']} {data['author']}",
88
- data["website"],
89
- ]
90
-
91
-
92
- def footer_text(width=_RULE_WIDTH):
93
- """Rule-separated plain text. Used for PDF and any non-markup rendering."""
94
- data = metadata()
95
- rule = "-" * width
96
- body = "\n".join(line.center(width).rstrip() for line in _lines(data))
97
- return f"{rule}\n{body}\n{rule}\n"
98
-
99
-
100
- def footer_markdown():
101
- """A thematic break and the four lines, with the site as a link."""
102
- data = metadata()
103
- return (
104
- "---\n\n"
105
- f"<sub>{data['attributionPrefix']} **{data['projectName']}** — "
106
- f"{data['tagline']}<br>\n"
107
- f"{data['authorPrefix']} "
108
- f"[{data['author']}]({data['website']})</sub>\n"
109
- )
110
-
111
-
112
- def footer_html(class_name="qa-pack-attribution"):
113
- """A self-contained `<footer>`: inline styles, muted, centered, small.
114
-
115
- The author's site opens in a new tab. `rel="noopener noreferrer"` is not
116
- optional — a report may be opened from anywhere, and a new tab that can reach
117
- back into `window.opener` is a real hazard.
118
- """
119
- data = metadata()
120
- site = html.escape(data["website"], quote=True)
121
- project = html.escape(data["projectName"])
122
- tagline = html.escape(data["tagline"])
123
- author = html.escape(data["author"])
124
- attribution_prefix = html.escape(data["attributionPrefix"])
125
- author_prefix = html.escape(data["authorPrefix"])
126
- css_class = html.escape(class_name, quote=True)
127
-
128
- return (
129
- f'<footer class="{css_class}" style="margin-top:2.5rem;padding-top:1rem;'
130
- 'border-top:1px solid rgba(128,128,128,0.25);font-size:0.75rem;line-height:1.6;'
131
- 'color:#6b7280;text-align:center;font-family:system-ui,-apple-system,'
132
- 'Segoe UI,Roboto,sans-serif;">\n'
133
- f' <div>{attribution_prefix} <strong>{project}</strong></div>\n'
134
- f' <div>{tagline}</div>\n'
135
- f' <div>{author_prefix} '
136
- f'<a href="{site}" target="_blank" rel="noopener noreferrer" '
137
- 'style="color:inherit;text-decoration:underline;">'
138
- f'{author}</a></div>\n'
139
- '</footer>\n'
140
- )
141
-
142
-
143
- _RENDERERS = {
144
- "html": footer_html,
145
- "markdown": footer_markdown,
146
- "md": footer_markdown,
147
- "text": footer_text,
148
- "txt": footer_text,
149
- "pdf": footer_text, # what a PDF writer embeds when it cannot render markup
150
- }
151
-
152
- FORMATS = ("html", "markdown", "text")
153
-
154
-
155
- def footer(fmt="text"):
156
- """Render the footer in `fmt`. Raises BrandingError on an unknown format."""
157
- renderer = _RENDERERS.get(fmt.lower())
158
- if renderer is None:
159
- raise BrandingError(
160
- f"unknown branding format {fmt!r}; expected one of: {', '.join(FORMATS)}"
161
- )
162
- return renderer()
163
-
164
-
165
- def append_to(document, fmt="markdown"):
166
- """Return `document` with the footer appended, idempotently.
167
-
168
- Idempotence matters: a report assembled in stages, or regenerated over its own
169
- output, must not accumulate footers.
170
- """
171
- rendered = footer(fmt)
172
- if rendered.strip() and rendered.strip() in document:
173
- return document
174
- separator = "" if document.endswith("\n") else "\n"
175
- return f"{document}{separator}\n{rendered}"
@@ -1,144 +0,0 @@
1
- """Unified CLI for the analysis core.
2
-
3
- Every analyzer is reachable as a subcommand that writes JSON to stdout, so the
4
- toolkit is usable from a shell, from CI, and (once bundled) by a skill. Exit
5
- code 0 on success, 2 on a malformed artifact or bad usage.
6
-
7
- Usage:
8
- python -m qa_analysis.cli junit <path>
9
- python -m qa_analysis.cli har <path> [--slow-ms N]
10
- python -m qa_analysis.cli discover [--root DIR] [--path P ...]
11
- python -m qa_analysis.cli diff-guard <diff-file>
12
- python -m qa_analysis.cli redact <file>
13
- python -m qa_analysis.cli validate <instance.json> <schema.json>
14
- python -m qa_analysis.cli classify "<error message>" [--http-status N]
15
- python -m qa_analysis.cli context [--root DIR] [--path .qa/context.md]
16
- python -m qa_analysis.cli branding [--format html|markdown|text]
17
- python -m qa_analysis.cli report-html <result.json> [--out report.html]
18
- """
19
-
20
- import argparse
21
- import json
22
- import pathlib
23
- import sys
24
-
25
- from . import junit, har, discovery, diff_guard, redaction, contracts, taxonomy
26
- from . import branding as branding_module
27
- from . import report_html as report_html_module
28
- from . import context as context_module
29
- from .context import MalformedContext
30
- from .junit import MalformedArtifact
31
-
32
- _SCHEMA_DIR_CANDIDATES = (
33
- # Repository layout: shared/analysis/lib/qa_analysis -> shared/analysis/schemas
34
- pathlib.Path(__file__).resolve().parents[2] / "schemas",
35
- # Bundled layout: package data travels with the package.
36
- pathlib.Path(__file__).resolve().parent / "schemas",
37
- )
38
-
39
-
40
- def _context_schema():
41
- """The context contract, in whichever layout this package is running from."""
42
- for base in _SCHEMA_DIR_CANDIDATES:
43
- candidate = base / "context.schema.json"
44
- if candidate.is_file():
45
- return contracts.load_schema(candidate)
46
- return None
47
-
48
-
49
- def _emit(obj):
50
- json.dump(obj, sys.stdout, indent=2, sort_keys=True)
51
- sys.stdout.write("\n")
52
-
53
-
54
- def main(argv=None):
55
- parser = argparse.ArgumentParser(prog="qa-analysis", description="Deterministic QA analysis toolkit")
56
- sub = parser.add_subparsers(dest="command", required=True)
57
-
58
- p = sub.add_parser("junit"); p.add_argument("path")
59
- p = sub.add_parser("har"); p.add_argument("path"); p.add_argument("--slow-ms", type=int, default=1000)
60
- p = sub.add_parser("discover"); p.add_argument("--root", default="."); p.add_argument("--path", action="append", default=[])
61
- p = sub.add_parser("diff-guard"); p.add_argument("path")
62
- p = sub.add_parser("redact"); p.add_argument("path")
63
- p = sub.add_parser("validate"); p.add_argument("instance"); p.add_argument("schema")
64
- p = sub.add_parser("classify"); p.add_argument("message"); p.add_argument("--http-status", type=int, default=None)
65
- p = sub.add_parser("context"); p.add_argument("--root", default="."); p.add_argument("--path", default=None)
66
- p = sub.add_parser("report-html")
67
- p.add_argument("path"); p.add_argument("--out"); p.add_argument("--title")
68
- p = sub.add_parser("branding")
69
- p.add_argument("--format", default="text", choices=list(branding_module.FORMATS) + ["pdf", "md", "txt"])
70
- p.add_argument("--metadata", action="store_true", help="emit the branding metadata as JSON instead")
71
-
72
- args = parser.parse_args(argv)
73
-
74
- try:
75
- if args.command == "junit":
76
- _emit(junit.parse_junit(args.path))
77
- elif args.command == "har":
78
- _emit(har.parse_har(args.path, slow_ms=args.slow_ms))
79
- elif args.command == "discover":
80
- result = discovery.discover(root=args.root, explicit=args.path or None)
81
- _emit({k: [a.to_dict() if hasattr(a, "to_dict") else a for a in v] for k, v in result.items()})
82
- elif args.command == "diff-guard":
83
- with open(args.path, "r", encoding="utf-8") as handle:
84
- issues = diff_guard.check_diff(handle.read())
85
- _emit({"issues": issues, "safe": not any(i["severity"] == "high" for i in issues)})
86
- elif args.command == "redact":
87
- with open(args.path, "r", encoding="utf-8") as handle:
88
- sys.stdout.write(redaction.redact_text(handle.read()))
89
- elif args.command == "validate":
90
- with open(args.instance, "r", encoding="utf-8") as handle:
91
- instance = json.load(handle)
92
- ok, errors = contracts.validate(instance, contracts.load_schema(args.schema))
93
- _emit({"valid": ok, "errors": errors})
94
- return 0 if ok else 1
95
- elif args.command == "classify":
96
- classification, confidence, reason = taxonomy.classify(args.message, http_status=args.http_status)
97
- _emit({"classification": classification, "confidence": confidence, "reason": reason})
98
- elif args.command == "context":
99
- path = args.path or str(pathlib.Path(args.root) / ".qa" / "context.md")
100
- schema = _context_schema()
101
- parsed = context_module.parse_file(path, schema=schema)
102
- _emit({
103
- "path": path,
104
- "context": parsed["context"],
105
- "valid": parsed["valid"],
106
- "errors": parsed["errors"],
107
- "schemaChecked": schema is not None,
108
- })
109
- return 0 if parsed["valid"] else 1
110
- elif args.command == "branding":
111
- # Written to stdout verbatim, not as JSON: the caller pastes this into
112
- # a rendered report, so it must be the exact bytes to embed.
113
- if args.metadata:
114
- _emit(branding_module.metadata())
115
- else:
116
- sys.stdout.write(branding_module.footer(args.format))
117
- elif args.command == "report-html":
118
- document = report_html_module.render_file(args.path, title=args.title)
119
- if args.out:
120
- with open(args.out, "w", encoding="utf-8") as handle:
121
- handle.write(document)
122
- _emit({"written": args.out, "bytes": len(document)})
123
- else:
124
- sys.stdout.write(document)
125
- except report_html_module.ReportError as exc:
126
- _emit({"error": "report-error", "detail": str(exc)})
127
- return 2
128
- except branding_module.BrandingError as exc:
129
- _emit({"error": "branding-error", "detail": str(exc)})
130
- return 2
131
- except MalformedContext as exc:
132
- _emit({"error": "malformed-context", "detail": str(exc)})
133
- return 2
134
- except MalformedArtifact as exc:
135
- _emit({"error": "malformed-artifact", "detail": str(exc)})
136
- return 2
137
- except (OSError, json.JSONDecodeError) as exc:
138
- _emit({"error": "io-error", "detail": str(exc)})
139
- return 2
140
- return 0
141
-
142
-
143
- if __name__ == "__main__":
144
- sys.exit(main())