qa-engineer 0.9.1 → 0.9.2

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 (39) hide show
  1. package/COMPATIBILITY.md +25 -15
  2. package/README.md +20 -2
  3. package/package.json +4 -3
  4. package/packages/installer/lib/agents/registry.mjs +46 -0
  5. package/packages/installer/lib/commands/install.mjs +31 -4
  6. package/packages/installer/lib/core/config.mjs +1 -1
  7. package/packages/installer/lib/core/lockfile.mjs +1 -1
  8. package/packages/installer/lib/core/manifest.mjs +3 -0
  9. package/packages/installer/package.json +1 -1
  10. package/shared/analysis/lib/qa_analysis/cli.py +15 -0
  11. package/shared/analysis/lib/qa_analysis/har.py +32 -3
  12. package/shared/analysis/lib/qa_analysis/junit.py +25 -1
  13. package/shared/analysis/lib/qa_analysis/redaction.py +10 -2
  14. package/shared/analysis/lib/qa_analysis/report_html.py +781 -0
  15. package/skills/qa-api/references/deterministic-tooling.md +2 -0
  16. package/skills/qa-api/references/evidence-and-reporting.md +29 -4
  17. package/skills/qa-audit/references/deterministic-tooling.md +2 -0
  18. package/skills/qa-audit/references/evidence-and-reporting.md +29 -4
  19. package/skills/qa-debug/references/deterministic-tooling.md +2 -0
  20. package/skills/qa-debug/references/evidence-and-reporting.md +29 -4
  21. package/skills/qa-explore/SKILL.md +22 -5
  22. package/skills/qa-explore/contracts/explore-result.schema.json +26 -2
  23. package/skills/qa-explore/references/deterministic-tooling.md +130 -0
  24. package/skills/qa-explore/references/evidence-and-reporting.md +29 -4
  25. package/skills/qa-explore/references/report-pipeline.md +89 -8
  26. package/skills/qa-fix/references/deterministic-tooling.md +2 -0
  27. package/skills/qa-fix/references/evidence-and-reporting.md +29 -4
  28. package/skills/qa-flaky/references/deterministic-tooling.md +2 -0
  29. package/skills/qa-flaky/references/evidence-and-reporting.md +29 -4
  30. package/skills/qa-generate/references/evidence-and-reporting.md +29 -4
  31. package/skills/qa-init/references/deterministic-tooling.md +2 -0
  32. package/skills/qa-init/references/evidence-and-reporting.md +29 -4
  33. package/skills/qa-report/SKILL.md +3 -2
  34. package/skills/qa-report/references/deterministic-tooling.md +2 -0
  35. package/skills/qa-report/references/evidence-and-reporting.md +29 -4
  36. package/skills/qa-review/references/evidence-and-reporting.md +29 -4
  37. package/skills/qa-run/references/deterministic-tooling.md +2 -0
  38. package/skills/qa-run/references/evidence-and-reporting.md +29 -4
  39. package/packages/installer/lib/core/schema-validate.mjs +0 -142
package/COMPATIBILITY.md CHANGED
@@ -6,7 +6,14 @@ This document records which AI coding agents the pack targets, how each one disc
6
6
 
7
7
  ## Release compatibility
8
8
 
9
- No versions have been released yet. Beginning with the first tagged release, this section will carry a matrix of pack versions against tested agent versions and the Agent Skills specification revision each release was validated against.
9
+ | Pack version | Released | Discovery paths verified against agent docs | Live agent runs |
10
+ | --- | --- | --- | --- |
11
+ | 0.9.0, 0.9.1 | 2026-07-28 | Not systematically re-read for the release | Claude Code, Cursor (manual, by the maintainer) |
12
+ | 0.9.2 | 2026-07-28 | Yes — every host's own documentation, re-read on 2026-07-28 (sources in the matrix below) | Claude Code, Cursor (manual, by the maintainer) |
13
+
14
+ "Verified" in the third column means the discovery paths in the matrix below were read off each host's own documentation on that date and are asserted against the installer by `packages/installer/test/hosts.test.mjs`, which fails if the installer writes to a path that host does not document reading. It does **not** mean the pack was executed inside every host: that is the Tier 1 bar, and only Claude Code and Cursor have had a real run.
15
+
16
+ Documentation is re-read at each release. A test can pin the installer to a recorded claim; it cannot notice that a vendor moved a directory, so that check is a release step performed by a person.
10
17
 
11
18
  ## The standard this pack targets
12
19
 
@@ -14,23 +21,26 @@ Skills are authored against the open [Agent Skills specification](https://agents
14
21
 
15
22
  ## Target agent matrix
16
23
 
17
- Verified against official documentation as of July 2026. Entries marked *unverified* rely on secondary sources and will be confirmed before the relevant integration ships.
24
+ Each row's discovery paths were read off that host's own documentation on **2026-07-28** and are recorded in the installer's agent registry, where a test asserts the installer writes to one of them. The **bold** path is the one `qa install` targets.
25
+
26
+ | Agent | Paths this host reads (project) | Pack installs to | How you reach a skill | Source | Planned tier |
27
+ | --- | --- | --- | --- | --- | --- |
28
+ | Claude Code | `.claude/skills/` | **`.claude/skills/`** | `/qa-explore`, or describe the task | [docs](https://code.claude.com/docs/en/skills) | Tier 1 |
29
+ | OpenAI Codex CLI | `.agents/skills/` — at the working directory, its parent, and the repo root. There is **no** `.codex/skills/` | **`.agents/skills/`** | `$qa-explore`, or `/skills` to browse | [docs](https://developers.openai.com/codex/skills) | Tier 1 |
30
+ | OpenCode | `.agents/skills/`, `.opencode/skills/`, `.claude/skills/` | **`.agents/skills/`** | `/qa-explore` (generated command), or the agent's own `skill` tool | [docs](https://opencode.ai/docs/skills/) | Tier 1 |
31
+ | Cursor | `.agents/skills/`, `.cursor/skills/`, plus Claude and Codex directories | **`.agents/skills/`** | `/` in Agent chat, then pick the skill | [docs](https://cursor.com/docs/skills) | Tier 2 |
32
+ | Antigravity | `.agents/skills/` (default), `.agent/skills/` (legacy) | **`.agents/skills/`** | describe the task — skills auto-activate | [docs](https://antigravity.google/docs/skills) | Tier 2 |
33
+ | GitHub Copilot | `.agents/skills/`, `.github/skills/` | **`.agents/skills/`** | `/qa-explore` in Copilot Chat (generated prompt file) | [docs](https://docs.github.com/en/copilot) | Tier 2 |
34
+ | Gemini CLI | `.agents/skills/`, `.gemini/skills/` | **`.agents/skills/`** | `/qa-explore` (generated command) | [docs](https://google-gemini.github.io/gemini-cli/) | Tier 2 |
35
+ | Kimi (Agent Skills copy) / other spec-compliant hosts | `.agents/skills/` | **`.agents/skills/`** | product's own surface | [spec](https://agentskills.io/specification) | Tier 2 |
18
36
 
19
- | Agent | Native `SKILL.md` support | Skill discovery path the pack targets | Invocation surface | Planned tier |
20
- | --- | --- | --- | --- | --- |
21
- | Claude Code | Yes (format originator) | `.claude/skills/` (project), plugins | `/skill-name`, auto-activation | Tier 1 |
22
- | OpenAI Codex CLI | Yes | `.agents/skills/` | `$skill-name` mention, `/skills` menu, auto-activation | Tier 1 |
23
- | OpenCode | Yes | `.agents/skills/`, `.opencode/skills/` | native `skill` tool (auto); slash via generated commands | Tier 1 |
24
- | Cursor | Yes (2.4+) | `.agents/skills/`, `.cursor/skills/` | `/skill-name`, auto-activation | Tier 2 |
25
- | GitHub Copilot | Yes (VS Code, JetBrains, CLI, cloud) | `.agents/skills/`, `.github/skills/` | `/skill-name` in VS Code; auto-activation elsewhere | Tier 2 |
26
- | Gemini CLI | Yes | `.agents/skills/`, `.gemini/skills/` | auto-activation; slash via generated commands | Tier 2 |
27
- | Antigravity | Yes | `.agents/skills/` | auto-activation; slash via workflows (*path unverified*) | Tier 2 |
28
- | Kimi / other Agent Skills hosts | Yes (copy) | `.agents/skills/` (or product-specific path) | auto-activation / product slash | Tier 2 |
37
+ Three facts follow from that table and shape the design:
29
38
 
30
- Two facts shape the integration design:
39
+ 1. **One path serves seven of the eight hosts.** Everything except Claude Code reads `.agents/skills/`, so `qa install` writes the same canonical skill set there — copies, never conversions. Claude Code gets `.claude/skills/` as well, and a project where only Claude Code is detected still receives the shared path, so opening the same repository in Cursor or Codex tomorrow needs no reinstall.
40
+ 2. **Auto-activation is the only channel every host shares.** Codex takes `$qa-explore`, Cursor matches on `/`, OpenCode's agent calls a `skill` tool. `qa install` prints the convention for the host it detected, because a user who types the wrong one has no way to tell a wrong keystroke from a failed install.
41
+ 3. **Slash ergonomics on Gemini CLI, OpenCode, and Copilot need a thin generated wrapper.** Those wrappers are rendered from skill frontmatter alone (ADR-0002), capped at 15 lines, and carry no knowledge. Antigravity's workflow directory (`.agents/workflows/`) is *not* documented by Google, so those wrappers are generated only on explicit `--agent antigravity`; skills themselves work there without them.
31
42
 
32
- 1. Six of the seven primary agents read `.agents/skills/` directly; Claude Code additionally requires `.claude/skills/`. A single canonical skill set therefore covers every target with copies, not conversions. Additional hosts (Kimi and peers) use the same copy path see [docs/installation/other-agents.md](docs/installation/other-agents.md).
33
- 2. Description-driven auto-activation is the only invocation channel available on all primary agents. Slash-command ergonomics on Gemini CLI, OpenCode, and Antigravity require thin generated wrappers from the installer (`qa install`).
43
+ **Antigravity detection is deliberately conservative.** Its skills live at `.agents/skills/`, which is also the path this installer creates for every host so detecting Antigravity from `.agents/` would mean detecting it because the pack had run once before. An earlier release did exactly that and reported a host that was never there. Skills work in Antigravity either way; only the optional workflow wrappers need `--agent antigravity`.
34
44
 
35
45
  ## Support tiers
36
46
 
package/README.md CHANGED
@@ -256,9 +256,27 @@ qa-artifacts/
256
256
 
257
257
  Most teams **do** commit `.agents/skills/` and `qa-lock.json`, so everyone on the team gets the same skills.
258
258
 
259
- ## All twelve commands
259
+ ## How you type a command in your assistant
260
+
261
+ Type these in your AI assistant's **chat**, not the terminal. The keystroke differs
262
+ by assistant, and typing the wrong one looks exactly like a failed install — so
263
+ `qa install` prints the right one for whatever it detected. For reference:
260
264
 
261
- Type these in your AI assistant's chat, not the terminal.
265
+ | Assistant | How to reach a skill |
266
+ | --- | --- |
267
+ | Claude Code | `/qa-explore`, or just describe the task |
268
+ | Cursor | type `/` in Agent chat, then pick the skill from the list |
269
+ | OpenAI Codex CLI | `$qa-explore` — or `/skills` to browse them |
270
+ | OpenCode | `/qa-explore`, or describe the task and the agent loads the skill itself |
271
+ | Antigravity | describe the task; skills activate on their own |
272
+ | GitHub Copilot / Gemini CLI | `/qa-explore` |
273
+
274
+ Every one of them also activates a skill on its own when your request matches — you
275
+ never *have* to remember a command. "Find bugs on my login page at localhost:3000"
276
+ reaches `/qa-explore` in all of them. Full details and the source for each path:
277
+ [COMPATIBILITY.md](COMPATIBILITY.md).
278
+
279
+ ## All twelve commands
262
280
 
263
281
  | Command | What it does | Changes your files? |
264
282
  | --- | --- | --- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qa-engineer",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "A vendor-neutral Agent Skills pack that teaches AI coding agents to work like senior QA automation engineers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,18 +48,19 @@
48
48
  "!**/tests"
49
49
  ],
50
50
  "scripts": {
51
- "qa": "node packages/installer/bin/qa.mjs",
52
51
  "eval:live": "python3 tests/evals/run_live.py --baseline tests/evals/baselines/reference.json",
53
52
  "prepublishOnly": "npm run validate:release && npm run validate:doc-claims && npm run validate:docs-commands && npm test && npm run validate:python && npm run validate:evals",
53
+ "qa": "node packages/installer/bin/qa.mjs",
54
54
  "release:checksums": "node scripts/release/release-notes.mjs --checksums",
55
55
  "release:notes": "node scripts/release/release-notes.mjs",
56
- "test": "node --test packages/installer/test/*.test.mjs",
56
+ "test": "node --test packages/installer/test/*.test.mjs packages/engine/test/*.test.mjs",
57
57
  "validate:architecture": "node scripts/check-architecture-fitness.mjs",
58
58
  "validate:branding": "node scripts/check-branding.mjs",
59
59
  "validate:bundle": "python3 scripts/bundle_python.py --check",
60
60
  "validate:context": "cd shared/analysis/lib && python3 -m qa_analysis.cli context --path tests/fixtures/valid-context.md",
61
61
  "validate:doc-claims": "node scripts/check-doc-claims.mjs",
62
62
  "validate:docs-commands": "node scripts/check-docs-commands.mjs",
63
+ "validate:engine-parity": "node scripts/check-engine-parity.mjs",
63
64
  "validate:evals": "python3 tests/evals/run_evals.py",
64
65
  "validate:keywords": "node scripts/check-keywords.mjs",
65
66
  "validate:knowledge": "node scripts/check-knowledge.mjs",
@@ -14,9 +14,18 @@ import { SHARED_SKILLS_DIR, CLAUDE_SKILLS_DIR } from '../constants.mjs';
14
14
  * @property {string} skillsDir project-relative skills discovery path
15
15
  * @property {string|null} wrapperFormat wrappers.mjs renderer key, or null
16
16
  * @property {string|null} wrapperDir where wrappers are written, or null
17
+ * @property {readonly string[]} discovery every project path this host reads skills from
18
+ * @property {string} docs the primary documentation the discovery list was read from
19
+ * @property {string} invoke how a user reaches a skill in this host, in their words
17
20
  * @property {(root:string)=>boolean} detect
18
21
  */
19
22
 
23
+ // `discovery` is what makes an install correct rather than hopeful: writing skills
24
+ // to a path the host does not read produces a silent no-op — the files are there,
25
+ // the agent never sees them, and the user concludes the pack does not work. Each
26
+ // list below was read off that host's own documentation on 2026-07-28 (`docs`), and
27
+ // a test asserts `skillsDir` is a member of it. See COMPATIBILITY.md.
28
+
20
29
  /** @type {AgentDef[]} */
21
30
  export const AGENTS = Object.freeze([
22
31
  {
@@ -26,6 +35,9 @@ export const AGENTS = Object.freeze([
26
35
  skillsDir: CLAUDE_SKILLS_DIR,
27
36
  wrapperFormat: null,
28
37
  wrapperDir: null,
38
+ discovery: Object.freeze(['.claude/skills']),
39
+ docs: 'https://code.claude.com/docs/en/skills',
40
+ invoke: 'type /qa-explore, or just describe the task — skills auto-activate',
29
41
  detect: (root) =>
30
42
  fs.existsSync(path.join(root, '.claude')) ||
31
43
  fs.existsSync(path.join(root, 'CLAUDE.md')),
@@ -35,6 +47,10 @@ export const AGENTS = Object.freeze([
35
47
  name: 'Cursor',
36
48
  tier: 2,
37
49
  skillsDir: SHARED_SKILLS_DIR,
50
+ // Cursor reads both, and additionally loads Claude and Codex directories.
51
+ discovery: Object.freeze(['.agents/skills', '.cursor/skills']),
52
+ docs: 'https://cursor.com/docs/skills',
53
+ invoke: 'type / in Agent chat and pick qa-explore',
38
54
  wrapperFormat: null,
39
55
  wrapperDir: null,
40
56
  detect: (root) =>
@@ -46,6 +62,12 @@ export const AGENTS = Object.freeze([
46
62
  name: 'OpenAI Codex CLI',
47
63
  tier: 1,
48
64
  skillsDir: SHARED_SKILLS_DIR,
65
+ // Codex documents `.agents/skills` exclusively — at the cwd, the parent, and
66
+ // the repository root. There is no `.codex/skills`, so `.codex/` is only ever
67
+ // a detection marker here, never a target.
68
+ discovery: Object.freeze(['.agents/skills']),
69
+ docs: 'https://developers.openai.com/codex/skills',
70
+ invoke: 'type $qa-explore, or /skills to browse them',
49
71
  wrapperFormat: null,
50
72
  wrapperDir: null,
51
73
  detect: (root) =>
@@ -57,7 +79,12 @@ export const AGENTS = Object.freeze([
57
79
  name: 'OpenCode',
58
80
  tier: 1,
59
81
  skillsDir: SHARED_SKILLS_DIR,
82
+ discovery: Object.freeze(['.agents/skills', '.opencode/skills', '.claude/skills']),
83
+ docs: 'https://opencode.ai/docs/skills/',
84
+ invoke: 'type /qa-explore, or describe the task — the agent loads skills itself',
60
85
  wrapperFormat: 'command-md',
86
+ // Plural, per https://opencode.ai/docs/commands/ — global is
87
+ // ~/.config/opencode/commands/, per-project .opencode/commands/.
61
88
  wrapperDir: '.opencode/commands',
62
89
  detect: (root) => fs.existsSync(path.join(root, '.opencode')),
63
90
  },
@@ -66,6 +93,9 @@ export const AGENTS = Object.freeze([
66
93
  name: 'Gemini CLI',
67
94
  tier: 2,
68
95
  skillsDir: SHARED_SKILLS_DIR,
96
+ discovery: Object.freeze(['.agents/skills', '.gemini/skills']),
97
+ docs: 'https://google-gemini.github.io/gemini-cli/',
98
+ invoke: 'type /qa-explore (generated command), or describe the task',
69
99
  wrapperFormat: 'command-toml',
70
100
  wrapperDir: '.gemini/commands',
71
101
  detect: (root) => fs.existsSync(path.join(root, '.gemini')),
@@ -75,6 +105,9 @@ export const AGENTS = Object.freeze([
75
105
  name: 'GitHub Copilot',
76
106
  tier: 2,
77
107
  skillsDir: SHARED_SKILLS_DIR,
108
+ discovery: Object.freeze(['.agents/skills', '.github/skills']),
109
+ docs: 'https://docs.github.com/en/copilot',
110
+ invoke: 'type /qa-explore in Copilot Chat (generated prompt file)',
78
111
  wrapperFormat: 'prompt-md',
79
112
  wrapperDir: '.github/prompts',
80
113
  // Detected by a Copilot-specific marker, not by `.github/` — almost every
@@ -90,6 +123,13 @@ export const AGENTS = Object.freeze([
90
123
  name: 'Antigravity',
91
124
  tier: 2,
92
125
  skillsDir: SHARED_SKILLS_DIR,
126
+ // Antigravity defaults to `.agents/skills` at the workspace root, with
127
+ // backward support for `.agent/skills`; global skills live under
128
+ // ~/.gemini/config/skills. So the pack's default install path is already the
129
+ // one it reads — detection below only governs the optional workflow wrappers.
130
+ discovery: Object.freeze(['.agents/skills', '.agent/skills']),
131
+ docs: 'https://antigravity.google/docs/skills',
132
+ invoke: 'describe the task — skills auto-activate; workflows give slash commands',
93
133
  wrapperFormat: 'workflow-md',
94
134
  wrapperDir: '.agents/workflows',
95
135
  // Detected by its own configuration directory, NOT by `.agents/`.
@@ -106,6 +146,9 @@ export const AGENTS = Object.freeze([
106
146
  name: 'Kimi (Agent Skills copy)',
107
147
  tier: 2,
108
148
  skillsDir: SHARED_SKILLS_DIR,
149
+ discovery: Object.freeze(['.agents/skills']),
150
+ docs: 'https://agentskills.io/specification',
151
+ invoke: 'describe the task, or use the product\'s own slash surface',
109
152
  wrapperFormat: null,
110
153
  wrapperDir: null,
111
154
  detect: () => false,
@@ -119,6 +162,9 @@ export const AGENTS = Object.freeze([
119
162
  name: 'Unknown agent (shared Agent Skills path)',
120
163
  tier: null,
121
164
  skillsDir: SHARED_SKILLS_DIR,
165
+ discovery: Object.freeze(['.agents/skills']),
166
+ docs: 'https://agentskills.io/specification',
167
+ invoke: 'describe the task; most hosts also expose skills on /',
122
168
  wrapperFormat: null,
123
169
  wrapperDir: null,
124
170
  detected: false,
@@ -23,6 +23,31 @@ import { INSTALL_STEPS, progressBar } from '../ui/progress.mjs';
23
23
  import { detectFrameworks } from '../detect/frameworks.mjs';
24
24
  import { getFramework } from '../../../../shared/frameworks/registry.mjs';
25
25
 
26
+ /**
27
+ * Say how to reach a skill in the host that was actually detected.
28
+ *
29
+ * The invocation surface differs per host — Codex takes `$qa-explore`, Cursor
30
+ * matches on `/`, OpenCode's agent loads skills itself — and a user who types the
31
+ * wrong one concludes the install failed. The registry records each host's own
32
+ * convention beside the paths it reads, so this prints what will work here rather
33
+ * than a generic "/qa-explore" that is wrong in two of the five hosts.
34
+ */
35
+ function reportInvocation(agents, logger) {
36
+ const named = agents.filter((a) => a.invoke && a.id !== 'agent-skills');
37
+ if (named.length === 0) {
38
+ const fallback = agents.find((a) => a.invoke);
39
+ if (fallback) {
40
+ logger.info(` → no specific agent detected; ${fallback.invoke}`);
41
+ logger.info(' → the skills are on the standard Agent Skills path, which every');
42
+ logger.info(' supported host reads: Cursor, Codex, OpenCode, Antigravity, Gemini, Copilot');
43
+ }
44
+ return;
45
+ }
46
+ for (const agent of named) {
47
+ logger.info(` → in ${agent.name}: ${agent.invoke}`);
48
+ }
49
+ }
50
+
26
51
  /**
27
52
  * Say plainly which commands this project can actually use.
28
53
  *
@@ -60,10 +85,11 @@ function reportFrameworkFit(root, logger) {
60
85
  return;
61
86
  }
62
87
 
63
- logger.warn('no supported end-to-end framework detected (Playwright, Selenium, Cypress, WebdriverIO)');
64
- logger.info(' → /qa-run and /qa-generate need one; they will tell you so rather than guess');
65
- logger.info(' → these work without one: /qa-review, /qa-api, /qa-report, /qa-audit, /qa-explore');
66
- logger.info(' → unit tests only (Jest, Vitest, pytest)? That is expected — see the README');
88
+ logger.warn('no end-to-end framework detected (Playwright, Selenium, Cypress, WebdriverIO)');
89
+ logger.info(' → start with /qa-generate it bootstraps a framework when none exists');
90
+ logger.info(' → /qa-run needs one first; it will say so rather than guess');
91
+ logger.info(' → these work today: /qa-review, /qa-api, /qa-audit, /qa-explore, /qa-report');
92
+ logger.info(' → unit tests only (Jest, Vitest, Jasmine, pytest)? That is expected');
67
93
  }
68
94
 
69
95
  /**
@@ -250,6 +276,7 @@ export async function executeInstall({
250
276
  } else if (!json) {
251
277
  logger.ok(`installed ${unique.length} file(s); lockfile ${lockPath(root)}`);
252
278
  for (const step of INSTALL_STEPS) logger.ok(step.label);
279
+ reportInvocation(agents, logger);
253
280
  reportFrameworkFit(root, logger);
254
281
  }
255
282
 
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { CONFIG_FILE } from '../constants.mjs';
9
9
  import { QaError, usageError } from './errors.mjs';
10
- import { validate } from './schema-validate.mjs';
10
+ import { validate } from '../../../engine/lib/analysis/contracts.mjs';
11
11
 
12
12
  const here = path.dirname(fileURLToPath(import.meta.url));
13
13
  const SCHEMA_PATH = path.join(here, '..', '..', 'schemas', 'qa.config.schema.json');
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { LOCKFILE, PACK_NAME } from '../constants.mjs';
9
9
  import { VERSION, SPEC_REVISION } from '../version.mjs';
10
10
  import { QaError } from './errors.mjs';
11
- import { validate } from './schema-validate.mjs';
11
+ import { validate } from '../../../engine/lib/analysis/contracts.mjs';
12
12
 
13
13
  const here = path.dirname(fileURLToPath(import.meta.url));
14
14
  const SCHEMA_PATH = path.join(here, '..', '..', 'schemas', 'qa-lock.schema.json');
@@ -18,6 +18,9 @@ export const BUNDLE_MANIFEST = Object.freeze({
18
18
  'qa-debug': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: ['playwright_analysis'] }),
19
19
  'qa-fix': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
20
20
  'qa-report': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
21
+ // qa-explore renders its HTML report with qa_analysis.report_html rather than
22
+ // typing it, so every required finding field reaches the page.
23
+ 'qa-explore': Object.freeze({ packages: ['qa_analysis'], modules: [] }),
21
24
  'qa-flaky': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
22
25
  'qa-api': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
23
26
  'qa-audit': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qa-engineer/installer",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "The qa-engineer installer: agent detection, copy-based install, lockfile integrity, and wrapper generation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,7 @@ Usage:
14
14
  python -m qa_analysis.cli classify "<error message>" [--http-status N]
15
15
  python -m qa_analysis.cli context [--root DIR] [--path .qa/context.md]
16
16
  python -m qa_analysis.cli branding [--format html|markdown|text]
17
+ python -m qa_analysis.cli report-html <result.json> [--out report.html]
17
18
  """
18
19
 
19
20
  import argparse
@@ -23,6 +24,7 @@ import sys
23
24
 
24
25
  from . import junit, har, discovery, diff_guard, redaction, contracts, taxonomy
25
26
  from . import branding as branding_module
27
+ from . import report_html as report_html_module
26
28
  from . import context as context_module
27
29
  from .context import MalformedContext
28
30
  from .junit import MalformedArtifact
@@ -61,6 +63,8 @@ def main(argv=None):
61
63
  p = sub.add_parser("validate"); p.add_argument("instance"); p.add_argument("schema")
62
64
  p = sub.add_parser("classify"); p.add_argument("message"); p.add_argument("--http-status", type=int, default=None)
63
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")
64
68
  p = sub.add_parser("branding")
65
69
  p.add_argument("--format", default="text", choices=list(branding_module.FORMATS) + ["pdf", "md", "txt"])
66
70
  p.add_argument("--metadata", action="store_true", help="emit the branding metadata as JSON instead")
@@ -110,6 +114,17 @@ def main(argv=None):
110
114
  _emit(branding_module.metadata())
111
115
  else:
112
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
113
128
  except branding_module.BrandingError as exc:
114
129
  _emit({"error": "branding-error", "detail": str(exc)})
115
130
  return 2
@@ -30,17 +30,27 @@ def parse_har(path, slow_ms=1000):
30
30
  except (KeyError, TypeError) as exc:
31
31
  raise MalformedArtifact(f"not a HAR document at {path}") from exc
32
32
 
33
+ # The shape has to be checked, not assumed. `log.entries` being a *string*
34
+ # satisfies the subscripting above, and iterating it then yields characters,
35
+ # so the parser died on `'str' object has no attribute 'get'` — an
36
+ # AttributeError, which the CLI does not catch, so the caller got a traceback
37
+ # instead of the documented exit 2. Found by the Node port's parity gate.
38
+ if not isinstance(raw_entries, list):
39
+ raise MalformedArtifact(f"HAR log.entries is not a list at {path}")
40
+
33
41
  entries = []
34
42
  for item in raw_entries:
35
- request = item.get("request", {})
36
- response = item.get("response", {})
43
+ if not isinstance(item, dict):
44
+ raise MalformedArtifact(f"HAR log.entries contains a non-object at {path}")
45
+ request = item.get("request") or {}
46
+ response = item.get("response") or {}
37
47
  status = _int(response.get("status"))
38
48
  entry = {
39
49
  "method": request.get("method", ""),
40
50
  # Redaction strips any credentials embedded in the URL.
41
51
  "url": redact_text(request.get("url", "")),
42
52
  "status": status,
43
- "durationMs": int(round(float(item.get("time", 0) or 0))),
53
+ "durationMs": _millis(item.get("time"), path),
44
54
  "requestHeaders": redact_headers(request.get("headers", [])),
45
55
  "responseHeaders": redact_headers(response.get("headers", [])),
46
56
  }
@@ -56,3 +66,22 @@ def _int(value, default=0):
56
66
  return int(value)
57
67
  except (TypeError, ValueError):
58
68
  return default
69
+
70
+
71
+ def _millis(raw, path):
72
+ """HAR `time` is already milliseconds, rounded to a whole number.
73
+
74
+ A value that is present but not a number raises rather than becoming 0 — the
75
+ same rule as the JUnit parser, and for the same reason: a fabricated duration
76
+ makes a malformed document look like a clean measurement. Before this, the
77
+ bare `float()` escaped as a ValueError and reached the caller as a traceback.
78
+ """
79
+ if raw is None or raw == "":
80
+ return 0
81
+ try:
82
+ value = float(raw)
83
+ except (TypeError, ValueError):
84
+ raise MalformedArtifact(f"entry time={raw!r} is not a number at {path}") from None
85
+ if value != value or value in (float("inf"), float("-inf")):
86
+ raise MalformedArtifact(f"entry time={raw!r} is not a finite number at {path}")
87
+ return int(round(value))
@@ -24,6 +24,30 @@ def _int(value, default=0):
24
24
  return default
25
25
 
26
26
 
27
+ def _duration_ms(raw, path):
28
+ """`time` in seconds as whole milliseconds.
29
+
30
+ An absent or empty attribute is zero — plenty of runners omit it. A value
31
+ that is present but not a finite number means the document is not what it
32
+ claims to be, so it raises: found by the Node port's parity corpus, where
33
+ `time="not-a-number"` escaped as a bare ValueError and `time="nan"` as
34
+ "cannot convert float NaN to integer". Both reached the caller as a traceback
35
+ and exit 1, while the CLI's documented failure mode is exit 2 with
36
+ {error, detail}.
37
+ """
38
+ if raw is None or raw == "":
39
+ return 0
40
+ try:
41
+ seconds = float(raw)
42
+ except (TypeError, ValueError):
43
+ raise MalformedArtifact(
44
+ f"testcase time={raw!r} is not a number at {path}"
45
+ ) from None
46
+ if seconds != seconds or seconds in (float("inf"), float("-inf")):
47
+ raise MalformedArtifact(f"testcase time={raw!r} is not a finite number at {path}")
48
+ return int(round(seconds * 1000))
49
+
50
+
27
51
  def parse_junit(path):
28
52
  """Parse a JUnit XML file into a normalized result.
29
53
 
@@ -65,7 +89,7 @@ def parse_junit(path):
65
89
  "title": case.get("name", ""),
66
90
  "file": case.get("classname", ""),
67
91
  "status": status,
68
- "durationMs": int(round(float(case.get("time", "0") or "0") * 1000)),
92
+ "durationMs": _duration_ms(case.get("time"), path),
69
93
  }
70
94
  if message:
71
95
  entry["message"] = message
@@ -18,8 +18,16 @@ _RULES = [
18
18
  ("openai-key", re.compile(r"sk-[A-Za-z0-9]{20,}")),
19
19
  ("bearer", re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]+=*")),
20
20
  # Sensitive header lines: keep the header name, mask the value.
21
- ("auth-header", re.compile(r"(?im)^(\s*(?:authorization|proxy-authorization)\s*[:=]\s*).+$")),
22
- ("cookie-header", re.compile(r"(?im)^(\s*(?:set-cookie|cookie)\s*[:=]\s*).+$")),
21
+ #
22
+ # The value is `[^\r\n]+` rather than `.+$`, and the indent is `[ \t]*` rather
23
+ # than `\s*`, for one reason: on CRLF text, `.+` swallows the carriage return
24
+ # and `$` matches before the newline, so redacting a header silently rewrote
25
+ # the line ending to LF and corrupted the rest of the document's endings.
26
+ # Horizontal whitespace is also what a header indent actually is. Found by the
27
+ # Node port's parity corpus, where the two languages disagreed here and
28
+ # JavaScript was right.
29
+ ("auth-header", re.compile(r"(?im)^([ \t]*(?:authorization|proxy-authorization)[ \t]*[:=][ \t]*)[^\r\n]+")),
30
+ ("cookie-header", re.compile(r"(?im)^([ \t]*(?:set-cookie|cookie)[ \t]*[:=][ \t]*)[^\r\n]+")),
23
31
  # Secret-like assignments: key=value / "key": "value".
24
32
  ("assigned-secret", re.compile(
25
33
  r'(?i)(\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*["\']?)'