@sun-asterisk/sungen 3.2.24 → 3.2.25

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 (305) hide show
  1. package/dist/capabilities/context-router.d.ts.map +1 -1
  2. package/dist/capabilities/context-router.js +15 -7
  3. package/dist/capabilities/context-router.js.map +1 -1
  4. package/dist/cli/commands/capability.d.ts.map +1 -1
  5. package/dist/cli/commands/capability.js +6 -48
  6. package/dist/cli/commands/capability.js.map +1 -1
  7. package/dist/cli/commands/changed.d.ts +14 -0
  8. package/dist/cli/commands/changed.d.ts.map +1 -0
  9. package/dist/cli/commands/changed.js +128 -0
  10. package/dist/cli/commands/changed.js.map +1 -0
  11. package/dist/cli/commands/delivery.d.ts.map +1 -1
  12. package/dist/cli/commands/delivery.js +25 -1
  13. package/dist/cli/commands/delivery.js.map +1 -1
  14. package/dist/cli/commands/generate.js +1 -1
  15. package/dist/cli/commands/generate.js.map +1 -1
  16. package/dist/cli/commands/results-merge.d.ts +19 -0
  17. package/dist/cli/commands/results-merge.d.ts.map +1 -0
  18. package/dist/cli/commands/results-merge.js +191 -0
  19. package/dist/cli/commands/results-merge.js.map +1 -0
  20. package/dist/cli/commands/update.d.ts.map +1 -1
  21. package/dist/cli/commands/update.js +100 -12
  22. package/dist/cli/commands/update.js.map +1 -1
  23. package/dist/cli/index.js +4 -0
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/exporters/api-catalog-loader.d.ts.map +1 -1
  26. package/dist/exporters/api-catalog-loader.js +10 -3
  27. package/dist/exporters/api-catalog-loader.js.map +1 -1
  28. package/dist/exporters/api-testcase-formatter.d.ts +9 -0
  29. package/dist/exporters/api-testcase-formatter.d.ts.map +1 -1
  30. package/dist/exporters/api-testcase-formatter.js +55 -3
  31. package/dist/exporters/api-testcase-formatter.js.map +1 -1
  32. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  33. package/dist/exporters/csv-exporter.js +10 -1
  34. package/dist/exporters/csv-exporter.js.map +1 -1
  35. package/dist/exporters/json-exporter.d.ts.map +1 -1
  36. package/dist/exporters/json-exporter.js +6 -1
  37. package/dist/exporters/json-exporter.js.map +1 -1
  38. package/dist/exporters/matrix/build.d.ts.map +1 -1
  39. package/dist/exporters/matrix/build.js +10 -0
  40. package/dist/exporters/matrix/build.js.map +1 -1
  41. package/dist/exporters/matrix/export.js +1 -1
  42. package/dist/exporters/matrix/export.js.map +1 -1
  43. package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
  44. package/dist/exporters/matrix/render-csv.js +5 -1
  45. package/dist/exporters/matrix/render-csv.js.map +1 -1
  46. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  47. package/dist/exporters/matrix/render-xlsx.js +4 -1
  48. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  49. package/dist/exporters/matrix/types.d.ts +7 -0
  50. package/dist/exporters/matrix/types.d.ts.map +1 -1
  51. package/dist/exporters/matrix/types.js.map +1 -1
  52. package/dist/exporters/playwright-report-parser.d.ts +17 -2
  53. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  54. package/dist/exporters/playwright-report-parser.js +68 -8
  55. package/dist/exporters/playwright-report-parser.js.map +1 -1
  56. package/dist/exporters/results-merge.d.ts +46 -0
  57. package/dist/exporters/results-merge.d.ts.map +1 -0
  58. package/dist/exporters/results-merge.js +148 -0
  59. package/dist/exporters/results-merge.js.map +1 -0
  60. package/dist/exporters/selector-key-resolver.d.ts +4 -0
  61. package/dist/exporters/selector-key-resolver.d.ts.map +1 -1
  62. package/dist/exporters/selector-key-resolver.js +101 -1
  63. package/dist/exporters/selector-key-resolver.js.map +1 -1
  64. package/dist/exporters/types.d.ts +8 -1
  65. package/dist/exporters/types.d.ts.map +1 -1
  66. package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
  67. package/dist/exporters/xlsx-report-builder.js +5 -4
  68. package/dist/exporters/xlsx-report-builder.js.map +1 -1
  69. package/dist/generators/gherkin-parser/index.d.ts +2 -0
  70. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  71. package/dist/generators/gherkin-parser/index.js +52 -1
  72. package/dist/generators/gherkin-parser/index.js.map +1 -1
  73. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +20 -10
  74. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  75. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts +2 -11
  76. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts.map +1 -1
  77. package/dist/generators/test-generator/adapters/appium/appium-adapter.js.map +1 -1
  78. package/dist/generators/test-generator/adapters/appium/templates/imports.hbs +3 -0
  79. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/date-picker-unsupported.hbs +4 -0
  80. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/lifecycle-missing-app-id.hbs +5 -0
  81. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/matches-format-assertion.hbs +15 -0
  82. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +5 -2
  83. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +185 -26
  84. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +2 -11
  85. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
  86. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
  87. package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  88. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/select-action.hbs +17 -1
  89. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/attribute-assertion.hbs +6 -1
  90. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/have-value-assertion.hbs +18 -1
  91. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-filter-assertion.hbs +5 -1
  92. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/matches-format-assertion.hbs +5 -0
  93. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +4 -2
  94. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-for-element-with-text.hbs +4 -1
  95. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-text.hbs +1 -1
  96. package/dist/generators/test-generator/code-generator.d.ts +6 -0
  97. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  98. package/dist/generators/test-generator/code-generator.js +80 -7
  99. package/dist/generators/test-generator/code-generator.js.map +1 -1
  100. package/dist/generators/test-generator/diagnostics.d.ts +60 -6
  101. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  102. package/dist/generators/test-generator/diagnostics.js +59 -5
  103. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  104. package/dist/generators/test-generator/patterns/expect-patterns.d.ts +7 -0
  105. package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -1
  106. package/dist/generators/test-generator/patterns/expect-patterns.js +36 -2
  107. package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -1
  108. package/dist/generators/test-generator/patterns/index.d.ts +19 -12
  109. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  110. package/dist/generators/test-generator/patterns/index.js +58 -38
  111. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  112. package/dist/generators/test-generator/patterns/types.d.ts +23 -0
  113. package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
  114. package/dist/generators/test-generator/patterns/unsupported-step-code.d.ts +18 -0
  115. package/dist/generators/test-generator/patterns/unsupported-step-code.d.ts.map +1 -0
  116. package/dist/generators/test-generator/patterns/unsupported-step-code.js +23 -0
  117. package/dist/generators/test-generator/patterns/unsupported-step-code.js.map +1 -0
  118. package/dist/generators/test-generator/recipe-expander.js +9 -1
  119. package/dist/generators/test-generator/recipe-expander.js.map +1 -1
  120. package/dist/generators/test-generator/reset-tier.d.ts +68 -0
  121. package/dist/generators/test-generator/reset-tier.d.ts.map +1 -0
  122. package/dist/generators/test-generator/reset-tier.js +94 -0
  123. package/dist/generators/test-generator/reset-tier.js.map +1 -0
  124. package/dist/generators/test-generator/step-mapper.d.ts +39 -0
  125. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  126. package/dist/generators/test-generator/step-mapper.js +144 -5
  127. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  128. package/dist/generators/test-generator/template-engine.d.ts +2 -10
  129. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  130. package/dist/generators/test-generator/template-engine.js +16 -2
  131. package/dist/generators/test-generator/template-engine.js.map +1 -1
  132. package/dist/generators/test-generator/utils/format-pattern.d.ts +35 -0
  133. package/dist/generators/test-generator/utils/format-pattern.d.ts.map +1 -0
  134. package/dist/generators/test-generator/utils/format-pattern.js +82 -0
  135. package/dist/generators/test-generator/utils/format-pattern.js.map +1 -0
  136. package/dist/generators/test-generator/utils/js-literal-scanner.d.ts +42 -0
  137. package/dist/generators/test-generator/utils/js-literal-scanner.d.ts.map +1 -0
  138. package/dist/generators/test-generator/utils/js-literal-scanner.js +142 -0
  139. package/dist/generators/test-generator/utils/js-literal-scanner.js.map +1 -0
  140. package/dist/generators/test-generator/utils/mask-refs-for-matching.d.ts +18 -0
  141. package/dist/generators/test-generator/utils/mask-refs-for-matching.d.ts.map +1 -0
  142. package/dist/generators/test-generator/utils/mask-refs-for-matching.js +25 -0
  143. package/dist/generators/test-generator/utils/mask-refs-for-matching.js.map +1 -0
  144. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +9 -1
  145. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  146. package/dist/generators/test-generator/utils/runtime-data-transformer.js +124 -63
  147. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  148. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  149. package/dist/generators/test-generator/utils/selector-resolver.js +20 -1
  150. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  151. package/dist/generators/test-generator/utils/spec-syntax-check.d.ts +14 -0
  152. package/dist/generators/test-generator/utils/spec-syntax-check.d.ts.map +1 -0
  153. package/dist/generators/test-generator/utils/spec-syntax-check.js +26 -0
  154. package/dist/generators/test-generator/utils/spec-syntax-check.js.map +1 -0
  155. package/dist/harness/annotation-overrides.d.ts +0 -12
  156. package/dist/harness/annotation-overrides.d.ts.map +1 -1
  157. package/dist/harness/annotation-overrides.js +9 -5
  158. package/dist/harness/annotation-overrides.js.map +1 -1
  159. package/dist/harness/audit.js +4 -4
  160. package/dist/harness/audit.js.map +1 -1
  161. package/dist/harness/capability-plan.d.ts +2 -1
  162. package/dist/harness/capability-plan.d.ts.map +1 -1
  163. package/dist/harness/capability-plan.js +8 -3
  164. package/dist/harness/capability-plan.js.map +1 -1
  165. package/dist/harness/capability.d.ts +11 -0
  166. package/dist/harness/capability.d.ts.map +1 -1
  167. package/dist/harness/capability.js +8 -0
  168. package/dist/harness/capability.js.map +1 -1
  169. package/dist/harness/catalog/drivers.yaml +5 -0
  170. package/dist/harness/parse.d.ts.map +1 -1
  171. package/dist/harness/parse.js +5 -1
  172. package/dist/harness/parse.js.map +1 -1
  173. package/dist/harness/run-manifest.d.ts +58 -0
  174. package/dist/harness/run-manifest.d.ts.map +1 -0
  175. package/dist/harness/run-manifest.js +264 -0
  176. package/dist/harness/run-manifest.js.map +1 -0
  177. package/dist/harness/viewpoint-ledger.d.ts +1 -1
  178. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  179. package/dist/harness/viewpoint-ledger.js +25 -4
  180. package/dist/harness/viewpoint-ledger.js.map +1 -1
  181. package/dist/index.d.ts +1 -0
  182. package/dist/index.d.ts.map +1 -1
  183. package/dist/index.js +5 -1
  184. package/dist/index.js.map +1 -1
  185. package/dist/orchestrator/lockstep-version.d.ts +22 -0
  186. package/dist/orchestrator/lockstep-version.d.ts.map +1 -0
  187. package/dist/orchestrator/lockstep-version.js +58 -0
  188. package/dist/orchestrator/lockstep-version.js.map +1 -0
  189. package/dist/orchestrator/project-deps-sync.d.ts +39 -0
  190. package/dist/orchestrator/project-deps-sync.d.ts.map +1 -0
  191. package/dist/orchestrator/project-deps-sync.js +175 -0
  192. package/dist/orchestrator/project-deps-sync.js.map +1 -0
  193. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  194. package/dist/orchestrator/project-initializer.js +5 -2
  195. package/dist/orchestrator/project-initializer.js.map +1 -1
  196. package/dist/orchestrator/templates/ai-src/commands/run-test.md +109 -0
  197. package/dist/orchestrator/templates/ai-src/config/claude.md +1 -1
  198. package/dist/orchestrator/templates/ai-src/config/copilot.md +1 -1
  199. package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +49 -0
  200. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +14 -3
  201. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +110 -6
  202. package/dist/orchestrator/templates/ai-src/skills/sungen-locale/SKILL.md +1 -1
  203. package/dist/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +8 -0
  204. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +1 -1
  205. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +9 -0
  206. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +15 -0
  207. package/dist/orchestrator/templates/env.appium.example +10 -0
  208. package/dist/orchestrator/templates/specs-api.d.ts +4 -2
  209. package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
  210. package/dist/orchestrator/templates/specs-api.js +233 -10
  211. package/dist/orchestrator/templates/specs-api.js.map +1 -1
  212. package/dist/orchestrator/templates/specs-api.ts +214 -11
  213. package/dist/orchestrator/templates/specs-regex-escape.d.ts +13 -0
  214. package/dist/orchestrator/templates/specs-regex-escape.d.ts.map +1 -0
  215. package/dist/orchestrator/templates/specs-regex-escape.js +19 -0
  216. package/dist/orchestrator/templates/specs-regex-escape.js.map +1 -0
  217. package/dist/orchestrator/templates/specs-regex-escape.ts +16 -0
  218. package/dist/orchestrator/templates/specs-test-data.ts +53 -6
  219. package/dist/orchestrator/templates/wdio.conf.ts +28 -9
  220. package/package.json +8 -6
  221. package/src/capabilities/context-router.ts +13 -5
  222. package/src/cli/commands/capability.ts +4 -38
  223. package/src/cli/commands/changed.ts +104 -0
  224. package/src/cli/commands/delivery.ts +27 -2
  225. package/src/cli/commands/generate.ts +1 -1
  226. package/src/cli/commands/results-merge.ts +153 -0
  227. package/src/cli/commands/update.ts +72 -13
  228. package/src/cli/index.ts +4 -0
  229. package/src/exporters/api-catalog-loader.ts +10 -2
  230. package/src/exporters/api-testcase-formatter.ts +59 -3
  231. package/src/exporters/csv-exporter.ts +11 -2
  232. package/src/exporters/json-exporter.ts +7 -2
  233. package/src/exporters/matrix/build.ts +9 -0
  234. package/src/exporters/matrix/export.ts +1 -1
  235. package/src/exporters/matrix/render-csv.ts +5 -1
  236. package/src/exporters/matrix/render-xlsx.ts +4 -1
  237. package/src/exporters/matrix/types.ts +7 -0
  238. package/src/exporters/playwright-report-parser.ts +68 -9
  239. package/src/exporters/results-merge.ts +150 -0
  240. package/src/exporters/selector-key-resolver.ts +81 -1
  241. package/src/exporters/types.ts +8 -1
  242. package/src/exporters/xlsx-report-builder.ts +5 -4
  243. package/src/generators/gherkin-parser/index.ts +55 -1
  244. package/src/generators/test-generator/adapters/adapter-interface.ts +23 -1
  245. package/src/generators/test-generator/adapters/appium/appium-adapter.ts +2 -2
  246. package/src/generators/test-generator/adapters/appium/templates/imports.hbs +3 -0
  247. package/src/generators/test-generator/adapters/appium/templates/steps/actions/date-picker-unsupported.hbs +4 -0
  248. package/src/generators/test-generator/adapters/appium/templates/steps/actions/lifecycle-missing-app-id.hbs +5 -0
  249. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/matches-format-assertion.hbs +15 -0
  250. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +5 -2
  251. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +185 -26
  252. package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +2 -2
  253. package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  254. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/select-action.hbs +17 -1
  255. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/attribute-assertion.hbs +6 -1
  256. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/have-value-assertion.hbs +18 -1
  257. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-filter-assertion.hbs +5 -1
  258. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/matches-format-assertion.hbs +5 -0
  259. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +4 -2
  260. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-for-element-with-text.hbs +4 -1
  261. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-text.hbs +1 -1
  262. package/src/generators/test-generator/code-generator.ts +86 -7
  263. package/src/generators/test-generator/diagnostics.ts +60 -6
  264. package/src/generators/test-generator/patterns/expect-patterns.ts +37 -2
  265. package/src/generators/test-generator/patterns/index.ts +58 -33
  266. package/src/generators/test-generator/patterns/types.ts +36 -0
  267. package/src/generators/test-generator/patterns/unsupported-step-code.ts +19 -0
  268. package/src/generators/test-generator/recipe-expander.ts +9 -1
  269. package/src/generators/test-generator/reset-tier.ts +93 -0
  270. package/src/generators/test-generator/step-mapper.ts +149 -6
  271. package/src/generators/test-generator/template-engine.ts +18 -3
  272. package/src/generators/test-generator/utils/format-pattern.ts +79 -0
  273. package/src/generators/test-generator/utils/js-literal-scanner.ts +132 -0
  274. package/src/generators/test-generator/utils/mask-refs-for-matching.ts +21 -0
  275. package/src/generators/test-generator/utils/runtime-data-transformer.ts +126 -76
  276. package/src/generators/test-generator/utils/selector-resolver.ts +22 -1
  277. package/src/generators/test-generator/utils/spec-syntax-check.ts +22 -0
  278. package/src/harness/annotation-overrides.ts +10 -5
  279. package/src/harness/audit.ts +4 -4
  280. package/src/harness/capability-plan.ts +10 -5
  281. package/src/harness/capability.ts +16 -0
  282. package/src/harness/catalog/drivers.yaml +5 -0
  283. package/src/harness/parse.ts +5 -1
  284. package/src/harness/run-manifest.ts +254 -0
  285. package/src/harness/viewpoint-ledger.ts +26 -5
  286. package/src/index.ts +1 -0
  287. package/src/orchestrator/lockstep-version.ts +46 -0
  288. package/src/orchestrator/project-deps-sync.ts +175 -0
  289. package/src/orchestrator/project-initializer.ts +5 -2
  290. package/src/orchestrator/templates/ai-src/commands/run-test.md +109 -0
  291. package/src/orchestrator/templates/ai-src/config/claude.md +1 -1
  292. package/src/orchestrator/templates/ai-src/config/copilot.md +1 -1
  293. package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +49 -0
  294. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +14 -3
  295. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +110 -6
  296. package/src/orchestrator/templates/ai-src/skills/sungen-locale/SKILL.md +1 -1
  297. package/src/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +8 -0
  298. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +1 -1
  299. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +9 -0
  300. package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +15 -0
  301. package/src/orchestrator/templates/env.appium.example +10 -0
  302. package/src/orchestrator/templates/specs-api.ts +214 -11
  303. package/src/orchestrator/templates/specs-regex-escape.ts +16 -0
  304. package/src/orchestrator/templates/specs-test-data.ts +53 -6
  305. package/src/orchestrator/templates/wdio.conf.ts +28 -9
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Run-manifest — which scenarios does a QA edit actually oblige us to re-run?
3
+ *
4
+ * The problem it serves: the JSON reporter writes ONE file per target, named only for the unit +
5
+ * platform + env, so a subset re-run overwrites the full run's results. `sungen results merge`
6
+ * restores the lost half, but only if the caller knew to back the file up first — and only after
7
+ * the caller decided WHICH cases to run. This module answers the second question deterministically
8
+ * so `run-test --change-only` is a computation, not a guess.
9
+ *
10
+ * Sibling of `manifest.ts`, deliberately not an extension of it: that one hashes spec.md sections
11
+ * to decide what to REGENERATE, this one hashes the compiled inputs to decide what to RE-RUN. Same
12
+ * conventions (deterministic hashing, `.sungen/` storage, a plan object), different question.
13
+ *
14
+ * The bias is fixed and one-directional: when an edit cannot be attributed to specific scenarios,
15
+ * EVERY scenario is selected. Over-running costs minutes; under-running reports a scenario green
16
+ * that never re-ran after the change that broke it, which is the failure this whole family of
17
+ * guards exists to prevent.
18
+ *
19
+ * Storage: `.sungen/run-manifest/<slug>.json`.
20
+ */
21
+ import * as fs from 'fs';
22
+ import * as path from 'path';
23
+ import { createHash } from 'crypto';
24
+ import yaml from 'yaml';
25
+ import { featureFilesFor, reportSlug } from './unit-paths';
26
+ import { readTextFile } from './read-text';
27
+ import { GherkinParser } from '../generators/gherkin-parser';
28
+
29
+ const sha = (s: string): string => createHash('sha256').update(s).digest('hex').slice(0, 16);
30
+
31
+ /** Mirrors SelectorResolver.generateKey — a `[Ref]` becomes this before the YAML lookup. */
32
+ const refToKey = (label: string): string => label.normalize('NFC').toLowerCase().trim().replace(/\s+/g, ' ');
33
+
34
+ export interface RunManifest {
35
+ unit: string;
36
+ recordedAt: string;
37
+ /** scenario title → hash of its whole block (tags + steps) */
38
+ scenarios: Record<string, string>;
39
+ /** selectors.yaml key → hash of that entry */
40
+ selectorKeys: Record<string, string>;
41
+ /** test-data key → hash of that value */
42
+ dataKeys: Record<string, string>;
43
+ /** scenario title → the selector keys and data keys its steps touch */
44
+ deps: Record<string, { selectors: string[]; data: string[] }>;
45
+ /** hash of the Background block: a change there reaches every scenario */
46
+ background: string;
47
+ /**
48
+ * PROJECT-level inputs, `qa/<file>` → hash. These reach every scenario of every unit — app.yaml
49
+ * carries widget recipes and the behaviour profile, datasources.yaml the db/api endpoints,
50
+ * capabilities.yaml which drivers even load. Per-key attribution is not possible (a recipe change
51
+ * can alter any step's compilation), so a move here widens to the whole unit. They were the last
52
+ * known under-selection hole: edit app.yaml and the selection used to report "nothing changed".
53
+ */
54
+ globals: Record<string, string>;
55
+ }
56
+
57
+ export interface ChangeSelection {
58
+ unit: string;
59
+ /** scenario titles to re-run, in feature order */
60
+ scenarios: string[];
61
+ /** why each was selected — one short clause per title, for the operator */
62
+ reasons: Record<string, string>;
63
+ /** true when the whole unit was selected because the change could not be attributed */
64
+ all: boolean;
65
+ summary: { total: number; selected: number; firstRun: boolean };
66
+ }
67
+
68
+ const manifestPath = (cwd: string, unit: string): string =>
69
+ path.join(cwd, '.sungen', 'run-manifest', `${reportSlug(unit)}.json`);
70
+
71
+ /** Flatten a YAML mapping to `key → hash(value)`; a non-mapping file yields one whole-file entry. */
72
+ function hashTopLevel(file: string): Record<string, string> {
73
+ if (!fs.existsSync(file)) return {};
74
+ const text = readTextFile(file);
75
+ let doc: unknown;
76
+ try {
77
+ doc = yaml.parse(text);
78
+ } catch {
79
+ // Unparseable: attribute nothing, and let the caller widen to the whole unit rather than
80
+ // silently treating a broken file as "no change".
81
+ return { __unparseable__: sha(text) };
82
+ }
83
+ // An empty or comment-only file parses to null. That is an EMPTY mapping — nothing to attribute
84
+ // and nothing to widen on — not an unrecognised shape. (A test-data file holding only a comment
85
+ // made the first probe widen to all 18 scenarios.)
86
+ if (doc === null || doc === undefined) return {};
87
+ if (typeof doc !== 'object' || Array.isArray(doc)) return { __whole__: sha(text) };
88
+ const out: Record<string, string> = {};
89
+ for (const [k, v] of Object.entries(doc as Record<string, unknown>)) {
90
+ out[refToKey(k)] = sha(JSON.stringify(v ?? null));
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** Read the unit's compiled inputs and fingerprint them. */
96
+ export function buildRunManifest(cwd: string, unitDir: string, unit: string): RunManifest {
97
+ const parser = new GherkinParser();
98
+ const scenarios: Record<string, string> = {};
99
+ const deps: RunManifest['deps'] = {};
100
+ const bgParts: string[] = [];
101
+
102
+ for (const file of featureFilesFor(unitDir, unit)) {
103
+ const parsed = parser.parseFeatureFile(file);
104
+ if (parsed.background?.steps?.length) {
105
+ bgParts.push(parsed.background.steps.map((s) => `${s.keyword} ${s.text}`).join('\n'));
106
+ }
107
+ for (const sc of parsed.scenarios) {
108
+ const block = [...(sc.tags || []), ...sc.steps.map((s) => `${s.keyword} ${s.text}`)].join('\n');
109
+ scenarios[sc.name] = sha(block);
110
+ const sel = new Set<string>();
111
+ const dat = new Set<string>();
112
+ for (const st of sc.steps) {
113
+ if (st.selectorRef) sel.add(refToKey(st.selectorRef));
114
+ if (st.parentRef) sel.add(refToKey(st.parentRef));
115
+ if (st.childRef) sel.add(refToKey(st.childRef));
116
+ if (st.dataRef) dat.add(refToKey(st.dataRef));
117
+ }
118
+ deps[sc.name] = { selectors: [...sel].sort(), data: [...dat].sort() };
119
+ }
120
+ }
121
+
122
+ const selDir = path.join(unitDir, 'selectors');
123
+ const dataDir = path.join(unitDir, 'test-data');
124
+ const collect = (dir: string): Record<string, string> => {
125
+ if (!fs.existsSync(dir)) return {};
126
+ const merged: Record<string, string> = {};
127
+ for (const f of fs.readdirSync(dir).filter((n) => /\.ya?ml$/.test(n)).sort()) {
128
+ Object.assign(merged, hashTopLevel(path.join(dir, f)));
129
+ }
130
+ return merged;
131
+ };
132
+
133
+ // Project-level config lives beside the units, not inside them.
134
+ const qaDir = path.join(cwd, 'qa');
135
+ const globals: Record<string, string> = {};
136
+ for (const name of ['app.yaml', 'datasources.yaml', 'capabilities.yaml']) {
137
+ const f = path.join(qaDir, name);
138
+ if (fs.existsSync(f)) globals[`qa/${name}`] = sha(readTextFile(f));
139
+ }
140
+
141
+ return {
142
+ unit,
143
+ recordedAt: new Date().toISOString(),
144
+ scenarios,
145
+ selectorKeys: collect(selDir),
146
+ dataKeys: collect(dataDir),
147
+ deps,
148
+ background: sha(bgParts.join('\n---\n')),
149
+ globals,
150
+ };
151
+ }
152
+
153
+ export function readRunManifest(cwd: string, unit: string): RunManifest | null {
154
+ const p = manifestPath(cwd, unit);
155
+ // Absent baseline is the FIRST-RUN case, not an error — selectChanged widens to the whole unit.
156
+ if (!fs.existsSync(p)) return null;
157
+ try { return JSON.parse(readTextFile(p)) as RunManifest; } catch { return null; }
158
+ }
159
+
160
+ export function writeRunManifest(cwd: string, unit: string, m: RunManifest): string {
161
+ const p = manifestPath(cwd, unit);
162
+ fs.mkdirSync(path.dirname(p), { recursive: true });
163
+ fs.writeFileSync(p, JSON.stringify(m, null, 2));
164
+ return p;
165
+ }
166
+
167
+ /** Keys whose hash differs, was added, or disappeared. */
168
+ function movedKeys(before: Record<string, string>, after: Record<string, string>): Set<string> {
169
+ const moved = new Set<string>();
170
+ for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
171
+ if (before[k] !== after[k]) moved.add(k);
172
+ }
173
+ return moved;
174
+ }
175
+
176
+ /**
177
+ * Which scenarios must re-run, given the recorded manifest and the current inputs.
178
+ *
179
+ * A selector key matches a step's `[Ref]` either exactly or as one of the resolver's suffixed
180
+ * forms (`key--button`, `key--3`), because that is how the lookup itself resolves.
181
+ */
182
+ export function selectChanged(
183
+ before: RunManifest | null,
184
+ after: RunManifest,
185
+ /**
186
+ * Run-affecting files an outside source (git) reports as changed. Passed in rather than read here
187
+ * so this function stays pure and testable; the CLI gathers the evidence. Non-empty + an
188
+ * otherwise-empty selection means something changed that per-key hashing cannot see (a rename, a
189
+ * new file shape) — unattributable, so it widens like the other unattributable cases.
190
+ */
191
+ externalChanged: string[] = []
192
+ ): ChangeSelection {
193
+ const titles = Object.keys(after.scenarios);
194
+ const selectAll = (reason: string): ChangeSelection => ({
195
+ unit: after.unit,
196
+ scenarios: titles,
197
+ reasons: Object.fromEntries(titles.map((t) => [t, reason])),
198
+ all: true,
199
+ summary: { total: titles.length, selected: titles.length, firstRun: !before },
200
+ });
201
+
202
+ if (!before) return selectAll('no previous run recorded');
203
+ const movedGlobals = movedKeys(before.globals || {}, after.globals || {});
204
+ if (movedGlobals.size) {
205
+ return selectAll(`project config changed (${[...movedGlobals].join(', ')}) — it reaches every scenario`);
206
+ }
207
+ if (before.background !== after.background) return selectAll('the Background changed — it runs before every scenario');
208
+ if (after.selectorKeys.__unparseable__ || after.dataKeys.__unparseable__) {
209
+ return selectAll('a selectors/test-data file could not be parsed, so the edit cannot be attributed');
210
+ }
211
+ if (after.selectorKeys.__whole__ || after.dataKeys.__whole__) {
212
+ const movedWhole = movedKeys(before.selectorKeys, after.selectorKeys).size
213
+ || movedKeys(before.dataKeys, after.dataKeys).size;
214
+ if (movedWhole) return selectAll('a selectors/test-data file is not a key mapping, so the edit cannot be attributed');
215
+ }
216
+
217
+ const movedSel = movedKeys(before.selectorKeys, after.selectorKeys);
218
+ const movedDat = movedKeys(before.dataKeys, after.dataKeys);
219
+ const touches = (key: string, dep: string): boolean => key === dep || key.startsWith(`${dep}--`);
220
+
221
+ const reasons: Record<string, string> = {};
222
+ for (const title of titles) {
223
+ if (before.scenarios[title] !== after.scenarios[title]) {
224
+ reasons[title] = before.scenarios[title] ? 'its steps or tags changed' : 'new scenario';
225
+ continue;
226
+ }
227
+ const dep = after.deps[title] || { selectors: [], data: [] };
228
+ const sel = [...movedSel].filter((k) => dep.selectors.some((d) => touches(k, d)));
229
+ if (sel.length) { reasons[title] = `selector changed: ${sel.slice(0, 3).join(', ')}`; continue; }
230
+ const dat = [...movedDat].filter((k) => dep.data.some((d) => touches(k, d)));
231
+ if (dat.length) { reasons[title] = `test-data changed: ${dat.slice(0, 3).join(', ')}`; continue; }
232
+ }
233
+
234
+ let selected = titles.filter((t) => reasons[t]);
235
+
236
+ // Git cross-check. Per-key hashing is blind to structural moves — a renamed selectors file, a new
237
+ // file shape, an encoding change — because the keys simply appear or vanish. When an outside
238
+ // source reports run-affecting files changed and we attributed NOTHING, that is the same
239
+ // unattributable case as an unparseable YAML, so it widens rather than reporting "all current".
240
+ if (!selected.length && externalChanged.length) {
241
+ return selectAll(
242
+ `git reports ${externalChanged.length} run-affecting file(s) changed (${externalChanged.slice(0, 3).join(', ')}) `
243
+ + 'that per-key hashing could not attribute'
244
+ );
245
+ }
246
+
247
+ return {
248
+ unit: after.unit,
249
+ scenarios: selected,
250
+ reasons,
251
+ all: false,
252
+ summary: { total: titles.length, selected: selected.length, firstRun: false },
253
+ };
254
+ }
@@ -170,19 +170,39 @@ export function viewpointLedger(viewpointPath: string, scenarios: ScenarioInfo[]
170
170
  * The language has no reload/back step at all (#593), so the honest outcome is a `@manual`
171
171
  * deferral naming the gesture — not a silent swap.
172
172
  */
173
- const BROWSER_GESTURES: Array<{ gesture: string; re: RegExp; appSubstitute: RegExp }> = [
173
+ const BROWSER_GESTURES: Array<{ gesture: string; re: RegExp; appSubstitute: RegExp; platform?: string }> = [
174
174
  { gesture: 'browser back', re: /\bbrowser(?:'s)?[ -]?(?:back|history)\b|\bhistory[ -]back\b/i,
175
- appSubstitute: /\bclick \[[^\]]*back[^\]]*\]/i },
175
+ appSubstitute: /\bclick \[[^\]]*back[^\]]*\]/i, platform: 'web' },
176
176
  { gesture: 'browser reload/refresh', re: /\b(?:page |browser )?(?:reload|refresh)(?:ing|ed|es)?\b/i,
177
- appSubstitute: /\bis on \[[^\]]+\] page\b/i },
177
+ appSubstitute: /\bis on \[[^\]]+\] page\b/i, platform: 'web' },
178
178
  { gesture: 'closing and reopening the tab', re: /\bclos(?:e|ing) (?:and reopen\w*\s*)?the tab\b|\breopen\w* the tab\b/i,
179
- appSubstitute: /\bis on \[[^\]]+\] page\b/i },
179
+ appSubstitute: /\bis on \[[^\]]+\] page\b/i, platform: 'web' },
180
+
181
+ // The same class on mobile, where the vocabulary is different but the trap is identical: an
182
+ // in-app control is not the OS gesture. Android's hardware/system back does not run the
183
+ // screen's own handler unless the app wires it, and backgrounding can destroy an Activity that
184
+ // an in-app navigation never touches — so a defect that only appears on the OS path lives
185
+ // exactly in the substitution (#626). The check was web-only, so mobile flows were silent about
186
+ // the very risk it exists to catch.
187
+ { gesture: 'the OS/hardware back gesture', platform: 'mobile',
188
+ re: /\b(?:hardware|system|os|device|android|physical)[ -]?back\b|\bback (?:gesture|button|swipe)\b|\bswipe[ -]back\b/i,
189
+ appSubstitute: /\b(?:tap|click) \[[^\]]*back[^\]]*\]/i },
190
+ { gesture: 'backgrounding and resuming the app', platform: 'mobile',
191
+ re: /\b(?:background(?:ing|ed)?|foreground(?:ing|ed)?|app switcher|recent apps|minimi[sz]|resum(?:e|ing)|kill(?:ed|ing)? the app|force[ -]stop|process death)\b/i,
192
+ appSubstitute: /\b(?:tap|click) \[[^\]]+\]|is on \[[^\]]+\] screen\b/i },
193
+ { gesture: 'rotating the device', platform: 'mobile',
194
+ re: /\b(?:rotat(?:e|ing|ion)|landscape|portrait|orientation change)\b/i,
195
+ appSubstitute: /\bis on \[[^\]]+\] screen\b/i },
180
196
  ];
181
197
 
182
198
  export function browserGestureSubstitutions(
183
- viewpointPath: string, scenarios: ScenarioInfo[],
199
+ viewpointPath: string, scenarios: ScenarioInfo[], platform?: string,
184
200
  ): Array<{ gesture: string; item: string }> {
185
201
  const out: Array<{ gesture: string; item: string }> = [];
202
+ // A gesture only applies where its platform does: "reload" means nothing on a native app, and
203
+ // "backgrounding" means nothing in a browser tab. An unknown platform checks everything, which
204
+ // is how the web-only set behaved before.
205
+ const applies = (g: { platform?: string }): boolean => !platform || !g.platform || g.platform === platform;
186
206
  // Judged PER SCENARIO, not over the whole file: a @manual scenario elsewhere that merely
187
207
  // mentions the gesture in passing (explaining a tooling limit) silenced the check for an
188
208
  // item that an automated scenario had quietly substituted.
@@ -190,6 +210,7 @@ export function browserGestureSubstitutions(
190
210
  const automated = scenarios.filter((s) => !s.manual).map((s) => s.haystack);
191
211
  for (const item of parseViewpointItems(viewpointPath)) {
192
212
  for (const g of BROWSER_GESTURES) {
213
+ if (!applies(g)) continue;
193
214
  if (!g.re.test(item.text)) continue;
194
215
  // A @manual scenario whose own subject IS the gesture is the honest answer.
195
216
  if (deferred.some((h) => g.re.test(h))) break;
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export type { ResolvedAppProfile } from './generators/test-generator/app-profile
20
20
  export type { MappedStep } from './generators/test-generator/step-mapper';
21
21
  export type { ParsedStep } from './generators/gherkin-parser';
22
22
  export { getPathCode, inferPath, resolvePathVariables } from './generators/test-generator/utils/path-inference';
23
+ export { formatToRegexSource, formatToRegexLiteral, hasFormatToken } from './generators/test-generator/utils/format-pattern';
23
24
  export { LIST_MEMBER_ROLES } from './generators/test-generator/utils/selector-resolver';
24
25
 
25
26
  // --- Precondition-annotation override grammar (shared by the @query / @api driver codegen) ---
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from 'child_process';
2
+
3
+ /**
4
+ * Lockstep version resolution for the sungen package family.
5
+ *
6
+ * Core (`@sun-asterisk/sungen`) and every capability driver (`@sungen/driver-*`) are published
7
+ * together, so a project must hold ONE version line across all of them: a driver built against
8
+ * core 3.2.24 loading against core 3.2.20 is a silent-breakage class we refuse to ship. Both
9
+ * `sungen capability add` (first install) and `sungen update` (project sync) resolve the target
10
+ * version through here so they can never disagree.
11
+ */
12
+
13
+ /** The running CLI's (core) version — family packages install in lockstep with it (not `@latest`). */
14
+ export function coreVersion(): string {
15
+ try { return require('../../package.json').version || 'latest'; } catch { return 'latest'; }
16
+ }
17
+
18
+ /**
19
+ * Resolve the version to install for a family package, given the running `core` version.
20
+ *
21
+ * Lockstep wants the EXACT `core` version. But the beta channel publishes `<core>-beta.N` before a
22
+ * stable `<core>` ever exists — so a released core (e.g. 3.2.10) asking for `@3.2.10` hits ETARGET
23
+ * out-of-the-box when npm only has `3.2.10-beta.2` (`sungen capability add` then fails for a fresh
24
+ * user). Fall back on the SAME version line: prefer exact `core`, else the highest `core-beta.N`,
25
+ * else the `beta` dist-tag as a last resort. Keeps the driver's pinned core-dep on the same line.
26
+ */
27
+ export function resolveDriverVersion(pkg: string, core: string): string {
28
+ if (core === 'latest') return 'beta';
29
+ try {
30
+ const out = spawnSync('npm', ['view', pkg, 'versions', '--json'], { encoding: 'utf-8', shell: true });
31
+ if (out.status !== 0 || !out.stdout) return core; // can't query → let the exact install try/fail
32
+ let versions: string[] = [];
33
+ const parsed = JSON.parse(out.stdout);
34
+ versions = Array.isArray(parsed) ? parsed : [parsed];
35
+ if (versions.includes(core)) return core; // exact exists → lockstep
36
+ const prefix = `${core}-beta.`;
37
+ const betas = versions
38
+ .filter((v) => typeof v === 'string' && v.startsWith(prefix))
39
+ .map((v) => parseInt(v.slice(prefix.length), 10))
40
+ .filter((n) => Number.isInteger(n));
41
+ if (betas.length) return `${core}-beta.${Math.max(...betas)}`; // highest same-line prerelease
42
+ return 'beta'; // nothing on this line → newest beta dist-tag
43
+ } catch {
44
+ return core;
45
+ }
46
+ }
@@ -0,0 +1,175 @@
1
+ import { spawnSync } from 'child_process';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { resolveDriverVersion } from './lockstep-version';
5
+
6
+ /**
7
+ * Project-local sync of the sungen package family after a global `sungen update`.
8
+ *
9
+ * `sungen update` used to run `npm install -g @sun-asterisk/sungen@<channel>` and stop there. That
10
+ * left every PROJECT-local copy behind, and a project always has them: `sungen capability add <x>`
11
+ * installs `@sungen/driver-<x>` as a devDependency pinned to the core version of the day, and each
12
+ * driver declares `@sun-asterisk/sungen` as a dependency — so a local core lands in the project's
13
+ * node_modules too, along with the `sungen` bin link. The lockfile then pins all of it. Result after
14
+ * an update: global 3.2.24, project still 3.2.20, and `npx sungen` inside the project silently
15
+ * running the OLD compiler — with drivers built against a core that is no longer the one loading
16
+ * them.
17
+ *
18
+ * So the update also reconciles the project: every family package declared (or physically present)
19
+ * in the project is reinstalled on the running core's version line.
20
+ */
21
+
22
+ /** Family = core + every capability driver. These MUST share one version line in a project. */
23
+ const CORE_PKG = '@sun-asterisk/sungen';
24
+ const isFamily = (name: string): boolean => name === CORE_PKG || name.startsWith('@sungen/driver-');
25
+
26
+ /** Declared specs pointing at a checkout, not the registry — a workspace/dogfood link we must not clobber. */
27
+ const isLocalSpec = (spec: string): boolean =>
28
+ /^(file:|link:|workspace:|portal:|\.{1,2}\/|\/)/.test(spec) || spec.startsWith('git+') || spec.startsWith('github:');
29
+
30
+ export interface FamilyPkg {
31
+ pkg: string;
32
+ /** Range in package.json dependencies/devDependencies, or null when only present transitively. */
33
+ declared: string | null;
34
+ /** Version actually resolved in the project tree, or null when absent. `link` = symlinked. */
35
+ installed: string | null;
36
+ }
37
+
38
+ export interface DepSyncPlan {
39
+ /** `npm install -D` specs to run. Empty when nothing needs to change. */
40
+ specs: string[];
41
+ /** Family packages found in the project, for reporting. */
42
+ found: FamilyPkg[];
43
+ /** Packages deliberately left alone, with the reason. */
44
+ skipped: Array<{ pkg: string; why: string }>;
45
+ /** Set when there is no project to sync at all (no package.json / no family packages). */
46
+ noop?: string;
47
+ }
48
+
49
+ /** Read a package.json, returning null when missing or malformed. */
50
+ function readJson(file: string): Record<string, any> | null {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** Version of `pkg` as installed in the project tree, or null. Symlinked (workspace) → `link`. */
59
+ function installedVersion(cwd: string, pkg: string): string | null {
60
+ const dir = path.join(cwd, 'node_modules', ...pkg.split('/'));
61
+ try {
62
+ if (fs.lstatSync(dir).isSymbolicLink()) return 'link';
63
+ } catch {
64
+ return null;
65
+ }
66
+ return readJson(path.join(dir, 'package.json'))?.version ?? null;
67
+ }
68
+
69
+ /**
70
+ * Decide what the project needs so its family packages sit on `core`'s version line.
71
+ *
72
+ * Pure apart from reading the project tree and (for stale packages) asking npm which versions exist
73
+ * on that line — no installs, so `--dry-run` and the tests can plan without side effects.
74
+ */
75
+ export function planProjectDepSync(
76
+ cwd: string,
77
+ core: string,
78
+ resolve: (pkg: string, core: string) => string = resolveDriverVersion,
79
+ ): DepSyncPlan {
80
+ const pkgJson = readJson(path.join(cwd, 'package.json'));
81
+ if (!pkgJson) {
82
+ return { specs: [], found: [], skipped: [], noop: 'no package.json here — nothing project-local to sync' };
83
+ }
84
+
85
+ const declaredAll: Record<string, string> = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
86
+ const names = new Set(Object.keys(declaredAll).filter(isFamily));
87
+
88
+ // A transitively-installed core (pulled in by a driver) is the copy that owns the project's `sungen`
89
+ // bin link, so it must be reconciled even though nothing declares it.
90
+ if (!names.has(CORE_PKG) && installedVersion(cwd, CORE_PKG)) names.add(CORE_PKG);
91
+
92
+ if (!names.size) {
93
+ return {
94
+ specs: [],
95
+ found: [],
96
+ skipped: [],
97
+ noop: 'no sungen packages installed in this project — the global update is all that was needed',
98
+ };
99
+ }
100
+
101
+ const found: FamilyPkg[] = [...names].sort().map((pkg) => ({
102
+ pkg,
103
+ declared: declaredAll[pkg] ?? null,
104
+ installed: installedVersion(cwd, pkg),
105
+ }));
106
+
107
+ const specs: string[] = [];
108
+ const skipped: Array<{ pkg: string; why: string }> = [];
109
+
110
+ for (const entry of found) {
111
+ if (entry.declared && isLocalSpec(entry.declared)) {
112
+ skipped.push({ pkg: entry.pkg, why: `declared as ${entry.declared} (local checkout, not the registry)` });
113
+ continue;
114
+ }
115
+ if (entry.installed === 'link') {
116
+ skipped.push({ pkg: entry.pkg, why: 'symlinked (workspace or npm link)' });
117
+ continue;
118
+ }
119
+ if (entry.installed === core) continue; // already on the running core's exact version
120
+ specs.push(`${entry.pkg}@${resolve(entry.pkg, core)}`);
121
+ }
122
+
123
+ return { specs, found, skipped };
124
+ }
125
+
126
+ /**
127
+ * Reconcile the project's sungen family packages with the running core version.
128
+ *
129
+ * Runs ONE `npm install -D` for every stale package so the lockfile is rewritten in a single
130
+ * resolution pass. Best-effort by design: a failure here (offline, private registry, no write
131
+ * permission) must not fail `sungen update` — the global install already succeeded, and the user
132
+ * gets the exact command to finish the job by hand.
133
+ *
134
+ * @returns true when the project ended up in sync (or needed nothing), false when the install failed
135
+ */
136
+ export function syncProjectSungenDeps(cwd: string, core: string, dryRun: boolean): boolean {
137
+ const plan = planProjectDepSync(cwd, core);
138
+
139
+ console.log('🔗 Syncing project-local sungen packages...');
140
+ if (plan.noop) {
141
+ console.log(` – ${plan.noop}\n`);
142
+ return true;
143
+ }
144
+ for (const s of plan.skipped) console.log(` – skipped ${s.pkg} — ${s.why}`);
145
+
146
+ if (!plan.specs.length) {
147
+ console.log(` ✓ already on ${core}: ${plan.found.map((f) => f.pkg).join(', ')}\n`);
148
+ return true;
149
+ }
150
+
151
+ for (const f of plan.found) {
152
+ if (!plan.specs.some((s) => s.startsWith(`${f.pkg}@`))) continue;
153
+ const wasTransitive = f.declared ? '' : ' (was transitive — pinning explicitly)';
154
+ console.log(` • ${f.pkg} ${f.installed ?? '(not installed)'} → ${core}${wasTransitive}`);
155
+ }
156
+
157
+ const cmd = `npm install -D ${plan.specs.join(' ')}`;
158
+ if (dryRun) {
159
+ console.log(` [dry-run] would run: ${cmd}\n`);
160
+ return true;
161
+ }
162
+
163
+ console.log(` 📦 ${cmd}`);
164
+ const r = spawnSync('npm', ['install', '-D', ...plan.specs], { cwd, stdio: 'inherit', shell: true });
165
+ if (r.status !== 0) {
166
+ console.warn(
167
+ `⚠️ Project-local sync failed — the GLOBAL sungen is now ${core}, but this project still holds\n` +
168
+ ' older copies, so `npx sungen` here would keep running the old compiler. Finish manually:\n' +
169
+ ` ${cmd}\n`,
170
+ );
171
+ return false;
172
+ }
173
+ console.log(` ✓ project-local packages on ${core}\n`);
174
+ return true;
175
+ }
@@ -656,9 +656,12 @@ export class ProjectInitializer {
656
656
 
657
657
  // Mobile-only drops @playwright/test (no web runner needed). `ajv`/`ajv-formats` back the API
658
658
  // Driver's `expect {{name.body}} matches schema [Ref]` (loaded lazily; harmless if unused).
659
+ // fast-xml-parser: the API driver's XML request/response support lazy-requires it
660
+ // from the generated specs/api.ts — installed up front so an `encoding: xml`
661
+ // catalog entry works without a mid-suite MODULE_NOT_FOUND.
659
662
  const requiredDeps = this.mobileOnly
660
- ? ['@types/node', 'yaml', 'ajv', 'ajv-formats']
661
- : ['@playwright/test', '@types/node', 'yaml', 'ajv', 'ajv-formats'];
663
+ ? ['@types/node', 'yaml', 'ajv', 'ajv-formats', 'fast-xml-parser']
664
+ : ['@playwright/test', '@types/node', 'yaml', 'ajv', 'ajv-formats', 'fast-xml-parser'];
662
665
 
663
666
  let missingDeps: string[] = requiredDeps;
664
667
  try {