@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,104 @@
1
+ /**
2
+ * `sungen changed` — which scenarios does the current edit oblige a re-run of?
3
+ *
4
+ * Backs `run-test --change-only`. The selection is computed here rather than in the AI workflow so
5
+ * it is deterministic and testable: the same edit always yields the same list, and the list can be
6
+ * asserted in CI. The workflow's job is only to run it, merge, and record.
7
+ *
8
+ * Bias: when an edit cannot be attributed to specific scenarios, EVERY scenario is selected and the
9
+ * output says so. Over-running costs minutes; under-running reports a scenario green that never
10
+ * re-ran after the change that broke it.
11
+ */
12
+ import { Command } from 'commander';
13
+ import { buildRunManifest, readRunManifest, writeRunManifest, selectChanged } from '../../harness/run-manifest';
14
+ import { findUnitDir } from '../resolve-unit';
15
+ import { execFileSync } from 'child_process';
16
+ import * as path from 'path';
17
+
18
+ /**
19
+ * Run-affecting files git reports changed for this unit, or [] when git has no usable opinion.
20
+ *
21
+ * Deliberately narrow and fail-quiet:
22
+ * - not a repo, or the unit is UNTRACKED → [] (git knows nothing; the dogfood project is exactly
23
+ * this case, and treating "untracked" as "changed" would widen on every single run);
24
+ * - only `features/`, `selectors/`, `test-data/` count — a spec.md or screenshot edit is
25
+ * regeneration territory (`sungen manifest --diff`), not a reason to re-run;
26
+ * - `diff HEAD` so staged and unstaged edits both count.
27
+ */
28
+ function gitChangedFiles(unitDir: string): string[] {
29
+ const rel = path.relative(process.cwd(), unitDir) || '.';
30
+ const git = (args: string[]): string =>
31
+ execFileSync('git', args, { cwd: process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
32
+ try {
33
+ if (!git(['ls-files', rel]).trim()) return []; // untracked unit → git has no baseline
34
+ return git(['diff', 'HEAD', '--name-only', '--', rel])
35
+ .split('\n')
36
+ .map((l) => l.trim())
37
+ .filter((l) => /\/(features|selectors|test-data)\//.test(l));
38
+ } catch {
39
+ return []; // no git, no repo, detached weirdness — the fingerprint still decides
40
+ }
41
+ }
42
+
43
+ /** Escape a scenario title for a `-g` / `--mochaOpts.grep` regex. */
44
+ const toGrep = (titles: string[]): string =>
45
+ titles.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
46
+
47
+ export function registerChangedCommand(program: Command): void {
48
+ program
49
+ .command('changed')
50
+ .description('List the scenarios a selectors/test-data/feature edit obliges you to re-run (backs run-test --change-only)')
51
+ .option('-s, --screen <name>', 'Screen or flow name')
52
+ .option('--api <name>', 'API-first area or api flow (qa/api/<name>)')
53
+ .option('--area <name>', 'Alias of --api')
54
+ .option('--json', 'Output the raw selection as JSON')
55
+ .option('--grep', 'Output ONLY a regex of the selected titles, for -g / --mochaOpts.grep')
56
+ .option('--record', 'Record the current inputs as the new baseline (run this AFTER a successful run)')
57
+ .action((options) => {
58
+ try {
59
+ const name = options.screen || options.api || options.area;
60
+ if (!name) throw new Error('Provide --screen <name> (or --api <area>)');
61
+ const dir = findUnitDir(name);
62
+ if (!dir) throw new Error(`Not found: qa/screens|flows|api/${name}`);
63
+
64
+ const cwd = process.cwd();
65
+ const after = buildRunManifest(cwd, dir, name);
66
+
67
+ if (options.record) {
68
+ const p = writeRunManifest(cwd, name, after);
69
+ console.log(`✓ recorded run baseline for ${name} → ${p.replace(`${cwd}/`, '')} (${Object.keys(after.scenarios).length} scenarios)`);
70
+ return;
71
+ }
72
+
73
+ const selection = selectChanged(readRunManifest(cwd, name), after, gitChangedFiles(dir));
74
+
75
+ if (options.grep) {
76
+ // Empty output is meaningful: nothing to run. A caller must treat "" as "skip the run",
77
+ // never as "match everything" — an empty grep would run the whole suite.
78
+ console.log(toGrep(selection.scenarios));
79
+ return;
80
+ }
81
+ if (options.json) { console.log(JSON.stringify(selection, null, 2)); return; }
82
+
83
+ const { total, selected, firstRun } = selection.summary;
84
+ console.log(`\n━━━ Change selection: ${name} ━━━`);
85
+ if (!selected) {
86
+ console.log(` ✓ Nothing changed since the last recorded run — ${total} scenario(s) still current.\n`);
87
+ return;
88
+ }
89
+ if (selection.all) {
90
+ console.log(` ⚠ ALL ${total} scenario(s) selected — ${selection.reasons[selection.scenarios[0]]}.`);
91
+ console.log(firstRun
92
+ ? ' Record a baseline after this run so the next one can narrow: sungen changed --screen ' + name + ' --record\n'
93
+ : ' An unattributable edit widens to the whole unit on purpose — a missed scenario would report green without re-running.\n');
94
+ return;
95
+ }
96
+ console.log(` ${selected} of ${total} scenario(s) need a re-run:\n`);
97
+ for (const t of selection.scenarios) console.log(` ↻ ${t}\n ${selection.reasons[t]}`);
98
+ console.log('');
99
+ } catch (err) {
100
+ console.error(err instanceof Error ? err.message : String(err));
101
+ process.exitCode = 1;
102
+ }
103
+ });
104
+ }
@@ -436,7 +436,8 @@ async function exportTarget(
436
436
  const specLink = fs.existsSync(specMdFile) ? path.relative(cwd, specMdFile) : '';
437
437
  const explicitEnv = process.env.SUNGEN_ENV;
438
438
 
439
- // For api-kind units, load the endpoint catalog so we can add the API detail sheet.
439
+ // Load the endpoint catalog for every kind: api-kind gets the API detail sheet;
440
+ // screen/flow rows name their @api precondition endpoints from it.
440
441
  const apiCatalog = loadApiCatalog(cwd, target.kind, target.screen);
441
442
  const hasApiCatalog = target.kind === 'api' && Object.keys(apiCatalog).length > 0;
442
443
 
@@ -672,8 +673,21 @@ async function exportMatrixTarget(
672
673
  cwd: string,
673
674
  target: DeliveryTarget,
674
675
  opts: { check?: boolean; preview?: boolean; format?: MatrixFormat },
675
- ): Promise<MatrixRunSummary | null> {
676
+ ): Promise<MatrixRunSummary | 'skipped' | null> {
676
677
  const paths = matrixPathsFor(cwd, target);
678
+
679
+ // A feature whose scenarios are ALL @exploration probes has nothing to deliver —
680
+ // skip it quietly (no map required) instead of demanding a reviewed grouping for
681
+ // probes that are, by contract, outside the official suite.
682
+ {
683
+ const featureMeta = parseFeatureMetadata(paths.featureFile);
684
+ const deliverable = featureMeta.scenarios.filter((s) => !s.tags.some((t) => /^@exploration\b/i.test(t)));
685
+ if (featureMeta.scenarios.length > 0 && deliverable.length === 0) {
686
+ log(`${COLOR.gray}${paths.label}: nothing to deliver — all ${featureMeta.scenarios.length} scenario(s) are @exploration probes (outside the official suite).${COLOR.reset}`);
687
+ return 'skipped';
688
+ }
689
+ }
690
+
677
691
  const { model, mapMissing, mapErrors } = loadMatrixModel(paths);
678
692
 
679
693
  if (mapMissing) {
@@ -937,6 +951,16 @@ export function registerDeliveryCommand(program: Command): void {
937
951
  let blocked = false;
938
952
  for (const target of toExport) {
939
953
  const paths = matrixPathsFor(cwd, target);
954
+ // Same contract as the export path: an all-@exploration feature has no
955
+ // deliverable variants, so there is no map to approve — skip, not an error.
956
+ {
957
+ const featureMeta = parseFeatureMetadata(paths.featureFile);
958
+ if (featureMeta.scenarios.length > 0
959
+ && featureMeta.scenarios.every((s) => s.tags.some((t) => /^@exploration\b/i.test(t)))) {
960
+ log(`${COLOR.gray}${paths.label}: nothing to approve — all scenario(s) are @exploration probes.${COLOR.reset}`);
961
+ continue;
962
+ }
963
+ }
940
964
  try {
941
965
  const { findings, approved } = approveMatrix(paths, ids);
942
966
  if (approved.length === 0) {
@@ -961,6 +985,7 @@ export function registerDeliveryCommand(program: Command): void {
961
985
  const s = await exportMatrixTarget(cwd, target, {
962
986
  check: options.check, preview: options.preview, format: format as MatrixFormat,
963
987
  });
988
+ if (s === 'skipped') continue; // all-@exploration feature — neither exported nor a failure
964
989
  if (s) matrixSummaries.push(s);
965
990
  else notExported.push(target.isFlow ? `flow/${target.featureBaseName}` : target.featureBaseName);
966
991
  }
@@ -200,7 +200,7 @@ export function registerGenerateCommand(program: Command): void {
200
200
  console.log(`\n⚠️ Compile diagnostics (${diagnostics.count}):`);
201
201
  for (const line of diagnostics.formatLines().slice(0, 20)) console.log(` ${line}`);
202
202
  if (diagnostics.count > 20) console.log(` … and ${diagnostics.count - 20} more`);
203
- console.log(` Unmatched steps compile to no-op // TODO comments — the test runs weaker than it reads.`);
203
+ console.log(` Unsupported steps compile to a runtime throw — the test fails loudly instead of passing having checked nothing.`);
204
204
  if (options.strict) {
205
205
  console.error(`\n--strict: failing generation on ${diagnostics.count} diagnostic(s).`);
206
206
  process.exit(1);
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `sungen results merge` — fold a subset re-run's Playwright JSON report into
3
+ * the existing per-target result file instead of overwriting it. See
4
+ * exporters/results-merge.ts for the title-keyed semantics; this command owns
5
+ * path resolution, validation, ghost warnings, and the atomic write.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { Command } from 'commander';
11
+ import { mergeResultsReports, findGhostTitles } from '../../exporters/results-merge';
12
+ import { resolveResultsPath } from '../../exporters/playwright-report-parser';
13
+ import { parseSpecFile } from '../../exporters/spec-parser';
14
+
15
+ interface MergeCliOptions {
16
+ out?: string;
17
+ screen?: string;
18
+ }
19
+
20
+ function readReport(file: string, label: string): any {
21
+ if (!fs.existsSync(file)) {
22
+ throw new Error(`results merge: ${label} file not found: ${file}`);
23
+ }
24
+ try {
25
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
26
+ } catch (err) {
27
+ throw new Error(`results merge: ${label} file is not valid JSON (${file}): ${err instanceof Error ? err.message : String(err)}`);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Resolve the generated dir that owns FEATURE `name` — by its spec file, not by
33
+ * unit-dir name: a multi-feature screen keeps `home.spec.ts` AND
34
+ * `home-modal.spec.ts` in specs/generated/home/, and each feature has its OWN
35
+ * per-target result file. Kind order mirrors delivery's target resolution
36
+ * (flows → screens → api → api flows); the same basename found in more than one
37
+ * place is an ambiguity error, never a silent pick.
38
+ */
39
+ function resolveFeatureDir(cwd: string, name: string): string {
40
+ const roots = [
41
+ path.join(cwd, 'specs', 'generated', 'flows'),
42
+ path.join(cwd, 'specs', 'generated'),
43
+ path.join(cwd, 'specs', 'generated', 'api'),
44
+ path.join(cwd, 'specs', 'generated', 'api', 'flows'),
45
+ ];
46
+ const hits: string[] = [];
47
+ for (const root of roots) {
48
+ if (!fs.existsSync(root)) continue;
49
+ for (const unit of fs.readdirSync(root)) {
50
+ const dir = path.join(root, unit);
51
+ // the middle root also contains flows/ and api/ themselves — units only
52
+ if (['flows', 'api'].includes(unit) && root.endsWith(path.join('specs', 'generated'))) continue;
53
+ if (fs.existsSync(path.join(dir, `${name}.spec.ts`)) && !hits.includes(dir)) hits.push(dir);
54
+ }
55
+ }
56
+ if (hits.length === 0) {
57
+ throw new Error(`results merge: no generated spec named "${name}.spec.ts" found under specs/generated/ — pass the feature basename (a multi-feature screen has one result file PER feature), and run sungen generate first.`);
58
+ }
59
+ if (hits.length > 1) {
60
+ throw new Error(`results merge: "${name}" is ambiguous — found in ${hits.map((h) => path.relative(cwd, h)).join(' and ')}. Use the explicit two-file form.`);
61
+ }
62
+ return hits[0];
63
+ }
64
+
65
+ /**
66
+ * Runs the merge. Exported for tests; the commander wiring below is a thin
67
+ * shell around it. Returns the path written.
68
+ */
69
+ export function runResultsMerge(baseArg: string | undefined, newArg: string | undefined, opts: MergeCliOptions, cwd: string = process.cwd()): string {
70
+ let baseFile = baseArg;
71
+ let newFile = newArg;
72
+ let specTitles: string[] | null = null;
73
+
74
+ if (opts.screen) {
75
+ // Convenience form: `results merge --screen <name> <fresh.json>` — <name> is
76
+ // the FEATURE basename; its per-target file lives next to its spec, with the
77
+ // same in-dir candidate order delivery reads.
78
+ if (baseArg && newArg) throw new Error('results merge: --screen takes ONE report file (the fresh subset run).');
79
+ newFile = baseArg ?? newArg;
80
+ if (!newFile) throw new Error('results merge: --screen needs the fresh subset report file as the argument.');
81
+ const dir = resolveFeatureDir(cwd, opts.screen);
82
+ baseFile = resolveResultsPath(cwd, dir, opts.screen);
83
+ // resolveResultsPath falls back to the cwd-global test-results/results.json —
84
+ // a file shared by EVERY unit. Merging one unit's subset into it would report
85
+ // success while delivery keeps reading the stale per-target file: refuse.
86
+ if (!baseFile || path.dirname(baseFile) !== dir) {
87
+ throw new Error(`results merge: no per-target result file for "${opts.screen}" in ${path.relative(cwd, dir)} — nothing to merge into (a first full run writes it directly).`);
88
+ }
89
+ // Ghost/foreign-title checks need the CURRENT spec titles.
90
+ const specFile = path.join(dir, `${opts.screen}.spec.ts`);
91
+ try { specTitles = parseSpecFile(specFile).tests.map((t) => t.testTitle); } catch { specTitles = null; }
92
+ }
93
+
94
+ if (!baseFile || !newFile) {
95
+ throw new Error('results merge: expected <base.json> <new.json>, or --screen <name> <new.json>.');
96
+ }
97
+
98
+ const base = readReport(baseFile, 'base');
99
+ const fresh = readReport(newFile, 'new');
100
+ const { merged, replaced, added, kept } = mergeResultsReports(base, fresh);
101
+
102
+ if (specTitles) {
103
+ // A fresh report whose ADDED titles are unknown to this feature's spec was
104
+ // produced by a DIFFERENT feature — writing it here would silently corrupt
105
+ // this file while the right one stays stale. Refuse before writing.
106
+ const foreign = findGhostTitles(added, specTitles);
107
+ if (foreign.length > 0) {
108
+ throw new Error(`results merge: ${foreign.length} title(s) in ${path.basename(newFile)} match no scenario of "${opts.screen}" (e.g. ${foreign[0]}) — is this another feature's report? Base file untouched.`);
109
+ }
110
+ const ghosts = findGhostTitles(kept, specTitles);
111
+ if (ghosts.length > 0) {
112
+ console.warn(`⚠ ${ghosts.length} result title(s) no longer match any current scenario (renamed by a spec edit?) — kept, not deleted:`);
113
+ for (const g of ghosts.slice(0, 10)) console.warn(` - ${g}`);
114
+ if (ghosts.length > 10) console.warn(` … and ${ghosts.length - 10} more`);
115
+ }
116
+ }
117
+
118
+ // Atomic write: a crash mid-write must never leave a truncated result file —
119
+ // that file is the single source of execution truth for delivery/dashboard.
120
+ // (Same-dir rename = same device; concurrent merges are last-rename-wins.)
121
+ const outFile = opts.out ?? baseFile;
122
+ const tmp = `${outFile}.tmp-${process.pid}`;
123
+ try {
124
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2));
125
+ fs.renameSync(tmp, outFile);
126
+ } catch (err) {
127
+ try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort cleanup */ }
128
+ throw err;
129
+ }
130
+
131
+ console.log(`✓ merged ${path.basename(newFile)} into ${path.relative(cwd, outFile)} — ${replaced.length} updated, ${added.length} added, ${kept.length} kept`);
132
+ return outFile;
133
+ }
134
+
135
+ export function registerResultsCommand(program: Command): void {
136
+ const results = program
137
+ .command('results')
138
+ .description('Operate on per-target Playwright result files');
139
+
140
+ results
141
+ .command('merge [base] [new]')
142
+ .description('Merge a subset re-run\'s report into an existing result file (same title → new wins; others kept)')
143
+ .option('-o, --out <file>', 'write the merged report here instead of overwriting the base file')
144
+ .option('--screen <name>', 'resolve the base file from this FEATURE basename (a multi-feature screen has one result file per feature); pass only the fresh report as the argument')
145
+ .action((baseArg: string | undefined, newArg: string | undefined, opts: MergeCliOptions) => {
146
+ try {
147
+ runResultsMerge(baseArg, newArg, opts);
148
+ } catch (err) {
149
+ console.error(err instanceof Error ? err.message : String(err));
150
+ process.exitCode = 1;
151
+ }
152
+ });
153
+ }
@@ -1,5 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { spawnSync } from 'child_process';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { coreVersion } from '../../orchestrator/lockstep-version';
6
+ import { syncProjectSungenDeps } from '../../orchestrator/project-deps-sync';
3
7
 
4
8
  /**
5
9
  * `sungen update` does two jobs in sequence:
@@ -7,8 +11,12 @@ import { spawnSync } from 'child_process';
7
11
  * 1. Reinstall `@sun-asterisk/sungen@latest` globally via npm so the bundled
8
12
  * AI templates are refreshed.
9
13
  * 2. Re-execute `sungen update` with the env var `SUNGEN_UPDATE_SKIP_NPM=1`
10
- * so the AI rules / commands / skills inside the project get overwritten
11
- * from the *new* templates.
14
+ * so the *new* binary (a) reconciles the project's own sungen packages onto
15
+ * its version line and (b) overwrites the AI rules / commands / skills
16
+ * inside the project from the new templates.
17
+ *
18
+ * Step 2 runs under the NEW binary on purpose: the version the project should be
19
+ * pinned to is the one doing the pinning, so the two can never disagree.
12
20
  *
13
21
  * We use an environment variable (not a CLI flag) for the internal hand-off
14
22
  * because Commander throws "unknown option" if the currently-installed
@@ -23,6 +31,12 @@ import { spawnSync } from 'child_process';
23
31
 
24
32
  const SKIP_NPM_ENV = 'SUNGEN_UPDATE_SKIP_NPM';
25
33
 
34
+ /**
35
+ * Carried to the re-exec as an env var for the same reason as SKIP_NPM_ENV: a flag would crash an
36
+ * older published binary with "unknown option", an env var is ignored by it.
37
+ */
38
+ const SKIP_PROJECT_ENV = 'SUNGEN_UPDATE_SKIP_PROJECT';
39
+
26
40
  export function registerUpdateCommand(program: Command): void {
27
41
  program
28
42
  .command('update')
@@ -48,13 +62,20 @@ export function registerUpdateCommand(program: Command): void {
48
62
  )
49
63
  .option(
50
64
  '--skip-npm-install',
51
- 'Skip the npm install step (refresh project AI assets only)',
65
+ 'Skip the global npm install step (refresh project AI assets only)',
66
+ false,
67
+ )
68
+ .option(
69
+ '--skip-project',
70
+ 'Skip reinstalling the project-local sungen packages (AI assets only — leaves node_modules stale)',
52
71
  false,
53
72
  )
54
- .action(async (options: { dryRun?: boolean; beta?: boolean; tag?: string; skipNpmInstall?: boolean; assistant?: string[]; force?: boolean }) => {
73
+ .action(async (options: { dryRun?: boolean; beta?: boolean; tag?: string; skipNpmInstall?: boolean; skipProject?: boolean; assistant?: string[]; force?: boolean }) => {
55
74
  try {
56
75
  const skipNpm =
57
76
  Boolean(options.skipNpmInstall) || process.env[SKIP_NPM_ENV] === '1';
77
+ const skipProject =
78
+ Boolean(options.skipProject) || process.env[SKIP_PROJECT_ENV] === '1';
58
79
 
59
80
  // Channel resolution via npm dist-tags. Default `latest` = official/stable.
60
81
  // `--beta` → beta channel; running plain `sungen update` later switches back to stable.
@@ -63,10 +84,18 @@ export function registerUpdateCommand(program: Command): void {
63
84
  if (!skipNpm) {
64
85
  reinstallSungen(channel);
65
86
  printCurrentVersion(channel);
66
- reExecUpdateForAIAssets(options.dryRun ?? false, options.assistant ?? [], options.force ?? false);
87
+ reExecUpdateForAIAssets(options.dryRun ?? false, options.assistant ?? [], options.force ?? false, skipProject);
67
88
  return;
68
89
  }
69
90
 
91
+ // Running under the NEW binary now: pin the project's own copies to THIS version before
92
+ // refreshing the AI assets, so the compiler the project runs matches the templates it gets.
93
+ if (skipProject) {
94
+ console.log('⏭ Skipping project-local package sync (--skip-project).\n');
95
+ } else {
96
+ syncProjectSungenDeps(process.cwd(), coreVersion(), options.dryRun ?? false);
97
+ }
98
+
70
99
  const { resolveAssistants } = require('../../orchestrator/ai-skills/resolve-assistants');
71
100
  const { AIRulesUpdater } = require('../../orchestrator/ai-rules-updater');
72
101
  const updater = new AIRulesUpdater(process.cwd());
@@ -94,8 +123,15 @@ function reinstallSungen(channel: string): void {
94
123
  }
95
124
 
96
125
  function printCurrentVersion(channel: string): void {
97
- console.log('\n🔎 Installed version:');
98
- spawnSync('sungen', ['--version'], { stdio: 'inherit', shell: true });
126
+ console.log('\n🔎 Installed version (global):');
127
+ // Same PATH caveat as the re-exec: report the version that was just installed globally, not a
128
+ // project-local copy that happens to shadow it.
129
+ const entry = globalCoreEntry();
130
+ if (entry) {
131
+ spawnSync(process.execPath, [entry, '--version'], { stdio: 'inherit' });
132
+ } else {
133
+ spawnSync('sungen', ['--version'], { stdio: 'inherit', shell: true });
134
+ }
99
135
  if (channel === 'latest') {
100
136
  console.log(' Channel: stable. To try prereleases: sungen update --beta');
101
137
  } else {
@@ -104,17 +140,40 @@ function printCurrentVersion(channel: string): void {
104
140
  console.log('');
105
141
  }
106
142
 
107
- function reExecUpdateForAIAssets(dryRun: boolean, assistants: string[], force: boolean): void {
143
+ /**
144
+ * Absolute path to the globally installed core's CLI entry, or null when it can't be located
145
+ * (then the caller falls back to a PATH lookup). Asks npm for the global root instead of guessing
146
+ * a prefix, so nvm / Volta / Windows / custom-prefix installs all resolve correctly.
147
+ */
148
+ function globalCoreEntry(): string | null {
149
+ try {
150
+ const out = spawnSync('npm', ['root', '-g'], { encoding: 'utf-8', shell: true });
151
+ const root = out.status === 0 ? (out.stdout || '').trim() : '';
152
+ if (!root) return null;
153
+ const entry = path.join(root, '@sun-asterisk', 'sungen', 'bin', 'sungen.js');
154
+ return fs.existsSync(entry) ? entry : null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ function reExecUpdateForAIAssets(dryRun: boolean, assistants: string[], force: boolean, skipProject: boolean): void {
108
161
  const args = ['update'];
109
162
  if (dryRun) args.push('--dry-run');
110
163
  if (force) args.push('--force');
111
164
  for (const a of assistants) args.push('--assistant', a);
112
165
 
113
- const result = spawnSync('sungen', args, {
114
- stdio: 'inherit',
115
- shell: true,
116
- env: { ...process.env, [SKIP_NPM_ENV]: '1' },
117
- });
166
+ const env: NodeJS.ProcessEnv = { ...process.env, [SKIP_NPM_ENV]: '1' };
167
+ if (skipProject) env[SKIP_PROJECT_ENV] = '1';
168
+
169
+ // Run the JUST-INSTALLED global entry directly rather than whatever `sungen` resolves to on PATH.
170
+ // Inside a project, `npx sungen update` / `npm run` put the project's own bin dir FIRST on PATH, so
171
+ // a PATH lookup here re-executes the OLD local copy — step 2 would then refresh nothing, which is
172
+ // exactly the "update ran but the project didn't change" failure this flow exists to prevent.
173
+ const entry = globalCoreEntry();
174
+ const result = entry
175
+ ? spawnSync(process.execPath, [entry, ...args], { stdio: 'inherit', env })
176
+ : spawnSync('sungen', args, { stdio: 'inherit', shell: true, env });
118
177
 
119
178
  if (result.status !== 0) {
120
179
  console.error(
package/src/cli/index.ts CHANGED
@@ -22,6 +22,7 @@ import { registerGateCommand } from './commands/gate';
22
22
  import { registerIngestCommand } from './commands/ingest';
23
23
  import { registerEvalCommand } from './commands/eval';
24
24
  import { registerManifestCommand } from './commands/manifest';
25
+ import { registerChangedCommand } from './commands/changed';
25
26
  import { registerLedgerCommand } from './commands/ledger';
26
27
  import { registerFeedbackCommand } from './commands/feedback';
27
28
  import { registerQaFeedbackCommand } from './commands/qa-feedback';
@@ -34,6 +35,7 @@ import { registerCapabilityCommand } from './commands/capability';
34
35
  import { registerFlowCheckCommand } from './commands/flow-check';
35
36
  import { registerContextCommand } from './commands/context';
36
37
  import { registerRepairCommand } from './commands/repair';
38
+ import { registerResultsCommand } from './commands/results-merge';
37
39
  import { registerLoginCommand } from './commands/login';
38
40
  import { capabilityRegistry } from '../capabilities/registry';
39
41
  import { discoverAndRegisterCapabilities } from '../capabilities/discover';
@@ -70,6 +72,7 @@ async function main() {
70
72
  registerJourneyCommand(program);
71
73
  registerGateCommand(program);
72
74
  registerManifestCommand(program);
75
+ registerChangedCommand(program);
73
76
  registerLedgerCommand(program);
74
77
  registerFeedbackCommand(program);
75
78
  registerQaFeedbackCommand(program);
@@ -82,6 +85,7 @@ async function main() {
82
85
  registerFlowCheckCommand(program);
83
86
  registerContextCommand(program);
84
87
  registerRepairCommand(program);
88
+ registerResultsCommand(program);
85
89
  registerIngestCommand(program);
86
90
  registerEvalCommand(program);
87
91
  registerLoginCommand(program);
@@ -22,9 +22,17 @@ export function loadApiCatalog(
22
22
  kind: 'screen' | 'flow' | 'api',
23
23
  screen: string,
24
24
  ): Record<string, ApiCatalogEntry> {
25
- if (kind !== 'api') return {};
25
+ // Every kind can carry `@api:` preconditions, and the runtime resolves their
26
+ // endpoints from the UNIT's own catalog first (qa/screens|flows/<u>/api/apis.yaml)
27
+ // then the shared one — this loader mirrors that scheme so the deliverable can
28
+ // name the endpoint a screen/flow scenario actually calls.
29
+ const unitDir = kind === 'api'
30
+ ? path.join(cwd, 'qa', 'api', screen)
31
+ : kind === 'flow'
32
+ ? path.join(cwd, 'qa', 'flows', screen)
33
+ : path.join(cwd, 'qa', 'screens', screen);
26
34
  const shared = readApiCatalogFile(path.join(cwd, 'qa', 'api', 'apis.yaml'));
27
- const unit = readApiCatalogFile(path.join(cwd, 'qa', 'api', screen, 'api', 'apis.yaml'));
35
+ const unit = readApiCatalogFile(path.join(unitDir, 'api', 'apis.yaml'));
28
36
  return { ...shared, ...unit };
29
37
  }
30
38
 
@@ -98,7 +98,7 @@ function callBlockLines(call: ApiCall, entry: ApiCatalogEntry, localize: (text:
98
98
  const filesStr = formatFiles(entry.files, call.args, localize);
99
99
  const bodyFileStr = formatBodyFile(entry.bodyFile, call.args, localize);
100
100
  // Body is scalar fields; a pure file upload may have none → still show `Body: —` for a stable shape.
101
- lines.push(`Body: ${formatBody(entry.body, call.args, localize)}`);
101
+ lines.push(`Body: ${formatBody(entry.body, call.args, localize, entry)}`);
102
102
  if (filesStr) lines.push(`Files: ${filesStr}`);
103
103
  if (bodyFileStr) lines.push(`Body file: ${bodyFileStr}`);
104
104
  return lines;
@@ -111,6 +111,24 @@ function callBlockLines(call: ApiCall, entry: ApiCatalogEntry, localize: (text:
111
111
  * has more than one call — same style as formatApiRequest — which also keeps a
112
112
  * deliberately repeated call distinct through the matrix's precondition Set-dedup.
113
113
  */
114
+ /**
115
+ * Terse one-line-per-call summary for a screen/flow row's PRE-CONDITION cell:
116
+ * `Call API <name> — <METHOD> <path>`. Built on the strict parseApiCalls so the
117
+ * deliverable never documents a call the runtime silently dropped (a malformed
118
+ * tag never runs). Only name/method/path from the catalog render — tag args
119
+ * carry runtime values. A missing endpoint is loud; a present entry missing a
120
+ * field shows `—` for that field instead of blaming the wrong file.
121
+ */
122
+ export function formatApiCallSummaryLines(tags: string[], catalog: Record<string, ApiCatalogEntry>): string[] {
123
+ return parseApiCalls(tags).map(({ name }) => {
124
+ const entry = catalog[name];
125
+ if (!entry) return `Call API ${name} — (endpoint not in apis.yaml)`;
126
+ const method = typeof entry.method === 'string' && entry.method ? entry.method.toUpperCase() : '—';
127
+ const apiPath = typeof entry.path === 'string' && entry.path ? entry.path : '—';
128
+ return `Call API ${name} — ${method} ${apiPath}`;
129
+ });
130
+ }
131
+
114
132
  export function formatApiCallLines(
115
133
  calls: ApiCall[],
116
134
  catalog: Record<string, ApiCatalogEntry>,
@@ -185,6 +203,38 @@ function bindParams(raw: string, args: Record<string, string>): string {
185
203
  });
186
204
  }
187
205
 
206
+ /**
207
+ * Whitelist variant for XML string bodies: bind ONLY the declared param names
208
+ * (longest first, word-boundary guarded) — the generic `:word` scan above would
209
+ * rewrite namespace prefixes (soapenv:Envelope) into garbage cells. Mirrors the
210
+ * runtime's whitelist binding.
211
+ */
212
+ function bindXmlParamsForCell(raw: string, args: Record<string, string>, paramNames: string[]): string {
213
+ let out = raw;
214
+ for (const p of [...paramNames].sort((a, b) => b.length - a.length)) {
215
+ const expr = p in args ? args[p] : `{{${p}}}`;
216
+ out = out.replace(new RegExp(`:${p}(?![A-Za-z0-9_])`, 'g'), () => unquoteExpr(expr));
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /**
222
+ * Mask the value of credential-named ELEMENTS inside an XML string body
223
+ * (`<signature>…</signature>` → `<signature>***</signature>`). The object-body
224
+ * masking below never sees a string template, so without this the raw signature
225
+ * from the catalog example would print verbatim in the client's API detail sheet.
226
+ */
227
+ function maskXmlSensitiveElements(xml: string): string {
228
+ // The open tag may carry attributes (<signature type="hmac">) and the value may
229
+ // be a CDATA section — both are common spellings for exactly the credential
230
+ // elements this masks, so the pattern must accept them or the secret prints raw.
231
+ return xml.replace(
232
+ /(<([A-Za-z0-9_.:-]+)(?:\s[^>]*)?>)((?:<!\[CDATA\[[\s\S]*?\]\]>|[^<])*)(<\/\2>)/g,
233
+ (whole, open: string, tag: string, _value: string, close: string) =>
234
+ SENSITIVE_NAME.test(tag) ? `${open}***${close}` : whole,
235
+ );
236
+ }
237
+
188
238
  /** Strip a matching pair of surrounding quotes from a tag-arg literal (`"x"` → `x`). */
189
239
  function unquoteExpr(e: string): string {
190
240
  const m = e.match(/^"(.*)"$/) || e.match(/^'(.*)'$/);
@@ -230,6 +280,7 @@ function formatHeaders(
230
280
  function contentTypeFor(encoding?: string): string {
231
281
  if (encoding === 'form') return 'application/x-www-form-urlencoded';
232
282
  if (encoding === 'multipart') return 'multipart/form-data';
283
+ if (encoding === 'xml') return 'application/xml';
233
284
  return 'application/json';
234
285
  }
235
286
 
@@ -247,8 +298,13 @@ function uploadContentType(entry: ApiCatalogEntry): string {
247
298
  * that field can be replaced with `***`; otherwise the body resolves wholesale
248
299
  * (preserving numbers/nesting exactly as before).
249
300
  */
250
- function formatBody(body: unknown, args: Record<string, string>, localize: (text: string) => string): string {
301
+ function formatBody(body: unknown, args: Record<string, string>, localize: (text: string) => string, entry?: ApiCatalogEntry): string {
251
302
  if (body === undefined || body === null) return '—';
303
+ // XML string template: whitelist-bind declared params, then mask credential elements.
304
+ if (entry?.encoding === 'xml' && typeof body === 'string') {
305
+ const names = Array.isArray(entry.params) ? (entry.params as string[]) : [];
306
+ return localize(maskXmlSensitiveElements(bindXmlParamsForCell(body, args, names)));
307
+ }
252
308
  if (isPlainObject(body)) {
253
309
  const obj = body as Record<string, unknown>;
254
310
  if (Object.keys(obj).some(isSensitiveField)) {
@@ -321,7 +377,7 @@ function formatBodyFile(bodyFile: unknown, args: Record<string, string>, localiz
321
377
  * test-data keys — catches variants like confirmPassword / new_password /
322
378
  * password_confirmation / access_token / client_secret / otp / passcode.
323
379
  */
324
- export const SENSITIVE_NAME = /pass(word|code)?|passwd|pwd|secret|token|otp|api[-_]?key|apikey|credential|authorization/i;
380
+ export const SENSITIVE_NAME = /pass(word|code)?|passwd|pwd|secret|token|otp|api[-_]?key|apikey|credential|authorization|signature/i;
325
381
  function isSensitiveField(key: string): boolean {
326
382
  return SENSITIVE_NAME.test(key);
327
383
  }
@@ -23,7 +23,7 @@ import {
23
23
  statusToTestResult,
24
24
  } from './playwright-report-parser';
25
25
  import { ApiCatalogEntry, EnvironmentInfo, PlaywrightResult, ScreenSummary, TestCaseRow } from './types';
26
- import { collectSecretValues, formatApiExpected, formatApiRequest, hasApiCalls, redactSecrets } from './api-testcase-formatter';
26
+ import { collectSecretValues, formatApiCallSummaryLines, formatApiExpected, formatApiRequest, hasApiCalls, redactSecrets } from './api-testcase-formatter';
27
27
  import { SelectorKeyMap, substituteSelectorKeysInStep } from './selector-key-resolver';
28
28
  import { formatVariantRow, resolveResultVariants } from './result-variants';
29
29
  import { collectQaEnvSecretDenylist, warnIfRedactionIncomplete } from './qa-env-secret-denylist';
@@ -103,9 +103,18 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
103
103
  };
104
104
  const formatStep = (s: { text: string; bucket: 'given' | 'when' | 'then' }) =>
105
105
  localize(s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text));
106
+ // `@api:` preconditions on a screen/flow scenario run before its UI steps —
107
+ // without these lines the deliverable never names the real endpoint (QA
108
+ // post-processed every export to add them back). They belong in the
109
+ // PRE-CONDITION cell, the same home Background moved to: inlining setup in
110
+ // Steps shifts the numbering and every `— after step N` anchor with it.
111
+ // kind:api keeps its own richer request-block rendering below.
112
+ const apiCallLines = (input.kind === 'screen' || input.kind === 'flow') && input.apiCatalog && hasApiCalls(m.feature.tags)
113
+ ? formatApiCallSummaryLines(m.feature.tags, input.apiCatalog)
114
+ : [];
106
115
  const precondition = formatPrecondition(
107
116
  authRole,
108
- (m.resolvedBackgroundSteps ?? []).map(formatStep).filter(Boolean)
117
+ [...(m.resolvedBackgroundSteps ?? []).map(formatStep).filter(Boolean), ...apiCallLines]
109
118
  );
110
119
  const stepLines = m.resolvedSteps.map(formatStep).filter(Boolean);
111
120
  // Scenarios whose assertions verify different moments render Expected as