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
|
@@ -1,124 +1,101 @@
|
|
|
1
|
-
// Bundling deterministic
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Bundling the deterministic engine, integrated into installation.
|
|
2
|
+
//
|
|
3
|
+
// Skills that run the engine need it to travel with them (self-containment). The
|
|
4
|
+
// installer materializes it by COPYING the engine from packages/engine/ into the
|
|
4
5
|
// installed skill's scripts/lib/ — a pure file copy, no code executed at install
|
|
5
|
-
// time, honoring the SECURITY.md guarantee.
|
|
6
|
-
// scripts/bundle_python.py does for repository development; users never run it.
|
|
6
|
+
// time, honoring the SECURITY.md guarantee.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// The engine is Node, and Node is already present: the user ran `npx` to get here.
|
|
9
|
+
// That removes the whole class of failure the Python bundle had, where the files
|
|
10
|
+
// could be copied perfectly and still not run because the interpreter was missing,
|
|
11
|
+
// the wrong version, or named `python` rather than `python3` (ADR-0012).
|
|
11
12
|
|
|
12
13
|
import fs from 'node:fs';
|
|
13
14
|
import path from 'node:path';
|
|
14
15
|
import { spawnSync } from 'node:child_process';
|
|
15
|
-
import {
|
|
16
|
-
BUNDLE_MANIFEST,
|
|
17
|
-
BUNDLE_SOURCES,
|
|
18
|
-
BUNDLE_PACKAGE_DATA,
|
|
19
|
-
BUNDLE_MODULE_SOURCES,
|
|
20
|
-
BUNDLE_LAUNCHER,
|
|
21
|
-
BUNDLE_DEST,
|
|
22
|
-
} from './manifest.mjs';
|
|
16
|
+
import { BUNDLE_MANIFEST, ENGINE_SOURCE, ENGINE_DATA, BUNDLE_DEST } from './manifest.mjs';
|
|
23
17
|
import { listFilesRelative } from './paths.mjs';
|
|
24
18
|
|
|
25
19
|
/** Bundle file entries for one skill, relative to the skill directory. */
|
|
26
20
|
export function bundleFilesForSkill(sourceRoot, skill) {
|
|
27
|
-
|
|
28
|
-
if (!entry) return [];
|
|
21
|
+
if (!BUNDLE_MANIFEST[skill]) return [];
|
|
29
22
|
const entries = [];
|
|
30
23
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
24
|
+
const engineRoot = path.join(sourceRoot, ENGINE_SOURCE);
|
|
25
|
+
if (!fs.existsSync(engineRoot)) {
|
|
26
|
+
throw new Error(`engine source missing: ${ENGINE_SOURCE} (needed by ${skill})`);
|
|
27
|
+
}
|
|
28
|
+
for (const rel of listFilesRelative(engineRoot)) {
|
|
29
|
+
// Tests and the package manifest are development files; the bundle is the
|
|
30
|
+
// runtime only.
|
|
31
|
+
if (rel.startsWith('test/') || rel === 'package.json') continue;
|
|
32
|
+
entries.push({
|
|
33
|
+
rel: `${BUNDLE_DEST}/${rel}`,
|
|
34
|
+
content: fs.readFileSync(path.join(engineRoot, rel)),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Data the engine reads at runtime: the context contract, the internal seam
|
|
39
|
+
// schemas, and the branding metadata.
|
|
40
|
+
for (const data of ENGINE_DATA) {
|
|
41
|
+
const from = path.join(sourceRoot, data.from);
|
|
42
|
+
if (!fs.existsSync(from)) {
|
|
43
|
+
throw new Error(`engine data missing: ${data.from} (needed by ${skill})`);
|
|
41
44
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
for (const data of BUNDLE_PACKAGE_DATA[pkg] ?? []) {
|
|
45
|
-
const dataSrc = path.join(sourceRoot, data.from);
|
|
46
|
-
if (!fs.existsSync(dataSrc)) {
|
|
47
|
-
throw new Error(`bundled package data missing: ${data.from} (needed by ${skill})`);
|
|
48
|
-
}
|
|
49
|
-
for (const rel of listFilesRelative(dataSrc)) {
|
|
45
|
+
if (fs.statSync(from).isDirectory()) {
|
|
46
|
+
for (const rel of listFilesRelative(from)) {
|
|
50
47
|
entries.push({
|
|
51
|
-
rel: `${BUNDLE_DEST}/${
|
|
52
|
-
content: fs.readFileSync(path.join(
|
|
48
|
+
rel: `${BUNDLE_DEST}/${data.to}/${rel}`,
|
|
49
|
+
content: fs.readFileSync(path.join(from, rel)),
|
|
53
50
|
});
|
|
54
51
|
}
|
|
52
|
+
} else {
|
|
53
|
+
entries.push({ rel: `${BUNDLE_DEST}/${data.to}`, content: fs.readFileSync(from) });
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
// The launcher
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
for (const mod of entry.modules) {
|
|
66
|
-
const src = path.join(sourceRoot, BUNDLE_MODULE_SOURCES[mod]);
|
|
67
|
-
if (!fs.existsSync(src)) {
|
|
68
|
-
throw new Error(`bundled module source missing: ${BUNDLE_MODULE_SOURCES[mod]} (needed by ${skill})`);
|
|
69
|
-
}
|
|
70
|
-
entries.push({
|
|
71
|
-
rel: `${BUNDLE_DEST}/${path.basename(src)}`,
|
|
72
|
-
content: fs.readFileSync(src),
|
|
73
|
-
});
|
|
74
|
-
}
|
|
57
|
+
// The launcher is deliberately NOT bundled. It is a *committed* file in each
|
|
58
|
+
// skill (kept in step by sync-shared), because that is what makes a skill work
|
|
59
|
+
// when a generic file copier installs it. Copying it again here produced two
|
|
60
|
+
// different contents for one path — the committed copy carries a provenance
|
|
61
|
+
// marker — and the installer's conflict detector rejected the install outright.
|
|
62
|
+
// One file, one source.
|
|
75
63
|
|
|
76
64
|
return entries;
|
|
77
65
|
}
|
|
78
66
|
|
|
79
|
-
/** True if any skill carries bundled
|
|
67
|
+
/** True if any skill carries the bundled engine (used to gate doctor's deep check). */
|
|
80
68
|
export function packHasBundles() {
|
|
81
69
|
return Object.keys(BUNDLE_MANIFEST).length > 0;
|
|
82
70
|
}
|
|
83
71
|
|
|
84
|
-
/** Locate a Python 3 interpreter, or null. Used only by doctor. */
|
|
85
|
-
export function findPython() {
|
|
86
|
-
for (const candidate of ['python3', 'python']) {
|
|
87
|
-
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8' });
|
|
88
|
-
if (probe.status === 0) {
|
|
89
|
-
const version = (probe.stdout || probe.stderr).trim();
|
|
90
|
-
return { bin: candidate, version };
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Runs inside the bundle with only the bundle on PYTHONPATH. Importing is not
|
|
97
|
-
// enough: a missing data file only surfaces when the engine actually runs, which
|
|
98
|
-
// is exactly the failure this check exists to catch.
|
|
99
|
-
const SMOKE = `
|
|
100
|
-
import json, sys
|
|
101
|
-
names = json.loads(sys.argv[1])
|
|
102
|
-
for name in names:
|
|
103
|
-
__import__(name)
|
|
104
|
-
if "qa_diagnostics" in names:
|
|
105
|
-
from qa_diagnostics import engine
|
|
106
|
-
diagnosis = engine.diagnose({
|
|
107
|
-
"tests": {"total": 1, "passed": 0, "failed": 1, "skipped": 0},
|
|
108
|
-
"executed": [{"title": "t", "status": "failed", "message": "no such element: #cart",
|
|
109
|
-
"file": "t.spec.ts", "retries": 0}],
|
|
110
|
-
})
|
|
111
|
-
assert diagnosis["entries"], "engine produced no diagnosis entries"
|
|
112
|
-
`;
|
|
113
|
-
|
|
114
72
|
/**
|
|
115
|
-
* Verify bundled
|
|
116
|
-
*
|
|
73
|
+
* Verify the bundled engine RUNS from its own directory, under the Node that will
|
|
74
|
+
* run it.
|
|
75
|
+
*
|
|
76
|
+
* Copying files correctly is not the same as the result working: a missing data file
|
|
77
|
+
* only surfaces when the code reads it. So this executes a real command whose whole
|
|
78
|
+
* path touches the runtime data — `classify` exercises the taxonomy, and the
|
|
79
|
+
* diagnostics report validates against the bundled internal schemas.
|
|
117
80
|
*/
|
|
118
|
-
export function
|
|
119
|
-
const
|
|
120
|
-
|
|
81
|
+
export function verifyEngine({ libDir }) {
|
|
82
|
+
const cli = path.join(libDir, 'bin', 'qa-engine.mjs');
|
|
83
|
+
if (!fs.existsSync(cli)) {
|
|
84
|
+
return { ok: false, stderr: `bundled engine CLI missing at ${cli}` };
|
|
85
|
+
}
|
|
86
|
+
const probe = spawnSync(process.execPath, [cli, 'analysis', 'classify', 'no such element: #cart'], {
|
|
121
87
|
encoding: 'utf8',
|
|
122
88
|
});
|
|
123
|
-
|
|
89
|
+
if (probe.status !== 0) {
|
|
90
|
+
return { ok: false, stderr: (probe.stderr || probe.stdout || '').trim() };
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(probe.stdout);
|
|
94
|
+
if (parsed.classification !== 'locator-failure') {
|
|
95
|
+
return { ok: false, stderr: `engine classified a missing element as ${parsed.classification}` };
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return { ok: false, stderr: `engine did not emit JSON: ${error.message}` };
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, stderr: '' };
|
|
124
101
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { entryDigest, entryPresent } from './integrity.mjs';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* @param {object} args
|
|
@@ -19,10 +19,11 @@ export function detectConflicts({ projectRoot, planned, priorLock }) {
|
|
|
19
19
|
const owned = new Set((priorLock?.files ?? []).map((f) => f.path));
|
|
20
20
|
const conflicts = [];
|
|
21
21
|
for (const entry of planned) {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// `entryPresent` rather than `existsSync`: a link whose target is gone still
|
|
23
|
+
// occupies the path, and overwriting it blind would lose where it pointed.
|
|
24
|
+
if (!entryPresent(projectRoot, entry)) continue; // brand-new path, no conflict
|
|
24
25
|
if (owned.has(entry.path)) continue; // pack-owned, safe to update
|
|
25
|
-
if (
|
|
26
|
+
if (entryDigest(projectRoot, entry) === entry.sha256) continue; // already correct, no-op
|
|
26
27
|
conflicts.push({ path: entry.path, reason: 'exists and is not owned by a previous install' });
|
|
27
28
|
}
|
|
28
29
|
return conflicts;
|
|
@@ -109,6 +109,80 @@ function ensureDir(dir, createdDirs) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The target of a symlink at `abs`, or null when there is no link there.
|
|
114
|
+
*
|
|
115
|
+
* `lstat` rather than `exists`, because a link pointing at something that has been
|
|
116
|
+
* deleted still exists as a link and still has to be replaced rather than written over.
|
|
117
|
+
*/
|
|
118
|
+
export function readLinkTarget(abs) {
|
|
119
|
+
try {
|
|
120
|
+
const stat = fs.lstatSync(abs);
|
|
121
|
+
if (!stat.isSymbolicLink()) return null;
|
|
122
|
+
return fs.readlinkSync(abs);
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create a directory link, choosing the kind the platform will allow.
|
|
130
|
+
*
|
|
131
|
+
* On Windows a symbolic link needs either administrator rights or Developer Mode, while
|
|
132
|
+
* a *junction* needs neither and behaves the same for reading a directory. Preferring a
|
|
133
|
+
* junction there is the difference between a global install that works for every Windows
|
|
134
|
+
* developer and one that works only for those running an elevated shell.
|
|
135
|
+
*/
|
|
136
|
+
export function createLink(abs, target) {
|
|
137
|
+
const type = process.platform === 'win32' ? 'junction' : 'dir';
|
|
138
|
+
fs.symlinkSync(target, abs, type);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Can this filesystem hold a directory link at all?
|
|
143
|
+
*
|
|
144
|
+
* Asked once, before planning, rather than discovered halfway through a transaction.
|
|
145
|
+
* The answer is no more often than it looks: a FAT- or exFAT-formatted volume supports
|
|
146
|
+
* neither symlinks nor junctions, some container and network mounts refuse them, and a
|
|
147
|
+
* Windows host without Developer Mode refuses symlinks (which is why `createLink` asks
|
|
148
|
+
* for a junction there instead).
|
|
149
|
+
*
|
|
150
|
+
* When the answer is no the installer copies instead. A global install that fails
|
|
151
|
+
* outright on a USB stick would be a worse tool than one that uses more disk.
|
|
152
|
+
*/
|
|
153
|
+
export function canLink(root) {
|
|
154
|
+
const probe = path.join(root, `.qa-linkprobe-${process.pid}`);
|
|
155
|
+
const target = path.join(root, `.qa-linktarget-${process.pid}`);
|
|
156
|
+
try {
|
|
157
|
+
fs.mkdirSync(target, { recursive: true });
|
|
158
|
+
createLink(probe, target);
|
|
159
|
+
const ok = readLinkTarget(probe) !== null;
|
|
160
|
+
fs.unlinkSync(probe);
|
|
161
|
+
fs.rmdirSync(target);
|
|
162
|
+
return ok;
|
|
163
|
+
} catch {
|
|
164
|
+
// Clean up whatever got as far as existing, then report no.
|
|
165
|
+
try {
|
|
166
|
+
if (readLinkTarget(probe) !== null) fs.unlinkSync(probe);
|
|
167
|
+
} catch { /* nothing to undo */ }
|
|
168
|
+
try {
|
|
169
|
+
if (fs.existsSync(target)) fs.rmdirSync(target);
|
|
170
|
+
} catch { /* nothing to undo */ }
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** True when the file already holds exactly these bytes. */
|
|
176
|
+
function sameContent(file, content) {
|
|
177
|
+
try {
|
|
178
|
+
const stat = fs.statSync(file);
|
|
179
|
+
if (!stat.isFile() || stat.size !== content.length) return false;
|
|
180
|
+
return fs.readFileSync(file).equals(content);
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
112
186
|
/** Write content to file via a temp file + rename, so readers never see a partial write. */
|
|
113
187
|
function atomicWrite(file, content) {
|
|
114
188
|
const tmp = `${file}.qa-tmp-${process.pid}`;
|
|
@@ -134,6 +208,24 @@ export class Transaction {
|
|
|
134
208
|
this.ops.push({ kind: 'delete', rel: relPath });
|
|
135
209
|
}
|
|
136
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Stage a symbolic link at `relPath` pointing at `targetAbs`.
|
|
213
|
+
*
|
|
214
|
+
* Links are how a global install serves an agent without copying the skills again.
|
|
215
|
+
* They go through the transaction rather than around it so a half-linked install
|
|
216
|
+
* rolls back like a half-written one — and because replacing a link silently would
|
|
217
|
+
* lose where the old one pointed.
|
|
218
|
+
*
|
|
219
|
+
* Only a link may be replaced by a link. A real directory at the target path belongs
|
|
220
|
+
* to someone else, and the conflict detector is what decides whether the user wants it
|
|
221
|
+
* overwritten; reaching here with one is a bug, so it throws rather than deleting a
|
|
222
|
+
* directory nobody agreed to lose.
|
|
223
|
+
*/
|
|
224
|
+
link(relPath, targetAbs) {
|
|
225
|
+
resolveInside(this.root, relPath);
|
|
226
|
+
this.ops.push({ kind: 'link', rel: relPath, target: targetAbs });
|
|
227
|
+
}
|
|
228
|
+
|
|
137
229
|
/** Directories the transaction will need to write into, deduplicated. */
|
|
138
230
|
targetDirs() {
|
|
139
231
|
const dirs = new Set();
|
|
@@ -169,6 +261,8 @@ export class Transaction {
|
|
|
169
261
|
const created = []; // files that did not exist before (remove on rollback)
|
|
170
262
|
const createdDirs = []; // directories we made (remove on rollback if empty)
|
|
171
263
|
const backups = []; // { abs, backup } for files that existed (restore on rollback)
|
|
264
|
+
let unchanged = 0; // already byte-identical: neither written nor backed up
|
|
265
|
+
const replacedLinks = []; // { abs, target } symlinks removed (recreate on rollback)
|
|
172
266
|
let backedUp = 0;
|
|
173
267
|
|
|
174
268
|
const backup = (abs, rel) => {
|
|
@@ -185,21 +279,65 @@ export class Transaction {
|
|
|
185
279
|
// other code, and containment is cheap to reassert.
|
|
186
280
|
const abs = resolveInside(this.root, op.rel);
|
|
187
281
|
if (op.kind === 'write') {
|
|
188
|
-
if (fs.existsSync(abs))
|
|
189
|
-
|
|
282
|
+
if (fs.existsSync(abs)) {
|
|
283
|
+
// Reinstalling identical content is the common case for update and repair.
|
|
284
|
+
// Backing it up copies the whole install into a dated directory on every
|
|
285
|
+
// run — three copies of the engine after two commands — and restoring a
|
|
286
|
+
// byte-identical file on rollback achieves nothing.
|
|
287
|
+
if (sameContent(abs, op.content)) {
|
|
288
|
+
unchanged += 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
backup(abs, op.rel);
|
|
292
|
+
} else {
|
|
293
|
+
created.push(abs);
|
|
294
|
+
}
|
|
190
295
|
ensureDir(path.dirname(abs), createdDirs);
|
|
191
296
|
atomicWrite(abs, op.content);
|
|
192
297
|
} else if (op.kind === 'delete') {
|
|
193
|
-
|
|
298
|
+
// A link is deleted by unlinking it, and "backed up" by remembering where it
|
|
299
|
+
// pointed. Copying it would follow the link and try to copy a directory as a
|
|
300
|
+
// file, which is how uninstall crashed on a global install.
|
|
301
|
+
const linkTarget = readLinkTarget(abs);
|
|
302
|
+
if (linkTarget !== null) {
|
|
303
|
+
replacedLinks.push({ abs, target: linkTarget });
|
|
304
|
+
fs.unlinkSync(abs);
|
|
305
|
+
} else if (fs.existsSync(abs)) {
|
|
194
306
|
backup(abs, op.rel);
|
|
195
307
|
fs.rmSync(abs);
|
|
196
308
|
}
|
|
309
|
+
} else if (op.kind === 'link') {
|
|
310
|
+
const existing = readLinkTarget(abs);
|
|
311
|
+
if (existing !== null) {
|
|
312
|
+
replacedLinks.push({ abs, target: existing });
|
|
313
|
+
fs.unlinkSync(abs);
|
|
314
|
+
} else if (fs.existsSync(abs)) {
|
|
315
|
+
throw environmentError(
|
|
316
|
+
`refusing to replace ${abs} with a link: it is a real file or directory, not a link ` +
|
|
317
|
+
'this installer created',
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
created.push(abs);
|
|
321
|
+
}
|
|
322
|
+
ensureDir(path.dirname(abs), createdDirs);
|
|
323
|
+
createLink(abs, op.target);
|
|
197
324
|
}
|
|
198
325
|
}
|
|
199
326
|
} catch (error) {
|
|
200
|
-
// Roll back: remove created files, restore backups
|
|
327
|
+
// Roll back: remove created files and links, restore backups and replaced links,
|
|
328
|
+
// drop created dirs.
|
|
201
329
|
for (const abs of created) {
|
|
202
|
-
if (
|
|
330
|
+
if (readLinkTarget(abs) !== null) fs.unlinkSync(abs);
|
|
331
|
+
else if (fs.existsSync(abs)) fs.rmSync(abs, { force: true });
|
|
332
|
+
}
|
|
333
|
+
for (const { abs, target } of replacedLinks) {
|
|
334
|
+
if (!fs.existsSync(abs) && readLinkTarget(abs) === null) {
|
|
335
|
+
try {
|
|
336
|
+
createLink(abs, target);
|
|
337
|
+
} catch {
|
|
338
|
+
/* best effort: the original error is what matters */
|
|
339
|
+
}
|
|
340
|
+
}
|
|
203
341
|
}
|
|
204
342
|
for (const { abs, backup: from } of backups) {
|
|
205
343
|
fs.copyFileSync(from, abs);
|
|
@@ -215,8 +353,10 @@ export class Transaction {
|
|
|
215
353
|
}
|
|
216
354
|
|
|
217
355
|
return {
|
|
218
|
-
written: this.ops.filter((o) => o.kind === 'write').length,
|
|
356
|
+
written: this.ops.filter((o) => o.kind === 'write').length - unchanged,
|
|
357
|
+
unchanged,
|
|
219
358
|
deleted: this.ops.filter((o) => o.kind === 'delete').length,
|
|
359
|
+
linked: this.ops.filter((o) => o.kind === 'link').length,
|
|
220
360
|
backedUp,
|
|
221
361
|
backupDir: backedUp > 0 ? this.backupDir : null,
|
|
222
362
|
dryRun: false,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// What a lockfile entry should hash to on disk, whatever kind of entry it is.
|
|
2
|
+
//
|
|
3
|
+
// Four commands independently asked "does this file still match its recorded hash?" —
|
|
4
|
+
// validate, verify, uninstall, and the conflict detector — and each did it by calling
|
|
5
|
+
// `hashFile` directly. That was correct while every entry was a file. The moment a
|
|
6
|
+
// global install started recording links, all four tried to read a directory and threw
|
|
7
|
+
// EISDIR, which is a crash rather than a diagnosis.
|
|
8
|
+
//
|
|
9
|
+
// So the question moves here and is asked once. A link's identity is *where it points*,
|
|
10
|
+
// not what is behind it: hashing the target means `verify` catches a link repointed at
|
|
11
|
+
// something else, and does not report drift merely because the canonical skill it points
|
|
12
|
+
// at was legitimately updated.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { hashFile, hashString } from './hash.mjs';
|
|
18
|
+
import { readLinkTarget } from './fs-safe.mjs';
|
|
19
|
+
import { toPosix } from './paths.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The digest of an installed entry, or `null` when it is not there.
|
|
23
|
+
*
|
|
24
|
+
* `null` means missing, which every caller already treats as drift — so a link whose
|
|
25
|
+
* path now holds a real directory reads as drift rather than as a match, which is what
|
|
26
|
+
* it is.
|
|
27
|
+
*/
|
|
28
|
+
export function entryDigest(root, entry) {
|
|
29
|
+
const absolute = path.join(root, entry.path);
|
|
30
|
+
|
|
31
|
+
if (entry.owner === 'link') {
|
|
32
|
+
const target = readLinkTarget(absolute);
|
|
33
|
+
if (target === null) return null;
|
|
34
|
+
// Relative to the scope root, so a lockfile stays valid when the whole tree moves —
|
|
35
|
+
// a home directory that changes name should not invalidate every link in it.
|
|
36
|
+
return hashString(toPosix(path.relative(root, path.resolve(path.dirname(absolute), target))));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!fs.existsSync(absolute)) return null;
|
|
40
|
+
try {
|
|
41
|
+
return hashFile(absolute);
|
|
42
|
+
} catch {
|
|
43
|
+
// A path that exists but cannot be read as a file — a directory where a file was
|
|
44
|
+
// recorded — is drift, not a crash.
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when the entry on disk matches what the lockfile recorded. */
|
|
50
|
+
export function entryMatches(root, entry) {
|
|
51
|
+
const digest = entryDigest(root, entry);
|
|
52
|
+
return digest !== null && digest === entry.sha256;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when anything at all occupies the entry's path, link or file. */
|
|
56
|
+
export function entryPresent(root, entry) {
|
|
57
|
+
const absolute = path.join(root, entry.path);
|
|
58
|
+
return fs.existsSync(absolute) || readLinkTarget(absolute) !== null;
|
|
59
|
+
}
|
|
@@ -20,8 +20,19 @@ export function lockPath(projectRoot) {
|
|
|
20
20
|
return path.join(projectRoot, LOCKFILE);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The lockfile for a scope.
|
|
25
|
+
*
|
|
26
|
+
* A global install keeps its lockfile inside `~/.qa-engineer/`, never loose in the home
|
|
27
|
+
* directory — the whole point of owning a directory is that everything the tool wrote is
|
|
28
|
+
* in one place a user can inspect and delete.
|
|
29
|
+
*/
|
|
30
|
+
export function scopeLockPath(scope) {
|
|
31
|
+
return path.join(scope.root, scope.lockfile);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readLock(projectRoot, relative = LOCKFILE) {
|
|
35
|
+
const file = path.join(projectRoot, relative);
|
|
25
36
|
if (!fs.existsSync(file)) return null;
|
|
26
37
|
let raw;
|
|
27
38
|
try {
|
|
@@ -40,12 +51,17 @@ export function readLock(projectRoot) {
|
|
|
40
51
|
* Assemble a lockfile object. `now` is injected so tests are deterministic;
|
|
41
52
|
* the CLI passes the real time.
|
|
42
53
|
*/
|
|
43
|
-
export function buildLock({ agents, files, now }) {
|
|
54
|
+
export function buildLock({ agents, files, now, scope = null }) {
|
|
44
55
|
const lock = {
|
|
45
56
|
lockfileVersion: LOCKFILE_VERSION,
|
|
46
57
|
pack: { name: PACK_NAME, version: VERSION, specRevision: SPEC_REVISION },
|
|
47
58
|
installer: VERSION,
|
|
48
59
|
generatedAt: now,
|
|
60
|
+
// Recorded so every later command knows what it is operating on without being told
|
|
61
|
+
// again. Omitted for a project install, which is what a lockfile without one means.
|
|
62
|
+
...(scope && scope.kind !== 'project'
|
|
63
|
+
? { scope: { kind: scope.kind, qaRoot: scope.qaRootRelative, sharedEngine: scope.shareEngine } }
|
|
64
|
+
: {}),
|
|
49
65
|
agents: agents.map((a) => ({
|
|
50
66
|
id: a.id,
|
|
51
67
|
name: a.name,
|
|
@@ -1,74 +1,65 @@
|
|
|
1
|
-
// What the pack ships: the set of skills, the files inside each, and which
|
|
2
|
-
//
|
|
3
|
-
// mirrors scripts/bundle_python.py — the two must agree, and a test asserts it.
|
|
1
|
+
// What the pack ships: the set of skills, the files inside each, and which skills
|
|
2
|
+
// carry the bundled deterministic engine.
|
|
4
3
|
|
|
5
4
|
import fs from 'node:fs';
|
|
6
5
|
import path from 'node:path';
|
|
7
6
|
import { listFilesRelative } from './paths.mjs';
|
|
8
7
|
|
|
9
|
-
//
|
|
8
|
+
// Every bundling skill carries the same payload, named once so the map below reads
|
|
9
|
+
// as the list of skills it is.
|
|
10
|
+
const ENGINE = Object.freeze({ engine: true });
|
|
11
|
+
|
|
12
|
+
// Skills that run the deterministic engine in the consumer's repository. The
|
|
10
13
|
// bundle is materialized at install time, never shipped pre-built.
|
|
11
|
-
// qa-review is knowledge-only and bundles nothing.
|
|
12
14
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
+
// Every one of them bundles the same thing now. Under the Python engine this was a
|
|
16
|
+
// per-skill list of packages and single-file adapters, because a skill that never
|
|
17
|
+
// touched a Playwright trace had no reason to carry the adapter. The Node engine is
|
|
18
|
+
// one package of about 3,000 lines with no dependencies, so splitting it would save
|
|
19
|
+
// nothing measurable and would give nine skills nine slightly different bundles to
|
|
20
|
+
// go wrong in. qa-review, qa-generate, qa-example and the qa router remain
|
|
21
|
+
// knowledge-only and bundle nothing.
|
|
15
22
|
export const BUNDLE_MANIFEST = Object.freeze({
|
|
16
|
-
'qa-init':
|
|
17
|
-
'qa-run':
|
|
18
|
-
'qa-debug':
|
|
19
|
-
'qa-fix':
|
|
20
|
-
'qa-report':
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
'qa-
|
|
24
|
-
'qa-
|
|
25
|
-
'qa-api': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
|
|
26
|
-
'qa-audit': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
// Canonical source of each bundled package, relative to the pack root.
|
|
30
|
-
export const BUNDLE_SOURCES = Object.freeze({
|
|
31
|
-
qa_analysis: 'shared/analysis/lib/qa_analysis',
|
|
32
|
-
qa_diagnostics: 'shared/diagnostics/lib/qa_diagnostics',
|
|
23
|
+
'qa-init': ENGINE,
|
|
24
|
+
'qa-run': ENGINE,
|
|
25
|
+
'qa-debug': ENGINE,
|
|
26
|
+
'qa-fix': ENGINE,
|
|
27
|
+
'qa-report': ENGINE,
|
|
28
|
+
'qa-flaky': ENGINE,
|
|
29
|
+
'qa-api': ENGINE,
|
|
30
|
+
'qa-audit': ENGINE,
|
|
31
|
+
'qa-explore': ENGINE,
|
|
33
32
|
});
|
|
34
33
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
export const BUNDLE_PACKAGE_DATA = Object.freeze({
|
|
39
|
-
// The context CLI validates .qa/context.md against the context contract.
|
|
40
|
-
qa_analysis: Object.freeze([
|
|
41
|
-
Object.freeze({ from: 'shared/analysis/schemas', to: 'schemas' }),
|
|
42
|
-
]),
|
|
43
|
-
qa_diagnostics: Object.freeze([
|
|
44
|
-
Object.freeze({ from: 'shared/diagnostics/schemas/internal', to: 'schemas/internal' }),
|
|
45
|
-
]),
|
|
46
|
-
});
|
|
34
|
+
// The engine, copied wholesale: `lib/` for the modules and `bin/` for the CLI the
|
|
35
|
+
// launcher executes.
|
|
36
|
+
export const ENGINE_SOURCE = 'packages/engine';
|
|
47
37
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
38
|
+
// The engine keeps the non-code files it reads at runtime — the context contract,
|
|
39
|
+
// the internal seam schemas, the branding metadata — inside its own lib/, so a
|
|
40
|
+
// wholesale copy of the engine directory is a complete engine. Nothing to enumerate
|
|
41
|
+
// here, and no second place for a data file to be forgotten.
|
|
42
|
+
export const ENGINE_DATA = Object.freeze([]);
|
|
52
43
|
|
|
53
|
-
// The launcher every bundling skill carries, one level above lib/.
|
|
54
|
-
//
|
|
55
|
-
//
|
|
44
|
+
// The launcher every bundling skill carries, one level above lib/.
|
|
45
|
+
//
|
|
46
|
+
// It is a *committed* file in each skill rather than something the installer
|
|
47
|
+
// generates — `node scripts/sync-shared.mjs --write` refreshes the copies and
|
|
48
|
+
// `--check` fails on drift. That is what lets a generic file copier (`npx skills
|
|
49
|
+
// add`, or a plain `git clone`) produce a working skill without running this
|
|
50
|
+
// installer at all. Consequently the bundler does not copy it: two sources for one
|
|
51
|
+
// destination path is a conflict, and the installer is right to refuse it.
|
|
56
52
|
export const BUNDLE_LAUNCHER = Object.freeze({
|
|
57
|
-
from: 'shared/tooling/
|
|
58
|
-
to: 'scripts/
|
|
53
|
+
from: 'shared/tooling/qa-tool.mjs',
|
|
54
|
+
to: 'scripts/qa-tool.mjs',
|
|
59
55
|
});
|
|
60
56
|
|
|
61
|
-
// Where bundled
|
|
57
|
+
// Where the bundled engine lands inside an installed skill.
|
|
62
58
|
export const BUNDLE_DEST = 'scripts/lib';
|
|
63
59
|
|
|
64
|
-
/**
|
|
65
|
-
export function
|
|
66
|
-
return BUNDLE_MANIFEST
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Module names bundled into a skill. */
|
|
70
|
-
export function bundleModulesForSkill(skill) {
|
|
71
|
-
return BUNDLE_MANIFEST[skill]?.modules ?? [];
|
|
60
|
+
/** True when this skill bundles the engine. */
|
|
61
|
+
export function bundlesEngine(skill) {
|
|
62
|
+
return Object.prototype.hasOwnProperty.call(BUNDLE_MANIFEST, skill);
|
|
72
63
|
}
|
|
73
64
|
|
|
74
65
|
/** All skill names shipped by the pack (directories under skills/). */
|
|
@@ -82,9 +73,9 @@ export function listSkills(sourceRoot) {
|
|
|
82
73
|
}
|
|
83
74
|
|
|
84
75
|
/**
|
|
85
|
-
* Files that make up a skill, relative to the skill directory. Excludes the
|
|
86
|
-
*
|
|
87
|
-
*
|
|
76
|
+
* Files that make up a skill, relative to the skill directory. Excludes the bundled
|
|
77
|
+
* engine (scripts/lib): it is a generated artifact produced by the bundle step at
|
|
78
|
+
* install time, not copied from source.
|
|
88
79
|
*/
|
|
89
80
|
export function skillFiles(sourceRoot, skill) {
|
|
90
81
|
const dir = path.join(sourceRoot, 'skills', skill);
|