@pennyfarthing/core 10.0.3 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/README.md +9 -7
  2. package/package.json +7 -1
  3. package/packages/core/dist/cli/commands/cyclist.d.ts +5 -1
  4. package/packages/core/dist/cli/commands/cyclist.d.ts.map +1 -1
  5. package/packages/core/dist/cli/commands/cyclist.js +4 -4
  6. package/packages/core/dist/cli/commands/cyclist.js.map +1 -1
  7. package/packages/core/dist/cli/commands/cyclist.test.js +2 -2
  8. package/packages/core/dist/cli/commands/cyclist.test.js.map +1 -1
  9. package/packages/core/dist/cli/commands/doctor-legacy.test.js +17 -16
  10. package/packages/core/dist/cli/commands/doctor-legacy.test.js.map +1 -1
  11. package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
  12. package/packages/core/dist/cli/commands/doctor.js +251 -4
  13. package/packages/core/dist/cli/commands/doctor.js.map +1 -1
  14. package/packages/core/dist/cli/commands/init.d.ts +7 -0
  15. package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
  16. package/packages/core/dist/cli/commands/init.js +43 -7
  17. package/packages/core/dist/cli/commands/init.js.map +1 -1
  18. package/packages/core/dist/cli/commands/update.d.ts.map +1 -1
  19. package/packages/core/dist/cli/commands/update.js +26 -0
  20. package/packages/core/dist/cli/commands/update.js.map +1 -1
  21. package/packages/core/dist/cli/index.js +1 -1
  22. package/packages/core/dist/cli/index.js.map +1 -1
  23. package/packages/core/dist/cli/ocean-profiles.test.js +1 -1
  24. package/packages/core/dist/cli/ocean-profiles.test.js.map +1 -1
  25. package/packages/core/dist/cli/utils/files.d.ts +10 -0
  26. package/packages/core/dist/cli/utils/files.d.ts.map +1 -1
  27. package/packages/core/dist/cli/utils/files.js +35 -0
  28. package/packages/core/dist/cli/utils/files.js.map +1 -1
  29. package/packages/core/dist/cli/utils/python.d.ts +22 -0
  30. package/packages/core/dist/cli/utils/python.d.ts.map +1 -0
  31. package/packages/core/dist/cli/utils/python.js +102 -0
  32. package/packages/core/dist/cli/utils/python.js.map +1 -0
  33. package/packages/core/dist/cli/utils/settings.d.ts.map +1 -1
  34. package/packages/core/dist/cli/utils/settings.js +10 -0
  35. package/packages/core/dist/cli/utils/settings.js.map +1 -1
  36. package/packages/core/dist/scripts/generate-report.d.ts.map +1 -1
  37. package/packages/core/dist/scripts/generate-report.js +11 -7
  38. package/packages/core/dist/scripts/generate-report.js.map +1 -1
  39. package/packages/core/dist/scripts/generate-spider-report.d.ts.map +1 -1
  40. package/packages/core/dist/scripts/generate-spider-report.js +12 -8
  41. package/packages/core/dist/scripts/generate-spider-report.js.map +1 -1
  42. package/packages/core/dist/scripts/generate-spider.d.ts.map +1 -1
  43. package/packages/core/dist/scripts/generate-spider.js +6 -4
  44. package/packages/core/dist/scripts/generate-spider.js.map +1 -1
  45. package/packages/core/dist/scripts/generate-spider.test.js +2 -2
  46. package/packages/core/dist/scripts/generate-spider.test.js.map +1 -1
  47. package/pennyfarthing-dist/agents/README.md +1 -3
  48. package/pennyfarthing-dist/agents/architect.md +0 -6
  49. package/pennyfarthing-dist/agents/devops.md +0 -6
  50. package/pennyfarthing-dist/agents/orchestrator.md +0 -6
  51. package/pennyfarthing-dist/agents/pm.md +1 -7
  52. package/pennyfarthing-dist/agents/sm-finish.md +1 -1
  53. package/pennyfarthing-dist/agents/sm-setup.md +2 -2
  54. package/pennyfarthing-dist/agents/sm.md +4 -11
  55. package/pennyfarthing-dist/commands/architect.md +11 -3
  56. package/pennyfarthing-dist/commands/close-epic.md +24 -131
  57. package/pennyfarthing-dist/commands/create-theme.md +14 -24
  58. package/pennyfarthing-dist/commands/dev.md +11 -3
  59. package/pennyfarthing-dist/commands/devops.md +11 -3
  60. package/pennyfarthing-dist/commands/health-check.md +1 -3
  61. package/pennyfarthing-dist/commands/help.md +8 -12
  62. package/pennyfarthing-dist/commands/list-themes.md +14 -16
  63. package/pennyfarthing-dist/commands/orchestrator.md +11 -3
  64. package/pennyfarthing-dist/commands/parallel-work.md +1 -3
  65. package/pennyfarthing-dist/commands/pm.md +11 -3
  66. package/pennyfarthing-dist/commands/prime.md +6 -6
  67. package/pennyfarthing-dist/commands/repo-status.md +2 -2
  68. package/pennyfarthing-dist/commands/reviewer.md +11 -3
  69. package/pennyfarthing-dist/commands/run-ci.md +1 -1
  70. package/pennyfarthing-dist/commands/set-theme.md +14 -51
  71. package/pennyfarthing-dist/commands/setup.md +1 -1
  72. package/pennyfarthing-dist/commands/show-theme.md +14 -16
  73. package/pennyfarthing-dist/commands/sm.md +11 -3
  74. package/pennyfarthing-dist/commands/sprint.md +8 -8
  75. package/pennyfarthing-dist/commands/tea.md +11 -3
  76. package/pennyfarthing-dist/commands/tech-writer.md +11 -3
  77. package/pennyfarthing-dist/commands/theme-maker.md +14 -671
  78. package/pennyfarthing-dist/commands/theme.md +95 -0
  79. package/pennyfarthing-dist/commands/ux-designer.md +11 -3
  80. package/pennyfarthing-dist/commands/work.md +3 -5
  81. package/pennyfarthing-dist/guides/agent-coordination.md +11 -13
  82. package/pennyfarthing-dist/guides/agent-template-tactical.md +2 -3
  83. package/pennyfarthing-dist/guides/command-tag-taxonomy.md +212 -0
  84. package/pennyfarthing-dist/guides/hooks.md +5 -5
  85. package/pennyfarthing-dist/guides/patterns/fan-out-fan-in-pattern.md +3 -3
  86. package/pennyfarthing-dist/guides/patterns/helper-delegation-pattern.md +9 -59
  87. package/pennyfarthing-dist/guides/patterns/tdd-flow-pattern.md +4 -5
  88. package/pennyfarthing-dist/guides/prime.md +2 -2
  89. package/pennyfarthing-dist/guides/skill-schema.md +25 -26
  90. package/pennyfarthing-dist/guides/xml-tags.md +2 -2
  91. package/pennyfarthing-dist/scripts/README.md +2 -2
  92. package/pennyfarthing-dist/scripts/core/agent-session.sh +6 -2
  93. package/pennyfarthing-dist/scripts/core/prime.sh +8 -10
  94. package/pennyfarthing-dist/scripts/git/git-status-all.sh +1 -1
  95. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +8 -6
  96. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +3 -3
  97. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +14 -12
  98. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +4 -3
  99. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +11 -5
  100. package/pennyfarthing-dist/scripts/hooks/sprint-yaml-validation.sh +1 -1
  101. package/pennyfarthing-dist/scripts/misc/README.md +1 -1
  102. package/pennyfarthing-dist/scripts/misc/repo-utils.sh +3 -3
  103. package/pennyfarthing-dist/scripts/misc/validate-subagent-frontmatter.sh +1 -2
  104. package/pennyfarthing-dist/scripts/sprint/README.md +32 -17
  105. package/pennyfarthing-dist/scripts/story/README.md +1 -1
  106. package/pennyfarthing-dist/scripts/test/test-setup.sh +1 -1
  107. package/pennyfarthing-dist/scripts/tests/handoff-phase-update.test.sh +5 -5
  108. package/pennyfarthing-dist/scripts/tests/test-drift-detection.sh +3 -79
  109. package/pennyfarthing-dist/scripts/theme/README.md +1 -1
  110. package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +0 -1
  111. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +62 -17
  112. package/pennyfarthing-dist/skills/dev-patterns/SKILL.md +2 -2
  113. package/pennyfarthing-dist/skills/skill-registry.yaml +41 -28
  114. package/pennyfarthing-dist/skills/sprint/skill.md +386 -68
  115. package/pennyfarthing-dist/skills/story/skill.md +14 -206
  116. package/pennyfarthing-dist/skills/theme/skill.md +290 -75
  117. package/pennyfarthing-dist/skills/theme-creation/SKILL.md +23 -166
  118. package/pennyfarthing-dist/skills/workflow/skill.md +4 -4
  119. package/pennyfarthing-dist/templates/agent-scopes.yaml.template +0 -11
  120. package/pennyfarthing-dist/templates/auto-load-sm.sh.template +14 -0
  121. package/pennyfarthing-dist/templates/settings.local.json.template +9 -0
  122. package/pennyfarthing-dist/workflows/2party-tdd.yaml +399 -0
  123. package/pennyfarthing-dist/workflows/epics-and-stories/steps/step-05-import-to-future.md +42 -25
  124. package/pennyfarthing-dist/workflows/git-cleanup.yaml +1 -1
  125. package/pennyfarthing-dist/workflows/project-setup/steps/step-10-complete.md +1 -1
  126. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  127. package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/__pycache__/schema_validation_hook.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/cli.py +15 -0
  131. package/pennyfarthing_scripts/codemarkers/__init__.py +19 -0
  132. package/pennyfarthing_scripts/codemarkers/__main__.py +6 -0
  133. package/pennyfarthing_scripts/codemarkers/__pycache__/__init__.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/codemarkers/__pycache__/__main__.cpython-314.pyc +0 -0
  135. package/pennyfarthing_scripts/codemarkers/__pycache__/analyze.cpython-314.pyc +0 -0
  136. package/pennyfarthing_scripts/codemarkers/__pycache__/cli.cpython-314.pyc +0 -0
  137. package/pennyfarthing_scripts/codemarkers/__pycache__/formatters.cpython-314.pyc +0 -0
  138. package/pennyfarthing_scripts/codemarkers/__pycache__/models.cpython-314.pyc +0 -0
  139. package/pennyfarthing_scripts/codemarkers/analyze.py +326 -0
  140. package/pennyfarthing_scripts/codemarkers/cli.py +129 -0
  141. package/pennyfarthing_scripts/codemarkers/formatters.py +89 -0
  142. package/pennyfarthing_scripts/codemarkers/models.py +45 -0
  143. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/common/__pycache__/themes.cpython-314.pyc +0 -0
  145. package/pennyfarthing_scripts/common/config.py +2 -1
  146. package/pennyfarthing_scripts/complexity/__init__.py +15 -0
  147. package/pennyfarthing_scripts/complexity/__main__.py +6 -0
  148. package/pennyfarthing_scripts/complexity/__pycache__/__init__.cpython-314.pyc +0 -0
  149. package/pennyfarthing_scripts/complexity/__pycache__/__main__.cpython-314.pyc +0 -0
  150. package/pennyfarthing_scripts/complexity/__pycache__/analyze.cpython-314.pyc +0 -0
  151. package/pennyfarthing_scripts/complexity/__pycache__/cli.cpython-314.pyc +0 -0
  152. package/pennyfarthing_scripts/complexity/__pycache__/formatters.cpython-314.pyc +0 -0
  153. package/pennyfarthing_scripts/complexity/__pycache__/models.cpython-314.pyc +0 -0
  154. package/pennyfarthing_scripts/complexity/analyze.py +207 -0
  155. package/pennyfarthing_scripts/complexity/cli.py +78 -0
  156. package/pennyfarthing_scripts/complexity/formatters.py +64 -0
  157. package/pennyfarthing_scripts/complexity/models.py +32 -0
  158. package/pennyfarthing_scripts/deadcode/__init__.py +6 -0
  159. package/pennyfarthing_scripts/deadcode/__main__.py +6 -0
  160. package/pennyfarthing_scripts/deadcode/__pycache__/__init__.cpython-314.pyc +0 -0
  161. package/pennyfarthing_scripts/deadcode/__pycache__/__main__.cpython-314.pyc +0 -0
  162. package/pennyfarthing_scripts/deadcode/__pycache__/analyze.cpython-314.pyc +0 -0
  163. package/pennyfarthing_scripts/deadcode/__pycache__/cli.cpython-314.pyc +0 -0
  164. package/pennyfarthing_scripts/deadcode/__pycache__/formatters.cpython-314.pyc +0 -0
  165. package/pennyfarthing_scripts/deadcode/__pycache__/models.cpython-314.pyc +0 -0
  166. package/pennyfarthing_scripts/deadcode/analyze.py +323 -0
  167. package/pennyfarthing_scripts/deadcode/cli.py +163 -0
  168. package/pennyfarthing_scripts/deadcode/formatters.py +106 -0
  169. package/pennyfarthing_scripts/deadcode/models.py +54 -0
  170. package/pennyfarthing_scripts/dependencies/__init__.py +20 -0
  171. package/pennyfarthing_scripts/dependencies/__main__.py +5 -0
  172. package/pennyfarthing_scripts/dependencies/__pycache__/__init__.cpython-314.pyc +0 -0
  173. package/pennyfarthing_scripts/dependencies/__pycache__/__main__.cpython-314.pyc +0 -0
  174. package/pennyfarthing_scripts/dependencies/__pycache__/analyze.cpython-314.pyc +0 -0
  175. package/pennyfarthing_scripts/dependencies/__pycache__/cli.cpython-314.pyc +0 -0
  176. package/pennyfarthing_scripts/dependencies/__pycache__/formatters.cpython-314.pyc +0 -0
  177. package/pennyfarthing_scripts/dependencies/__pycache__/models.cpython-314.pyc +0 -0
  178. package/pennyfarthing_scripts/dependencies/analyze.py +155 -0
  179. package/pennyfarthing_scripts/dependencies/cli.py +72 -0
  180. package/pennyfarthing_scripts/dependencies/formatters.py +63 -0
  181. package/pennyfarthing_scripts/dependencies/models.py +39 -0
  182. package/pennyfarthing_scripts/healthscore/__init__.py +21 -0
  183. package/pennyfarthing_scripts/healthscore/__main__.py +6 -0
  184. package/pennyfarthing_scripts/healthscore/__pycache__/__init__.cpython-314.pyc +0 -0
  185. package/pennyfarthing_scripts/healthscore/__pycache__/__main__.cpython-314.pyc +0 -0
  186. package/pennyfarthing_scripts/healthscore/__pycache__/analyze.cpython-314.pyc +0 -0
  187. package/pennyfarthing_scripts/healthscore/__pycache__/cli.cpython-314.pyc +0 -0
  188. package/pennyfarthing_scripts/healthscore/__pycache__/formatters.cpython-314.pyc +0 -0
  189. package/pennyfarthing_scripts/healthscore/__pycache__/models.cpython-314.pyc +0 -0
  190. package/pennyfarthing_scripts/healthscore/analyze.py +161 -0
  191. package/pennyfarthing_scripts/healthscore/cli.py +76 -0
  192. package/pennyfarthing_scripts/healthscore/formatters.py +46 -0
  193. package/pennyfarthing_scripts/healthscore/models.py +44 -0
  194. package/pennyfarthing_scripts/hotspots/__pycache__/__init__.cpython-314.pyc +0 -0
  195. package/pennyfarthing_scripts/hotspots/__pycache__/__main__.cpython-314.pyc +0 -0
  196. package/pennyfarthing_scripts/hotspots/__pycache__/analyze.cpython-314.pyc +0 -0
  197. package/pennyfarthing_scripts/hotspots/__pycache__/cli.cpython-314.pyc +0 -0
  198. package/pennyfarthing_scripts/hotspots/__pycache__/formatters.cpython-314.pyc +0 -0
  199. package/pennyfarthing_scripts/hotspots/__pycache__/models.cpython-314.pyc +0 -0
  200. package/pennyfarthing_scripts/hotspots/analyze.py +28 -1
  201. package/pennyfarthing_scripts/hotspots/cli.py +11 -9
  202. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  203. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  204. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  205. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  206. package/pennyfarthing_scripts/jira/__pycache__/create.cpython-314.pyc +0 -0
  207. package/pennyfarthing_scripts/jira/__pycache__/operations.cpython-314.pyc +0 -0
  208. package/pennyfarthing_scripts/jira/__pycache__/reconcile.cpython-314.pyc +0 -0
  209. package/pennyfarthing_scripts/jira/bidirectional.py +42 -15
  210. package/pennyfarthing_scripts/jira/cli.py +78 -1
  211. package/pennyfarthing_scripts/jira/client.py +28 -0
  212. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  213. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  214. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  215. package/pennyfarthing_scripts/prime/__pycache__/tiers.cpython-314.pyc +0 -0
  216. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  217. package/pennyfarthing_scripts/prime/workflow.py +5 -3
  218. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  219. package/pennyfarthing_scripts/sprint/__pycache__/archive_epic.cpython-314.pyc +0 -0
  220. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  221. package/pennyfarthing_scripts/sprint/__pycache__/epic_add.cpython-314.pyc +0 -0
  222. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  223. package/pennyfarthing_scripts/sprint/__pycache__/story_add.cpython-314.pyc +0 -0
  224. package/pennyfarthing_scripts/sprint/__pycache__/story_finish.cpython-314.pyc +0 -0
  225. package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
  226. package/pennyfarthing_scripts/sprint/__pycache__/validate_cmd.cpython-314.pyc +0 -0
  227. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  228. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  229. package/pennyfarthing_scripts/sprint/__pycache__/yaml_io.cpython-314.pyc +0 -0
  230. package/pennyfarthing_scripts/sprint/archive.py +63 -6
  231. package/pennyfarthing_scripts/sprint/archive_epic.py +198 -85
  232. package/pennyfarthing_scripts/sprint/cli.py +1565 -65
  233. package/pennyfarthing_scripts/sprint/epic_add.py +173 -0
  234. package/pennyfarthing_scripts/sprint/loader.py +46 -2
  235. package/pennyfarthing_scripts/sprint/story_add.py +202 -27
  236. package/pennyfarthing_scripts/sprint/story_finish.py +211 -0
  237. package/pennyfarthing_scripts/sprint/validate_cmd.py +44 -5
  238. package/pennyfarthing_scripts/sprint/validator.py +13 -3
  239. package/pennyfarthing_scripts/sprint/work.py +43 -3
  240. package/pennyfarthing_scripts/sprint/yaml_io.py +124 -15
  241. package/pennyfarthing_scripts/tests/__pycache__/test_codemarkers.cpython-314-pytest-9.0.2.pyc +0 -0
  242. package/pennyfarthing_scripts/tests/__pycache__/test_healthscore.cpython-314-pytest-9.0.2.pyc +0 -0
  243. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_package.cpython-314-pytest-9.0.2.pyc +0 -0
  244. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
  245. package/pennyfarthing_scripts/tests/__pycache__/test_story_add.cpython-314-pytest-9.0.2.pyc +0 -0
  246. package/pennyfarthing_scripts/tests/__pycache__/test_story_update.cpython-314-pytest-9.0.2.pyc +0 -0
  247. package/pennyfarthing_scripts/tests/__pycache__/test_validate_cmd.cpython-314-pytest-9.0.2.pyc +0 -0
  248. package/pennyfarthing_scripts/tests/__pycache__/test_yaml_io.cpython-314-pytest-9.0.2.pyc +0 -0
  249. package/pennyfarthing_scripts/tests/test_codemarkers.py +682 -0
  250. package/pennyfarthing_scripts/tests/test_healthscore.py +524 -0
  251. package/pennyfarthing_scripts/tests/test_sprint_package.py +166 -0
  252. package/pennyfarthing_scripts/tests/test_yaml_io.py +117 -0
  253. package/pennyfarthing_scripts/theme/__init__.py +5 -0
  254. package/pennyfarthing_scripts/theme/__main__.py +6 -0
  255. package/pennyfarthing_scripts/theme/__pycache__/__init__.cpython-314.pyc +0 -0
  256. package/pennyfarthing_scripts/theme/__pycache__/cli.cpython-314.pyc +0 -0
  257. package/pennyfarthing_scripts/theme/cli.py +286 -0
  258. package/scripts/README.md +53 -0
  259. package/scripts/postinstall.cjs +34 -0
  260. package/pennyfarthing-dist/agents/workflow-status-check.md +0 -96
  261. package/pennyfarthing-dist/scripts/sprint/archive-story.sh +0 -133
  262. package/pennyfarthing-dist/scripts/sprint/available-stories.sh +0 -91
  263. package/pennyfarthing-dist/scripts/sprint/check-story.sh +0 -158
  264. package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +0 -52
  265. package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +0 -63
  266. package/pennyfarthing-dist/scripts/sprint/list-future.sh +0 -145
  267. package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +0 -110
  268. package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +0 -148
  269. package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +0 -415
  270. package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +0 -33
  271. package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +0 -230
  272. package/pennyfarthing-dist/scripts/sprint/sprint-status.sh +0 -134
  273. package/pennyfarthing-dist/scripts/sprint/validate-sprint-yaml.sh +0 -139
  274. package/pennyfarthing-dist/skills/sprint/scripts/archive-story.sh +0 -101
  275. package/pennyfarthing-dist/skills/sprint/scripts/available-stories.sh +0 -97
  276. package/pennyfarthing-dist/skills/sprint/scripts/check-story.sh +0 -164
  277. package/pennyfarthing-dist/skills/sprint/scripts/create-jira-epic.sh +0 -23
  278. package/pennyfarthing-dist/skills/sprint/scripts/new-sprint.sh +0 -116
  279. package/pennyfarthing-dist/skills/sprint/scripts/promote-epic.sh +0 -164
  280. package/pennyfarthing-dist/skills/sprint/scripts/sprint-info.sh +0 -39
  281. package/pennyfarthing-dist/skills/sprint/scripts/sprint-status.sh +0 -147
  282. package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +0 -23
@@ -0,0 +1,682 @@
1
+ """Tests for codemarkers module.
2
+
3
+ Story 80-1: Python codemarkers module — grep + git blame.
4
+ MSSCI-14454
5
+
6
+ TDD RED phase — all tests written before implementation.
7
+ Tests cover: models, analyze engine, CLI, formatters.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ from dataclasses import asdict
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from unittest.mock import AsyncMock, MagicMock, patch
18
+
19
+ import pytest
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Models
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ class TestCodeMarkerModel:
28
+ """CodeMarker dataclass fields and defaults."""
29
+
30
+ def test_required_fields(self) -> None:
31
+ """CodeMarker requires path, line, marker_type, text."""
32
+ from pennyfarthing_scripts.codemarkers.models import CodeMarker
33
+
34
+ m = CodeMarker(path="src/app.ts", line=42, marker_type="TODO", text="TODO: refactor")
35
+ assert m.path == "src/app.ts"
36
+ assert m.line == 42
37
+ assert m.marker_type == "TODO"
38
+ assert m.text == "TODO: refactor"
39
+
40
+ def test_default_blame_fields(self) -> None:
41
+ """Blame fields default to empty/zero."""
42
+ from pennyfarthing_scripts.codemarkers.models import CodeMarker
43
+
44
+ m = CodeMarker(path="a.py", line=1, marker_type="FIXME", text="FIXME: broken")
45
+ assert m.author == ""
46
+ assert m.date == ""
47
+ assert m.age_days == 0.0
48
+ assert m.is_stale is False
49
+
50
+ def test_stale_marker(self) -> None:
51
+ """is_stale can be set to True."""
52
+ from pennyfarthing_scripts.codemarkers.models import CodeMarker
53
+
54
+ m = CodeMarker(
55
+ path="old.py", line=10, marker_type="TODO", text="TODO: old",
56
+ author="dev", date="2025-01-01T00:00:00", age_days=120.0, is_stale=True,
57
+ )
58
+ assert m.is_stale is True
59
+ assert m.age_days == 120.0
60
+
61
+ def test_serializable(self) -> None:
62
+ """CodeMarker should be serializable via dataclasses.asdict."""
63
+ from pennyfarthing_scripts.codemarkers.models import CodeMarker
64
+
65
+ m = CodeMarker(path="x.py", line=5, marker_type="HACK", text="HACK: workaround")
66
+ d = asdict(m)
67
+ assert d["path"] == "x.py"
68
+ assert d["marker_type"] == "HACK"
69
+ assert isinstance(d, dict)
70
+
71
+
72
+ class TestMarkerSummaryModel:
73
+ """MarkerSummary aggregates counts by type."""
74
+
75
+ def test_defaults(self) -> None:
76
+ """MarkerSummary fields default to zero/empty."""
77
+ from pennyfarthing_scripts.codemarkers.models import MarkerSummary
78
+
79
+ s = MarkerSummary()
80
+ assert s.total_markers == 0
81
+ assert s.stale_markers == 0
82
+ assert s.by_type == {}
83
+
84
+ def test_populated(self) -> None:
85
+ """MarkerSummary with values."""
86
+ from pennyfarthing_scripts.codemarkers.models import MarkerSummary
87
+
88
+ s = MarkerSummary(
89
+ total_markers=10, stale_markers=3,
90
+ by_type={"TODO": 6, "FIXME": 4},
91
+ )
92
+ assert s.total_markers == 10
93
+ assert s.by_type["FIXME"] == 4
94
+
95
+
96
+ class TestCodeMarkersResultModel:
97
+ """CodeMarkersResult follows ADR-0008 pattern."""
98
+
99
+ def test_success_result(self) -> None:
100
+ """Successful result has success=True and markers list."""
101
+ from pennyfarthing_scripts.codemarkers.models import CodeMarker, CodeMarkersResult
102
+
103
+ r = CodeMarkersResult(
104
+ success=True, repo_name="test", repo_path="/tmp/test",
105
+ stale_threshold_days=90,
106
+ markers=[CodeMarker(path="a.py", line=1, marker_type="TODO", text="TODO: x")],
107
+ )
108
+ assert r.success is True
109
+ assert len(r.markers) == 1
110
+ assert r.error is None
111
+
112
+ def test_error_result(self) -> None:
113
+ """Error result has success=False and error message."""
114
+ from pennyfarthing_scripts.codemarkers.models import CodeMarkersResult
115
+
116
+ r = CodeMarkersResult(
117
+ success=False, repo_name="bad", repo_path="/nonexistent",
118
+ stale_threshold_days=90, error="Path not found",
119
+ )
120
+ assert r.success is False
121
+ assert "not found" in r.error
122
+
123
+ def test_json_serializable(self) -> None:
124
+ """Full result serializes to JSON via asdict."""
125
+ from pennyfarthing_scripts.codemarkers.models import (
126
+ CodeMarker, CodeMarkersResult, MarkerSummary,
127
+ )
128
+
129
+ r = CodeMarkersResult(
130
+ success=True, repo_name="repo", repo_path="/tmp",
131
+ stale_threshold_days=90,
132
+ markers=[CodeMarker(path="b.py", line=2, marker_type="XXX", text="XXX: bad")],
133
+ summary=MarkerSummary(total_markers=1, by_type={"XXX": 1}),
134
+ )
135
+ text = json.dumps(asdict(r), default=str)
136
+ parsed = json.loads(text)
137
+ assert parsed["success"] is True
138
+ assert parsed["markers"][0]["marker_type"] == "XXX"
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Analyze — grep and blame
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ class TestGrepMarkers:
147
+ """_grep_markers finds TODO/FIXME/HACK/XXX in source files."""
148
+
149
+ def test_finds_todo(self, tmp_path: Path) -> None:
150
+ """Detects TODO comments."""
151
+ from pennyfarthing_scripts.codemarkers.analyze import _grep_markers
152
+
153
+ f = tmp_path / "app.py"
154
+ f.write_text("x = 1\n# TODO: fix this\ny = 2\n")
155
+ markers = _grep_markers(tmp_path, excludes=[])
156
+ assert len(markers) == 1
157
+ assert markers[0]["marker_type"] == "TODO"
158
+ assert markers[0]["line"] == 2
159
+
160
+ def test_finds_fixme(self, tmp_path: Path) -> None:
161
+ """Detects FIXME comments."""
162
+ from pennyfarthing_scripts.codemarkers.analyze import _grep_markers
163
+
164
+ f = tmp_path / "bug.ts"
165
+ f.write_text("// FIXME: null check needed\n")
166
+ markers = _grep_markers(tmp_path, excludes=[])
167
+ assert len(markers) == 1
168
+ assert markers[0]["marker_type"] == "FIXME"
169
+
170
+ def test_finds_hack(self, tmp_path: Path) -> None:
171
+ """Detects HACK comments."""
172
+ from pennyfarthing_scripts.codemarkers.analyze import _grep_markers
173
+
174
+ f = tmp_path / "util.js"
175
+ f.write_text("/* HACK: temporary workaround */\n")
176
+ markers = _grep_markers(tmp_path, excludes=[])
177
+ assert len(markers) == 1
178
+ assert markers[0]["marker_type"] == "HACK"
179
+
180
+ def test_finds_xxx(self, tmp_path: Path) -> None:
181
+ """Detects XXX comments."""
182
+ from pennyfarthing_scripts.codemarkers.analyze import _grep_markers
183
+
184
+ f = tmp_path / "danger.py"
185
+ f.write_text("# XXX: this is terrible\n")
186
+ markers = _grep_markers(tmp_path, excludes=[])
187
+ assert len(markers) == 1
188
+ assert markers[0]["marker_type"] == "XXX"
189
+
190
+ def test_multiple_markers_same_file(self, tmp_path: Path) -> None:
191
+ """Finds multiple markers in a single file."""
192
+ from pennyfarthing_scripts.codemarkers.analyze import _grep_markers
193
+
194
+ f = tmp_path / "multi.py"
195
+ f.write_text("# TODO: first\nx = 1\n# FIXME: second\n# HACK: third\n")
196
+ markers = _grep_markers(tmp_path, excludes=[])
197
+ assert len(markers) == 3
198
+ types = {m["marker_type"] for m in markers}
199
+ assert types == {"TODO", "FIXME", "HACK"}
200
+
201
+ def test_excludes_node_modules(self, tmp_path: Path) -> None:
202
+ """Files matching exclude patterns are skipped."""
203
+ from pennyfarthing_scripts.codemarkers.analyze import _grep_markers
204
+
205
+ nm = tmp_path / "node_modules" / "pkg"
206
+ nm.mkdir(parents=True)
207
+ (nm / "index.js").write_text("// TODO: vendor code\n")
208
+ (tmp_path / "src.py").write_text("# TODO: real code\n")
209
+
210
+ markers = _grep_markers(tmp_path, excludes=["node_modules/*"])
211
+ paths = [m["path"] for m in markers]
212
+ assert not any("node_modules" in p for p in paths)
213
+ assert len(markers) == 1
214
+
215
+ def test_case_sensitive(self, tmp_path: Path) -> None:
216
+ """Markers must be uppercase to match."""
217
+ from pennyfarthing_scripts.codemarkers.analyze import _grep_markers
218
+
219
+ f = tmp_path / "lower.py"
220
+ f.write_text("# todo: lowercase should not match\n# TODO: uppercase matches\n")
221
+ markers = _grep_markers(tmp_path, excludes=[])
222
+ assert len(markers) == 1
223
+ assert markers[0]["marker_type"] == "TODO"
224
+
225
+ def test_nested_directories(self, tmp_path: Path) -> None:
226
+ """Scans recursively into subdirectories."""
227
+ from pennyfarthing_scripts.codemarkers.analyze import _grep_markers
228
+
229
+ sub = tmp_path / "src" / "lib"
230
+ sub.mkdir(parents=True)
231
+ (sub / "deep.ts").write_text("// FIXME: deep bug\n")
232
+ markers = _grep_markers(tmp_path, excludes=[])
233
+ assert len(markers) == 1
234
+
235
+ def test_empty_directory(self, tmp_path: Path) -> None:
236
+ """Returns empty list for directory with no markers."""
237
+ from pennyfarthing_scripts.codemarkers.analyze import _grep_markers
238
+
239
+ (tmp_path / "clean.py").write_text("x = 1\ny = 2\n")
240
+ markers = _grep_markers(tmp_path, excludes=[])
241
+ assert markers == []
242
+
243
+ def test_binary_files_skipped(self, tmp_path: Path) -> None:
244
+ """Binary files should not cause crashes."""
245
+ from pennyfarthing_scripts.codemarkers.analyze import _grep_markers
246
+
247
+ (tmp_path / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
248
+ (tmp_path / "code.py").write_text("# TODO: real marker\n")
249
+ markers = _grep_markers(tmp_path, excludes=[])
250
+ assert len(markers) == 1
251
+
252
+
253
+ class TestParseBlameOutput:
254
+ """_parse_blame_porcelain extracts author and date from git blame --porcelain."""
255
+
256
+ def test_parses_author_and_time(self) -> None:
257
+ """Extracts author name and author-time from porcelain output."""
258
+ from pennyfarthing_scripts.codemarkers.analyze import _parse_blame_porcelain
259
+
260
+ porcelain = (
261
+ "abc123 1 1 1\n"
262
+ "author John Doe\n"
263
+ "author-mail <john@example.com>\n"
264
+ "author-time 1700000000\n"
265
+ "author-tz +0000\n"
266
+ "committer John Doe\n"
267
+ "committer-mail <john@example.com>\n"
268
+ "committer-time 1700000000\n"
269
+ "committer-tz +0000\n"
270
+ "summary Some commit\n"
271
+ "filename src/app.py\n"
272
+ "\t# TODO: fix this\n"
273
+ )
274
+ result = _parse_blame_porcelain(porcelain, line=1)
275
+ assert result["author"] == "John Doe"
276
+ assert result["author_time"] == 1700000000
277
+
278
+ def test_empty_output(self) -> None:
279
+ """Returns empty dict for empty output."""
280
+ from pennyfarthing_scripts.codemarkers.analyze import _parse_blame_porcelain
281
+
282
+ result = _parse_blame_porcelain("", line=1)
283
+ assert result == {} or result.get("author") == ""
284
+
285
+
286
+ class TestBatchBlame:
287
+ """_batch_blame_file calls git blame once per file, extracts multiple lines."""
288
+
289
+ @pytest.mark.asyncio
290
+ async def test_blames_entire_file(self) -> None:
291
+ """Blames the whole file and returns data for requested lines."""
292
+ from pennyfarthing_scripts.codemarkers.analyze import _batch_blame_file
293
+
294
+ blame_output = (
295
+ "abc123 1 1 1\n"
296
+ "author Alice\n"
297
+ "author-time 1700000000\n"
298
+ "author-tz +0000\n"
299
+ "committer Alice\n"
300
+ "committer-time 1700000000\n"
301
+ "committer-tz +0000\n"
302
+ "summary init\n"
303
+ "filename test.py\n"
304
+ "\tline 1\n"
305
+ "def456 2 2 1\n"
306
+ "author Bob\n"
307
+ "author-time 1700100000\n"
308
+ "author-tz +0000\n"
309
+ "committer Bob\n"
310
+ "committer-time 1700100000\n"
311
+ "committer-tz +0000\n"
312
+ "summary fix\n"
313
+ "filename test.py\n"
314
+ "\tline 2\n"
315
+ )
316
+
317
+ with patch(
318
+ "pennyfarthing_scripts.codemarkers.analyze._run_git_command",
319
+ new_callable=AsyncMock,
320
+ return_value=(blame_output, "", 0),
321
+ ):
322
+ results = await _batch_blame_file(
323
+ Path("/repo"), "test.py", [1, 2]
324
+ )
325
+ assert 1 in results
326
+ assert 2 in results
327
+ assert results[1]["author"] == "Alice"
328
+ assert results[2]["author"] == "Bob"
329
+
330
+ @pytest.mark.asyncio
331
+ async def test_git_blame_failure_returns_empty(self) -> None:
332
+ """When git blame fails, returns empty dict for all lines."""
333
+ from pennyfarthing_scripts.codemarkers.analyze import _batch_blame_file
334
+
335
+ with patch(
336
+ "pennyfarthing_scripts.codemarkers.analyze._run_git_command",
337
+ new_callable=AsyncMock,
338
+ return_value=("", "fatal: not a git repo", 128),
339
+ ):
340
+ results = await _batch_blame_file(Path("/bad"), "x.py", [1])
341
+ assert results == {} or results.get(1, {}).get("author", "") == ""
342
+
343
+
344
+ class TestAnalyzeRepo:
345
+ """analyze_repo is the main entry point — grep + blame + staleness."""
346
+
347
+ @pytest.mark.asyncio
348
+ async def test_nonexistent_path(self) -> None:
349
+ """Returns error result for nonexistent path."""
350
+ from pennyfarthing_scripts.codemarkers.analyze import analyze_repo
351
+
352
+ result = await analyze_repo("bad", Path("/nonexistent/repo"), days=90)
353
+ assert result.success is False
354
+ assert "not found" in result.error.lower() or "not exist" in result.error.lower()
355
+
356
+ @pytest.mark.asyncio
357
+ async def test_empty_repo(self, tmp_path: Path) -> None:
358
+ """Returns success with empty markers for repo with no markers."""
359
+ from pennyfarthing_scripts.codemarkers.analyze import analyze_repo
360
+
361
+ (tmp_path / ".git").mkdir()
362
+ (tmp_path / "clean.py").write_text("x = 1\n")
363
+
364
+ with patch(
365
+ "pennyfarthing_scripts.codemarkers.analyze._batch_blame_file",
366
+ new_callable=AsyncMock,
367
+ return_value={},
368
+ ):
369
+ result = await analyze_repo("test", tmp_path, days=90)
370
+ assert result.success is True
371
+ assert len(result.markers) == 0
372
+
373
+ @pytest.mark.asyncio
374
+ async def test_markers_enriched_with_blame(self, tmp_path: Path) -> None:
375
+ """Markers get author, date, age_days from git blame."""
376
+ from pennyfarthing_scripts.codemarkers.analyze import analyze_repo
377
+
378
+ (tmp_path / ".git").mkdir()
379
+ (tmp_path / "app.py").write_text("x = 1\n# TODO: fix this\ny = 2\n")
380
+
381
+ # Mock blame: author Alice, time = 90 days ago
382
+ import time
383
+ ninety_days_ago = int(time.time()) - (90 * 86400)
384
+
385
+ async def mock_blame(repo_path, file_path, lines):
386
+ return {
387
+ 2: {"author": "Alice", "author_time": ninety_days_ago},
388
+ }
389
+
390
+ with patch(
391
+ "pennyfarthing_scripts.codemarkers.analyze._batch_blame_file",
392
+ side_effect=mock_blame,
393
+ ):
394
+ result = await analyze_repo("test", tmp_path, days=90)
395
+ assert result.success is True
396
+ assert len(result.markers) == 1
397
+ assert result.markers[0].author == "Alice"
398
+ assert result.markers[0].age_days >= 89 # approximately 90
399
+
400
+ @pytest.mark.asyncio
401
+ async def test_stale_threshold(self, tmp_path: Path) -> None:
402
+ """Markers older than threshold are flagged is_stale=True."""
403
+ from pennyfarthing_scripts.codemarkers.analyze import analyze_repo
404
+
405
+ (tmp_path / ".git").mkdir()
406
+ (tmp_path / "old.py").write_text("# TODO: ancient code\n")
407
+
408
+ import time
409
+ old_time = int(time.time()) - (200 * 86400) # 200 days ago
410
+
411
+ async def mock_blame(repo_path, file_path, lines):
412
+ return {1: {"author": "OldDev", "author_time": old_time}}
413
+
414
+ with patch(
415
+ "pennyfarthing_scripts.codemarkers.analyze._batch_blame_file",
416
+ side_effect=mock_blame,
417
+ ):
418
+ result = await analyze_repo("test", tmp_path, days=90)
419
+ assert result.markers[0].is_stale is True
420
+ assert result.markers[0].age_days >= 199
421
+
422
+ @pytest.mark.asyncio
423
+ async def test_summary_computed(self, tmp_path: Path) -> None:
424
+ """Result includes summary with counts by type."""
425
+ from pennyfarthing_scripts.codemarkers.analyze import analyze_repo
426
+
427
+ (tmp_path / ".git").mkdir()
428
+ (tmp_path / "mix.py").write_text("# TODO: one\n# FIXME: two\n# TODO: three\n")
429
+
430
+ import time
431
+ recent = int(time.time()) - (10 * 86400)
432
+
433
+ async def mock_blame(repo_path, file_path, lines):
434
+ return {ln: {"author": "Dev", "author_time": recent} for ln in lines}
435
+
436
+ with patch(
437
+ "pennyfarthing_scripts.codemarkers.analyze._batch_blame_file",
438
+ side_effect=mock_blame,
439
+ ):
440
+ result = await analyze_repo("test", tmp_path, days=90)
441
+ assert result.summary is not None
442
+ assert result.summary.total_markers == 3
443
+ assert result.summary.by_type["TODO"] == 2
444
+ assert result.summary.by_type["FIXME"] == 1
445
+ assert result.summary.stale_markers == 0
446
+
447
+ @pytest.mark.asyncio
448
+ async def test_default_excludes_applied(self, tmp_path: Path) -> None:
449
+ """DEFAULT_EXCLUDES filters out node_modules, dist, etc."""
450
+ from pennyfarthing_scripts.codemarkers.analyze import analyze_repo
451
+
452
+ (tmp_path / ".git").mkdir()
453
+ nm = tmp_path / "node_modules" / "pkg"
454
+ nm.mkdir(parents=True)
455
+ (nm / "lib.js").write_text("// TODO: vendor code\n")
456
+ (tmp_path / "src.py").write_text("# TODO: real code\n")
457
+
458
+ import time
459
+ recent = int(time.time()) - 86400
460
+
461
+ async def mock_blame(repo_path, file_path, lines):
462
+ return {ln: {"author": "Dev", "author_time": recent} for ln in lines}
463
+
464
+ with patch(
465
+ "pennyfarthing_scripts.codemarkers.analyze._batch_blame_file",
466
+ side_effect=mock_blame,
467
+ ):
468
+ result = await analyze_repo("test", tmp_path, days=90)
469
+ paths = [m.path for m in result.markers]
470
+ assert not any("node_modules" in p for p in paths)
471
+
472
+
473
+ class TestShouldExclude:
474
+ """_should_exclude matches file paths against glob patterns."""
475
+
476
+ def test_matches_node_modules(self) -> None:
477
+ from pennyfarthing_scripts.codemarkers.analyze import _should_exclude
478
+
479
+ assert _should_exclude("node_modules/pkg/index.js", ["node_modules/*"]) is True
480
+
481
+ def test_matches_lock_file(self) -> None:
482
+ from pennyfarthing_scripts.codemarkers.analyze import _should_exclude
483
+
484
+ assert _should_exclude("package-lock.json", ["package-lock.json"]) is True
485
+
486
+ def test_matches_glob_extension(self) -> None:
487
+ from pennyfarthing_scripts.codemarkers.analyze import _should_exclude
488
+
489
+ assert _should_exclude("bundle.min.js", ["*.min.js"]) is True
490
+
491
+ def test_no_match(self) -> None:
492
+ from pennyfarthing_scripts.codemarkers.analyze import _should_exclude
493
+
494
+ assert _should_exclude("src/app.py", ["node_modules/*", "dist/*"]) is False
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # Formatters
499
+ # ---------------------------------------------------------------------------
500
+
501
+
502
+ class TestTableFormatter:
503
+ """format_marker_table produces column-aligned output."""
504
+
505
+ def test_empty_list(self) -> None:
506
+ """Empty marker list produces 'no markers' message."""
507
+ from pennyfarthing_scripts.codemarkers.formatters import format_marker_table
508
+
509
+ out = format_marker_table([])
510
+ assert "no" in out.lower() or "No" in out
511
+
512
+ def test_table_has_headers(self) -> None:
513
+ """Table output includes column headers."""
514
+ from pennyfarthing_scripts.codemarkers.formatters import format_marker_table
515
+ from pennyfarthing_scripts.codemarkers.models import CodeMarker
516
+
517
+ markers = [
518
+ CodeMarker(path="a.py", line=1, marker_type="TODO", text="TODO: test",
519
+ author="Dev", age_days=10.0),
520
+ ]
521
+ out = format_marker_table(markers)
522
+ assert "Type" in out
523
+ assert "File" in out or "Path" in out
524
+ assert "Age" in out
525
+
526
+ def test_top_n_limits_output(self) -> None:
527
+ """Table respects top_n limit."""
528
+ from pennyfarthing_scripts.codemarkers.formatters import format_marker_table
529
+ from pennyfarthing_scripts.codemarkers.models import CodeMarker
530
+
531
+ markers = [
532
+ CodeMarker(path=f"f{i}.py", line=i, marker_type="TODO", text=f"TODO: {i}")
533
+ for i in range(50)
534
+ ]
535
+ out = format_marker_table(markers, top_n=5)
536
+ # Header + separator + 5 data rows = 7 lines
537
+ lines = [l for l in out.strip().split("\n") if l.strip()]
538
+ assert len(lines) <= 7
539
+
540
+
541
+ class TestJsonExport:
542
+ """export_json serializes full result to JSON."""
543
+
544
+ def test_valid_json(self) -> None:
545
+ from pennyfarthing_scripts.codemarkers.formatters import export_json
546
+ from pennyfarthing_scripts.codemarkers.models import (
547
+ CodeMarker, CodeMarkersResult, MarkerSummary,
548
+ )
549
+
550
+ r = CodeMarkersResult(
551
+ success=True, repo_name="test", repo_path="/tmp",
552
+ stale_threshold_days=90,
553
+ markers=[CodeMarker(path="x.py", line=1, marker_type="TODO", text="TODO: x")],
554
+ summary=MarkerSummary(total_markers=1, by_type={"TODO": 1}),
555
+ )
556
+ text = export_json(r)
557
+ parsed = json.loads(text)
558
+ assert parsed["success"] is True
559
+ assert len(parsed["markers"]) == 1
560
+
561
+
562
+ class TestCsvExport:
563
+ """export_csv outputs CSV with headers."""
564
+
565
+ def test_csv_header_row(self) -> None:
566
+ from pennyfarthing_scripts.codemarkers.formatters import export_csv
567
+ from pennyfarthing_scripts.codemarkers.models import CodeMarker
568
+
569
+ markers = [
570
+ CodeMarker(path="a.py", line=1, marker_type="TODO", text="TODO: test"),
571
+ ]
572
+ text = export_csv(markers)
573
+ lines = text.strip().split("\n")
574
+ assert "path" in lines[0]
575
+ assert "marker_type" in lines[0]
576
+ assert len(lines) == 2 # header + 1 data row
577
+
578
+
579
+ # ---------------------------------------------------------------------------
580
+ # CLI
581
+ # ---------------------------------------------------------------------------
582
+
583
+
584
+ class TestCLI:
585
+ """Click CLI commands."""
586
+
587
+ def test_cli_help(self) -> None:
588
+ """codemarkers group shows help."""
589
+ from click.testing import CliRunner
590
+ from pennyfarthing_scripts.codemarkers.cli import codemarkers
591
+
592
+ runner = CliRunner()
593
+ result = runner.invoke(codemarkers, ["--help"])
594
+ assert result.exit_code == 0
595
+ assert "analyze" in result.output.lower()
596
+
597
+ def test_analyze_command_exists(self) -> None:
598
+ """analyze subcommand is registered."""
599
+ from click.testing import CliRunner
600
+ from pennyfarthing_scripts.codemarkers.cli import codemarkers
601
+
602
+ runner = CliRunner()
603
+ result = runner.invoke(codemarkers, ["analyze", "--help"])
604
+ assert result.exit_code == 0
605
+ assert "--days" in result.output
606
+ assert "--format" in result.output
607
+
608
+ def test_stale_command_exists(self) -> None:
609
+ """stale subcommand is registered."""
610
+ from click.testing import CliRunner
611
+ from pennyfarthing_scripts.codemarkers.cli import codemarkers
612
+
613
+ runner = CliRunner()
614
+ result = runner.invoke(codemarkers, ["stale", "--help"])
615
+ assert result.exit_code == 0
616
+
617
+ def test_summary_command_exists(self) -> None:
618
+ """summary subcommand is registered."""
619
+ from click.testing import CliRunner
620
+ from pennyfarthing_scripts.codemarkers.cli import codemarkers
621
+
622
+ runner = CliRunner()
623
+ result = runner.invoke(codemarkers, ["summary", "--help"])
624
+ assert result.exit_code == 0
625
+
626
+ def test_json_format_option(self) -> None:
627
+ """--format json produces JSON output."""
628
+ from click.testing import CliRunner
629
+ from pennyfarthing_scripts.codemarkers.cli import codemarkers
630
+
631
+ runner = CliRunner()
632
+ with patch(
633
+ "pennyfarthing_scripts.codemarkers.cli._run_analysis"
634
+ ) as mock_run:
635
+ from pennyfarthing_scripts.codemarkers.models import CodeMarkersResult, MarkerSummary
636
+ mock_run.return_value = CodeMarkersResult(
637
+ success=True, repo_name="test", repo_path="/tmp",
638
+ stale_threshold_days=90, markers=[],
639
+ summary=MarkerSummary(),
640
+ )
641
+ result = runner.invoke(codemarkers, ["analyze", "--path", "/tmp", "--format", "json"])
642
+ assert result.exit_code == 0
643
+ parsed = json.loads(result.output)
644
+ assert parsed["success"] is True
645
+
646
+
647
+ class TestMainModule:
648
+ """python -m pennyfarthing_scripts.codemarkers works."""
649
+
650
+ def test_module_runnable(self) -> None:
651
+ """Module can be invoked with --help."""
652
+ import subprocess
653
+ import sys
654
+
655
+ result = subprocess.run(
656
+ [sys.executable, "-m", "pennyfarthing_scripts.codemarkers", "--help"],
657
+ capture_output=True, text=True, timeout=30,
658
+ )
659
+ assert result.returncode == 0
660
+ assert "analyze" in result.stdout.lower()
661
+
662
+
663
+ # ---------------------------------------------------------------------------
664
+ # __init__ re-exports
665
+ # ---------------------------------------------------------------------------
666
+
667
+
668
+ class TestModuleExports:
669
+ """__init__.py re-exports key symbols."""
670
+
671
+ def test_exports_models(self) -> None:
672
+ from pennyfarthing_scripts.codemarkers import (
673
+ CodeMarker,
674
+ CodeMarkersResult,
675
+ MarkerSummary,
676
+ )
677
+ assert CodeMarker is not None
678
+ assert CodeMarkersResult is not None
679
+
680
+ def test_exports_analyze(self) -> None:
681
+ from pennyfarthing_scripts.codemarkers import analyze_repo
682
+ assert callable(analyze_repo)