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
|
@@ -4,18 +4,15 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { BACKUP_DIR, LOCKFILE, EXIT } from '../constants.mjs';
|
|
7
|
-
import { resolveSourceRoot
|
|
8
|
-
import { listSkills, skillFiles } from '../core/manifest.mjs';
|
|
9
|
-
import { bundleFilesForSkill } from '../core/bundle.mjs';
|
|
10
|
-
import { hashBytes } from '../core/hash.mjs';
|
|
7
|
+
import { resolveSourceRoot } from '../core/paths.mjs';
|
|
11
8
|
import { detectConflicts } from '../core/conflict.mjs';
|
|
12
|
-
import { Transaction } from '../core/fs-safe.mjs';
|
|
13
|
-
import { buildLock, readLock, serializeLock,
|
|
9
|
+
import { Transaction, readLinkTarget, canLink } from '../core/fs-safe.mjs';
|
|
10
|
+
import { buildLock, readLock, serializeLock, scopeLockPath } from '../core/lockfile.mjs';
|
|
11
|
+
import { resolveScope } from '../core/scope.mjs';
|
|
12
|
+
import { buildPlan, dedupePlan } from '../core/plan.mjs';
|
|
13
|
+
import { resolveScopeTargets, describeUnservedAgents } from '../agents/targets.mjs';
|
|
14
14
|
import { loadConfig } from '../core/config.mjs';
|
|
15
|
-
import { readSkillMeta } from '../core/skill-meta.mjs';
|
|
16
|
-
import { renderWrapper } from '../core/wrappers.mjs';
|
|
17
15
|
import { conflictError, verifyError } from '../core/errors.mjs';
|
|
18
|
-
import { resolveInstallTargets } from '../agents/registry.mjs';
|
|
19
16
|
import { createLogger } from '../core/logger.mjs';
|
|
20
17
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
21
18
|
import { validateInstall } from '../core/validate-install.mjs';
|
|
@@ -92,6 +89,19 @@ function reportFrameworkFit(root, logger) {
|
|
|
92
89
|
logger.info(' → unit tests only (Jest, Vitest, Jasmine, pytest)? That is expected');
|
|
93
90
|
}
|
|
94
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Name the hosts a global install could not serve, and what to do about them.
|
|
94
|
+
*
|
|
95
|
+
* Staying quiet here would leave a Cursor user waiting for slash commands that are never
|
|
96
|
+
* going to appear, with an install that reported success.
|
|
97
|
+
*/
|
|
98
|
+
function reportUnserved(unserved, logger) {
|
|
99
|
+
if (!unserved || unserved.length === 0) return;
|
|
100
|
+
logger.info(` → not installed for ${unserved.map((entry) => entry.id).join(', ')}:`);
|
|
101
|
+
for (const entry of unserved) logger.info(` ${entry.id} — ${entry.reason}`);
|
|
102
|
+
logger.info(' → run `qa install --project .` inside a repository to serve those hosts');
|
|
103
|
+
}
|
|
104
|
+
|
|
95
105
|
/**
|
|
96
106
|
* Core install implementation shared by install / onboard / repair / update.
|
|
97
107
|
*
|
|
@@ -108,7 +118,9 @@ function reportFrameworkFit(root, logger) {
|
|
|
108
118
|
*/
|
|
109
119
|
export async function executeInstall({
|
|
110
120
|
projectRoot,
|
|
121
|
+
scope: providedScope = null,
|
|
111
122
|
agentIds = [],
|
|
123
|
+
allAgents = false,
|
|
112
124
|
force = false,
|
|
113
125
|
dryRun = false,
|
|
114
126
|
skipValidate = false,
|
|
@@ -118,85 +130,58 @@ export async function executeInstall({
|
|
|
118
130
|
} = {}) {
|
|
119
131
|
const logger = log ?? createLogger();
|
|
120
132
|
const sourceRoot = resolveSourceRoot();
|
|
121
|
-
|
|
133
|
+
// A caller that names a scope gets it; everything else is a project install at the
|
|
134
|
+
// path it asked for, which is what every pre-0.11 caller means.
|
|
135
|
+
const scope = providedScope ?? resolveScope({ project: projectRoot ?? process.cwd() });
|
|
136
|
+
const root = scope.root;
|
|
122
137
|
const { config } = loadConfig(root);
|
|
123
138
|
const explicit = agentIds.length > 0 ? agentIds : config.agents ?? [];
|
|
124
|
-
const agents =
|
|
125
|
-
const
|
|
139
|
+
const agents = resolveScopeTargets(scope, { explicitIds: explicit, allAgents });
|
|
140
|
+
const unserved = describeUnservedAgents(scope, agents);
|
|
141
|
+
|
|
142
|
+
if (agents.length === 0) {
|
|
143
|
+
throw conflictError(
|
|
144
|
+
`no agent can be served by a ${scope.kind} install here`,
|
|
145
|
+
scope.kind === 'global'
|
|
146
|
+
? 'no supported host has a user-level skills directory on this machine; ' +
|
|
147
|
+
'install per project with: qa install --project .'
|
|
148
|
+
: 'pass --agent <id> to name one explicitly',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Probe once, before planning. A filesystem that cannot hold a directory link — FAT,
|
|
153
|
+
// some network and container mounts, Windows without junction support — gets copies
|
|
154
|
+
// instead of a failed install.
|
|
155
|
+
const linksSupported = scope.shareEngine ? canLink(scope.root) : false;
|
|
156
|
+
const { skills, files, links } = buildPlan({
|
|
157
|
+
sourceRoot,
|
|
158
|
+
scope,
|
|
159
|
+
targets: agents,
|
|
160
|
+
preferLinks: linksSupported,
|
|
161
|
+
});
|
|
126
162
|
|
|
127
163
|
if (!json) {
|
|
128
164
|
logger.step(`source: ${sourceRoot}`);
|
|
129
|
-
logger.step(`
|
|
165
|
+
logger.step(`scope: ${scope.label}`);
|
|
130
166
|
logger.step(`agents: ${agents.map((a) => a.id).join(', ')}`);
|
|
131
167
|
logger.step(`skills: ${skills.length}`);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
for (const agent of agents) {
|
|
138
|
-
for (const skill of skills) {
|
|
139
|
-
const files = skillFiles(sourceRoot, skill);
|
|
140
|
-
for (const rel of files) {
|
|
141
|
-
if (rel.startsWith('tests/') || rel.includes('/tests/')) continue;
|
|
142
|
-
const abs = path.join(sourceRoot, 'skills', skill, rel);
|
|
143
|
-
const content = fs.readFileSync(abs);
|
|
144
|
-
const dest = toPosix(path.join(agent.skillsDir, skill, rel));
|
|
145
|
-
planned.push({
|
|
146
|
-
path: dest,
|
|
147
|
-
sha256: hashBytes(content),
|
|
148
|
-
bytes: content.length,
|
|
149
|
-
owner: 'skill',
|
|
150
|
-
skill,
|
|
151
|
-
agent: agent.id,
|
|
152
|
-
content,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
for (const bundled of bundleFilesForSkill(sourceRoot, skill)) {
|
|
156
|
-
const dest = toPosix(path.join(agent.skillsDir, skill, bundled.rel));
|
|
157
|
-
planned.push({
|
|
158
|
-
path: dest,
|
|
159
|
-
sha256: hashBytes(bundled.content),
|
|
160
|
-
bytes: bundled.content.length,
|
|
161
|
-
owner: 'skill',
|
|
162
|
-
skill,
|
|
163
|
-
agent: agent.id,
|
|
164
|
-
content: bundled.content,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (agent.wrapperFormat && agent.wrapperDir) {
|
|
170
|
-
for (const skill of skills) {
|
|
171
|
-
const meta = readSkillMeta(path.join(sourceRoot, 'skills', skill, 'SKILL.md'));
|
|
172
|
-
if (!meta.name) continue;
|
|
173
|
-
const { filename, content } = renderWrapper(agent.wrapperFormat, meta);
|
|
174
|
-
const buf = Buffer.from(content, 'utf8');
|
|
175
|
-
const dest = toPosix(path.join(agent.wrapperDir, filename));
|
|
176
|
-
planned.push({
|
|
177
|
-
path: dest,
|
|
178
|
-
sha256: hashBytes(buf),
|
|
179
|
-
bytes: buf.length,
|
|
180
|
-
owner: 'wrapper',
|
|
181
|
-
skill,
|
|
182
|
-
agent: agent.id,
|
|
183
|
-
content: buf,
|
|
184
|
-
});
|
|
168
|
+
if (scope.shareEngine) {
|
|
169
|
+
logger.step(`shared engine: ${path.join(scope.qaRoot, 'engine')}`);
|
|
170
|
+
if (!linksSupported) {
|
|
171
|
+
logger.warn('this filesystem does not support directory links — copying skills instead');
|
|
172
|
+
logger.info(' → the install works the same; it uses more disk and each agent gets its own copy');
|
|
185
173
|
}
|
|
186
174
|
}
|
|
187
175
|
}
|
|
188
176
|
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (prev && prev.sha256 !== entry.sha256) {
|
|
193
|
-
throw conflictError(`conflicting content for ${entry.path}`);
|
|
194
|
-
}
|
|
195
|
-
if (!prev) byPath.set(entry.path, entry);
|
|
177
|
+
const { unique: uniqueFiles, conflicts: contentConflicts } = dedupePlan(files);
|
|
178
|
+
if (contentConflicts.length > 0) {
|
|
179
|
+
throw conflictError(`conflicting content for ${contentConflicts[0]}`);
|
|
196
180
|
}
|
|
197
|
-
const unique =
|
|
181
|
+
const { unique: uniqueLinks } = dedupePlan(links);
|
|
182
|
+
const unique = [...uniqueFiles, ...uniqueLinks];
|
|
198
183
|
|
|
199
|
-
const priorLock = readLock(root);
|
|
184
|
+
const priorLock = readLock(root, scope.lockfile);
|
|
200
185
|
const conflicts = detectConflicts({
|
|
201
186
|
projectRoot: root,
|
|
202
187
|
planned: unique,
|
|
@@ -220,10 +205,42 @@ export async function executeInstall({
|
|
|
220
205
|
reportProgress(INSTALL_STEPS[0].label, 1);
|
|
221
206
|
|
|
222
207
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
223
|
-
|
|
208
|
+
// A scope that owns a directory keeps its backups inside it. Otherwise a global
|
|
209
|
+
// install writes them to `~/.qa/backups`, which is loose in the user's home — the
|
|
210
|
+
// exact thing owning a directory was meant to stop.
|
|
211
|
+
const backupRoot = scope.qaRootRelative ? path.join(scope.qaRootRelative, 'backups') : BACKUP_DIR;
|
|
212
|
+
const backupDir = path.join(root, backupRoot, stamp);
|
|
224
213
|
const tx = new Transaction(root, backupDir, { dryRun });
|
|
225
214
|
for (const entry of unique) {
|
|
226
|
-
tx.
|
|
215
|
+
if (entry.owner === 'link') tx.link(entry.path, entry.linkTarget);
|
|
216
|
+
else tx.write(entry.path, entry.content);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Files the previous install owned that this one does not: remove them.
|
|
220
|
+
//
|
|
221
|
+
// Without this, anything that leaves the pack between versions stays on disk
|
|
222
|
+
// forever, and `verify` cannot see it — verify checks that lockfile entries are
|
|
223
|
+
// present and unmodified, and an orphan is in no lockfile. Upgrading 0.9.1 to
|
|
224
|
+
// 0.9.3, which replaced the Python engine with a Node one, left 154 dead Python
|
|
225
|
+
// files in the user's repository, reported as a clean install and ready to be
|
|
226
|
+
// committed.
|
|
227
|
+
//
|
|
228
|
+
// Only files the pack itself wrote are touched: the candidates come from the
|
|
229
|
+
// prior lockfile, which is the record of what this installer created. Each goes
|
|
230
|
+
// through the same Transaction as a write, so it is backed up first and restored
|
|
231
|
+
// if any later step fails.
|
|
232
|
+
const orphans = [];
|
|
233
|
+
if (priorLock && !dryRun) {
|
|
234
|
+
const stillOwned = new Set(unique.map((entry) => entry.path));
|
|
235
|
+
for (const previous of priorLock.files ?? []) {
|
|
236
|
+
if (previous.path === scope.lockfile || stillOwned.has(previous.path)) continue;
|
|
237
|
+
const absolute = path.join(root, previous.path);
|
|
238
|
+
// A stale link still counts as present even when its target is gone, so lstat
|
|
239
|
+
// rather than exists — otherwise a broken link survives every upgrade.
|
|
240
|
+
if (!fs.existsSync(absolute) && readLinkTarget(absolute) === null) continue;
|
|
241
|
+
orphans.push(previous.path);
|
|
242
|
+
tx.delete(previous.path);
|
|
243
|
+
}
|
|
227
244
|
}
|
|
228
245
|
|
|
229
246
|
reportProgress(INSTALL_STEPS[1].label, 2);
|
|
@@ -237,18 +254,24 @@ export async function executeInstall({
|
|
|
237
254
|
skillsDir: a.skillsDir,
|
|
238
255
|
detected: a.detected,
|
|
239
256
|
})),
|
|
240
|
-
|
|
257
|
+
// Shared entries — the engine, and the canonical skill tree — belong to no single
|
|
258
|
+
// agent, so the optional keys are omitted rather than set to undefined. The schema
|
|
259
|
+
// types them as strings, and a present-but-undefined key fails that as loudly as a
|
|
260
|
+
// wrong one.
|
|
261
|
+
files: unique.map(({ path: p, sha256, bytes, owner, skill, agent, linkTarget }) => ({
|
|
241
262
|
path: p,
|
|
242
263
|
sha256,
|
|
243
264
|
bytes,
|
|
244
265
|
owner,
|
|
245
|
-
skill,
|
|
246
|
-
agent,
|
|
266
|
+
...(skill ? { skill } : {}),
|
|
267
|
+
...(agent ? { agent } : {}),
|
|
268
|
+
...(linkTarget ? { linkTarget } : {}),
|
|
247
269
|
})),
|
|
248
270
|
now: new Date().toISOString(),
|
|
271
|
+
scope,
|
|
249
272
|
});
|
|
250
273
|
if (!dryRun) {
|
|
251
|
-
tx.write(
|
|
274
|
+
tx.write(scope.lockfile, Buffer.from(serializeLock(lock), 'utf8'));
|
|
252
275
|
}
|
|
253
276
|
|
|
254
277
|
const summary = tx.commit();
|
|
@@ -258,7 +281,7 @@ export async function executeInstall({
|
|
|
258
281
|
|
|
259
282
|
let validation = null;
|
|
260
283
|
if (!dryRun && !skipValidate) {
|
|
261
|
-
validation = validateInstall(root);
|
|
284
|
+
validation = validateInstall(root, { scope });
|
|
262
285
|
if (!validation.ok) {
|
|
263
286
|
const failed = validation.checks.filter((c) => c.hard && !c.ok);
|
|
264
287
|
throw verifyError(
|
|
@@ -274,20 +297,38 @@ export async function executeInstall({
|
|
|
274
297
|
if (dryRun) {
|
|
275
298
|
if (!json) logger.ok(`dry run: would write ${summary.written} file(s)`);
|
|
276
299
|
} else if (!json) {
|
|
277
|
-
|
|
300
|
+
const linked = unique.filter((entry) => entry.owner === 'link').length;
|
|
301
|
+
logger.ok(
|
|
302
|
+
`installed ${unique.length - linked} file(s)` +
|
|
303
|
+
(linked > 0 ? ` and ${linked} link(s)` : '') +
|
|
304
|
+
`; lockfile ${scopeLockPath(scope)}`,
|
|
305
|
+
);
|
|
306
|
+
if (orphans.length > 0) {
|
|
307
|
+
logger.ok(`removed ${orphans.length} file(s) the previous version owned and this one does not`);
|
|
308
|
+
}
|
|
278
309
|
for (const step of INSTALL_STEPS) logger.ok(step.label);
|
|
279
310
|
reportInvocation(agents, logger);
|
|
280
|
-
|
|
311
|
+
// A global install has no project to inspect, and reporting "no framework detected"
|
|
312
|
+
// about the user's home directory would be noise pretending to be a diagnosis.
|
|
313
|
+
if (scope.kind !== 'global') reportFrameworkFit(root, logger);
|
|
314
|
+
reportUnserved(unserved, logger);
|
|
281
315
|
}
|
|
282
316
|
|
|
283
317
|
return {
|
|
284
318
|
ok: true,
|
|
285
319
|
dryRun,
|
|
320
|
+
scope: scope.kind,
|
|
321
|
+
root,
|
|
322
|
+
// Kept under its original name so every existing caller and test keeps working.
|
|
286
323
|
projectRoot: root,
|
|
324
|
+
qaRoot: scope.qaRoot,
|
|
287
325
|
agents: agents.map((a) => a.id),
|
|
326
|
+
unservedAgents: unserved,
|
|
288
327
|
skills: skills.length,
|
|
289
|
-
files: unique.length,
|
|
290
|
-
|
|
328
|
+
files: unique.filter((entry) => entry.owner !== 'link').length,
|
|
329
|
+
links: unique.filter((entry) => entry.owner === 'link').length,
|
|
330
|
+
lockfile: dryRun ? null : scope.lockfile,
|
|
331
|
+
removed: orphans,
|
|
291
332
|
validation,
|
|
292
333
|
};
|
|
293
334
|
}
|
|
@@ -296,17 +337,45 @@ export async function runInstall(argv, { log } = {}) {
|
|
|
296
337
|
const opts = parseCommonFlags(argv);
|
|
297
338
|
const logger = log ?? createLogger();
|
|
298
339
|
if (opts.help) {
|
|
299
|
-
logger.result(`Usage: qa install [--agent <id>]... [--
|
|
340
|
+
logger.result(`Usage: qa install [scope] [--agent <id>]... [--all-agents]
|
|
341
|
+
[--force] [--dry-run] [--yes] [--json]
|
|
342
|
+
|
|
343
|
+
Scope — choose one; --project is the default:
|
|
344
|
+
|
|
345
|
+
--global, -g Install once for this machine, in ~/.qa-engineer.
|
|
346
|
+
One engine, one copy of the skills, linked into the
|
|
347
|
+
user-level skills directory of every host that has one.
|
|
348
|
+
Every project then works with no per-project install.
|
|
349
|
+
Override the location with QA_ENGINEER_HOME.
|
|
350
|
+
|
|
351
|
+
--workspace, -w Install once at the root of the monorepo containing the
|
|
352
|
+
current directory, shared by every package in it.
|
|
353
|
+
Detects pnpm, npm/yarn workspaces, Nx, Turborepo, Lerna,
|
|
354
|
+
Rush, Go, and Cargo.
|
|
300
355
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
356
|
+
--project [dir], -C Install into one project (default: this directory).
|
|
357
|
+
Self-contained: the engine travels inside each skill, so
|
|
358
|
+
the repository works on a machine with nothing installed.
|
|
359
|
+
|
|
360
|
+
Other:
|
|
361
|
+
--all-agents Install for every host that has a user-level directory,
|
|
362
|
+
not just the ones detected here.
|
|
363
|
+
--agent <id> Target a specific host; repeatable.
|
|
364
|
+
--force Overwrite files a previous install does not own.
|
|
365
|
+
--dry-run Report what would change and write nothing.`);
|
|
304
366
|
return EXIT.OK;
|
|
305
367
|
}
|
|
306
368
|
|
|
369
|
+
const scope = resolveScope({
|
|
370
|
+
global: opts.global,
|
|
371
|
+
workspace: opts.workspace,
|
|
372
|
+
project: opts.project,
|
|
373
|
+
});
|
|
374
|
+
|
|
307
375
|
const result = await executeInstall({
|
|
308
|
-
|
|
376
|
+
scope,
|
|
309
377
|
agentIds: opts.agents,
|
|
378
|
+
allAgents: opts.allAgents,
|
|
310
379
|
force: opts.force,
|
|
311
380
|
dryRun: opts.dryRun,
|
|
312
381
|
json: opts.json,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `qa repair` — fix common install problems by reinstalling pack-owned files.
|
|
2
2
|
|
|
3
3
|
import { EXIT, LOCKFILE } from '../constants.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveOperatingScope } from '../core/scope.mjs';
|
|
5
5
|
import { readLock } from '../core/lockfile.mjs';
|
|
6
6
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
7
7
|
import { executeInstall } from './install.mjs';
|
|
@@ -21,9 +21,10 @@ Repair a broken or drifted installation:
|
|
|
21
21
|
return EXIT.OK;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
24
|
+
const scope = resolveOperatingScope(opts);
|
|
25
|
+
const root = scope.root;
|
|
26
|
+
const prior = readLock(root, scope.lockfile);
|
|
27
|
+
const before = validateInstall(root, { scope });
|
|
27
28
|
|
|
28
29
|
if (!opts.json) {
|
|
29
30
|
logger.step(`repairing ${root}`);
|
|
@@ -40,7 +41,10 @@ Repair a broken or drifted installation:
|
|
|
40
41
|
: (prior?.agents ?? []).map((a) => a.id).filter(Boolean);
|
|
41
42
|
|
|
42
43
|
const result = await executeInstall({
|
|
43
|
-
|
|
44
|
+
// The scope the command resolved, not just its root: without it the reinstall
|
|
45
|
+
// reverts a global install to a project-shaped one — 1225 files and a lockfile in
|
|
46
|
+
// the wrong place — while reporting success.
|
|
47
|
+
scope,
|
|
44
48
|
agentIds,
|
|
45
49
|
force: true,
|
|
46
50
|
dryRun: opts.dryRun,
|
|
@@ -48,7 +52,7 @@ Repair a broken or drifted installation:
|
|
|
48
52
|
log: logger,
|
|
49
53
|
});
|
|
50
54
|
|
|
51
|
-
const after = opts.dryRun ? before : validateInstall(root);
|
|
55
|
+
const after = opts.dryRun ? before : validateInstall(root, { scope });
|
|
52
56
|
|
|
53
57
|
if (!opts.json) {
|
|
54
58
|
if (after.ok) logger.ok('repair complete — installation validated');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `qa self-test` — verify an installation is operational.
|
|
2
2
|
|
|
3
3
|
import { EXIT } from '../constants.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveOperatingScope } from '../core/scope.mjs';
|
|
5
5
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
6
6
|
import { validateInstall } from '../core/validate-install.mjs';
|
|
7
7
|
import { createLogger } from '../core/logger.mjs';
|
|
@@ -17,8 +17,9 @@ Prints PASS/FAIL per check. Does not invoke live AI agents.`);
|
|
|
17
17
|
return EXIT.OK;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const
|
|
21
|
-
const
|
|
20
|
+
const scope = resolveOperatingScope(opts);
|
|
21
|
+
const root = scope.root;
|
|
22
|
+
const { ok, checks } = validateInstall(root, { scope });
|
|
22
23
|
|
|
23
24
|
if (!opts.json) {
|
|
24
25
|
logger.step(`self-test — ${root}`);
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { EXIT, LOCKFILE, BACKUP_DIR } from '../constants.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { resolveOperatingScope } from '../core/scope.mjs';
|
|
20
20
|
import { readLock, lockPath } from '../core/lockfile.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import { entryDigest } from '../core/integrity.mjs';
|
|
22
22
|
import { Transaction, pruneEmptyDirs } from '../core/fs-safe.mjs';
|
|
23
23
|
import { conflictError, verifyError } from '../core/errors.mjs';
|
|
24
24
|
import { createLogger } from '../core/logger.mjs';
|
|
@@ -40,7 +40,7 @@ function findPycache(dir) {
|
|
|
40
40
|
return found;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export async function runUninstall(argv, { log } = {}) {
|
|
43
|
+
export async function runUninstall(argv, { log, env = process.env } = {}) {
|
|
44
44
|
const opts = parseCommonFlags(argv);
|
|
45
45
|
const logger = log ?? createLogger();
|
|
46
46
|
if (opts.help) {
|
|
@@ -54,8 +54,11 @@ Only pack-owned files are removed; anything else is left alone.
|
|
|
54
54
|
return EXIT.OK;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
// `env` is injected by the test suite so a global uninstall can be exercised
|
|
58
|
+
// against a temporary home rather than the developer's own.
|
|
59
|
+
const scope = resolveOperatingScope({ ...opts, env });
|
|
60
|
+
const root = scope.root;
|
|
61
|
+
const lock = readLock(root, scope.lockfile);
|
|
59
62
|
if (!lock) {
|
|
60
63
|
if (!opts.json) {
|
|
61
64
|
logger.error(`FAIL no ${LOCKFILE} in ${root}`);
|
|
@@ -73,7 +76,7 @@ Only pack-owned files are removed; anything else is left alone.
|
|
|
73
76
|
missing.push(entry.path);
|
|
74
77
|
continue;
|
|
75
78
|
}
|
|
76
|
-
if (
|
|
79
|
+
if (entryDigest(root, entry) !== entry.sha256) drifted.push(entry.path);
|
|
77
80
|
present.push(entry.path);
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -97,9 +100,12 @@ Only pack-owned files are removed; anything else is left alone.
|
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
100
|
-
|
|
103
|
+
// Same rule as install: a scope that owns a directory keeps its backups inside it,
|
|
104
|
+
// rather than dropping `.qa/backups` into the user's home.
|
|
105
|
+
const backupRoot = scope.qaRootRelative ? path.join(scope.qaRootRelative, 'backups') : BACKUP_DIR;
|
|
106
|
+
const tx = new Transaction(root, path.join(root, backupRoot, stamp), { dryRun: opts.dryRun });
|
|
101
107
|
for (const rel of present) tx.delete(rel);
|
|
102
|
-
if (fs.existsSync(
|
|
108
|
+
if (fs.existsSync(path.join(root, scope.lockfile))) tx.delete(scope.lockfile);
|
|
103
109
|
const summary = tx.commit();
|
|
104
110
|
|
|
105
111
|
// Byproducts of the pack's own bundled code: Python writes __pycache__ next to
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// `qa update` — refresh an install from the current pack source.
|
|
2
2
|
|
|
3
3
|
import { EXIT } from '../constants.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveSourceRoot } from '../core/paths.mjs';
|
|
5
|
+
import { resolveOperatingScope } from '../core/scope.mjs';
|
|
5
6
|
import { readLock } from '../core/lockfile.mjs';
|
|
6
7
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
7
8
|
import { executeInstall } from './install.mjs';
|
|
@@ -20,9 +21,10 @@ Always re-validates after updating.`);
|
|
|
20
21
|
return EXIT.OK;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
+
const scope = resolveOperatingScope(opts);
|
|
25
|
+
const root = scope.root;
|
|
24
26
|
const sourceRoot = resolveSourceRoot();
|
|
25
|
-
const lock = readLock(root);
|
|
27
|
+
const lock = readLock(root, scope.lockfile);
|
|
26
28
|
const installedVersion = lock?.pack?.version ?? null;
|
|
27
29
|
|
|
28
30
|
if (!opts.json) {
|
|
@@ -43,7 +45,10 @@ Always re-validates after updating.`);
|
|
|
43
45
|
: (lock?.agents ?? []).map((a) => a.id).filter(Boolean);
|
|
44
46
|
|
|
45
47
|
const result = await executeInstall({
|
|
46
|
-
|
|
48
|
+
// The scope the command resolved, not just its root: without it the reinstall
|
|
49
|
+
// reverts a global install to a project-shaped one — 1225 files and a lockfile in
|
|
50
|
+
// the wrong place — while reporting success.
|
|
51
|
+
scope,
|
|
47
52
|
agentIds,
|
|
48
53
|
force: true,
|
|
49
54
|
dryRun: opts.dryRun,
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { EXIT, LOCKFILE } from '../constants.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { resolveOperatingScope } from '../core/scope.mjs';
|
|
7
7
|
import { readLock } from '../core/lockfile.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { entryDigest } from '../core/integrity.mjs';
|
|
9
9
|
import { verifyError } from '../core/errors.mjs';
|
|
10
10
|
import { createLogger } from '../core/logger.mjs';
|
|
11
11
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
@@ -21,8 +21,9 @@ On failure: run qa repair`);
|
|
|
21
21
|
return EXIT.OK;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const
|
|
25
|
-
const
|
|
24
|
+
const scope = resolveOperatingScope(opts);
|
|
25
|
+
const root = scope.root;
|
|
26
|
+
const lock = readLock(root, scope.lockfile);
|
|
26
27
|
if (!lock) {
|
|
27
28
|
if (!opts.json) {
|
|
28
29
|
logger.error(`FAIL no ${LOCKFILE}`);
|
|
@@ -33,14 +34,14 @@ On failure: run qa repair`);
|
|
|
33
34
|
|
|
34
35
|
const problems = [];
|
|
35
36
|
for (const entry of lock.files) {
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
problems.push({ path: entry.path, reason: 'missing' });
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
const actual = entryDigest(root, entry);
|
|
38
|
+
if (actual === null) {
|
|
39
|
+
problems.push({ path: entry.path, reason: entry.owner === 'link' ? 'link missing' : 'missing' });
|
|
40
|
+
} else if (actual !== entry.sha256) {
|
|
41
|
+
problems.push({
|
|
42
|
+
path: entry.path,
|
|
43
|
+
reason: entry.owner === 'link' ? 'link points somewhere else' : 'hash mismatch',
|
|
44
|
+
});
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -11,6 +11,19 @@ export const LOCKFILE = 'qa-lock.json';
|
|
|
11
11
|
export const QA_DIR = '.qa';
|
|
12
12
|
export const BACKUP_DIR = `${QA_DIR}/backups`;
|
|
13
13
|
|
|
14
|
+
// The directory QA Engineer owns. In a global install it sits in the user's home; in a
|
|
15
|
+
// workspace install it sits at the monorepo root. Same name in both, so a developer who
|
|
16
|
+
// has seen one recognises the other.
|
|
17
|
+
export const QA_HOME_DIR_NAME = '.qa-engineer';
|
|
18
|
+
|
|
19
|
+
// Full override for where the global install lives. Also what makes global installs
|
|
20
|
+
// testable — the suite points it at a temporary directory instead of a real $HOME.
|
|
21
|
+
export const QA_HOME_ENV = 'QA_ENGINEER_HOME';
|
|
22
|
+
|
|
23
|
+
// Where the shared engine lands inside a qaRoot, and where the canonical skills live.
|
|
24
|
+
export const SHARED_ENGINE_DIR = 'engine';
|
|
25
|
+
export const SHARED_SKILLS_STORE = 'skills';
|
|
26
|
+
|
|
14
27
|
// Marker line prepended to every generated invocation wrapper. Wrappers are
|
|
15
28
|
// disposable build artifacts; this line tells humans and `verify` that the file
|
|
16
29
|
// is pack-owned and regenerated, never hand-edited.
|