qa-engineer 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/COMPATIBILITY.md +26 -12
  2. package/README.md +182 -84
  3. package/package.json +11 -20
  4. package/packages/engine/bin/qa-engine.mjs +560 -0
  5. package/packages/engine/lib/analysis/branding.mjs +187 -0
  6. package/packages/engine/lib/analysis/context.mjs +294 -0
  7. package/packages/engine/lib/analysis/contracts.mjs +149 -0
  8. package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
  9. package/packages/engine/lib/analysis/discovery.mjs +220 -0
  10. package/packages/engine/lib/analysis/evidence.mjs +116 -0
  11. package/packages/engine/lib/analysis/har.mjs +124 -0
  12. package/packages/engine/lib/analysis/junit.mjs +126 -0
  13. package/packages/engine/lib/analysis/network.mjs +237 -0
  14. package/packages/engine/lib/analysis/redaction.mjs +127 -0
  15. package/packages/engine/lib/analysis/report-html.mjs +76 -0
  16. package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
  17. package/packages/engine/lib/analysis/xml.mjs +153 -0
  18. package/packages/engine/lib/analysis/zip.mjs +107 -0
  19. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  20. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  21. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  22. package/packages/engine/lib/diagnostics/engine.mjs +165 -0
  23. package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
  24. package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
  25. package/packages/engine/lib/diagnostics/repair.mjs +73 -0
  26. package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
  27. package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
  28. package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
  29. package/packages/engine/lib/frameworks/playwright.mjs +158 -0
  30. package/packages/engine/lib/report/components/charts.mjs +424 -0
  31. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  32. package/packages/engine/lib/report/components/findings.mjs +258 -0
  33. package/packages/engine/lib/report/components/nav.mjs +99 -0
  34. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  35. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  36. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  37. package/packages/engine/lib/report/core/model.mjs +270 -0
  38. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  39. package/packages/engine/lib/report/core/sections.mjs +978 -0
  40. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  41. package/packages/engine/lib/report/export/html.mjs +183 -0
  42. package/packages/engine/lib/report/export/machine.mjs +290 -0
  43. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  44. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  45. package/packages/engine/lib/report/theme/css.mjs +529 -0
  46. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  47. package/packages/engine/lib/report/version.mjs +78 -0
  48. package/packages/engine/package.json +14 -0
  49. package/packages/installer/lib/agents/targets.mjs +90 -0
  50. package/packages/installer/lib/agents/user-level.mjs +80 -0
  51. package/packages/installer/lib/cli/flags.mjs +20 -2
  52. package/packages/installer/lib/commands/doctor.mjs +13 -20
  53. package/packages/installer/lib/commands/install.mjs +160 -91
  54. package/packages/installer/lib/commands/repair.mjs +10 -6
  55. package/packages/installer/lib/commands/self-test.mjs +4 -3
  56. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  57. package/packages/installer/lib/commands/update.mjs +9 -4
  58. package/packages/installer/lib/commands/verify.mjs +13 -12
  59. package/packages/installer/lib/constants.mjs +13 -0
  60. package/packages/installer/lib/core/bundle.mjs +69 -92
  61. package/packages/installer/lib/core/conflict.mjs +5 -4
  62. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  63. package/packages/installer/lib/core/integrity.mjs +59 -0
  64. package/packages/installer/lib/core/lockfile.mjs +19 -3
  65. package/packages/installer/lib/core/manifest.mjs +48 -57
  66. package/packages/installer/lib/core/plan.mjs +213 -0
  67. package/packages/installer/lib/core/qa-home.mjs +145 -0
  68. package/packages/installer/lib/core/scope.mjs +274 -0
  69. package/packages/installer/lib/core/validate-install.mjs +49 -31
  70. package/packages/installer/package.json +1 -1
  71. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  72. package/shared/tooling/qa-tool.mjs +161 -0
  73. package/skills/qa-api/SKILL.md +5 -5
  74. package/skills/qa-api/references/deterministic-tooling.md +34 -32
  75. package/skills/qa-api/references/evidence-and-reporting.md +5 -5
  76. package/skills/qa-api/scripts/qa-tool.mjs +162 -0
  77. package/skills/qa-audit/SKILL.md +5 -5
  78. package/skills/qa-audit/references/deterministic-tooling.md +34 -32
  79. package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
  80. package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
  81. package/skills/qa-debug/SKILL.md +6 -6
  82. package/skills/qa-debug/references/deterministic-tooling.md +34 -32
  83. package/skills/qa-debug/references/diagnostic-engine.md +3 -3
  84. package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
  85. package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
  86. package/skills/qa-explore/SKILL.md +31 -15
  87. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  88. package/skills/qa-explore/references/api-replay.md +40 -1
  89. package/skills/qa-explore/references/deterministic-tooling.md +34 -32
  90. package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
  91. package/skills/qa-explore/references/report-pipeline.md +266 -96
  92. package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
  93. package/skills/qa-fix/SKILL.md +4 -4
  94. package/skills/qa-fix/references/deterministic-tooling.md +34 -32
  95. package/skills/qa-fix/references/diagnostic-engine.md +3 -3
  96. package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
  97. package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
  98. package/skills/qa-flaky/SKILL.md +4 -4
  99. package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
  100. package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
  101. package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
  102. package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
  103. package/skills/qa-init/SKILL.md +4 -4
  104. package/skills/qa-init/references/deterministic-tooling.md +34 -32
  105. package/skills/qa-init/references/evidence-and-reporting.md +5 -5
  106. package/skills/qa-init/scripts/qa-tool.mjs +162 -0
  107. package/skills/qa-report/SKILL.md +7 -7
  108. package/skills/qa-report/references/deterministic-tooling.md +34 -32
  109. package/skills/qa-report/references/diagnostic-engine.md +3 -3
  110. package/skills/qa-report/references/evidence-and-reporting.md +5 -5
  111. package/skills/qa-report/scripts/qa-tool.mjs +162 -0
  112. package/skills/qa-review/references/evidence-and-reporting.md +5 -5
  113. package/skills/qa-run/SKILL.md +6 -6
  114. package/skills/qa-run/references/deterministic-tooling.md +34 -32
  115. package/skills/qa-run/references/evidence-and-reporting.md +5 -5
  116. package/skills/qa-run/scripts/qa-tool.mjs +162 -0
  117. package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
  118. package/shared/analysis/lib/qa_analysis/branding.py +0 -175
  119. package/shared/analysis/lib/qa_analysis/cli.py +0 -144
  120. package/shared/analysis/lib/qa_analysis/context.py +0 -233
  121. package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
  122. package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
  123. package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
  124. package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
  125. package/shared/analysis/lib/qa_analysis/har.py +0 -87
  126. package/shared/analysis/lib/qa_analysis/junit.py +0 -104
  127. package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
  128. package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
  129. package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
  130. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
  131. package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
  132. package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
  133. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
  134. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
  135. package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
  136. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
  137. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
  138. package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
  139. package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
  140. package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
  141. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
  142. package/shared/tooling/qa_tool.py +0 -127
  143. /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
  144. /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
  145. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
  146. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
  147. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
@@ -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
+ }