@codeyam/codeyam-cli 0.1.8 → 0.1.9

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 (335) hide show
  1. package/analyzer-template/.build-info.json +8 -8
  2. package/analyzer-template/log.txt +3 -3
  3. package/analyzer-template/package.json +4 -4
  4. package/analyzer-template/packages/ai/src/lib/generateExecutionFlows.ts +0 -33
  5. package/analyzer-template/packages/analyze/src/lib/ProjectAnalyzer.ts +13 -7
  6. package/analyzer-template/packages/analyze/src/lib/asts/index.ts +7 -2
  7. package/analyzer-template/packages/analyze/src/lib/files/scenarios/generateExecutionFlows.ts +0 -98
  8. package/analyzer-template/packages/aws/package.json +1 -1
  9. package/analyzer-template/packages/database/src/lib/kysely/tables/editorScenariosTable.ts +31 -0
  10. package/analyzer-template/packages/database/src/lib/loadEntities.ts +0 -6
  11. package/analyzer-template/packages/database/src/lib/updateCommitMetadata.ts +0 -65
  12. package/analyzer-template/packages/github/dist/database/src/lib/kysely/tables/editorScenariosTable.d.ts +5 -0
  13. package/analyzer-template/packages/github/dist/database/src/lib/kysely/tables/editorScenariosTable.d.ts.map +1 -1
  14. package/analyzer-template/packages/github/dist/database/src/lib/kysely/tables/editorScenariosTable.js +31 -0
  15. package/analyzer-template/packages/github/dist/database/src/lib/kysely/tables/editorScenariosTable.js.map +1 -1
  16. package/analyzer-template/packages/github/dist/database/src/lib/loadEntities.d.ts.map +1 -1
  17. package/analyzer-template/packages/github/dist/database/src/lib/loadEntities.js +0 -6
  18. package/analyzer-template/packages/github/dist/database/src/lib/loadEntities.js.map +1 -1
  19. package/analyzer-template/packages/github/dist/database/src/lib/updateCommitMetadata.d.ts.map +1 -1
  20. package/analyzer-template/packages/github/dist/database/src/lib/updateCommitMetadata.js +0 -25
  21. package/analyzer-template/packages/github/dist/database/src/lib/updateCommitMetadata.js.map +1 -1
  22. package/analyzer-template/packages/github/dist/types/src/enums/ProjectFramework.d.ts +2 -0
  23. package/analyzer-template/packages/github/dist/types/src/enums/ProjectFramework.d.ts.map +1 -1
  24. package/analyzer-template/packages/github/dist/types/src/enums/ProjectFramework.js +2 -0
  25. package/analyzer-template/packages/github/dist/types/src/enums/ProjectFramework.js.map +1 -1
  26. package/analyzer-template/packages/types/src/enums/ProjectFramework.ts +2 -0
  27. package/analyzer-template/packages/ui-components/package.json +1 -1
  28. package/analyzer-template/packages/utils/dist/types/src/enums/ProjectFramework.d.ts +2 -0
  29. package/analyzer-template/packages/utils/dist/types/src/enums/ProjectFramework.d.ts.map +1 -1
  30. package/analyzer-template/packages/utils/dist/types/src/enums/ProjectFramework.js +2 -0
  31. package/analyzer-template/packages/utils/dist/types/src/enums/ProjectFramework.js.map +1 -1
  32. package/codeyam-cli/src/__tests__/memory-scripts/filter-session.test.js +196 -0
  33. package/codeyam-cli/src/__tests__/memory-scripts/filter-session.test.js.map +1 -0
  34. package/codeyam-cli/src/__tests__/memory-scripts/read-json-field.test.js +114 -0
  35. package/codeyam-cli/src/__tests__/memory-scripts/read-json-field.test.js.map +1 -0
  36. package/codeyam-cli/src/__tests__/memory-scripts/ripgrep-fallback.test.js +149 -0
  37. package/codeyam-cli/src/__tests__/memory-scripts/ripgrep-fallback.test.js.map +1 -0
  38. package/codeyam-cli/src/commands/__tests__/editor.stepDispatch.test.js +45 -0
  39. package/codeyam-cli/src/commands/__tests__/editor.stepDispatch.test.js.map +1 -0
  40. package/codeyam-cli/src/commands/__tests__/init.gitignore.test.js +101 -47
  41. package/codeyam-cli/src/commands/__tests__/init.gitignore.test.js.map +1 -1
  42. package/codeyam-cli/src/commands/default.js +3 -46
  43. package/codeyam-cli/src/commands/default.js.map +1 -1
  44. package/codeyam-cli/src/commands/editor.js +1619 -243
  45. package/codeyam-cli/src/commands/editor.js.map +1 -1
  46. package/codeyam-cli/src/commands/init.js +67 -34
  47. package/codeyam-cli/src/commands/init.js.map +1 -1
  48. package/codeyam-cli/src/data/techStacks.js +77 -0
  49. package/codeyam-cli/src/data/techStacks.js.map +1 -0
  50. package/codeyam-cli/src/utils/__tests__/analyzerFinalization.test.js +144 -0
  51. package/codeyam-cli/src/utils/__tests__/analyzerFinalization.test.js.map +1 -0
  52. package/codeyam-cli/src/utils/__tests__/backgroundServer.test.js +46 -0
  53. package/codeyam-cli/src/utils/__tests__/backgroundServer.test.js.map +1 -0
  54. package/codeyam-cli/src/utils/__tests__/devServerState.test.js +134 -0
  55. package/codeyam-cli/src/utils/__tests__/devServerState.test.js.map +1 -0
  56. package/codeyam-cli/src/utils/__tests__/editorApi.test.js +127 -0
  57. package/codeyam-cli/src/utils/__tests__/editorApi.test.js.map +1 -0
  58. package/codeyam-cli/src/utils/__tests__/editorAudit.test.js +610 -1
  59. package/codeyam-cli/src/utils/__tests__/editorAudit.test.js.map +1 -1
  60. package/codeyam-cli/src/utils/__tests__/editorCapture.test.js +93 -0
  61. package/codeyam-cli/src/utils/__tests__/editorCapture.test.js.map +1 -0
  62. package/codeyam-cli/src/utils/__tests__/editorDevServer.test.js +181 -3
  63. package/codeyam-cli/src/utils/__tests__/editorDevServer.test.js.map +1 -1
  64. package/codeyam-cli/src/utils/__tests__/editorEntityChangeStatus.test.js +121 -0
  65. package/codeyam-cli/src/utils/__tests__/editorEntityChangeStatus.test.js.map +1 -0
  66. package/codeyam-cli/src/utils/__tests__/editorImageVerifier.test.js +294 -0
  67. package/codeyam-cli/src/utils/__tests__/editorImageVerifier.test.js.map +1 -0
  68. package/codeyam-cli/src/utils/__tests__/editorJournal.test.js +249 -2
  69. package/codeyam-cli/src/utils/__tests__/editorJournal.test.js.map +1 -1
  70. package/codeyam-cli/src/utils/__tests__/editorLoaderHelpers.test.js +520 -0
  71. package/codeyam-cli/src/utils/__tests__/editorLoaderHelpers.test.js.map +1 -0
  72. package/codeyam-cli/src/utils/__tests__/editorPreloadHelpers.test.js +118 -1
  73. package/codeyam-cli/src/utils/__tests__/editorPreloadHelpers.test.js.map +1 -1
  74. package/codeyam-cli/src/utils/__tests__/editorPreview.test.js +195 -3
  75. package/codeyam-cli/src/utils/__tests__/editorPreview.test.js.map +1 -1
  76. package/codeyam-cli/src/utils/__tests__/editorProxySession.test.js +153 -0
  77. package/codeyam-cli/src/utils/__tests__/editorProxySession.test.js.map +1 -0
  78. package/codeyam-cli/src/utils/__tests__/editorScenarioLookup.test.js +139 -0
  79. package/codeyam-cli/src/utils/__tests__/editorScenarioLookup.test.js.map +1 -0
  80. package/codeyam-cli/src/utils/__tests__/editorScenarioSwitch.test.js +221 -0
  81. package/codeyam-cli/src/utils/__tests__/editorScenarioSwitch.test.js.map +1 -0
  82. package/codeyam-cli/src/utils/__tests__/editorScenarios.test.js +781 -2
  83. package/codeyam-cli/src/utils/__tests__/editorScenarios.test.js.map +1 -1
  84. package/codeyam-cli/src/utils/__tests__/editorSeedAdapter.test.js +213 -0
  85. package/codeyam-cli/src/utils/__tests__/editorSeedAdapter.test.js.map +1 -0
  86. package/codeyam-cli/src/utils/__tests__/entityChangeStatus.test.js +1742 -0
  87. package/codeyam-cli/src/utils/__tests__/entityChangeStatus.test.js.map +1 -0
  88. package/codeyam-cli/src/utils/__tests__/journalCaptureStabilization.test.js +107 -0
  89. package/codeyam-cli/src/utils/__tests__/journalCaptureStabilization.test.js.map +1 -0
  90. package/codeyam-cli/src/utils/__tests__/parseRegisterArg.test.js +101 -0
  91. package/codeyam-cli/src/utils/__tests__/parseRegisterArg.test.js.map +1 -0
  92. package/codeyam-cli/src/utils/__tests__/scenarioCoverage.test.js +227 -0
  93. package/codeyam-cli/src/utils/__tests__/scenarioCoverage.test.js.map +1 -0
  94. package/codeyam-cli/src/utils/__tests__/scenariosManifest.test.js +300 -0
  95. package/codeyam-cli/src/utils/__tests__/scenariosManifest.test.js.map +1 -0
  96. package/codeyam-cli/src/utils/__tests__/setupClaudeCodeSettings.test.js +25 -5
  97. package/codeyam-cli/src/utils/__tests__/setupClaudeCodeSettings.test.js.map +1 -1
  98. package/codeyam-cli/src/utils/__tests__/templateConsistency.test.js +51 -0
  99. package/codeyam-cli/src/utils/__tests__/templateConsistency.test.js.map +1 -0
  100. package/codeyam-cli/src/utils/__tests__/webappDetection.test.js +142 -0
  101. package/codeyam-cli/src/utils/__tests__/webappDetection.test.js.map +1 -0
  102. package/codeyam-cli/src/utils/analyzer.js +9 -0
  103. package/codeyam-cli/src/utils/analyzer.js.map +1 -1
  104. package/codeyam-cli/src/utils/analyzerFinalization.js +96 -0
  105. package/codeyam-cli/src/utils/analyzerFinalization.js.map +1 -0
  106. package/codeyam-cli/src/utils/backgroundServer.js +94 -18
  107. package/codeyam-cli/src/utils/backgroundServer.js.map +1 -1
  108. package/codeyam-cli/src/utils/database.js +37 -2
  109. package/codeyam-cli/src/utils/database.js.map +1 -1
  110. package/codeyam-cli/src/utils/devServerState.js +71 -0
  111. package/codeyam-cli/src/utils/devServerState.js.map +1 -0
  112. package/codeyam-cli/src/utils/editorApi.js +73 -0
  113. package/codeyam-cli/src/utils/editorApi.js.map +1 -0
  114. package/codeyam-cli/src/utils/editorAudit.js +101 -7
  115. package/codeyam-cli/src/utils/editorAudit.js.map +1 -1
  116. package/codeyam-cli/src/utils/editorCapture.js +102 -0
  117. package/codeyam-cli/src/utils/editorCapture.js.map +1 -0
  118. package/codeyam-cli/src/utils/editorDevServer.js +100 -1
  119. package/codeyam-cli/src/utils/editorDevServer.js.map +1 -1
  120. package/codeyam-cli/src/utils/editorEntityChangeStatus.js +44 -0
  121. package/codeyam-cli/src/utils/editorEntityChangeStatus.js.map +1 -0
  122. package/codeyam-cli/src/utils/editorImageVerifier.js +155 -0
  123. package/codeyam-cli/src/utils/editorImageVerifier.js.map +1 -0
  124. package/codeyam-cli/src/utils/editorJournal.js +92 -4
  125. package/codeyam-cli/src/utils/editorJournal.js.map +1 -1
  126. package/codeyam-cli/src/utils/editorLoaderHelpers.js +113 -0
  127. package/codeyam-cli/src/utils/editorLoaderHelpers.js.map +1 -0
  128. package/codeyam-cli/src/utils/editorMockState.js +1 -1
  129. package/codeyam-cli/src/utils/editorPreloadHelpers.js +72 -1
  130. package/codeyam-cli/src/utils/editorPreloadHelpers.js.map +1 -1
  131. package/codeyam-cli/src/utils/editorPreview.js +67 -1
  132. package/codeyam-cli/src/utils/editorPreview.js.map +1 -1
  133. package/codeyam-cli/src/utils/editorScenarioSwitch.js +112 -0
  134. package/codeyam-cli/src/utils/editorScenarioSwitch.js.map +1 -0
  135. package/codeyam-cli/src/utils/editorScenarios.js +276 -0
  136. package/codeyam-cli/src/utils/editorScenarios.js.map +1 -1
  137. package/codeyam-cli/src/utils/editorSeedAdapter.js +173 -0
  138. package/codeyam-cli/src/utils/editorSeedAdapter.js.map +1 -0
  139. package/codeyam-cli/src/utils/entityChangeStatus.js +349 -0
  140. package/codeyam-cli/src/utils/entityChangeStatus.js.map +1 -0
  141. package/codeyam-cli/src/utils/entityChangeStatus.server.js +158 -0
  142. package/codeyam-cli/src/utils/entityChangeStatus.server.js.map +1 -0
  143. package/codeyam-cli/src/utils/install-skills.js +1 -1
  144. package/codeyam-cli/src/utils/install-skills.js.map +1 -1
  145. package/codeyam-cli/src/utils/parseRegisterArg.js +31 -0
  146. package/codeyam-cli/src/utils/parseRegisterArg.js.map +1 -0
  147. package/codeyam-cli/src/utils/scenarioCoverage.js +75 -0
  148. package/codeyam-cli/src/utils/scenarioCoverage.js.map +1 -0
  149. package/codeyam-cli/src/utils/scenariosManifest.js +159 -0
  150. package/codeyam-cli/src/utils/scenariosManifest.js.map +1 -0
  151. package/codeyam-cli/src/utils/serverState.js +30 -0
  152. package/codeyam-cli/src/utils/serverState.js.map +1 -1
  153. package/codeyam-cli/src/utils/setupClaudeCodeSettings.js +46 -16
  154. package/codeyam-cli/src/utils/setupClaudeCodeSettings.js.map +1 -1
  155. package/codeyam-cli/src/utils/simulationGateMiddleware.js +8 -1
  156. package/codeyam-cli/src/utils/simulationGateMiddleware.js.map +1 -1
  157. package/codeyam-cli/src/utils/slugUtils.js +25 -0
  158. package/codeyam-cli/src/utils/slugUtils.js.map +1 -0
  159. package/codeyam-cli/src/utils/syncMocksMiddleware.js +2 -2
  160. package/codeyam-cli/src/utils/syncMocksMiddleware.js.map +1 -1
  161. package/codeyam-cli/src/utils/webappDetection.js +21 -0
  162. package/codeyam-cli/src/utils/webappDetection.js.map +1 -1
  163. package/codeyam-cli/src/webserver/__tests__/clientErrors.test.js +40 -0
  164. package/codeyam-cli/src/webserver/__tests__/clientErrors.test.js.map +1 -0
  165. package/codeyam-cli/src/webserver/__tests__/editorProxy.test.js +567 -0
  166. package/codeyam-cli/src/webserver/__tests__/editorProxy.test.js.map +1 -0
  167. package/codeyam-cli/src/webserver/app/lib/clientErrors.js +65 -0
  168. package/codeyam-cli/src/webserver/app/lib/clientErrors.js.map +1 -0
  169. package/codeyam-cli/src/webserver/app/lib/git.js +397 -0
  170. package/codeyam-cli/src/webserver/app/lib/git.js.map +1 -0
  171. package/codeyam-cli/src/webserver/build/client/assets/{CopyButton-DmJveP3T.js → CopyButton-BPXZwM4t.js} +1 -1
  172. package/codeyam-cli/src/webserver/build/client/assets/{EntityItem-C76mRRiF.js → EntityItem-BcgbViKV.js} +3 -3
  173. package/codeyam-cli/src/webserver/build/client/assets/{EntityTypeIcon-CobE682z.js → EntityTypeIcon-CQIG2qda.js} +9 -9
  174. package/codeyam-cli/src/webserver/build/client/assets/{ReportIssueModal-djPLI-WV.js → ReportIssueModal-BzHcG7SE.js} +3 -3
  175. package/codeyam-cli/src/webserver/build/client/assets/{ScenarioViewer-B76aig_2.js → ScenarioViewer-Bd-hxofb.js} +3 -3
  176. package/codeyam-cli/src/webserver/build/client/assets/ViewportInspectBar-oAf2Kqsf.js +1 -0
  177. package/codeyam-cli/src/webserver/build/client/assets/{_index-C96V0n15.js → _index-DLxKhri3.js} +3 -3
  178. package/codeyam-cli/src/webserver/build/client/assets/{activity.(_tab)-BpKzcsJz.js → activity.(_tab)-BcY3q6nt.js} +6 -6
  179. package/codeyam-cli/src/webserver/build/client/assets/addon-canvas-DpzMmAy5.js +1 -0
  180. package/codeyam-cli/src/webserver/build/client/assets/addon-fit-YJmn1quW.js +12 -0
  181. package/codeyam-cli/src/webserver/build/client/assets/addon-webgl-DI8QOUvO.js +58 -0
  182. package/codeyam-cli/src/webserver/build/client/assets/{agent-transcripts-D9hemwl6.js → agent-transcripts-Bni3iiUj.js} +5 -5
  183. package/codeyam-cli/src/webserver/build/client/assets/api.editor-file-diff-l0sNRNKZ.js +1 -0
  184. package/codeyam-cli/src/webserver/build/client/assets/api.editor-file-l0sNRNKZ.js +1 -0
  185. package/codeyam-cli/src/webserver/build/client/assets/api.editor-project-info-l0sNRNKZ.js +1 -0
  186. package/codeyam-cli/src/webserver/build/client/assets/api.editor-scenario-coverage-l0sNRNKZ.js +1 -0
  187. package/codeyam-cli/src/webserver/build/client/assets/{book-open-D_nMCFmP.js → book-open-BYOypzCa.js} +2 -2
  188. package/codeyam-cli/src/webserver/build/client/assets/{chevron-down-BH2h1Ea2.js → chevron-down-C_Pmso5S.js} +2 -2
  189. package/codeyam-cli/src/webserver/build/client/assets/{circle-check-DyIKORY6.js → circle-check-BVMi9VA5.js} +2 -2
  190. package/codeyam-cli/src/webserver/build/client/assets/{copy-NDbZjXao.js → copy-n2FB0_Sw.js} +3 -3
  191. package/codeyam-cli/src/webserver/build/client/assets/createLucideIcon-CC6AbExI.js +41 -0
  192. package/codeyam-cli/src/webserver/build/client/assets/dev.empty-BsDh6TSF.js +1 -0
  193. package/codeyam-cli/src/webserver/build/client/assets/editor-PBc_6L9R.js +10 -0
  194. package/codeyam-cli/src/webserver/build/client/assets/editorPreview-4FzHlcNn.js +41 -0
  195. package/codeyam-cli/src/webserver/build/client/assets/{entity._sha._-CrjR3zZW.js → entity._sha._-BsDXNp45.js} +3 -3
  196. package/codeyam-cli/src/webserver/build/client/assets/entity._sha.scenarios._scenarioId.dev-BgAqUtTZ.js +6 -0
  197. package/codeyam-cli/src/webserver/build/client/assets/entity._sha.scenarios._scenarioId.fullscreen-Bmshgrij.js +6 -0
  198. package/codeyam-cli/src/webserver/build/client/assets/{files-DO4CZ16O.js → files-BZrlFE1F.js} +1 -1
  199. package/codeyam-cli/src/webserver/build/client/assets/git-DdZcvjGh.js +1 -0
  200. package/codeyam-cli/src/webserver/build/client/assets/globals-B8vTTNy2.css +1 -0
  201. package/codeyam-cli/src/webserver/build/client/assets/index-yHOVb4rc.js +15 -0
  202. package/codeyam-cli/src/webserver/build/client/assets/{loader-circle-BAXYRVEO.js → loader-circle-DaAZ_H2w.js} +2 -2
  203. package/codeyam-cli/src/webserver/build/client/assets/manifest-65850841.js +1 -0
  204. package/codeyam-cli/src/webserver/build/client/assets/memory-9gnxSZlb.js +101 -0
  205. package/codeyam-cli/src/webserver/build/client/assets/{pause-DTAcYxBt.js → pause-f5-1lKBt.js} +3 -3
  206. package/codeyam-cli/src/webserver/build/client/assets/root-BwX8YgFb.js +67 -0
  207. package/codeyam-cli/src/webserver/build/client/assets/{search-fKo7v0Zo.js → search-Di64LWVb.js} +2 -2
  208. package/codeyam-cli/src/webserver/build/client/assets/{settings-DfuTtcJP.js → settings-0OrEMU6J.js} +1 -1
  209. package/codeyam-cli/src/webserver/build/client/assets/{simulations-B3aOzpCZ.js → simulations-DWT-CvLy.js} +1 -1
  210. package/codeyam-cli/src/webserver/build/client/assets/{terminal-BG4heKCG.js → terminal-Br7MOqts.js} +3 -3
  211. package/codeyam-cli/src/webserver/build/client/assets/{triangle-alert-DtSmdtM4.js → triangle-alert-BLdiCuG-.js} +2 -2
  212. package/codeyam-cli/src/webserver/build/client/assets/useCustomSizes-BE43Hjti.js +1 -0
  213. package/codeyam-cli/src/webserver/build/server/assets/index-DEEQf4pi.js +1 -0
  214. package/codeyam-cli/src/webserver/build/server/assets/init-CkWmyFY2.js +10 -0
  215. package/codeyam-cli/src/webserver/build/server/assets/server-build-BHi-9O8W.js +439 -0
  216. package/codeyam-cli/src/webserver/build/server/index.js +1 -1
  217. package/codeyam-cli/src/webserver/build-info.json +5 -5
  218. package/codeyam-cli/src/webserver/editorProxy.js +487 -50
  219. package/codeyam-cli/src/webserver/editorProxy.js.map +1 -1
  220. package/codeyam-cli/src/webserver/scripts/codeyam-preload.mjs +242 -3
  221. package/codeyam-cli/src/webserver/scripts/journalCapture.ts +94 -4
  222. package/codeyam-cli/src/webserver/server.js +46 -14
  223. package/codeyam-cli/src/webserver/server.js.map +1 -1
  224. package/codeyam-cli/src/webserver/terminalServer.js +39 -11
  225. package/codeyam-cli/src/webserver/terminalServer.js.map +1 -1
  226. package/codeyam-cli/templates/chrome-extension-react/EXTENSION_SETUP.md +75 -0
  227. package/codeyam-cli/templates/chrome-extension-react/README.md +46 -0
  228. package/codeyam-cli/templates/chrome-extension-react/gitignore +15 -0
  229. package/codeyam-cli/templates/chrome-extension-react/index.html +12 -0
  230. package/codeyam-cli/templates/chrome-extension-react/package.json +27 -0
  231. package/codeyam-cli/templates/chrome-extension-react/popup.html +12 -0
  232. package/codeyam-cli/templates/chrome-extension-react/public/manifest.json +15 -0
  233. package/codeyam-cli/templates/chrome-extension-react/src/background/service-worker.ts +7 -0
  234. package/codeyam-cli/templates/chrome-extension-react/src/globals.css +6 -0
  235. package/codeyam-cli/templates/chrome-extension-react/src/lib/storage.ts +37 -0
  236. package/codeyam-cli/templates/chrome-extension-react/src/popup/App.tsx +12 -0
  237. package/codeyam-cli/templates/chrome-extension-react/src/popup/main.tsx +10 -0
  238. package/codeyam-cli/templates/chrome-extension-react/tsconfig.json +24 -0
  239. package/codeyam-cli/templates/chrome-extension-react/vite.config.ts +41 -0
  240. package/codeyam-cli/templates/codeyam-editor-claude.md +84 -5
  241. package/codeyam-cli/templates/editor-step-hook.py +97 -8
  242. package/codeyam-cli/templates/expo-react-native/MOBILE_SETUP.md +89 -0
  243. package/codeyam-cli/templates/expo-react-native/README.md +41 -0
  244. package/codeyam-cli/templates/expo-react-native/app/(tabs)/_layout.tsx +33 -0
  245. package/codeyam-cli/templates/expo-react-native/app/(tabs)/index.tsx +12 -0
  246. package/codeyam-cli/templates/expo-react-native/app/(tabs)/settings.tsx +12 -0
  247. package/codeyam-cli/templates/expo-react-native/app/_layout.tsx +12 -0
  248. package/codeyam-cli/templates/expo-react-native/app.json +18 -0
  249. package/codeyam-cli/templates/expo-react-native/babel.config.js +9 -0
  250. package/codeyam-cli/templates/expo-react-native/gitignore +12 -0
  251. package/codeyam-cli/templates/expo-react-native/global.css +3 -0
  252. package/codeyam-cli/templates/expo-react-native/lib/storage.ts +32 -0
  253. package/codeyam-cli/templates/expo-react-native/metro.config.js +6 -0
  254. package/codeyam-cli/templates/expo-react-native/nativewind-env.d.ts +1 -0
  255. package/codeyam-cli/templates/expo-react-native/package.json +38 -0
  256. package/codeyam-cli/templates/expo-react-native/tailwind.config.js +10 -0
  257. package/codeyam-cli/templates/expo-react-native/tsconfig.json +10 -0
  258. package/codeyam-cli/templates/nextjs-prisma-sqlite/AUTH_PATTERNS.md +308 -0
  259. package/codeyam-cli/templates/nextjs-prisma-sqlite/AUTH_UPGRADE.md +304 -0
  260. package/codeyam-cli/templates/nextjs-prisma-sqlite/DATABASE.md +126 -0
  261. package/codeyam-cli/templates/nextjs-prisma-sqlite/FEATURE_PATTERNS.md +37 -0
  262. package/codeyam-cli/templates/nextjs-prisma-sqlite/README.md +53 -0
  263. package/codeyam-cli/templates/nextjs-prisma-sqlite/app/codeyam-isolate/layout.tsx +12 -0
  264. package/codeyam-cli/templates/nextjs-prisma-sqlite/app/lib/prisma.ts +9 -4
  265. package/codeyam-cli/templates/nextjs-prisma-sqlite/env +4 -0
  266. package/codeyam-cli/templates/nextjs-prisma-sqlite/gitignore +21 -0
  267. package/codeyam-cli/templates/nextjs-prisma-sqlite/package.json +5 -1
  268. package/codeyam-cli/templates/nextjs-prisma-sqlite/prisma/seed.ts +4 -1
  269. package/codeyam-cli/templates/nextjs-prisma-sqlite/seed-adapter.ts +92 -0
  270. package/codeyam-cli/templates/nextjs-prisma-sqlite/vitest.config.ts +13 -0
  271. package/codeyam-cli/templates/nextjs-prisma-supabase/README.md +52 -0
  272. package/codeyam-cli/templates/{nextjs-prisma-sqlite/PRISMA_SETUP.md → nextjs-prisma-supabase/SUPABASE_SETUP.md} +37 -17
  273. package/codeyam-cli/templates/nextjs-prisma-supabase/app/api/todos/route.ts +17 -0
  274. package/codeyam-cli/templates/nextjs-prisma-supabase/app/globals.css +26 -0
  275. package/codeyam-cli/templates/nextjs-prisma-supabase/app/layout.tsx +34 -0
  276. package/codeyam-cli/templates/nextjs-prisma-supabase/app/lib/prisma.ts +20 -0
  277. package/codeyam-cli/templates/nextjs-prisma-supabase/app/lib/supabase.ts +12 -0
  278. package/codeyam-cli/templates/nextjs-prisma-supabase/app/page.tsx +10 -0
  279. package/codeyam-cli/templates/nextjs-prisma-supabase/env +9 -0
  280. package/codeyam-cli/templates/nextjs-prisma-supabase/eslint.config.mjs +11 -0
  281. package/codeyam-cli/templates/nextjs-prisma-supabase/gitignore +40 -0
  282. package/codeyam-cli/templates/nextjs-prisma-supabase/next.config.ts +11 -0
  283. package/codeyam-cli/templates/nextjs-prisma-supabase/package.json +37 -0
  284. package/codeyam-cli/templates/nextjs-prisma-supabase/postcss.config.mjs +7 -0
  285. package/codeyam-cli/templates/nextjs-prisma-supabase/prisma/schema.prisma +27 -0
  286. package/codeyam-cli/templates/nextjs-prisma-supabase/prisma/seed.ts +39 -0
  287. package/codeyam-cli/templates/nextjs-prisma-supabase/prisma.config.ts +12 -0
  288. package/codeyam-cli/templates/nextjs-prisma-supabase/tsconfig.json +34 -0
  289. package/codeyam-cli/templates/skills/codeyam-dev-mode/SKILL.md +2 -2
  290. package/codeyam-cli/templates/skills/codeyam-editor/SKILL.md +96 -17
  291. package/codeyam-cli/templates/skills/codeyam-memory/SKILL.md +10 -10
  292. package/codeyam-cli/templates/skills/codeyam-memory/scripts/holistic-analysis/detect-deprecated-patterns.mjs +139 -0
  293. package/codeyam-cli/templates/skills/codeyam-memory/scripts/holistic-analysis/find-exports.mjs +52 -0
  294. package/codeyam-cli/templates/skills/codeyam-memory/scripts/lib/read-json-field.mjs +61 -0
  295. package/codeyam-cli/templates/skills/codeyam-memory/scripts/lib/ripgrep-fallback.mjs +155 -0
  296. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/cleanup.mjs +13 -0
  297. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/filter-session.mjs +95 -0
  298. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/preprocess.mjs +160 -0
  299. package/package.json +14 -9
  300. package/packages/ai/src/lib/generateExecutionFlows.js +0 -11
  301. package/packages/ai/src/lib/generateExecutionFlows.js.map +1 -1
  302. package/packages/analyze/src/lib/ProjectAnalyzer.js +10 -4
  303. package/packages/analyze/src/lib/ProjectAnalyzer.js.map +1 -1
  304. package/packages/analyze/src/lib/asts/index.js +4 -2
  305. package/packages/analyze/src/lib/asts/index.js.map +1 -1
  306. package/packages/analyze/src/lib/files/scenarios/generateExecutionFlows.js +0 -40
  307. package/packages/analyze/src/lib/files/scenarios/generateExecutionFlows.js.map +1 -1
  308. package/packages/database/src/lib/kysely/tables/editorScenariosTable.js +31 -0
  309. package/packages/database/src/lib/kysely/tables/editorScenariosTable.js.map +1 -1
  310. package/packages/database/src/lib/loadEntities.js +0 -6
  311. package/packages/database/src/lib/loadEntities.js.map +1 -1
  312. package/packages/database/src/lib/updateCommitMetadata.js +0 -25
  313. package/packages/database/src/lib/updateCommitMetadata.js.map +1 -1
  314. package/packages/types/src/enums/ProjectFramework.js +2 -0
  315. package/packages/types/src/enums/ProjectFramework.js.map +1 -1
  316. package/codeyam-cli/src/webserver/build/client/assets/Terminal-Dnj5CY9R.js +0 -41
  317. package/codeyam-cli/src/webserver/build/client/assets/addon-fit-CUXOrorO.js +0 -1
  318. package/codeyam-cli/src/webserver/build/client/assets/createLucideIcon-CMT1jU2q.js +0 -21
  319. package/codeyam-cli/src/webserver/build/client/assets/dev.empty-BiM6z3Do.js +0 -1
  320. package/codeyam-cli/src/webserver/build/client/assets/editor-D1DAKXtT.js +0 -8
  321. package/codeyam-cli/src/webserver/build/client/assets/entity._sha.scenarios._scenarioId.dev-DkzqFzFj.js +0 -6
  322. package/codeyam-cli/src/webserver/build/client/assets/entity._sha.scenarios._scenarioId.fullscreen-C28BiQzt.js +0 -6
  323. package/codeyam-cli/src/webserver/build/client/assets/git-CFCTYk9I.js +0 -15
  324. package/codeyam-cli/src/webserver/build/client/assets/globals-B17TBSS6.css +0 -1
  325. package/codeyam-cli/src/webserver/build/client/assets/manifest-a632de18.js +0 -1
  326. package/codeyam-cli/src/webserver/build/client/assets/memory-Dg0mvYrI.js +0 -96
  327. package/codeyam-cli/src/webserver/build/client/assets/root-DUKqhFlb.js +0 -67
  328. package/codeyam-cli/src/webserver/build/client/assets/useCustomSizes-ByhSyh0W.js +0 -1
  329. package/codeyam-cli/src/webserver/build/server/assets/index-HfLydfDq.js +0 -1
  330. package/codeyam-cli/src/webserver/build/server/assets/server-build-CUu_F-oo.js +0 -366
  331. package/codeyam-cli/templates/skills/codeyam-memory/scripts/holistic-analysis/detect-deprecated-patterns.sh +0 -108
  332. package/codeyam-cli/templates/skills/codeyam-memory/scripts/holistic-analysis/find-exports.sh +0 -69
  333. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/cleanup.sh +0 -12
  334. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/filter.jq +0 -45
  335. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/preprocess.sh +0 -139
@@ -0,0 +1,1742 @@
1
+ import { buildReverseDependencyGraph, classifyDirectChanges, computeEntityChangeStatus, buildChangedFilesMap, pageNameFromUrl, scenarioEntityName, buildEntityInfosFromScenarios, buildEntityInfosFromGlossary, filterGroupsByChangeStatus, filterGlossaryByChangeStatus, filterScenarioScreenshotsByChangeStatus, } from "../entityChangeStatus.js";
2
+ import { scanPageFilePaths, detectFirstFeature, readFeatureStartedAt, } from "../entityChangeStatus.server.js";
3
+ import * as fsModule from 'fs';
4
+ import * as pathModule from 'path';
5
+ import * as os from 'os';
6
+ // ── Helpers ──────────────────────────────────────────────────────────────
7
+ /** Shorthand to build an EntityInfo with importedBy metadata */
8
+ function entity(name, filePath, importedBy) {
9
+ if (!importedBy)
10
+ return { name, filePath };
11
+ // Convert simplified { filePath: [importerName, ...] } to DB format
12
+ const dbFormat = {};
13
+ for (const [fp, importers] of Object.entries(importedBy)) {
14
+ dbFormat[fp] = {};
15
+ for (const imp of importers) {
16
+ dbFormat[fp][imp] = { shas: ['test-sha'] };
17
+ }
18
+ }
19
+ return { name, filePath, importedBy: dbFormat };
20
+ }
21
+ /** Helper: get sorted impactedBy names from a result */
22
+ function impactNames(result, entityName) {
23
+ return (result[entityName]?.impactedBy || []).map((d) => d.name).sort();
24
+ }
25
+ // ── Tests ────────────────────────────────────────────────────────────────
26
+ describe('entityChangeStatus', () => {
27
+ // ── buildChangedFilesMap ───────────────────────────────────────────────
28
+ describe('buildChangedFilesMap', () => {
29
+ it('should map "added" files to "new"', () => {
30
+ const files = [
31
+ { path: 'components/New.tsx', status: 'added' },
32
+ ];
33
+ const result = buildChangedFilesMap(files, false);
34
+ expect(result.get('components/New.tsx')).toBe('new');
35
+ });
36
+ it('should map "untracked" files to "new"', () => {
37
+ const files = [
38
+ { path: 'components/Untracked.tsx', status: 'untracked' },
39
+ ];
40
+ const result = buildChangedFilesMap(files, false);
41
+ expect(result.get('components/Untracked.tsx')).toBe('new');
42
+ });
43
+ it('should map "modified" files to "edited"', () => {
44
+ const files = [
45
+ { path: 'components/Modified.tsx', status: 'modified' },
46
+ ];
47
+ const result = buildChangedFilesMap(files, false);
48
+ expect(result.get('components/Modified.tsx')).toBe('edited');
49
+ });
50
+ it('should ignore "deleted" files', () => {
51
+ const files = [
52
+ { path: 'components/Deleted.tsx', status: 'deleted' },
53
+ ];
54
+ const result = buildChangedFilesMap(files, false);
55
+ expect(result.size).toBe(0);
56
+ });
57
+ it('should ignore "renamed" files', () => {
58
+ const files = [
59
+ { path: 'components/Renamed.tsx', status: 'renamed' },
60
+ ];
61
+ const result = buildChangedFilesMap(files, false);
62
+ expect(result.size).toBe(0);
63
+ });
64
+ it('should treat ALL non-deleted files as "new" when isFirstFeature is true', () => {
65
+ const files = [
66
+ { path: 'components/Modified.tsx', status: 'modified' },
67
+ { path: 'components/Added.tsx', status: 'added' },
68
+ { path: 'components/Untracked.tsx', status: 'untracked' },
69
+ { path: 'components/Renamed.tsx', status: 'renamed' },
70
+ { path: 'components/Deleted.tsx', status: 'deleted' },
71
+ ];
72
+ const result = buildChangedFilesMap(files, true);
73
+ // Everything except deleted should be 'new'
74
+ expect(result.get('components/Modified.tsx')).toBe('new');
75
+ expect(result.get('components/Added.tsx')).toBe('new');
76
+ expect(result.get('components/Untracked.tsx')).toBe('new');
77
+ expect(result.get('components/Renamed.tsx')).toBe('new');
78
+ expect(result.has('components/Deleted.tsx')).toBe(false);
79
+ });
80
+ it('should return empty map for empty input', () => {
81
+ expect(buildChangedFilesMap([], false).size).toBe(0);
82
+ expect(buildChangedFilesMap([], true).size).toBe(0);
83
+ });
84
+ it('should handle mix of statuses correctly', () => {
85
+ const files = [
86
+ { path: 'a.tsx', status: 'added' },
87
+ { path: 'b.tsx', status: 'modified' },
88
+ { path: 'c.tsx', status: 'untracked' },
89
+ { path: 'd.tsx', status: 'deleted' },
90
+ ];
91
+ const result = buildChangedFilesMap(files, false);
92
+ expect(result.get('a.tsx')).toBe('new');
93
+ expect(result.get('b.tsx')).toBe('edited');
94
+ expect(result.get('c.tsx')).toBe('new');
95
+ expect(result.has('d.tsx')).toBe(false);
96
+ expect(result.size).toBe(3);
97
+ });
98
+ });
99
+ // ── pageNameFromUrl ───────────────────────────────────────────────────
100
+ describe('pageNameFromUrl', () => {
101
+ it('should return "Home" for null', () => {
102
+ expect(pageNameFromUrl(null)).toBe('Home');
103
+ });
104
+ it('should return "Home" for undefined', () => {
105
+ expect(pageNameFromUrl(undefined)).toBe('Home');
106
+ });
107
+ it('should return "Home" for root path "/"', () => {
108
+ expect(pageNameFromUrl('/')).toBe('Home');
109
+ });
110
+ it('should capitalize first segment for simple paths', () => {
111
+ expect(pageNameFromUrl('/about')).toBe('About');
112
+ expect(pageNameFromUrl('/drinks')).toBe('Drinks');
113
+ expect(pageNameFromUrl('/settings')).toBe('Settings');
114
+ });
115
+ it('should use only the first segment for nested paths', () => {
116
+ expect(pageNameFromUrl('/drinks/123')).toBe('Drinks');
117
+ expect(pageNameFromUrl('/blog/posts/latest')).toBe('Blog');
118
+ });
119
+ it('should strip query parameters', () => {
120
+ expect(pageNameFromUrl('/settings?tab=profile')).toBe('Settings');
121
+ expect(pageNameFromUrl('/drinks?sort=name&filter=ipa')).toBe('Drinks');
122
+ });
123
+ it('should handle paths without leading slash', () => {
124
+ expect(pageNameFromUrl('about')).toBe('About');
125
+ });
126
+ it('should handle empty string as Home', () => {
127
+ // Empty string: after stripping '/', split gives [''], charAt(0) is ''
128
+ // This is an edge case — empty string is falsy so returns Home
129
+ expect(pageNameFromUrl('')).toBe('Home');
130
+ });
131
+ it('should return Home for root path with query string', () => {
132
+ expect(pageNameFromUrl('/?demo=empty')).toBe('Home');
133
+ expect(pageNameFromUrl('/?state=seeded')).toBe('Home');
134
+ expect(pageNameFromUrl('/?tab=overview&sort=date')).toBe('Home');
135
+ });
136
+ it('should handle dynamic route segments', () => {
137
+ expect(pageNameFromUrl('/drinks/[id]')).toBe('Drinks');
138
+ expect(pageNameFromUrl('/users/[userId]/posts')).toBe('Users');
139
+ });
140
+ });
141
+ // ── buildEntityInfosFromScenarios ─────────────────────────────────────
142
+ describe('buildEntityInfosFromScenarios', () => {
143
+ it('should extract component entity from scenario with componentName/componentPath', () => {
144
+ const scenarios = [
145
+ {
146
+ componentName: 'DrinkCard',
147
+ componentPath: 'components/DrinkCard.tsx',
148
+ },
149
+ ];
150
+ const result = buildEntityInfosFromScenarios(scenarios, {}, []);
151
+ expect(result).toEqual([
152
+ { name: 'DrinkCard', filePath: 'components/DrinkCard.tsx' },
153
+ ]);
154
+ });
155
+ it('should extract page entity from scenario with URL', () => {
156
+ const scenarios = [
157
+ { componentName: null, componentPath: null, url: '/' },
158
+ ];
159
+ const pageFilePaths = { Home: 'app/page.tsx' };
160
+ const result = buildEntityInfosFromScenarios(scenarios, pageFilePaths, []);
161
+ expect(result).toEqual([{ name: 'Home', filePath: 'app/page.tsx' }]);
162
+ });
163
+ it('should derive page name from URL using pageNameFromUrl', () => {
164
+ const scenarios = [
165
+ { componentName: null, componentPath: null, url: '/drinks/123' },
166
+ ];
167
+ const pageFilePaths = { Drinks: 'app/drinks/page.tsx' };
168
+ const result = buildEntityInfosFromScenarios(scenarios, pageFilePaths, []);
169
+ expect(result).toEqual([
170
+ { name: 'Drinks', filePath: 'app/drinks/page.tsx' },
171
+ ]);
172
+ });
173
+ it('should skip page scenarios when page file path is not found', () => {
174
+ const scenarios = [
175
+ { componentName: null, componentPath: null, url: '/nonexistent' },
176
+ ];
177
+ const result = buildEntityInfosFromScenarios(scenarios, {}, []);
178
+ expect(result).toEqual([]);
179
+ });
180
+ it('should deduplicate by entity name (first occurrence wins)', () => {
181
+ const scenarios = [
182
+ {
183
+ componentName: 'DrinkCard',
184
+ componentPath: 'components/DrinkCard.tsx',
185
+ },
186
+ {
187
+ componentName: 'DrinkCard',
188
+ componentPath: 'components/DrinkCard.tsx',
189
+ },
190
+ {
191
+ componentName: 'DrinkCard',
192
+ componentPath: 'components/DrinkCard2.tsx',
193
+ },
194
+ ];
195
+ const result = buildEntityInfosFromScenarios(scenarios, {}, []);
196
+ expect(result).toHaveLength(1);
197
+ expect(result[0].filePath).toBe('components/DrinkCard.tsx');
198
+ });
199
+ it('should enrich entities with importedBy metadata from entity DB', () => {
200
+ const scenarios = [
201
+ {
202
+ componentName: 'DrinkCard',
203
+ componentPath: 'components/DrinkCard.tsx',
204
+ },
205
+ ];
206
+ const entitiesWithMetadata = [
207
+ {
208
+ name: 'DrinkCard',
209
+ metadata: {
210
+ importedBy: {
211
+ 'components/DrinkCard.tsx': {
212
+ DrinkList: { shas: ['abc'] },
213
+ },
214
+ },
215
+ },
216
+ },
217
+ ];
218
+ const result = buildEntityInfosFromScenarios(scenarios, {}, entitiesWithMetadata);
219
+ expect(result[0].importedBy).toEqual({
220
+ 'components/DrinkCard.tsx': {
221
+ DrinkList: { shas: ['abc'] },
222
+ },
223
+ });
224
+ });
225
+ it('should handle entities without matching metadata (no importedBy)', () => {
226
+ const scenarios = [
227
+ {
228
+ componentName: 'DrinkCard',
229
+ componentPath: 'components/DrinkCard.tsx',
230
+ },
231
+ ];
232
+ const entitiesWithMetadata = [
233
+ { name: 'UnrelatedEntity', metadata: null },
234
+ ];
235
+ const result = buildEntityInfosFromScenarios(scenarios, {}, entitiesWithMetadata);
236
+ expect(result[0].importedBy).toBeUndefined();
237
+ });
238
+ it('should handle mixed component and page scenarios', () => {
239
+ const scenarios = [
240
+ {
241
+ componentName: 'DrinkCard',
242
+ componentPath: 'components/DrinkCard.tsx',
243
+ },
244
+ { componentName: null, componentPath: null, url: '/' },
245
+ { componentName: 'Header', componentPath: 'components/Header.tsx' },
246
+ { componentName: null, componentPath: null, url: '/drinks' },
247
+ ];
248
+ const pageFilePaths = {
249
+ Home: 'app/page.tsx',
250
+ Drinks: 'app/drinks/page.tsx',
251
+ };
252
+ const result = buildEntityInfosFromScenarios(scenarios, pageFilePaths, []);
253
+ expect(result.map((e) => e.name)).toEqual([
254
+ 'DrinkCard',
255
+ 'Home',
256
+ 'Header',
257
+ 'Drinks',
258
+ ]);
259
+ });
260
+ it('should skip scenarios with componentName but no componentPath', () => {
261
+ const scenarios = [
262
+ { componentName: 'DrinkCard', componentPath: null },
263
+ ];
264
+ const result = buildEntityInfosFromScenarios(scenarios, {}, []);
265
+ expect(result).toEqual([]);
266
+ });
267
+ it('should handle empty inputs', () => {
268
+ expect(buildEntityInfosFromScenarios([], {}, [])).toEqual([]);
269
+ });
270
+ it('should handle scenarios where url is undefined (not a page scenario)', () => {
271
+ // A scenario with no componentName and no url — should be skipped
272
+ const scenarios = [
273
+ { componentName: null, componentPath: null },
274
+ ];
275
+ const result = buildEntityInfosFromScenarios(scenarios, {}, []);
276
+ expect(result).toEqual([]);
277
+ });
278
+ it('should handle entity metadata with null metadata field', () => {
279
+ const scenarios = [
280
+ { componentName: 'Card', componentPath: 'components/Card.tsx' },
281
+ ];
282
+ const metadata = [
283
+ { name: 'Card', metadata: null },
284
+ ];
285
+ const result = buildEntityInfosFromScenarios(scenarios, {}, metadata);
286
+ expect(result[0].importedBy).toBeUndefined();
287
+ });
288
+ });
289
+ // ── buildReverseDependencyGraph ──────────────────────────────────────
290
+ describe('buildReverseDependencyGraph', () => {
291
+ it('should return empty map for empty entities', () => {
292
+ expect(buildReverseDependencyGraph([]).size).toBe(0);
293
+ });
294
+ it('should build reverse graph from a single importer', () => {
295
+ const result = buildReverseDependencyGraph([
296
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
297
+ 'components/DrinkCard.tsx': ['DrinkList'],
298
+ }),
299
+ ]);
300
+ expect(result.get('DrinkCard')).toEqual(new Set(['DrinkList']));
301
+ });
302
+ it('should collect multiple importers across file paths', () => {
303
+ const result = buildReverseDependencyGraph([
304
+ entity('Button', 'components/Button.tsx', {
305
+ 'components/Button.tsx': ['LoginForm'],
306
+ 'lib/ui/Button.tsx': ['SignupForm'],
307
+ }),
308
+ ]);
309
+ expect(result.get('Button')).toEqual(new Set(['LoginForm', 'SignupForm']));
310
+ });
311
+ it('should deduplicate importers appearing under multiple file paths', () => {
312
+ const result = buildReverseDependencyGraph([
313
+ entity('Icon', 'components/Icon.tsx', {
314
+ 'components/Icon.tsx': ['Header'],
315
+ 'lib/Icon.tsx': ['Header'],
316
+ }),
317
+ ]);
318
+ expect(result.get('Icon')).toEqual(new Set(['Header']));
319
+ });
320
+ it('should skip entities with no importedBy metadata', () => {
321
+ const result = buildReverseDependencyGraph([
322
+ entity('Orphan', 'components/Orphan.tsx'),
323
+ entity('Used', 'components/Used.tsx', {
324
+ 'components/Used.tsx': ['Parent'],
325
+ }),
326
+ ]);
327
+ expect(result.has('Orphan')).toBe(false);
328
+ expect(result.get('Used')).toEqual(new Set(['Parent']));
329
+ });
330
+ it('should handle empty importedBy object gracefully', () => {
331
+ const entities = [
332
+ { name: 'Empty', filePath: 'components/Empty.tsx', importedBy: {} },
333
+ ];
334
+ const result = buildReverseDependencyGraph(entities);
335
+ expect(result.has('Empty')).toBe(false);
336
+ });
337
+ it('should handle multiple importers under a single file path', () => {
338
+ const result = buildReverseDependencyGraph([
339
+ entity('utils', 'lib/utils.ts', {
340
+ 'lib/utils.ts': ['DrinkCard', 'DrinkList', 'Header'],
341
+ }),
342
+ ]);
343
+ expect(result.get('utils')).toEqual(new Set(['DrinkCard', 'DrinkList', 'Header']));
344
+ });
345
+ });
346
+ // ── classifyDirectChanges ────────────────────────────────────────────
347
+ describe('classifyDirectChanges', () => {
348
+ it('should classify entities whose file paths match changed files', () => {
349
+ const changedFiles = new Map([
350
+ ['components/DrinkCard.tsx', 'edited'],
351
+ ['app/page.tsx', 'new'],
352
+ ]);
353
+ const entities = [
354
+ entity('DrinkCard', 'components/DrinkCard.tsx'),
355
+ entity('Home', 'app/page.tsx'),
356
+ entity('Header', 'components/Header.tsx'),
357
+ ];
358
+ const result = classifyDirectChanges(changedFiles, entities);
359
+ expect(result.get('DrinkCard')).toBe('edited');
360
+ expect(result.get('Home')).toBe('new');
361
+ expect(result.has('Header')).toBe(false);
362
+ });
363
+ it('should return empty map when no files match', () => {
364
+ const changedFiles = new Map([
365
+ ['unrelated/file.ts', 'edited'],
366
+ ]);
367
+ const result = classifyDirectChanges(changedFiles, [
368
+ entity('DrinkCard', 'components/DrinkCard.tsx'),
369
+ ]);
370
+ expect(result.size).toBe(0);
371
+ });
372
+ it('should return empty map for empty inputs', () => {
373
+ expect(classifyDirectChanges(new Map(), [])).toEqual(new Map());
374
+ });
375
+ it('should match file paths exactly (no normalization)', () => {
376
+ const changedFiles = new Map([
377
+ ['components/DrinkCard.tsx', 'edited'],
378
+ ]);
379
+ // Slightly different path should NOT match
380
+ const result = classifyDirectChanges(changedFiles, [
381
+ entity('DrinkCard', './components/DrinkCard.tsx'),
382
+ ]);
383
+ expect(result.size).toBe(0);
384
+ });
385
+ });
386
+ // ── computeEntityChangeStatus ────────────────────────────────────────
387
+ describe('computeEntityChangeStatus', () => {
388
+ // ── Basic classification ───────────────────────────────────────────
389
+ it('should mark entity as "new" when its file is added', () => {
390
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'new']]), [entity('DrinkCard', 'components/DrinkCard.tsx')]);
391
+ expect(result['DrinkCard']).toEqual({ status: 'new' });
392
+ });
393
+ it('should mark entity as "edited" when its file is modified', () => {
394
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'edited']]), [entity('DrinkCard', 'components/DrinkCard.tsx')]);
395
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
396
+ });
397
+ it('should omit unchanged entities with no impacted deps', () => {
398
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'edited']]), [
399
+ entity('DrinkCard', 'components/DrinkCard.tsx'),
400
+ entity('Header', 'components/Header.tsx'),
401
+ ]);
402
+ expect(result['DrinkCard']).toBeDefined();
403
+ expect(result['Header']).toBeUndefined();
404
+ });
405
+ it('should return empty map for empty inputs', () => {
406
+ expect(computeEntityChangeStatus(new Map(), [])).toEqual({});
407
+ });
408
+ it('should return empty map when changedFiles has entries but no entity matches', () => {
409
+ const result = computeEntityChangeStatus(new Map([['unrelated/file.ts', 'edited']]), [entity('DrinkCard', 'components/DrinkCard.tsx')]);
410
+ expect(result).toEqual({});
411
+ });
412
+ // ── Single-level impact ────────────────────────────────────────────
413
+ it('should mark single-level impact: A imports B, B edited → A impacted by B', () => {
414
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'edited']]), [
415
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
416
+ 'components/DrinkCard.tsx': ['DrinkList'],
417
+ }),
418
+ entity('DrinkList', 'components/DrinkList.tsx'),
419
+ ]);
420
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
421
+ expect(result['DrinkList']).toEqual({
422
+ status: 'impacted',
423
+ impactedBy: [
424
+ {
425
+ name: 'DrinkCard',
426
+ filePath: 'components/DrinkCard.tsx',
427
+ changeType: 'edited',
428
+ },
429
+ ],
430
+ });
431
+ });
432
+ it('should preserve changeType correctly: "new" root cause shows as "new" in impactedBy', () => {
433
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'new']]), [
434
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
435
+ 'components/DrinkCard.tsx': ['DrinkList'],
436
+ }),
437
+ entity('DrinkList', 'components/DrinkList.tsx'),
438
+ ]);
439
+ expect(result['DrinkList']?.impactedBy?.[0]?.changeType).toBe('new');
440
+ });
441
+ // ── Multi-level transitive ─────────────────────────────────────────
442
+ it('should trace multi-level transitive: Icon→DrinkCard→DrinkList, Icon edited → all impacted by Icon', () => {
443
+ const result = computeEntityChangeStatus(new Map([['components/Icon.tsx', 'edited']]), [
444
+ entity('Icon', 'components/Icon.tsx', {
445
+ 'components/Icon.tsx': ['DrinkCard'],
446
+ }),
447
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
448
+ 'components/DrinkCard.tsx': ['DrinkList'],
449
+ }),
450
+ entity('DrinkList', 'components/DrinkList.tsx'),
451
+ ]);
452
+ expect(result['Icon']).toEqual({ status: 'edited' });
453
+ // Both should trace back to Icon as root cause (not intermediate DrinkCard)
454
+ expect(result['DrinkCard']?.impactedBy).toEqual([
455
+ { name: 'Icon', filePath: 'components/Icon.tsx', changeType: 'edited' },
456
+ ]);
457
+ expect(result['DrinkList']?.impactedBy).toEqual([
458
+ { name: 'Icon', filePath: 'components/Icon.tsx', changeType: 'edited' },
459
+ ]);
460
+ });
461
+ // ── Multiple root causes ───────────────────────────────────────────
462
+ it('should track multiple root causes: DrinkCard imports Icon and utils (both changed)', () => {
463
+ const result = computeEntityChangeStatus(new Map([
464
+ ['components/Icon.tsx', 'edited'],
465
+ ['lib/utils.ts', 'new'],
466
+ ]), [
467
+ entity('Icon', 'components/Icon.tsx', {
468
+ 'components/Icon.tsx': ['DrinkCard'],
469
+ }),
470
+ entity('utils', 'lib/utils.ts', {
471
+ 'lib/utils.ts': ['DrinkCard'],
472
+ }),
473
+ entity('DrinkCard', 'components/DrinkCard.tsx'),
474
+ ]);
475
+ expect(result['DrinkCard']?.status).toBe('impacted');
476
+ expect(impactNames(result, 'DrinkCard')).toEqual(['Icon', 'utils']);
477
+ // Verify changeTypes are preserved
478
+ const iconDep = result['DrinkCard']?.impactedBy?.find((d) => d.name === 'Icon');
479
+ const utilsDep = result['DrinkCard']?.impactedBy?.find((d) => d.name === 'utils');
480
+ expect(iconDep?.changeType).toBe('edited');
481
+ expect(utilsDep?.changeType).toBe('new');
482
+ });
483
+ // ── Diamond dependency (BFS root cause propagation) ────────────────
484
+ it('should propagate ALL root causes through diamond: D1→A→C→E, D2→B→C→E', () => {
485
+ // This tests that when two paths converge at C and C has downstream
486
+ // importers (E), ALL root causes propagate through — not just the
487
+ // ones from whichever path was processed first.
488
+ //
489
+ // D1(edited) ──importedBy──→ A ──importedBy──→ C ──importedBy──→ E
490
+ // D2(new) ──importedBy──→ B ──importedBy──→ C
491
+ //
492
+ const result = computeEntityChangeStatus(new Map([
493
+ ['components/D1.tsx', 'edited'],
494
+ ['components/D2.tsx', 'new'],
495
+ ]), [
496
+ entity('D1', 'components/D1.tsx', {
497
+ 'components/D1.tsx': ['A'],
498
+ }),
499
+ entity('D2', 'components/D2.tsx', {
500
+ 'components/D2.tsx': ['B'],
501
+ }),
502
+ entity('A', 'components/A.tsx', {
503
+ 'components/A.tsx': ['C'],
504
+ }),
505
+ entity('B', 'components/B.tsx', {
506
+ 'components/B.tsx': ['C'],
507
+ }),
508
+ entity('C', 'components/C.tsx', {
509
+ 'components/C.tsx': ['E'],
510
+ }),
511
+ entity('E', 'components/E.tsx'),
512
+ ]);
513
+ // C should have both root causes
514
+ expect(result['C']?.status).toBe('impacted');
515
+ expect(impactNames(result, 'C')).toEqual(['D1', 'D2']);
516
+ // E (downstream of C) must also have both root causes
517
+ expect(result['E']?.status).toBe('impacted');
518
+ expect(impactNames(result, 'E')).toEqual(['D1', 'D2']);
519
+ });
520
+ it('should propagate root causes through diamond with unequal path lengths', () => {
521
+ // D1 reaches C directly (depth 1), D2 reaches C via B (depth 2).
522
+ // C has downstream E. E must get both root causes.
523
+ //
524
+ // D1(edited) ──importedBy──→ C ──importedBy──→ E
525
+ // D2(new) ──importedBy──→ B ──importedBy──→ C
526
+ //
527
+ const result = computeEntityChangeStatus(new Map([
528
+ ['components/D1.tsx', 'edited'],
529
+ ['components/D2.tsx', 'new'],
530
+ ]), [
531
+ entity('D1', 'components/D1.tsx', {
532
+ 'components/D1.tsx': ['C'],
533
+ }),
534
+ entity('D2', 'components/D2.tsx', {
535
+ 'components/D2.tsx': ['B'],
536
+ }),
537
+ entity('B', 'components/B.tsx', {
538
+ 'components/B.tsx': ['C'],
539
+ }),
540
+ entity('C', 'components/C.tsx', {
541
+ 'components/C.tsx': ['E'],
542
+ }),
543
+ entity('E', 'components/E.tsx'),
544
+ ]);
545
+ expect(impactNames(result, 'C')).toEqual(['D1', 'D2']);
546
+ expect(impactNames(result, 'E')).toEqual(['D1', 'D2']);
547
+ });
548
+ // ── Cycles ─────────────────────────────────────────────────────────
549
+ it('should handle simple cycle without infinite loop: A↔B, A edited', () => {
550
+ const result = computeEntityChangeStatus(new Map([['components/A.tsx', 'edited']]), [
551
+ entity('A', 'components/A.tsx', {
552
+ 'components/A.tsx': ['B'],
553
+ }),
554
+ entity('B', 'components/B.tsx', {
555
+ 'components/B.tsx': ['A'],
556
+ }),
557
+ ]);
558
+ expect(result['A']).toEqual({ status: 'edited' });
559
+ expect(result['B']).toEqual({
560
+ status: 'impacted',
561
+ impactedBy: [
562
+ { name: 'A', filePath: 'components/A.tsx', changeType: 'edited' },
563
+ ],
564
+ });
565
+ });
566
+ it('should handle three-node cycle: A→B→C→A, A edited', () => {
567
+ const result = computeEntityChangeStatus(new Map([['components/A.tsx', 'edited']]), [
568
+ entity('A', 'components/A.tsx', {
569
+ 'components/A.tsx': ['B'],
570
+ }),
571
+ entity('B', 'components/B.tsx', {
572
+ 'components/B.tsx': ['C'],
573
+ }),
574
+ entity('C', 'components/C.tsx', {
575
+ 'components/C.tsx': ['A'],
576
+ }),
577
+ ]);
578
+ expect(result['A']).toEqual({ status: 'edited' });
579
+ expect(result['B']?.status).toBe('impacted');
580
+ expect(result['C']?.status).toBe('impacted');
581
+ expect(impactNames(result, 'B')).toEqual(['A']);
582
+ expect(impactNames(result, 'C')).toEqual(['A']);
583
+ });
584
+ it('should handle cycle with multiple seeds: A→B→C→A, A and C both edited', () => {
585
+ const result = computeEntityChangeStatus(new Map([
586
+ ['components/A.tsx', 'edited'],
587
+ ['components/C.tsx', 'new'],
588
+ ]), [
589
+ entity('A', 'components/A.tsx', {
590
+ 'components/A.tsx': ['B'],
591
+ }),
592
+ entity('B', 'components/B.tsx', {
593
+ 'components/B.tsx': ['C'],
594
+ }),
595
+ entity('C', 'components/C.tsx', {
596
+ 'components/C.tsx': ['A'],
597
+ }),
598
+ ]);
599
+ // A and C directly changed
600
+ expect(result['A']).toEqual({ status: 'edited' });
601
+ expect(result['C']).toEqual({ status: 'new' });
602
+ // B is impacted by both (A importedBy B via A→B, C importedBy B via C→...→B)
603
+ expect(result['B']?.status).toBe('impacted');
604
+ expect(impactNames(result, 'B')).toEqual(['A', 'C']);
605
+ });
606
+ it('should handle self-import cycle gracefully', () => {
607
+ // Entity lists itself as its own importer
608
+ const result = computeEntityChangeStatus(new Map([['components/A.tsx', 'edited']]), [
609
+ entity('A', 'components/A.tsx', {
610
+ 'components/A.tsx': ['A'],
611
+ }),
612
+ ]);
613
+ // A is directly changed — self-loop should not create an impacted entry
614
+ expect(result['A']).toEqual({ status: 'edited' });
615
+ });
616
+ // ── Direct change priority ─────────────────────────────────────────
617
+ it('should NOT mark directly-changed entities as impacted even if they import other changed entities', () => {
618
+ const result = computeEntityChangeStatus(new Map([
619
+ ['components/A.tsx', 'edited'],
620
+ ['components/B.tsx', 'new'],
621
+ ]), [
622
+ entity('A', 'components/A.tsx', {
623
+ 'components/A.tsx': ['B'],
624
+ }),
625
+ entity('B', 'components/B.tsx'),
626
+ ]);
627
+ expect(result['A']).toEqual({ status: 'edited' });
628
+ expect(result['B']).toEqual({ status: 'new' });
629
+ });
630
+ it('should not produce impacted entries when ALL entities are directly changed', () => {
631
+ const result = computeEntityChangeStatus(new Map([
632
+ ['components/A.tsx', 'edited'],
633
+ ['components/B.tsx', 'new'],
634
+ ['components/C.tsx', 'edited'],
635
+ ]), [
636
+ entity('A', 'components/A.tsx', {
637
+ 'components/A.tsx': ['B'],
638
+ }),
639
+ entity('B', 'components/B.tsx', {
640
+ 'components/B.tsx': ['C'],
641
+ }),
642
+ entity('C', 'components/C.tsx'),
643
+ ]);
644
+ expect(Object.values(result).every((s) => s.status !== 'impacted')).toBe(true);
645
+ expect(result['A']?.status).toBe('edited');
646
+ expect(result['B']?.status).toBe('new');
647
+ expect(result['C']?.status).toBe('edited');
648
+ });
649
+ // ── Missing metadata / graceful degradation ────────────────────────
650
+ it('should handle missing importedBy metadata gracefully (no impact propagation)', () => {
651
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'edited']]), [
652
+ entity('DrinkCard', 'components/DrinkCard.tsx'),
653
+ entity('DrinkList', 'components/DrinkList.tsx'),
654
+ ]);
655
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
656
+ expect(result['DrinkList']).toBeUndefined();
657
+ });
658
+ it('should skip phantom importers (importedBy references entity not in entities list)', () => {
659
+ // DrinkCard's importedBy references "PhantomPage" which is not in entities
660
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'edited']]), [
661
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
662
+ 'components/DrinkCard.tsx': ['PhantomPage', 'DrinkList'],
663
+ }),
664
+ entity('DrinkList', 'components/DrinkList.tsx'),
665
+ ]);
666
+ // PhantomPage should not appear in results (not in entities list)
667
+ expect(result['PhantomPage']).toBeUndefined();
668
+ // DrinkList should still be impacted
669
+ expect(result['DrinkList']?.status).toBe('impacted');
670
+ });
671
+ // ── maxDepth ───────────────────────────────────────────────────────
672
+ it('should enforce maxDepth and stop propagation beyond limit', () => {
673
+ // Chain: D → C → B → A
674
+ const result = computeEntityChangeStatus(new Map([['components/D.tsx', 'edited']]), [
675
+ entity('D', 'components/D.tsx', { 'components/D.tsx': ['C'] }),
676
+ entity('C', 'components/C.tsx', { 'components/C.tsx': ['B'] }),
677
+ entity('B', 'components/B.tsx', { 'components/B.tsx': ['A'] }),
678
+ entity('A', 'components/A.tsx'),
679
+ ], 2);
680
+ expect(result['D']).toEqual({ status: 'edited' });
681
+ expect(result['C']?.status).toBe('impacted');
682
+ expect(result['B']?.status).toBe('impacted');
683
+ // A at depth 3 is cut off
684
+ expect(result['A']).toBeUndefined();
685
+ });
686
+ it('should use default maxDepth of 20 when not specified', () => {
687
+ // Build a chain of depth 15 — should all be reached with default maxDepth
688
+ const entities = [];
689
+ for (let i = 0; i < 16; i++) {
690
+ const name = `E${i}`;
691
+ const filePath = `components/${name}.tsx`;
692
+ if (i < 15) {
693
+ entities.push(entity(name, filePath, { [filePath]: [`E${i + 1}`] }));
694
+ }
695
+ else {
696
+ entities.push(entity(name, filePath));
697
+ }
698
+ }
699
+ const result = computeEntityChangeStatus(new Map([['components/E0.tsx', 'edited']]), entities);
700
+ // E15 at depth 15 should still be reached (within default maxDepth of 20)
701
+ expect(result['E15']?.status).toBe('impacted');
702
+ });
703
+ // ── Fan-out ────────────────────────────────────────────────────────
704
+ it('should handle large fan-out: one changed entity imported by many', () => {
705
+ const importers = Array.from({ length: 10 }, (_, i) => `Comp${i}`);
706
+ const entities = [
707
+ entity('SharedUtil', 'lib/shared.ts', {
708
+ 'lib/shared.ts': importers,
709
+ }),
710
+ ...importers.map((name) => entity(name, `components/${name}.tsx`)),
711
+ ];
712
+ const result = computeEntityChangeStatus(new Map([['lib/shared.ts', 'edited']]), entities);
713
+ expect(result['SharedUtil']).toEqual({ status: 'edited' });
714
+ for (const name of importers) {
715
+ expect(result[name]?.status).toBe('impacted');
716
+ expect(result[name]?.impactedBy).toEqual([
717
+ {
718
+ name: 'SharedUtil',
719
+ filePath: 'lib/shared.ts',
720
+ changeType: 'edited',
721
+ },
722
+ ]);
723
+ }
724
+ });
725
+ // ── Deterministic output ───────────────────────────────────────────
726
+ it('should sort impactedBy array by name for deterministic output', () => {
727
+ const result = computeEntityChangeStatus(new Map([
728
+ ['components/Zebra.tsx', 'edited'],
729
+ ['components/Apple.tsx', 'new'],
730
+ ['components/Mango.tsx', 'edited'],
731
+ ]), [
732
+ entity('Zebra', 'components/Zebra.tsx', {
733
+ 'components/Zebra.tsx': ['Target'],
734
+ }),
735
+ entity('Apple', 'components/Apple.tsx', {
736
+ 'components/Apple.tsx': ['Target'],
737
+ }),
738
+ entity('Mango', 'components/Mango.tsx', {
739
+ 'components/Mango.tsx': ['Target'],
740
+ }),
741
+ entity('Target', 'components/Target.tsx'),
742
+ ]);
743
+ expect(impactNames(result, 'Target')).toEqual([
744
+ 'Apple',
745
+ 'Mango',
746
+ 'Zebra',
747
+ ]);
748
+ });
749
+ // ── Realistic integration-style scenarios ──────────────────────────
750
+ it('should compute correct status for a realistic Next.js app scenario', () => {
751
+ // Simulates a real editor session:
752
+ // - User edited DrinkCard component and added a new formatPrice utility
753
+ // - DrinkCard is imported by DrinkList component and Home page
754
+ // - formatPrice is imported by DrinkCard
755
+ // - Header is unchanged and imports nothing changed
756
+ //
757
+ // Expected:
758
+ // - DrinkCard: edited (directly changed)
759
+ // - formatPrice: new (directly changed)
760
+ // - DrinkList: impacted by [DrinkCard, formatPrice] (DrinkCard is edited,
761
+ // and formatPrice flows through DrinkCard)
762
+ // - Home: impacted by [DrinkCard, formatPrice] (same reasoning)
763
+ // - Header: not in results (unchanged, no changed deps)
764
+ const result = computeEntityChangeStatus(new Map([
765
+ ['components/DrinkCard.tsx', 'edited'],
766
+ ['lib/formatPrice.ts', 'new'],
767
+ ]), [
768
+ entity('formatPrice', 'lib/formatPrice.ts', {
769
+ 'lib/formatPrice.ts': ['DrinkCard'],
770
+ }),
771
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
772
+ 'components/DrinkCard.tsx': ['DrinkList', 'Home'],
773
+ }),
774
+ entity('DrinkList', 'components/DrinkList.tsx'),
775
+ entity('Home', 'app/page.tsx'),
776
+ entity('Header', 'components/Header.tsx'),
777
+ ]);
778
+ expect(result['formatPrice']).toEqual({ status: 'new' });
779
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
780
+ expect(result['Header']).toBeUndefined();
781
+ // DrinkList and Home are impacted by DrinkCard (directly, its an importer)
782
+ // AND by formatPrice (transitively via DrinkCard)
783
+ expect(result['DrinkList']?.status).toBe('impacted');
784
+ expect(impactNames(result, 'DrinkList')).toEqual([
785
+ 'DrinkCard',
786
+ 'formatPrice',
787
+ ]);
788
+ expect(result['Home']?.status).toBe('impacted');
789
+ expect(impactNames(result, 'Home')).toEqual(['DrinkCard', 'formatPrice']);
790
+ });
791
+ it('should handle a realistic scenario where component is both directly changed AND an intermediate', () => {
792
+ // DrinkCard is edited AND it imports formatPrice (also changed).
793
+ // DrinkCard should be marked 'edited' (direct), not 'impacted'.
794
+ // DrinkList imports DrinkCard → impacted by both DrinkCard and formatPrice.
795
+ const result = computeEntityChangeStatus(new Map([
796
+ ['components/DrinkCard.tsx', 'edited'],
797
+ ['lib/formatPrice.ts', 'new'],
798
+ ]), [
799
+ entity('formatPrice', 'lib/formatPrice.ts', {
800
+ 'lib/formatPrice.ts': ['DrinkCard'],
801
+ }),
802
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
803
+ 'components/DrinkCard.tsx': ['DrinkList'],
804
+ }),
805
+ entity('DrinkList', 'components/DrinkList.tsx'),
806
+ ]);
807
+ // DrinkCard is directly changed — status should be 'edited', no impactedBy
808
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
809
+ // DrinkList is impacted by BOTH root causes
810
+ expect(result['DrinkList']?.status).toBe('impacted');
811
+ expect(impactNames(result, 'DrinkList')).toEqual([
812
+ 'DrinkCard',
813
+ 'formatPrice',
814
+ ]);
815
+ });
816
+ // ── End-to-end pipeline test ───────────────────────────────────────
817
+ it('should produce correct results through the full pipeline: git files → scenarios → entity infos → status', () => {
818
+ // This tests the complete data flow that both route handlers execute:
819
+ // 1. buildChangedFilesMap from git status
820
+ // 2. buildEntityInfosFromScenarios from scenario data + metadata
821
+ // 3. computeEntityChangeStatus from the above
822
+ // Step 1: Git status
823
+ const gitFiles = [
824
+ { path: 'components/DrinkCard.tsx', status: 'modified' },
825
+ { path: 'lib/formatPrice.ts', status: 'added' },
826
+ { path: 'components/Header.tsx', status: 'deleted' },
827
+ ];
828
+ const changedFiles = buildChangedFilesMap(gitFiles, false);
829
+ expect(changedFiles.get('components/DrinkCard.tsx')).toBe('edited');
830
+ expect(changedFiles.get('lib/formatPrice.ts')).toBe('new');
831
+ expect(changedFiles.has('components/Header.tsx')).toBe(false);
832
+ // Step 2: Build entity infos from scenarios
833
+ const scenarios = [
834
+ {
835
+ componentName: 'DrinkCard',
836
+ componentPath: 'components/DrinkCard.tsx',
837
+ },
838
+ { componentName: null, componentPath: null, url: '/' },
839
+ {
840
+ componentName: 'DrinkList',
841
+ componentPath: 'components/DrinkList.tsx',
842
+ },
843
+ ];
844
+ const pageFilePaths = { Home: 'app/page.tsx' };
845
+ const metadata = [
846
+ {
847
+ name: 'DrinkCard',
848
+ metadata: {
849
+ importedBy: {
850
+ 'components/DrinkCard.tsx': {
851
+ DrinkList: { shas: ['sha1'] },
852
+ Home: { shas: ['sha2'] },
853
+ },
854
+ },
855
+ },
856
+ },
857
+ { name: 'DrinkList', metadata: null },
858
+ ];
859
+ const entityInfos = buildEntityInfosFromScenarios(scenarios, pageFilePaths, metadata);
860
+ expect(entityInfos).toHaveLength(3);
861
+ expect(entityInfos.find((e) => e.name === 'DrinkCard')?.importedBy).toBeDefined();
862
+ // Step 3: Compute status
863
+ const result = computeEntityChangeStatus(changedFiles, entityInfos);
864
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
865
+ expect(result['DrinkList']?.status).toBe('impacted');
866
+ expect(result['Home']?.status).toBe('impacted');
867
+ expect(impactNames(result, 'DrinkList')).toEqual(['DrinkCard']);
868
+ expect(impactNames(result, 'Home')).toEqual(['DrinkCard']);
869
+ });
870
+ it('should produce correct results for first-feature pipeline (all files new)', () => {
871
+ const gitFiles = [
872
+ { path: 'components/DrinkCard.tsx', status: 'modified' },
873
+ { path: 'app/page.tsx', status: 'modified' },
874
+ { path: 'components/Header.tsx', status: 'untracked' },
875
+ ];
876
+ // isFirstFeature = true → all become 'new'
877
+ const changedFiles = buildChangedFilesMap(gitFiles, true);
878
+ expect(changedFiles.get('components/DrinkCard.tsx')).toBe('new');
879
+ expect(changedFiles.get('app/page.tsx')).toBe('new');
880
+ expect(changedFiles.get('components/Header.tsx')).toBe('new');
881
+ const scenarios = [
882
+ {
883
+ componentName: 'DrinkCard',
884
+ componentPath: 'components/DrinkCard.tsx',
885
+ },
886
+ { componentName: null, componentPath: null, url: '/' },
887
+ { componentName: 'Header', componentPath: 'components/Header.tsx' },
888
+ ];
889
+ const entityInfos = buildEntityInfosFromScenarios(scenarios, { Home: 'app/page.tsx' }, []);
890
+ const result = computeEntityChangeStatus(changedFiles, entityInfos);
891
+ // All should be 'new' — none impacted
892
+ expect(result['DrinkCard']).toEqual({ status: 'new' });
893
+ expect(result['Home']).toEqual({ status: 'new' });
894
+ expect(result['Header']).toEqual({ status: 'new' });
895
+ expect(Object.values(result).some((s) => s.status === 'impacted')).toBe(false);
896
+ });
897
+ // ── Pass-through propagation ─────────────────────────────────────
898
+ it('should propagate root causes THROUGH a directly-changed intermediate entity', () => {
899
+ // D is edited, A is also edited, A importedBy B.
900
+ // D importedBy A (so D flows through A).
901
+ // B should be impacted by BOTH D (transitive through A) and A (direct importer).
902
+ //
903
+ // D(edited) ──importedBy──→ A(edited) ──importedBy──→ B
904
+ //
905
+ const result = computeEntityChangeStatus(new Map([
906
+ ['components/D.tsx', 'edited'],
907
+ ['components/A.tsx', 'edited'],
908
+ ]), [
909
+ entity('D', 'components/D.tsx', {
910
+ 'components/D.tsx': ['A'],
911
+ }),
912
+ entity('A', 'components/A.tsx', {
913
+ 'components/A.tsx': ['B'],
914
+ }),
915
+ entity('B', 'components/B.tsx'),
916
+ ]);
917
+ expect(result['D']).toEqual({ status: 'edited' });
918
+ expect(result['A']).toEqual({ status: 'edited' });
919
+ // B must see BOTH root causes — D propagated through A
920
+ expect(result['B']?.status).toBe('impacted');
921
+ expect(impactNames(result, 'B')).toEqual(['A', 'D']);
922
+ });
923
+ it('should propagate through a chain of directly-changed entities: D→C→B→A all changed except A', () => {
924
+ // D, C, B all directly changed. A imports B.
925
+ // A should see D, C, B as root causes.
926
+ const result = computeEntityChangeStatus(new Map([
927
+ ['components/D.tsx', 'edited'],
928
+ ['components/C.tsx', 'new'],
929
+ ['components/B.tsx', 'edited'],
930
+ ]), [
931
+ entity('D', 'components/D.tsx', { 'components/D.tsx': ['C'] }),
932
+ entity('C', 'components/C.tsx', { 'components/C.tsx': ['B'] }),
933
+ entity('B', 'components/B.tsx', { 'components/B.tsx': ['A'] }),
934
+ entity('A', 'components/A.tsx'),
935
+ ]);
936
+ expect(result['A']?.status).toBe('impacted');
937
+ expect(impactNames(result, 'A')).toEqual(['B', 'C', 'D']);
938
+ });
939
+ // ── Wide diamond ─────────────────────────────────────────────────
940
+ it('should handle wide diamond: 3+ paths converging then fanning out', () => {
941
+ // D1, D2, D3 all edited, all importedBy Merge, Merge importedBy [Out1, Out2]
942
+ //
943
+ // D1 ─┐
944
+ // D2 ─┼──importedBy──→ Merge ──importedBy──→ Out1
945
+ // D3 ─┘ └──importedBy──→ Out2
946
+ //
947
+ const result = computeEntityChangeStatus(new Map([
948
+ ['components/D1.tsx', 'edited'],
949
+ ['components/D2.tsx', 'new'],
950
+ ['components/D3.tsx', 'edited'],
951
+ ]), [
952
+ entity('D1', 'components/D1.tsx', { 'components/D1.tsx': ['Merge'] }),
953
+ entity('D2', 'components/D2.tsx', { 'components/D2.tsx': ['Merge'] }),
954
+ entity('D3', 'components/D3.tsx', { 'components/D3.tsx': ['Merge'] }),
955
+ entity('Merge', 'components/Merge.tsx', {
956
+ 'components/Merge.tsx': ['Out1', 'Out2'],
957
+ }),
958
+ entity('Out1', 'components/Out1.tsx'),
959
+ entity('Out2', 'components/Out2.tsx'),
960
+ ]);
961
+ // Merge gets all 3 root causes
962
+ expect(impactNames(result, 'Merge')).toEqual(['D1', 'D2', 'D3']);
963
+ // Out1 and Out2 also get all 3 root causes (propagated through Merge)
964
+ expect(impactNames(result, 'Out1')).toEqual(['D1', 'D2', 'D3']);
965
+ expect(impactNames(result, 'Out2')).toEqual(['D1', 'D2', 'D3']);
966
+ });
967
+ // ── changedFiles with non-entity entries ─────────────────────────
968
+ it('should ignore changedFiles entries that do not match any entity file path', () => {
969
+ // Git shows many changed files, but only some are entities
970
+ const result = computeEntityChangeStatus(new Map([
971
+ ['components/DrinkCard.tsx', 'edited'],
972
+ ['package.json', 'edited'],
973
+ ['README.md', 'new'],
974
+ ['.env', 'edited'],
975
+ ['tsconfig.json', 'edited'],
976
+ ]), [
977
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
978
+ 'components/DrinkCard.tsx': ['DrinkList'],
979
+ }),
980
+ entity('DrinkList', 'components/DrinkList.tsx'),
981
+ ]);
982
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
983
+ expect(result['DrinkList']?.status).toBe('impacted');
984
+ // Only entities should appear in results
985
+ expect(Object.keys(result)).toEqual(['DrinkCard', 'DrinkList']);
986
+ });
987
+ });
988
+ // ── buildEntityInfosFromScenarios — additional edge cases ────────
989
+ describe('buildEntityInfosFromScenarios (edge cases)', () => {
990
+ it('should deduplicate page scenarios with different URLs resolving to same name', () => {
991
+ // /drinks and /drinks/123 both resolve to "Drinks"
992
+ const scenarios = [
993
+ { componentName: null, componentPath: null, url: '/drinks' },
994
+ { componentName: null, componentPath: null, url: '/drinks/123' },
995
+ {
996
+ componentName: null,
997
+ componentPath: null,
998
+ url: '/drinks/456?tab=reviews',
999
+ },
1000
+ ];
1001
+ const pageFilePaths = { Drinks: 'app/drinks/page.tsx' };
1002
+ const result = buildEntityInfosFromScenarios(scenarios, pageFilePaths, []);
1003
+ // Should deduplicate to a single "Drinks" entity
1004
+ expect(result).toHaveLength(1);
1005
+ expect(result[0]).toEqual({
1006
+ name: 'Drinks',
1007
+ filePath: 'app/drinks/page.tsx',
1008
+ });
1009
+ });
1010
+ it('should handle component and page scenario with same conceptual name (component wins)', () => {
1011
+ // A component named "Home" AND a page URL "/" both produce name "Home"
1012
+ // Component scenario comes first — it should win
1013
+ const scenarios = [
1014
+ { componentName: 'Home', componentPath: 'components/Home.tsx' },
1015
+ { componentName: null, componentPath: null, url: '/' },
1016
+ ];
1017
+ const pageFilePaths = { Home: 'app/page.tsx' };
1018
+ const result = buildEntityInfosFromScenarios(scenarios, pageFilePaths, []);
1019
+ expect(result).toHaveLength(1);
1020
+ // Component path should win (first occurrence)
1021
+ expect(result[0].filePath).toBe('components/Home.tsx');
1022
+ });
1023
+ it('should handle multiple scenarios for the same component (only first is included)', () => {
1024
+ const scenarios = [
1025
+ {
1026
+ componentName: 'DrinkCard',
1027
+ componentPath: 'components/DrinkCard.tsx',
1028
+ },
1029
+ {
1030
+ componentName: 'DrinkCard',
1031
+ componentPath: 'components/DrinkCard.tsx',
1032
+ },
1033
+ {
1034
+ componentName: 'DrinkCard',
1035
+ componentPath: 'components/DrinkCard.tsx',
1036
+ },
1037
+ ];
1038
+ const metadata = [
1039
+ {
1040
+ name: 'DrinkCard',
1041
+ metadata: {
1042
+ importedBy: {
1043
+ 'components/DrinkCard.tsx': { DrinkList: { shas: ['abc'] } },
1044
+ },
1045
+ },
1046
+ },
1047
+ ];
1048
+ const result = buildEntityInfosFromScenarios(scenarios, {}, metadata);
1049
+ expect(result).toHaveLength(1);
1050
+ expect(result[0].importedBy).toBeDefined();
1051
+ });
1052
+ });
1053
+ // ── Full pipeline regression tests ───────────────────────────────
1054
+ describe('full pipeline regression tests', () => {
1055
+ it('regression: entity with changed file AND imported by other unchanged entities should propagate', () => {
1056
+ // This catches the old regex-based bug: previously, "impacted" was marked
1057
+ // for every entity not directly changed, even if no dependency was actually changed.
1058
+ // With the new system, only entities that transitively import a changed entity get impacted.
1059
+ const gitFiles = [
1060
+ { path: 'lib/formatPrice.ts', status: 'added' },
1061
+ ];
1062
+ const changedFiles = buildChangedFilesMap(gitFiles, false);
1063
+ const scenarios = [
1064
+ {
1065
+ componentName: 'DrinkCard',
1066
+ componentPath: 'components/DrinkCard.tsx',
1067
+ },
1068
+ { componentName: 'Header', componentPath: 'components/Header.tsx' },
1069
+ { componentName: null, componentPath: null, url: '/' },
1070
+ ];
1071
+ const pageFilePaths = { Home: 'app/page.tsx' };
1072
+ const metadata = [
1073
+ {
1074
+ name: 'formatPrice',
1075
+ metadata: {
1076
+ importedBy: {
1077
+ 'lib/formatPrice.ts': { DrinkCard: { shas: ['sha1'] } },
1078
+ },
1079
+ },
1080
+ },
1081
+ {
1082
+ name: 'DrinkCard',
1083
+ metadata: {
1084
+ importedBy: {
1085
+ 'components/DrinkCard.tsx': { Home: { shas: ['sha2'] } },
1086
+ },
1087
+ },
1088
+ },
1089
+ ];
1090
+ // formatPrice is not a scenario entity — only DrinkCard, Header, Home are.
1091
+ // But formatPrice IS in the metadata as importedBy DrinkCard.
1092
+ // We need to include formatPrice in entityInfos for the graph to work.
1093
+ // However, buildEntityInfosFromScenarios only builds from scenarios,
1094
+ // so formatPrice won't be in the entities list → DrinkCard won't be impacted.
1095
+ //
1096
+ // This test documents the current behavior: only entities from scenarios
1097
+ // participate in the change status graph. Changed files that aren't entities
1098
+ // don't propagate impact (they'd need to be in the entities list).
1099
+ const entityInfos = buildEntityInfosFromScenarios(scenarios, pageFilePaths, metadata);
1100
+ const result = computeEntityChangeStatus(changedFiles, entityInfos);
1101
+ // formatPrice is not in scenarios, so not in entityInfos
1102
+ expect(entityInfos.find((e) => e.name === 'formatPrice')).toBeUndefined();
1103
+ // No entity files match changed files → empty result
1104
+ // DrinkCard and Home are NOT impacted because formatPrice is not in entities
1105
+ expect(result['DrinkCard']).toBeUndefined();
1106
+ expect(result['Header']).toBeUndefined();
1107
+ expect(result['Home']).toBeUndefined();
1108
+ });
1109
+ it('pipeline: changed utility file that IS an entity propagates impact correctly', () => {
1110
+ // When the changed utility IS registered as a scenario entity,
1111
+ // the full chain works end-to-end.
1112
+ const gitFiles = [
1113
+ { path: 'lib/formatPrice.ts', status: 'added' },
1114
+ ];
1115
+ const changedFiles = buildChangedFilesMap(gitFiles, false);
1116
+ const scenarios = [
1117
+ { componentName: 'formatPrice', componentPath: 'lib/formatPrice.ts' },
1118
+ {
1119
+ componentName: 'DrinkCard',
1120
+ componentPath: 'components/DrinkCard.tsx',
1121
+ },
1122
+ { componentName: null, componentPath: null, url: '/' },
1123
+ ];
1124
+ const pageFilePaths = { Home: 'app/page.tsx' };
1125
+ const metadata = [
1126
+ {
1127
+ name: 'formatPrice',
1128
+ metadata: {
1129
+ importedBy: {
1130
+ 'lib/formatPrice.ts': { DrinkCard: { shas: ['sha1'] } },
1131
+ },
1132
+ },
1133
+ },
1134
+ {
1135
+ name: 'DrinkCard',
1136
+ metadata: {
1137
+ importedBy: {
1138
+ 'components/DrinkCard.tsx': { Home: { shas: ['sha2'] } },
1139
+ },
1140
+ },
1141
+ },
1142
+ ];
1143
+ const entityInfos = buildEntityInfosFromScenarios(scenarios, pageFilePaths, metadata);
1144
+ const result = computeEntityChangeStatus(changedFiles, entityInfos);
1145
+ expect(result['formatPrice']).toEqual({ status: 'new' });
1146
+ expect(result['DrinkCard']?.status).toBe('impacted');
1147
+ expect(impactNames(result, 'DrinkCard')).toEqual(['formatPrice']);
1148
+ expect(result['Home']?.status).toBe('impacted');
1149
+ expect(impactNames(result, 'Home')).toEqual(['formatPrice']);
1150
+ });
1151
+ it('pipeline: renamed file status is correctly ignored', () => {
1152
+ const gitFiles = [
1153
+ { path: 'components/OldName.tsx', status: 'renamed' },
1154
+ { path: 'components/DrinkCard.tsx', status: 'modified' },
1155
+ ];
1156
+ const changedFiles = buildChangedFilesMap(gitFiles, false);
1157
+ // Renamed file should not appear
1158
+ expect(changedFiles.has('components/OldName.tsx')).toBe(false);
1159
+ expect(changedFiles.get('components/DrinkCard.tsx')).toBe('edited');
1160
+ });
1161
+ it('pipeline: first feature mode with import graph still marks everything new', () => {
1162
+ // Even with importedBy metadata, first-feature mode should mark
1163
+ // all entities as 'new' with no 'impacted' entries.
1164
+ const gitFiles = [
1165
+ { path: 'components/DrinkCard.tsx', status: 'modified' },
1166
+ { path: 'app/page.tsx', status: 'modified' },
1167
+ ];
1168
+ const changedFiles = buildChangedFilesMap(gitFiles, true); // first feature!
1169
+ const scenarios = [
1170
+ {
1171
+ componentName: 'DrinkCard',
1172
+ componentPath: 'components/DrinkCard.tsx',
1173
+ },
1174
+ { componentName: null, componentPath: null, url: '/' },
1175
+ ];
1176
+ const metadata = [
1177
+ {
1178
+ name: 'DrinkCard',
1179
+ metadata: {
1180
+ importedBy: {
1181
+ 'components/DrinkCard.tsx': { Home: { shas: ['sha1'] } },
1182
+ },
1183
+ },
1184
+ },
1185
+ ];
1186
+ const entityInfos = buildEntityInfosFromScenarios(scenarios, { Home: 'app/page.tsx' }, metadata);
1187
+ const result = computeEntityChangeStatus(changedFiles, entityInfos);
1188
+ // Both are directly changed (new) → no impact propagation
1189
+ expect(result['DrinkCard']).toEqual({ status: 'new' });
1190
+ expect(result['Home']).toEqual({ status: 'new' });
1191
+ });
1192
+ });
1193
+ // ── filterGroupsByChangeStatus ──────────────────────────────────────────
1194
+ describe('filterGroupsByChangeStatus', () => {
1195
+ const groups = [
1196
+ ['Home', ['s1', 's2']],
1197
+ ['About', ['s3']],
1198
+ ['Header', ['s4']],
1199
+ ];
1200
+ it('should return all groups when entityChangeStatus is undefined', () => {
1201
+ expect(filterGroupsByChangeStatus(groups, undefined)).toEqual(groups);
1202
+ });
1203
+ it('should return all groups when entityChangeStatus is empty', () => {
1204
+ expect(filterGroupsByChangeStatus(groups, {})).toEqual(groups);
1205
+ });
1206
+ it('should filter to only groups with a change status', () => {
1207
+ const status = {
1208
+ Home: { status: 'new' },
1209
+ Header: { status: 'edited' },
1210
+ };
1211
+ const result = filterGroupsByChangeStatus(groups, status);
1212
+ expect(result).toEqual([
1213
+ ['Home', ['s1', 's2']],
1214
+ ['Header', ['s4']],
1215
+ ]);
1216
+ });
1217
+ it('should return empty array when no groups match', () => {
1218
+ const status = {
1219
+ Footer: { status: 'new' },
1220
+ };
1221
+ const result = filterGroupsByChangeStatus(groups, status);
1222
+ expect(result).toEqual([]);
1223
+ });
1224
+ });
1225
+ // ── buildEntityInfosFromGlossary ────────────────────────────────────
1226
+ describe('buildEntityInfosFromGlossary', () => {
1227
+ it('should convert glossary entries to EntityInfo with name and filePath', () => {
1228
+ const glossary = [
1229
+ { name: 'pad', filePath: 'app/lib/calendar.ts' },
1230
+ { name: 'formatDate', filePath: 'app/lib/calendar.ts' },
1231
+ ];
1232
+ const result = buildEntityInfosFromGlossary(glossary);
1233
+ expect(result).toEqual([
1234
+ { name: 'pad', filePath: 'app/lib/calendar.ts' },
1235
+ { name: 'formatDate', filePath: 'app/lib/calendar.ts' },
1236
+ ]);
1237
+ });
1238
+ it('should deduplicate by name (first occurrence wins)', () => {
1239
+ const glossary = [
1240
+ { name: 'pad', filePath: 'app/lib/calendar.ts' },
1241
+ { name: 'pad', filePath: 'app/lib/other.ts' },
1242
+ ];
1243
+ const result = buildEntityInfosFromGlossary(glossary);
1244
+ expect(result).toHaveLength(1);
1245
+ expect(result[0].filePath).toBe('app/lib/calendar.ts');
1246
+ });
1247
+ it('should skip entries already present in existingNames set', () => {
1248
+ const glossary = [
1249
+ { name: 'Home', filePath: 'app/lib/calendar.ts' },
1250
+ { name: 'pad', filePath: 'app/lib/calendar.ts' },
1251
+ ];
1252
+ const existing = new Set(['Home']);
1253
+ const result = buildEntityInfosFromGlossary(glossary, existing);
1254
+ expect(result).toHaveLength(1);
1255
+ expect(result[0].name).toBe('pad');
1256
+ });
1257
+ it('should return empty array for empty input', () => {
1258
+ expect(buildEntityInfosFromGlossary([])).toEqual([]);
1259
+ });
1260
+ });
1261
+ // ── filterGlossaryByChangeStatus ──────────────────────────────────────
1262
+ describe('filterGlossaryByChangeStatus', () => {
1263
+ const functions = [
1264
+ {
1265
+ name: 'pad',
1266
+ filePath: 'lib/calendar.ts',
1267
+ description: '',
1268
+ testFile: 'test.ts',
1269
+ },
1270
+ {
1271
+ name: 'formatDate',
1272
+ filePath: 'lib/calendar.ts',
1273
+ description: '',
1274
+ testFile: 'test.ts',
1275
+ },
1276
+ {
1277
+ name: 'getDays',
1278
+ filePath: 'lib/calendar.ts',
1279
+ description: '',
1280
+ testFile: 'test.ts',
1281
+ },
1282
+ ];
1283
+ it('should return all functions when entityChangeStatus is undefined', () => {
1284
+ expect(filterGlossaryByChangeStatus(functions, undefined)).toEqual(functions);
1285
+ });
1286
+ it('should return all functions when entityChangeStatus is empty', () => {
1287
+ expect(filterGlossaryByChangeStatus(functions, {})).toEqual(functions);
1288
+ });
1289
+ it('should filter to only functions with a change status', () => {
1290
+ const status = {
1291
+ pad: { status: 'new' },
1292
+ getDays: { status: 'edited' },
1293
+ };
1294
+ const result = filterGlossaryByChangeStatus(functions, status);
1295
+ expect(result).toHaveLength(2);
1296
+ expect(result.map((f) => f.name)).toEqual(['pad', 'getDays']);
1297
+ });
1298
+ it('should return empty array when no functions match', () => {
1299
+ const status = {
1300
+ SomeOtherThing: { status: 'new' },
1301
+ };
1302
+ const result = filterGlossaryByChangeStatus(functions, status);
1303
+ expect(result).toEqual([]);
1304
+ });
1305
+ });
1306
+ // ── scenarioEntityName ──────────────────────────────────────────────
1307
+ describe('scenarioEntityName', () => {
1308
+ it('should return componentName when set', () => {
1309
+ expect(scenarioEntityName({
1310
+ componentName: 'ReviewCard',
1311
+ url: '/isolated-components/ReviewCard?s=Default',
1312
+ })).toBe('ReviewCard');
1313
+ });
1314
+ it('should return page name from URL when componentName is null', () => {
1315
+ expect(scenarioEntityName({ componentName: null, url: '/drinks/1' })).toBe('Drinks');
1316
+ });
1317
+ it('should return Home for root URL when componentName is null', () => {
1318
+ expect(scenarioEntityName({ componentName: null, url: '/' })).toBe('Home');
1319
+ });
1320
+ it('should return Home when both componentName and url are null', () => {
1321
+ expect(scenarioEntityName({ componentName: null, url: null })).toBe('Home');
1322
+ });
1323
+ it('should return page name when componentName is undefined', () => {
1324
+ expect(scenarioEntityName({ url: '/settings' })).toBe('Settings');
1325
+ });
1326
+ it('should return Home when no fields provided', () => {
1327
+ expect(scenarioEntityName({})).toBe('Home');
1328
+ });
1329
+ });
1330
+ // ── filterScenarioScreenshotsByChangeStatus ───────────────────────────
1331
+ describe('filterScenarioScreenshotsByChangeStatus', () => {
1332
+ it('should return all screenshots when entityChangeStatus is undefined', () => {
1333
+ const screenshots = [
1334
+ { name: 'Busy Month', path: 'busy.png', componentName: null, url: '/' },
1335
+ {
1336
+ name: 'EventPill - Default',
1337
+ path: 'pill.png',
1338
+ componentName: 'EventPill',
1339
+ url: '/isolated-components/EventPill',
1340
+ },
1341
+ ];
1342
+ expect(filterScenarioScreenshotsByChangeStatus(screenshots, undefined)).toEqual(screenshots);
1343
+ });
1344
+ it('should return all screenshots when entityChangeStatus is empty', () => {
1345
+ const screenshots = [
1346
+ { name: 'Busy Month', path: 'busy.png', componentName: null, url: '/' },
1347
+ ];
1348
+ expect(filterScenarioScreenshotsByChangeStatus(screenshots, {})).toEqual(screenshots);
1349
+ });
1350
+ it('should include component scenarios whose entity has a change status', () => {
1351
+ const screenshots = [
1352
+ {
1353
+ name: 'EventPill - Default',
1354
+ path: 'pill.png',
1355
+ componentName: 'EventPill',
1356
+ url: '/isolated-components/EventPill',
1357
+ },
1358
+ {
1359
+ name: 'ErrorState - Server Error',
1360
+ path: 'error.png',
1361
+ componentName: 'ErrorState',
1362
+ url: '/isolated-components/ErrorState',
1363
+ },
1364
+ {
1365
+ name: 'CalendarGrid - Default',
1366
+ path: 'grid.png',
1367
+ componentName: 'CalendarGrid',
1368
+ url: '/isolated-components/CalendarGrid',
1369
+ },
1370
+ ];
1371
+ const status = {
1372
+ ErrorState: { status: 'new' },
1373
+ EventPill: { status: 'edited' },
1374
+ };
1375
+ const result = filterScenarioScreenshotsByChangeStatus(screenshots, status);
1376
+ expect(result.map((s) => s.name)).toEqual([
1377
+ 'EventPill - Default',
1378
+ 'ErrorState - Server Error',
1379
+ ]);
1380
+ });
1381
+ it('should include page scenarios whose page entity has a change status', () => {
1382
+ const screenshots = [
1383
+ { name: 'Busy Month', path: 'busy.png', componentName: null, url: '/' },
1384
+ {
1385
+ name: 'Empty Calendar',
1386
+ path: 'empty.png',
1387
+ componentName: null,
1388
+ url: '/',
1389
+ },
1390
+ ];
1391
+ const status = {
1392
+ Home: { status: 'edited' },
1393
+ };
1394
+ const result = filterScenarioScreenshotsByChangeStatus(screenshots, status);
1395
+ expect(result).toEqual(screenshots);
1396
+ });
1397
+ it('should exclude page scenarios when their page entity has no status', () => {
1398
+ const screenshots = [
1399
+ { name: 'Busy Month', path: 'busy.png', componentName: null, url: '/' },
1400
+ {
1401
+ name: 'EventPill - Default',
1402
+ path: 'pill.png',
1403
+ componentName: 'EventPill',
1404
+ url: '/isolated-components/EventPill',
1405
+ },
1406
+ ];
1407
+ const status = {
1408
+ SomeComponent: { status: 'new' },
1409
+ };
1410
+ const result = filterScenarioScreenshotsByChangeStatus(screenshots, status);
1411
+ // Home page has no status, so "Busy Month" is excluded
1412
+ // SomeComponent doesn't match EventPill either
1413
+ expect(result).toEqual([]);
1414
+ });
1415
+ it('should include both page and component scenarios when both entities have status', () => {
1416
+ const screenshots = [
1417
+ { name: 'Busy Month', path: 'busy.png', componentName: null, url: '/' },
1418
+ {
1419
+ name: 'Empty Calendar',
1420
+ path: 'empty.png',
1421
+ componentName: null,
1422
+ url: '/',
1423
+ },
1424
+ {
1425
+ name: 'EventPill - Default',
1426
+ path: 'pill.png',
1427
+ componentName: 'EventPill',
1428
+ url: '/isolated-components/EventPill',
1429
+ },
1430
+ ];
1431
+ const status = {
1432
+ Home: { status: 'edited' },
1433
+ EventPill: { status: 'new' },
1434
+ };
1435
+ const result = filterScenarioScreenshotsByChangeStatus(screenshots, status);
1436
+ expect(result.map((s) => s.name)).toEqual([
1437
+ 'Busy Month',
1438
+ 'Empty Calendar',
1439
+ 'EventPill - Default',
1440
+ ]);
1441
+ });
1442
+ it('should handle page scenarios with dashes in names using URL, not name parsing', () => {
1443
+ // The original bug: "Drink Detail - Earl Grey" was parsed as component "Drink Detail"
1444
+ const screenshots = [
1445
+ {
1446
+ name: 'Drink Detail - Earl Grey',
1447
+ path: 'earl.png',
1448
+ componentName: null,
1449
+ url: '/drinks/1',
1450
+ },
1451
+ {
1452
+ name: 'Drink Detail - No Reviews',
1453
+ path: 'no-reviews.png',
1454
+ componentName: null,
1455
+ url: '/drinks/1',
1456
+ },
1457
+ {
1458
+ name: 'ReviewCard - Default',
1459
+ path: 'review.png',
1460
+ componentName: 'ReviewCard',
1461
+ url: '/isolated-components/ReviewCard',
1462
+ },
1463
+ {
1464
+ name: 'Full Catalog',
1465
+ path: 'catalog.png',
1466
+ componentName: null,
1467
+ url: '/',
1468
+ },
1469
+ ];
1470
+ const status = {
1471
+ Drinks: { status: 'new' },
1472
+ ReviewCard: { status: 'new' },
1473
+ Home: { status: 'edited' },
1474
+ };
1475
+ const result = filterScenarioScreenshotsByChangeStatus(screenshots, status);
1476
+ expect(result.map((s) => s.name)).toEqual([
1477
+ 'Drink Detail - Earl Grey',
1478
+ 'Drink Detail - No Reviews',
1479
+ 'ReviewCard - Default',
1480
+ 'Full Catalog',
1481
+ ]);
1482
+ });
1483
+ it('should exclude page scenarios for unchanged pages even with dashes in names', () => {
1484
+ const screenshots = [
1485
+ {
1486
+ name: 'Drink Detail - Earl Grey',
1487
+ path: 'earl.png',
1488
+ componentName: null,
1489
+ url: '/drinks/1',
1490
+ },
1491
+ {
1492
+ name: 'ReviewCard - Default',
1493
+ path: 'review.png',
1494
+ componentName: 'ReviewCard',
1495
+ url: '/isolated-components/ReviewCard',
1496
+ },
1497
+ ];
1498
+ const status = {
1499
+ ReviewCard: { status: 'edited' },
1500
+ };
1501
+ const result = filterScenarioScreenshotsByChangeStatus(screenshots, status);
1502
+ // Drinks page has no status, so "Drink Detail" page scenarios are excluded
1503
+ expect(result.map((s) => s.name)).toEqual(['ReviewCard - Default']);
1504
+ });
1505
+ it('should use componentName for matching, not the name prefix', () => {
1506
+ const screenshots = [
1507
+ {
1508
+ name: 'Card Loading State - Spinner',
1509
+ path: 'spinner.png',
1510
+ componentName: 'LoadingCard',
1511
+ url: '/isolated-components/LoadingCard',
1512
+ },
1513
+ ];
1514
+ const status = {
1515
+ LoadingCard: { status: 'new' },
1516
+ };
1517
+ const result = filterScenarioScreenshotsByChangeStatus(screenshots, status);
1518
+ expect(result.map((s) => s.name)).toEqual([
1519
+ 'Card Loading State - Spinner',
1520
+ ]);
1521
+ });
1522
+ it('should exclude component scenario when its entity has no status', () => {
1523
+ const screenshots = [
1524
+ {
1525
+ name: 'Card Loading State - Spinner',
1526
+ path: 'spinner.png',
1527
+ componentName: 'LoadingCard',
1528
+ url: '/isolated-components/LoadingCard',
1529
+ },
1530
+ ];
1531
+ const status = {
1532
+ SomeOtherComponent: { status: 'new' },
1533
+ };
1534
+ const result = filterScenarioScreenshotsByChangeStatus(screenshots, status);
1535
+ expect(result).toEqual([]);
1536
+ });
1537
+ it('should filter per-page independently (not all-or-nothing for pages)', () => {
1538
+ // Only Drinks page changed, not Home — scenarios for each page should be filtered independently
1539
+ const screenshots = [
1540
+ {
1541
+ name: 'Full Catalog',
1542
+ path: 'catalog.png',
1543
+ componentName: null,
1544
+ url: '/',
1545
+ },
1546
+ {
1547
+ name: 'Drink Detail - Earl Grey',
1548
+ path: 'earl.png',
1549
+ componentName: null,
1550
+ url: '/drinks/1',
1551
+ },
1552
+ ];
1553
+ const status = {
1554
+ Drinks: { status: 'new' },
1555
+ };
1556
+ const result = filterScenarioScreenshotsByChangeStatus(screenshots, status);
1557
+ // Home page has no status, so "Full Catalog" is excluded
1558
+ // Drinks page has status, so "Drink Detail" is included
1559
+ expect(result.map((s) => s.name)).toEqual(['Drink Detail - Earl Grey']);
1560
+ });
1561
+ it('should fall back to name-based parsing when componentName is not present (backward compat)', () => {
1562
+ const screenshots = [
1563
+ { name: 'EventPill - Default', path: 'pill.png' },
1564
+ { name: 'Full Calendar', path: 'cal.png' },
1565
+ ];
1566
+ const status = {
1567
+ EventPill: { status: 'new' },
1568
+ Home: { status: 'edited' },
1569
+ };
1570
+ const result = filterScenarioScreenshotsByChangeStatus(screenshots, status);
1571
+ expect(result.map((s) => s.name)).toEqual([
1572
+ 'EventPill - Default',
1573
+ 'Full Calendar',
1574
+ ]);
1575
+ });
1576
+ });
1577
+ // ── scanPageFilePaths ───────────────────────────────────────────────────
1578
+ describe('scanPageFilePaths', () => {
1579
+ let tempDir;
1580
+ beforeEach(() => {
1581
+ tempDir = fsModule.mkdtempSync(pathModule.join(os.tmpdir(), 'scanpages-test-'));
1582
+ });
1583
+ afterEach(() => {
1584
+ fsModule.rmSync(tempDir, { recursive: true, force: true });
1585
+ });
1586
+ it('should find page.tsx files in nested app/ structure', () => {
1587
+ const appDir = pathModule.join(tempDir, 'app');
1588
+ fsModule.mkdirSync(appDir, { recursive: true });
1589
+ fsModule.writeFileSync(pathModule.join(appDir, 'page.tsx'), '');
1590
+ fsModule.mkdirSync(pathModule.join(appDir, 'drinks'), {
1591
+ recursive: true,
1592
+ });
1593
+ fsModule.writeFileSync(pathModule.join(appDir, 'drinks', 'page.tsx'), '');
1594
+ fsModule.mkdirSync(pathModule.join(appDir, 'settings', 'profile'), {
1595
+ recursive: true,
1596
+ });
1597
+ fsModule.writeFileSync(pathModule.join(appDir, 'settings', 'profile', 'page.tsx'), '');
1598
+ const result = scanPageFilePaths(tempDir);
1599
+ expect(result).toEqual({
1600
+ Home: 'app/page.tsx',
1601
+ Drinks: 'app/drinks/page.tsx',
1602
+ Settings: 'app/settings/profile/page.tsx',
1603
+ });
1604
+ });
1605
+ it('should find page.js files', () => {
1606
+ const appDir = pathModule.join(tempDir, 'app');
1607
+ fsModule.mkdirSync(appDir, { recursive: true });
1608
+ fsModule.writeFileSync(pathModule.join(appDir, 'page.js'), '');
1609
+ const result = scanPageFilePaths(tempDir);
1610
+ expect(result).toEqual({ Home: 'app/page.js' });
1611
+ });
1612
+ it('should skip isolated-components directories', () => {
1613
+ const appDir = pathModule.join(tempDir, 'app');
1614
+ fsModule.mkdirSync(pathModule.join(appDir, 'isolated-components', 'test'), {
1615
+ recursive: true,
1616
+ });
1617
+ fsModule.writeFileSync(pathModule.join(appDir, 'isolated-components', 'test', 'page.tsx'), '');
1618
+ const result = scanPageFilePaths(tempDir);
1619
+ expect(result).toEqual({});
1620
+ });
1621
+ it('should return empty map when app/ does not exist', () => {
1622
+ const result = scanPageFilePaths(tempDir);
1623
+ expect(result).toEqual({});
1624
+ });
1625
+ // ── Expo Router support ──────────────────────────────────────────────
1626
+ it('should find index.tsx as Home page (Expo Router)', () => {
1627
+ const appDir = pathModule.join(tempDir, 'app');
1628
+ fsModule.mkdirSync(appDir, { recursive: true });
1629
+ fsModule.writeFileSync(pathModule.join(appDir, 'index.tsx'), '');
1630
+ const result = scanPageFilePaths(tempDir);
1631
+ expect(result).toEqual({ Home: 'app/index.tsx' });
1632
+ });
1633
+ it('should find named route files as pages (Expo Router)', () => {
1634
+ const appDir = pathModule.join(tempDir, 'app');
1635
+ fsModule.mkdirSync(appDir, { recursive: true });
1636
+ fsModule.writeFileSync(pathModule.join(appDir, 'index.tsx'), '');
1637
+ fsModule.writeFileSync(pathModule.join(appDir, 'add-tea.tsx'), '');
1638
+ fsModule.mkdirSync(pathModule.join(appDir, 'tea'), { recursive: true });
1639
+ fsModule.writeFileSync(pathModule.join(appDir, 'tea', '[id].tsx'), '');
1640
+ const result = scanPageFilePaths(tempDir);
1641
+ expect(result).toEqual({
1642
+ Home: 'app/index.tsx',
1643
+ 'Add-tea': 'app/add-tea.tsx',
1644
+ Tea: 'app/tea/[id].tsx',
1645
+ });
1646
+ });
1647
+ it('should skip _layout.tsx files (Expo Router)', () => {
1648
+ const appDir = pathModule.join(tempDir, 'app');
1649
+ fsModule.mkdirSync(appDir, { recursive: true });
1650
+ fsModule.writeFileSync(pathModule.join(appDir, '_layout.tsx'), '');
1651
+ fsModule.writeFileSync(pathModule.join(appDir, 'index.tsx'), '');
1652
+ const result = scanPageFilePaths(tempDir);
1653
+ expect(result).toEqual({ Home: 'app/index.tsx' });
1654
+ });
1655
+ it('should handle route groups like (tabs) transparently (Expo Router)', () => {
1656
+ const appDir = pathModule.join(tempDir, 'app');
1657
+ const tabsDir = pathModule.join(appDir, '(tabs)');
1658
+ fsModule.mkdirSync(tabsDir, { recursive: true });
1659
+ fsModule.writeFileSync(pathModule.join(tabsDir, '_layout.tsx'), '');
1660
+ fsModule.writeFileSync(pathModule.join(tabsDir, 'index.tsx'), '');
1661
+ fsModule.writeFileSync(pathModule.join(tabsDir, 'settings.tsx'), '');
1662
+ const result = scanPageFilePaths(tempDir);
1663
+ expect(result).toEqual({
1664
+ Home: 'app/(tabs)/index.tsx',
1665
+ Settings: 'app/(tabs)/settings.tsx',
1666
+ });
1667
+ });
1668
+ it('should prefer page.tsx over index.tsx when both exist (Next.js priority)', () => {
1669
+ const appDir = pathModule.join(tempDir, 'app');
1670
+ fsModule.mkdirSync(appDir, { recursive: true });
1671
+ fsModule.writeFileSync(pathModule.join(appDir, 'page.tsx'), '');
1672
+ fsModule.writeFileSync(pathModule.join(appDir, 'index.tsx'), '');
1673
+ const result = scanPageFilePaths(tempDir);
1674
+ expect(result).toEqual({ Home: 'app/page.tsx' });
1675
+ });
1676
+ });
1677
+ // ── detectFirstFeature ─────────────────────────────────────────────────
1678
+ describe('detectFirstFeature', () => {
1679
+ it('should return true when git has 0 or 1 commits', () => {
1680
+ const tempDir = fsModule.mkdtempSync(pathModule.join(os.tmpdir(), 'firstfeat-test-'));
1681
+ try {
1682
+ // Init a git repo with no commits
1683
+ require('child_process').execSync('git init', {
1684
+ cwd: tempDir,
1685
+ stdio: 'ignore',
1686
+ });
1687
+ expect(detectFirstFeature(tempDir)).toBe(true);
1688
+ }
1689
+ finally {
1690
+ fsModule.rmSync(tempDir, { recursive: true, force: true });
1691
+ }
1692
+ });
1693
+ it('should return false when git has multiple commits', () => {
1694
+ const tempDir = fsModule.mkdtempSync(pathModule.join(os.tmpdir(), 'firstfeat-test-'));
1695
+ try {
1696
+ require('child_process').execSync('git init && git config user.email "test@test.com" && git config user.name "test" && git commit --allow-empty -m "first" && git commit --allow-empty -m "second"', { cwd: tempDir, stdio: 'ignore' });
1697
+ expect(detectFirstFeature(tempDir)).toBe(false);
1698
+ }
1699
+ finally {
1700
+ fsModule.rmSync(tempDir, { recursive: true, force: true });
1701
+ }
1702
+ });
1703
+ it('should return true when not a git repo', () => {
1704
+ const tempDir = fsModule.mkdtempSync(pathModule.join(os.tmpdir(), 'firstfeat-test-'));
1705
+ try {
1706
+ expect(detectFirstFeature(tempDir)).toBe(true);
1707
+ }
1708
+ finally {
1709
+ fsModule.rmSync(tempDir, { recursive: true, force: true });
1710
+ }
1711
+ });
1712
+ });
1713
+ // ── readFeatureStartedAt ───────────────────────────────────────────────
1714
+ describe('readFeatureStartedAt', () => {
1715
+ let tempDir;
1716
+ beforeEach(() => {
1717
+ tempDir = fsModule.mkdtempSync(pathModule.join(os.tmpdir(), 'featurets-test-'));
1718
+ fsModule.mkdirSync(pathModule.join(tempDir, '.codeyam'), {
1719
+ recursive: true,
1720
+ });
1721
+ });
1722
+ afterEach(() => {
1723
+ fsModule.rmSync(tempDir, { recursive: true, force: true });
1724
+ });
1725
+ it('should return featureStartedAt from editor-step.json', () => {
1726
+ fsModule.writeFileSync(pathModule.join(tempDir, '.codeyam', 'editor-step.json'), JSON.stringify({ featureStartedAt: '2026-03-01T10:00:00.000Z' }));
1727
+ expect(readFeatureStartedAt(tempDir)).toBe('2026-03-01T10:00:00.000Z');
1728
+ });
1729
+ it('should return null when file does not exist', () => {
1730
+ expect(readFeatureStartedAt(tempDir)).toBeNull();
1731
+ });
1732
+ it('should return null when featureStartedAt is not set', () => {
1733
+ fsModule.writeFileSync(pathModule.join(tempDir, '.codeyam', 'editor-step.json'), JSON.stringify({ step: 5 }));
1734
+ expect(readFeatureStartedAt(tempDir)).toBeNull();
1735
+ });
1736
+ it('should return null for invalid JSON', () => {
1737
+ fsModule.writeFileSync(pathModule.join(tempDir, '.codeyam', 'editor-step.json'), 'not json');
1738
+ expect(readFeatureStartedAt(tempDir)).toBeNull();
1739
+ });
1740
+ });
1741
+ });
1742
+ //# sourceMappingURL=entityChangeStatus.test.js.map