qa-engineer 0.10.0 → 0.12.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/README.md +167 -59
- package/package.json +1 -1
- package/packages/engine/bin/qa-engine.mjs +232 -8
- package/packages/engine/lib/analysis/har.mjs +34 -0
- package/packages/engine/lib/analysis/network.mjs +237 -0
- package/packages/engine/lib/analysis/report-html.mjs +47 -734
- 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/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 +2 -2
- 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 +6 -4
- package/packages/installer/lib/commands/install.mjs +134 -93
- 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/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/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 +37 -12
- package/packages/installer/package.json +1 -1
- package/packages/installer/schemas/qa-lock.schema.json +119 -21
- package/shared/tooling/qa-tool.mjs +41 -5
- package/skills/qa-api/scripts/qa-tool.mjs +41 -5
- package/skills/qa-audit/scripts/qa-tool.mjs +41 -5
- package/skills/qa-debug/SKILL.md +3 -0
- package/skills/qa-debug/references/failure-handoff.md +44 -0
- package/skills/qa-debug/scripts/qa-tool.mjs +41 -5
- package/skills/qa-explore/SKILL.md +26 -10
- 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/report-pipeline.md +265 -95
- package/skills/qa-explore/scripts/qa-tool.mjs +41 -5
- package/skills/qa-fix/scripts/qa-tool.mjs +41 -5
- package/skills/qa-flaky/scripts/qa-tool.mjs +41 -5
- package/skills/qa-init/scripts/qa-tool.mjs +41 -5
- package/skills/qa-report/scripts/qa-tool.mjs +41 -5
- package/skills/qa-run/README.md +7 -0
- package/skills/qa-run/SKILL.md +12 -7
- package/skills/qa-run/contracts/execution-result.schema.json +77 -0
- package/skills/qa-run/examples/execute-playwright.md +42 -10
- package/skills/qa-run/examples/plan-a-run.md +5 -3
- package/skills/qa-run/references/artifact-collector.md +2 -1
- package/skills/qa-run/references/command-builder.md +18 -1
- package/skills/qa-run/references/execution-strategy.md +2 -0
- package/skills/qa-run/references/failure-handoff.md +44 -0
- package/skills/qa-run/references/playwright-artifacts.md +3 -1
- package/skills/qa-run/references/playwright-execution.md +14 -1
- package/skills/qa-run/scripts/qa-tool.mjs +41 -5
|
@@ -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
|
+
}
|
|
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { LOCKFILE, SHARED_SKILLS_DIR, CLAUDE_SKILLS_DIR } from '../constants.mjs';
|
|
6
6
|
import { readLock } from './lockfile.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { entryDigest } from './integrity.mjs';
|
|
8
8
|
import { verifyEngine, packHasBundles } from './bundle.mjs';
|
|
9
9
|
import { BUNDLE_DEST, BUNDLE_MANIFEST } from './manifest.mjs';
|
|
10
10
|
|
|
@@ -17,17 +17,18 @@ import { BUNDLE_DEST, BUNDLE_MANIFEST } from './manifest.mjs';
|
|
|
17
17
|
* @param {string} projectRoot
|
|
18
18
|
* @returns {{ ok: boolean, checks: CheckResult[] }}
|
|
19
19
|
*/
|
|
20
|
-
export function validateInstall(projectRoot) {
|
|
20
|
+
export function validateInstall(projectRoot, { scope = null } = {}) {
|
|
21
21
|
/** @type {CheckResult[]} */
|
|
22
22
|
const checks = [];
|
|
23
23
|
|
|
24
|
-
const
|
|
24
|
+
const lockRelative = scope?.lockfile ?? LOCKFILE;
|
|
25
|
+
const lock = readLock(projectRoot, lockRelative);
|
|
25
26
|
if (!lock) {
|
|
26
27
|
checks.push({
|
|
27
28
|
id: 'lockfile',
|
|
28
29
|
ok: false,
|
|
29
30
|
hard: true,
|
|
30
|
-
message: `${
|
|
31
|
+
message: `${lockRelative} missing`,
|
|
31
32
|
hint: 'run: qa install',
|
|
32
33
|
});
|
|
33
34
|
return { ok: false, checks };
|
|
@@ -37,17 +38,12 @@ export function validateInstall(projectRoot) {
|
|
|
37
38
|
id: 'lockfile',
|
|
38
39
|
ok: true,
|
|
39
40
|
hard: true,
|
|
40
|
-
message: `${
|
|
41
|
+
message: `${lockRelative} present (${lock.files.length} files, pack ${lock.pack?.version ?? '?'})`,
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
let drift = 0;
|
|
44
45
|
for (const entry of lock.files) {
|
|
45
|
-
|
|
46
|
-
if (!fs.existsSync(abs)) {
|
|
47
|
-
drift += 1;
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
if (hashFile(abs) !== entry.sha256) drift += 1;
|
|
46
|
+
if (entryDigest(projectRoot, entry) !== entry.sha256) drift += 1;
|
|
51
47
|
}
|
|
52
48
|
checks.push({
|
|
53
49
|
id: 'integrity',
|
|
@@ -92,7 +88,36 @@ export function validateInstall(projectRoot) {
|
|
|
92
88
|
: 'no contract schemas found (optional for minimal installs)',
|
|
93
89
|
});
|
|
94
90
|
|
|
95
|
-
|
|
91
|
+
// Where the engine should be depends on how it was installed: a shared scope has
|
|
92
|
+
// exactly one copy in its qaRoot, a project install has one inside each bundling
|
|
93
|
+
// skill. Looking only for the second is what made the first report a broken install.
|
|
94
|
+
const sharedScope = scope?.shareEngine || lock.scope?.sharedEngine;
|
|
95
|
+
if (sharedScope) {
|
|
96
|
+
const qaRootRelative = scope?.qaRootRelative ?? lock.scope?.qaRoot ?? '.';
|
|
97
|
+
const libDir = path.join(projectRoot, qaRootRelative, 'engine');
|
|
98
|
+
const bundleOk = fs.existsSync(path.join(libDir, 'bin', 'qa-engine.mjs'));
|
|
99
|
+
checks.push({
|
|
100
|
+
id: 'engine',
|
|
101
|
+
ok: bundleOk,
|
|
102
|
+
hard: true,
|
|
103
|
+
message: bundleOk
|
|
104
|
+
? `deterministic engine shared at ${path.relative(projectRoot, libDir) || '.'}`
|
|
105
|
+
: 'shared deterministic engine missing',
|
|
106
|
+
hint: bundleOk ? undefined : 'run: qa repair',
|
|
107
|
+
});
|
|
108
|
+
if (bundleOk) {
|
|
109
|
+
const result = verifyEngine({ libDir });
|
|
110
|
+
checks.push({
|
|
111
|
+
id: 'engine-runs',
|
|
112
|
+
ok: result.ok,
|
|
113
|
+
hard: true,
|
|
114
|
+
message: result.ok
|
|
115
|
+
? `shared engine runs (node ${process.versions.node})`
|
|
116
|
+
: `shared engine failed to run: ${result.stderr || 'unknown error'}`,
|
|
117
|
+
hint: result.ok ? undefined : 'run: qa repair',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
} else if (packHasBundles()) {
|
|
96
121
|
const bundledSkill = Object.keys(BUNDLE_MANIFEST)[0];
|
|
97
122
|
let libDir = null;
|
|
98
123
|
for (const base of [SHARED_SKILLS_DIR, CLAUDE_SKILLS_DIR]) {
|
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
"description": "qa-lock.json records exactly what was installed and a hash per file, so an install can be verified, updated, or removed without guessing. Written by the installer; not hand-edited.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
|
-
"required": [
|
|
8
|
+
"required": [
|
|
9
|
+
"lockfileVersion",
|
|
10
|
+
"pack",
|
|
11
|
+
"installer",
|
|
12
|
+
"generatedAt",
|
|
13
|
+
"agents",
|
|
14
|
+
"files"
|
|
15
|
+
],
|
|
9
16
|
"properties": {
|
|
10
17
|
"lockfileVersion": {
|
|
11
18
|
"type": "integer",
|
|
@@ -14,11 +21,22 @@
|
|
|
14
21
|
"pack": {
|
|
15
22
|
"type": "object",
|
|
16
23
|
"additionalProperties": false,
|
|
17
|
-
"required": [
|
|
24
|
+
"required": [
|
|
25
|
+
"name",
|
|
26
|
+
"version",
|
|
27
|
+
"specRevision"
|
|
28
|
+
],
|
|
18
29
|
"properties": {
|
|
19
|
-
"name": {
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
"name": {
|
|
31
|
+
"type": "string"
|
|
32
|
+
},
|
|
33
|
+
"version": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"pattern": "^\\d+\\.\\d+\\.\\d+"
|
|
36
|
+
},
|
|
37
|
+
"specRevision": {
|
|
38
|
+
"type": "string"
|
|
39
|
+
}
|
|
22
40
|
}
|
|
23
41
|
},
|
|
24
42
|
"installer": {
|
|
@@ -35,17 +53,31 @@
|
|
|
35
53
|
"items": {
|
|
36
54
|
"type": "object",
|
|
37
55
|
"additionalProperties": false,
|
|
38
|
-
"required": [
|
|
56
|
+
"required": [
|
|
57
|
+
"id",
|
|
58
|
+
"name",
|
|
59
|
+
"tier"
|
|
60
|
+
],
|
|
39
61
|
"properties": {
|
|
40
|
-
"id": {
|
|
41
|
-
|
|
62
|
+
"id": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"pattern": "^[a-z][a-z0-9-]*$"
|
|
65
|
+
},
|
|
66
|
+
"name": {
|
|
67
|
+
"type": "string"
|
|
68
|
+
},
|
|
42
69
|
"tier": {
|
|
43
|
-
"type": [
|
|
70
|
+
"type": [
|
|
71
|
+
"integer",
|
|
72
|
+
"null"
|
|
73
|
+
],
|
|
44
74
|
"minimum": 1,
|
|
45
75
|
"maximum": 2,
|
|
46
|
-
"description": "Support tier, or null when the host is unknown
|
|
76
|
+
"description": "Support tier, or null when the host is unknown \u2014 the shared Agent Skills path was installed without identifying a product."
|
|
77
|
+
},
|
|
78
|
+
"skillsDir": {
|
|
79
|
+
"type": "string"
|
|
47
80
|
},
|
|
48
|
-
"skillsDir": { "type": "string" },
|
|
49
81
|
"detected": {
|
|
50
82
|
"type": "boolean",
|
|
51
83
|
"description": "True only when project markers identified this host. False when it was requested explicitly or stands in as the unknown-agent path. Optional for lockfiles written before this field existed."
|
|
@@ -59,7 +91,12 @@
|
|
|
59
91
|
"items": {
|
|
60
92
|
"type": "object",
|
|
61
93
|
"additionalProperties": false,
|
|
62
|
-
"required": [
|
|
94
|
+
"required": [
|
|
95
|
+
"path",
|
|
96
|
+
"sha256",
|
|
97
|
+
"bytes",
|
|
98
|
+
"owner"
|
|
99
|
+
],
|
|
63
100
|
"properties": {
|
|
64
101
|
"path": {
|
|
65
102
|
"type": "string",
|
|
@@ -67,11 +104,36 @@
|
|
|
67
104
|
"description": "Project-relative POSIX path. Must not be absolute and must not contain a '..' segment: a lockfile travels with a cloned repository, and uninstall deletes what it lists.",
|
|
68
105
|
"pattern": "^(?!/)(?!.*(^|/)\\.\\.($|/)).+$"
|
|
69
106
|
},
|
|
70
|
-
"sha256": {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"
|
|
107
|
+
"sha256": {
|
|
108
|
+
"type": "string",
|
|
109
|
+
"pattern": "^[0-9a-f]{64}$"
|
|
110
|
+
},
|
|
111
|
+
"bytes": {
|
|
112
|
+
"type": "integer",
|
|
113
|
+
"minimum": 0
|
|
114
|
+
},
|
|
115
|
+
"owner": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"enum": [
|
|
118
|
+
"skill",
|
|
119
|
+
"wrapper",
|
|
120
|
+
"config",
|
|
121
|
+
"engine",
|
|
122
|
+
"link"
|
|
123
|
+
],
|
|
124
|
+
"description": "What created the entry. `engine` is the shared deterministic engine, present once per scope. `link` is a symbolic link into the canonical skill tree, whose sha256 is the hash of its scope-relative target rather than of file content \u2014 a link has no content, and hashing the target is what lets verify catch one that has been repointed."
|
|
125
|
+
},
|
|
126
|
+
"skill": {
|
|
127
|
+
"type": "string"
|
|
128
|
+
},
|
|
129
|
+
"agent": {
|
|
130
|
+
"type": "string"
|
|
131
|
+
},
|
|
132
|
+
"linkTarget": {
|
|
133
|
+
"type": "string",
|
|
134
|
+
"minLength": 1,
|
|
135
|
+
"description": "Absolute path a `link` entry points at. Absolute because the link is created with it verbatim; the hashed form in sha256 is scope-relative so the lockfile stays machine-independent."
|
|
136
|
+
}
|
|
75
137
|
}
|
|
76
138
|
}
|
|
77
139
|
},
|
|
@@ -79,10 +141,46 @@
|
|
|
79
141
|
"type": "object",
|
|
80
142
|
"additionalProperties": false,
|
|
81
143
|
"properties": {
|
|
82
|
-
"skills": {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
144
|
+
"skills": {
|
|
145
|
+
"type": "integer",
|
|
146
|
+
"minimum": 0
|
|
147
|
+
},
|
|
148
|
+
"files": {
|
|
149
|
+
"type": "integer",
|
|
150
|
+
"minimum": 0
|
|
151
|
+
},
|
|
152
|
+
"wrappers": {
|
|
153
|
+
"type": "integer",
|
|
154
|
+
"minimum": 0
|
|
155
|
+
},
|
|
156
|
+
"directories": {
|
|
157
|
+
"type": "integer",
|
|
158
|
+
"minimum": 0
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
"scope": {
|
|
163
|
+
"type": "object",
|
|
164
|
+
"additionalProperties": false,
|
|
165
|
+
"required": [
|
|
166
|
+
"kind"
|
|
167
|
+
],
|
|
168
|
+
"description": "Which installation mode wrote this lockfile. Absent means `project`: before 0.11 that was the only mode, so its absence is a fact rather than a gap.",
|
|
169
|
+
"properties": {
|
|
170
|
+
"kind": {
|
|
171
|
+
"enum": [
|
|
172
|
+
"global",
|
|
173
|
+
"workspace",
|
|
174
|
+
"project"
|
|
175
|
+
]
|
|
176
|
+
},
|
|
177
|
+
"qaRoot": {
|
|
178
|
+
"type": "string",
|
|
179
|
+
"description": "Scope-relative directory holding the shared engine and canonical skills. Absent for a project install, which shares nothing."
|
|
180
|
+
},
|
|
181
|
+
"sharedEngine": {
|
|
182
|
+
"type": "boolean"
|
|
183
|
+
}
|
|
86
184
|
}
|
|
87
185
|
}
|
|
88
186
|
}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
// `node qa-tool.mjs …`, which needs none.
|
|
29
29
|
|
|
30
30
|
import fs from 'node:fs';
|
|
31
|
+
import os from 'node:os';
|
|
31
32
|
import path from 'node:path';
|
|
32
33
|
import { spawnSync } from 'node:child_process';
|
|
33
34
|
import { fileURLToPath } from 'node:url';
|
|
@@ -37,7 +38,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
37
38
|
const USAGE = `usage: node qa-tool.mjs <tool> <subcommand> [args]
|
|
38
39
|
|
|
39
40
|
analysis parse artifacts, classify errors, validate contracts, diff-guard,
|
|
40
|
-
|
|
41
|
+
read .qa/context.md, render an HTML report, print the footer
|
|
41
42
|
diagnostics root cause, timeline, priority, repair plans, release readiness
|
|
42
43
|
playwright normalize a Playwright report or summarize a trace
|
|
43
44
|
|
|
@@ -52,15 +53,26 @@ examples:
|
|
|
52
53
|
/**
|
|
53
54
|
* Where the engine is, and how we found it.
|
|
54
55
|
*
|
|
55
|
-
* Ordered by cost: a bundled copy needs no resolution and no network, a
|
|
56
|
-
* node_modules copy needs no network, and npx needs both
|
|
57
|
-
* *which* one answered matters when a skill degrades — "the
|
|
58
|
-
* "the engine is being fetched" are different problems.
|
|
56
|
+
* Ordered by cost: a bundled copy needs no resolution and no network, a shared copy
|
|
57
|
+
* needs one stat per ancestor, a node_modules copy needs no network, and npx needs both
|
|
58
|
+
* on first use. Reporting *which* one answered matters when a skill degrades — "the
|
|
59
|
+
* engine is missing" and "the engine is being fetched" are different problems.
|
|
60
|
+
*
|
|
61
|
+
* The shared lookup is what lets one skill directory serve a project, a workspace, and a
|
|
62
|
+
* machine-wide install without knowing which installed it. Walking up for
|
|
63
|
+
* `.qa-engineer/engine` finds a workspace install from a skill inside the repository,
|
|
64
|
+
* and finds a global install from a skill linked into an agent's user-level directory —
|
|
65
|
+
* `~/.claude/skills/qa-explore` walks up to `~`, where `~/.qa-engineer/engine` is.
|
|
59
66
|
*/
|
|
60
67
|
function resolveEngine() {
|
|
61
68
|
const bundled = path.join(here, 'lib', 'bin', 'qa-engine.mjs');
|
|
62
69
|
if (fs.existsSync(bundled)) return { kind: 'bundled', command: [process.execPath, bundled] };
|
|
63
70
|
|
|
71
|
+
for (const root of candidateSharedRoots()) {
|
|
72
|
+
const shared = path.join(root, 'bin', 'qa-engine.mjs');
|
|
73
|
+
if (fs.existsSync(shared)) return { kind: 'shared', command: [process.execPath, shared] };
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
for (const base of candidateModuleRoots()) {
|
|
65
77
|
const installed = path.join(base, 'qa-engineer', 'packages', 'engine', 'bin', 'qa-engine.mjs');
|
|
66
78
|
if (fs.existsSync(installed)) {
|
|
@@ -74,6 +86,30 @@ function resolveEngine() {
|
|
|
74
86
|
};
|
|
75
87
|
}
|
|
76
88
|
|
|
89
|
+
/** Shared engine directories worth checking, most specific first. */
|
|
90
|
+
function candidateSharedRoots() {
|
|
91
|
+
const roots = [];
|
|
92
|
+
|
|
93
|
+
// An explicit home wins over anything discovered, so a user who moved the install can
|
|
94
|
+
// rely on it rather than on whatever the walk happens to find first.
|
|
95
|
+
const override = process.env.QA_ENGINEER_HOME;
|
|
96
|
+
if (override && override.trim()) roots.push(path.join(path.resolve(override.trim()), 'engine'));
|
|
97
|
+
|
|
98
|
+
let dir = here;
|
|
99
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
100
|
+
roots.push(path.join(dir, '.qa-engineer', 'engine'));
|
|
101
|
+
const parent = path.dirname(dir);
|
|
102
|
+
if (parent === dir) break;
|
|
103
|
+
dir = parent;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The default machine home, for the case where the skill lives outside it entirely.
|
|
107
|
+
const home = os.homedir();
|
|
108
|
+
if (home) roots.push(path.join(home, '.qa-engineer', 'engine'));
|
|
109
|
+
|
|
110
|
+
return roots;
|
|
111
|
+
}
|
|
112
|
+
|
|
77
113
|
/** node_modules directories worth checking, nearest first. */
|
|
78
114
|
function candidateModuleRoots() {
|
|
79
115
|
const roots = [];
|