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.
Files changed (232) hide show
  1. package/COMPATIBILITY.md +71 -0
  2. package/LICENSE +21 -0
  3. package/README.md +508 -0
  4. package/package.json +90 -0
  5. package/packages/installer/README.md +46 -0
  6. package/packages/installer/bin/qa.mjs +136 -0
  7. package/packages/installer/lib/agents/registry.mjs +209 -0
  8. package/packages/installer/lib/cli/commands.mjs +29 -0
  9. package/packages/installer/lib/cli/flags.mjs +64 -0
  10. package/packages/installer/lib/commands/doctor.mjs +190 -0
  11. package/packages/installer/lib/commands/install.mjs +291 -0
  12. package/packages/installer/lib/commands/onboard.mjs +163 -0
  13. package/packages/installer/lib/commands/repair.mjs +68 -0
  14. package/packages/installer/lib/commands/self-test.mjs +45 -0
  15. package/packages/installer/lib/commands/uninstall.mjs +167 -0
  16. package/packages/installer/lib/commands/update.mjs +69 -0
  17. package/packages/installer/lib/commands/verify.mjs +62 -0
  18. package/packages/installer/lib/constants.mjs +34 -0
  19. package/packages/installer/lib/core/bundle.mjs +124 -0
  20. package/packages/installer/lib/core/config.mjs +73 -0
  21. package/packages/installer/lib/core/conflict.mjs +29 -0
  22. package/packages/installer/lib/core/errors.mjs +29 -0
  23. package/packages/installer/lib/core/fs-safe.mjs +236 -0
  24. package/packages/installer/lib/core/hash.mjs +19 -0
  25. package/packages/installer/lib/core/lockfile.mjs +75 -0
  26. package/packages/installer/lib/core/logger.mjs +46 -0
  27. package/packages/installer/lib/core/manifest.mjs +89 -0
  28. package/packages/installer/lib/core/paths.mjs +74 -0
  29. package/packages/installer/lib/core/schema-validate.mjs +142 -0
  30. package/packages/installer/lib/core/skill-meta.mjs +75 -0
  31. package/packages/installer/lib/core/validate-install.mjs +152 -0
  32. package/packages/installer/lib/core/wrappers.mjs +91 -0
  33. package/packages/installer/lib/detect/environment.mjs +54 -0
  34. package/packages/installer/lib/detect/frameworks.mjs +94 -0
  35. package/packages/installer/lib/detect/project.mjs +126 -0
  36. package/packages/installer/lib/detect/recommend.mjs +85 -0
  37. package/packages/installer/lib/detect/scan.mjs +48 -0
  38. package/packages/installer/lib/ui/progress.mjs +32 -0
  39. package/packages/installer/lib/ui/theme.mjs +81 -0
  40. package/packages/installer/lib/version.mjs +47 -0
  41. package/packages/installer/package.json +28 -0
  42. package/packages/installer/schemas/qa-lock.schema.json +89 -0
  43. package/packages/installer/schemas/qa.config.schema.json +91 -0
  44. package/shared/analysis/lib/qa_analysis/__init__.py +11 -0
  45. package/shared/analysis/lib/qa_analysis/branding.json +9 -0
  46. package/shared/analysis/lib/qa_analysis/branding.py +175 -0
  47. package/shared/analysis/lib/qa_analysis/cli.py +129 -0
  48. package/shared/analysis/lib/qa_analysis/context.py +233 -0
  49. package/shared/analysis/lib/qa_analysis/contracts.py +158 -0
  50. package/shared/analysis/lib/qa_analysis/diff_guard.py +327 -0
  51. package/shared/analysis/lib/qa_analysis/discovery.py +113 -0
  52. package/shared/analysis/lib/qa_analysis/evidence.py +128 -0
  53. package/shared/analysis/lib/qa_analysis/har.py +58 -0
  54. package/shared/analysis/lib/qa_analysis/junit.py +80 -0
  55. package/shared/analysis/lib/qa_analysis/redaction.py +99 -0
  56. package/shared/analysis/lib/qa_analysis/taxonomy.py +98 -0
  57. package/shared/analysis/schemas/context.schema.json +82 -0
  58. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +13 -0
  59. package/shared/diagnostics/lib/qa_diagnostics/cli.py +124 -0
  60. package/shared/diagnostics/lib/qa_diagnostics/engine.py +143 -0
  61. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +75 -0
  62. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +101 -0
  63. package/shared/diagnostics/lib/qa_diagnostics/repair.py +72 -0
  64. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +89 -0
  65. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +71 -0
  66. package/shared/diagnostics/schemas/internal/analysis-result.schema.json +28 -0
  67. package/shared/diagnostics/schemas/internal/diagnosis.schema.json +55 -0
  68. package/shared/diagnostics/schemas/internal/execution-result-min.schema.json +34 -0
  69. package/shared/frameworks/cypress/lib/cypress_analysis.py +27 -0
  70. package/shared/frameworks/playwright/lib/playwright_analysis.py +167 -0
  71. package/shared/frameworks/registry.json +150 -0
  72. package/shared/frameworks/registry.mjs +37 -0
  73. package/shared/frameworks/registry.schema.json +74 -0
  74. package/shared/frameworks/selenium/lib/selenium_analysis.py +28 -0
  75. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +24 -0
  76. package/shared/tooling/qa_tool.py +127 -0
  77. package/skills/README.md +31 -0
  78. package/skills/qa/README.md +19 -0
  79. package/skills/qa/SKILL.md +52 -0
  80. package/skills/qa/examples/routing.md +86 -0
  81. package/skills/qa/references/routing-map.md +33 -0
  82. package/skills/qa-api/README.md +19 -0
  83. package/skills/qa-api/SKILL.md +68 -0
  84. package/skills/qa-api/contracts/api-result.schema.json +51 -0
  85. package/skills/qa-api/examples/graphql-review.md +43 -0
  86. package/skills/qa-api/references/authentication.md +45 -0
  87. package/skills/qa-api/references/deterministic-tooling.md +128 -0
  88. package/skills/qa-api/references/evidence-and-reporting.md +66 -0
  89. package/skills/qa-api/references/graphql.md +44 -0
  90. package/skills/qa-api/references/rest.md +45 -0
  91. package/skills/qa-api/references/websocket.md +44 -0
  92. package/skills/qa-audit/README.md +19 -0
  93. package/skills/qa-audit/SKILL.md +70 -0
  94. package/skills/qa-audit/contracts/audit-result.schema.json +63 -0
  95. package/skills/qa-audit/examples/accessibility-audit.md +46 -0
  96. package/skills/qa-audit/references/accessibility.md +44 -0
  97. package/skills/qa-audit/references/deterministic-tooling.md +128 -0
  98. package/skills/qa-audit/references/evidence-and-reporting.md +66 -0
  99. package/skills/qa-audit/references/performance.md +44 -0
  100. package/skills/qa-audit/references/security.md +43 -0
  101. package/skills/qa-audit/references/visual-testing.md +44 -0
  102. package/skills/qa-debug/README.md +19 -0
  103. package/skills/qa-debug/SKILL.md +76 -0
  104. package/skills/qa-debug/contracts/debug-result.schema.json +121 -0
  105. package/skills/qa-debug/examples/failed-login.md +61 -0
  106. package/skills/qa-debug/examples/locator-break.md +59 -0
  107. package/skills/qa-debug/examples/network-timeout.md +61 -0
  108. package/skills/qa-debug/examples/successful-debug.md +62 -0
  109. package/skills/qa-debug/references/confidence-model.md +26 -0
  110. package/skills/qa-debug/references/deterministic-tooling.md +128 -0
  111. package/skills/qa-debug/references/diagnostic-engine.md +36 -0
  112. package/skills/qa-debug/references/evidence-and-reporting.md +66 -0
  113. package/skills/qa-debug/references/evidence-model.md +41 -0
  114. package/skills/qa-debug/references/failure-taxonomy.md +44 -0
  115. package/skills/qa-debug/references/finding-prioritization.md +43 -0
  116. package/skills/qa-debug/references/investigation-workflow.md +48 -0
  117. package/skills/qa-debug/references/recommendation-ranking.md +34 -0
  118. package/skills/qa-debug/references/root-cause-analysis.md +49 -0
  119. package/skills/qa-debug/references/timeline-builder.md +37 -0
  120. package/skills/qa-example/README.md +19 -0
  121. package/skills/qa-example/SKILL.md +60 -0
  122. package/skills/qa-example/contracts/self-check-report.schema.json +65 -0
  123. package/skills/qa-example/examples/self-check.md +53 -0
  124. package/skills/qa-example/references/example-domain.md +28 -0
  125. package/skills/qa-example/references/skill-format-notes.md +26 -0
  126. package/skills/qa-explore/README.md +22 -0
  127. package/skills/qa-explore/SKILL.md +87 -0
  128. package/skills/qa-explore/contracts/explore-result.schema.json +249 -0
  129. package/skills/qa-explore/examples/attached-test-cases.md +70 -0
  130. package/skills/qa-explore/examples/screenshot-proof-finding.md +50 -0
  131. package/skills/qa-explore/examples/url-smoke-explore.md +80 -0
  132. package/skills/qa-explore/references/accessibility.md +44 -0
  133. package/skills/qa-explore/references/api-replay.md +48 -0
  134. package/skills/qa-explore/references/browser-adapters.md +51 -0
  135. package/skills/qa-explore/references/evidence-and-reporting.md +66 -0
  136. package/skills/qa-explore/references/evidence-capture.md +54 -0
  137. package/skills/qa-explore/references/exploratory-qa.md +52 -0
  138. package/skills/qa-explore/references/finding-taxonomy.md +47 -0
  139. package/skills/qa-explore/references/performance.md +44 -0
  140. package/skills/qa-explore/references/pipeline.md +74 -0
  141. package/skills/qa-explore/references/report-pipeline.md +69 -0
  142. package/skills/qa-explore/references/security.md +43 -0
  143. package/skills/qa-explore/references/test-case-intake.md +44 -0
  144. package/skills/qa-fix/README.md +19 -0
  145. package/skills/qa-fix/SKILL.md +70 -0
  146. package/skills/qa-fix/contracts/fix-result.schema.json +132 -0
  147. package/skills/qa-fix/examples/repair-plan.md +56 -0
  148. package/skills/qa-fix/references/deterministic-tooling.md +128 -0
  149. package/skills/qa-fix/references/diagnostic-engine.md +36 -0
  150. package/skills/qa-fix/references/evidence-and-reporting.md +66 -0
  151. package/skills/qa-fix/references/repair-strategy.md +48 -0
  152. package/skills/qa-fix/references/root-cause-analysis.md +49 -0
  153. package/skills/qa-fix/references/suite-extension.md +73 -0
  154. package/skills/qa-flaky/README.md +19 -0
  155. package/skills/qa-flaky/SKILL.md +67 -0
  156. package/skills/qa-flaky/contracts/flaky-result.schema.json +62 -0
  157. package/skills/qa-flaky/examples/flaky-locator.md +46 -0
  158. package/skills/qa-flaky/references/deterministic-tooling.md +128 -0
  159. package/skills/qa-flaky/references/evidence-and-reporting.md +66 -0
  160. package/skills/qa-flaky/references/flakiness.md +48 -0
  161. package/skills/qa-flaky/references/retry.md +44 -0
  162. package/skills/qa-flaky/references/waiting-strategies.md +44 -0
  163. package/skills/qa-generate/README.md +42 -0
  164. package/skills/qa-generate/SKILL.md +83 -0
  165. package/skills/qa-generate/contracts/generation-result.schema.json +124 -0
  166. package/skills/qa-generate/examples/bootstrap-new-framework.md +24 -0
  167. package/skills/qa-generate/examples/extend-existing-suite.md +26 -0
  168. package/skills/qa-generate/references/code-style.md +29 -0
  169. package/skills/qa-generate/references/evidence-and-reporting.md +66 -0
  170. package/skills/qa-generate/references/framework-selection.md +88 -0
  171. package/skills/qa-generate/references/generation-strategy.md +48 -0
  172. package/skills/qa-generate/references/naming-conventions.md +27 -0
  173. package/skills/qa-generate/references/playwright-conventions.md +23 -0
  174. package/skills/qa-generate/references/playwright-generation.md +41 -0
  175. package/skills/qa-generate/references/playwright-project-discovery.md +42 -0
  176. package/skills/qa-generate/references/project-bootstrap.md +54 -0
  177. package/skills/qa-generate/references/repository-analysis.md +43 -0
  178. package/skills/qa-generate/references/suite-extension.md +73 -0
  179. package/skills/qa-generate/references/template-selection.md +48 -0
  180. package/skills/qa-generate/templates/playwright/api.ts +36 -0
  181. package/skills/qa-generate/templates/playwright/base-page.ts +14 -0
  182. package/skills/qa-generate/templates/playwright/data.ts +25 -0
  183. package/skills/qa-generate/templates/playwright/env.example +16 -0
  184. package/skills/qa-generate/templates/playwright/example.spec.ts +14 -0
  185. package/skills/qa-generate/templates/playwright/fixtures.ts +28 -0
  186. package/skills/qa-generate/templates/playwright/framework-readme.md +39 -0
  187. package/skills/qa-generate/templates/playwright/login.page.ts +26 -0
  188. package/skills/qa-generate/templates/playwright/playwright.config.ts +28 -0
  189. package/skills/qa-generate/templates/playwright/utils.ts +18 -0
  190. package/skills/qa-init/README.md +20 -0
  191. package/skills/qa-init/SKILL.md +72 -0
  192. package/skills/qa-init/examples/initialize-a-repo.md +91 -0
  193. package/skills/qa-init/references/detection-guide.md +76 -0
  194. package/skills/qa-init/references/deterministic-tooling.md +128 -0
  195. package/skills/qa-init/references/evidence-and-reporting.md +66 -0
  196. package/skills/qa-init/templates/context.md +68 -0
  197. package/skills/qa-report/README.md +19 -0
  198. package/skills/qa-report/SKILL.md +72 -0
  199. package/skills/qa-report/contracts/report-result.schema.json +186 -0
  200. package/skills/qa-report/examples/release-report.md +68 -0
  201. package/skills/qa-report/references/deterministic-tooling.md +128 -0
  202. package/skills/qa-report/references/diagnostic-engine.md +36 -0
  203. package/skills/qa-report/references/evidence-and-reporting.md +66 -0
  204. package/skills/qa-report/references/finding-prioritization.md +43 -0
  205. package/skills/qa-report/references/recommendation-ranking.md +34 -0
  206. package/skills/qa-report/references/report-aggregation.md +44 -0
  207. package/skills/qa-review/README.md +19 -0
  208. package/skills/qa-review/SKILL.md +58 -0
  209. package/skills/qa-review/contracts/review-result.schema.json +59 -0
  210. package/skills/qa-review/examples/suite-review.md +51 -0
  211. package/skills/qa-review/references/anti-patterns.md +49 -0
  212. package/skills/qa-review/references/assertion-patterns.md +45 -0
  213. package/skills/qa-review/references/evidence-and-reporting.md +66 -0
  214. package/skills/qa-review/references/fixtures.md +45 -0
  215. package/skills/qa-review/references/page-objects.md +45 -0
  216. package/skills/qa-run/README.md +22 -0
  217. package/skills/qa-run/SKILL.md +92 -0
  218. package/skills/qa-run/contracts/execution-plan.schema.json +132 -0
  219. package/skills/qa-run/contracts/execution-result.schema.json +204 -0
  220. package/skills/qa-run/examples/execute-playwright.md +89 -0
  221. package/skills/qa-run/examples/plan-a-run.md +83 -0
  222. package/skills/qa-run/references/artifact-collector.md +40 -0
  223. package/skills/qa-run/references/browser-launch.md +39 -0
  224. package/skills/qa-run/references/command-builder.md +39 -0
  225. package/skills/qa-run/references/deterministic-tooling.md +128 -0
  226. package/skills/qa-run/references/environment-detection.md +37 -0
  227. package/skills/qa-run/references/evidence-and-reporting.md +66 -0
  228. package/skills/qa-run/references/execution-strategy.md +54 -0
  229. package/skills/qa-run/references/playwright-artifacts.md +29 -0
  230. package/skills/qa-run/references/playwright-execution.md +48 -0
  231. package/skills/qa-run/references/playwright-project-discovery.md +42 -0
  232. package/skills/qa-run/references/report-normalization.md +49 -0
@@ -0,0 +1,29 @@
1
+ // Typed errors carrying an exit code, so the CLI can translate a thrown error
2
+ // into the right process exit without a chain of instanceof checks.
3
+
4
+ import { EXIT } from '../constants.mjs';
5
+
6
+ export class QaError extends Error {
7
+ constructor(message, { code = EXIT.FAILURE, hint = null } = {}) {
8
+ super(message);
9
+ this.name = 'QaError';
10
+ this.code = code; // one of EXIT.*
11
+ this.hint = hint; // optional actionable next step shown to the user
12
+ }
13
+ }
14
+
15
+ export function usageError(message, hint = null) {
16
+ return new QaError(message, { code: EXIT.USAGE, hint });
17
+ }
18
+
19
+ export function conflictError(message, hint = null) {
20
+ return new QaError(message, { code: EXIT.CONFLICT, hint });
21
+ }
22
+
23
+ export function verifyError(message, hint = null) {
24
+ return new QaError(message, { code: EXIT.VERIFY, hint });
25
+ }
26
+
27
+ export function environmentError(message, hint = null) {
28
+ return new QaError(message, { code: EXIT.ENVIRONMENT, hint });
29
+ }
@@ -0,0 +1,236 @@
1
+ // Safe filesystem mutation. Every install, update, or uninstall runs through a
2
+ // Transaction that backs up whatever it is about to overwrite or delete, writes
3
+ // atomically, and — on any failure — restores the project to exactly its prior
4
+ // state. A dry run plans without touching disk.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { environmentError, conflictError } from './errors.mjs';
9
+
10
+ // Directories whose contents are never the installer's to touch, however a path
11
+ // reaches it. Deleting a repository's own metadata (or its hooks) is not a
12
+ // recoverable mistake, and no pack file legitimately lives there.
13
+ const FORBIDDEN_SEGMENTS = new Set(['.git', '.hg', '.svn']);
14
+
15
+ /** Contained inside `root` (or equal to it), compared as resolved paths. */
16
+ function isInside(root, target) {
17
+ if (target === root) return true;
18
+ const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
19
+ return target.startsWith(prefix);
20
+ }
21
+
22
+ /**
23
+ * The deepest existing ancestor of `target`, with symlinks resolved, plus the
24
+ * not-yet-existing suffix appended. Used so containment reflects where a write
25
+ * would *land*, not merely how its path is spelled.
26
+ */
27
+ function realTargetPath(target) {
28
+ let probe = target;
29
+ while (!fs.existsSync(probe)) {
30
+ const parent = path.dirname(probe);
31
+ if (parent === probe) return target; // reached the filesystem root
32
+ probe = parent;
33
+ }
34
+ const suffix = path.relative(probe, target);
35
+ const realProbe = fs.realpathSync(probe);
36
+ return suffix ? path.join(realProbe, suffix) : realProbe;
37
+ }
38
+
39
+ /**
40
+ * Resolve a project-relative path, refusing anything that escapes the project.
41
+ *
42
+ * Every mutation the installer performs is expressed as a path relative to the
43
+ * project root, and some of those paths come from `qa-lock.json` — a file that is
44
+ * committed to the consumer's repository and therefore travels with a clone.
45
+ * Both the lockfile *and* the tree it describes are attacker-influenced, so two
46
+ * escapes must be closed, and closing only the first is a false sense of safety:
47
+ *
48
+ * lexical `../../etc/passwd` — spelled outside the project.
49
+ * symbolic `link/file` where `link` is a symlink pointing outside. The path is
50
+ * lexically inside; `fs` follows the link and writes or deletes
51
+ * outside anyway. Git stores symlinks, so this arrives by clone.
52
+ *
53
+ * Containment therefore compares *real* paths, and lives at the single point
54
+ * every write and delete passes through rather than at each call site.
55
+ */
56
+ function resolveInside(root, relPath) {
57
+ if (typeof relPath !== 'string' || relPath.length === 0) {
58
+ throw conflictError('refusing to operate on an empty path');
59
+ }
60
+ if (relPath.includes('\0')) {
61
+ throw conflictError('refusing to operate on a path containing a null byte');
62
+ }
63
+ if (path.isAbsolute(relPath)) {
64
+ throw conflictError(`refusing to operate on an absolute path: ${relPath}`);
65
+ }
66
+ for (const segment of relPath.split(/[\\/]+/)) {
67
+ if (FORBIDDEN_SEGMENTS.has(segment)) {
68
+ throw conflictError(
69
+ `refusing to operate inside ${segment}/: ${relPath}`,
70
+ 'no pack file belongs in a version-control directory',
71
+ );
72
+ }
73
+ }
74
+
75
+ const resolvedRoot = path.resolve(root);
76
+ const target = path.resolve(resolvedRoot, relPath);
77
+ if (!isInside(resolvedRoot, target)) {
78
+ throw conflictError(
79
+ `refusing to operate outside the project: ${relPath}`,
80
+ 'a lockfile or config entry points outside the project root; re-run: qa install --force',
81
+ );
82
+ }
83
+
84
+ // Symlink containment: where would this actually land?
85
+ const realRoot = fs.existsSync(resolvedRoot) ? fs.realpathSync(resolvedRoot) : resolvedRoot;
86
+ if (!isInside(realRoot, realTargetPath(target))) {
87
+ throw conflictError(
88
+ `refusing to follow a link out of the project: ${relPath}`,
89
+ 'a path inside the project resolves outside it through a symbolic link',
90
+ );
91
+ }
92
+
93
+ return target;
94
+ }
95
+
96
+ /** Create dir and parents; returns the list of directories actually created. */
97
+ function ensureDir(dir, createdDirs) {
98
+ const missing = [];
99
+ let current = dir;
100
+ while (!fs.existsSync(current)) {
101
+ missing.unshift(current);
102
+ const parent = path.dirname(current);
103
+ if (parent === current) break;
104
+ current = parent;
105
+ }
106
+ for (const d of missing) {
107
+ fs.mkdirSync(d);
108
+ createdDirs.push(d);
109
+ }
110
+ }
111
+
112
+ /** Write content to file via a temp file + rename, so readers never see a partial write. */
113
+ function atomicWrite(file, content) {
114
+ const tmp = `${file}.qa-tmp-${process.pid}`;
115
+ fs.writeFileSync(tmp, content);
116
+ fs.renameSync(tmp, file);
117
+ }
118
+
119
+ export class Transaction {
120
+ constructor(projectRoot, backupDir, { dryRun = false } = {}) {
121
+ this.root = projectRoot;
122
+ this.backupDir = backupDir; // absolute path for this run's backups
123
+ this.dryRun = dryRun;
124
+ this.ops = []; // { kind: 'write'|'delete', rel, content? }
125
+ }
126
+
127
+ write(relPath, content) {
128
+ resolveInside(this.root, relPath); // reject escapes at staging time, not mid-commit
129
+ this.ops.push({ kind: 'write', rel: relPath, content: Buffer.isBuffer(content) ? content : Buffer.from(content) });
130
+ }
131
+
132
+ delete(relPath) {
133
+ resolveInside(this.root, relPath);
134
+ this.ops.push({ kind: 'delete', rel: relPath });
135
+ }
136
+
137
+ /** Directories the transaction will need to write into, deduplicated. */
138
+ targetDirs() {
139
+ const dirs = new Set();
140
+ for (const op of this.ops) {
141
+ if (op.kind === 'write') dirs.add(path.dirname(path.join(this.root, op.rel)));
142
+ }
143
+ return [...dirs];
144
+ }
145
+
146
+ /** Fail early if a target directory exists but is not writable. */
147
+ checkPermissions() {
148
+ for (const dir of this.targetDirs()) {
149
+ let probe = dir;
150
+ while (!fs.existsSync(probe)) probe = path.dirname(probe); // nearest existing ancestor
151
+ try {
152
+ fs.accessSync(probe, fs.constants.W_OK);
153
+ } catch {
154
+ throw environmentError(`no write permission for ${probe}`);
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Execute the staged operations. On any error, everything already done is
161
+ * rolled back and the original error is rethrown. Returns a summary.
162
+ */
163
+ commit() {
164
+ if (this.dryRun) {
165
+ return { written: this.ops.filter((o) => o.kind === 'write').length, deleted: this.ops.filter((o) => o.kind === 'delete').length, backedUp: 0, dryRun: true };
166
+ }
167
+ this.checkPermissions();
168
+
169
+ const created = []; // files that did not exist before (remove on rollback)
170
+ const createdDirs = []; // directories we made (remove on rollback if empty)
171
+ const backups = []; // { abs, backup } for files that existed (restore on rollback)
172
+ let backedUp = 0;
173
+
174
+ const backup = (abs, rel) => {
175
+ const dest = path.join(this.backupDir, rel);
176
+ ensureDir(path.dirname(dest), createdDirs);
177
+ fs.copyFileSync(abs, dest);
178
+ backups.push({ abs, backup: dest });
179
+ backedUp += 1;
180
+ };
181
+
182
+ try {
183
+ for (const op of this.ops) {
184
+ // Re-checked at commit time: staging and commit may be separated by
185
+ // other code, and containment is cheap to reassert.
186
+ const abs = resolveInside(this.root, op.rel);
187
+ if (op.kind === 'write') {
188
+ if (fs.existsSync(abs)) backup(abs, op.rel);
189
+ else created.push(abs);
190
+ ensureDir(path.dirname(abs), createdDirs);
191
+ atomicWrite(abs, op.content);
192
+ } else if (op.kind === 'delete') {
193
+ if (fs.existsSync(abs)) {
194
+ backup(abs, op.rel);
195
+ fs.rmSync(abs);
196
+ }
197
+ }
198
+ }
199
+ } catch (error) {
200
+ // Roll back: remove created files, restore backups, drop created dirs.
201
+ for (const abs of created) {
202
+ if (fs.existsSync(abs)) fs.rmSync(abs, { force: true });
203
+ }
204
+ for (const { abs, backup: from } of backups) {
205
+ fs.copyFileSync(from, abs);
206
+ }
207
+ for (const dir of [...createdDirs].reverse()) {
208
+ try {
209
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
210
+ } catch {
211
+ /* leave non-empty directories in place */
212
+ }
213
+ }
214
+ throw error;
215
+ }
216
+
217
+ return {
218
+ written: this.ops.filter((o) => o.kind === 'write').length,
219
+ deleted: this.ops.filter((o) => o.kind === 'delete').length,
220
+ backedUp,
221
+ backupDir: backedUp > 0 ? this.backupDir : null,
222
+ dryRun: false,
223
+ };
224
+ }
225
+ }
226
+
227
+ /** Remove now-empty directories from `dirs` (deepest first). Best-effort. */
228
+ export function pruneEmptyDirs(dirs) {
229
+ for (const dir of [...dirs].sort((a, b) => b.length - a.length)) {
230
+ try {
231
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
232
+ } catch {
233
+ /* ignore */
234
+ }
235
+ }
236
+ }
@@ -0,0 +1,19 @@
1
+ // Content hashing for the lockfile. sha256 over raw bytes, so integrity checks
2
+ // are exact and independent of line endings or text encoding assumptions.
3
+
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+
7
+ export const HASH_ALGORITHM = 'sha256';
8
+
9
+ export function hashBytes(buffer) {
10
+ return crypto.createHash(HASH_ALGORITHM).update(buffer).digest('hex');
11
+ }
12
+
13
+ export function hashString(text) {
14
+ return hashBytes(Buffer.from(text, 'utf8'));
15
+ }
16
+
17
+ export function hashFile(filePath) {
18
+ return hashBytes(fs.readFileSync(filePath));
19
+ }
@@ -0,0 +1,75 @@
1
+ // Reading and writing qa-lock.json — the record of exactly what was installed.
2
+ // The lockfile is the manifest uninstall and verify trust; it is validated
3
+ // against its schema on both read and write so a corrupt lockfile fails loudly.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { LOCKFILE, PACK_NAME } from '../constants.mjs';
9
+ import { VERSION, SPEC_REVISION } from '../version.mjs';
10
+ import { QaError } from './errors.mjs';
11
+ import { validate } from './schema-validate.mjs';
12
+
13
+ const here = path.dirname(fileURLToPath(import.meta.url));
14
+ const SCHEMA_PATH = path.join(here, '..', '..', 'schemas', 'qa-lock.schema.json');
15
+
16
+ export const LOCK_SCHEMA = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
17
+ export const LOCKFILE_VERSION = 1;
18
+
19
+ export function lockPath(projectRoot) {
20
+ return path.join(projectRoot, LOCKFILE);
21
+ }
22
+
23
+ export function readLock(projectRoot) {
24
+ const file = lockPath(projectRoot);
25
+ if (!fs.existsSync(file)) return null;
26
+ let raw;
27
+ try {
28
+ raw = JSON.parse(fs.readFileSync(file, 'utf8'));
29
+ } catch (error) {
30
+ throw new QaError(`${LOCKFILE} is corrupt (invalid JSON): ${error.message}`);
31
+ }
32
+ const errors = validate(raw, LOCK_SCHEMA);
33
+ if (errors.length > 0) {
34
+ throw new QaError(`${LOCKFILE} does not match its schema:\n - ${errors.join('\n - ')}`);
35
+ }
36
+ return raw;
37
+ }
38
+
39
+ /**
40
+ * Assemble a lockfile object. `now` is injected so tests are deterministic;
41
+ * the CLI passes the real time.
42
+ */
43
+ export function buildLock({ agents, files, now }) {
44
+ const lock = {
45
+ lockfileVersion: LOCKFILE_VERSION,
46
+ pack: { name: PACK_NAME, version: VERSION, specRevision: SPEC_REVISION },
47
+ installer: VERSION,
48
+ generatedAt: now,
49
+ agents: agents.map((a) => ({
50
+ id: a.id,
51
+ name: a.name,
52
+ tier: a.tier ?? null,
53
+ skillsDir: a.skillsDir,
54
+ // Whether project markers actually identified this host, as opposed to it
55
+ // being requested with --agent or standing in as the unknown-agent path.
56
+ detected: Boolean(a.detected),
57
+ })),
58
+ files: [...files].sort((a, b) => a.path.localeCompare(b.path)),
59
+ summary: {
60
+ skills: new Set(files.filter((f) => f.owner === 'skill' && f.skill).map((f) => `${f.agent}:${f.skill}`)).size,
61
+ files: files.length,
62
+ wrappers: files.filter((f) => f.owner === 'wrapper').length,
63
+ directories: new Set(files.map((f) => path.posix.dirname(f.path))).size,
64
+ },
65
+ };
66
+ const errors = validate(lock, LOCK_SCHEMA);
67
+ if (errors.length > 0) {
68
+ throw new QaError(`internal error: generated lockfile is invalid:\n - ${errors.join('\n - ')}`);
69
+ }
70
+ return lock;
71
+ }
72
+
73
+ export function serializeLock(lock) {
74
+ return `${JSON.stringify(lock, null, 2)}\n`;
75
+ }
@@ -0,0 +1,46 @@
1
+ // A tiny leveled logger. Human-readable progress and diagnostics go to stderr
2
+ // so that stdout carries only a command's primary result (often JSON), keeping
3
+ // the CLI pipeable. Colour is used only when stderr is a TTY.
4
+
5
+ const LEVELS = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 };
6
+
7
+ const COLOR = {
8
+ reset: '\x1b[0m',
9
+ dim: '\x1b[2m',
10
+ red: '\x1b[31m',
11
+ yellow: '\x1b[33m',
12
+ green: '\x1b[32m',
13
+ cyan: '\x1b[36m',
14
+ };
15
+
16
+ export function createLogger({ level = 'info', color = process.stderr.isTTY } = {}) {
17
+ const threshold = LEVELS[level] ?? LEVELS.info;
18
+ const paint = (c, s) => (color ? `${c}${s}${COLOR.reset}` : s);
19
+ const at = (name) => LEVELS[name] <= threshold;
20
+
21
+ return {
22
+ level,
23
+ error(msg) {
24
+ if (at('error')) process.stderr.write(`${paint(COLOR.red, 'error')} ${msg}\n`);
25
+ },
26
+ warn(msg) {
27
+ if (at('warn')) process.stderr.write(`${paint(COLOR.yellow, 'warn')} ${msg}\n`);
28
+ },
29
+ info(msg) {
30
+ if (at('info')) process.stderr.write(`${msg}\n`);
31
+ },
32
+ step(msg) {
33
+ if (at('info')) process.stderr.write(`${paint(COLOR.cyan, '›')} ${msg}\n`);
34
+ },
35
+ ok(msg) {
36
+ if (at('info')) process.stderr.write(`${paint(COLOR.green, '✓')} ${msg}\n`);
37
+ },
38
+ debug(msg) {
39
+ if (at('debug')) process.stderr.write(`${paint(COLOR.dim, `· ${msg}`)}\n`);
40
+ },
41
+ // Primary machine-readable output belongs on stdout.
42
+ result(value) {
43
+ process.stdout.write(typeof value === 'string' ? `${value}\n` : `${JSON.stringify(value, null, 2)}\n`);
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,89 @@
1
+ // What the pack ships: the set of skills, the files inside each, and which
2
+ // skills carry bundled deterministic tooling. This manifest deliberately
3
+ // mirrors scripts/bundle_python.py — the two must agree, and a test asserts it.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { listFilesRelative } from './paths.mjs';
8
+
9
+ // Skills that run deterministic tooling in the consumer's repository. The
10
+ // bundle is materialized at install time, never shipped pre-built.
11
+ // qa-review is knowledge-only and bundles nothing.
12
+ //
13
+ // packages — importable directories
14
+ // modules — single-file framework adapters, bundled flat
15
+ export const BUNDLE_MANIFEST = Object.freeze({
16
+ 'qa-init': Object.freeze({ packages: ['qa_analysis'], modules: [] }),
17
+ 'qa-run': Object.freeze({ packages: ['qa_analysis'], modules: ['playwright_analysis'] }),
18
+ 'qa-debug': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: ['playwright_analysis'] }),
19
+ 'qa-fix': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
20
+ 'qa-report': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
21
+ 'qa-flaky': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
22
+ 'qa-api': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
23
+ 'qa-audit': Object.freeze({ packages: ['qa_analysis', 'qa_diagnostics'], modules: [] }),
24
+ });
25
+
26
+ // Canonical source of each bundled package, relative to the pack root.
27
+ export const BUNDLE_SOURCES = Object.freeze({
28
+ qa_analysis: 'shared/analysis/lib/qa_analysis',
29
+ qa_diagnostics: 'shared/diagnostics/lib/qa_diagnostics',
30
+ });
31
+
32
+ // Non-Python files a bundled package reads at runtime, relative to the pack
33
+ // root and to the package directory. The diagnostics engine validates every
34
+ // diagnosis against its internal schemas, so without these it cannot run.
35
+ export const BUNDLE_PACKAGE_DATA = Object.freeze({
36
+ // The context CLI validates .qa/context.md against the context contract.
37
+ qa_analysis: Object.freeze([
38
+ Object.freeze({ from: 'shared/analysis/schemas', to: 'schemas' }),
39
+ ]),
40
+ qa_diagnostics: Object.freeze([
41
+ Object.freeze({ from: 'shared/diagnostics/schemas/internal', to: 'schemas/internal' }),
42
+ ]),
43
+ });
44
+
45
+ // Canonical source of each bundled single-file module.
46
+ export const BUNDLE_MODULE_SOURCES = Object.freeze({
47
+ playwright_analysis: 'shared/frameworks/playwright/lib/playwright_analysis.py',
48
+ });
49
+
50
+ // The launcher every bundling skill carries, one level above lib/. It resolves
51
+ // its own lib/ path, so the documented invocation needs no shell features and
52
+ // works identically in PowerShell and cmd.exe.
53
+ export const BUNDLE_LAUNCHER = Object.freeze({
54
+ from: 'shared/tooling/qa_tool.py',
55
+ to: 'scripts/qa_tool.py',
56
+ });
57
+
58
+ // Where bundled code lands inside an installed skill.
59
+ export const BUNDLE_DEST = 'scripts/lib';
60
+
61
+ /** Package names bundled into a skill (empty array when it bundles nothing). */
62
+ export function bundlePackagesForSkill(skill) {
63
+ return BUNDLE_MANIFEST[skill]?.packages ?? [];
64
+ }
65
+
66
+ /** Module names bundled into a skill. */
67
+ export function bundleModulesForSkill(skill) {
68
+ return BUNDLE_MANIFEST[skill]?.modules ?? [];
69
+ }
70
+
71
+ /** All skill names shipped by the pack (directories under skills/). */
72
+ export function listSkills(sourceRoot) {
73
+ const base = path.join(sourceRoot, 'skills');
74
+ return fs
75
+ .readdirSync(base, { withFileTypes: true })
76
+ .filter((e) => e.isDirectory())
77
+ .map((e) => e.name)
78
+ .sort();
79
+ }
80
+
81
+ /**
82
+ * Files that make up a skill, relative to the skill directory. Excludes the
83
+ * bundled tooling (scripts/lib): it is a generated artifact produced by the
84
+ * bundle step at install time, not copied from source.
85
+ */
86
+ export function skillFiles(sourceRoot, skill) {
87
+ const dir = path.join(sourceRoot, 'skills', skill);
88
+ return listFilesRelative(dir).filter((rel) => !rel.startsWith(`${BUNDLE_DEST}/`));
89
+ }
@@ -0,0 +1,74 @@
1
+ // Locating the pack source (what we install FROM) and the target project (what
2
+ // we install INTO), plus small filesystem helpers used across the installer.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { PACK_NAME } from '../constants.mjs';
8
+ import { environmentError } from './errors.mjs';
9
+
10
+ const here = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ function isPackRoot(dir) {
13
+ const manifest = path.join(dir, 'package.json');
14
+ if (!fs.existsSync(manifest) || !fs.existsSync(path.join(dir, 'skills'))) return false;
15
+ try {
16
+ return JSON.parse(fs.readFileSync(manifest, 'utf8')).name === PACK_NAME;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * The directory that contains the skills to install. Resolved from, in order:
24
+ * 1. QA_PACK_SOURCE (an explicit override, used by tests and power users),
25
+ * 2. the nearest ancestor of this file that looks like the pack root.
26
+ * Whether run from a git checkout or an npm/npx install, both resolve here.
27
+ */
28
+ export function resolveSourceRoot() {
29
+ const override = process.env.QA_PACK_SOURCE;
30
+ if (override) {
31
+ const resolved = path.resolve(override);
32
+ if (!isPackRoot(resolved)) {
33
+ throw environmentError(`QA_PACK_SOURCE is not a pack root: ${resolved}`);
34
+ }
35
+ return resolved;
36
+ }
37
+ let dir = here;
38
+ while (true) {
39
+ if (isPackRoot(dir)) return dir;
40
+ const parent = path.dirname(dir);
41
+ if (parent === dir) break;
42
+ dir = parent;
43
+ }
44
+ throw environmentError(
45
+ 'could not locate the pack source (no ancestor package.json named ' +
46
+ `"${PACK_NAME}" with a skills/ directory); set QA_PACK_SOURCE to override`,
47
+ );
48
+ }
49
+
50
+ /** The project we install into. Defaults to the current working directory. */
51
+ export function resolveProjectRoot(cwd = process.cwd()) {
52
+ return path.resolve(cwd);
53
+ }
54
+
55
+ /** All files under dir, returned as paths relative to dir, sorted, POSIX-style. */
56
+ export function listFilesRelative(dir) {
57
+ const out = [];
58
+ const walk = (current, prefix) => {
59
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
60
+ if (entry.name === '__pycache__' || entry.name.endsWith('.pyc')) continue;
61
+ const abs = path.join(current, entry.name);
62
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
63
+ if (entry.isDirectory()) walk(abs, rel);
64
+ else if (entry.isFile()) out.push(rel);
65
+ }
66
+ };
67
+ if (fs.existsSync(dir)) walk(dir, '');
68
+ return out.sort();
69
+ }
70
+
71
+ /** POSIX-normalized relative path, so lockfiles are identical across platforms. */
72
+ export function toPosix(relPath) {
73
+ return relPath.split(path.sep).join('/');
74
+ }