patchwork-conventions 0.1.0__py3-none-any.whl
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.
- patchwork/__init__.py +10 -0
- patchwork/cli.py +336 -0
- patchwork/mcp/__init__.py +1 -0
- patchwork/mcp/server.py +442 -0
- patchwork/miners/__init__.py +1 -0
- patchwork/miners/api_patterns.py +204 -0
- patchwork/miners/ast_base.py +113 -0
- patchwork/miners/config_detector.py +273 -0
- patchwork/miners/error_handling.py +207 -0
- patchwork/miners/git_patterns.py +169 -0
- patchwork/miners/imports.py +158 -0
- patchwork/miners/naming.py +277 -0
- patchwork/miners/structure.py +204 -0
- patchwork/miners/testing.py +204 -0
- patchwork/output/__init__.py +1 -0
- patchwork/output/report.py +417 -0
- patchwork/scanner.py +162 -0
- patchwork_conventions-0.1.0.dist-info/METADATA +393 -0
- patchwork_conventions-0.1.0.dist-info/RECORD +23 -0
- patchwork_conventions-0.1.0.dist-info/WHEEL +5 -0
- patchwork_conventions-0.1.0.dist-info/entry_points.txt +2 -0
- patchwork_conventions-0.1.0.dist-info/licenses/LICENSE +21 -0
- patchwork_conventions-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StructureMiner — Detects project layout conventions:
|
|
3
|
+
- Source root (src/, lib/, app/)
|
|
4
|
+
- Test layout (tests/, __tests__/, *.test.ts co-location)
|
|
5
|
+
- Feature vs layer organisation
|
|
6
|
+
- Monorepo vs single-package
|
|
7
|
+
- Key directories and their roles
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class StructureResult:
|
|
19
|
+
source_root: str | None # e.g. 'src', 'lib', None (flat)
|
|
20
|
+
test_layout: str | None # 'colocated' | 'separate' | 'both'
|
|
21
|
+
test_dirs: list[str]
|
|
22
|
+
organisation: str | None # 'feature' | 'layer' | 'flat'
|
|
23
|
+
is_monorepo: bool
|
|
24
|
+
monorepo_packages: list[str] # e.g. ['packages/api', 'packages/web']
|
|
25
|
+
key_dirs: dict[str, str] # dir → role description
|
|
26
|
+
depth_avg: float # average nesting depth
|
|
27
|
+
notes: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_KNOWN_SOURCE_ROOTS = ["src", "lib", "app", "source", "core", "pkg"]
|
|
31
|
+
_KNOWN_TEST_DIRS = ["tests", "test", "__tests__", "spec", "specs", "e2e"]
|
|
32
|
+
_LAYER_DIRS = {"controllers", "services", "models", "views", "routes",
|
|
33
|
+
"middleware", "utils", "helpers", "handlers", "repositories",
|
|
34
|
+
"components", "pages", "hooks", "stores", "api"}
|
|
35
|
+
_FEATURE_SIGNAL = 4 # if a dir has ≥4 of the above names as siblings, it's layer-based
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _top_dirs(root: Path) -> list[str]:
|
|
39
|
+
try:
|
|
40
|
+
return [
|
|
41
|
+
d.name for d in root.iterdir()
|
|
42
|
+
if d.is_dir() and not d.name.startswith(".")
|
|
43
|
+
]
|
|
44
|
+
except PermissionError:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _detect_source_root(top_dirs: list[str]) -> str | None:
|
|
49
|
+
for name in _KNOWN_SOURCE_ROOTS:
|
|
50
|
+
if name in top_dirs:
|
|
51
|
+
return name
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _detect_test_layout(files: list[tuple[Path, str]]) -> tuple[str | None, list[str]]:
|
|
56
|
+
colocated = 0
|
|
57
|
+
separate_dirs: set[str] = set()
|
|
58
|
+
|
|
59
|
+
for path, _ in files:
|
|
60
|
+
parts = path.parts
|
|
61
|
+
stem = path.stem
|
|
62
|
+
# Co-located: *.test.ts / *.spec.js / test_*.py next to source
|
|
63
|
+
if any(x in stem for x in (".test", ".spec", "test_")) or (
|
|
64
|
+
stem.startswith("test_") or stem.endswith("_test")
|
|
65
|
+
):
|
|
66
|
+
# Check if it's inside a dedicated test dir
|
|
67
|
+
if any(p in _KNOWN_TEST_DIRS for p in parts):
|
|
68
|
+
separate_dirs.add(next(p for p in parts if p in _KNOWN_TEST_DIRS))
|
|
69
|
+
else:
|
|
70
|
+
colocated += 1
|
|
71
|
+
|
|
72
|
+
layout = None
|
|
73
|
+
if colocated > 0 and separate_dirs:
|
|
74
|
+
layout = "both"
|
|
75
|
+
elif colocated > 0:
|
|
76
|
+
layout = "colocated"
|
|
77
|
+
elif separate_dirs:
|
|
78
|
+
layout = "separate"
|
|
79
|
+
|
|
80
|
+
return layout, sorted(separate_dirs)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _detect_organisation(root: Path, source_root: str | None) -> str | None:
|
|
84
|
+
"""Heuristic: if the immediate children of src/ look like layers, it's layer-based."""
|
|
85
|
+
check_root = root / source_root if source_root else root
|
|
86
|
+
try:
|
|
87
|
+
children = {d.name.lower() for d in check_root.iterdir() if d.is_dir()}
|
|
88
|
+
except (PermissionError, OSError):
|
|
89
|
+
return None
|
|
90
|
+
layer_hits = children & _LAYER_DIRS
|
|
91
|
+
if len(layer_hits) >= 3:
|
|
92
|
+
return "layer"
|
|
93
|
+
# Feature-based: children are domain names, each containing index/types
|
|
94
|
+
feature_signals = 0
|
|
95
|
+
for child in check_root.iterdir():
|
|
96
|
+
if child.is_dir():
|
|
97
|
+
sub = {d.name for d in child.iterdir() if d.is_dir() or d.is_file()}
|
|
98
|
+
if any(n in sub for n in ("index.ts", "index.js", "types.ts", "types.py")):
|
|
99
|
+
feature_signals += 1
|
|
100
|
+
if feature_signals >= 3:
|
|
101
|
+
return "feature"
|
|
102
|
+
return "flat"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _detect_monorepo(root: Path, top_dirs: list[str]) -> tuple[bool, list[str]]:
|
|
106
|
+
monorepo_roots = ["packages", "apps", "services", "libs", "modules"]
|
|
107
|
+
for mr in monorepo_roots:
|
|
108
|
+
if mr in top_dirs:
|
|
109
|
+
mr_path = root / mr
|
|
110
|
+
try:
|
|
111
|
+
pkgs = [
|
|
112
|
+
f"{mr}/{d.name}"
|
|
113
|
+
for d in mr_path.iterdir()
|
|
114
|
+
if d.is_dir() and (d / "package.json").exists()
|
|
115
|
+
or (d / "pyproject.toml").exists()
|
|
116
|
+
or (d / "go.mod").exists()
|
|
117
|
+
]
|
|
118
|
+
if len(pkgs) >= 2:
|
|
119
|
+
return True, pkgs[:10]
|
|
120
|
+
except (PermissionError, OSError):
|
|
121
|
+
pass
|
|
122
|
+
return False, []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _avg_depth(files: list[tuple[Path, str]], root: Path) -> float:
|
|
126
|
+
if not files:
|
|
127
|
+
return 0.0
|
|
128
|
+
return round(
|
|
129
|
+
sum(len(p.relative_to(root).parts) for p, _ in files) / len(files), 1
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_DIR_ROLES = {
|
|
134
|
+
"src": "source root",
|
|
135
|
+
"lib": "source root / shared libraries",
|
|
136
|
+
"app": "application source",
|
|
137
|
+
"tests": "test suite",
|
|
138
|
+
"test": "test suite",
|
|
139
|
+
"__tests__": "co-located Jest test suite",
|
|
140
|
+
"spec": "test suite (RSpec/Jasmine style)",
|
|
141
|
+
"docs": "documentation",
|
|
142
|
+
"scripts": "build / utility scripts",
|
|
143
|
+
"config": "configuration files",
|
|
144
|
+
"public": "static assets served publicly",
|
|
145
|
+
"static": "static assets",
|
|
146
|
+
"assets": "static assets",
|
|
147
|
+
"migrations": "database migrations",
|
|
148
|
+
"db": "database-related code",
|
|
149
|
+
"api": "API layer",
|
|
150
|
+
"models": "data models",
|
|
151
|
+
"views": "view layer / templates",
|
|
152
|
+
"controllers": "controller layer",
|
|
153
|
+
"services": "service layer",
|
|
154
|
+
"utils": "utility functions",
|
|
155
|
+
"helpers": "helper functions",
|
|
156
|
+
"hooks": "React / lifecycle hooks",
|
|
157
|
+
"components": "UI components",
|
|
158
|
+
"pages": "page components (Next.js / Nuxt style)",
|
|
159
|
+
"types": "TypeScript type definitions",
|
|
160
|
+
"interfaces": "TypeScript interfaces",
|
|
161
|
+
"constants": "shared constants",
|
|
162
|
+
"middleware": "middleware layer",
|
|
163
|
+
"routes": "route definitions",
|
|
164
|
+
"plugins": "plugin definitions",
|
|
165
|
+
"store": "state management store",
|
|
166
|
+
"stores": "state management stores",
|
|
167
|
+
"i18n": "internationalisation strings",
|
|
168
|
+
"locales": "locale/translation files",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class StructureMiner:
|
|
173
|
+
def __init__(self, root: Path):
|
|
174
|
+
self.root = root
|
|
175
|
+
|
|
176
|
+
def mine(self, files: list[tuple[Path, str]]) -> StructureResult:
|
|
177
|
+
top = _top_dirs(self.root)
|
|
178
|
+
source_root = _detect_source_root(top)
|
|
179
|
+
test_layout, test_dirs = _detect_test_layout(files)
|
|
180
|
+
organisation = _detect_organisation(self.root, source_root)
|
|
181
|
+
is_mono, mono_pkgs = _detect_monorepo(self.root, top)
|
|
182
|
+
depth = _avg_depth(files, self.root)
|
|
183
|
+
|
|
184
|
+
key_dirs = {
|
|
185
|
+
name: role
|
|
186
|
+
for name, role in _DIR_ROLES.items()
|
|
187
|
+
if name in top
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
notes: list[str] = []
|
|
191
|
+
if is_mono:
|
|
192
|
+
notes.append(f"Monorepo with {len(mono_pkgs)} packages")
|
|
193
|
+
|
|
194
|
+
return StructureResult(
|
|
195
|
+
source_root=source_root,
|
|
196
|
+
test_layout=test_layout,
|
|
197
|
+
test_dirs=test_dirs,
|
|
198
|
+
organisation=organisation,
|
|
199
|
+
is_monorepo=is_mono,
|
|
200
|
+
monorepo_packages=mono_pkgs,
|
|
201
|
+
key_dirs=key_dirs,
|
|
202
|
+
depth_avg=depth,
|
|
203
|
+
notes=notes,
|
|
204
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TestingMiner — Detects testing conventions:
|
|
3
|
+
- Framework (pytest / unittest / jest / vitest / go test / etc.)
|
|
4
|
+
- Test organisation (describe/it / class-based / function-based)
|
|
5
|
+
- Coverage tooling present
|
|
6
|
+
- Fixture/factory patterns
|
|
7
|
+
- Assertion style (assert vs expect vs should)
|
|
8
|
+
- Test file ratio
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TestingResult:
|
|
20
|
+
framework: str | None
|
|
21
|
+
test_file_count: int
|
|
22
|
+
source_file_count: int
|
|
23
|
+
test_ratio: float # test files / source files
|
|
24
|
+
organisation: str | None # 'describe/it' | 'class-based' | 'function-based'
|
|
25
|
+
assertion_style: str | None # 'assert' | 'expect' | 'should' | 'mixed'
|
|
26
|
+
has_coverage: bool
|
|
27
|
+
coverage_tool: str | None
|
|
28
|
+
has_fixtures: bool
|
|
29
|
+
has_factories: bool
|
|
30
|
+
has_mocking: bool
|
|
31
|
+
mock_library: str | None
|
|
32
|
+
notes: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_FRAMEWORK_SIGNALS = {
|
|
36
|
+
"python": {
|
|
37
|
+
"pytest": [r"\bimport pytest\b", r"\bfrom pytest\b", r"@pytest\.fixture", r"def test_"],
|
|
38
|
+
"unittest": [r"\bimport unittest\b", r"class\s+\w+\(unittest\.TestCase\)"],
|
|
39
|
+
"nose": [r"\bimport nose\b", r"\bfrom nose\b"],
|
|
40
|
+
},
|
|
41
|
+
"javascript": {
|
|
42
|
+
"jest": [r"\bimport.*from\s+['\"]jest['\"]", r"\bdescribe\(", r"\btest\(", r"\bit\("],
|
|
43
|
+
"vitest": [r"\bimport.*from\s+['\"]vitest['\"]", r"\bdescribe\(", r"\bit\("],
|
|
44
|
+
"mocha": [r"\bimport.*from\s+['\"]mocha['\"]", r"\bdescribe\(", r"\bit\("],
|
|
45
|
+
"jasmine": [r"\bjasmine\.", r"\bdescribe\("],
|
|
46
|
+
},
|
|
47
|
+
"typescript": {
|
|
48
|
+
"jest": [r"\bimport.*from\s+['\"]@jest/", r"\bdescribe\(", r"\bit\("],
|
|
49
|
+
"vitest": [r"\bimport.*from\s+['\"]vitest['\"]", r"\bdescribe\("],
|
|
50
|
+
"mocha": [r"\bimport.*from\s+['\"]mocha['\"]"],
|
|
51
|
+
},
|
|
52
|
+
"go": {
|
|
53
|
+
"testing": [r"\bimport\s+\"testing\"", r"\bfunc\s+Test\w+\(t\s+\*testing\.T\)"],
|
|
54
|
+
"testify": [r"\bimport.*testify"],
|
|
55
|
+
},
|
|
56
|
+
"rust": {
|
|
57
|
+
"built-in": [r"#\[cfg\(test\)\]", r"#\[test\]", r"\bmod\s+tests\s*\{"],
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_COVERAGE = {
|
|
62
|
+
"python": ["coverage", "pytest-cov", "coveragepy"],
|
|
63
|
+
"javascript": ["c8", "istanbul", "nyc", "jest --coverage"],
|
|
64
|
+
"typescript": ["c8", "istanbul", "nyc"],
|
|
65
|
+
"go": ["go test -cover"],
|
|
66
|
+
"rust": ["cargo-tarpaulin", "cargo-llvm-cov"],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_MOCK_LIBRARIES = {
|
|
70
|
+
"python": ["unittest.mock", "pytest-mock", "mock", "MagicMock", "patch"],
|
|
71
|
+
"javascript": ["jest.fn", "sinon", "jest.mock"],
|
|
72
|
+
"typescript": ["jest.fn", "ts-mockito", "jest.mock"],
|
|
73
|
+
"go": ["testify/mock", "gomock"],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_RE_DESCRIBE = re.compile(r'\bdescribe\s*\(')
|
|
77
|
+
_RE_IT = re.compile(r'\bit\s*\(')
|
|
78
|
+
_RE_EXPECT = re.compile(r'\bexpect\s*\(')
|
|
79
|
+
_RE_ASSERT = re.compile(r'\bassert\s')
|
|
80
|
+
_RE_SHOULD = re.compile(r'\.should\.')
|
|
81
|
+
_RE_FIXTURE = re.compile(r'@pytest\.fixture|@fixture|class\s+\w+Factory')
|
|
82
|
+
_RE_FACTORY = re.compile(r'Factory\b|factory_boy|FactoryBot|factory\.create')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_test_file(path: Path) -> bool:
|
|
86
|
+
stem = path.stem
|
|
87
|
+
return (
|
|
88
|
+
stem.startswith("test_") or stem.endswith("_test")
|
|
89
|
+
or ".test." in path.name or ".spec." in path.name
|
|
90
|
+
or stem.startswith("Test") or path.parent.name in ("tests", "test", "__tests__", "spec")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestingMiner:
|
|
95
|
+
def __init__(self, root: Path):
|
|
96
|
+
self.root = root
|
|
97
|
+
|
|
98
|
+
def mine(self, by_lang: dict[str, list[Path]]) -> dict[str, TestingResult]:
|
|
99
|
+
results: dict[str, TestingResult] = {}
|
|
100
|
+
for lang, paths in by_lang.items():
|
|
101
|
+
results[lang] = self._mine_lang(lang, paths)
|
|
102
|
+
return results
|
|
103
|
+
|
|
104
|
+
def _mine_lang(self, lang: str, paths: list[Path]) -> TestingResult:
|
|
105
|
+
test_files = [p for p in paths if _is_test_file(p)]
|
|
106
|
+
src_files = [p for p in paths if not _is_test_file(p)]
|
|
107
|
+
ratio = len(test_files) / len(src_files) if src_files else 0.0
|
|
108
|
+
|
|
109
|
+
framework_counts: Counter[str] = Counter()
|
|
110
|
+
describe_count = 0
|
|
111
|
+
expect_count = 0
|
|
112
|
+
assert_count = 0
|
|
113
|
+
should_count = 0
|
|
114
|
+
has_fixtures = False
|
|
115
|
+
has_factories = False
|
|
116
|
+
has_mocking = False
|
|
117
|
+
mock_libs: Counter[str] = Counter()
|
|
118
|
+
has_coverage = False
|
|
119
|
+
coverage_tool: str | None = None
|
|
120
|
+
|
|
121
|
+
for path in (test_files + src_files)[:200]:
|
|
122
|
+
try:
|
|
123
|
+
text = path.read_text(errors="replace")
|
|
124
|
+
except OSError:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Framework detection
|
|
128
|
+
for fw, patterns in _FRAMEWORK_SIGNALS.get(lang, {}).items():
|
|
129
|
+
for pat in patterns:
|
|
130
|
+
if re.search(pat, text):
|
|
131
|
+
framework_counts[fw] += 1
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
describe_count += len(_RE_DESCRIBE.findall(text))
|
|
135
|
+
expect_count += len(_RE_EXPECT.findall(text))
|
|
136
|
+
assert_count += len(_RE_ASSERT.findall(text))
|
|
137
|
+
should_count += len(_RE_SHOULD.findall(text))
|
|
138
|
+
|
|
139
|
+
if _RE_FIXTURE.search(text):
|
|
140
|
+
has_fixtures = True
|
|
141
|
+
if _RE_FACTORY.search(text):
|
|
142
|
+
has_factories = True
|
|
143
|
+
|
|
144
|
+
for ml in _MOCK_LIBRARIES.get(lang, []):
|
|
145
|
+
if ml in text:
|
|
146
|
+
has_mocking = True
|
|
147
|
+
mock_libs[ml] += 1
|
|
148
|
+
|
|
149
|
+
# Coverage detection from config files
|
|
150
|
+
for cov in _COVERAGE.get(lang, []):
|
|
151
|
+
cfg_files = [
|
|
152
|
+
self.root / "pyproject.toml",
|
|
153
|
+
self.root / "setup.cfg",
|
|
154
|
+
self.root / "package.json",
|
|
155
|
+
self.root / "jest.config.js",
|
|
156
|
+
self.root / "jest.config.ts",
|
|
157
|
+
self.root / ".nycrc",
|
|
158
|
+
]
|
|
159
|
+
for cfg in cfg_files:
|
|
160
|
+
if cfg.exists():
|
|
161
|
+
try:
|
|
162
|
+
if cov.split()[0] in cfg.read_text():
|
|
163
|
+
has_coverage = True
|
|
164
|
+
coverage_tool = cov.split()[0]
|
|
165
|
+
break
|
|
166
|
+
except OSError:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
framework = framework_counts.most_common(1)[0][0] if framework_counts else None
|
|
170
|
+
|
|
171
|
+
org = None
|
|
172
|
+
if describe_count > 3:
|
|
173
|
+
org = "describe/it"
|
|
174
|
+
elif lang == "python" and "unittest" in (framework or ""):
|
|
175
|
+
org = "class-based"
|
|
176
|
+
elif lang in ("python", "go", "rust"):
|
|
177
|
+
org = "function-based"
|
|
178
|
+
|
|
179
|
+
assertion_style = None
|
|
180
|
+
total_assert = expect_count + assert_count + should_count
|
|
181
|
+
if total_assert > 0:
|
|
182
|
+
if expect_count >= max(assert_count, should_count):
|
|
183
|
+
assertion_style = "expect"
|
|
184
|
+
elif assert_count >= max(expect_count, should_count):
|
|
185
|
+
assertion_style = "assert"
|
|
186
|
+
else:
|
|
187
|
+
assertion_style = "should"
|
|
188
|
+
|
|
189
|
+
mock_lib = mock_libs.most_common(1)[0][0] if mock_libs else None
|
|
190
|
+
|
|
191
|
+
return TestingResult(
|
|
192
|
+
framework=framework,
|
|
193
|
+
test_file_count=len(test_files),
|
|
194
|
+
source_file_count=len(src_files),
|
|
195
|
+
test_ratio=round(ratio, 2),
|
|
196
|
+
organisation=org,
|
|
197
|
+
assertion_style=assertion_style,
|
|
198
|
+
has_coverage=has_coverage,
|
|
199
|
+
coverage_tool=coverage_tool,
|
|
200
|
+
has_fixtures=has_fixtures,
|
|
201
|
+
has_factories=has_factories,
|
|
202
|
+
has_mocking=has_mocking,
|
|
203
|
+
mock_library=mock_lib,
|
|
204
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# output package
|