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.
@@ -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