qa-engineer 0.9.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 +71 -0
- package/LICENSE +21 -0
- package/README.md +508 -0
- package/package.json +90 -0
- package/packages/installer/README.md +46 -0
- package/packages/installer/bin/qa.mjs +136 -0
- package/packages/installer/lib/agents/registry.mjs +209 -0
- package/packages/installer/lib/cli/commands.mjs +29 -0
- package/packages/installer/lib/cli/flags.mjs +64 -0
- package/packages/installer/lib/commands/doctor.mjs +190 -0
- package/packages/installer/lib/commands/install.mjs +291 -0
- package/packages/installer/lib/commands/onboard.mjs +163 -0
- package/packages/installer/lib/commands/repair.mjs +68 -0
- package/packages/installer/lib/commands/self-test.mjs +45 -0
- package/packages/installer/lib/commands/uninstall.mjs +167 -0
- package/packages/installer/lib/commands/update.mjs +69 -0
- package/packages/installer/lib/commands/verify.mjs +62 -0
- package/packages/installer/lib/constants.mjs +34 -0
- package/packages/installer/lib/core/bundle.mjs +124 -0
- package/packages/installer/lib/core/config.mjs +73 -0
- package/packages/installer/lib/core/conflict.mjs +29 -0
- package/packages/installer/lib/core/errors.mjs +29 -0
- package/packages/installer/lib/core/fs-safe.mjs +236 -0
- package/packages/installer/lib/core/hash.mjs +19 -0
- package/packages/installer/lib/core/lockfile.mjs +75 -0
- package/packages/installer/lib/core/logger.mjs +46 -0
- package/packages/installer/lib/core/manifest.mjs +89 -0
- package/packages/installer/lib/core/paths.mjs +74 -0
- package/packages/installer/lib/core/schema-validate.mjs +142 -0
- package/packages/installer/lib/core/skill-meta.mjs +75 -0
- package/packages/installer/lib/core/validate-install.mjs +152 -0
- package/packages/installer/lib/core/wrappers.mjs +91 -0
- package/packages/installer/lib/detect/environment.mjs +54 -0
- package/packages/installer/lib/detect/frameworks.mjs +94 -0
- package/packages/installer/lib/detect/project.mjs +126 -0
- package/packages/installer/lib/detect/recommend.mjs +85 -0
- package/packages/installer/lib/detect/scan.mjs +48 -0
- package/packages/installer/lib/ui/progress.mjs +32 -0
- package/packages/installer/lib/ui/theme.mjs +81 -0
- package/packages/installer/lib/version.mjs +47 -0
- package/packages/installer/package.json +28 -0
- package/packages/installer/schemas/qa-lock.schema.json +89 -0
- package/packages/installer/schemas/qa.config.schema.json +91 -0
- package/shared/analysis/lib/qa_analysis/__init__.py +11 -0
- package/shared/analysis/lib/qa_analysis/branding.json +9 -0
- package/shared/analysis/lib/qa_analysis/branding.py +175 -0
- package/shared/analysis/lib/qa_analysis/cli.py +129 -0
- package/shared/analysis/lib/qa_analysis/context.py +233 -0
- package/shared/analysis/lib/qa_analysis/contracts.py +158 -0
- package/shared/analysis/lib/qa_analysis/diff_guard.py +327 -0
- package/shared/analysis/lib/qa_analysis/discovery.py +113 -0
- package/shared/analysis/lib/qa_analysis/evidence.py +128 -0
- package/shared/analysis/lib/qa_analysis/har.py +58 -0
- package/shared/analysis/lib/qa_analysis/junit.py +80 -0
- package/shared/analysis/lib/qa_analysis/redaction.py +99 -0
- package/shared/analysis/lib/qa_analysis/taxonomy.py +98 -0
- package/shared/analysis/schemas/context.schema.json +82 -0
- package/shared/diagnostics/lib/qa_diagnostics/__init__.py +13 -0
- package/shared/diagnostics/lib/qa_diagnostics/cli.py +124 -0
- package/shared/diagnostics/lib/qa_diagnostics/engine.py +143 -0
- package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +75 -0
- package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +101 -0
- package/shared/diagnostics/lib/qa_diagnostics/repair.py +72 -0
- package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +89 -0
- package/shared/diagnostics/lib/qa_diagnostics/timeline.py +71 -0
- package/shared/diagnostics/schemas/internal/analysis-result.schema.json +28 -0
- package/shared/diagnostics/schemas/internal/diagnosis.schema.json +55 -0
- package/shared/diagnostics/schemas/internal/execution-result-min.schema.json +34 -0
- package/shared/frameworks/cypress/lib/cypress_analysis.py +27 -0
- package/shared/frameworks/playwright/lib/playwright_analysis.py +167 -0
- package/shared/frameworks/registry.json +150 -0
- package/shared/frameworks/registry.mjs +37 -0
- package/shared/frameworks/registry.schema.json +74 -0
- package/shared/frameworks/selenium/lib/selenium_analysis.py +28 -0
- package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +24 -0
- package/shared/tooling/qa_tool.py +127 -0
- package/skills/README.md +31 -0
- package/skills/qa/README.md +19 -0
- package/skills/qa/SKILL.md +52 -0
- package/skills/qa/examples/routing.md +86 -0
- package/skills/qa/references/routing-map.md +33 -0
- package/skills/qa-api/README.md +19 -0
- package/skills/qa-api/SKILL.md +68 -0
- package/skills/qa-api/contracts/api-result.schema.json +51 -0
- package/skills/qa-api/examples/graphql-review.md +43 -0
- package/skills/qa-api/references/authentication.md +45 -0
- package/skills/qa-api/references/deterministic-tooling.md +128 -0
- package/skills/qa-api/references/evidence-and-reporting.md +66 -0
- package/skills/qa-api/references/graphql.md +44 -0
- package/skills/qa-api/references/rest.md +45 -0
- package/skills/qa-api/references/websocket.md +44 -0
- package/skills/qa-audit/README.md +19 -0
- package/skills/qa-audit/SKILL.md +70 -0
- package/skills/qa-audit/contracts/audit-result.schema.json +63 -0
- package/skills/qa-audit/examples/accessibility-audit.md +46 -0
- package/skills/qa-audit/references/accessibility.md +44 -0
- package/skills/qa-audit/references/deterministic-tooling.md +128 -0
- package/skills/qa-audit/references/evidence-and-reporting.md +66 -0
- package/skills/qa-audit/references/performance.md +44 -0
- package/skills/qa-audit/references/security.md +43 -0
- package/skills/qa-audit/references/visual-testing.md +44 -0
- package/skills/qa-debug/README.md +19 -0
- package/skills/qa-debug/SKILL.md +76 -0
- package/skills/qa-debug/contracts/debug-result.schema.json +121 -0
- package/skills/qa-debug/examples/failed-login.md +61 -0
- package/skills/qa-debug/examples/locator-break.md +59 -0
- package/skills/qa-debug/examples/network-timeout.md +61 -0
- package/skills/qa-debug/examples/successful-debug.md +62 -0
- package/skills/qa-debug/references/confidence-model.md +26 -0
- package/skills/qa-debug/references/deterministic-tooling.md +128 -0
- package/skills/qa-debug/references/diagnostic-engine.md +36 -0
- package/skills/qa-debug/references/evidence-and-reporting.md +66 -0
- package/skills/qa-debug/references/evidence-model.md +41 -0
- package/skills/qa-debug/references/failure-taxonomy.md +44 -0
- package/skills/qa-debug/references/finding-prioritization.md +43 -0
- package/skills/qa-debug/references/investigation-workflow.md +48 -0
- package/skills/qa-debug/references/recommendation-ranking.md +34 -0
- package/skills/qa-debug/references/root-cause-analysis.md +49 -0
- package/skills/qa-debug/references/timeline-builder.md +37 -0
- package/skills/qa-example/README.md +19 -0
- package/skills/qa-example/SKILL.md +60 -0
- package/skills/qa-example/contracts/self-check-report.schema.json +65 -0
- package/skills/qa-example/examples/self-check.md +53 -0
- package/skills/qa-example/references/example-domain.md +28 -0
- package/skills/qa-example/references/skill-format-notes.md +26 -0
- package/skills/qa-explore/README.md +22 -0
- package/skills/qa-explore/SKILL.md +87 -0
- package/skills/qa-explore/contracts/explore-result.schema.json +249 -0
- package/skills/qa-explore/examples/attached-test-cases.md +70 -0
- package/skills/qa-explore/examples/screenshot-proof-finding.md +50 -0
- package/skills/qa-explore/examples/url-smoke-explore.md +80 -0
- package/skills/qa-explore/references/accessibility.md +44 -0
- package/skills/qa-explore/references/api-replay.md +48 -0
- package/skills/qa-explore/references/browser-adapters.md +51 -0
- package/skills/qa-explore/references/evidence-and-reporting.md +66 -0
- package/skills/qa-explore/references/evidence-capture.md +54 -0
- package/skills/qa-explore/references/exploratory-qa.md +52 -0
- package/skills/qa-explore/references/finding-taxonomy.md +47 -0
- package/skills/qa-explore/references/performance.md +44 -0
- package/skills/qa-explore/references/pipeline.md +74 -0
- package/skills/qa-explore/references/report-pipeline.md +69 -0
- package/skills/qa-explore/references/security.md +43 -0
- package/skills/qa-explore/references/test-case-intake.md +44 -0
- package/skills/qa-fix/README.md +19 -0
- package/skills/qa-fix/SKILL.md +70 -0
- package/skills/qa-fix/contracts/fix-result.schema.json +132 -0
- package/skills/qa-fix/examples/repair-plan.md +56 -0
- package/skills/qa-fix/references/deterministic-tooling.md +128 -0
- package/skills/qa-fix/references/diagnostic-engine.md +36 -0
- package/skills/qa-fix/references/evidence-and-reporting.md +66 -0
- package/skills/qa-fix/references/repair-strategy.md +48 -0
- package/skills/qa-fix/references/root-cause-analysis.md +49 -0
- package/skills/qa-fix/references/suite-extension.md +73 -0
- package/skills/qa-flaky/README.md +19 -0
- package/skills/qa-flaky/SKILL.md +67 -0
- package/skills/qa-flaky/contracts/flaky-result.schema.json +62 -0
- package/skills/qa-flaky/examples/flaky-locator.md +46 -0
- package/skills/qa-flaky/references/deterministic-tooling.md +128 -0
- package/skills/qa-flaky/references/evidence-and-reporting.md +66 -0
- package/skills/qa-flaky/references/flakiness.md +48 -0
- package/skills/qa-flaky/references/retry.md +44 -0
- package/skills/qa-flaky/references/waiting-strategies.md +44 -0
- package/skills/qa-generate/README.md +42 -0
- package/skills/qa-generate/SKILL.md +83 -0
- package/skills/qa-generate/contracts/generation-result.schema.json +124 -0
- package/skills/qa-generate/examples/bootstrap-new-framework.md +24 -0
- package/skills/qa-generate/examples/extend-existing-suite.md +26 -0
- package/skills/qa-generate/references/code-style.md +29 -0
- package/skills/qa-generate/references/evidence-and-reporting.md +66 -0
- package/skills/qa-generate/references/framework-selection.md +88 -0
- package/skills/qa-generate/references/generation-strategy.md +48 -0
- package/skills/qa-generate/references/naming-conventions.md +27 -0
- package/skills/qa-generate/references/playwright-conventions.md +23 -0
- package/skills/qa-generate/references/playwright-generation.md +41 -0
- package/skills/qa-generate/references/playwright-project-discovery.md +42 -0
- package/skills/qa-generate/references/project-bootstrap.md +54 -0
- package/skills/qa-generate/references/repository-analysis.md +43 -0
- package/skills/qa-generate/references/suite-extension.md +73 -0
- package/skills/qa-generate/references/template-selection.md +48 -0
- package/skills/qa-generate/templates/playwright/api.ts +36 -0
- package/skills/qa-generate/templates/playwright/base-page.ts +14 -0
- package/skills/qa-generate/templates/playwright/data.ts +25 -0
- package/skills/qa-generate/templates/playwright/env.example +16 -0
- package/skills/qa-generate/templates/playwright/example.spec.ts +14 -0
- package/skills/qa-generate/templates/playwright/fixtures.ts +28 -0
- package/skills/qa-generate/templates/playwright/framework-readme.md +39 -0
- package/skills/qa-generate/templates/playwright/login.page.ts +26 -0
- package/skills/qa-generate/templates/playwright/playwright.config.ts +28 -0
- package/skills/qa-generate/templates/playwright/utils.ts +18 -0
- package/skills/qa-init/README.md +20 -0
- package/skills/qa-init/SKILL.md +72 -0
- package/skills/qa-init/examples/initialize-a-repo.md +91 -0
- package/skills/qa-init/references/detection-guide.md +76 -0
- package/skills/qa-init/references/deterministic-tooling.md +128 -0
- package/skills/qa-init/references/evidence-and-reporting.md +66 -0
- package/skills/qa-init/templates/context.md +68 -0
- package/skills/qa-report/README.md +19 -0
- package/skills/qa-report/SKILL.md +72 -0
- package/skills/qa-report/contracts/report-result.schema.json +186 -0
- package/skills/qa-report/examples/release-report.md +68 -0
- package/skills/qa-report/references/deterministic-tooling.md +128 -0
- package/skills/qa-report/references/diagnostic-engine.md +36 -0
- package/skills/qa-report/references/evidence-and-reporting.md +66 -0
- package/skills/qa-report/references/finding-prioritization.md +43 -0
- package/skills/qa-report/references/recommendation-ranking.md +34 -0
- package/skills/qa-report/references/report-aggregation.md +44 -0
- package/skills/qa-review/README.md +19 -0
- package/skills/qa-review/SKILL.md +58 -0
- package/skills/qa-review/contracts/review-result.schema.json +59 -0
- package/skills/qa-review/examples/suite-review.md +51 -0
- package/skills/qa-review/references/anti-patterns.md +49 -0
- package/skills/qa-review/references/assertion-patterns.md +45 -0
- package/skills/qa-review/references/evidence-and-reporting.md +66 -0
- package/skills/qa-review/references/fixtures.md +45 -0
- package/skills/qa-review/references/page-objects.md +45 -0
- package/skills/qa-run/README.md +22 -0
- package/skills/qa-run/SKILL.md +92 -0
- package/skills/qa-run/contracts/execution-plan.schema.json +132 -0
- package/skills/qa-run/contracts/execution-result.schema.json +204 -0
- package/skills/qa-run/examples/execute-playwright.md +89 -0
- package/skills/qa-run/examples/plan-a-run.md +83 -0
- package/skills/qa-run/references/artifact-collector.md +40 -0
- package/skills/qa-run/references/browser-launch.md +39 -0
- package/skills/qa-run/references/command-builder.md +39 -0
- package/skills/qa-run/references/deterministic-tooling.md +128 -0
- package/skills/qa-run/references/environment-detection.md +37 -0
- package/skills/qa-run/references/evidence-and-reporting.md +66 -0
- package/skills/qa-run/references/execution-strategy.md +54 -0
- package/skills/qa-run/references/playwright-artifacts.md +29 -0
- package/skills/qa-run/references/playwright-execution.md +48 -0
- package/skills/qa-run/references/playwright-project-discovery.md +42 -0
- package/skills/qa-run/references/report-normalization.md +49 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// `qa repair` — fix common install problems by reinstalling pack-owned files.
|
|
2
|
+
|
|
3
|
+
import { EXIT, LOCKFILE } from '../constants.mjs';
|
|
4
|
+
import { resolveProjectRoot } from '../core/paths.mjs';
|
|
5
|
+
import { readLock } from '../core/lockfile.mjs';
|
|
6
|
+
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
7
|
+
import { executeInstall } from './install.mjs';
|
|
8
|
+
import { validateInstall } from '../core/validate-install.mjs';
|
|
9
|
+
import { createLogger } from '../core/logger.mjs';
|
|
10
|
+
|
|
11
|
+
export async function runRepair(argv, { log } = {}) {
|
|
12
|
+
const opts = parseCommonFlags(argv);
|
|
13
|
+
const logger = log ?? createLogger();
|
|
14
|
+
if (opts.help) {
|
|
15
|
+
logger.result(`Usage: qa repair [--project <dir>] [--json] [--dry-run]
|
|
16
|
+
|
|
17
|
+
Repair a broken or drifted installation:
|
|
18
|
+
- missing lockfile → fresh install
|
|
19
|
+
- drifted / missing pack files → reinstall with --force
|
|
20
|
+
- then re-validate`);
|
|
21
|
+
return EXIT.OK;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const root = resolveProjectRoot(opts.project ?? process.cwd());
|
|
25
|
+
const prior = readLock(root);
|
|
26
|
+
const before = validateInstall(root);
|
|
27
|
+
|
|
28
|
+
if (!opts.json) {
|
|
29
|
+
logger.step(`repairing ${root}`);
|
|
30
|
+
if (!prior) logger.warn(`no ${LOCKFILE} — performing fresh install`);
|
|
31
|
+
else {
|
|
32
|
+
const bad = before.checks.filter((c) => !c.ok);
|
|
33
|
+
if (bad.length === 0) logger.ok('installation already healthy — refreshing anyway');
|
|
34
|
+
else for (const c of bad) logger.warn(`${c.id}: ${c.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const agentIds = opts.agents.length
|
|
39
|
+
? opts.agents
|
|
40
|
+
: (prior?.agents ?? []).map((a) => a.id).filter(Boolean);
|
|
41
|
+
|
|
42
|
+
const result = await executeInstall({
|
|
43
|
+
projectRoot: root,
|
|
44
|
+
agentIds,
|
|
45
|
+
force: true,
|
|
46
|
+
dryRun: opts.dryRun,
|
|
47
|
+
json: opts.json,
|
|
48
|
+
log: logger,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const after = opts.dryRun ? before : validateInstall(root);
|
|
52
|
+
|
|
53
|
+
if (!opts.json) {
|
|
54
|
+
if (after.ok) logger.ok('repair complete — installation validated');
|
|
55
|
+
else logger.error('repair finished but validation still failing — run: qa doctor');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
logger.result({
|
|
59
|
+
ok: after.ok,
|
|
60
|
+
repaired: true,
|
|
61
|
+
dryRun: opts.dryRun,
|
|
62
|
+
before: before.checks.filter((c) => !c.ok).map((c) => c.id),
|
|
63
|
+
after: after.checks,
|
|
64
|
+
install: result,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return after.ok || opts.dryRun ? EXIT.OK : EXIT.VERIFY;
|
|
68
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// `qa self-test` — verify an installation is operational.
|
|
2
|
+
|
|
3
|
+
import { EXIT } from '../constants.mjs';
|
|
4
|
+
import { resolveProjectRoot } from '../core/paths.mjs';
|
|
5
|
+
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
6
|
+
import { validateInstall } from '../core/validate-install.mjs';
|
|
7
|
+
import { createLogger } from '../core/logger.mjs';
|
|
8
|
+
|
|
9
|
+
export async function runSelfTest(argv, { log } = {}) {
|
|
10
|
+
const opts = parseCommonFlags(argv);
|
|
11
|
+
const logger = log ?? createLogger();
|
|
12
|
+
if (opts.help) {
|
|
13
|
+
logger.result(`Usage: qa self-test [--project <dir>] [--json]
|
|
14
|
+
|
|
15
|
+
Verify skills, lockfile integrity, bundled engine, contracts, and environment.
|
|
16
|
+
Prints PASS/FAIL per check. Does not invoke live AI agents.`);
|
|
17
|
+
return EXIT.OK;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const root = resolveProjectRoot(opts.project ?? process.cwd());
|
|
21
|
+
const { ok, checks } = validateInstall(root);
|
|
22
|
+
|
|
23
|
+
if (!opts.json) {
|
|
24
|
+
logger.step(`self-test — ${root}`);
|
|
25
|
+
for (const check of checks) {
|
|
26
|
+
const tag = check.ok ? 'PASS' : check.hard ? 'FAIL' : 'WARN';
|
|
27
|
+
const line = `[${tag}] ${check.id}: ${check.message}`;
|
|
28
|
+
if (check.ok) logger.ok(line);
|
|
29
|
+
else if (check.hard) logger.error(line);
|
|
30
|
+
else logger.warn(line);
|
|
31
|
+
if (!check.ok && check.hint) logger.info(` → ${check.hint}`);
|
|
32
|
+
}
|
|
33
|
+
if (ok) logger.ok('self-test PASSED');
|
|
34
|
+
else logger.error('self-test FAILED');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logger.result({
|
|
38
|
+
ok,
|
|
39
|
+
projectRoot: root,
|
|
40
|
+
checks,
|
|
41
|
+
note: 'Live skill activation is verified in your AI assistant (see guided first-run).',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return ok ? EXIT.OK : EXIT.VERIFY;
|
|
45
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// `qa uninstall` — remove exactly what was installed, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// The lockfile is the manifest: every path it lists was written by the installer
|
|
4
|
+
// with a recorded hash, so uninstall knows precisely what it owns. Anything not
|
|
5
|
+
// in the lockfile is the user's and is never touched.
|
|
6
|
+
//
|
|
7
|
+
// Removal runs through the same Transaction as install, update, and repair, so
|
|
8
|
+
// it inherits the same guarantees: every deleted file is backed up first, and a
|
|
9
|
+
// failure part-way through restores the project to its prior state rather than
|
|
10
|
+
// leaving a half-removed install.
|
|
11
|
+
//
|
|
12
|
+
// A file that drifted from its recorded hash may carry local edits. Deleting it
|
|
13
|
+
// silently would destroy work, so drift stops the uninstall and names the files;
|
|
14
|
+
// `--force` proceeds (still with backups).
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { EXIT, LOCKFILE, BACKUP_DIR } from '../constants.mjs';
|
|
19
|
+
import { resolveProjectRoot } from '../core/paths.mjs';
|
|
20
|
+
import { readLock, lockPath } from '../core/lockfile.mjs';
|
|
21
|
+
import { hashFile } from '../core/hash.mjs';
|
|
22
|
+
import { Transaction, pruneEmptyDirs } from '../core/fs-safe.mjs';
|
|
23
|
+
import { conflictError, verifyError } from '../core/errors.mjs';
|
|
24
|
+
import { createLogger } from '../core/logger.mjs';
|
|
25
|
+
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
26
|
+
|
|
27
|
+
/** Every __pycache__ directory at or beneath `dir` (empty list if dir is gone). */
|
|
28
|
+
function findPycache(dir) {
|
|
29
|
+
const found = [];
|
|
30
|
+
if (!fs.existsSync(dir)) return found;
|
|
31
|
+
const walk = (current) => {
|
|
32
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
33
|
+
if (!entry.isDirectory()) continue;
|
|
34
|
+
const full = path.join(current, entry.name);
|
|
35
|
+
if (entry.name === '__pycache__') found.push(full);
|
|
36
|
+
else walk(full);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(dir);
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function runUninstall(argv, { log } = {}) {
|
|
44
|
+
const opts = parseCommonFlags(argv);
|
|
45
|
+
const logger = log ?? createLogger();
|
|
46
|
+
if (opts.help) {
|
|
47
|
+
logger.result(`Usage: qa uninstall [--project <dir>] [--json] [--dry-run] [--force]
|
|
48
|
+
|
|
49
|
+
Remove every file listed in ${LOCKFILE}, then the lockfile itself.
|
|
50
|
+
Only pack-owned files are removed; anything else is left alone.
|
|
51
|
+
|
|
52
|
+
--dry-run list what would be removed, write nothing
|
|
53
|
+
--force remove files that drifted from their recorded hash`);
|
|
54
|
+
return EXIT.OK;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const root = resolveProjectRoot(opts.project ?? process.cwd());
|
|
58
|
+
const lock = readLock(root);
|
|
59
|
+
if (!lock) {
|
|
60
|
+
if (!opts.json) {
|
|
61
|
+
logger.error(`FAIL no ${LOCKFILE} in ${root}`);
|
|
62
|
+
logger.info(' → nothing to uninstall');
|
|
63
|
+
}
|
|
64
|
+
throw verifyError(`no ${LOCKFILE} in ${root}`, 'nothing to uninstall');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const present = [];
|
|
68
|
+
const missing = [];
|
|
69
|
+
const drifted = [];
|
|
70
|
+
for (const entry of lock.files) {
|
|
71
|
+
const abs = path.join(root, entry.path);
|
|
72
|
+
if (!fs.existsSync(abs)) {
|
|
73
|
+
missing.push(entry.path);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (hashFile(abs) !== entry.sha256) drifted.push(entry.path);
|
|
77
|
+
present.push(entry.path);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (drifted.length > 0 && !opts.force) {
|
|
81
|
+
if (!opts.json) {
|
|
82
|
+
logger.error(`FAIL ${drifted.length} installed file(s) have local changes`);
|
|
83
|
+
for (const p of drifted.slice(0, 20)) logger.info(` ${p}`);
|
|
84
|
+
if (drifted.length > 20) logger.info(` … and ${drifted.length - 20} more`);
|
|
85
|
+
logger.info(' → keep them: back them up, then re-run');
|
|
86
|
+
logger.info(' → discard them: qa uninstall --force');
|
|
87
|
+
}
|
|
88
|
+
throw conflictError(
|
|
89
|
+
`${drifted.length} installed file(s) drifted from ${LOCKFILE}`,
|
|
90
|
+
'run: qa uninstall --force to remove them anyway',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!opts.json) {
|
|
95
|
+
logger.step(`project: ${root}`);
|
|
96
|
+
logger.step(`removing: ${present.length} file(s)${missing.length ? `, ${missing.length} already gone` : ''}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
100
|
+
const tx = new Transaction(root, path.join(root, BACKUP_DIR, stamp), { dryRun: opts.dryRun });
|
|
101
|
+
for (const rel of present) tx.delete(rel);
|
|
102
|
+
if (fs.existsSync(lockPath(root))) tx.delete(LOCKFILE);
|
|
103
|
+
const summary = tx.commit();
|
|
104
|
+
|
|
105
|
+
// Byproducts of the pack's own bundled code: Python writes __pycache__ next to
|
|
106
|
+
// the modules it imports, so those directories exist because of us and are not
|
|
107
|
+
// in the lockfile. They are regenerable bytecode, so they are removed without
|
|
108
|
+
// a backup — but only under directories that held pack-owned bundle files,
|
|
109
|
+
// never anywhere else in the project.
|
|
110
|
+
let removedByproducts = 0;
|
|
111
|
+
if (!opts.dryRun) {
|
|
112
|
+
const bundleDirs = new Set();
|
|
113
|
+
for (const rel of lock.files) {
|
|
114
|
+
const marker = rel.path.indexOf('scripts/lib/');
|
|
115
|
+
if (marker !== -1) {
|
|
116
|
+
bundleDirs.add(path.join(root, rel.path.slice(0, marker + 'scripts/lib'.length)));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const bundleDir of bundleDirs) {
|
|
120
|
+
for (const cache of findPycache(bundleDir)) {
|
|
121
|
+
fs.rmSync(cache, { recursive: true, force: true });
|
|
122
|
+
removedByproducts += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Leave no empty skeleton behind: prune directories the pack created, deepest
|
|
128
|
+
// first, stopping at anything that still holds a file the user owns.
|
|
129
|
+
let prunedDirs = 0;
|
|
130
|
+
if (!opts.dryRun) {
|
|
131
|
+
const dirs = new Set();
|
|
132
|
+
for (const rel of [...present, ...missing]) {
|
|
133
|
+
let dir = path.dirname(path.join(root, rel));
|
|
134
|
+
// Walk up to (but never past) the project root.
|
|
135
|
+
while (dir.startsWith(root) && dir !== root) {
|
|
136
|
+
dirs.add(dir);
|
|
137
|
+
dir = path.dirname(dir);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const before = [...dirs].filter((d) => fs.existsSync(d)).length;
|
|
141
|
+
pruneEmptyDirs([...dirs]);
|
|
142
|
+
prunedDirs = before - [...dirs].filter((d) => fs.existsSync(d)).length;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!opts.json) {
|
|
146
|
+
if (opts.dryRun) logger.ok(`dry run: would remove ${present.length + 1} file(s)`);
|
|
147
|
+
else {
|
|
148
|
+
logger.ok(`uninstalled ${summary.deleted} file(s); removed ${LOCKFILE}`);
|
|
149
|
+
if (prunedDirs > 0) logger.step(`pruned ${prunedDirs} empty directory(ies)`);
|
|
150
|
+
if (summary.backupDir) logger.step(`backup: ${path.relative(root, summary.backupDir)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
logger.result({
|
|
155
|
+
ok: true,
|
|
156
|
+
uninstalled: true,
|
|
157
|
+
dryRun: Boolean(opts.dryRun),
|
|
158
|
+
removed: opts.dryRun ? present.length + 1 : summary.deleted,
|
|
159
|
+
alreadyMissing: missing.length,
|
|
160
|
+
driftedRemoved: opts.force ? drifted.length : 0,
|
|
161
|
+
removedByproducts,
|
|
162
|
+
prunedDirectories: prunedDirs,
|
|
163
|
+
backupDir: summary.backupDir ?? null,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return EXIT.OK;
|
|
167
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// `qa update` — refresh an install from the current pack source.
|
|
2
|
+
|
|
3
|
+
import { EXIT } from '../constants.mjs';
|
|
4
|
+
import { resolveProjectRoot, resolveSourceRoot } from '../core/paths.mjs';
|
|
5
|
+
import { readLock } from '../core/lockfile.mjs';
|
|
6
|
+
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
7
|
+
import { executeInstall } from './install.mjs';
|
|
8
|
+
import { VERSION } from '../version.mjs';
|
|
9
|
+
import { createLogger } from '../core/logger.mjs';
|
|
10
|
+
|
|
11
|
+
export async function runUpdate(argv, { log } = {}) {
|
|
12
|
+
const opts = parseCommonFlags(argv);
|
|
13
|
+
const logger = log ?? createLogger();
|
|
14
|
+
if (opts.help) {
|
|
15
|
+
logger.result(`Usage: qa update [--project <dir>] [--json] [--dry-run]
|
|
16
|
+
|
|
17
|
+
Update the installed pack from the current source tree (checkout or npm pack).
|
|
18
|
+
After registry publish, this will resolve the latest published version.
|
|
19
|
+
Always re-validates after updating.`);
|
|
20
|
+
return EXIT.OK;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const root = resolveProjectRoot(opts.project ?? process.cwd());
|
|
24
|
+
const sourceRoot = resolveSourceRoot();
|
|
25
|
+
const lock = readLock(root);
|
|
26
|
+
const installedVersion = lock?.pack?.version ?? null;
|
|
27
|
+
|
|
28
|
+
if (!opts.json) {
|
|
29
|
+
logger.step(`pack source: ${sourceRoot}`);
|
|
30
|
+
logger.step(`available version: ${VERSION}`);
|
|
31
|
+
if (installedVersion) logger.step(`installed version: ${installedVersion}`);
|
|
32
|
+
else logger.warn('no prior install found — will install fresh');
|
|
33
|
+
if (installedVersion === VERSION) {
|
|
34
|
+
logger.info('already on the source version — refreshing files for integrity');
|
|
35
|
+
}
|
|
36
|
+
logger.info(
|
|
37
|
+
'Note: registry-based updates activate after the package is published to npm.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const agentIds = opts.agents.length
|
|
42
|
+
? opts.agents
|
|
43
|
+
: (lock?.agents ?? []).map((a) => a.id).filter(Boolean);
|
|
44
|
+
|
|
45
|
+
const result = await executeInstall({
|
|
46
|
+
projectRoot: root,
|
|
47
|
+
agentIds,
|
|
48
|
+
force: true,
|
|
49
|
+
dryRun: opts.dryRun,
|
|
50
|
+
json: opts.json,
|
|
51
|
+
log: logger,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!opts.json) {
|
|
55
|
+
logger.ok(`updated to ${VERSION}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
logger.result({
|
|
59
|
+
ok: true,
|
|
60
|
+
dryRun: opts.dryRun,
|
|
61
|
+
previousVersion: installedVersion,
|
|
62
|
+
version: VERSION,
|
|
63
|
+
sourceRoot,
|
|
64
|
+
registryNote: 'npm registry updates available after publish',
|
|
65
|
+
install: result,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return EXIT.OK;
|
|
69
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// `qa verify` — compare on-disk files to qa-lock.json hashes.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { EXIT, LOCKFILE } from '../constants.mjs';
|
|
6
|
+
import { resolveProjectRoot } from '../core/paths.mjs';
|
|
7
|
+
import { readLock } from '../core/lockfile.mjs';
|
|
8
|
+
import { hashFile } from '../core/hash.mjs';
|
|
9
|
+
import { verifyError } from '../core/errors.mjs';
|
|
10
|
+
import { createLogger } from '../core/logger.mjs';
|
|
11
|
+
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
12
|
+
|
|
13
|
+
export async function runVerify(argv, { log } = {}) {
|
|
14
|
+
const opts = parseCommonFlags(argv);
|
|
15
|
+
const logger = log ?? createLogger();
|
|
16
|
+
if (opts.help) {
|
|
17
|
+
logger.result(`Usage: qa verify [--project <dir>] [--json]
|
|
18
|
+
|
|
19
|
+
Verify installed files match qa-lock.json.
|
|
20
|
+
On failure: run qa repair`);
|
|
21
|
+
return EXIT.OK;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const root = resolveProjectRoot(opts.project ?? process.cwd());
|
|
25
|
+
const lock = readLock(root);
|
|
26
|
+
if (!lock) {
|
|
27
|
+
if (!opts.json) {
|
|
28
|
+
logger.error(`FAIL no ${LOCKFILE}`);
|
|
29
|
+
logger.info(' → run: qa install');
|
|
30
|
+
}
|
|
31
|
+
throw verifyError(`no ${LOCKFILE} in ${root}`, 'run: qa install');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const problems = [];
|
|
35
|
+
for (const entry of lock.files) {
|
|
36
|
+
const abs = path.join(root, entry.path);
|
|
37
|
+
if (!fs.existsSync(abs)) {
|
|
38
|
+
problems.push({ path: entry.path, reason: 'missing' });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const actual = hashFile(abs);
|
|
42
|
+
if (actual !== entry.sha256) {
|
|
43
|
+
problems.push({ path: entry.path, reason: 'hash mismatch' });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (problems.length > 0) {
|
|
48
|
+
if (!opts.json) {
|
|
49
|
+
logger.error(`FAIL ${problems.length} drift(s) detected`);
|
|
50
|
+
for (const p of problems.slice(0, 20)) logger.info(` ${p.path}: ${p.reason}`);
|
|
51
|
+
if (problems.length > 20) logger.info(` … and ${problems.length - 20} more`);
|
|
52
|
+
logger.info(' → run: qa repair');
|
|
53
|
+
}
|
|
54
|
+
throw verifyError('installed files drifted from qa-lock.json', 'run: qa repair');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!opts.json) {
|
|
58
|
+
logger.ok(`PASS verified ${lock.files.length} file(s)`);
|
|
59
|
+
}
|
|
60
|
+
logger.result({ ok: true, files: lock.files.length, pack: lock.pack });
|
|
61
|
+
return EXIT.OK;
|
|
62
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Names and paths shared across the installer. Kept in one place so the CLI,
|
|
2
|
+
// commands, and tests agree on where things live.
|
|
3
|
+
|
|
4
|
+
export const PACK_NAME = 'qa-engineer';
|
|
5
|
+
|
|
6
|
+
// Files the installer writes into the target project root.
|
|
7
|
+
export const CONFIG_FILE = 'qa.config.json';
|
|
8
|
+
export const LOCKFILE = 'qa-lock.json';
|
|
9
|
+
|
|
10
|
+
// The pack's private working directory inside a target project.
|
|
11
|
+
export const QA_DIR = '.qa';
|
|
12
|
+
export const BACKUP_DIR = `${QA_DIR}/backups`;
|
|
13
|
+
|
|
14
|
+
// Marker line prepended to every generated invocation wrapper. Wrappers are
|
|
15
|
+
// disposable build artifacts; this line tells humans and `verify` that the file
|
|
16
|
+
// is pack-owned and regenerated, never hand-edited.
|
|
17
|
+
export const GENERATED_MARKER =
|
|
18
|
+
'Generated by qa-engineer — do not edit. Regenerate with: qa install --force';
|
|
19
|
+
|
|
20
|
+
// Process exit codes. Stable so scripts and CI can branch on them.
|
|
21
|
+
export const EXIT = {
|
|
22
|
+
OK: 0,
|
|
23
|
+
FAILURE: 1, // an operation failed
|
|
24
|
+
USAGE: 2, // the command line was wrong
|
|
25
|
+
CONFLICT: 3, // refused to overwrite un-owned files without --force
|
|
26
|
+
VERIFY: 4, // installed files drifted from the lockfile
|
|
27
|
+
ENVIRONMENT: 5, // the environment cannot support the requested operation
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// The two canonical skill discovery directories. Every target agent reads one
|
|
31
|
+
// of these; agent-specific directories are alternatives, not additions, so the
|
|
32
|
+
// planner installs each skill once per unique directory (no duplicates).
|
|
33
|
+
export const SHARED_SKILLS_DIR = '.agents/skills';
|
|
34
|
+
export const CLAUDE_SKILLS_DIR = '.claude/skills';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Bundling deterministic tooling, integrated into installation. Skills that run
|
|
2
|
+
// a Python engine need that engine to travel with them (self-containment). The
|
|
3
|
+
// installer materializes it by COPYING the canonical code from shared/ into the
|
|
4
|
+
// installed skill's scripts/lib/ — a pure file copy, no code executed at install
|
|
5
|
+
// time, honoring the SECURITY.md guarantee. This mirrors what
|
|
6
|
+
// scripts/bundle_python.py does for repository development; users never run it.
|
|
7
|
+
//
|
|
8
|
+
// Deep verification (that the bundle *runs* under the user's interpreter)
|
|
9
|
+
// requires running Python and therefore lives in `qa doctor`, an explicit,
|
|
10
|
+
// user-invoked diagnostic — never in the install path.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
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';
|
|
23
|
+
import { listFilesRelative } from './paths.mjs';
|
|
24
|
+
|
|
25
|
+
/** Bundle file entries for one skill, relative to the skill directory. */
|
|
26
|
+
export function bundleFilesForSkill(sourceRoot, skill) {
|
|
27
|
+
const entry = BUNDLE_MANIFEST[skill];
|
|
28
|
+
if (!entry) return [];
|
|
29
|
+
const entries = [];
|
|
30
|
+
|
|
31
|
+
for (const pkg of entry.packages) {
|
|
32
|
+
const src = path.join(sourceRoot, BUNDLE_SOURCES[pkg]);
|
|
33
|
+
if (!fs.existsSync(src)) {
|
|
34
|
+
throw new Error(`bundled package source missing: ${BUNDLE_SOURCES[pkg]} (needed by ${skill})`);
|
|
35
|
+
}
|
|
36
|
+
for (const rel of listFilesRelative(src)) {
|
|
37
|
+
entries.push({
|
|
38
|
+
rel: `${BUNDLE_DEST}/${pkg}/${rel}`,
|
|
39
|
+
content: fs.readFileSync(path.join(src, rel)),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// Package data the code reads at runtime (internal schemas). Without these
|
|
43
|
+
// the diagnostics engine raises on every diagnosis.
|
|
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)) {
|
|
50
|
+
entries.push({
|
|
51
|
+
rel: `${BUNDLE_DEST}/${pkg}/${data.to}/${rel}`,
|
|
52
|
+
content: fs.readFileSync(path.join(dataSrc, rel)),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The launcher: the documented entry point, beside lib/ rather than inside it.
|
|
59
|
+
const launcherSrc = path.join(sourceRoot, BUNDLE_LAUNCHER.from);
|
|
60
|
+
if (!fs.existsSync(launcherSrc)) {
|
|
61
|
+
throw new Error(`bundled launcher missing: ${BUNDLE_LAUNCHER.from} (needed by ${skill})`);
|
|
62
|
+
}
|
|
63
|
+
entries.push({ rel: BUNDLE_LAUNCHER.to, content: fs.readFileSync(launcherSrc) });
|
|
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
|
+
}
|
|
75
|
+
|
|
76
|
+
return entries;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True if any skill carries bundled tooling (used to gate doctor's deep check). */
|
|
80
|
+
export function packHasBundles() {
|
|
81
|
+
return Object.keys(BUNDLE_MANIFEST).length > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
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
|
+
/**
|
|
115
|
+
* Verify bundled tooling RUNS using ONLY its own directory on PYTHONPATH —
|
|
116
|
+
* proving self-containment under the user's interpreter. Doctor-only.
|
|
117
|
+
*/
|
|
118
|
+
export function verifyImports({ pythonBin, libDir, packages }) {
|
|
119
|
+
const result = spawnSync(pythonBin, ['-c', SMOKE, JSON.stringify(packages)], {
|
|
120
|
+
env: { PYTHONPATH: libDir, PATH: process.env.PATH ?? '' },
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
});
|
|
123
|
+
return { ok: result.status === 0, stderr: (result.stderr ?? '').trim() };
|
|
124
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Loading, validating, and defaulting qa.config.json. The file is optional;
|
|
2
|
+
// when absent, DEFAULTS apply. When present, it is validated against the
|
|
3
|
+
// published schema and merged over the defaults.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { CONFIG_FILE } from '../constants.mjs';
|
|
9
|
+
import { QaError, usageError } from './errors.mjs';
|
|
10
|
+
import { validate } from './schema-validate.mjs';
|
|
11
|
+
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const SCHEMA_PATH = path.join(here, '..', '..', 'schemas', 'qa.config.schema.json');
|
|
14
|
+
|
|
15
|
+
export const CONFIG_SCHEMA = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
|
|
16
|
+
|
|
17
|
+
// Every documented default in one place. The effective configuration is these
|
|
18
|
+
// values with any qa.config.json overrides applied on top.
|
|
19
|
+
export const DEFAULTS = Object.freeze({
|
|
20
|
+
version: 1,
|
|
21
|
+
frameworks: {},
|
|
22
|
+
browser: { default: 'chromium' },
|
|
23
|
+
output: { directory: 'qa-artifacts' },
|
|
24
|
+
logging: { level: 'info' },
|
|
25
|
+
telemetry: { enabled: false },
|
|
26
|
+
plugins: [],
|
|
27
|
+
agents: [],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function deepMerge(base, override) {
|
|
31
|
+
const result = Array.isArray(base) ? [...base] : { ...base };
|
|
32
|
+
for (const [key, value] of Object.entries(override ?? {})) {
|
|
33
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && typeof result[key] === 'object') {
|
|
34
|
+
result[key] = deepMerge(result[key], value);
|
|
35
|
+
} else {
|
|
36
|
+
result[key] = value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The starter config written by `qa init`. Minimal on purpose — only version. */
|
|
43
|
+
export function starterConfig() {
|
|
44
|
+
return { version: 1 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Validate a raw config object; throws a usage error listing every problem. */
|
|
48
|
+
export function validateConfig(raw, source = CONFIG_FILE) {
|
|
49
|
+
const errors = validate(raw, CONFIG_SCHEMA);
|
|
50
|
+
if (errors.length > 0) {
|
|
51
|
+
throw usageError(`${source} is invalid:\n - ${errors.join('\n - ')}`);
|
|
52
|
+
}
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Load the effective configuration for a project. Returns { config, source }
|
|
58
|
+
* where source is the file path when one was read, or null for defaults.
|
|
59
|
+
*/
|
|
60
|
+
export function loadConfig(projectRoot) {
|
|
61
|
+
const file = path.join(projectRoot, CONFIG_FILE);
|
|
62
|
+
if (!fs.existsSync(file)) {
|
|
63
|
+
return { config: structuredClone(DEFAULTS), source: null };
|
|
64
|
+
}
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new QaError(`${CONFIG_FILE} is not valid JSON: ${error.message}`);
|
|
70
|
+
}
|
|
71
|
+
validateConfig(raw, file);
|
|
72
|
+
return { config: deepMerge(DEFAULTS, raw), source: file };
|
|
73
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Conflict detection. The installer must never silently overwrite a file it
|
|
2
|
+
// does not own. A conflict is an on-disk file that (a) we are about to write,
|
|
3
|
+
// (b) differs from what we would write, and (c) was not recorded as pack-owned
|
|
4
|
+
// by a previous install. Files owned by the prior lockfile are ours to update;
|
|
5
|
+
// byte-identical files are a no-op, not a conflict.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { hashFile } from './hash.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} args
|
|
13
|
+
* @param {string} args.projectRoot
|
|
14
|
+
* @param {Array<{path:string, sha256:string}>} args.planned files we intend to write
|
|
15
|
+
* @param {object|null} args.priorLock the previous lockfile, if any
|
|
16
|
+
* @returns {Array<{path:string, reason:string}>} conflicts (empty = safe)
|
|
17
|
+
*/
|
|
18
|
+
export function detectConflicts({ projectRoot, planned, priorLock }) {
|
|
19
|
+
const owned = new Set((priorLock?.files ?? []).map((f) => f.path));
|
|
20
|
+
const conflicts = [];
|
|
21
|
+
for (const entry of planned) {
|
|
22
|
+
const abs = path.join(projectRoot, entry.path);
|
|
23
|
+
if (!fs.existsSync(abs)) continue; // brand-new file, no conflict
|
|
24
|
+
if (owned.has(entry.path)) continue; // pack-owned, safe to update
|
|
25
|
+
if (hashFile(abs) === entry.sha256) continue; // identical content, no-op
|
|
26
|
+
conflicts.push({ path: entry.path, reason: 'exists and is not owned by a previous install' });
|
|
27
|
+
}
|
|
28
|
+
return conflicts;
|
|
29
|
+
}
|