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.
- package/COMPATIBILITY.md +25 -15
- package/README.md +20 -2
- package/package.json +4 -3
- package/packages/installer/lib/agents/registry.mjs +46 -0
- package/packages/installer/lib/commands/install.mjs +31 -4
- package/packages/installer/lib/core/config.mjs +1 -1
- package/packages/installer/lib/core/lockfile.mjs +1 -1
- package/packages/installer/lib/core/manifest.mjs +3 -0
- package/packages/installer/package.json +1 -1
- package/shared/analysis/lib/qa_analysis/cli.py +15 -0
- package/shared/analysis/lib/qa_analysis/har.py +32 -3
- package/shared/analysis/lib/qa_analysis/junit.py +25 -1
- package/shared/analysis/lib/qa_analysis/redaction.py +10 -2
- package/shared/analysis/lib/qa_analysis/report_html.py +781 -0
- package/skills/qa-api/references/deterministic-tooling.md +2 -0
- package/skills/qa-api/references/evidence-and-reporting.md +29 -4
- package/skills/qa-audit/references/deterministic-tooling.md +2 -0
- package/skills/qa-audit/references/evidence-and-reporting.md +29 -4
- package/skills/qa-debug/references/deterministic-tooling.md +2 -0
- package/skills/qa-debug/references/evidence-and-reporting.md +29 -4
- package/skills/qa-explore/SKILL.md +22 -5
- package/skills/qa-explore/contracts/explore-result.schema.json +26 -2
- package/skills/qa-explore/references/deterministic-tooling.md +130 -0
- package/skills/qa-explore/references/evidence-and-reporting.md +29 -4
- package/skills/qa-explore/references/report-pipeline.md +89 -8
- package/skills/qa-fix/references/deterministic-tooling.md +2 -0
- package/skills/qa-fix/references/evidence-and-reporting.md +29 -4
- package/skills/qa-flaky/references/deterministic-tooling.md +2 -0
- package/skills/qa-flaky/references/evidence-and-reporting.md +29 -4
- package/skills/qa-generate/references/evidence-and-reporting.md +29 -4
- package/skills/qa-init/references/deterministic-tooling.md +2 -0
- package/skills/qa-init/references/evidence-and-reporting.md +29 -4
- package/skills/qa-report/SKILL.md +3 -2
- package/skills/qa-report/references/deterministic-tooling.md +2 -0
- package/skills/qa-report/references/evidence-and-reporting.md +29 -4
- package/skills/qa-review/references/evidence-and-reporting.md +29 -4
- package/skills/qa-run/references/deterministic-tooling.md +2 -0
- package/skills/qa-run/references/evidence-and-reporting.md +29 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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.
|
|
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
|
|
64
|
-
logger.info(' →
|
|
65
|
-
logger.info(' →
|
|
66
|
-
logger.info(' →
|
|
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 '
|
|
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 '
|
|
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: [] }),
|
|
@@ -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
|
-
|
|
36
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
22
|
-
|
|
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*["\']?)'
|