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.
- package/COMPATIBILITY.md +26 -12
- package/README.md +182 -84
- package/package.json +11 -20
- package/packages/engine/bin/qa-engine.mjs +560 -0
- package/packages/engine/lib/analysis/branding.mjs +187 -0
- package/packages/engine/lib/analysis/context.mjs +294 -0
- package/packages/engine/lib/analysis/contracts.mjs +149 -0
- package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
- package/packages/engine/lib/analysis/discovery.mjs +220 -0
- package/packages/engine/lib/analysis/evidence.mjs +116 -0
- package/packages/engine/lib/analysis/har.mjs +124 -0
- package/packages/engine/lib/analysis/junit.mjs +126 -0
- package/packages/engine/lib/analysis/network.mjs +237 -0
- package/packages/engine/lib/analysis/redaction.mjs +127 -0
- package/packages/engine/lib/analysis/report-html.mjs +76 -0
- package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
- package/packages/engine/lib/analysis/xml.mjs +153 -0
- package/packages/engine/lib/analysis/zip.mjs +107 -0
- package/packages/engine/lib/artifacts/manager.mjs +453 -0
- package/packages/engine/lib/artifacts/mime.mjs +109 -0
- package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
- package/packages/engine/lib/diagnostics/engine.mjs +165 -0
- package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
- package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
- package/packages/engine/lib/diagnostics/repair.mjs +73 -0
- package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
- package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
- package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
- package/packages/engine/lib/frameworks/playwright.mjs +158 -0
- package/packages/engine/lib/report/components/charts.mjs +424 -0
- package/packages/engine/lib/report/components/evidence.mjs +207 -0
- package/packages/engine/lib/report/components/findings.mjs +258 -0
- package/packages/engine/lib/report/components/nav.mjs +99 -0
- package/packages/engine/lib/report/components/primitives.mjs +246 -0
- package/packages/engine/lib/report/components/runtime.mjs +246 -0
- package/packages/engine/lib/report/components/timeline.mjs +65 -0
- package/packages/engine/lib/report/core/model.mjs +270 -0
- package/packages/engine/lib/report/core/normalize.mjs +226 -0
- package/packages/engine/lib/report/core/sections.mjs +978 -0
- package/packages/engine/lib/report/export/bundle.mjs +293 -0
- package/packages/engine/lib/report/export/html.mjs +183 -0
- package/packages/engine/lib/report/export/machine.mjs +290 -0
- package/packages/engine/lib/report/export/markdown.mjs +323 -0
- package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
- package/packages/engine/lib/report/theme/css.mjs +529 -0
- package/packages/engine/lib/report/theme/tokens.mjs +137 -0
- package/packages/engine/lib/report/version.mjs +78 -0
- package/packages/engine/package.json +14 -0
- package/packages/installer/lib/agents/targets.mjs +90 -0
- package/packages/installer/lib/agents/user-level.mjs +80 -0
- package/packages/installer/lib/cli/flags.mjs +20 -2
- package/packages/installer/lib/commands/doctor.mjs +13 -20
- package/packages/installer/lib/commands/install.mjs +160 -91
- package/packages/installer/lib/commands/repair.mjs +10 -6
- package/packages/installer/lib/commands/self-test.mjs +4 -3
- package/packages/installer/lib/commands/uninstall.mjs +14 -8
- package/packages/installer/lib/commands/update.mjs +9 -4
- package/packages/installer/lib/commands/verify.mjs +13 -12
- package/packages/installer/lib/constants.mjs +13 -0
- package/packages/installer/lib/core/bundle.mjs +69 -92
- package/packages/installer/lib/core/conflict.mjs +5 -4
- package/packages/installer/lib/core/fs-safe.mjs +146 -6
- package/packages/installer/lib/core/integrity.mjs +59 -0
- package/packages/installer/lib/core/lockfile.mjs +19 -3
- package/packages/installer/lib/core/manifest.mjs +48 -57
- package/packages/installer/lib/core/plan.mjs +213 -0
- package/packages/installer/lib/core/qa-home.mjs +145 -0
- package/packages/installer/lib/core/scope.mjs +274 -0
- package/packages/installer/lib/core/validate-install.mjs +49 -31
- package/packages/installer/package.json +1 -1
- package/packages/installer/schemas/qa-lock.schema.json +119 -21
- package/shared/tooling/qa-tool.mjs +161 -0
- package/skills/qa-api/SKILL.md +5 -5
- package/skills/qa-api/references/deterministic-tooling.md +34 -32
- package/skills/qa-api/references/evidence-and-reporting.md +5 -5
- package/skills/qa-api/scripts/qa-tool.mjs +162 -0
- package/skills/qa-audit/SKILL.md +5 -5
- package/skills/qa-audit/references/deterministic-tooling.md +34 -32
- package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
- package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
- package/skills/qa-debug/SKILL.md +6 -6
- package/skills/qa-debug/references/deterministic-tooling.md +34 -32
- package/skills/qa-debug/references/diagnostic-engine.md +3 -3
- package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
- package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
- package/skills/qa-explore/SKILL.md +31 -15
- package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
- package/skills/qa-explore/references/api-replay.md +40 -1
- package/skills/qa-explore/references/deterministic-tooling.md +34 -32
- package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
- package/skills/qa-explore/references/report-pipeline.md +266 -96
- package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
- package/skills/qa-fix/SKILL.md +4 -4
- package/skills/qa-fix/references/deterministic-tooling.md +34 -32
- package/skills/qa-fix/references/diagnostic-engine.md +3 -3
- package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
- package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
- package/skills/qa-flaky/SKILL.md +4 -4
- package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
- package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
- package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
- package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/SKILL.md +4 -4
- package/skills/qa-init/references/deterministic-tooling.md +34 -32
- package/skills/qa-init/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/scripts/qa-tool.mjs +162 -0
- package/skills/qa-report/SKILL.md +7 -7
- package/skills/qa-report/references/deterministic-tooling.md +34 -32
- package/skills/qa-report/references/diagnostic-engine.md +3 -3
- package/skills/qa-report/references/evidence-and-reporting.md +5 -5
- package/skills/qa-report/scripts/qa-tool.mjs +162 -0
- package/skills/qa-review/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/SKILL.md +6 -6
- package/skills/qa-run/references/deterministic-tooling.md +34 -32
- package/skills/qa-run/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/scripts/qa-tool.mjs +162 -0
- package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
- package/shared/analysis/lib/qa_analysis/branding.py +0 -175
- package/shared/analysis/lib/qa_analysis/cli.py +0 -144
- package/shared/analysis/lib/qa_analysis/context.py +0 -233
- package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
- package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
- package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
- package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
- package/shared/analysis/lib/qa_analysis/har.py +0 -87
- package/shared/analysis/lib/qa_analysis/junit.py +0 -104
- package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
- package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
- package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
- package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
- package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
- package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
- package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
- package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
- package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
- package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
- package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
- package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
- package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
- package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
- package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
- package/shared/tooling/qa_tool.py +0 -127
- /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
- /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Turning a scope into a list of files and links to create.
|
|
2
|
+
//
|
|
3
|
+
// The planner was inline in `install.mjs` and knew exactly one arrangement: copy every
|
|
4
|
+
// skill, with the engine bundled inside it, into every agent directory. That produced
|
|
5
|
+
// eighteen copies of the engine for a single install — nine bundling skills times the
|
|
6
|
+
// two discovery directories the installer writes — and there was nowhere to put a
|
|
7
|
+
// different arrangement without an `if` in the middle of the transaction.
|
|
8
|
+
//
|
|
9
|
+
// So planning is separated from executing. A scope describes *what sharing is possible*;
|
|
10
|
+
// this module turns that into a concrete plan; `install.mjs` commits it. The three modes
|
|
11
|
+
// then differ in data rather than in control flow, which is what makes a fourth mode a
|
|
12
|
+
// table entry instead of a branch.
|
|
13
|
+
//
|
|
14
|
+
// ## The two plans
|
|
15
|
+
//
|
|
16
|
+
// bundled (project) every skill carries its own engine — unchanged, and still the
|
|
17
|
+
// right answer for a repository that must work on a machine where
|
|
18
|
+
// nothing else is installed
|
|
19
|
+
// shared (global, one engine and one canonical skill tree in the scope's qaRoot;
|
|
20
|
+
// workspace) agents reach them by link, or by copy where a link cannot be made
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { SHARED_ENGINE_DIR, SHARED_SKILLS_STORE } from '../constants.mjs';
|
|
26
|
+
import { toPosix, listFilesRelative } from './paths.mjs';
|
|
27
|
+
import { listSkills, skillFiles, ENGINE_SOURCE } from './manifest.mjs';
|
|
28
|
+
import { bundleFilesForSkill } from './bundle.mjs';
|
|
29
|
+
import { hashBytes, hashString } from './hash.mjs';
|
|
30
|
+
import { readSkillMeta } from './skill-meta.mjs';
|
|
31
|
+
import { renderWrapper } from './wrappers.mjs';
|
|
32
|
+
|
|
33
|
+
/** A skill file that is never installed: tests are development-only. */
|
|
34
|
+
function isDevelopmentFile(rel) {
|
|
35
|
+
return rel.startsWith('tests/') || rel.includes('/tests/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Files for the shared engine, at `<qaRoot>/engine/`.
|
|
40
|
+
*
|
|
41
|
+
* One copy. The launcher finds it by walking up for `.qa-engineer/engine`, so no skill
|
|
42
|
+
* needs to be told where it went.
|
|
43
|
+
*/
|
|
44
|
+
function planSharedEngine(sourceRoot, qaRootRelative) {
|
|
45
|
+
const engineRoot = path.join(sourceRoot, ENGINE_SOURCE);
|
|
46
|
+
const entries = [];
|
|
47
|
+
for (const rel of listFilesRelative(engineRoot)) {
|
|
48
|
+
if (rel.startsWith('test/') || rel === 'package.json') continue;
|
|
49
|
+
const content = fs.readFileSync(path.join(engineRoot, rel));
|
|
50
|
+
entries.push({
|
|
51
|
+
path: toPosix(path.join(qaRootRelative, SHARED_ENGINE_DIR, rel)),
|
|
52
|
+
sha256: hashBytes(content),
|
|
53
|
+
bytes: content.length,
|
|
54
|
+
owner: 'engine',
|
|
55
|
+
content,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return entries;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The canonical skill tree at `<qaRoot>/skills/`, with no engine inside it. */
|
|
62
|
+
function planSkillStore(sourceRoot, skills, qaRootRelative) {
|
|
63
|
+
const entries = [];
|
|
64
|
+
for (const skill of skills) {
|
|
65
|
+
for (const rel of skillFiles(sourceRoot, skill)) {
|
|
66
|
+
if (isDevelopmentFile(rel)) continue;
|
|
67
|
+
const content = fs.readFileSync(path.join(sourceRoot, 'skills', skill, rel));
|
|
68
|
+
entries.push({
|
|
69
|
+
path: toPosix(path.join(qaRootRelative, SHARED_SKILLS_STORE, skill, rel)),
|
|
70
|
+
sha256: hashBytes(content),
|
|
71
|
+
bytes: content.length,
|
|
72
|
+
owner: 'skill',
|
|
73
|
+
skill,
|
|
74
|
+
content,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Skill files copied into one agent directory, optionally with the engine bundled. */
|
|
82
|
+
function planSkillCopies(sourceRoot, skills, skillsDir, agentId, { bundleEngine }) {
|
|
83
|
+
const entries = [];
|
|
84
|
+
for (const skill of skills) {
|
|
85
|
+
for (const rel of skillFiles(sourceRoot, skill)) {
|
|
86
|
+
if (isDevelopmentFile(rel)) continue;
|
|
87
|
+
const content = fs.readFileSync(path.join(sourceRoot, 'skills', skill, rel));
|
|
88
|
+
entries.push({
|
|
89
|
+
path: toPosix(path.join(skillsDir, skill, rel)),
|
|
90
|
+
sha256: hashBytes(content),
|
|
91
|
+
bytes: content.length,
|
|
92
|
+
owner: 'skill',
|
|
93
|
+
skill,
|
|
94
|
+
agent: agentId,
|
|
95
|
+
content,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (!bundleEngine) continue;
|
|
99
|
+
for (const bundled of bundleFilesForSkill(sourceRoot, skill)) {
|
|
100
|
+
entries.push({
|
|
101
|
+
path: toPosix(path.join(skillsDir, skill, bundled.rel)),
|
|
102
|
+
sha256: hashBytes(bundled.content),
|
|
103
|
+
bytes: bundled.content.length,
|
|
104
|
+
owner: 'skill',
|
|
105
|
+
skill,
|
|
106
|
+
agent: agentId,
|
|
107
|
+
content: bundled.content,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return entries;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Slash-command wrappers, for the agents that need one to expose a skill. */
|
|
115
|
+
function planWrappers(sourceRoot, skills, agent) {
|
|
116
|
+
if (!agent.wrapperFormat || !agent.wrapperDir) return [];
|
|
117
|
+
const entries = [];
|
|
118
|
+
for (const skill of skills) {
|
|
119
|
+
const meta = readSkillMeta(path.join(sourceRoot, 'skills', skill, 'SKILL.md'));
|
|
120
|
+
if (!meta.name) continue;
|
|
121
|
+
const { filename, content } = renderWrapper(agent.wrapperFormat, meta);
|
|
122
|
+
const buffer = Buffer.from(content, 'utf8');
|
|
123
|
+
entries.push({
|
|
124
|
+
path: toPosix(path.join(agent.wrapperDir, filename)),
|
|
125
|
+
sha256: hashBytes(buffer),
|
|
126
|
+
bytes: buffer.length,
|
|
127
|
+
owner: 'wrapper',
|
|
128
|
+
skill,
|
|
129
|
+
agent: agent.id,
|
|
130
|
+
content: buffer,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return entries;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One link per skill, from an agent's directory to the canonical tree.
|
|
138
|
+
*
|
|
139
|
+
* The lockfile needs a hash for every entry, and a link has no content to hash — so the
|
|
140
|
+
* hash is of the target path. That is not a formality: it makes `verify` able to catch a
|
|
141
|
+
* link that still exists but now points somewhere else, which is exactly what happens
|
|
142
|
+
* when a second tool claims the same directory.
|
|
143
|
+
*/
|
|
144
|
+
function planLinks(skills, skillsDir, agentId, qaRoot, root) {
|
|
145
|
+
return skills.map((skill) => {
|
|
146
|
+
const target = path.join(qaRoot, SHARED_SKILLS_STORE, skill);
|
|
147
|
+
const rel = toPosix(path.join(skillsDir, skill));
|
|
148
|
+
return {
|
|
149
|
+
path: rel,
|
|
150
|
+
sha256: hashString(toPosix(path.relative(root, target))),
|
|
151
|
+
bytes: 0,
|
|
152
|
+
owner: 'link',
|
|
153
|
+
skill,
|
|
154
|
+
agent: agentId,
|
|
155
|
+
linkTarget: target,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build the full plan for a scope.
|
|
162
|
+
*
|
|
163
|
+
* `targets` are agent definitions already resolved for this scope, each carrying the
|
|
164
|
+
* scope-relative `skillsDir` it wants and whether it can be served by a link.
|
|
165
|
+
*/
|
|
166
|
+
export function buildPlan({ sourceRoot, scope, targets, preferLinks = true }) {
|
|
167
|
+
const skills = listSkills(sourceRoot);
|
|
168
|
+
const files = [];
|
|
169
|
+
const links = [];
|
|
170
|
+
|
|
171
|
+
if (scope.shareEngine) {
|
|
172
|
+
files.push(...planSharedEngine(sourceRoot, scope.qaRootRelative));
|
|
173
|
+
files.push(...planSkillStore(sourceRoot, skills, scope.qaRootRelative));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const agent of targets) {
|
|
177
|
+
if (scope.shareEngine && preferLinks && agent.linkable !== false) {
|
|
178
|
+
links.push(...planLinks(skills, agent.skillsDir, agent.id, scope.qaRoot, scope.root));
|
|
179
|
+
} else {
|
|
180
|
+
files.push(
|
|
181
|
+
...planSkillCopies(sourceRoot, skills, agent.skillsDir, agent.id, {
|
|
182
|
+
// A copied skill needs its own engine only when nothing is shared; with a
|
|
183
|
+
// shared engine present the launcher finds it by walking up.
|
|
184
|
+
bundleEngine: !scope.shareEngine,
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
files.push(...planWrappers(sourceRoot, skills, agent));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { skills, files, links };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Collapse duplicate destinations, refusing any that disagree about content.
|
|
196
|
+
*
|
|
197
|
+
* Two agents can name the same directory — Cursor and Codex both read `.agents/skills` —
|
|
198
|
+
* and writing it twice is wasted work. Two *different* contents for one path is a
|
|
199
|
+
* planning bug, and failing loudly beats letting whichever ran last win.
|
|
200
|
+
*/
|
|
201
|
+
export function dedupePlan(entries) {
|
|
202
|
+
const byPath = new Map();
|
|
203
|
+
const conflicts = [];
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
const previous = byPath.get(entry.path);
|
|
206
|
+
if (!previous) {
|
|
207
|
+
byPath.set(entry.path, entry);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (previous.sha256 !== entry.sha256) conflicts.push(entry.path);
|
|
211
|
+
}
|
|
212
|
+
return { unique: [...byPath.values()], conflicts };
|
|
213
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The directory QA Engineer owns on a machine, and the rules for what lives in it.
|
|
2
|
+
//
|
|
3
|
+
// ## Why a single owned directory
|
|
4
|
+
//
|
|
5
|
+
// Before this existed, installing "globally" meant pointing `--project` at `$HOME`,
|
|
6
|
+
// which scattered `qa-lock.json`, `.agents/`, and `.claude/` across the user's home
|
|
7
|
+
// directory and depended on behaviour nothing documented or tested. A tool that writes
|
|
8
|
+
// into `$HOME` directly is a tool that cannot be cleanly uninstalled, because nobody
|
|
9
|
+
// can tell which of those files it put there.
|
|
10
|
+
//
|
|
11
|
+
// So: one directory, everything inside it, and `uninstall` can remove it wholesale.
|
|
12
|
+
// The same shape Docker, the AWS CLI, and Cargo use, for the same reason.
|
|
13
|
+
//
|
|
14
|
+
// ~/.qa-engineer/
|
|
15
|
+
// engine/ one copy of the deterministic engine, shared by every skill
|
|
16
|
+
// skills/ one canonical copy of the skills
|
|
17
|
+
// config/ machine-level configuration
|
|
18
|
+
// sessions/ stored authentication, per application
|
|
19
|
+
// cache/ regenerable — safe to delete at any time
|
|
20
|
+
// logs/ diagnostics
|
|
21
|
+
// qa-lock.json what is installed, with a hash per file
|
|
22
|
+
//
|
|
23
|
+
// ## Why not XDG
|
|
24
|
+
//
|
|
25
|
+
// On Linux the tidier answer is `$XDG_DATA_HOME/qa-engineer` plus `$XDG_STATE_HOME`
|
|
26
|
+
// and `$XDG_CACHE_HOME` — three directories, three variables, three fallbacks, and a
|
|
27
|
+
// different layout on macOS and Windows. The cost is that a user who wants to see what
|
|
28
|
+
// the tool owns has to look in three places on one OS and one place on another, and
|
|
29
|
+
// that `uninstall` has three roots to get right.
|
|
30
|
+
//
|
|
31
|
+
// One dot-directory in `$HOME` is what Docker, AWS, Cargo, and Git do, works
|
|
32
|
+
// identically on all three platforms, and is overridable in full by
|
|
33
|
+
// `QA_ENGINEER_HOME` for anyone who wants it elsewhere. Cache lives inside it and is
|
|
34
|
+
// documented as safe to delete, which is the only thing XDG would really have bought.
|
|
35
|
+
//
|
|
36
|
+
// ## Directories are created on demand
|
|
37
|
+
//
|
|
38
|
+
// Only `engine/`, `skills/`, and the lockfile are written at install time. `sessions/`,
|
|
39
|
+
// `cache/`, and `logs/` are created by whoever first writes to them. An empty directory
|
|
40
|
+
// promising a feature that does not exist yet is a lie the filesystem tells forever.
|
|
41
|
+
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import os from 'node:os';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
|
|
46
|
+
import { QA_HOME_DIR_NAME, QA_HOME_ENV, LOCKFILE } from '../constants.mjs';
|
|
47
|
+
import { environmentError } from './errors.mjs';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Subdirectories of the home, and whether an install creates them.
|
|
51
|
+
*
|
|
52
|
+
* `installed: true` means the installer writes content there and `verify` expects it.
|
|
53
|
+
* Everything else appears the first time something needs it.
|
|
54
|
+
*/
|
|
55
|
+
export const HOME_LAYOUT = Object.freeze({
|
|
56
|
+
engine: { dir: 'engine', installed: true, purpose: 'the deterministic engine, shared by every skill' },
|
|
57
|
+
skills: { dir: 'skills', installed: true, purpose: 'the canonical copy of the skills' },
|
|
58
|
+
adapters: { dir: 'adapters', installed: false, purpose: 'per-agent integration state' },
|
|
59
|
+
config: { dir: 'config', installed: false, purpose: 'machine-level configuration' },
|
|
60
|
+
sessions: { dir: 'sessions', installed: false, purpose: 'stored authentication, one file per application' },
|
|
61
|
+
cache: { dir: 'cache', installed: false, purpose: 'regenerable — safe to delete at any time' },
|
|
62
|
+
logs: { dir: 'logs', installed: false, purpose: 'diagnostics' },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The absolute path of the QA Engineer home.
|
|
67
|
+
*
|
|
68
|
+
* `QA_ENGINEER_HOME` wins when set, which is what makes the whole thing testable: the
|
|
69
|
+
* test suite points it at a temporary directory rather than touching a real `$HOME`.
|
|
70
|
+
* It is also the escape hatch for anyone whose home is on a network share or who wants
|
|
71
|
+
* the install on another volume.
|
|
72
|
+
*/
|
|
73
|
+
export function qaHome({ env = process.env, homedir = os.homedir } = {}) {
|
|
74
|
+
const override = env[QA_HOME_ENV];
|
|
75
|
+
if (override && override.trim()) {
|
|
76
|
+
const resolved = path.resolve(override.trim());
|
|
77
|
+
if (resolved === path.parse(resolved).root) {
|
|
78
|
+
// A home at `/` would make `uninstall` a catastrophe.
|
|
79
|
+
throw environmentError(`${QA_HOME_ENV} must not be a filesystem root: ${resolved}`);
|
|
80
|
+
}
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The test suite redirects the user's home wholesale; the owned directory follows it,
|
|
85
|
+
// so a global install can be proven end to end without touching a real home.
|
|
86
|
+
const home = env.QA_ENGINEER_USER_HOME?.trim() || homedir();
|
|
87
|
+
if (!home || home === path.parse(home || '/').root) {
|
|
88
|
+
throw environmentError(
|
|
89
|
+
`could not determine a home directory for the global install; set ${QA_HOME_ENV} to choose one`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return path.join(home, QA_HOME_DIR_NAME);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Absolute path of one layout directory. */
|
|
96
|
+
export function homePath(kind, options) {
|
|
97
|
+
const entry = HOME_LAYOUT[kind];
|
|
98
|
+
if (!entry) throw new Error(`unknown qa-home directory: ${kind}`);
|
|
99
|
+
return path.join(qaHome(options), entry.dir);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Absolute path of the global lockfile. Inside the home, never loose in `$HOME`. */
|
|
103
|
+
export function homeLockPath(options) {
|
|
104
|
+
return path.join(qaHome(options), LOCKFILE);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Create a layout directory on demand and return it. */
|
|
108
|
+
export function ensureHomeDir(kind, options) {
|
|
109
|
+
const target = homePath(kind, options);
|
|
110
|
+
fs.mkdirSync(target, { recursive: true });
|
|
111
|
+
return target;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when a global install is present — the engine is the load-bearing part. */
|
|
115
|
+
export function isGlobalInstalled(options) {
|
|
116
|
+
return fs.existsSync(path.join(homePath('engine', options), 'bin', 'qa-engine.mjs'));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A description of the home for `doctor`: what exists, what does not, and what each
|
|
121
|
+
* directory is for. Reporting an absent optional directory as "not yet created" rather
|
|
122
|
+
* than as missing is the difference between a diagnostic and a false alarm.
|
|
123
|
+
*/
|
|
124
|
+
export function describeHome(options) {
|
|
125
|
+
const root = qaHome(options);
|
|
126
|
+
const present = fs.existsSync(root);
|
|
127
|
+
return {
|
|
128
|
+
root,
|
|
129
|
+
present,
|
|
130
|
+
lockfile: homeLockPath(options),
|
|
131
|
+
engineInstalled: isGlobalInstalled(options),
|
|
132
|
+
directories: Object.entries(HOME_LAYOUT).map(([kind, entry]) => {
|
|
133
|
+
const absolute = path.join(root, entry.dir);
|
|
134
|
+
const exists = fs.existsSync(absolute);
|
|
135
|
+
return {
|
|
136
|
+
kind,
|
|
137
|
+
path: absolute,
|
|
138
|
+
purpose: entry.purpose,
|
|
139
|
+
exists,
|
|
140
|
+
expected: entry.installed,
|
|
141
|
+
state: exists ? 'present' : entry.installed ? 'missing' : 'not yet created',
|
|
142
|
+
};
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Installation scopes: where an install lives, and what it shares.
|
|
2
|
+
//
|
|
3
|
+
// Before this, there was one shape — "a project" — and every other arrangement was a
|
|
4
|
+
// user pointing `--project` somewhere unusual and hoping. Three arrangements are real,
|
|
5
|
+
// and each is now a named, tested mode rather than a trick:
|
|
6
|
+
//
|
|
7
|
+
// global one install per machine, in ~/.qa-engineer, reused by every project
|
|
8
|
+
// workspace one install per monorepo, at the repository root, reused by every package
|
|
9
|
+
// project one install in this project — the original behaviour, unchanged
|
|
10
|
+
//
|
|
11
|
+
// ## The one idea that makes sharing work
|
|
12
|
+
//
|
|
13
|
+
// A scope has a `qaRoot`: a directory holding exactly one copy of the engine and one
|
|
14
|
+
// canonical copy of the skills. Global puts it in the user's home; workspace puts it at
|
|
15
|
+
// the monorepo root; project has none, and keeps the engine bundled inside each skill
|
|
16
|
+
// the way it always has.
|
|
17
|
+
//
|
|
18
|
+
// The launcher (`shared/tooling/qa-tool.mjs`) walks up from wherever a skill happens to
|
|
19
|
+
// live looking for `.qa-engineer/engine`, then falls back to the machine home, then to
|
|
20
|
+
// node_modules, then to npx. So a skill does not need to know which scope installed it
|
|
21
|
+
// — which is precisely why one skill directory can serve all three.
|
|
22
|
+
//
|
|
23
|
+
// ## Why project mode is left alone
|
|
24
|
+
//
|
|
25
|
+
// Every existing install on disk is a project install. Changing what those look like
|
|
26
|
+
// would mean an upgrade that rewrites a user's repository, and `verify` reporting drift
|
|
27
|
+
// on installs that were fine. The safe direction here is obvious: new modes are added,
|
|
28
|
+
// the old one is not touched, and a 0.10 lockfile still verifies under 0.11.
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import os from 'node:os';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
|
|
34
|
+
import { LOCKFILE, QA_HOME_DIR_NAME } from '../constants.mjs';
|
|
35
|
+
import { qaHome } from './qa-home.mjs';
|
|
36
|
+
import { usageError } from './errors.mjs';
|
|
37
|
+
|
|
38
|
+
export const SCOPES = Object.freeze(['global', 'workspace', 'project']);
|
|
39
|
+
|
|
40
|
+
// Files that mark the root of a monorepo. Ordered by how strongly each implies "this is
|
|
41
|
+
// the top": a lockfile is decisive, a workspace manifest nearly so, `.git` last because
|
|
42
|
+
// a submodule has one too.
|
|
43
|
+
const WORKSPACE_MARKERS = Object.freeze([
|
|
44
|
+
{ file: 'pnpm-workspace.yaml', kind: 'pnpm' },
|
|
45
|
+
{ file: 'lerna.json', kind: 'lerna' },
|
|
46
|
+
{ file: 'nx.json', kind: 'nx' },
|
|
47
|
+
{ file: 'turbo.json', kind: 'turborepo' },
|
|
48
|
+
{ file: 'rush.json', kind: 'rush' },
|
|
49
|
+
{ file: 'go.work', kind: 'go' },
|
|
50
|
+
{ file: 'Cargo.toml', kind: 'cargo', check: (text) => /^\s*\[workspace\]/m.test(text) },
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** True when this package.json declares npm/yarn workspaces. */
|
|
54
|
+
function declaresNpmWorkspaces(file) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
57
|
+
return Array.isArray(parsed.workspaces) || typeof parsed.workspaces === 'object';
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Walk up from `start` looking for the root of a monorepo.
|
|
65
|
+
*
|
|
66
|
+
* Returns the *highest* match rather than the nearest: in a repository where a package
|
|
67
|
+
* has its own `package.json` and the root declares workspaces, the root is the answer.
|
|
68
|
+
* Stopping at the first hit would install once per package, which is the duplication
|
|
69
|
+
* this mode exists to remove.
|
|
70
|
+
*/
|
|
71
|
+
export function findWorkspaceRoot(start = process.cwd(), { limit = 24 } = {}) {
|
|
72
|
+
let dir = path.resolve(start);
|
|
73
|
+
let best = null;
|
|
74
|
+
|
|
75
|
+
for (let depth = 0; depth < limit; depth += 1) {
|
|
76
|
+
for (const marker of WORKSPACE_MARKERS) {
|
|
77
|
+
const file = path.join(dir, marker.file);
|
|
78
|
+
if (!fs.existsSync(file)) continue;
|
|
79
|
+
if (marker.check) {
|
|
80
|
+
let text = '';
|
|
81
|
+
try {
|
|
82
|
+
text = fs.readFileSync(file, 'utf8');
|
|
83
|
+
} catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!marker.check(text)) continue;
|
|
87
|
+
}
|
|
88
|
+
best = { root: dir, kind: marker.kind, marker: marker.file };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const manifest = path.join(dir, 'package.json');
|
|
92
|
+
if (fs.existsSync(manifest) && declaresNpmWorkspaces(manifest)) {
|
|
93
|
+
best = { root: dir, kind: 'npm', marker: 'package.json' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parent = path.dirname(dir);
|
|
97
|
+
if (parent === dir) break;
|
|
98
|
+
dir = parent;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return best;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The user's home, where agent user-level directories live.
|
|
106
|
+
*
|
|
107
|
+
* `QA_ENGINEER_USER_HOME` exists for the test suite, which must not write into a real
|
|
108
|
+
* home to prove that a global install works. It is deliberately separate from
|
|
109
|
+
* `QA_ENGINEER_HOME`: that one moves what *we* own, this one moves where *agents* look,
|
|
110
|
+
* and a test needs to move both together while a user normally moves neither.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveUserHome({ env = process.env } = {}) {
|
|
113
|
+
const override = env.QA_ENGINEER_USER_HOME;
|
|
114
|
+
if (override && override.trim()) return path.resolve(override.trim());
|
|
115
|
+
return os.homedir();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The deepest directory containing both paths. */
|
|
119
|
+
function commonAncestor(a, b) {
|
|
120
|
+
const left = path.resolve(a).split(path.sep);
|
|
121
|
+
const right = path.resolve(b).split(path.sep);
|
|
122
|
+
const shared = [];
|
|
123
|
+
for (let i = 0; i < Math.min(left.length, right.length); i += 1) {
|
|
124
|
+
if (left[i] !== right[i]) break;
|
|
125
|
+
shared.push(left[i]);
|
|
126
|
+
}
|
|
127
|
+
return shared.join(path.sep) || path.parse(path.resolve(a)).root;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** POSIX-style path from `root` to `target`, for lockfile entries. */
|
|
131
|
+
function toRelative(root, target) {
|
|
132
|
+
return path.relative(root, target).split(path.sep).join('/');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the scope a command should operate on.
|
|
137
|
+
*
|
|
138
|
+
* Exactly one mode may be requested. Defaulting silently to `project` when `--global`
|
|
139
|
+
* was misspelled would install into whatever directory the user happened to be in,
|
|
140
|
+
* which is the kind of surprise that costs trust once and permanently.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveScope({
|
|
143
|
+
global: wantGlobal = false,
|
|
144
|
+
workspace = false,
|
|
145
|
+
project = null,
|
|
146
|
+
cwd = process.cwd(),
|
|
147
|
+
env = process.env,
|
|
148
|
+
} = {}) {
|
|
149
|
+
const requested = [wantGlobal && 'global', workspace && 'workspace', project && 'project'].filter(Boolean);
|
|
150
|
+
if (requested.length > 1) {
|
|
151
|
+
throw usageError(
|
|
152
|
+
`choose one installation scope, not ${requested.length}: ${requested.map((r) => `--${r}`).join(' and ')}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (wantGlobal) {
|
|
157
|
+
// Two directories are in play and they are not the same one. Everything the tool
|
|
158
|
+
// owns goes in `qaRoot`; the links that make agents see it go in *their* user-level
|
|
159
|
+
// directories, which live in the user's home. Conflating the two put `.claude/` and
|
|
160
|
+
// `.gemini/` inside `~/.qa-engineer`, where no agent looks.
|
|
161
|
+
const qaRoot = qaHome({ env });
|
|
162
|
+
const userHome = resolveUserHome({ env });
|
|
163
|
+
const root = commonAncestor(qaRoot, userHome);
|
|
164
|
+
|
|
165
|
+
if (root === path.parse(root).root) {
|
|
166
|
+
throw usageError(
|
|
167
|
+
`${qaRoot} and ${userHome} share no directory but the filesystem root, so a single ` +
|
|
168
|
+
'install cannot contain both',
|
|
169
|
+
'point QA_ENGINEER_HOME somewhere inside your home directory',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const qaRootRelative = toRelative(root, qaRoot);
|
|
174
|
+
return Object.freeze({
|
|
175
|
+
kind: 'global',
|
|
176
|
+
// The transaction is confined here, and it contains both the owned directory and
|
|
177
|
+
// the agent directories being linked into.
|
|
178
|
+
root,
|
|
179
|
+
qaRoot,
|
|
180
|
+
qaRootRelative,
|
|
181
|
+
userHome,
|
|
182
|
+
lockfile: path.posix.join(qaRootRelative, LOCKFILE),
|
|
183
|
+
shareEngine: true,
|
|
184
|
+
userLevel: true,
|
|
185
|
+
label: `global (${qaRoot})`,
|
|
186
|
+
describe: () => `machine-wide install in ${qaRoot}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (workspace) {
|
|
191
|
+
const found = findWorkspaceRoot(cwd);
|
|
192
|
+
if (!found) {
|
|
193
|
+
throw usageError(
|
|
194
|
+
`no monorepo root found above ${path.resolve(cwd)} — looked for ` +
|
|
195
|
+
`${WORKSPACE_MARKERS.map((m) => m.file).join(', ')}, and package.json with a "workspaces" field`,
|
|
196
|
+
'run from inside the monorepo, or use --project <dir> to name the root explicitly',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return Object.freeze({
|
|
200
|
+
kind: 'workspace',
|
|
201
|
+
root: found.root,
|
|
202
|
+
qaRoot: path.join(found.root, QA_HOME_DIR_NAME),
|
|
203
|
+
qaRootRelative: QA_HOME_DIR_NAME,
|
|
204
|
+
lockfile: LOCKFILE,
|
|
205
|
+
shareEngine: true,
|
|
206
|
+
userLevel: false,
|
|
207
|
+
workspaceKind: found.kind,
|
|
208
|
+
label: `workspace (${found.root}, detected by ${found.marker})`,
|
|
209
|
+
describe: () => `${found.kind} monorepo rooted at ${found.root}`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const root = path.resolve(project ?? cwd);
|
|
214
|
+
return Object.freeze({
|
|
215
|
+
kind: 'project',
|
|
216
|
+
root,
|
|
217
|
+
// No shared root: the engine travels inside each skill, as it always has.
|
|
218
|
+
qaRoot: null,
|
|
219
|
+
qaRootRelative: null,
|
|
220
|
+
lockfile: LOCKFILE,
|
|
221
|
+
shareEngine: false,
|
|
222
|
+
userLevel: false,
|
|
223
|
+
label: `project (${root})`,
|
|
224
|
+
describe: () => `project install in ${root}`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The scope an already-installed tree belongs to, for commands that operate on an
|
|
230
|
+
* existing install rather than creating one.
|
|
231
|
+
*
|
|
232
|
+
* A lockfile records its own scope from 0.11 onward. One written by an earlier version
|
|
233
|
+
* has no `scope` field and is a project install by definition, because that is the only
|
|
234
|
+
* kind that existed — so the absence is read as `project` rather than as corruption.
|
|
235
|
+
*/
|
|
236
|
+
export function scopeOfLock(lock) {
|
|
237
|
+
return lock?.scope?.kind ?? 'project';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The scope a lifecycle command should act on when the user did not name one.
|
|
242
|
+
*
|
|
243
|
+
* `qa verify` with no flags should verify the install that is actually in play, and
|
|
244
|
+
* which one that is depends on where you are standing. A project with its own lockfile
|
|
245
|
+
* is the answer there; otherwise the machine-wide install is, because that is what is
|
|
246
|
+
* serving this project. Making the user pass `--global` to check the only install they
|
|
247
|
+
* have would be a tool asking a question it can answer itself.
|
|
248
|
+
*
|
|
249
|
+
* Project beats global deliberately, matching every layered tool a developer already
|
|
250
|
+
* knows: a local `node_modules` beats a global one, `.git/config` beats `~/.gitconfig`.
|
|
251
|
+
*/
|
|
252
|
+
export function resolveOperatingScope({
|
|
253
|
+
global: wantGlobal = false,
|
|
254
|
+
workspace = false,
|
|
255
|
+
project = null,
|
|
256
|
+
cwd = process.cwd(),
|
|
257
|
+
env = process.env,
|
|
258
|
+
} = {}) {
|
|
259
|
+
if (wantGlobal || workspace || project) {
|
|
260
|
+
return resolveScope({ global: wantGlobal, workspace, project, cwd, env });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const here = path.resolve(cwd);
|
|
264
|
+
if (fs.existsSync(path.join(here, LOCKFILE))) {
|
|
265
|
+
return resolveScope({ project: here, cwd, env });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const globalScope = resolveScope({ global: true, cwd, env });
|
|
269
|
+
if (fs.existsSync(path.join(globalScope.root, globalScope.lockfile))) return globalScope;
|
|
270
|
+
|
|
271
|
+
// Nothing installed anywhere reachable. Return the project scope so the command
|
|
272
|
+
// reports "no lockfile here", which is the honest and actionable message.
|
|
273
|
+
return resolveScope({ project: here, cwd, env });
|
|
274
|
+
}
|