@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.
- package/README.md +1 -1
- package/package.json +2 -1
- package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/init.js +7 -6
- package/packages/core/dist/cli/commands/init.js.map +1 -1
- package/packages/core/dist/cli/utils/symlinks.d.ts +7 -0
- package/packages/core/dist/cli/utils/symlinks.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/symlinks.js +25 -0
- package/packages/core/dist/cli/utils/symlinks.js.map +1 -1
- package/pennyfarthing-dist/scripts/core/phase-check-start.sh +1 -1
- package/pennyfarthing-dist/scripts/core/prime.sh +23 -0
- package/pennyfarthing-dist/scripts/core/run.sh +5 -5
- package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +2 -2
- package/pennyfarthing-dist/scripts/git/release.sh +2 -2
- package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -1
- package/pennyfarthing-dist/scripts/hooks/post-merge.sh +2 -2
- package/pennyfarthing-dist/scripts/hooks/pre-push.sh +2 -2
- package/pennyfarthing-dist/scripts/hooks/session-stop.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +1 -1
- package/pennyfarthing-dist/scripts/lib/common.sh +1 -1
- package/pennyfarthing-dist/scripts/lib/find-root.sh +4 -4
- package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -1
- package/pennyfarthing-dist/scripts/misc/add_short_names.py +2 -2
- package/pennyfarthing-dist/scripts/misc/backlog.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/deploy.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +4 -4
- package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/run-ci.sh +1 -1
- package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +2 -2
- package/pennyfarthing-dist/scripts/sprint/archive-story.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/available-stories.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/check-story.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +2 -2
- package/pennyfarthing-dist/scripts/sprint/list-future.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +3 -3
- package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +2 -2
- package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/check.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/finish-story.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +1 -1
- package/pennyfarthing_scripts/__init__.py +17 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bellmode_hook.py +154 -0
- package/pennyfarthing_scripts/brownfield/__init__.py +35 -0
- package/pennyfarthing_scripts/brownfield/__main__.py +7 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/cli.py +131 -0
- package/pennyfarthing_scripts/brownfield/discover.py +753 -0
- package/pennyfarthing_scripts/common/__init__.py +49 -0
- package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/config.py +65 -0
- package/pennyfarthing_scripts/common/output.py +180 -0
- package/pennyfarthing_scripts/config.py +21 -0
- package/pennyfarthing_scripts/git/__init__.py +29 -0
- package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/create_branches.py +439 -0
- package/pennyfarthing_scripts/git/status_all.py +310 -0
- package/pennyfarthing_scripts/hooks.py +455 -0
- package/pennyfarthing_scripts/jira/__init__.py +93 -0
- package/pennyfarthing_scripts/jira/__main__.py +10 -0
- package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/bidirectional.py +561 -0
- package/pennyfarthing_scripts/jira/claim.py +211 -0
- package/pennyfarthing_scripts/jira/cli.py +150 -0
- package/pennyfarthing_scripts/jira/client.py +613 -0
- package/pennyfarthing_scripts/jira/epic.py +176 -0
- package/pennyfarthing_scripts/jira/story.py +219 -0
- package/pennyfarthing_scripts/jira/sync.py +350 -0
- package/pennyfarthing_scripts/jira_bidirectional_sync.py +37 -0
- package/pennyfarthing_scripts/jira_epic_creation.py +30 -0
- package/pennyfarthing_scripts/jira_sync.py +36 -0
- package/pennyfarthing_scripts/jira_sync_story.py +30 -0
- package/pennyfarthing_scripts/output.py +37 -0
- package/pennyfarthing_scripts/preflight/__init__.py +17 -0
- package/pennyfarthing_scripts/preflight/__main__.py +10 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/cli.py +141 -0
- package/pennyfarthing_scripts/preflight/finish.py +382 -0
- package/pennyfarthing_scripts/pretooluse_hook.py +142 -0
- package/pennyfarthing_scripts/prime/__init__.py +38 -0
- package/pennyfarthing_scripts/prime/__main__.py +8 -0
- package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/cli.py +220 -0
- package/pennyfarthing_scripts/prime/loader.py +239 -0
- package/pennyfarthing_scripts/sprint/__init__.py +66 -0
- package/pennyfarthing_scripts/sprint/__main__.py +10 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/archive.py +108 -0
- package/pennyfarthing_scripts/sprint/cli.py +124 -0
- package/pennyfarthing_scripts/sprint/loader.py +193 -0
- package/pennyfarthing_scripts/sprint/status.py +122 -0
- package/pennyfarthing_scripts/sprint/validator.py +405 -0
- package/pennyfarthing_scripts/sprint/work.py +192 -0
- package/pennyfarthing_scripts/story/__init__.py +67 -0
- package/pennyfarthing_scripts/story/__main__.py +10 -0
- package/pennyfarthing_scripts/story/cli.py +105 -0
- package/pennyfarthing_scripts/story/create.py +167 -0
- package/pennyfarthing_scripts/story/size.py +113 -0
- package/pennyfarthing_scripts/story/template.py +151 -0
- package/pennyfarthing_scripts/swebench.py +216 -0
- package/pennyfarthing_scripts/tests/__init__.py +1 -0
- package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/conftest.py +106 -0
- package/pennyfarthing_scripts/tests/test_brownfield.py +842 -0
- package/pennyfarthing_scripts/tests/test_cli_modules.py +245 -0
- package/pennyfarthing_scripts/tests/test_common.py +180 -0
- package/pennyfarthing_scripts/tests/test_git_utils.py +866 -0
- package/pennyfarthing_scripts/tests/test_jira_package.py +334 -0
- package/pennyfarthing_scripts/tests/test_package_structure.py +372 -0
- package/pennyfarthing_scripts/tests/test_prime.py +397 -0
- package/pennyfarthing_scripts/tests/test_sprint_package.py +236 -0
- package/pennyfarthing_scripts/tests/test_sprint_validator.py +675 -0
- package/pennyfarthing_scripts/tests/test_story_package.py +156 -0
- package/pennyfarthing_scripts/welcome_hook.py +157 -0
- 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)
|