@funnelsgrove/cli 0.1.14 → 0.1.18

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 (293) hide show
  1. package/contracts/step-contract-v2.json +733 -0
  2. package/dist/analyticsOutput.d.ts +30 -15
  3. package/dist/analyticsOutput.js +45 -28
  4. package/dist/apiClient.d.ts +85 -0
  5. package/dist/apiClient.js +225 -8
  6. package/dist/cli.d.ts +149 -1
  7. package/dist/cli.js +1232 -71
  8. package/dist/diagnosticOutput.d.ts +42 -0
  9. package/dist/diagnosticOutput.js +83 -0
  10. package/dist/docsOutput.d.ts +19 -0
  11. package/dist/docsOutput.js +73 -0
  12. package/dist/docsTransaction.d.ts +81 -0
  13. package/dist/docsTransaction.js +1755 -0
  14. package/dist/funnelValidation.d.ts +14 -0
  15. package/dist/funnelValidation.js +297 -0
  16. package/dist/localSync.d.ts +79 -0
  17. package/dist/localSync.js +428 -53
  18. package/dist/projectValidator.d.ts +27 -0
  19. package/dist/projectValidator.js +208 -0
  20. package/dist/reskin.js +27 -1
  21. package/dist/stepContractMigration.d.ts +71 -0
  22. package/dist/stepContractMigration.js +1323 -0
  23. package/dist/templateDocs.js +8 -25
  24. package/docs-release-history.json +66 -0
  25. package/legacy-docs-catalog.json +1012 -0
  26. package/package.json +13 -4
  27. package/template_docs/.funnelsgrove-docs.json +144 -0
  28. package/template_docs/AGENTS.md +30 -58
  29. package/template_docs/CLAUDE.md +0 -2
  30. package/template_docs/docs/funnelsgrove/START-HERE.md +73 -0
  31. package/template_docs/docs/funnelsgrove/contracts/analytics-events.md +59 -0
  32. package/template_docs/docs/funnelsgrove/contracts/content-answers.md +44 -0
  33. package/template_docs/docs/funnelsgrove/contracts/flow-routing.md +41 -0
  34. package/template_docs/docs/funnelsgrove/contracts/payments.md +42 -0
  35. package/template_docs/docs/funnelsgrove/contracts/step-metadata.md +57 -0
  36. package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +39 -0
  37. package/template_docs/docs/funnelsgrove/qa/analytics.md +43 -0
  38. package/template_docs/docs/funnelsgrove/qa/local.md +42 -0
  39. package/template_docs/docs/funnelsgrove/qa/paywall-checkout.md +55 -0
  40. package/template_docs/docs/funnelsgrove/qa/publish.md +32 -0
  41. package/template_docs/docs/funnelsgrove/recipes/add-experiment.md +34 -0
  42. package/template_docs/docs/funnelsgrove/recipes/add-step.md +33 -0
  43. package/template_docs/docs/funnelsgrove/recipes/edit-step.md +31 -0
  44. package/template_docs/docs/funnelsgrove/steps/INDEX.md +26 -0
  45. package/template_docs/docs/funnelsgrove/steps/cancellation_offer.md +112 -0
  46. package/template_docs/docs/funnelsgrove/steps/checkout.md +120 -0
  47. package/template_docs/docs/funnelsgrove/steps/form_input.md +115 -0
  48. package/template_docs/docs/funnelsgrove/steps/intro_hero.md +110 -0
  49. package/template_docs/docs/funnelsgrove/steps/multi_select_choice.md +123 -0
  50. package/template_docs/docs/funnelsgrove/steps/paywall_offer.md +115 -0
  51. package/template_docs/docs/funnelsgrove/steps/progress_interstitial.md +111 -0
  52. package/template_docs/docs/funnelsgrove/steps/purchase_completed.md +117 -0
  53. package/template_docs/docs/funnelsgrove/steps/single_step_choice.md +122 -0
  54. package/template_docs/docs/funnelsgrove/steps/single_step_choice_emoji.md +122 -0
  55. package/template_docs/docs/funnelsgrove/steps/social_proof.md +110 -0
  56. package/template_docs/docs/funnelsgrove/steps/subscription_handoff.md +113 -0
  57. package/template_docs/docs/funnelsgrove/steps/subscription_management.md +112 -0
  58. package/template_docs/docs/funnelsgrove/steps/summary_confirmation.md +110 -0
  59. package/template_docs/docs/funnelsgrove/steps/upsell_offer.md +112 -0
  60. package/template_docs/docs/funnelsgrove/steps/value_prop_story.md +110 -0
  61. package/template_docs/funnel-docs.config.json +43 -0
  62. package/template_scaffold/.env.example +41 -0
  63. package/template_scaffold/.funnelsgrove-contract-tools.json +19 -0
  64. package/template_scaffold/.funnelsgrove-docs.json +144 -0
  65. package/template_scaffold/.funnelsgrove-scaffold.json +1078 -0
  66. package/template_scaffold/AGENTS.md +42 -0
  67. package/template_scaffold/CLAUDE.md +1 -0
  68. package/template_scaffold/PLAN.md +81 -0
  69. package/template_scaffold/PRODUCT_SENSE.md +28 -0
  70. package/template_scaffold/context/build-context.md +34 -0
  71. package/template_scaffold/docs/AGENT_FUNNEL_IMPLEMENTATION_SPEC.md +5 -0
  72. package/template_scaffold/docs/ARCHITECTURE.md +5 -0
  73. package/template_scaffold/docs/FLOW_CONFIG_AND_ROUTING.md +5 -0
  74. package/template_scaffold/docs/PAYWALL_AND_PAYMENTS.md +5 -0
  75. package/template_scaffold/docs/README.md +5 -0
  76. package/template_scaffold/docs/funnelsgrove/START-HERE.md +73 -0
  77. package/template_scaffold/docs/funnelsgrove/contracts/analytics-events.md +59 -0
  78. package/template_scaffold/docs/funnelsgrove/contracts/content-answers.md +44 -0
  79. package/template_scaffold/docs/funnelsgrove/contracts/flow-routing.md +41 -0
  80. package/template_scaffold/docs/funnelsgrove/contracts/payments.md +42 -0
  81. package/template_scaffold/docs/funnelsgrove/contracts/step-metadata.md +57 -0
  82. package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +39 -0
  83. package/template_scaffold/docs/funnelsgrove/qa/analytics.md +43 -0
  84. package/template_scaffold/docs/funnelsgrove/qa/local.md +42 -0
  85. package/template_scaffold/docs/funnelsgrove/qa/paywall-checkout.md +55 -0
  86. package/template_scaffold/docs/funnelsgrove/qa/publish.md +32 -0
  87. package/template_scaffold/docs/funnelsgrove/recipes/add-experiment.md +34 -0
  88. package/template_scaffold/docs/funnelsgrove/recipes/add-step.md +33 -0
  89. package/template_scaffold/docs/funnelsgrove/recipes/edit-step.md +31 -0
  90. package/template_scaffold/docs/funnelsgrove/steps/INDEX.md +26 -0
  91. package/template_scaffold/docs/funnelsgrove/steps/cancellation_offer.md +112 -0
  92. package/template_scaffold/docs/funnelsgrove/steps/checkout.md +120 -0
  93. package/template_scaffold/docs/funnelsgrove/steps/form_input.md +115 -0
  94. package/template_scaffold/docs/funnelsgrove/steps/intro_hero.md +110 -0
  95. package/template_scaffold/docs/funnelsgrove/steps/multi_select_choice.md +123 -0
  96. package/template_scaffold/docs/funnelsgrove/steps/paywall_offer.md +115 -0
  97. package/template_scaffold/docs/funnelsgrove/steps/progress_interstitial.md +111 -0
  98. package/template_scaffold/docs/funnelsgrove/steps/purchase_completed.md +117 -0
  99. package/template_scaffold/docs/funnelsgrove/steps/single_step_choice.md +122 -0
  100. package/template_scaffold/docs/funnelsgrove/steps/single_step_choice_emoji.md +122 -0
  101. package/template_scaffold/docs/funnelsgrove/steps/social_proof.md +110 -0
  102. package/template_scaffold/docs/funnelsgrove/steps/subscription_handoff.md +113 -0
  103. package/template_scaffold/docs/funnelsgrove/steps/subscription_management.md +112 -0
  104. package/template_scaffold/docs/funnelsgrove/steps/summary_confirmation.md +110 -0
  105. package/template_scaffold/docs/funnelsgrove/steps/upsell_offer.md +112 -0
  106. package/template_scaffold/docs/funnelsgrove/steps/value_prop_story.md +110 -0
  107. package/template_scaffold/docs/references/funnel-sdk-user-answers.md +5 -0
  108. package/template_scaffold/eslint.config.mjs +16 -0
  109. package/template_scaffold/funnel-agent-docs.test.ts +699 -0
  110. package/template_scaffold/funnel-docs.config.json +43 -0
  111. package/template_scaffold/funnel.config.json +5 -0
  112. package/template_scaffold/gitignore.template +5 -0
  113. package/template_scaffold/next-env.d.ts +6 -0
  114. package/template_scaffold/next.config.ts +53 -0
  115. package/template_scaffold/package-lock.json +7737 -0
  116. package/template_scaffold/package.json +37 -0
  117. package/template_scaffold/package.test.ts +23 -0
  118. package/template_scaffold/public/claimbee/bee.png +0 -0
  119. package/template_scaffold/public/claimbee/device-iphone.png +0 -0
  120. package/template_scaffold/public/claimbee/device-macbook.png +0 -0
  121. package/template_scaffold/public/claimbee/device-none.png +0 -0
  122. package/template_scaffold/public/claimbee/device-watch.png +0 -0
  123. package/template_scaffold/public/claimbee/glow.svg +3 -0
  124. package/template_scaffold/public/claimbee/steps-sprite-alt.png +0 -0
  125. package/template_scaffold/public/claimbee/steps-sprite-default.png +0 -0
  126. package/template_scaffold/public/claimbee/testimonial-avatar.png +0 -0
  127. package/template_scaffold/public/claimbee/testimonial-star.svg +3 -0
  128. package/template_scaffold/public/paywall/ai-claim-assistant.png +0 -0
  129. package/template_scaffold/public/paywall/claim-history.png +0 -0
  130. package/template_scaffold/public/paywall/clear-progress.png +0 -0
  131. package/template_scaffold/public/paywall/hero.png +0 -0
  132. package/template_scaffold/public/paywall/money-alerts.png +0 -0
  133. package/template_scaffold/public/paywall/money-back-badge.png +0 -0
  134. package/template_scaffold/public/paywall/paywall-image.png +0 -0
  135. package/template_scaffold/public/paywall/r1.png +0 -0
  136. package/template_scaffold/public/paywall/r2.png +0 -0
  137. package/template_scaffold/public/paywall/r3.png +0 -0
  138. package/template_scaffold/public/paywall/r4.png +0 -0
  139. package/template_scaffold/public/paywall/r5.png +0 -0
  140. package/template_scaffold/public/paywall/r6.png +0 -0
  141. package/template_scaffold/public/paywall/special-offer-gift.png +0 -0
  142. package/template_scaffold/public/please/bee.png +0 -0
  143. package/template_scaffold/public/please/device-iphone.png +0 -0
  144. package/template_scaffold/public/please/device-macbook.png +0 -0
  145. package/template_scaffold/public/please/device-none.png +0 -0
  146. package/template_scaffold/public/please/device-watch.png +0 -0
  147. package/template_scaffold/public/please/glow.svg +3 -0
  148. package/template_scaffold/public/please/steps-sprite-alt.png +0 -0
  149. package/template_scaffold/public/please/steps-sprite-default.png +0 -0
  150. package/template_scaffold/public/please/testimonial-avatar.png +0 -0
  151. package/template_scaffold/public/please/testimonial-star.svg +3 -0
  152. package/template_scaffold/public/upsell/upgrade-hero.png +0 -0
  153. package/template_scaffold/src/app/[stepId]/page.test.tsx +59 -0
  154. package/template_scaffold/src/app/[stepId]/page.tsx +62 -0
  155. package/template_scaffold/src/app/globals.css +49 -0
  156. package/template_scaffold/src/app/layout.test.ts +26 -0
  157. package/template_scaffold/src/app/layout.tsx +61 -0
  158. package/template_scaffold/src/app/page.tsx +8 -0
  159. package/template_scaffold/src/app/sdk/page.tsx +673 -0
  160. package/template_scaffold/src/app/subscription-dashboard/page.tsx +274 -0
  161. package/template_scaffold/src/assets/claimbee/bee.png +0 -0
  162. package/template_scaffold/src/assets/claimbee/device-iphone.png +0 -0
  163. package/template_scaffold/src/assets/claimbee/device-macbook.png +0 -0
  164. package/template_scaffold/src/assets/claimbee/device-none.png +0 -0
  165. package/template_scaffold/src/assets/claimbee/device-watch.png +0 -0
  166. package/template_scaffold/src/assets/claimbee/glow.svg +3 -0
  167. package/template_scaffold/src/assets/claimbee/steps-sprite-alt.png +0 -0
  168. package/template_scaffold/src/assets/claimbee/steps-sprite-default.png +0 -0
  169. package/template_scaffold/src/assets/claimbee/testimonial-avatar.png +0 -0
  170. package/template_scaffold/src/assets/claimbee/testimonial-star.svg +3 -0
  171. package/template_scaffold/src/components/FunnelEditorPanel.tsx +194 -0
  172. package/template_scaffold/src/components/FunnelFlow.test.tsx +17 -0
  173. package/template_scaffold/src/components/FunnelFlow.tsx +114 -0
  174. package/template_scaffold/src/components/FunnelStepImage.test.ts +15 -0
  175. package/template_scaffold/src/components/FunnelStepImage.tsx +28 -0
  176. package/template_scaffold/src/components/step/FunnelStepHeader.tsx +35 -0
  177. package/template_scaffold/src/config/billing.plans.test.ts +143 -0
  178. package/template_scaffold/src/config/billing.plans.ts +69 -0
  179. package/template_scaffold/src/config/billing.test.plans.ts +32 -0
  180. package/template_scaffold/src/config/experiments.generated.ts +4 -0
  181. package/template_scaffold/src/config/experiments.test.ts +12 -0
  182. package/template_scaffold/src/config/experiments.ts +9 -0
  183. package/template_scaffold/src/config/funnel.config.ts +12 -0
  184. package/template_scaffold/src/config/funnel.manifest.test.ts +166 -0
  185. package/template_scaffold/src/config/funnel.manifest.ts +139 -0
  186. package/template_scaffold/src/config/offer-sets.generated.ts +4 -0
  187. package/template_scaffold/src/contract/funnel-project.validation.test.ts +80 -0
  188. package/template_scaffold/src/contract/funnel-project.validation.ts +44 -0
  189. package/template_scaffold/src/contract/funnel-validator.vite.config.ts +11 -0
  190. package/template_scaffold/src/contract/golden/cancellation_offer.fixture.ts +29 -0
  191. package/template_scaffold/src/contract/golden/checkout.fixture.ts +37 -0
  192. package/template_scaffold/src/contract/golden/form_input.fixture.ts +28 -0
  193. package/template_scaffold/src/contract/golden/golden-fixtures.test.ts +190 -0
  194. package/template_scaffold/src/contract/golden/intro_hero.fixture.ts +27 -0
  195. package/template_scaffold/src/contract/golden/multi_select_choice.fixture.ts +37 -0
  196. package/template_scaffold/src/contract/golden/paywall_offer.fixture.ts +30 -0
  197. package/template_scaffold/src/contract/golden/progress_interstitial.fixture.ts +28 -0
  198. package/template_scaffold/src/contract/golden/purchase_completed.fixture.ts +33 -0
  199. package/template_scaffold/src/contract/golden/single_step_choice.fixture.ts +36 -0
  200. package/template_scaffold/src/contract/golden/single_step_choice_emoji.fixture.ts +36 -0
  201. package/template_scaffold/src/contract/golden/social_proof.fixture.ts +27 -0
  202. package/template_scaffold/src/contract/golden/subscription_handoff.fixture.ts +30 -0
  203. package/template_scaffold/src/contract/golden/subscription_management.fixture.ts +29 -0
  204. package/template_scaffold/src/contract/golden/summary_confirmation.fixture.ts +27 -0
  205. package/template_scaffold/src/contract/golden/upsell_offer.fixture.ts +29 -0
  206. package/template_scaffold/src/contract/golden/value_prop_story.fixture.ts +27 -0
  207. package/template_scaffold/src/contract/validate-funnel.cli.test.ts +180 -0
  208. package/template_scaffold/src/contract/validate-funnel.cli.ts +199 -0
  209. package/template_scaffold/src/runtime/checkout-runtime-config.test.ts +60 -0
  210. package/template_scaffold/src/runtime/checkout-runtime-config.ts +26 -0
  211. package/template_scaffold/src/runtime/funnel-runtime.test.ts +25 -0
  212. package/template_scaffold/src/runtime/funnel-runtime.ts +118 -0
  213. package/template_scaffold/src/runtime/step-content-context.test.ts +38 -0
  214. package/template_scaffold/src/runtime/step-content-context.ts +65 -0
  215. package/template_scaffold/src/runtime/step-registry.ts +66 -0
  216. package/template_scaffold/src/runtime/step-variable-values.ts +31 -0
  217. package/template_scaffold/src/runtime/use-funnel-flow-controller.integration.test.ts +203 -0
  218. package/template_scaffold/src/runtime/use-funnel-flow-controller.test.ts +76 -0
  219. package/template_scaffold/src/runtime/use-funnel-flow-controller.ts +53 -0
  220. package/template_scaffold/src/steps/content/email-capture.content.ts +29 -0
  221. package/template_scaffold/src/steps/content/manage-subscription.content.ts +62 -0
  222. package/template_scaffold/src/steps/content/paywall.content.ts +191 -0
  223. package/template_scaffold/src/steps/content/step-01.content.ts +23 -0
  224. package/template_scaffold/src/steps/content/step-02.content.ts +77 -0
  225. package/template_scaffold/src/steps/content/step-03.content.ts +40 -0
  226. package/template_scaffold/src/steps/content/step-04.content.ts +49 -0
  227. package/template_scaffold/src/steps/content/step-32-paywall.content.ts +352 -0
  228. package/template_scaffold/src/steps/content/subscription-started.content.ts +68 -0
  229. package/template_scaffold/src/steps/editor/email-capture.editor.ts +58 -0
  230. package/template_scaffold/src/steps/editor/manage-subscription.editor.ts +179 -0
  231. package/template_scaffold/src/steps/editor/paywall.editor.ts +232 -0
  232. package/template_scaffold/src/steps/editor/step-01.editor.ts +22 -0
  233. package/template_scaffold/src/steps/editor/step-02.editor.ts +41 -0
  234. package/template_scaffold/src/steps/editor/step-03.editor.ts +58 -0
  235. package/template_scaffold/src/steps/editor/step-04.editor.ts +98 -0
  236. package/template_scaffold/src/steps/editor/step-32-paywall.editor.ts +478 -0
  237. package/template_scaffold/src/steps/editor/subscription-started.editor.ts +131 -0
  238. package/template_scaffold/src/steps/index.ts +25 -0
  239. package/template_scaffold/src/steps/paywall-checkout-options.test.ts +104 -0
  240. package/template_scaffold/src/steps/paywall-checkout-options.ts +130 -0
  241. package/template_scaffold/src/steps/pricing/paywall-b.pricing.ts +12 -0
  242. package/template_scaffold/src/steps/pricing/paywall.pricing.ts +12 -0
  243. package/template_scaffold/src/steps/pricing/shared-plan-order.ts +17 -0
  244. package/template_scaffold/src/steps/pricing/upsell-form.pricing.ts +16 -0
  245. package/template_scaffold/src/steps/shared-header-contract.test.ts +27 -0
  246. package/template_scaffold/src/steps/step-01.test.ts +38 -0
  247. package/template_scaffold/src/steps/step-01.tsx +108 -0
  248. package/template_scaffold/src/steps/step-02.test.ts +43 -0
  249. package/template_scaffold/src/steps/step-02.tsx +140 -0
  250. package/template_scaffold/src/steps/step-03.test.ts +15 -0
  251. package/template_scaffold/src/steps/step-03.tsx +262 -0
  252. package/template_scaffold/src/steps/step-04.test.ts +32 -0
  253. package/template_scaffold/src/steps/step-04.tsx +673 -0
  254. package/template_scaffold/src/steps/step-31-email-capture.test.ts +22 -0
  255. package/template_scaffold/src/steps/step-31-email-capture.tsx +195 -0
  256. package/template_scaffold/src/steps/step-32-paywall.test.ts +770 -0
  257. package/template_scaffold/src/steps/step-32-paywall.tsx +2253 -0
  258. package/template_scaffold/src/steps/step-32b-paywall-b.tsx +35 -0
  259. package/template_scaffold/src/steps/step-33-subscription-started.test.ts +26 -0
  260. package/template_scaffold/src/steps/step-33-subscription-started.tsx +86 -0
  261. package/template_scaffold/src/steps/step-34-upsell-form.test.ts +27 -0
  262. package/template_scaffold/src/steps/step-35-manage-subscription.tsx +46 -0
  263. package/template_scaffold/src/steps/step-content.registry.ts +45 -0
  264. package/template_scaffold/src/steps/step-image-migration.test.ts +20 -0
  265. package/template_scaffold/src/steps/styles/index.css +2 -0
  266. package/template_scaffold/src/steps/styles/shared/final-flow-shared.css +55 -0
  267. package/template_scaffold/src/steps/styles/shared/funnel-base.css +543 -0
  268. package/template_scaffold/src/steps/styles/shared/info-shared.css +139 -0
  269. package/template_scaffold/src/steps/styles/shared/progress-shared.css +117 -0
  270. package/template_scaffold/src/steps/styles/shared/question-shared.css +76 -0
  271. package/template_scaffold/src/steps/styles/shared/responsive-container.css +176 -0
  272. package/template_scaffold/src/steps/types.ts +7 -0
  273. package/template_scaffold/src/steps/use-claimbee-step-content.ts +15 -0
  274. package/template_scaffold/src/theme/theme.ts +81 -0
  275. package/template_scaffold/tsconfig.json +38 -0
  276. package/template_scaffold/vitest.config.ts +32 -0
  277. package/template_validation/.funnelsgrove-contract-tools.json +19 -0
  278. package/template_validation/src/contract/funnel-project.validation.ts +44 -0
  279. package/template_validation/src/contract/funnel-validator.vite.config.ts +11 -0
  280. package/template_validation/src/contract/validate-funnel.cli.ts +199 -0
  281. package/template_docs/docs/ab-experiments.md +0 -55
  282. package/template_docs/docs/analytics.md +0 -65
  283. package/template_docs/docs/editing-flow.md +0 -125
  284. package/template_docs/docs/editing-step.md +0 -125
  285. package/template_docs/docs/editor-and-content.md +0 -58
  286. package/template_docs/docs/funnel-runtime-architecture.md +0 -125
  287. package/template_docs/docs/meta-pixel-conversions-api.md +0 -50
  288. package/template_docs/docs/payment-plans-and-discounts.md +0 -106
  289. package/template_docs/docs/publishing-and-versioning.md +0 -117
  290. package/template_docs/docs/qa-checklist.md +0 -80
  291. package/template_docs/docs/sdk-api-endpoints.md +0 -78
  292. package/template_docs/docs/step-ui-guidelines.md +0 -110
  293. package/template_docs/docs/theme.md +0 -35
@@ -0,0 +1,1323 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile, } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import ts from 'typescript';
5
+ import { createFunnelContractDiagnostic, FUNNEL_CHOICE_STEP_TYPES, planStepContractV2Migration, } from '@funnelsgrove/runtime';
6
+ const MANIFEST_PATH = 'src/config/funnel.manifest.ts';
7
+ const STATE_DIRECTORY = '.funnelsgrove';
8
+ const MIGRATIONS_DIRECTORY = `${STATE_DIRECTORY}/migrations`;
9
+ const BACKUPS_DIRECTORY = `${MIGRATIONS_DIRECTORY}/backups`;
10
+ const DOCS_TRANSACTION_PREFIX = 'docs-transaction';
11
+ const GUIDE = 'docs/funnelsgrove/migrations/step-contract-v3.md';
12
+ const METADATA_FIELDS = ['id', 'name', 'type', 'kind', 'choice'];
13
+ const CHOICE_STEP_TYPES = new Set(FUNNEL_CHOICE_STEP_TYPES);
14
+ export const STEP_CONTRACT_V2_REPORT_PATH = `${MIGRATIONS_DIRECTORY}/step-contract-v2-report.json`;
15
+ const transactionDirectory = (targetVersion) => `${MIGRATIONS_DIRECTORY}/step-contract-v${targetVersion}-transaction`;
16
+ export const stepContractMigrationReportPath = (targetVersion) => `${MIGRATIONS_DIRECTORY}/step-contract-v${targetVersion}-report.json`;
17
+ export class StepContractMigrationError extends Error {
18
+ report;
19
+ constructor(message, report) {
20
+ super(message);
21
+ this.name = 'StepContractMigrationError';
22
+ this.report = report;
23
+ }
24
+ }
25
+ export const resolveStepContractMigrationMode = (flags) => {
26
+ if (flags.check && flags.apply) {
27
+ throw new StepContractMigrationError('--check and --apply are mutually exclusive');
28
+ }
29
+ return flags.apply ? 'apply' : 'check';
30
+ };
31
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
32
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
33
+ const isSafeRelativePath = (relativePath) => {
34
+ if (relativePath.length === 0
35
+ || relativePath.includes('\\')
36
+ || relativePath.includes('\0')
37
+ || path.posix.isAbsolute(relativePath)
38
+ || /^[a-zA-Z]:/.test(relativePath))
39
+ return false;
40
+ const normalized = path.posix.normalize(relativePath);
41
+ return normalized === relativePath
42
+ && normalized !== '..'
43
+ && !normalized.startsWith('../');
44
+ };
45
+ const absoluteProjectPath = (projectDir, relativePath) => {
46
+ if (!isSafeRelativePath(relativePath)) {
47
+ throw new StepContractMigrationError(`Unsafe migration path: ${relativePath}`);
48
+ }
49
+ return path.join(projectDir, ...relativePath.split('/'));
50
+ };
51
+ const migrationDiagnostic = (code, input) => createFunnelContractDiagnostic({
52
+ code,
53
+ file: input.file,
54
+ stepId: input.stepId,
55
+ expected: input.expected,
56
+ received: input.received,
57
+ reason: input.reason,
58
+ guide: GUIDE,
59
+ repair: input.repair,
60
+ });
61
+ const candidateDiagnostic = (file, received, reason) => migrationDiagnostic('FG-CANDIDATE-001', {
62
+ file,
63
+ expected: 'one static, unique, safely editable metadata object',
64
+ received,
65
+ reason,
66
+ repair: 'Make the manifest and component metadata explicit object literals, then rerun the check.',
67
+ });
68
+ const parseSource = (relativePath, source) => {
69
+ const sourceFile = ts.createSourceFile(relativePath, source, ts.ScriptTarget.Latest, true, relativePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
70
+ const diagnostics = sourceFile.parseDiagnostics;
71
+ if (diagnostics?.length) {
72
+ throw new StepContractMigrationError(`Unable to parse ${relativePath}`);
73
+ }
74
+ return sourceFile;
75
+ };
76
+ const unwrapExpression = (input) => {
77
+ let expression = input;
78
+ while (ts.isParenthesizedExpression(expression)
79
+ || ts.isAsExpression(expression)
80
+ || ts.isSatisfiesExpression(expression)
81
+ || ts.isTypeAssertionExpression(expression)) {
82
+ expression = expression.expression;
83
+ }
84
+ return expression;
85
+ };
86
+ const collectBindings = (sourceFile) => {
87
+ const bindings = new Map();
88
+ for (const statement of sourceFile.statements) {
89
+ if (!ts.isVariableStatement(statement))
90
+ continue;
91
+ for (const declaration of statement.declarationList.declarations) {
92
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer)
93
+ continue;
94
+ const values = bindings.get(declaration.name.text) ?? [];
95
+ values.push(declaration.initializer);
96
+ bindings.set(declaration.name.text, values);
97
+ }
98
+ }
99
+ return bindings;
100
+ };
101
+ const propertyName = (property) => {
102
+ if (!('name' in property) || !property.name)
103
+ return null;
104
+ if (ts.isComputedPropertyName(property.name))
105
+ return null;
106
+ if (ts.isIdentifier(property.name)
107
+ || ts.isStringLiteral(property.name)
108
+ || ts.isNumericLiteral(property.name))
109
+ return property.name.text;
110
+ return null;
111
+ };
112
+ const readStaticValue = (input, bindings, seen = new Set()) => {
113
+ const expression = unwrapExpression(input);
114
+ if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
115
+ return { status: 'ok', value: expression.text };
116
+ }
117
+ if (ts.isNumericLiteral(expression)) {
118
+ return { status: 'ok', value: Number(expression.text) };
119
+ }
120
+ if (expression.kind === ts.SyntaxKind.TrueKeyword)
121
+ return { status: 'ok', value: true };
122
+ if (expression.kind === ts.SyntaxKind.FalseKeyword)
123
+ return { status: 'ok', value: false };
124
+ if (expression.kind === ts.SyntaxKind.NullKeyword)
125
+ return { status: 'ok', value: null };
126
+ if (ts.isIdentifier(expression)) {
127
+ if (expression.text === 'undefined')
128
+ return { status: 'ok', value: undefined };
129
+ const candidates = bindings.get(expression.text) ?? [];
130
+ if (candidates.length > 1)
131
+ return { status: 'multiple' };
132
+ if (candidates.length !== 1 || seen.has(expression.text))
133
+ return { status: 'dynamic' };
134
+ return readStaticValue(candidates[0], bindings, new Set([...seen, expression.text]));
135
+ }
136
+ if (ts.isObjectLiteralExpression(expression)) {
137
+ if (expression.properties.some(ts.isSpreadAssignment))
138
+ return { status: 'spread' };
139
+ if (expression.properties.some((property) => 'name' in property && property.name && ts.isComputedPropertyName(property.name))) {
140
+ return { status: 'computed' };
141
+ }
142
+ const value = {};
143
+ const names = new Set();
144
+ for (const property of expression.properties) {
145
+ const name = propertyName(property);
146
+ if (!name || !ts.isPropertyAssignment(property))
147
+ return { status: 'dynamic' };
148
+ if (names.has(name))
149
+ return { status: 'multiple' };
150
+ names.add(name);
151
+ const result = readStaticValue(property.initializer, bindings, seen);
152
+ if (result.status !== 'ok')
153
+ return result;
154
+ value[name] = result.value;
155
+ }
156
+ return { status: 'ok', value };
157
+ }
158
+ return { status: 'dynamic' };
159
+ };
160
+ const ambiguousLocatedSource = (status, sourceId, file) => ({ semantic: { status, sourceId, file } });
161
+ const inspectMetadataObject = (input) => {
162
+ if (input.object.properties.some(ts.isSpreadAssignment)) {
163
+ return ambiguousLocatedSource('spread', input.sourceId, input.path);
164
+ }
165
+ if (input.object.properties.some((property) => 'name' in property && property.name && ts.isComputedPropertyName(property.name))) {
166
+ return ambiguousLocatedSource('computed', input.sourceId, input.path);
167
+ }
168
+ const grouped = new Map();
169
+ for (const property of input.object.properties) {
170
+ const name = propertyName(property);
171
+ if (!name || !METADATA_FIELDS.includes(name))
172
+ continue;
173
+ const values = grouped.get(name) ?? [];
174
+ values.push(property);
175
+ grouped.set(name, values);
176
+ }
177
+ if ([...grouped.values()].some((properties) => properties.length > 1)) {
178
+ return ambiguousLocatedSource('multiple', input.sourceId, input.path);
179
+ }
180
+ const properties = new Map();
181
+ const metadata = {
182
+ id: undefined,
183
+ name: undefined,
184
+ type: undefined,
185
+ kind: undefined,
186
+ choice: undefined,
187
+ };
188
+ for (const field of METADATA_FIELDS) {
189
+ const property = grouped.get(field)?.[0];
190
+ if (!property)
191
+ continue;
192
+ if (!ts.isPropertyAssignment(property)) {
193
+ return ambiguousLocatedSource('dynamic', input.sourceId, input.path);
194
+ }
195
+ const value = readStaticValue(property.initializer, input.bindings);
196
+ if (value.status !== 'ok') {
197
+ return ambiguousLocatedSource(value.status, input.sourceId, input.path);
198
+ }
199
+ properties.set(field, property);
200
+ metadata[field] = value.value;
201
+ }
202
+ const located = {
203
+ sourceId: input.sourceId,
204
+ path: input.path,
205
+ source: input.source,
206
+ sourceFile: input.sourceFile,
207
+ object: input.object,
208
+ properties,
209
+ metadata,
210
+ };
211
+ return {
212
+ semantic: {
213
+ status: 'unique',
214
+ sourceId: input.sourceId,
215
+ file: input.path,
216
+ metadata,
217
+ },
218
+ located,
219
+ };
220
+ };
221
+ const findVariableDeclarations = (sourceFile, name) => {
222
+ const declarations = [];
223
+ for (const statement of sourceFile.statements) {
224
+ if (!ts.isVariableStatement(statement))
225
+ continue;
226
+ for (const declaration of statement.declarationList.declarations) {
227
+ if (!ts.isIdentifier(declaration.name)
228
+ || declaration.name.text !== name)
229
+ continue;
230
+ declarations.push(declaration);
231
+ }
232
+ }
233
+ return declarations;
234
+ };
235
+ const objectFromDeclaration = (declaration) => {
236
+ if (!declaration.initializer)
237
+ return null;
238
+ const initializer = unwrapExpression(declaration.initializer);
239
+ return ts.isObjectLiteralExpression(initializer) ? initializer : null;
240
+ };
241
+ const propertyAssignments = (object, name) => object.properties.filter((property) => propertyName(property) === name && ts.isPropertyAssignment(property));
242
+ const inspectManifest = (source, targetVersion) => {
243
+ let sourceFile;
244
+ try {
245
+ sourceFile = parseSource(MANIFEST_PATH, source);
246
+ }
247
+ catch {
248
+ return {
249
+ source,
250
+ sourceFile: ts.createSourceFile(MANIFEST_PATH, source, ts.ScriptTarget.Latest),
251
+ root: null,
252
+ fromVersion: null,
253
+ versionProperty: null,
254
+ steps: [],
255
+ diagnostics: [candidateDiagnostic(MANIFEST_PATH, 'malformed TypeScript', 'The authored manifest cannot be parsed without evaluating project code.')],
256
+ };
257
+ }
258
+ const rootDeclarations = findVariableDeclarations(sourceFile, 'rawFunnelManifest');
259
+ const root = rootDeclarations.length === 1
260
+ ? objectFromDeclaration(rootDeclarations[0])
261
+ : null;
262
+ if (!root) {
263
+ return {
264
+ source,
265
+ sourceFile,
266
+ root: null,
267
+ fromVersion: null,
268
+ versionProperty: null,
269
+ steps: [],
270
+ diagnostics: [candidateDiagnostic(MANIFEST_PATH, rootDeclarations.length === 0
271
+ ? 'missing rawFunnelManifest'
272
+ : rootDeclarations.length > 1
273
+ ? 'multiple rawFunnelManifest declarations'
274
+ : 'dynamic rawFunnelManifest declaration', 'The project must expose one authored rawFunnelManifest object literal.')],
275
+ };
276
+ }
277
+ const diagnostics = [];
278
+ if (root.properties.some(ts.isSpreadAssignment)
279
+ || root.properties.some((property) => 'name' in property && property.name && ts.isComputedPropertyName(property.name))) {
280
+ diagnostics.push(candidateDiagnostic(MANIFEST_PATH, 'computed or spread root manifest properties', 'The root manifest may override migration-owned properties dynamically.'));
281
+ }
282
+ const versionProperties = propertyAssignments(root, 'stepContractVersion');
283
+ let fromVersion = null;
284
+ let versionProperty = null;
285
+ if (versionProperties.length > 1) {
286
+ diagnostics.push(candidateDiagnostic(MANIFEST_PATH, 'multiple stepContractVersion properties', 'The source contract version is ambiguous.'));
287
+ }
288
+ else if (versionProperties.length === 1) {
289
+ versionProperty = versionProperties[0];
290
+ const version = readStaticValue(versionProperty.initializer, collectBindings(sourceFile));
291
+ const supportedSourceVersions = targetVersion === 3 ? [1, 2] : [1];
292
+ if (version.status !== 'ok'
293
+ || typeof version.value !== 'number'
294
+ || !supportedSourceVersions.includes(version.value)) {
295
+ diagnostics.push(migrationDiagnostic('FG-VERSION-002', {
296
+ file: MANIFEST_PATH,
297
+ expected: targetVersion === 3
298
+ ? 'missing stepContractVersion or the literal 1 or 2'
299
+ : 'missing stepContractVersion or the literal 1',
300
+ received: version.status === 'ok' ? version.value : version.status,
301
+ reason: `Only a legacy contract can migrate to version ${targetVersion}.`,
302
+ repair: 'Use a compatible CLI or restore the authored legacy version before migrating.',
303
+ }));
304
+ }
305
+ else {
306
+ fromVersion = version.value;
307
+ }
308
+ }
309
+ const stepsProperties = propertyAssignments(root, 'steps');
310
+ const steps = [];
311
+ if (stepsProperties.length !== 1) {
312
+ diagnostics.push(candidateDiagnostic(MANIFEST_PATH, stepsProperties.length === 0 ? 'missing steps property' : 'multiple steps properties', 'The manifest steps array must have one static property assignment.'));
313
+ }
314
+ else {
315
+ const stepsExpression = unwrapExpression(stepsProperties[0].initializer);
316
+ if (!ts.isArrayLiteralExpression(stepsExpression)) {
317
+ diagnostics.push(candidateDiagnostic(MANIFEST_PATH, 'dynamic steps value', 'The manifest steps value must be a static array literal.'));
318
+ }
319
+ else {
320
+ const bindings = collectBindings(sourceFile);
321
+ stepsExpression.elements.forEach((element, index) => {
322
+ const expression = unwrapExpression(element);
323
+ const sourceId = `manifest:${index}`;
324
+ if (!ts.isObjectLiteralExpression(expression)) {
325
+ steps.push({
326
+ stepKey: `step-${index}`,
327
+ manifest: ambiguousLocatedSource('dynamic', sourceId, MANIFEST_PATH),
328
+ componentPath: null,
329
+ componentKey: null,
330
+ });
331
+ return;
332
+ }
333
+ const manifest = inspectMetadataObject({
334
+ sourceId,
335
+ path: MANIFEST_PATH,
336
+ source,
337
+ sourceFile,
338
+ object: expression,
339
+ bindings,
340
+ });
341
+ const filePathProperty = propertyAssignments(expression, 'filePath');
342
+ const componentKeyProperty = propertyAssignments(expression, 'componentKey');
343
+ const filePathValue = filePathProperty.length === 1
344
+ ? readStaticValue(filePathProperty[0].initializer, bindings)
345
+ : { status: 'multiple' };
346
+ const componentKeyValue = componentKeyProperty.length === 1
347
+ ? readStaticValue(componentKeyProperty[0].initializer, bindings)
348
+ : { status: 'multiple' };
349
+ const componentPath = filePathValue.status === 'ok'
350
+ && typeof filePathValue.value === 'string'
351
+ && isSafeRelativePath(filePathValue.value)
352
+ && filePathValue.value.startsWith('src/steps/')
353
+ ? filePathValue.value
354
+ : null;
355
+ const componentKey = componentKeyValue.status === 'ok'
356
+ && typeof componentKeyValue.value === 'string'
357
+ && /^[A-Za-z_$][\w$]*$/.test(componentKeyValue.value)
358
+ ? componentKeyValue.value
359
+ : null;
360
+ const metadata = manifest.semantic.status === 'unique'
361
+ ? manifest.semantic.metadata
362
+ : null;
363
+ steps.push({
364
+ stepKey: componentKey
365
+ ?? (typeof metadata?.id === 'string' ? metadata.id : `step-${index}`),
366
+ manifest,
367
+ componentPath,
368
+ componentKey,
369
+ });
370
+ });
371
+ }
372
+ }
373
+ return {
374
+ source,
375
+ sourceFile,
376
+ root,
377
+ fromVersion,
378
+ versionProperty,
379
+ steps,
380
+ diagnostics,
381
+ };
382
+ };
383
+ const readComponentSource = async (projectDir, descriptor) => {
384
+ const sourceId = `component:${descriptor.stepKey}`;
385
+ if (!descriptor.componentPath || !descriptor.componentKey) {
386
+ return ambiguousLocatedSource('dynamic', sourceId, descriptor.componentPath ?? MANIFEST_PATH);
387
+ }
388
+ let source;
389
+ try {
390
+ const info = await lstat(absoluteProjectPath(projectDir, descriptor.componentPath));
391
+ if (!info.isFile() || info.isSymbolicLink()) {
392
+ return ambiguousLocatedSource('missing', sourceId, descriptor.componentPath);
393
+ }
394
+ source = await readFile(absoluteProjectPath(projectDir, descriptor.componentPath), 'utf8');
395
+ }
396
+ catch {
397
+ return ambiguousLocatedSource('missing', sourceId, descriptor.componentPath);
398
+ }
399
+ let sourceFile;
400
+ try {
401
+ sourceFile = parseSource(descriptor.componentPath, source);
402
+ }
403
+ catch {
404
+ return ambiguousLocatedSource('dynamic', sourceId, descriptor.componentPath);
405
+ }
406
+ const declarations = findVariableDeclarations(sourceFile, descriptor.componentKey);
407
+ const object = declarations.length === 1 ? objectFromDeclaration(declarations[0]) : null;
408
+ if (!object) {
409
+ return ambiguousLocatedSource(declarations.length === 0 ? 'missing' : declarations.length > 1 ? 'multiple' : 'dynamic', sourceId, descriptor.componentPath);
410
+ }
411
+ return inspectMetadataObject({
412
+ sourceId,
413
+ path: descriptor.componentPath,
414
+ source,
415
+ sourceFile,
416
+ object,
417
+ bindings: collectBindings(sourceFile),
418
+ });
419
+ };
420
+ const resolveStaticOptionIds = (input, bindings, seen = new Set()) => {
421
+ const expression = unwrapExpression(input);
422
+ if (ts.isIdentifier(expression)) {
423
+ const candidates = bindings.get(expression.text) ?? [];
424
+ if (candidates.length !== 1 || seen.has(expression.text))
425
+ return null;
426
+ return resolveStaticOptionIds(candidates[0], bindings, new Set([...seen, expression.text]));
427
+ }
428
+ if (ts.isCallExpression(expression)
429
+ && ts.isPropertyAccessExpression(expression.expression)
430
+ && ts.isIdentifier(expression.expression.expression)
431
+ && expression.expression.expression.text === 'Object'
432
+ && expression.expression.name.text === 'freeze'
433
+ && expression.arguments.length === 1)
434
+ return resolveStaticOptionIds(expression.arguments[0], bindings, seen);
435
+ if (!ts.isArrayLiteralExpression(expression) || expression.elements.length === 0)
436
+ return null;
437
+ const ids = [];
438
+ for (const element of expression.elements) {
439
+ const option = unwrapExpression(element);
440
+ if (!ts.isObjectLiteralExpression(option)
441
+ || option.properties.some(ts.isSpreadAssignment)
442
+ || option.properties.some((property) => 'name' in property && property.name && ts.isComputedPropertyName(property.name)))
443
+ return null;
444
+ const idProperties = propertyAssignments(option, 'id');
445
+ if (idProperties.length !== 1)
446
+ return null;
447
+ const id = readStaticValue(idProperties[0].initializer, bindings);
448
+ if (id.status !== 'ok' || typeof id.value !== 'string' || id.value.length === 0)
449
+ return null;
450
+ ids.push(id.value);
451
+ }
452
+ return new Set(ids).size === ids.length ? ids : null;
453
+ };
454
+ const optionIdEvidence = (located, componentKey) => {
455
+ const validatorNames = new Set(['useStepChoices']);
456
+ for (const statement of located.sourceFile.statements) {
457
+ if (!ts.isImportDeclaration(statement)
458
+ || !ts.isStringLiteral(statement.moduleSpecifier)
459
+ || statement.moduleSpecifier.text !== '@funnelsgrove/runtime'
460
+ || !statement.importClause?.namedBindings
461
+ || !ts.isNamedImports(statement.importClause.namedBindings))
462
+ continue;
463
+ for (const element of statement.importClause.namedBindings.elements) {
464
+ if ((element.propertyName?.text ?? element.name.text) === 'useStepChoices') {
465
+ validatorNames.add(element.name.text);
466
+ }
467
+ }
468
+ }
469
+ const matches = [];
470
+ const visit = (node) => {
471
+ if (ts.isCallExpression(node)
472
+ && ts.isIdentifier(node.expression)
473
+ && validatorNames.has(node.expression.text)
474
+ && node.arguments.length >= 2) {
475
+ const metadataArgument = unwrapExpression(node.arguments[0]);
476
+ if (ts.isIdentifier(metadataArgument) && metadataArgument.text === componentKey) {
477
+ matches.push(node);
478
+ }
479
+ }
480
+ ts.forEachChild(node, visit);
481
+ };
482
+ visit(located.sourceFile);
483
+ if (matches.length !== 1)
484
+ return 'ambiguous';
485
+ return resolveStaticOptionIds(matches[0].arguments[1], collectBindings(located.sourceFile))
486
+ ? 'stable'
487
+ : 'ambiguous';
488
+ };
489
+ const indentationAt = (source, position) => {
490
+ const lineStart = source.lastIndexOf('\n', Math.max(0, position - 1)) + 1;
491
+ const prefix = source.slice(lineStart, position);
492
+ return /^\s*$/.test(prefix) ? prefix : '';
493
+ };
494
+ const quoteValue = (value, located) => {
495
+ if (typeof value !== 'string')
496
+ return JSON.stringify(value);
497
+ const stringProperty = [...located.properties.values()].find((property) => {
498
+ const expression = unwrapExpression(property.initializer);
499
+ return ts.isStringLiteral(expression);
500
+ });
501
+ if (stringProperty) {
502
+ const text = stringProperty.initializer.getText(located.sourceFile).trim();
503
+ if (text.startsWith("'"))
504
+ return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
505
+ }
506
+ return JSON.stringify(value);
507
+ };
508
+ const propertyRemovalRange = (located, property) => {
509
+ const start = property.getStart(located.sourceFile);
510
+ let cursor = property.end;
511
+ while (cursor < located.object.end && /\s/.test(located.source[cursor] ?? ''))
512
+ cursor += 1;
513
+ if (located.source[cursor] === ',')
514
+ return { start, end: cursor + 1 };
515
+ let previous = start - 1;
516
+ while (previous >= located.object.getStart(located.sourceFile) && /\s/.test(located.source[previous] ?? ''))
517
+ previous -= 1;
518
+ return located.source[previous] === ','
519
+ ? { start: previous, end: property.end }
520
+ : { start, end: property.end };
521
+ };
522
+ const propertyInsertion = (located, entries) => {
523
+ if (entries.length === 0) {
524
+ throw new StepContractMigrationError('Missing property insertion');
525
+ }
526
+ const close = located.object.end - 1;
527
+ const closeIndent = indentationAt(located.source, close);
528
+ const first = located.object.properties[0];
529
+ const propertyIndent = first
530
+ ? indentationAt(located.source, first.getStart(located.sourceFile))
531
+ : `${closeIndent} `;
532
+ if (located.object.properties.hasTrailingComma) {
533
+ const extraIndent = propertyIndent.startsWith(closeIndent)
534
+ ? propertyIndent.slice(closeIndent.length)
535
+ : propertyIndent;
536
+ return {
537
+ start: close,
538
+ end: close,
539
+ replacement: entries.map(({ field, value }) => (`${extraIndent}${field}: ${quoteValue(value, located)},\n${closeIndent}`)).join(''),
540
+ };
541
+ }
542
+ const last = located.object.properties.at(-1);
543
+ if (!last) {
544
+ return {
545
+ start: close,
546
+ end: close,
547
+ replacement: `\n${entries.map(({ field, value }) => (`${propertyIndent}${field}: ${quoteValue(value, located)}`)).join(',\n')},\n${closeIndent}`,
548
+ };
549
+ }
550
+ return {
551
+ start: last.end,
552
+ end: last.end,
553
+ replacement: `,\n${entries.map(({ field, value }) => (`${propertyIndent}${field}: ${quoteValue(value, located)}`)).join(',\n')}`,
554
+ };
555
+ };
556
+ const editForSemanticOperation = (operation, located) => {
557
+ const property = located.properties.get(operation.field);
558
+ let start;
559
+ let end;
560
+ let replacement;
561
+ if (operation.action === 'remove') {
562
+ if (!property)
563
+ throw new StepContractMigrationError(`Missing ${operation.field} range`);
564
+ ({ start, end } = propertyRemovalRange(located, property));
565
+ replacement = '';
566
+ }
567
+ else if (property) {
568
+ start = property.initializer.getStart(located.sourceFile);
569
+ end = property.initializer.end;
570
+ replacement = quoteValue(operation.after, located);
571
+ }
572
+ else {
573
+ ({ start, end, replacement } = propertyInsertion(located, [{ field: operation.field, value: operation.after }]));
574
+ }
575
+ return {
576
+ path: located.path,
577
+ start,
578
+ end,
579
+ beforeHash: sha256(located.source.slice(start, end)),
580
+ afterHash: sha256(replacement),
581
+ replacement,
582
+ rule: operation.rule,
583
+ };
584
+ };
585
+ const editForSemanticInsertions = (operations, located) => {
586
+ if (operations.length < 2
587
+ || operations.some((operation) => (operation.action !== 'set'
588
+ || located.properties.has(operation.field)))) {
589
+ throw new StepContractMigrationError('Invalid grouped property insertion');
590
+ }
591
+ const rules = [...new Set(operations.map((operation) => operation.rule))].sort();
592
+ const rule = rules.length === 1
593
+ ? rules[0]
594
+ : rules.join('+') === 'add-name-from-id+restore-reserved-step'
595
+ ? 'add-name-from-id+restore-reserved-step'
596
+ : (() => {
597
+ throw new StepContractMigrationError(`Unsupported composite migration rules: ${rules.join(', ')}`);
598
+ })();
599
+ const { start, end, replacement } = propertyInsertion(located, operations.map((operation) => ({
600
+ field: operation.field,
601
+ value: operation.after,
602
+ })));
603
+ return {
604
+ path: located.path,
605
+ start,
606
+ end,
607
+ beforeHash: sha256(located.source.slice(start, end)),
608
+ afterHash: sha256(replacement),
609
+ replacement,
610
+ rule,
611
+ };
612
+ };
613
+ const versionEdit = (manifest, targetVersion) => {
614
+ if (!manifest.root || manifest.diagnostics.length > 0)
615
+ return null;
616
+ const located = {
617
+ sourceId: 'manifest:root',
618
+ path: MANIFEST_PATH,
619
+ source: manifest.source,
620
+ sourceFile: manifest.sourceFile,
621
+ object: manifest.root,
622
+ properties: new Map(manifest.versionProperty ? [['stepContractVersion', manifest.versionProperty]] : []),
623
+ metadata: { id: undefined, name: undefined, type: undefined, kind: undefined, choice: undefined },
624
+ };
625
+ let start;
626
+ let end;
627
+ let replacement;
628
+ if (manifest.versionProperty) {
629
+ start = manifest.versionProperty.initializer.getStart(manifest.sourceFile);
630
+ end = manifest.versionProperty.initializer.end;
631
+ replacement = String(targetVersion);
632
+ }
633
+ else {
634
+ ({ start, end, replacement } = propertyInsertion(located, [{ field: 'stepContractVersion', value: targetVersion }]));
635
+ }
636
+ return {
637
+ path: MANIFEST_PATH,
638
+ start,
639
+ end,
640
+ beforeHash: sha256(manifest.source.slice(start, end)),
641
+ afterHash: sha256(replacement),
642
+ replacement,
643
+ rule: 'set-step-contract-version',
644
+ };
645
+ };
646
+ const compareEdits = (left, right) => left.path.localeCompare(right.path)
647
+ || left.start - right.start
648
+ || left.end - right.end
649
+ || left.rule.localeCompare(right.rule);
650
+ const publicOperation = ({ replacement: _replacement, ...operation }) => operation;
651
+ const emptyReport = (options, diagnostics) => {
652
+ const projectDir = path.resolve(options.projectDir);
653
+ const targetVersion = options.targetVersion ?? 2;
654
+ return {
655
+ reportPath: path.join(projectDir, stepContractMigrationReportPath(targetVersion)),
656
+ report: {
657
+ schemaVersion: 1,
658
+ fromVersion: null,
659
+ toVersion: targetVersion,
660
+ checkedAt: options.checkedAt ?? new Date().toISOString(),
661
+ operations: [],
662
+ protectedChanges: [],
663
+ diagnostics,
664
+ valid: false,
665
+ },
666
+ edits: [],
667
+ };
668
+ };
669
+ const pathIsRegularFile = async (target) => {
670
+ try {
671
+ const info = await lstat(target);
672
+ return info.isFile() && !info.isSymbolicLink();
673
+ }
674
+ catch {
675
+ return false;
676
+ }
677
+ };
678
+ const openDocsState = async (projectDir) => {
679
+ try {
680
+ const entries = await readdir(path.join(projectDir, STATE_DIRECTORY));
681
+ return entries.find((entry) => entry === DOCS_TRANSACTION_PREFIX
682
+ || entry.startsWith(`${DOCS_TRANSACTION_PREFIX}-`)) ?? null;
683
+ }
684
+ catch {
685
+ return null;
686
+ }
687
+ };
688
+ const pathExists = async (target) => {
689
+ try {
690
+ await lstat(target);
691
+ return true;
692
+ }
693
+ catch (error) {
694
+ if (error.code === 'ENOENT')
695
+ return false;
696
+ throw error;
697
+ }
698
+ };
699
+ const checkPreconditions = async (options) => {
700
+ const projectDir = path.resolve(options.projectDir);
701
+ const diagnostics = [];
702
+ if (!await pathIsRegularFile(path.join(projectDir, 'package.json'))) {
703
+ diagnostics.push(candidateDiagnostic('package.json', 'missing or unsafe project package', 'The migration target is not a supported funnel project root.'));
704
+ }
705
+ if (!await pathIsRegularFile(path.join(projectDir, MANIFEST_PATH))) {
706
+ diagnostics.push(candidateDiagnostic(MANIFEST_PATH, 'missing or unsafe authored manifest', 'The migration target does not contain the supported authored manifest.'));
707
+ }
708
+ if (!options.managedBundleAvailable) {
709
+ diagnostics.push(migrationDiagnostic('FG-DOC-001', {
710
+ file: null,
711
+ expected: 'the current managed documentation and validator bundle',
712
+ received: 'unavailable',
713
+ reason: 'The current managed documentation and validator bundle is required before planning migration.',
714
+ repair: 'Restore the packaged CLI bundle and rerun the migration check.',
715
+ }));
716
+ }
717
+ const docsState = await openDocsState(projectDir);
718
+ if (docsState) {
719
+ diagnostics.push(migrationDiagnostic('FG-DOC-001', {
720
+ file: `${STATE_DIRECTORY}/${docsState}`,
721
+ expected: 'no open documentation transaction',
722
+ received: docsState,
723
+ reason: 'A documentation update or recovery is still open.',
724
+ repair: 'Recover or finish the documentation transaction before migrating.',
725
+ }));
726
+ }
727
+ const targetVersion = options.targetVersion ?? 2;
728
+ const currentTransactionOpen = await pathExists(path.join(projectDir, transactionDirectory(targetVersion)));
729
+ const v2TransactionOpen = targetVersion === 3 && await pathExists(path.join(projectDir, transactionDirectory(2)));
730
+ if (currentTransactionOpen || v2TransactionOpen) {
731
+ diagnostics.push(candidateDiagnostic(transactionDirectory(v2TransactionOpen ? 2 : targetVersion), 'open step-contract migration transaction', 'An earlier migration must be recovered before another plan is created.'));
732
+ }
733
+ return diagnostics;
734
+ };
735
+ const buildProjectPlan = async (options) => {
736
+ const projectDir = path.resolve(options.projectDir);
737
+ const targetVersion = options.targetVersion ?? 2;
738
+ const preconditions = await checkPreconditions(options);
739
+ if (preconditions.length > 0)
740
+ return emptyReport(options, preconditions);
741
+ const manifestSource = await readFile(path.join(projectDir, MANIFEST_PATH), 'utf8');
742
+ const manifest = inspectManifest(manifestSource, targetVersion);
743
+ if (manifest.diagnostics.length > 0) {
744
+ return {
745
+ reportPath: path.join(projectDir, stepContractMigrationReportPath(targetVersion)),
746
+ report: {
747
+ schemaVersion: 1,
748
+ fromVersion: manifest.fromVersion,
749
+ toVersion: targetVersion,
750
+ checkedAt: options.checkedAt ?? new Date().toISOString(),
751
+ operations: [],
752
+ protectedChanges: [],
753
+ diagnostics: manifest.diagnostics,
754
+ valid: false,
755
+ },
756
+ edits: [],
757
+ };
758
+ }
759
+ const locatedBySourceId = new Map();
760
+ const steps = [];
761
+ for (const descriptor of manifest.steps) {
762
+ const component = await readComponentSource(projectDir, descriptor);
763
+ if (descriptor.manifest.located) {
764
+ locatedBySourceId.set(descriptor.manifest.located.sourceId, descriptor.manifest.located);
765
+ }
766
+ if (component.located)
767
+ locatedBySourceId.set(component.located.sourceId, component.located);
768
+ const manifestType = descriptor.manifest.semantic.status === 'unique'
769
+ ? descriptor.manifest.semantic.metadata.type
770
+ : undefined;
771
+ const componentType = component.semantic.status === 'unique'
772
+ ? component.semantic.metadata.type
773
+ : undefined;
774
+ const choiceStep = [manifestType, componentType].some((value) => typeof value === 'string' && CHOICE_STEP_TYPES.has(value));
775
+ steps.push({
776
+ stepKey: descriptor.stepKey,
777
+ manifest: descriptor.manifest.semantic,
778
+ component: component.semantic,
779
+ optionIds: choiceStep
780
+ ? component.located && descriptor.componentKey
781
+ ? optionIdEvidence(component.located, descriptor.componentKey)
782
+ : 'ambiguous'
783
+ : 'not-applicable',
784
+ });
785
+ }
786
+ const semantic = planStepContractV2Migration({ steps });
787
+ const diagnostics = [...manifest.diagnostics, ...semantic.diagnostics];
788
+ const edits = [];
789
+ const semanticEdits = [];
790
+ if (!manifest.diagnostics.some((diagnostic) => diagnostic.code === 'FG-VERSION-002')) {
791
+ const edit = versionEdit(manifest, targetVersion);
792
+ if (edit)
793
+ edits.push(edit);
794
+ }
795
+ const operationsBySource = new Map();
796
+ for (const operation of semantic.operations) {
797
+ const sourceOperations = operationsBySource.get(operation.sourceId) ?? [];
798
+ sourceOperations.push(operation);
799
+ operationsBySource.set(operation.sourceId, sourceOperations);
800
+ }
801
+ const purchaseTerminalSources = new Set();
802
+ if (targetVersion === 3) {
803
+ for (const [sourceId, located] of locatedBySourceId) {
804
+ const sourceOperations = operationsBySource.get(sourceId) ?? [];
805
+ const migrated = { ...located.metadata };
806
+ for (const operation of sourceOperations) {
807
+ migrated[operation.field] = operation.action === 'remove' ? undefined : operation.after;
808
+ }
809
+ if (migrated.id !== 'subscription-started'
810
+ || migrated.name !== 'subscription-started'
811
+ || migrated.type !== 'complete_registration'
812
+ || migrated.kind !== 'subscription-handoff')
813
+ continue;
814
+ purchaseTerminalSources.add(sourceId);
815
+ const typeOperation = sourceOperations.find((operation) => operation.field === 'type');
816
+ if (typeOperation) {
817
+ operationsBySource.set(sourceId, sourceOperations.map((operation) => (operation === typeOperation
818
+ ? { ...operation, after: 'purchase_completed' }
819
+ : operation)));
820
+ }
821
+ else {
822
+ operationsBySource.set(sourceId, [...sourceOperations, {
823
+ source: sourceId.startsWith('manifest:') ? 'manifest' : 'component',
824
+ sourceId,
825
+ stepKey: sourceId.slice(sourceId.indexOf(':') + 1),
826
+ stepId: 'subscription-started',
827
+ field: 'type',
828
+ action: 'set',
829
+ before: 'complete_registration',
830
+ after: 'purchase_completed',
831
+ rule: 'restore-reserved-step',
832
+ }]);
833
+ }
834
+ }
835
+ }
836
+ for (const sourceOperations of operationsBySource.values()) {
837
+ const firstOperation = sourceOperations[0];
838
+ const located = locatedBySourceId.get(firstOperation.sourceId);
839
+ if (!located) {
840
+ diagnostics.push(candidateDiagnostic(firstOperation.source === 'manifest' ? MANIFEST_PATH : null, {
841
+ sourceId: firstOperation.sourceId,
842
+ fields: sourceOperations.map((operation) => operation.field),
843
+ }, 'A safe semantic change did not have one exact source range.'));
844
+ continue;
845
+ }
846
+ const insertions = sourceOperations.filter((operation) => (operation.action === 'set' && !located.properties.has(operation.field)));
847
+ const groupedOperations = new Map();
848
+ if (insertions.length > 1) {
849
+ for (const operation of insertions)
850
+ groupedOperations.set(operation, insertions);
851
+ }
852
+ for (const operation of sourceOperations) {
853
+ const operationGroup = groupedOperations.get(operation) ?? [operation];
854
+ if (operationGroup[0] !== operation)
855
+ continue;
856
+ try {
857
+ const edit = operationGroup.length > 1
858
+ ? editForSemanticInsertions(operationGroup, located)
859
+ : editForSemanticOperation(operation, located);
860
+ if (purchaseTerminalSources.has(operation.sourceId)
861
+ && operationGroup.some((candidate) => candidate.field === 'type'))
862
+ edit.rule = 'upgrade-purchase-terminal';
863
+ edits.push(edit);
864
+ semanticEdits.push({ operations: operationGroup, edit });
865
+ }
866
+ catch (error) {
867
+ diagnostics.push(candidateDiagnostic(located.path, {
868
+ sourceId: operation.sourceId,
869
+ fields: operationGroup.map((candidate) => candidate.field),
870
+ }, error instanceof Error ? error.message : 'Unable to locate the source range.'));
871
+ }
872
+ }
873
+ }
874
+ edits.sort(compareEdits);
875
+ const protectedKeys = new Set(semantic.protectedChanges.map((operation) => `${operation.sourceId}\0${operation.field}\0${operation.rule}`));
876
+ const protectedChanges = semanticEdits.flatMap(({ operations, edit }) => {
877
+ if (edit.rule !== 'upgrade-purchase-terminal'
878
+ && !operations.some((operation) => protectedKeys.has(`${operation.sourceId}\0${operation.field}\0${operation.rule}`)))
879
+ return [];
880
+ return [publicOperation(edit)];
881
+ }).sort((left, right) => left.path.localeCompare(right.path) || left.start - right.start || left.rule.localeCompare(right.rule));
882
+ const report = {
883
+ schemaVersion: 1,
884
+ fromVersion: manifest.fromVersion,
885
+ toVersion: targetVersion,
886
+ checkedAt: options.checkedAt ?? new Date().toISOString(),
887
+ operations: edits.map(publicOperation),
888
+ protectedChanges,
889
+ diagnostics,
890
+ valid: diagnostics.length === 0,
891
+ };
892
+ return {
893
+ reportPath: path.join(projectDir, stepContractMigrationReportPath(targetVersion)),
894
+ report,
895
+ edits,
896
+ };
897
+ };
898
+ export const checkStepContractV2Migration = async (options) => {
899
+ const { edits: _edits, ...result } = await buildProjectPlan(options);
900
+ return result;
901
+ };
902
+ const writeAtomicFile = async (target, bytes, mode = 0o644) => {
903
+ await mkdir(path.dirname(target), { recursive: true });
904
+ const temporary = `${target}.tmp-${process.pid}-${randomUUID()}`;
905
+ try {
906
+ await writeFile(temporary, bytes);
907
+ await chmod(temporary, mode);
908
+ await rename(temporary, target);
909
+ }
910
+ finally {
911
+ await rm(temporary, { force: true });
912
+ }
913
+ };
914
+ const writeReport = async (reportPath, report) => writeAtomicFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
915
+ const EXCLUDED_TREE_NAMES = new Set([
916
+ '.git',
917
+ STATE_DIRECTORY,
918
+ 'node_modules',
919
+ '.next',
920
+ 'out',
921
+ ]);
922
+ const snapshotTree = async (root) => {
923
+ const snapshot = new Map();
924
+ const visit = async (relativeDirectory) => {
925
+ const directory = relativeDirectory
926
+ ? absoluteProjectPath(root, relativeDirectory)
927
+ : root;
928
+ const entries = await readdir(directory, { withFileTypes: true });
929
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
930
+ if (!relativeDirectory && EXCLUDED_TREE_NAMES.has(entry.name))
931
+ continue;
932
+ const relativePath = relativeDirectory
933
+ ? `${relativeDirectory}/${entry.name}`
934
+ : entry.name;
935
+ if (!isSafeRelativePath(relativePath) || entry.isSymbolicLink()) {
936
+ throw new StepContractMigrationError(`Unsafe project entry: ${relativePath}`);
937
+ }
938
+ if (entry.isDirectory()) {
939
+ await visit(relativePath);
940
+ }
941
+ else if (entry.isFile()) {
942
+ const absolutePath = absoluteProjectPath(root, relativePath);
943
+ const [bytes, info] = await Promise.all([readFile(absolutePath), stat(absolutePath)]);
944
+ snapshot.set(relativePath, {
945
+ bytes,
946
+ hash: sha256(bytes),
947
+ mode: info.mode & 0o777,
948
+ });
949
+ }
950
+ else {
951
+ throw new StepContractMigrationError(`Unsupported project entry: ${relativePath}`);
952
+ }
953
+ }
954
+ };
955
+ await visit('');
956
+ return snapshot;
957
+ };
958
+ const snapshotsEqual = (left, right) => {
959
+ if (left.size !== right.size)
960
+ return false;
961
+ for (const [relativePath, value] of left) {
962
+ const candidate = right.get(relativePath);
963
+ if (!candidate || candidate.hash !== value.hash || candidate.mode !== value.mode)
964
+ return false;
965
+ }
966
+ return true;
967
+ };
968
+ const applySourceEdits = async (candidateDir, edits) => {
969
+ const byPath = new Map();
970
+ for (const edit of edits) {
971
+ const values = byPath.get(edit.path) ?? [];
972
+ values.push(edit);
973
+ byPath.set(edit.path, values);
974
+ }
975
+ for (const [relativePath, fileEdits] of [...byPath].sort(([left], [right]) => left.localeCompare(right))) {
976
+ let source = await readFile(absoluteProjectPath(candidateDir, relativePath), 'utf8');
977
+ const descending = [...fileEdits].sort((left, right) => right.start - left.start);
978
+ let previousStart = source.length + 1;
979
+ for (const edit of descending) {
980
+ if (edit.end > previousStart) {
981
+ throw new StepContractMigrationError(`Overlapping migration ranges in ${relativePath}`);
982
+ }
983
+ const before = source.slice(edit.start, edit.end);
984
+ if (sha256(before) !== edit.beforeHash || sha256(edit.replacement) !== edit.afterHash) {
985
+ throw new StepContractMigrationError(`Stale migration range in ${relativePath}`);
986
+ }
987
+ source = `${source.slice(0, edit.start)}${edit.replacement}${source.slice(edit.end)}`;
988
+ previousStart = edit.start;
989
+ }
990
+ const mode = (await stat(absoluteProjectPath(candidateDir, relativePath))).mode & 0o777;
991
+ await writeAtomicFile(absoluteProjectPath(candidateDir, relativePath), source, mode);
992
+ }
993
+ };
994
+ const stateFromSnapshot = (snapshot) => snapshot
995
+ ? { exists: true, hash: snapshot.hash, mode: snapshot.mode }
996
+ : { exists: false, hash: null, mode: null };
997
+ const changedOperations = (before, after) => {
998
+ const paths = [...new Set([...before.keys(), ...after.keys()])].sort((left, right) => {
999
+ if (left === MANIFEST_PATH)
1000
+ return 1;
1001
+ if (right === MANIFEST_PATH)
1002
+ return -1;
1003
+ return left.localeCompare(right);
1004
+ });
1005
+ return paths.flatMap((relativePath) => {
1006
+ const previous = before.get(relativePath);
1007
+ const next = after.get(relativePath);
1008
+ if (previous && next && previous.hash === next.hash && previous.mode === next.mode)
1009
+ return [];
1010
+ return [{
1011
+ path: relativePath,
1012
+ before: stateFromSnapshot(previous),
1013
+ after: stateFromSnapshot(next),
1014
+ stagedPath: next ? `staged/${relativePath}` : null,
1015
+ backupPath: previous ? `${BACKUPS_DIRECTORY}/PENDING/${relativePath}` : null,
1016
+ applied: false,
1017
+ }];
1018
+ });
1019
+ };
1020
+ const writeJournal = async (transactionRoot, journal) => writeAtomicFile(path.join(transactionRoot, 'journal.json'), `${JSON.stringify(journal, null, 2)}\n`);
1021
+ const stageJournal = async (projectDir, candidate, baseline, targetVersion, faultInjector) => {
1022
+ const transactionId = randomUUID();
1023
+ const transactionRoot = path.join(projectDir, transactionDirectory(targetVersion));
1024
+ await mkdir(transactionRoot, { recursive: false });
1025
+ try {
1026
+ const operations = changedOperations(baseline, candidate).map((operation) => ({
1027
+ ...operation,
1028
+ backupPath: operation.backupPath?.replace('/PENDING/', `/${transactionId}/`) ?? null,
1029
+ }));
1030
+ if (operations.length === 0 || operations.at(-1)?.path !== MANIFEST_PATH) {
1031
+ throw new StepContractMigrationError('The complete candidate did not stage the version manifest last');
1032
+ }
1033
+ const journal = {
1034
+ schemaVersion: 1,
1035
+ transactionId,
1036
+ ownerPid: process.pid,
1037
+ targetRoot: projectDir,
1038
+ operations,
1039
+ };
1040
+ for (const operation of operations) {
1041
+ await faultInjector?.('before-stage-file', { path: operation.path });
1042
+ if (operation.before.exists && operation.backupPath) {
1043
+ const before = baseline.get(operation.path);
1044
+ if (!before)
1045
+ throw new StepContractMigrationError(`Missing baseline for ${operation.path}`);
1046
+ await writeAtomicFile(absoluteProjectPath(projectDir, operation.backupPath), before.bytes, before.mode);
1047
+ }
1048
+ if (operation.after.exists && operation.stagedPath) {
1049
+ const after = candidate.get(operation.path);
1050
+ if (!after)
1051
+ throw new StepContractMigrationError(`Missing candidate for ${operation.path}`);
1052
+ await writeAtomicFile(path.join(transactionRoot, ...operation.stagedPath.split('/')), after.bytes, after.mode);
1053
+ }
1054
+ await faultInjector?.('after-stage-file', { path: operation.path });
1055
+ }
1056
+ await writeJournal(transactionRoot, journal);
1057
+ return { transactionRoot, journal };
1058
+ }
1059
+ catch (error) {
1060
+ await rm(transactionRoot, { recursive: true, force: true });
1061
+ throw error;
1062
+ }
1063
+ };
1064
+ const currentFileState = async (projectDir, relativePath) => {
1065
+ try {
1066
+ const absolutePath = absoluteProjectPath(projectDir, relativePath);
1067
+ const info = await lstat(absolutePath);
1068
+ if (!info.isFile() || info.isSymbolicLink()) {
1069
+ throw new StepContractMigrationError(`Unsafe migration target: ${relativePath}`);
1070
+ }
1071
+ const bytes = await readFile(absolutePath);
1072
+ return { exists: true, hash: sha256(bytes), mode: info.mode & 0o777 };
1073
+ }
1074
+ catch (error) {
1075
+ if (error.code === 'ENOENT') {
1076
+ return { exists: false, hash: null, mode: null };
1077
+ }
1078
+ throw error;
1079
+ }
1080
+ };
1081
+ const statesEqual = (left, right) => left.exists === right.exists && left.hash === right.hash && left.mode === right.mode;
1082
+ const commitOperation = async (projectDir, transactionRoot, operation) => {
1083
+ const current = await currentFileState(projectDir, operation.path);
1084
+ if (!statesEqual(current, operation.before)) {
1085
+ throw new StepContractMigrationError(`${operation.path} changed while the migration candidate was prepared`);
1086
+ }
1087
+ const target = absoluteProjectPath(projectDir, operation.path);
1088
+ if (!operation.after.exists) {
1089
+ await rm(target);
1090
+ return;
1091
+ }
1092
+ if (!operation.stagedPath || operation.after.mode === null) {
1093
+ throw new StepContractMigrationError(`Missing staged state for ${operation.path}`);
1094
+ }
1095
+ const staged = path.join(transactionRoot, ...operation.stagedPath.split('/'));
1096
+ const bytes = await readFile(staged);
1097
+ if (sha256(bytes) !== operation.after.hash) {
1098
+ throw new StepContractMigrationError(`Corrupt staged state for ${operation.path}`);
1099
+ }
1100
+ await writeAtomicFile(target, bytes, operation.after.mode);
1101
+ };
1102
+ const restoreOperation = async (projectDir, operation) => {
1103
+ const current = await currentFileState(projectDir, operation.path);
1104
+ if (statesEqual(current, operation.before))
1105
+ return;
1106
+ if (!statesEqual(current, operation.after)) {
1107
+ throw new StepContractMigrationError(`Cannot restore ${operation.path} because it changed outside the migration`);
1108
+ }
1109
+ const target = absoluteProjectPath(projectDir, operation.path);
1110
+ if (!operation.before.exists) {
1111
+ await rm(target, { force: true });
1112
+ return;
1113
+ }
1114
+ if (!operation.backupPath || operation.before.mode === null) {
1115
+ throw new StepContractMigrationError(`Missing backup state for ${operation.path}`);
1116
+ }
1117
+ const backup = absoluteProjectPath(projectDir, operation.backupPath);
1118
+ const bytes = await readFile(backup);
1119
+ if (sha256(bytes) !== operation.before.hash) {
1120
+ throw new StepContractMigrationError(`Corrupt backup state for ${operation.path}`);
1121
+ }
1122
+ await writeAtomicFile(target, bytes, operation.before.mode);
1123
+ };
1124
+ const restoreJournal = async (transactionRoot, journal) => {
1125
+ for (const operation of [...journal.operations].reverse()) {
1126
+ await restoreOperation(journal.targetRoot, operation);
1127
+ }
1128
+ await rm(transactionRoot, { recursive: true, force: true });
1129
+ };
1130
+ const validationFailureDiagnostic = (received, reason) => migrationDiagnostic('FG-CLIENT-001', {
1131
+ file: null,
1132
+ expected: 'a complete target-version candidate that passes authoritative validation',
1133
+ received,
1134
+ reason,
1135
+ repair: 'Resolve the reported candidate failure and rerun the migration.',
1136
+ });
1137
+ const reportWithFailure = (report, diagnostics) => ({
1138
+ ...report,
1139
+ diagnostics: [...report.diagnostics, ...diagnostics],
1140
+ valid: false,
1141
+ });
1142
+ const invokeFault = async (options, point, context = {}) => options.faultInjector?.(point, context);
1143
+ export const applyStepContractV2Migration = async (options) => {
1144
+ const projectDir = path.resolve(options.projectDir);
1145
+ const targetVersion = options.targetVersion ?? 2;
1146
+ const plan = await buildProjectPlan({ ...options, projectDir });
1147
+ let report = plan.report;
1148
+ await writeReport(plan.reportPath, report);
1149
+ if (!report.valid) {
1150
+ throw new StepContractMigrationError('Step-contract migration check is blocked', report);
1151
+ }
1152
+ let shadowRoot = null;
1153
+ let preparedJournal = null;
1154
+ try {
1155
+ await invokeFault(options, 'after-report');
1156
+ const baseline = await snapshotTree(projectDir);
1157
+ shadowRoot = await mkdtemp(path.join(path.dirname(projectDir), `.fgrove-step-contract-v${targetVersion}-shadow-`));
1158
+ const candidateDir = path.join(shadowRoot, 'project');
1159
+ await cp(projectDir, candidateDir, {
1160
+ recursive: true,
1161
+ preserveTimestamps: true,
1162
+ filter: (source) => {
1163
+ const relativePath = path.relative(projectDir, source);
1164
+ if (!relativePath)
1165
+ return true;
1166
+ const topLevel = relativePath.split(path.sep)[0];
1167
+ return !EXCLUDED_TREE_NAMES.has(topLevel);
1168
+ },
1169
+ });
1170
+ await invokeFault(options, 'after-shadow-created');
1171
+ await applySourceEdits(candidateDir, plan.edits);
1172
+ await invokeFault(options, 'after-source-edits');
1173
+ await options.installManagedCandidate({ candidateDir, report });
1174
+ await invokeFault(options, 'after-managed-install');
1175
+ const shadowValidation = await options.validateCandidate({
1176
+ candidateDir,
1177
+ phase: 'shadow',
1178
+ authoringContractVersion: targetVersion,
1179
+ });
1180
+ if (!shadowValidation.valid || shadowValidation.diagnostics.length > 0) {
1181
+ report = reportWithFailure(report, shadowValidation.diagnostics.length > 0
1182
+ ? shadowValidation.diagnostics
1183
+ : [validationFailureDiagnostic('invalid shadow candidate', 'Shadow validation blocked.')]);
1184
+ await writeReport(plan.reportPath, report);
1185
+ throw new StepContractMigrationError('Shadow candidate validation failed', report);
1186
+ }
1187
+ await invokeFault(options, 'after-shadow-validation');
1188
+ const current = await snapshotTree(projectDir);
1189
+ if (!snapshotsEqual(current, baseline)) {
1190
+ throw new StepContractMigrationError('A project file changed while the migration candidate was prepared', report);
1191
+ }
1192
+ const candidate = await snapshotTree(candidateDir);
1193
+ preparedJournal = await stageJournal(projectDir, candidate, baseline, targetVersion, (point, context) => invokeFault(options, point, context));
1194
+ await invokeFault(options, 'after-journal-staged');
1195
+ for (const operation of preparedJournal.journal.operations) {
1196
+ await invokeFault(options, 'before-commit-file', { path: operation.path });
1197
+ await commitOperation(projectDir, preparedJournal.transactionRoot, operation);
1198
+ operation.applied = true;
1199
+ await writeJournal(preparedJournal.transactionRoot, preparedJournal.journal);
1200
+ await invokeFault(options, 'after-commit-file', { path: operation.path });
1201
+ }
1202
+ await invokeFault(options, 'after-manifest-commit', { path: MANIFEST_PATH });
1203
+ await invokeFault(options, 'before-committed-validation');
1204
+ const committedValidation = await options.validateCandidate({
1205
+ candidateDir: projectDir,
1206
+ phase: 'committed',
1207
+ authoringContractVersion: targetVersion,
1208
+ });
1209
+ if (!committedValidation.valid || committedValidation.diagnostics.length > 0) {
1210
+ report = reportWithFailure(report, committedValidation.diagnostics.length > 0
1211
+ ? committedValidation.diagnostics
1212
+ : [validationFailureDiagnostic('invalid committed candidate', 'Committed validation blocked.')]);
1213
+ await writeReport(plan.reportPath, report);
1214
+ throw new StepContractMigrationError('Committed candidate validation failed', report);
1215
+ }
1216
+ await invokeFault(options, 'after-committed-validation');
1217
+ await rm(preparedJournal.transactionRoot, { recursive: true, force: true });
1218
+ preparedJournal = null;
1219
+ return { reportPath: plan.reportPath, report, applied: true };
1220
+ }
1221
+ catch (error) {
1222
+ if (!(error instanceof StepContractMigrationError) || !error.report) {
1223
+ report = reportWithFailure(report, [validationFailureDiagnostic(error instanceof Error ? error.message : String(error), 'The migration did not complete and its committed changes were restored.')]);
1224
+ await writeReport(plan.reportPath, report);
1225
+ }
1226
+ if (preparedJournal) {
1227
+ preparedJournal.journal.ownerPid = null;
1228
+ await writeJournal(preparedJournal.transactionRoot, preparedJournal.journal);
1229
+ await restoreJournal(preparedJournal.transactionRoot, preparedJournal.journal);
1230
+ preparedJournal = null;
1231
+ }
1232
+ throw error;
1233
+ }
1234
+ finally {
1235
+ if (shadowRoot)
1236
+ await rm(shadowRoot, { recursive: true, force: true });
1237
+ }
1238
+ };
1239
+ const parseJournal = (value, projectDir) => {
1240
+ if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.operations)) {
1241
+ throw new StepContractMigrationError('Migration journal is corrupt');
1242
+ }
1243
+ if (typeof value.transactionId !== 'string'
1244
+ || !/^[a-f0-9-]{16,}$/.test(value.transactionId)
1245
+ || value.targetRoot !== projectDir
1246
+ || !(value.ownerPid === null || Number.isSafeInteger(value.ownerPid)))
1247
+ throw new StepContractMigrationError('Migration journal identity is corrupt');
1248
+ const operations = value.operations.map((raw) => {
1249
+ if (!isRecord(raw) || typeof raw.path !== 'string' || !isSafeRelativePath(raw.path)) {
1250
+ throw new StepContractMigrationError('Migration journal operation is corrupt');
1251
+ }
1252
+ const parseState = (state) => {
1253
+ if (!isRecord(state) || typeof state.exists !== 'boolean') {
1254
+ throw new StepContractMigrationError('Migration journal file state is corrupt');
1255
+ }
1256
+ if (state.exists) {
1257
+ if (typeof state.hash !== 'string'
1258
+ || !/^[a-f0-9]{64}$/.test(state.hash)
1259
+ || !Number.isInteger(state.mode))
1260
+ throw new StepContractMigrationError('Migration journal file state is corrupt');
1261
+ return { exists: true, hash: state.hash, mode: state.mode };
1262
+ }
1263
+ if (state.hash !== null || state.mode !== null) {
1264
+ throw new StepContractMigrationError('Migration journal absent state is corrupt');
1265
+ }
1266
+ return { exists: false, hash: null, mode: null };
1267
+ };
1268
+ const before = parseState(raw.before);
1269
+ const after = parseState(raw.after);
1270
+ if (typeof raw.applied !== 'boolean'
1271
+ || !(raw.stagedPath === null || (typeof raw.stagedPath === 'string'
1272
+ && isSafeRelativePath(raw.stagedPath)
1273
+ && raw.stagedPath.startsWith('staged/')))
1274
+ || !(raw.backupPath === null || (typeof raw.backupPath === 'string'
1275
+ && isSafeRelativePath(raw.backupPath)
1276
+ && raw.backupPath.startsWith(`${BACKUPS_DIRECTORY}/`)))
1277
+ || after.exists !== (raw.stagedPath !== null)
1278
+ || before.exists !== (raw.backupPath !== null))
1279
+ throw new StepContractMigrationError('Migration journal operation semantics are corrupt');
1280
+ return {
1281
+ path: raw.path,
1282
+ before,
1283
+ after,
1284
+ stagedPath: raw.stagedPath,
1285
+ backupPath: raw.backupPath,
1286
+ applied: raw.applied,
1287
+ };
1288
+ });
1289
+ return {
1290
+ schemaVersion: 1,
1291
+ transactionId: value.transactionId,
1292
+ ownerPid: value.ownerPid,
1293
+ targetRoot: projectDir,
1294
+ operations,
1295
+ };
1296
+ };
1297
+ const processIsAlive = (pid) => {
1298
+ try {
1299
+ process.kill(pid, 0);
1300
+ return true;
1301
+ }
1302
+ catch (error) {
1303
+ return error.code === 'EPERM';
1304
+ }
1305
+ };
1306
+ export const recoverStepContractV2Migration = async (projectDirectory, targetVersion = 2) => {
1307
+ const projectDir = path.resolve(projectDirectory);
1308
+ let restored = false;
1309
+ const versions = targetVersion === 3 ? [2, 3] : [2];
1310
+ for (const version of versions) {
1311
+ const transactionRoot = path.join(projectDir, transactionDirectory(version));
1312
+ if (!await pathExists(transactionRoot))
1313
+ continue;
1314
+ const raw = JSON.parse(await readFile(path.join(transactionRoot, 'journal.json'), 'utf8'));
1315
+ const journal = parseJournal(raw, projectDir);
1316
+ if (journal.ownerPid !== null && processIsAlive(journal.ownerPid)) {
1317
+ throw new StepContractMigrationError(`Migration transaction is still owned by process ${journal.ownerPid}`);
1318
+ }
1319
+ await restoreJournal(transactionRoot, journal);
1320
+ restored = true;
1321
+ }
1322
+ return restored ? 'restored' : 'none';
1323
+ };