openpitstop 1.0.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 (274) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +512 -0
  3. package/demo-repo/README.md +24 -0
  4. package/demo-repo/package-lock.json +4483 -0
  5. package/demo-repo/package.json +17 -0
  6. package/demo-repo/src/config.js +5 -0
  7. package/demo-repo/src/db.js +31 -0
  8. package/demo-repo/src/server.js +13 -0
  9. package/demo-repo/src/userController.js +22 -0
  10. package/demo-repo/src/userRepo.js +18 -0
  11. package/demo-repo/src/userService.js +59 -0
  12. package/demo-repo/test/user.test.js +12 -0
  13. package/demo-repo-fintech/README.md +27 -0
  14. package/demo-repo-fintech/package-lock.json +1023 -0
  15. package/demo-repo-fintech/package.json +14 -0
  16. package/demo-repo-fintech/pitstop-repro-ledger-order-ord-conc-1-charged-2.test.mjs +175 -0
  17. package/demo-repo-fintech/pitstop-repro-ledger-order-ord-retry-2-charged-2.test.mjs +175 -0
  18. package/demo-repo-fintech/pitstop-repro-ledger-order-ord-webhook-3-charged-2.test.mjs +175 -0
  19. package/demo-repo-fintech/src/gateway.js +26 -0
  20. package/demo-repo-fintech/src/server.js +61 -0
  21. package/demo-repo-generators/package-lock.json +1508 -0
  22. package/demo-repo-generators/package.json +17 -0
  23. package/demo-repo-generators/public/index.html +10 -0
  24. package/demo-repo-generators/scripts/build.js +17 -0
  25. package/demo-repo-generators/src/counter.js +16 -0
  26. package/demo-repo-integrity/README.md +49 -0
  27. package/demo-repo-integrity/package.json +12 -0
  28. package/demo-repo-integrity/src/money.js +19 -0
  29. package/demo-repo-integrity/test/money.test.js +17 -0
  30. package/dist/analyzers/accessibility.d.ts +18 -0
  31. package/dist/analyzers/accessibility.js +248 -0
  32. package/dist/analyzers/accessibility.js.map +1 -0
  33. package/dist/analyzers/dependencyGraph.d.ts +14 -0
  34. package/dist/analyzers/dependencyGraph.js +206 -0
  35. package/dist/analyzers/dependencyGraph.js.map +1 -0
  36. package/dist/analyzers/devex.d.ts +19 -0
  37. package/dist/analyzers/devex.js +301 -0
  38. package/dist/analyzers/devex.js.map +1 -0
  39. package/dist/analyzers/duplication.d.ts +2 -0
  40. package/dist/analyzers/duplication.js +47 -0
  41. package/dist/analyzers/duplication.js.map +1 -0
  42. package/dist/analyzers/index.d.ts +12 -0
  43. package/dist/analyzers/index.js +38 -0
  44. package/dist/analyzers/index.js.map +1 -0
  45. package/dist/analyzers/integrity/assertionLiteralTamper.d.ts +2 -0
  46. package/dist/analyzers/integrity/assertionLiteralTamper.js +164 -0
  47. package/dist/analyzers/integrity/assertionLiteralTamper.js.map +1 -0
  48. package/dist/analyzers/integrity/exceptionSwallow.d.ts +2 -0
  49. package/dist/analyzers/integrity/exceptionSwallow.js +198 -0
  50. package/dist/analyzers/integrity/exceptionSwallow.js.map +1 -0
  51. package/dist/analyzers/integrity/exitCheat.d.ts +2 -0
  52. package/dist/analyzers/integrity/exitCheat.js +31 -0
  53. package/dist/analyzers/integrity/exitCheat.js.map +1 -0
  54. package/dist/analyzers/integrity/git.d.ts +10 -0
  55. package/dist/analyzers/integrity/git.js +181 -0
  56. package/dist/analyzers/integrity/git.js.map +1 -0
  57. package/dist/analyzers/integrity/hardcodedMatch.d.ts +2 -0
  58. package/dist/analyzers/integrity/hardcodedMatch.js +93 -0
  59. package/dist/analyzers/integrity/hardcodedMatch.js.map +1 -0
  60. package/dist/analyzers/integrity/helpers.d.ts +21 -0
  61. package/dist/analyzers/integrity/helpers.js +66 -0
  62. package/dist/analyzers/integrity/helpers.js.map +1 -0
  63. package/dist/analyzers/integrity/index.d.ts +10 -0
  64. package/dist/analyzers/integrity/index.js +27 -0
  65. package/dist/analyzers/integrity/index.js.map +1 -0
  66. package/dist/analyzers/integrity/mockOverreach.d.ts +2 -0
  67. package/dist/analyzers/integrity/mockOverreach.js +72 -0
  68. package/dist/analyzers/integrity/mockOverreach.js.map +1 -0
  69. package/dist/analyzers/integrity/suppressionCreep.d.ts +2 -0
  70. package/dist/analyzers/integrity/suppressionCreep.js +27 -0
  71. package/dist/analyzers/integrity/suppressionCreep.js.map +1 -0
  72. package/dist/analyzers/integrity/testTamper.d.ts +2 -0
  73. package/dist/analyzers/integrity/testTamper.js +153 -0
  74. package/dist/analyzers/integrity/testTamper.js.map +1 -0
  75. package/dist/analyzers/integrity/types.d.ts +63 -0
  76. package/dist/analyzers/integrity/types.js +13 -0
  77. package/dist/analyzers/integrity/types.js.map +1 -0
  78. package/dist/analyzers/ledger/attacks.d.ts +17 -0
  79. package/dist/analyzers/ledger/attacks.js +135 -0
  80. package/dist/analyzers/ledger/attacks.js.map +1 -0
  81. package/dist/analyzers/ledger/discover.d.ts +15 -0
  82. package/dist/analyzers/ledger/discover.js +122 -0
  83. package/dist/analyzers/ledger/discover.js.map +1 -0
  84. package/dist/analyzers/ledger/evidence.d.ts +22 -0
  85. package/dist/analyzers/ledger/evidence.js +127 -0
  86. package/dist/analyzers/ledger/evidence.js.map +1 -0
  87. package/dist/analyzers/ledger/harness.d.ts +4 -0
  88. package/dist/analyzers/ledger/harness.js +270 -0
  89. package/dist/analyzers/ledger/harness.js.map +1 -0
  90. package/dist/analyzers/ledger/index.d.ts +14 -0
  91. package/dist/analyzers/ledger/index.js +116 -0
  92. package/dist/analyzers/ledger/index.js.map +1 -0
  93. package/dist/analyzers/ledger/types.d.ts +31 -0
  94. package/dist/analyzers/ledger/types.js +2 -0
  95. package/dist/analyzers/ledger/types.js.map +1 -0
  96. package/dist/analyzers/perf.d.ts +2 -0
  97. package/dist/analyzers/perf.js +106 -0
  98. package/dist/analyzers/perf.js.map +1 -0
  99. package/dist/analyzers/reliability.d.ts +22 -0
  100. package/dist/analyzers/reliability.js +339 -0
  101. package/dist/analyzers/reliability.js.map +1 -0
  102. package/dist/analyzers/routes.d.ts +17 -0
  103. package/dist/analyzers/routes.js +115 -0
  104. package/dist/analyzers/routes.js.map +1 -0
  105. package/dist/analyzers/security.d.ts +2 -0
  106. package/dist/analyzers/security.js +302 -0
  107. package/dist/analyzers/security.js.map +1 -0
  108. package/dist/analyzers/suiteRunner.d.ts +64 -0
  109. package/dist/analyzers/suiteRunner.js +381 -0
  110. package/dist/analyzers/suiteRunner.js.map +1 -0
  111. package/dist/analyzers/tests.d.ts +2 -0
  112. package/dist/analyzers/tests.js +215 -0
  113. package/dist/analyzers/tests.js.map +1 -0
  114. package/dist/analyzers/types.d.ts +203 -0
  115. package/dist/analyzers/types.js +2 -0
  116. package/dist/analyzers/types.js.map +1 -0
  117. package/dist/analyzers/util.d.ts +35 -0
  118. package/dist/analyzers/util.js +209 -0
  119. package/dist/analyzers/util.js.map +1 -0
  120. package/dist/ciCheckA.d.ts +2 -0
  121. package/dist/ciCheckA.js +5 -0
  122. package/dist/ciCheckA.js.map +1 -0
  123. package/dist/ciCheckB.d.ts +2 -0
  124. package/dist/ciCheckB.js +6 -0
  125. package/dist/ciCheckB.js.map +1 -0
  126. package/dist/cli.d.ts +2 -0
  127. package/dist/cli.js +65 -0
  128. package/dist/cli.js.map +1 -0
  129. package/dist/commands/budget.d.ts +33 -0
  130. package/dist/commands/budget.js +108 -0
  131. package/dist/commands/budget.js.map +1 -0
  132. package/dist/commands/ci.d.ts +14 -0
  133. package/dist/commands/ci.js +157 -0
  134. package/dist/commands/ci.js.map +1 -0
  135. package/dist/commands/demo.d.ts +2 -0
  136. package/dist/commands/demo.js +163 -0
  137. package/dist/commands/demo.js.map +1 -0
  138. package/dist/commands/digest.d.ts +8 -0
  139. package/dist/commands/digest.js +189 -0
  140. package/dist/commands/digest.js.map +1 -0
  141. package/dist/commands/doctor.d.ts +2 -0
  142. package/dist/commands/doctor.js +98 -0
  143. package/dist/commands/doctor.js.map +1 -0
  144. package/dist/commands/drive.d.ts +3 -0
  145. package/dist/commands/drive.js +170 -0
  146. package/dist/commands/drive.js.map +1 -0
  147. package/dist/commands/gate.d.ts +2 -0
  148. package/dist/commands/gate.js +136 -0
  149. package/dist/commands/gate.js.map +1 -0
  150. package/dist/commands/honesty.d.ts +2 -0
  151. package/dist/commands/honesty.js +182 -0
  152. package/dist/commands/honesty.js.map +1 -0
  153. package/dist/commands/inspect.d.ts +2 -0
  154. package/dist/commands/inspect.js +244 -0
  155. package/dist/commands/inspect.js.map +1 -0
  156. package/dist/commands/install.d.ts +2 -0
  157. package/dist/commands/install.js +81 -0
  158. package/dist/commands/install.js.map +1 -0
  159. package/dist/commands/integrity.d.ts +2 -0
  160. package/dist/commands/integrity.js +74 -0
  161. package/dist/commands/integrity.js.map +1 -0
  162. package/dist/commands/memory.d.ts +6 -0
  163. package/dist/commands/memory.js +90 -0
  164. package/dist/commands/memory.js.map +1 -0
  165. package/dist/commands/pen.d.ts +17 -0
  166. package/dist/commands/pen.js +158 -0
  167. package/dist/commands/pen.js.map +1 -0
  168. package/dist/commands/prompt.d.ts +2 -0
  169. package/dist/commands/prompt.js +37 -0
  170. package/dist/commands/prompt.js.map +1 -0
  171. package/dist/commands/readyCheck.d.ts +21 -0
  172. package/dist/commands/readyCheck.js +68 -0
  173. package/dist/commands/readyCheck.js.map +1 -0
  174. package/dist/commands/report.d.ts +2 -0
  175. package/dist/commands/report.js +48 -0
  176. package/dist/commands/report.js.map +1 -0
  177. package/dist/commands/repro.d.ts +3 -0
  178. package/dist/commands/repro.js +61 -0
  179. package/dist/commands/repro.js.map +1 -0
  180. package/dist/commands/scan.d.ts +27 -0
  181. package/dist/commands/scan.js +348 -0
  182. package/dist/commands/scan.js.map +1 -0
  183. package/dist/commands/share.d.ts +2 -0
  184. package/dist/commands/share.js +163 -0
  185. package/dist/commands/share.js.map +1 -0
  186. package/dist/commands/trends.d.ts +2 -0
  187. package/dist/commands/trends.js +103 -0
  188. package/dist/commands/trends.js.map +1 -0
  189. package/dist/commands/try.d.ts +12 -0
  190. package/dist/commands/try.js +208 -0
  191. package/dist/commands/try.js.map +1 -0
  192. package/dist/commands/verify.d.ts +44 -0
  193. package/dist/commands/verify.js +381 -0
  194. package/dist/commands/verify.js.map +1 -0
  195. package/dist/commands/watch.d.ts +10 -0
  196. package/dist/commands/watch.js +70 -0
  197. package/dist/commands/watch.js.map +1 -0
  198. package/dist/evidence.d.ts +37 -0
  199. package/dist/evidence.js +61 -0
  200. package/dist/evidence.js.map +1 -0
  201. package/dist/graph/correlate.d.ts +7 -0
  202. package/dist/graph/correlate.js +246 -0
  203. package/dist/graph/correlate.js.map +1 -0
  204. package/dist/graph/integrity.d.ts +22 -0
  205. package/dist/graph/integrity.js +39 -0
  206. package/dist/graph/integrity.js.map +1 -0
  207. package/dist/installer/targets.d.ts +23 -0
  208. package/dist/installer/targets.js +162 -0
  209. package/dist/installer/targets.js.map +1 -0
  210. package/dist/memory/store.d.ts +33 -0
  211. package/dist/memory/store.js +131 -0
  212. package/dist/memory/store.js.map +1 -0
  213. package/dist/pen/dynamic.d.ts +36 -0
  214. package/dist/pen/dynamic.js +622 -0
  215. package/dist/pen/dynamic.js.map +1 -0
  216. package/dist/pen/fix.d.ts +35 -0
  217. package/dist/pen/fix.js +239 -0
  218. package/dist/pen/fix.js.map +1 -0
  219. package/dist/pen/proof.d.ts +46 -0
  220. package/dist/pen/proof.js +112 -0
  221. package/dist/pen/proof.js.map +1 -0
  222. package/dist/pen/report.d.ts +11 -0
  223. package/dist/pen/report.js +258 -0
  224. package/dist/pen/report.js.map +1 -0
  225. package/dist/pen/static.d.ts +17 -0
  226. package/dist/pen/static.js +565 -0
  227. package/dist/pen/static.js.map +1 -0
  228. package/dist/pen/store.d.ts +11 -0
  229. package/dist/pen/store.js +36 -0
  230. package/dist/pen/store.js.map +1 -0
  231. package/dist/pen/types.d.ts +112 -0
  232. package/dist/pen/types.js +53 -0
  233. package/dist/pen/types.js.map +1 -0
  234. package/dist/report/format.d.ts +126 -0
  235. package/dist/report/format.js +706 -0
  236. package/dist/report/format.js.map +1 -0
  237. package/dist/report/score.d.ts +47 -0
  238. package/dist/report/score.js +164 -0
  239. package/dist/report/score.js.map +1 -0
  240. package/dist/repro/framework.d.ts +26 -0
  241. package/dist/repro/framework.js +224 -0
  242. package/dist/repro/framework.js.map +1 -0
  243. package/dist/repro/generate.d.ts +14 -0
  244. package/dist/repro/generate.js +669 -0
  245. package/dist/repro/generate.js.map +1 -0
  246. package/dist/repro/ids.d.ts +32 -0
  247. package/dist/repro/ids.js +175 -0
  248. package/dist/repro/ids.js.map +1 -0
  249. package/dist/repro/index.d.ts +24 -0
  250. package/dist/repro/index.js +69 -0
  251. package/dist/repro/index.js.map +1 -0
  252. package/dist/repro/pen.d.ts +26 -0
  253. package/dist/repro/pen.js +321 -0
  254. package/dist/repro/pen.js.map +1 -0
  255. package/dist/repro/run.d.ts +18 -0
  256. package/dist/repro/run.js +36 -0
  257. package/dist/repro/run.js.map +1 -0
  258. package/dist/sandbox/proxy.d.ts +41 -0
  259. package/dist/sandbox/proxy.js +231 -0
  260. package/dist/sandbox/proxy.js.map +1 -0
  261. package/dist/sandbox/startCmd.d.ts +24 -0
  262. package/dist/sandbox/startCmd.js +71 -0
  263. package/dist/sandbox/startCmd.js.map +1 -0
  264. package/dist/ui/spinner.d.ts +18 -0
  265. package/dist/ui/spinner.js +26 -0
  266. package/dist/ui/spinner.js.map +1 -0
  267. package/dist/verify/metrics.d.ts +13 -0
  268. package/dist/verify/metrics.js +72 -0
  269. package/dist/verify/metrics.js.map +1 -0
  270. package/package.json +68 -0
  271. package/scripts/cheat-demo.cjs +102 -0
  272. package/templates/ledger/preload.cjs +331 -0
  273. package/templates/pen/preload.cjs +196 -0
  274. package/templates/pitstop.prompt.md +371 -0
@@ -0,0 +1,669 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { detectTestFramework, reproDir, reproExtension, } from "./framework.js";
5
+ import { reproSlug } from "./ids.js";
6
+ const preloadPath = () => fileURLToPath(new URL("../../templates/ledger/preload.cjs", import.meta.url));
7
+ const isJestLike = (fw) => fw === "jest" || fw === "vitest";
8
+ function writeTest(repo, framework, slug, content) {
9
+ const ext = reproExtension(framework);
10
+ const fileName = `pitstop-repro-${slug}${ext}`;
11
+ // cargo integration tests must be in tests/, flutter tests in test/.
12
+ const dir = reproDir(framework);
13
+ const abs = dir ? path.join(repo, dir, fileName) : path.join(repo, fileName);
14
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
15
+ fs.writeFileSync(abs, content, "utf8");
16
+ return path.relative(repo, abs);
17
+ }
18
+ /** Non-Node stacks generate their own native repro files; JS/TS generators apply otherwise. */
19
+ const JS_LIKE_FRAMEWORKS = ["jest", "vitest", "node-test", "pytest"];
20
+ function nativeOnly(framework) {
21
+ return !JS_LIKE_FRAMEWORKS.includes(framework);
22
+ }
23
+ /** Framework-aware preamble: node-test (ESM) vs jest/vitest (CJS). */
24
+ function assemble(framework, header, body) {
25
+ const border = "=".repeat(78);
26
+ const head = `// ${border}\n${header}\n// ${border}\n\n`;
27
+ if (isJestLike(framework)) {
28
+ return `${head}const assert = require("assert/strict");\n\n${body}\n`;
29
+ }
30
+ return (`${head}import { test } from "node:test";\n` +
31
+ `import assert from "node:assert/strict";\n\n${body}\n`);
32
+ }
33
+ /**
34
+ * One-time boilerplate that defines `requireFromRepo` for node-test ESM runs.
35
+ * Emit this exactly once even when multiple `requireLine`s follow.
36
+ */
37
+ function requireBoilerplate(framework) {
38
+ if (isJestLike(framework))
39
+ return "";
40
+ return (`const { createRequire } = await import("node:module");\n` +
41
+ `const requireFromRepo = createRequire(import.meta.url);`);
42
+ }
43
+ /** Require a module from the repo (works for both jest CJS and node-test ESM). */
44
+ function requireLine(framework, target, varName) {
45
+ if (isJestLike(framework)) {
46
+ return `const ${varName} = require(${JSON.stringify(target)});`;
47
+ }
48
+ return `const ${varName} = requireFromRepo(${JSON.stringify(target)});`;
49
+ }
50
+ /* ------------------------------------------------------------------ */
51
+ /* 1. LEDGER — replay the exact attack, assert exactly one charge */
52
+ /* ------------------------------------------------------------------ */
53
+ function ledgerRepro(repo, framework, finding) {
54
+ if (isJestLike(framework)) {
55
+ // The ledger repro boots the app under the sandbox preload and uses node's
56
+ // own child/fetch APIs; it must be ESM (`node --test`), regardless of what
57
+ // the repo's test framework is, so it always runs via `node --test`.
58
+ return ledgerRepro(repo, "node-test", finding);
59
+ }
60
+ const ev = finding.data;
61
+ if (!ev || ev.chargeCalls.length === 0) {
62
+ return {
63
+ ok: false,
64
+ reason: "ledger evidence record has no recorded gateway calls to replay",
65
+ };
66
+ }
67
+ const first = ev.chargeCalls[0];
68
+ const gatewayHost = first.host;
69
+ const expectedKey = first.key ?? ev.orderId;
70
+ const amount = typeof first.requestBody?.amount === "number"
71
+ ? first.requestBody.amount
72
+ : 1000;
73
+ const currency = String(first.requestBody?.currency ?? "INR");
74
+ const J = (s) => JSON.stringify(s);
75
+ const slug = reproSlug(finding);
76
+ const header = [
77
+ `// OpenPitStop permanent repro test — finding ${finding.id}`,
78
+ `// Linked from .pitstop/scan-latest.json (issue / cluster finding id ${finding.id}).`,
79
+ `//`,
80
+ `// Scenario: ${ev.scenario} on ${ev.endpoint}`,
81
+ `// order ${ev.orderId} · idempotency key ${ev.idempotencyKey}`,
82
+ `// Gateway contract: exactly ONE charge for key "${expectedKey}"`,
83
+ `// (observed now: ${ev.chargeCalls.length} charges — the bug this test exists to guard).`,
84
+ `//`,
85
+ `// Run via: npx openpitstop repro ${finding.id}`,
86
+ `// The app is booted under OpenPitStop's nock sandbox, so no request can ever reach`,
87
+ `// a real payment gateway; the "gateway" is a local mock that records every call.`,
88
+ `// This test MUST FAIL on the buggy code and MUST PASS only after the fix.`,
89
+ ].join("\n");
90
+ const body = [
91
+ `const REPO = process.cwd();`,
92
+ `const PRELOAD = process.env.PITSTOP_LEDGER_PRELOAD;`,
93
+ `const GATEWAY_HOST = ${J(gatewayHost)};`,
94
+ `const ENDPOINT = ${J(ev.endpoint)};`,
95
+ `const SCENARIO = ${J(ev.scenario)};`,
96
+ `const ORDER_ID = ${J(ev.orderId)};`,
97
+ `const IDEM_KEY = ${J(ev.idempotencyKey)};`,
98
+ `const EXPECTED_KEY = ${J(expectedKey)};`,
99
+ `const AMOUNT = ${Number(amount)};`,
100
+ `const CURRENCY = ${J(currency)};`,
101
+ `const NODE_BASED = new Set(["node","node.exe","nodejs","npm","npm.cmd","npx","npx.cmd","yarn","yarn.cmd","pnpm","pnpm.cmd","tsx","ts-node","babel-node","ojs","vitest","jest"]);`,
102
+ ``,
103
+ `import { spawn } from "node:child_process";`,
104
+ `import { readFileSync, mkdirSync, existsSync } from "node:fs";`,
105
+ `import net from "node:net";`,
106
+ `import path from "node:path";`,
107
+ ``,
108
+ `function freePort() {`,
109
+ ` return new Promise((resolve, reject) => {`,
110
+ ` const s = net.createServer();`,
111
+ ` s.unref();`,
112
+ ` s.on("error", reject);`,
113
+ ` s.listen(0, () => {`,
114
+ ` const p = s.address().port;`,
115
+ ` s.close(() => resolve(p));`,
116
+ ` });`,
117
+ ` });`,
118
+ `}`,
119
+ `const sleep = (ms) => new Promise((r) => setTimeout(r, ms));`,
120
+ ``,
121
+ `function resolveStart() {`,
122
+ ` const pkg = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8"));`,
123
+ ` const start = (pkg.scripts && pkg.scripts.start) || "";`,
124
+ ` if (!start) throw new Error("no start script in package.json");`,
125
+ ` const tokens = start.trim().split(/\\s+/);`,
126
+ ` if (!NODE_BASED.has(tokens[0])) {`,
127
+ ` throw new Error("start script runs " + tokens[0] + ", which is not Node-based — refusing");`,
128
+ ` }`,
129
+ ` return { cmd: tokens[0], args: tokens.slice(1) };`,
130
+ `}`,
131
+ ``,
132
+ `function resolvePath(route, orderId) {`,
133
+ ` return route`,
134
+ ` .replace(/:([A-Za-z_][\\w-]*)/g, (_, name) => (/id|order/i.test(name) ? orderId : name))`,
135
+ ` .replace(/\\{([A-Za-z_][\\w-]*)\\}/g, (_, name) => (/id|order/i.test(name) ? orderId : name));`,
136
+ `}`,
137
+ ``,
138
+ `async function post(baseUrl, p, body, headers) {`,
139
+ ` try {`,
140
+ ` const res = await fetch(baseUrl + p, { method: "POST", headers, body: JSON.stringify(body) });`,
141
+ ` await res.text().catch(() => "");`,
142
+ ` return res.status;`,
143
+ ` } catch {`,
144
+ ` return 0;`,
145
+ ` }`,
146
+ `}`,
147
+ ``,
148
+ `function controlHas(controlPath, needle) {`,
149
+ ` if (!existsSync(controlPath)) return false;`,
150
+ ` try {`,
151
+ ` return readFileSync(controlPath, "utf8").split(/\\r?\\n/).some((l) => l.includes(needle));`,
152
+ ` } catch {`,
153
+ ` return false;`,
154
+ ` }`,
155
+ `}`,
156
+ ``,
157
+ `function countCharges(gatewayLogPath) {`,
158
+ ` if (!existsSync(gatewayLogPath)) return [];`,
159
+ ` const out = [];`,
160
+ ` for (const line of readFileSync(gatewayLogPath, "utf8").split(/\\r?\\n/)) {`,
161
+ ` const t = line.trim();`,
162
+ ` if (!t) continue;`,
163
+ ` try {`,
164
+ ` const e = JSON.parse(t);`,
165
+ ` if (e.charge && e.key === EXPECTED_KEY) out.push(e);`,
166
+ ` } catch {}`,
167
+ ` }`,
168
+ ` return out;`,
169
+ `}`,
170
+ ``,
171
+ `test("pitstop repro ${finding.id}: exactly one gateway charge for key '" + EXPECTED_KEY + "'", async () => {`,
172
+ ` assert.ok(PRELOAD, "PITSTOP_LEDGER_PRELOAD not set — run this via \`npx openpitstop repro ${finding.id}\`");`,
173
+ ``,
174
+ ` const start = resolveStart();`,
175
+ ` const runDir = path.join(REPO, ".pitstop", "repro", new Date().toISOString().replace(/[:.]/g, "-"));`,
176
+ ` mkdirSync(runDir, { recursive: true });`,
177
+ ` const controlPath = path.join(runDir, "control.jsonl");`,
178
+ ` const gatewayLogPath = path.join(runDir, "gateway.log.jsonl");`,
179
+ ` const port = await freePort();`,
180
+ ` const baseUrl = "http://127.0.0.1:" + port;`,
181
+ ``,
182
+ ` const env = {`,
183
+ ` ...process.env,`,
184
+ ` NODE_OPTIONS: [process.env.NODE_OPTIONS, "--require=" + PRELOAD].filter(Boolean).join(" "),`,
185
+ ` PORT: String(port),`,
186
+ ` PITSTOP_LEDGER_CONTROL: controlPath,`,
187
+ ` PITSTOP_LEDGER_GATEWAY_LOG: gatewayLogPath,`,
188
+ ` PITSTOP_LEDGER_GATEWAY_HOSTS: GATEWAY_HOST,`,
189
+ ` RAZORPAY_KEY_ID: process.env.RAZORPAY_KEY_ID || "rzp_test_pitstop000000",`,
190
+ ` RAZORPAY_KEY_SECRET: process.env.RAZORPAY_KEY_SECRET || "pitstop_fake_secret",`,
191
+ ` STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY || "sk_test_pitstop_fake",`,
192
+ ` NODE_ENV: process.env.NODE_ENV || "test",`,
193
+ ` };`,
194
+ ` const child = spawn(start.cmd, start.args, { cwd: REPO, env, stdio: ["ignore", "ignore", "inherit"] });`,
195
+ ` try {`,
196
+ ` const deadline = Date.now() + 25000;`,
197
+ ` let up = false;`,
198
+ ` while (Date.now() < deadline) {`,
199
+ ` if (child.exitCode !== null) break;`,
200
+ ` if (controlHas(controlPath, '"event":"armed"')) {`,
201
+ ` try {`,
202
+ ` await fetch(baseUrl + "/__pitstop_ledger_probe__");`,
203
+ ` up = true;`,
204
+ ` break;`,
205
+ ` } catch {}`,
206
+ ` }`,
207
+ ` await sleep(150);`,
208
+ ` }`,
209
+ ` assert.ok(up, "app did not come up under the sandbox within 25s (" + start.cmd + " " + start.args.join(" ") + ")");`,
210
+ ` assert.ok(!controlHas(controlPath, '"event":"abort"'), "sandbox aborted during startup");`,
211
+ ``,
212
+ ` if (SCENARIO === "concurrent-double-submit" || SCENARIO === "delayed-retry") {`,
213
+ ` const reqBody = { amount: AMOUNT, currency: CURRENCY };`,
214
+ ` const headers = { "Content-Type": "application/json", ...(IDEM_KEY ? { "Idempotency-Key": IDEM_KEY } : {}) };`,
215
+ ` const p = resolvePath(ENDPOINT, ORDER_ID);`,
216
+ ` if (SCENARIO === "concurrent-double-submit") {`,
217
+ ` await Promise.all([post(baseUrl, p, reqBody, headers), post(baseUrl, p, reqBody, headers)]);`,
218
+ ` } else {`,
219
+ ` await post(baseUrl, p, reqBody, headers);`,
220
+ ` await sleep(2000);`,
221
+ ` await post(baseUrl, p, reqBody, headers);`,
222
+ ` }`,
223
+ ` } else {`,
224
+ ` const payload = {`,
225
+ ` event: "payment.captured",`,
226
+ ` id: ORDER_ID + "_evt",`,
227
+ ` payload: { payment: { entity: { id: EXPECTED_KEY, amount: AMOUNT, currency: CURRENCY, order_id: ORDER_ID } } },`,
228
+ ` };`,
229
+ ` const headers = { "Content-Type": "application/json" };`,
230
+ ` await post(baseUrl, ENDPOINT, payload, headers);`,
231
+ ` await sleep(300);`,
232
+ ` await post(baseUrl, ENDPOINT, payload, headers);`,
233
+ ` }`,
234
+ ``,
235
+ ` await sleep(400);`,
236
+ ` assert.ok(!controlHas(controlPath, '"event":"abort"'), "sandbox aborted during the attack");`,
237
+ ` const charges = countCharges(gatewayLogPath);`,
238
+ ` assert.equal(`,
239
+ ` charges.length,`,
240
+ ` 1,`,
241
+ ` "expected exactly 1 gateway charge for key '" + EXPECTED_KEY + "', saw " + charges.length +`,
242
+ ` ". This repro proves the idempotency bug: it FAILS on the buggy code and must PASS only after the fix.",`,
243
+ ` );`,
244
+ ` } finally {`,
245
+ ` try { child.kill(); } catch {}`,
246
+ ` }`,
247
+ `});`,
248
+ ].join("\n");
249
+ const file = writeTest(repo, "node-test", slug, assemble("node-test", header, body));
250
+ return { ok: true, file, framework: "node-test" };
251
+ }
252
+ /* ------------------------------------------------------------------ */
253
+ /* 2. SECURITY — attempt the real exploit, assert it is rejected */
254
+ /* ------------------------------------------------------------------ */
255
+ /**
256
+ * Verified exploit recipes, keyed by the npm package name parsed from the
257
+ * audit finding. A recipe is only added once its PoC has been confirmed to
258
+ * (a) actually execute on the vulnerable version and (b) be blocked on the
259
+ * patched version. No recipe means `pitstop repro` refuses rather than emit
260
+ * a test that cannot genuinely fail.
261
+ */
262
+ const SECURITY_RECIPES = {
263
+ lodash: {
264
+ name: "lodash",
265
+ titleKeywords: ["command injection", "code injection"],
266
+ check: (fw) => {
267
+ const req = requireLine(fw, "lodash", "_");
268
+ return [
269
+ req,
270
+ ``,
271
+ `test("pitstop repro: lodash must reject the command-injection payload (CVE-2021-23337)", () => {`,
272
+ ` const MARKER = "PITSTOP-PWN-" + Date.now() + "-" + Math.floor(Math.random() * 1e6);`,
273
+ ` let markerHit = false;`,
274
+ ` let threw = false;`,
275
+ ` const origLog = console.log;`,
276
+ ` console.log = (...a) => { if (String(a[0] || "").includes(MARKER)) markerHit = true; origLog.apply(console, a); };`,
277
+ ` try {`,
278
+ ` _.template("", { variable: "){console.log('" + MARKER + "')}; with(obj" })();`,
279
+ ` } catch (e) {`,
280
+ ` threw = true;`,
281
+ ` } finally {`,
282
+ ` console.log = origLog;`,
283
+ ` }`,
284
+ ` assert.ok(threw, "fixed lodash must reject the malicious variable option (CVE-2021-23337)");`,
285
+ ` assert.ok(!markerHit, "attacker-controlled code EXECUTED via _.template — command injection is live");`,
286
+ `});`,
287
+ ].join("\n");
288
+ },
289
+ },
290
+ };
291
+ function securityPackageName(description) {
292
+ return description.split(/[:(]/)[0].trim();
293
+ }
294
+ function securityRepro(repo, framework, finding) {
295
+ if (framework === "pytest") {
296
+ return {
297
+ ok: false,
298
+ reason: "no verified exploit recipe for Python packages yet; add one in src/repro/generate.ts " +
299
+ "(recipes are only shipped once the PoC is proven to execute + be blocked)",
300
+ };
301
+ }
302
+ const name = securityPackageName(finding.description);
303
+ const recipe = SECURITY_RECIPES[name];
304
+ if (!recipe) {
305
+ return {
306
+ ok: false,
307
+ reason: `no verified exploit recipe for package "${name || finding.description}" — OpenPitStop refuses to ` +
308
+ "emit a repro test that cannot genuinely fail. Add a confirmed PoC to src/repro/generate.ts " +
309
+ "(it must actually execute on the vulnerable version and be blocked on the patched one) or " +
310
+ "write the exploit test by hand and commit it as pitstop-repro-*.test.*.",
311
+ };
312
+ }
313
+ const slug = reproSlug(finding);
314
+ const header = [
315
+ `// OpenPitStop permanent repro test — finding ${finding.id}`,
316
+ `// Linked from .pitstop/scan-latest.json (issue / cluster finding id ${finding.id}).`,
317
+ `//`,
318
+ `// Attempts the real ${recipe.name} exploit (${recipe.titleKeywords.join(", ")}) and asserts it`,
319
+ `// is rejected. This test MUST FAIL while the vulnerable version is installed and MUST PASS`,
320
+ `// after the dependency is upgraded.`,
321
+ ].join("\n");
322
+ const file = writeTest(repo, framework, slug, assemble(framework, header, requireBoilerplate(framework) + "\n\n" + recipe.check(framework)));
323
+ return { ok: true, file, framework };
324
+ }
325
+ /* ------------------------------------------------------------------ */
326
+ /* 3. RACE — parallel stress probe (inherently a probe, not a proof) */
327
+ /* ------------------------------------------------------------------ */
328
+ function raceRepro(repo, framework, finding) {
329
+ if (!finding.file) {
330
+ return { ok: false, reason: "race finding has no source file to stress" };
331
+ }
332
+ const rel = path.relative(repo, finding.file);
333
+ if (rel.startsWith("..")) {
334
+ return { ok: false, reason: `race finding file is outside the repo: ${finding.file}` };
335
+ }
336
+ // Keep the extension: ESM `import()` resolves relative specifiers strictly
337
+ // ("./src/counter.js"), whereas CJS `require()` tolerates extensionless paths.
338
+ const moduleTarget = rel;
339
+ const slug = reproSlug(finding);
340
+ const header = [
341
+ `// OpenPitStop repro test — finding ${finding.id}`,
342
+ `// Linked from .pitstop/scan-latest.json.`,
343
+ `//`,
344
+ `// PARALLEL STRESS PROBE (heuristic): the scan flagged module-level mutable`,
345
+ `// state in ${rel} near async code. This test pounds the module's exports with`,
346
+ `// concurrent invocations to try to surface a race.`,
347
+ `// ${finding.description}`,
348
+ `// A probe: a race may not manifest deterministically. If it PASSES, the race`,
349
+ `// hypothesis is UNPROVEN — the fix loop must re-diagnose rather than fix blind.`,
350
+ ].join("\n");
351
+ const body = [
352
+ `test("pitstop repro ${finding.id}: ${moduleTarget} stays consistent under concurrent load", async () => {`,
353
+ ` const mod = await import(${JSON.stringify("./" + moduleTarget)});`,
354
+ ` const fns = Object.values(mod).filter((v) => typeof v === "function");`,
355
+ ` assert.ok(fns.length > 0, "no exported functions to stress");`,
356
+ ` const N = 30;`,
357
+ ` const results = await Promise.all(`,
358
+ ` Array.from({ length: N }, () =>`,
359
+ ` Promise.resolve()`,
360
+ ` .then(() => Promise.all(fns.map((fn) => Promise.resolve().then(() => fn()))).then(() => "ok"))`,
361
+ ` .catch((e) => "err: " + e.message),`,
362
+ ` ),`,
363
+ ` );`,
364
+ ` const bad = results.filter((r) => r !== "ok");`,
365
+ ` assert.deepEqual(bad, [], "concurrent invocations of ${moduleTarget} threw: " + JSON.stringify(bad.slice(0, 5)));`,
366
+ `});`,
367
+ ].join("\n");
368
+ const file = writeTest(repo, framework, slug, assemble(framework, header, body));
369
+ return { ok: true, file, framework };
370
+ }
371
+ /* ------------------------------------------------------------------ */
372
+ /* 4. PERF — re-run the build, assert under the Phase-1 baseline */
373
+ /* ------------------------------------------------------------------ */
374
+ function perfRepro(repo, framework, finding, scan) {
375
+ const perf = scan.perf;
376
+ if (perf.buildTimeMs == null) {
377
+ return {
378
+ ok: false,
379
+ reason: "Phase-1 scan had no build baseline (no build script / perf skipped) — nothing to guard",
380
+ };
381
+ }
382
+ if (framework === "pytest") {
383
+ return { ok: false, reason: "perf repro currently supports Node build scripts only" };
384
+ }
385
+ const limitMs = Math.max(1, Math.ceil(perf.buildTimeMs * 1.5));
386
+ const slug = reproSlug(finding);
387
+ // Native build-guard repros for non-Node stacks. These are self-contained:
388
+ // they spawn the stack's own build command and assert the wall-clock time
389
+ // stays under the Phase-1 baseline.
390
+ if (framework === "go") {
391
+ const header = [
392
+ `// OpenPitStop perf repro test — finding ${finding.id}`,
393
+ `// Linked from .pitstop/scan-latest.json (perf baseline: build ${perf.buildTimeMs}ms).`,
394
+ `//`,
395
+ `// Regression guard: the build must stay under the Phase-1 baseline threshold.`,
396
+ `// Note: go test runs with cwd = this package dir, so the build is run with an`,
397
+ `// explicit Dir set to the repo root captured at generation time.`,
398
+ ].join("\n");
399
+ const body = [
400
+ `package pitstoprepro`,
401
+ ``,
402
+ `import (`,
403
+ ` "os/exec"`,
404
+ ` "testing"`,
405
+ ` "time"`,
406
+ `)`,
407
+ ``,
408
+ `func TestBuildStaysUnderBaseline(t *testing.T) {`,
409
+ ` start := time.Now()`,
410
+ ` cmd := exec.Command("go", "build", "./...")`,
411
+ ` cmd.Dir = ${JSON.stringify(repo)}`,
412
+ ` out, err := cmd.CombinedOutput()`,
413
+ ` took := time.Since(start)`,
414
+ ` if err != nil {`,
415
+ ` t.Fatalf("go build failed: %v\\n%s", err, out)`,
416
+ ` }`,
417
+ ` if took > ${limitMs}*time.Millisecond {`,
418
+ ` t.Fatalf("build took %v, over the Phase-1 baseline threshold of ${limitMs}ms", took)`,
419
+ ` }`,
420
+ `}`,
421
+ ].join("\n");
422
+ const file = writeTest(repo, "go", slug, header + "\n\n" + body);
423
+ return { ok: true, file, framework: "go" };
424
+ }
425
+ if (framework === "cargo") {
426
+ const header = [
427
+ `// OpenPitStop perf repro test — finding ${finding.id}`,
428
+ `// Linked from .pitstop/scan-latest.json (perf baseline: build ${perf.buildTimeMs}ms).`,
429
+ `//`,
430
+ `// Regression guard: the build must stay under the Phase-1 baseline threshold.`,
431
+ ].join("\n");
432
+ const body = [
433
+ `use std::process::Command;`,
434
+ `use std::time::Instant;`,
435
+ ``,
436
+ `#[test]`,
437
+ `fn build_stays_under_baseline() {`,
438
+ ` let start = Instant::now();`,
439
+ ` let out = Command::new("cargo").arg("build").output().expect("cargo build failed");`,
440
+ ` let took = start.elapsed();`,
441
+ ` assert!(`,
442
+ ` out.status.success(),`,
443
+ ` "cargo build failed: {}",`,
444
+ ` String::from_utf8_lossy(&out.stderr),`,
445
+ ` );`,
446
+ ` assert!(`,
447
+ ` took.as_millis() <= ${limitMs},`,
448
+ ` "build took {:?}, over the Phase-1 baseline threshold of ${limitMs}ms",`,
449
+ ` took,`,
450
+ ` );`,
451
+ `}`,
452
+ ].join("\n");
453
+ const file = writeTest(repo, "cargo", slug, header + "\n\n" + body);
454
+ return { ok: true, file, framework: "cargo" };
455
+ }
456
+ if (framework === "flutter") {
457
+ const header = [
458
+ `// OpenPitStop perf repro test — finding ${finding.id}`,
459
+ `// Linked from .pitstop/scan-latest.json (perf baseline: build ${perf.buildTimeMs}ms).`,
460
+ `//`,
461
+ `// Regression guard: the build must stay under the Phase-1 baseline threshold.`,
462
+ ].join("\n");
463
+ const body = [
464
+ `import 'dart:io';`,
465
+ `import 'package:flutter_test/flutter_test.dart';`,
466
+ ``,
467
+ `void main() {`,
468
+ ` test('build stays under baseline', () async {`,
469
+ ` final sw = Stopwatch()..start();`,
470
+ ` final r = await Process.run('flutter', ['build', 'web']);`,
471
+ ` sw.stop();`,
472
+ ` expect(r.exitCode, 0, reason: 'flutter build failed: \${r.stderr}');`,
473
+ ` expect(`,
474
+ ` sw.elapsedMilliseconds,`,
475
+ ` lessThanOrEqualTo(${limitMs}),`,
476
+ ` reason: 'build took \${sw.elapsedMilliseconds}ms, over the Phase-1 baseline threshold of ${limitMs}ms',`,
477
+ ` );`,
478
+ ` });`,
479
+ `}`,
480
+ ].join("\n");
481
+ const file = writeTest(repo, "flutter", slug, header + "\n\n" + body);
482
+ return { ok: true, file, framework: "flutter" };
483
+ }
484
+ if (framework === "dotnet" || framework === "maven" || framework === "gradle") {
485
+ return {
486
+ ok: false,
487
+ reason: `a ${framework} perf guard must live inside an existing test project (there is no ` +
488
+ "project-agnostic location for a standalone test file); add a build-duration assertion " +
489
+ "to your test project manually and commit it as a pitstop-repro guard.",
490
+ };
491
+ }
492
+ const bundleLimit = perf.bundleSizeBytes != null ? Math.max(1, Math.ceil(perf.bundleSizeBytes * 1.1)) : null;
493
+ const header = [
494
+ `// OpenPitStop perf repro test — finding ${finding.id}`,
495
+ `// Linked from .pitstop/scan-latest.json (perf baseline: build ${perf.buildTimeMs}ms` +
496
+ (bundleLimit != null ? `, bundle ${perf.bundleSizeBytes}B` : "") + `).`,
497
+ `//`,
498
+ `// Regression guard: the build must stay under the Phase-1 baseline threshold.`,
499
+ ].join("\n");
500
+ const body = [];
501
+ if (isJestLike(framework)) {
502
+ body.push(`const { execSync } = require("child_process");`, `const { statSync, existsSync, readdirSync } = require("fs");`, `const path = require("path");`);
503
+ }
504
+ else {
505
+ body.push(`import { execSync } from "node:child_process";`, `import { statSync, existsSync, readdirSync } from "node:fs";`, `import path from "node:path";`);
506
+ }
507
+ body.push(``, `test("pitstop repro ${finding.id}: build stays under ${limitMs}ms", () => {`, ` const t0 = Date.now();`, ` execSync("npm run build", { cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });`, ` const took = Date.now() - t0;`, ` assert.ok(took <= ${limitMs}, "build took " + took + "ms, over the Phase-1 baseline threshold of ${limitMs}ms");`);
508
+ if (bundleLimit != null) {
509
+ body.push(` const dist = path.join(process.cwd(), "dist");`, ` if (existsSync(dist)) {`, ` let total = 0;`, ` const walk = (d) => {`, ` for (const e of readdirSync(d, { withFileTypes: true })) {`, ` const p = path.join(d, e.name);`, ` if (e.isDirectory()) walk(p);`, ` else total += statSync(p).size;`, ` }`, ` };`, ` walk(dist);`, ` assert.ok(total <= ${bundleLimit}, "dist size " + total + "B over baseline limit ${bundleLimit}B");`, ` }`);
510
+ }
511
+ body.push(`});`);
512
+ const file = writeTest(repo, framework, slug, assemble(framework, header, body.join("\n")));
513
+ return { ok: true, file, framework };
514
+ }
515
+ /* ------------------------------------------------------------------ */
516
+ /* 5. ACCESSIBILITY — axe-core assertion against the flagged file */
517
+ /* ------------------------------------------------------------------ */
518
+ function a11yRepro(repo, framework, finding) {
519
+ if (!finding.file || !/\.html?$/i.test(finding.file)) {
520
+ return {
521
+ ok: false,
522
+ reason: "this accessibility finding is not backed by a runnable HTML file (static JSX lint findings " +
523
+ "cannot be replayed as an axe assertion). Point the scanner at an HTML page to get a runnable repro.",
524
+ };
525
+ }
526
+ if (!fs.existsSync(path.join(repo, "node_modules", "axe-core")) ||
527
+ !fs.existsSync(path.join(repo, "node_modules", "jsdom"))) {
528
+ return {
529
+ ok: false,
530
+ reason: `an axe-core assertion needs the "axe-core" and "jsdom" dependencies in the repo; ` +
531
+ "add them and re-run, or run the page through axe manually",
532
+ };
533
+ }
534
+ const rel = path.relative(repo, finding.file);
535
+ const slug = reproSlug(finding);
536
+ const header = [
537
+ `// OpenPitStop a11y repro test — finding ${finding.id}`,
538
+ `// Linked from .pitstop/scan-latest.json.`,
539
+ `// Axe-core assertion against ${rel}.`,
540
+ ].join("\n");
541
+ const axeReq = requireLine(framework, "axe-core", "axeCore");
542
+ const jsdomReq = requireLine(framework, "jsdom", "jsdom");
543
+ const fsReq = requireLine(framework, "node:fs", "nodefs");
544
+ const body = [
545
+ requireBoilerplate(framework),
546
+ axeReq,
547
+ jsdomReq,
548
+ fsReq,
549
+ ``,
550
+ `test("pitstop repro ${finding.id}: axe reports no violations for ${rel}", async () => {`,
551
+ ` const html = nodefs.readFileSync(${JSON.stringify(rel)}, "utf8");`,
552
+ ` const dom = new jsdom.JSDOM(html, { runScripts: "outside-only" });`,
553
+ ` const { window } = dom;`,
554
+ ` // axe-core runs inside the jsdom window, so inject its source there first.`,
555
+ ` window.eval(axeCore.source);`,
556
+ ` const results = await window.axe.run(window.document, {`,
557
+ ` // color-contrast is excluded: it needs real layout/rendering that jsdom`,
558
+ ` // does not provide, so it throws rather than returns a violation.`,
559
+ ` runOnly: { type: "rule", values: ["image-alt", "document-title", "html-has-lang", "link-name", "list"] },`,
560
+ ` });`,
561
+ ` assert.ok(results.violations.length === 0, JSON.stringify(results.violations, null, 2));`,
562
+ `});`,
563
+ ].join("\n");
564
+ const file = writeTest(repo, framework, slug, assemble(framework, header, body));
565
+ return { ok: true, file, framework };
566
+ }
567
+ /* ------------------------------------------------------------------ */
568
+ /* 6. CIRCULAR DEPENDENCY — import the cycle, assert clean init */
569
+ /* ------------------------------------------------------------------ */
570
+ function graphRepro(repo, framework, finding) {
571
+ const cycle = finding.data?.cycle;
572
+ if (!cycle || cycle.length < 2) {
573
+ return {
574
+ ok: false,
575
+ reason: "circular finding carries no cycle file list to replay",
576
+ };
577
+ }
578
+ // The repro test is written to the repo root, so relative specifiers with a
579
+ // "./" prefix resolve against the repo root for both ESM and CJS runners.
580
+ const rels = cycle.map((f) => {
581
+ const r = path.relative(repo, f).replace(/\\/g, "/");
582
+ return r.startsWith(".") ? r : "./" + r;
583
+ });
584
+ const cycleLabel = cycle
585
+ .map((f) => path.relative(repo, f).replace(/\\/g, "/"))
586
+ .join(" → ");
587
+ const slug = reproSlug(finding);
588
+ const header = [
589
+ `// OpenPitStop repro test — finding ${finding.id}`,
590
+ `// Linked from .pitstop/scan-latest.json.`,
591
+ `//`,
592
+ `// CIRCULAR LOAD PROBE: the scan flagged a module cycle:`,
593
+ `// ${cycleLabel}`,
594
+ `// ${finding.description}`,
595
+ `// This test loads every member of the cycle (import() for ESM runners,`,
596
+ `// require() under jest/vitest) and asserts each initializes cleanly. ESM`,
597
+ `// cycles throw at load time (TDZ / "Cannot access 'x' before initialization")`,
598
+ `// — those FAIL here and prove the bug. CJS cycles load lazily, so a CJS cycle`,
599
+ `// may silently PASS: then this probe is a hygiene / deadlock-risk reminder,`,
600
+ `// and the fix loop must re-diagnose rather than pretend the cycle is harmless.`,
601
+ ].join("\n");
602
+ const body = [];
603
+ if (isJestLike(framework)) {
604
+ // jest's VM refuses dynamic import() without --experimental-vm-modules, and
605
+ // a repro that fails for an environmental reason is a lie. CJS libs can be
606
+ // required natively, so generate one require per cycle member instead.
607
+ const vars = [];
608
+ rels.forEach((r, i) => {
609
+ vars.push(`m${i}`);
610
+ body.push(`const m${i} = require(${JSON.stringify(r)});`);
611
+ });
612
+ body.push(``, `test("pitstop repro ${finding.id}: every member of the cycle initializes cleanly", () => {`, ` const MODS = [${vars.join(", ")}];`, ` MODS.forEach((mod, i) => {`, ` for (const [k, v] of Object.entries(mod)) {`, ` if (k === "default") continue;`, ` assert.notEqual(v, undefined, "export '" + k + "' of " + CYCLE[i] + " is undefined after load");`, ` }`, ` });`, `});`);
613
+ body.unshift(`const CYCLE = ${JSON.stringify(rels)};`, ``);
614
+ }
615
+ else {
616
+ body.push(`const MODS = await Promise.all(${JSON.stringify(rels)}.map((m) => import(m)));`, ``, `test("pitstop repro ${finding.id}: every member of the cycle initializes cleanly", async () => {`, ` MODS.forEach((mod, i) => {`, ` for (const [k, v] of Object.entries(mod)) {`, ` if (k === "default") continue;`, ` assert.notEqual(v, undefined, "export '" + k + "' of " + ${JSON.stringify(rels)}[i] + " is undefined after load");`, ` }`, ` });`, `});`);
617
+ }
618
+ const file = writeTest(repo, framework, slug, assemble(framework, header, body.join("\n")));
619
+ return { ok: true, file, framework };
620
+ }
621
+ /* ------------------------------------------------------------------ */
622
+ export function generateRepro(repo, scan, finding) {
623
+ const framework = detectTestFramework(repo) ?? "node-test";
624
+ // Go/Rust/Flutter/.NET/Java repros need language-native generators (see the
625
+ // perf repro below, and per-finding recipes added as they are proven). Refuse
626
+ // rather than emit a JS test file into a non-JS repo.
627
+ if (nativeOnly(framework) && finding.source !== "perf") {
628
+ return {
629
+ ok: false,
630
+ reason: `native repro generation for ${framework} repos currently covers the perf guard only; ` +
631
+ "other finding types need a proven language-native recipe — add one in src/repro/generate.ts " +
632
+ "or hand-write the test (go: standalone _test.go, cargo: tests/, flutter: test/) and re-run.",
633
+ };
634
+ }
635
+ switch (finding.source) {
636
+ case "ledger":
637
+ return ledgerRepro(repo, framework, finding);
638
+ case "security":
639
+ return securityRepro(repo, framework, finding);
640
+ case "reliability":
641
+ if (finding.type === "race-condition")
642
+ return raceRepro(repo, framework, finding);
643
+ return {
644
+ ok: false,
645
+ reason: "flaky-test findings are observations of the suite, not a bug to replay — " +
646
+ "no genuine repro generator exists for them",
647
+ };
648
+ case "perf":
649
+ return perfRepro(repo, framework, finding, scan);
650
+ case "a11y":
651
+ return a11yRepro(repo, framework, finding);
652
+ case "graph":
653
+ if (finding.type === "circular")
654
+ return graphRepro(repo, framework, finding);
655
+ return {
656
+ ok: false,
657
+ reason: `graph findings of type "${finding.type}" cannot be replayed as a failing test — ` +
658
+ "no genuine repro generator exists for them",
659
+ };
660
+ default:
661
+ return {
662
+ ok: false,
663
+ reason: `findings of source "${finding.source}" (${finding.type}) cannot be replayed as a failing ` +
664
+ "test — they have no genuine repro generator. Fix them and confirm with a fresh scan.",
665
+ };
666
+ }
667
+ }
668
+ export { preloadPath };
669
+ //# sourceMappingURL=generate.js.map