@pennyfarthing/core 7.8.1 → 7.8.4

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 (178) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
  4. package/packages/core/dist/cli/commands/init.js +7 -6
  5. package/packages/core/dist/cli/commands/init.js.map +1 -1
  6. package/packages/core/dist/cli/utils/symlinks.d.ts +7 -0
  7. package/packages/core/dist/cli/utils/symlinks.d.ts.map +1 -1
  8. package/packages/core/dist/cli/utils/symlinks.js +25 -0
  9. package/packages/core/dist/cli/utils/symlinks.js.map +1 -1
  10. package/pennyfarthing-dist/scripts/core/phase-check-start.sh +1 -1
  11. package/pennyfarthing-dist/scripts/core/prime.sh +23 -0
  12. package/pennyfarthing-dist/scripts/core/run.sh +5 -5
  13. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +2 -2
  14. package/pennyfarthing-dist/scripts/git/release.sh +2 -2
  15. package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -1
  16. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +2 -2
  17. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +2 -2
  18. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +1 -1
  19. package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -1
  20. package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +1 -1
  21. package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +1 -1
  22. package/pennyfarthing-dist/scripts/lib/common.sh +1 -1
  23. package/pennyfarthing-dist/scripts/lib/find-root.sh +4 -4
  24. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -1
  25. package/pennyfarthing-dist/scripts/misc/add_short_names.py +2 -2
  26. package/pennyfarthing-dist/scripts/misc/backlog.sh +2 -2
  27. package/pennyfarthing-dist/scripts/misc/deploy.sh +2 -2
  28. package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +4 -4
  29. package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +2 -2
  30. package/pennyfarthing-dist/scripts/misc/run-ci.sh +1 -1
  31. package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +2 -2
  32. package/pennyfarthing-dist/scripts/sprint/archive-story.sh +1 -1
  33. package/pennyfarthing-dist/scripts/sprint/available-stories.sh +1 -1
  34. package/pennyfarthing-dist/scripts/sprint/check-story.sh +1 -1
  35. package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +1 -1
  36. package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +1 -1
  37. package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +2 -2
  38. package/pennyfarthing-dist/scripts/sprint/list-future.sh +1 -1
  39. package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +1 -1
  40. package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +1 -1
  41. package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +3 -3
  42. package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +1 -1
  43. package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +2 -2
  44. package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +2 -2
  45. package/pennyfarthing-dist/scripts/workflow/check.py +2 -2
  46. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +1 -1
  47. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +1 -1
  48. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +2 -2
  49. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +1 -1
  50. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -1
  51. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +1 -1
  52. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +1 -1
  53. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +1 -1
  54. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +1 -1
  55. package/pennyfarthing_scripts/__init__.py +17 -0
  56. package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  57. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  58. package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
  59. package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
  60. package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
  61. package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
  62. package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
  63. package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
  64. package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
  65. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  66. package/pennyfarthing_scripts/bellmode_hook.py +154 -0
  67. package/pennyfarthing_scripts/brownfield/__init__.py +35 -0
  68. package/pennyfarthing_scripts/brownfield/__main__.py +7 -0
  69. package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
  70. package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
  71. package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
  72. package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
  73. package/pennyfarthing_scripts/brownfield/cli.py +131 -0
  74. package/pennyfarthing_scripts/brownfield/discover.py +753 -0
  75. package/pennyfarthing_scripts/common/__init__.py +49 -0
  76. package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
  77. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  78. package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
  79. package/pennyfarthing_scripts/common/config.py +65 -0
  80. package/pennyfarthing_scripts/common/output.py +180 -0
  81. package/pennyfarthing_scripts/config.py +21 -0
  82. package/pennyfarthing_scripts/git/__init__.py +29 -0
  83. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  84. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  85. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  86. package/pennyfarthing_scripts/git/create_branches.py +439 -0
  87. package/pennyfarthing_scripts/git/status_all.py +310 -0
  88. package/pennyfarthing_scripts/hooks.py +455 -0
  89. package/pennyfarthing_scripts/jira/__init__.py +93 -0
  90. package/pennyfarthing_scripts/jira/__main__.py +10 -0
  91. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  92. package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  94. package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
  95. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  96. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  97. package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
  98. package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
  99. package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
  101. package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
  102. package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
  103. package/pennyfarthing_scripts/jira/bidirectional.py +561 -0
  104. package/pennyfarthing_scripts/jira/claim.py +211 -0
  105. package/pennyfarthing_scripts/jira/cli.py +150 -0
  106. package/pennyfarthing_scripts/jira/client.py +613 -0
  107. package/pennyfarthing_scripts/jira/epic.py +176 -0
  108. package/pennyfarthing_scripts/jira/story.py +219 -0
  109. package/pennyfarthing_scripts/jira/sync.py +350 -0
  110. package/pennyfarthing_scripts/jira_bidirectional_sync.py +37 -0
  111. package/pennyfarthing_scripts/jira_epic_creation.py +30 -0
  112. package/pennyfarthing_scripts/jira_sync.py +36 -0
  113. package/pennyfarthing_scripts/jira_sync_story.py +30 -0
  114. package/pennyfarthing_scripts/output.py +37 -0
  115. package/pennyfarthing_scripts/preflight/__init__.py +17 -0
  116. package/pennyfarthing_scripts/preflight/__main__.py +10 -0
  117. package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
  118. package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
  119. package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
  120. package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
  121. package/pennyfarthing_scripts/preflight/cli.py +141 -0
  122. package/pennyfarthing_scripts/preflight/finish.py +382 -0
  123. package/pennyfarthing_scripts/pretooluse_hook.py +142 -0
  124. package/pennyfarthing_scripts/prime/__init__.py +38 -0
  125. package/pennyfarthing_scripts/prime/__main__.py +8 -0
  126. package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
  127. package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  131. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/prime/cli.py +220 -0
  135. package/pennyfarthing_scripts/prime/loader.py +239 -0
  136. package/pennyfarthing_scripts/sprint/__init__.py +66 -0
  137. package/pennyfarthing_scripts/sprint/__main__.py +10 -0
  138. package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
  140. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  141. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  142. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  143. package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  145. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  146. package/pennyfarthing_scripts/sprint/archive.py +108 -0
  147. package/pennyfarthing_scripts/sprint/cli.py +124 -0
  148. package/pennyfarthing_scripts/sprint/loader.py +193 -0
  149. package/pennyfarthing_scripts/sprint/status.py +122 -0
  150. package/pennyfarthing_scripts/sprint/validator.py +405 -0
  151. package/pennyfarthing_scripts/sprint/work.py +192 -0
  152. package/pennyfarthing_scripts/story/__init__.py +67 -0
  153. package/pennyfarthing_scripts/story/__main__.py +10 -0
  154. package/pennyfarthing_scripts/story/cli.py +105 -0
  155. package/pennyfarthing_scripts/story/create.py +167 -0
  156. package/pennyfarthing_scripts/story/size.py +113 -0
  157. package/pennyfarthing_scripts/story/template.py +151 -0
  158. package/pennyfarthing_scripts/swebench.py +216 -0
  159. package/pennyfarthing_scripts/tests/__init__.py +1 -0
  160. package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
  161. package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  162. package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
  163. package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  164. package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
  165. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
  166. package/pennyfarthing_scripts/tests/conftest.py +106 -0
  167. package/pennyfarthing_scripts/tests/test_brownfield.py +842 -0
  168. package/pennyfarthing_scripts/tests/test_cli_modules.py +245 -0
  169. package/pennyfarthing_scripts/tests/test_common.py +180 -0
  170. package/pennyfarthing_scripts/tests/test_git_utils.py +866 -0
  171. package/pennyfarthing_scripts/tests/test_jira_package.py +334 -0
  172. package/pennyfarthing_scripts/tests/test_package_structure.py +372 -0
  173. package/pennyfarthing_scripts/tests/test_prime.py +397 -0
  174. package/pennyfarthing_scripts/tests/test_sprint_package.py +236 -0
  175. package/pennyfarthing_scripts/tests/test_sprint_validator.py +675 -0
  176. package/pennyfarthing_scripts/tests/test_story_package.py +156 -0
  177. package/pennyfarthing_scripts/welcome_hook.py +157 -0
  178. package/pennyfarthing_scripts/workflow.py +183 -0
@@ -0,0 +1,842 @@
1
+ """Tests for brownfield discovery module.
2
+
3
+ Story MSSCI-12419: Brownfield discovery command.
4
+
5
+ Tests verify:
6
+ 1. Project type detection (monorepo, single package, etc.)
7
+ 2. Tech stack extraction from manifest files
8
+ 3. Directory scanning with async parallelism
9
+ 4. Architecture pattern recognition
10
+ 5. Document generation matching _bmad-output format
11
+ 6. Depth levels (quick/standard/deep)
12
+ 7. CLI integration
13
+ """
14
+
15
+ import asyncio
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ from pathlib import Path
20
+ from typing import Generator
21
+ from unittest.mock import MagicMock, patch
22
+
23
+ import pytest
24
+
25
+ from pennyfarthing_scripts.brownfield import (
26
+ DepthLevel,
27
+ ProjectType,
28
+ DiscoveryResult,
29
+ detect_project_type,
30
+ detect_tech_stack,
31
+ scan_directory_structure,
32
+ detect_architecture_patterns,
33
+ generate_project_overview,
34
+ generate_tech_stack_doc,
35
+ generate_source_tree_doc,
36
+ generate_ai_guidance_doc,
37
+ discover,
38
+ )
39
+ from pennyfarthing_scripts.brownfield.discover import (
40
+ TechStackItem,
41
+ DirectoryNode,
42
+ ArchitecturePattern,
43
+ )
44
+
45
+
46
+ # =============================================================================
47
+ # FIXTURES
48
+ # =============================================================================
49
+
50
+
51
+ @pytest.fixture
52
+ def temp_project_dir() -> Generator[Path, None, None]:
53
+ """Create a temporary project directory."""
54
+ with tempfile.TemporaryDirectory() as tmp:
55
+ yield Path(tmp)
56
+
57
+
58
+ @pytest.fixture
59
+ def node_project(temp_project_dir: Path) -> Path:
60
+ """Create a basic Node.js project structure."""
61
+ # package.json
62
+ (temp_project_dir / "package.json").write_text('''{
63
+ "name": "test-project",
64
+ "version": "1.0.0",
65
+ "type": "module",
66
+ "dependencies": {
67
+ "express": "^4.18.2"
68
+ },
69
+ "devDependencies": {
70
+ "typescript": "^5.3.3"
71
+ }
72
+ }''')
73
+
74
+ # tsconfig.json
75
+ (temp_project_dir / "tsconfig.json").write_text('''{
76
+ "compilerOptions": {
77
+ "target": "ES2022",
78
+ "module": "NodeNext"
79
+ }
80
+ }''')
81
+
82
+ # src directory
83
+ src = temp_project_dir / "src"
84
+ src.mkdir()
85
+ (src / "index.ts").write_text("export const hello = 'world';")
86
+
87
+ return temp_project_dir
88
+
89
+
90
+ @pytest.fixture
91
+ def python_project(temp_project_dir: Path) -> Path:
92
+ """Create a basic Python project structure."""
93
+ # pyproject.toml
94
+ (temp_project_dir / "pyproject.toml").write_text('''[project]
95
+ name = "test-project"
96
+ version = "1.0.0"
97
+ dependencies = [
98
+ "requests>=2.28.0",
99
+ "pyyaml>=6.0"
100
+ ]
101
+
102
+ [project.optional-dependencies]
103
+ dev = ["pytest>=7.0.0", "black>=23.0.0"]
104
+ ''')
105
+
106
+ # src directory
107
+ src = temp_project_dir / "src" / "test_project"
108
+ src.mkdir(parents=True)
109
+ (src / "__init__.py").write_text("__version__ = '1.0.0'")
110
+
111
+ return temp_project_dir
112
+
113
+
114
+ @pytest.fixture
115
+ def monorepo_project(temp_project_dir: Path) -> Path:
116
+ """Create a pnpm monorepo structure."""
117
+ # Root package.json
118
+ (temp_project_dir / "package.json").write_text('''{
119
+ "name": "test-monorepo",
120
+ "version": "1.0.0",
121
+ "private": true,
122
+ "workspaces": ["packages/*"]
123
+ }''')
124
+
125
+ # pnpm-workspace.yaml
126
+ (temp_project_dir / "pnpm-workspace.yaml").write_text('''packages:
127
+ - packages/*
128
+ ''')
129
+
130
+ # Package A
131
+ pkg_a = temp_project_dir / "packages" / "core"
132
+ pkg_a.mkdir(parents=True)
133
+ (pkg_a / "package.json").write_text('''{
134
+ "name": "@test/core",
135
+ "version": "1.0.0"
136
+ }''')
137
+
138
+ # Package B
139
+ pkg_b = temp_project_dir / "packages" / "cli"
140
+ pkg_b.mkdir(parents=True)
141
+ (pkg_b / "package.json").write_text('''{
142
+ "name": "@test/cli",
143
+ "version": "1.0.0",
144
+ "dependencies": {
145
+ "@test/core": "workspace:*"
146
+ }
147
+ }''')
148
+
149
+ return temp_project_dir
150
+
151
+
152
+ @pytest.fixture
153
+ def multi_language_project(temp_project_dir: Path) -> Path:
154
+ """Create a project with multiple languages."""
155
+ # Node.js
156
+ (temp_project_dir / "package.json").write_text('{"name": "multi", "version": "1.0.0"}')
157
+
158
+ # Python
159
+ (temp_project_dir / "pyproject.toml").write_text('[project]\nname = "multi"\nversion = "1.0.0"')
160
+
161
+ # Go
162
+ (temp_project_dir / "go.mod").write_text('module example.com/multi\n\ngo 1.21')
163
+
164
+ return temp_project_dir
165
+
166
+
167
+ # =============================================================================
168
+ # PROJECT TYPE DETECTION TESTS
169
+ # =============================================================================
170
+
171
+
172
+ class TestProjectTypeDetection:
173
+ """Tests for detect_project_type()."""
174
+
175
+ @pytest.mark.asyncio
176
+ async def test_detect_monorepo_from_workspaces(self, monorepo_project: Path) -> None:
177
+ """Should detect monorepo from package.json workspaces."""
178
+ result = await detect_project_type(monorepo_project)
179
+ assert result == ProjectType.MONOREPO
180
+
181
+ @pytest.mark.asyncio
182
+ async def test_detect_monorepo_from_pnpm_workspace(self, temp_project_dir: Path) -> None:
183
+ """Should detect monorepo from pnpm-workspace.yaml."""
184
+ (temp_project_dir / "pnpm-workspace.yaml").write_text("packages:\n - packages/*")
185
+ (temp_project_dir / "packages").mkdir()
186
+
187
+ result = await detect_project_type(temp_project_dir)
188
+ assert result == ProjectType.MONOREPO
189
+
190
+ @pytest.mark.asyncio
191
+ async def test_detect_single_node_package(self, node_project: Path) -> None:
192
+ """Should detect single package from package.json without workspaces."""
193
+ result = await detect_project_type(node_project)
194
+ assert result == ProjectType.SINGLE_PACKAGE
195
+
196
+ @pytest.mark.asyncio
197
+ async def test_detect_single_python_package(self, python_project: Path) -> None:
198
+ """Should detect single package from pyproject.toml."""
199
+ result = await detect_project_type(python_project)
200
+ assert result == ProjectType.SINGLE_PACKAGE
201
+
202
+ @pytest.mark.asyncio
203
+ async def test_detect_multi_language(self, multi_language_project: Path) -> None:
204
+ """Should detect multi-language project."""
205
+ result = await detect_project_type(multi_language_project)
206
+ assert result == ProjectType.MULTI_LANGUAGE
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_detect_unknown_for_empty_dir(self, temp_project_dir: Path) -> None:
210
+ """Should return UNKNOWN for empty directory."""
211
+ result = await detect_project_type(temp_project_dir)
212
+ assert result == ProjectType.UNKNOWN
213
+
214
+
215
+ # =============================================================================
216
+ # TECH STACK DETECTION TESTS
217
+ # =============================================================================
218
+
219
+
220
+ class TestTechStackDetection:
221
+ """Tests for detect_tech_stack()."""
222
+
223
+ @pytest.mark.asyncio
224
+ async def test_detect_node_dependencies(self, node_project: Path) -> None:
225
+ """Should detect Node.js dependencies from package.json."""
226
+ result = await detect_tech_stack(node_project)
227
+
228
+ names = [item.name for item in result]
229
+ assert "express" in names
230
+ assert "typescript" in names
231
+
232
+ @pytest.mark.asyncio
233
+ async def test_detect_node_versions(self, node_project: Path) -> None:
234
+ """Should extract dependency versions."""
235
+ result = await detect_tech_stack(node_project)
236
+
237
+ express = next(item for item in result if item.name == "express")
238
+ assert express.version == "^4.18.2"
239
+
240
+ @pytest.mark.asyncio
241
+ async def test_categorize_dev_dependencies(self, node_project: Path) -> None:
242
+ """Should categorize devDependencies correctly."""
243
+ result = await detect_tech_stack(node_project)
244
+
245
+ typescript = next(item for item in result if item.name == "typescript")
246
+ assert typescript.category == "dev"
247
+
248
+ @pytest.mark.asyncio
249
+ async def test_detect_python_dependencies(self, python_project: Path) -> None:
250
+ """Should detect Python dependencies from pyproject.toml."""
251
+ result = await detect_tech_stack(python_project)
252
+
253
+ names = [item.name for item in result]
254
+ assert "requests" in names
255
+ assert "pyyaml" in names
256
+
257
+ @pytest.mark.asyncio
258
+ async def test_detect_python_dev_dependencies(self, python_project: Path) -> None:
259
+ """Should detect Python dev dependencies."""
260
+ result = await detect_tech_stack(python_project)
261
+
262
+ pytest_item = next((item for item in result if item.name == "pytest"), None)
263
+ assert pytest_item is not None
264
+ assert pytest_item.category == "dev"
265
+
266
+ @pytest.mark.asyncio
267
+ async def test_detect_go_module(self, temp_project_dir: Path) -> None:
268
+ """Should detect Go from go.mod."""
269
+ (temp_project_dir / "go.mod").write_text('''module example.com/test
270
+
271
+ go 1.21
272
+
273
+ require (
274
+ github.com/gin-gonic/gin v1.9.1
275
+ )
276
+ ''')
277
+
278
+ result = await detect_tech_stack(temp_project_dir)
279
+
280
+ names = [item.name for item in result]
281
+ assert "go" in names or "gin" in names
282
+
283
+ @pytest.mark.asyncio
284
+ async def test_detect_rust_crate(self, temp_project_dir: Path) -> None:
285
+ """Should detect Rust from Cargo.toml."""
286
+ (temp_project_dir / "Cargo.toml").write_text('''[package]
287
+ name = "test-crate"
288
+ version = "0.1.0"
289
+
290
+ [dependencies]
291
+ serde = "1.0"
292
+ ''')
293
+
294
+ result = await detect_tech_stack(temp_project_dir)
295
+
296
+ names = [item.name for item in result]
297
+ assert "serde" in names or "rust" in names
298
+
299
+ @pytest.mark.asyncio
300
+ async def test_quick_depth_only_root(self, monorepo_project: Path) -> None:
301
+ """Quick depth should only scan root manifest files."""
302
+ result = await detect_tech_stack(monorepo_project, depth=DepthLevel.QUICK)
303
+
304
+ # Should not include nested package deps
305
+ names = [item.name for item in result]
306
+ # Should have root project but not workspace packages' specific deps
307
+ assert len(names) >= 0 # At least scanned something
308
+
309
+ @pytest.mark.asyncio
310
+ async def test_deep_depth_includes_nested(self, monorepo_project: Path) -> None:
311
+ """Deep depth should scan nested packages."""
312
+ result = await detect_tech_stack(monorepo_project, depth=DepthLevel.DEEP)
313
+
314
+ # Should include deps from workspace packages
315
+ assert len(result) >= 0
316
+
317
+
318
+ # =============================================================================
319
+ # DIRECTORY SCANNING TESTS
320
+ # =============================================================================
321
+
322
+
323
+ class TestDirectoryScanning:
324
+ """Tests for scan_directory_structure()."""
325
+
326
+ @pytest.mark.asyncio
327
+ async def test_scan_returns_root_node(self, node_project: Path) -> None:
328
+ """Should return root DirectoryNode."""
329
+ result = await scan_directory_structure(node_project)
330
+
331
+ assert isinstance(result, DirectoryNode)
332
+ assert result.path == node_project
333
+ assert result.is_dir is True
334
+
335
+ @pytest.mark.asyncio
336
+ async def test_scan_includes_children(self, node_project: Path) -> None:
337
+ """Should include child directories."""
338
+ result = await scan_directory_structure(node_project)
339
+
340
+ child_names = [c.name for c in result.children]
341
+ assert "src" in child_names
342
+
343
+ @pytest.mark.asyncio
344
+ async def test_scan_respects_max_depth(self, temp_project_dir: Path) -> None:
345
+ """Should respect max_depth parameter."""
346
+ # Create deep nesting
347
+ deep = temp_project_dir / "a" / "b" / "c" / "d" / "e"
348
+ deep.mkdir(parents=True)
349
+
350
+ result = await scan_directory_structure(temp_project_dir, max_depth=2)
351
+
352
+ # Verify we don't go too deep
353
+ def count_depth(node: DirectoryNode, current: int = 0) -> int:
354
+ if not node.children:
355
+ return current
356
+ return max(count_depth(c, current + 1) for c in node.children)
357
+
358
+ assert count_depth(result) <= 2
359
+
360
+ @pytest.mark.asyncio
361
+ async def test_scan_annotates_common_dirs(self, node_project: Path) -> None:
362
+ """Should annotate common directory names."""
363
+ result = await scan_directory_structure(node_project)
364
+
365
+ src_node = next((c for c in result.children if c.name == "src"), None)
366
+ assert src_node is not None
367
+ assert src_node.annotation != "" # Should have annotation
368
+
369
+ @pytest.mark.asyncio
370
+ async def test_scan_excludes_node_modules(self, node_project: Path) -> None:
371
+ """Should exclude node_modules by default."""
372
+ (node_project / "node_modules" / "express").mkdir(parents=True)
373
+
374
+ result = await scan_directory_structure(node_project)
375
+
376
+ child_names = [c.name for c in result.children]
377
+ assert "node_modules" not in child_names
378
+
379
+ @pytest.mark.asyncio
380
+ async def test_scan_excludes_git(self, node_project: Path) -> None:
381
+ """Should exclude .git directory."""
382
+ (node_project / ".git" / "objects").mkdir(parents=True)
383
+
384
+ result = await scan_directory_structure(node_project)
385
+
386
+ child_names = [c.name for c in result.children]
387
+ assert ".git" not in child_names
388
+
389
+ @pytest.mark.asyncio
390
+ async def test_scan_parallel_execution(self, temp_project_dir: Path) -> None:
391
+ """Should scan directories in parallel."""
392
+ # Create multiple directories
393
+ for i in range(10):
394
+ (temp_project_dir / f"dir_{i}").mkdir()
395
+
396
+ # Time the scan (should be fast due to parallelism)
397
+ import time
398
+ start = time.time()
399
+ result = await scan_directory_structure(temp_project_dir)
400
+ elapsed = time.time() - start
401
+
402
+ assert len(result.children) == 10
403
+ # Should complete quickly with parallelism
404
+ assert elapsed < 5.0 # Very generous timeout
405
+
406
+
407
+ # =============================================================================
408
+ # ARCHITECTURE PATTERN DETECTION TESTS
409
+ # =============================================================================
410
+
411
+
412
+ class TestArchitecturePatterns:
413
+ """Tests for detect_architecture_patterns()."""
414
+
415
+ @pytest.mark.asyncio
416
+ async def test_detect_monorepo_pattern(self, monorepo_project: Path) -> None:
417
+ """Should detect monorepo architecture pattern."""
418
+ result = await detect_architecture_patterns(monorepo_project)
419
+
420
+ pattern_names = [p.name for p in result]
421
+ assert "monorepo" in pattern_names or "workspace" in pattern_names
422
+
423
+ @pytest.mark.asyncio
424
+ async def test_detect_src_lib_pattern(self, node_project: Path) -> None:
425
+ """Should detect src/ directory pattern."""
426
+ result = await detect_architecture_patterns(node_project)
427
+
428
+ pattern_names = [p.name for p in result]
429
+ # Should detect layered or src-based architecture
430
+ assert len(pattern_names) >= 0
431
+
432
+ @pytest.mark.asyncio
433
+ async def test_pattern_includes_evidence(self, monorepo_project: Path) -> None:
434
+ """Should include evidence for detected patterns."""
435
+ result = await detect_architecture_patterns(monorepo_project)
436
+
437
+ if result:
438
+ pattern = result[0]
439
+ assert len(pattern.evidence) > 0
440
+
441
+ @pytest.mark.asyncio
442
+ async def test_detect_mvc_pattern(self, temp_project_dir: Path) -> None:
443
+ """Should detect MVC pattern from directory structure."""
444
+ # Create MVC-like structure
445
+ (temp_project_dir / "models").mkdir()
446
+ (temp_project_dir / "views").mkdir()
447
+ (temp_project_dir / "controllers").mkdir()
448
+
449
+ result = await detect_architecture_patterns(temp_project_dir)
450
+
451
+ pattern_names = [p.name.lower() for p in result]
452
+ assert "mvc" in pattern_names
453
+
454
+ @pytest.mark.asyncio
455
+ async def test_detect_layered_architecture(self, temp_project_dir: Path) -> None:
456
+ """Should detect layered architecture."""
457
+ # Create layered structure
458
+ (temp_project_dir / "api").mkdir()
459
+ (temp_project_dir / "services").mkdir()
460
+ (temp_project_dir / "repositories").mkdir()
461
+
462
+ result = await detect_architecture_patterns(temp_project_dir)
463
+
464
+ pattern_names = [p.name.lower() for p in result]
465
+ assert "layered" in pattern_names or "service" in pattern_names
466
+
467
+
468
+ # =============================================================================
469
+ # DOCUMENT GENERATION TESTS
470
+ # =============================================================================
471
+
472
+
473
+ class TestDocumentGeneration:
474
+ """Tests for document generation functions."""
475
+
476
+ @pytest.fixture
477
+ def sample_result(self, node_project: Path) -> DiscoveryResult:
478
+ """Create a sample discovery result."""
479
+ return DiscoveryResult(
480
+ project_path=node_project,
481
+ project_type=ProjectType.SINGLE_PACKAGE,
482
+ project_name="test-project",
483
+ version="1.0.0",
484
+ tech_stack=[
485
+ TechStackItem("express", "^4.18.2", "runtime"),
486
+ TechStackItem("typescript", "^5.3.3", "dev"),
487
+ ],
488
+ directory_tree=DirectoryNode(
489
+ path=node_project,
490
+ name="test-project",
491
+ is_dir=True,
492
+ children=[
493
+ DirectoryNode(
494
+ path=node_project / "src",
495
+ name="src",
496
+ is_dir=True,
497
+ annotation="Source code",
498
+ ),
499
+ ],
500
+ ),
501
+ patterns=[
502
+ ArchitecturePattern(
503
+ "typescript",
504
+ "TypeScript project with compilation",
505
+ ["tsconfig.json present"],
506
+ ),
507
+ ],
508
+ )
509
+
510
+ def test_generate_project_overview_markdown(self, sample_result: DiscoveryResult) -> None:
511
+ """Should generate valid markdown."""
512
+ content = generate_project_overview(sample_result)
513
+
514
+ assert isinstance(content, str)
515
+ assert len(content) > 0
516
+ assert "# " in content # Has markdown headers
517
+
518
+ def test_project_overview_includes_name(self, sample_result: DiscoveryResult) -> None:
519
+ """Should include project name."""
520
+ content = generate_project_overview(sample_result)
521
+
522
+ assert "test-project" in content
523
+
524
+ def test_project_overview_includes_version(self, sample_result: DiscoveryResult) -> None:
525
+ """Should include version."""
526
+ content = generate_project_overview(sample_result)
527
+
528
+ assert "1.0.0" in content
529
+
530
+ def test_generate_tech_stack_markdown(self, sample_result: DiscoveryResult) -> None:
531
+ """Should generate tech stack markdown."""
532
+ content = generate_tech_stack_doc(sample_result)
533
+
534
+ assert isinstance(content, str)
535
+ assert "express" in content
536
+ assert "typescript" in content
537
+
538
+ def test_tech_stack_includes_table(self, sample_result: DiscoveryResult) -> None:
539
+ """Should include markdown table."""
540
+ content = generate_tech_stack_doc(sample_result)
541
+
542
+ assert "|" in content # Table delimiter
543
+
544
+ def test_generate_source_tree_markdown(self, sample_result: DiscoveryResult) -> None:
545
+ """Should generate source tree markdown."""
546
+ content = generate_source_tree_doc(sample_result)
547
+
548
+ assert isinstance(content, str)
549
+ assert "src" in content
550
+
551
+ def test_source_tree_shows_structure(self, sample_result: DiscoveryResult) -> None:
552
+ """Should show tree structure."""
553
+ content = generate_source_tree_doc(sample_result)
554
+
555
+ # Should have tree-like characters or indentation
556
+ assert "├" in content or "└" in content or " " in content
557
+
558
+ def test_generate_ai_guidance_markdown(self, sample_result: DiscoveryResult) -> None:
559
+ """Should generate AI guidance markdown."""
560
+ content = generate_ai_guidance_doc(sample_result)
561
+
562
+ assert isinstance(content, str)
563
+ assert len(content) > 0
564
+
565
+ def test_ai_guidance_includes_patterns(self, sample_result: DiscoveryResult) -> None:
566
+ """Should mention detected patterns."""
567
+ content = generate_ai_guidance_doc(sample_result)
568
+
569
+ assert "typescript" in content.lower()
570
+
571
+
572
+ # =============================================================================
573
+ # INTEGRATION TESTS - discover()
574
+ # =============================================================================
575
+
576
+
577
+ class TestDiscover:
578
+ """Integration tests for discover()."""
579
+
580
+ @pytest.mark.asyncio
581
+ async def test_discover_returns_result(self, node_project: Path) -> None:
582
+ """Should return DiscoveryResult."""
583
+ result = await discover(node_project)
584
+
585
+ assert isinstance(result, DiscoveryResult)
586
+ assert result.success is True
587
+
588
+ @pytest.mark.asyncio
589
+ async def test_discover_populates_all_fields(self, node_project: Path) -> None:
590
+ """Should populate all result fields."""
591
+ result = await discover(node_project)
592
+
593
+ assert result.project_name is not None
594
+ assert result.project_type != ProjectType.UNKNOWN
595
+ assert len(result.tech_stack) > 0
596
+ assert result.directory_tree is not None
597
+
598
+ @pytest.mark.asyncio
599
+ async def test_discover_quick_depth(self, node_project: Path) -> None:
600
+ """Quick depth should complete quickly."""
601
+ import time
602
+ start = time.time()
603
+ result = await discover(node_project, depth=DepthLevel.QUICK)
604
+ elapsed = time.time() - start
605
+
606
+ assert result.success is True
607
+ assert elapsed < 10.0 # Should be fast
608
+
609
+ @pytest.mark.asyncio
610
+ async def test_discover_writes_output(self, node_project: Path, temp_project_dir: Path) -> None:
611
+ """Should write output files when output_dir specified."""
612
+ output_dir = temp_project_dir / "output"
613
+ output_dir.mkdir()
614
+
615
+ result = await discover(node_project, output_dir=output_dir)
616
+
617
+ assert result.success is True
618
+ assert (output_dir / "project-overview.md").exists()
619
+
620
+ @pytest.mark.asyncio
621
+ async def test_discover_all_output_files(self, node_project: Path, temp_project_dir: Path) -> None:
622
+ """Should write all expected output files."""
623
+ output_dir = temp_project_dir / "output"
624
+ output_dir.mkdir()
625
+
626
+ result = await discover(node_project, output_dir=output_dir, depth=DepthLevel.DEEP)
627
+
628
+ expected_files = [
629
+ "project-overview.md",
630
+ "technology-stack.md",
631
+ "source-tree-analysis.md",
632
+ "ai-guidance.md",
633
+ ]
634
+
635
+ for filename in expected_files:
636
+ assert (output_dir / filename).exists(), f"Missing {filename}"
637
+
638
+ @pytest.mark.asyncio
639
+ async def test_discover_handles_nonexistent_path(self, temp_project_dir: Path) -> None:
640
+ """Should handle nonexistent path gracefully."""
641
+ nonexistent = temp_project_dir / "does-not-exist"
642
+
643
+ result = await discover(nonexistent)
644
+
645
+ assert result.success is False
646
+ assert result.error is not None
647
+
648
+ @pytest.mark.asyncio
649
+ async def test_discover_handles_file_path(self, node_project: Path) -> None:
650
+ """Should handle file path (not directory) gracefully."""
651
+ file_path = node_project / "package.json"
652
+
653
+ result = await discover(file_path)
654
+
655
+ assert result.success is False
656
+ assert result.error is not None
657
+
658
+
659
+ # =============================================================================
660
+ # CLI TESTS
661
+ # =============================================================================
662
+
663
+
664
+ class TestBrownfieldCLI:
665
+ """Tests for CLI entry point."""
666
+
667
+ def test_cli_help(self) -> None:
668
+ """CLI should show help with --help."""
669
+ result = subprocess.run(
670
+ [sys.executable, "-m", "pennyfarthing_scripts.brownfield", "--help"],
671
+ capture_output=True,
672
+ text=True,
673
+ timeout=30,
674
+ )
675
+
676
+ assert result.returncode == 0
677
+ assert "usage" in result.stdout.lower() or "Usage" in result.stdout
678
+
679
+ def test_cli_scan_subcommand_help(self) -> None:
680
+ """CLI should have scan subcommand."""
681
+ result = subprocess.run(
682
+ [sys.executable, "-m", "pennyfarthing_scripts.brownfield", "scan", "--help"],
683
+ capture_output=True,
684
+ text=True,
685
+ timeout=30,
686
+ )
687
+
688
+ assert result.returncode in (0, 1, 2)
689
+
690
+ def test_cli_scan_with_path(self, node_project: Path) -> None:
691
+ """CLI scan should accept path argument."""
692
+ result = subprocess.run(
693
+ [
694
+ sys.executable, "-m", "pennyfarthing_scripts.brownfield",
695
+ "scan", str(node_project), "--depth", "quick"
696
+ ],
697
+ capture_output=True,
698
+ text=True,
699
+ timeout=60,
700
+ )
701
+
702
+ # Should complete (success or expected failure from stub)
703
+ assert result.returncode in (0, 1, 2)
704
+
705
+ def test_cli_scan_with_output(self, node_project: Path, temp_project_dir: Path) -> None:
706
+ """CLI scan should accept --output option."""
707
+ output_dir = temp_project_dir / "output"
708
+ output_dir.mkdir()
709
+
710
+ result = subprocess.run(
711
+ [
712
+ sys.executable, "-m", "pennyfarthing_scripts.brownfield",
713
+ "scan", str(node_project),
714
+ "--output", str(output_dir),
715
+ "--depth", "quick"
716
+ ],
717
+ capture_output=True,
718
+ text=True,
719
+ timeout=60,
720
+ )
721
+
722
+ assert result.returncode in (0, 1, 2)
723
+
724
+
725
+ # =============================================================================
726
+ # EDGE CASES AND ERROR HANDLING
727
+ # =============================================================================
728
+
729
+
730
+ class TestEdgeCases:
731
+ """Tests for edge cases and error handling."""
732
+
733
+ @pytest.mark.asyncio
734
+ async def test_handles_permission_error(self, temp_project_dir: Path) -> None:
735
+ """Should handle permission errors gracefully."""
736
+ # Create unreadable directory (if supported by OS)
737
+ import os
738
+ import stat
739
+
740
+ restricted = temp_project_dir / "restricted"
741
+ restricted.mkdir()
742
+
743
+ try:
744
+ os.chmod(restricted, 0o000)
745
+ result = await discover(temp_project_dir)
746
+ # Should not crash, might skip restricted dir
747
+ assert result is not None
748
+ finally:
749
+ # Restore permissions for cleanup
750
+ os.chmod(restricted, stat.S_IRWXU)
751
+
752
+ @pytest.mark.asyncio
753
+ async def test_handles_symlink_loops(self, temp_project_dir: Path) -> None:
754
+ """Should handle symlink loops."""
755
+ # Create circular symlink
756
+ link = temp_project_dir / "loop"
757
+ try:
758
+ link.symlink_to(temp_project_dir)
759
+ except OSError:
760
+ pytest.skip("Symlinks not supported")
761
+
762
+ result = await scan_directory_structure(temp_project_dir)
763
+
764
+ # Should not infinite loop
765
+ assert result is not None
766
+
767
+ @pytest.mark.asyncio
768
+ async def test_handles_very_large_directory(self, temp_project_dir: Path) -> None:
769
+ """Should handle directories with many files."""
770
+ # Create many files
771
+ for i in range(100):
772
+ (temp_project_dir / f"file_{i}.txt").write_text(f"content {i}")
773
+
774
+ result = await scan_directory_structure(temp_project_dir)
775
+
776
+ assert result is not None
777
+ assert len(result.children) >= 100
778
+
779
+ @pytest.mark.asyncio
780
+ async def test_handles_binary_files(self, temp_project_dir: Path) -> None:
781
+ """Should handle binary files without crashing."""
782
+ # Create binary file
783
+ (temp_project_dir / "binary.bin").write_bytes(b"\x00\x01\x02\x03")
784
+
785
+ result = await discover(temp_project_dir)
786
+
787
+ # Should not crash
788
+ assert result is not None
789
+
790
+ @pytest.mark.asyncio
791
+ async def test_handles_malformed_json(self, temp_project_dir: Path) -> None:
792
+ """Should handle malformed package.json."""
793
+ (temp_project_dir / "package.json").write_text("{ invalid json }")
794
+
795
+ result = await detect_tech_stack(temp_project_dir)
796
+
797
+ # Should not crash, return empty or partial results
798
+ assert isinstance(result, list)
799
+
800
+ @pytest.mark.asyncio
801
+ async def test_handles_malformed_toml(self, temp_project_dir: Path) -> None:
802
+ """Should handle malformed pyproject.toml."""
803
+ (temp_project_dir / "pyproject.toml").write_text("[invalid\ntoml")
804
+
805
+ result = await detect_tech_stack(temp_project_dir)
806
+
807
+ # Should not crash
808
+ assert isinstance(result, list)
809
+
810
+
811
+ # =============================================================================
812
+ # DEPTH LEVEL TESTS
813
+ # =============================================================================
814
+
815
+
816
+ class TestDepthLevels:
817
+ """Tests verifying depth level behavior."""
818
+
819
+ @pytest.mark.asyncio
820
+ async def test_quick_depth_fastest(self, monorepo_project: Path) -> None:
821
+ """Quick depth should be faster than standard."""
822
+ import time
823
+
824
+ start = time.time()
825
+ await discover(monorepo_project, depth=DepthLevel.QUICK)
826
+ quick_time = time.time() - start
827
+
828
+ start = time.time()
829
+ await discover(monorepo_project, depth=DepthLevel.STANDARD)
830
+ standard_time = time.time() - start
831
+
832
+ # Quick should be faster (or at least not slower)
833
+ assert quick_time <= standard_time + 1.0 # Allow 1s tolerance
834
+
835
+ @pytest.mark.asyncio
836
+ async def test_deep_depth_most_thorough(self, monorepo_project: Path) -> None:
837
+ """Deep depth should find more items than quick."""
838
+ quick_result = await discover(monorepo_project, depth=DepthLevel.QUICK)
839
+ deep_result = await discover(monorepo_project, depth=DepthLevel.DEEP)
840
+
841
+ # Deep should find at least as much as quick
842
+ assert len(deep_result.tech_stack) >= len(quick_result.tech_stack)