testless 0.1.5__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.
testless/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """testless — analyze test value, coverage, duplication, and generate LLM planfiles."""
2
+
3
+ __version__ = "0.1.5"
File without changes
@@ -0,0 +1,44 @@
1
+ """Detect tests that contribute no unique coverage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from testless.models.coverage_map import CoverageMap
6
+ from testless.models.findings import DeadTestFinding, TestMeta
7
+
8
+
9
+ def find_dead_tests(
10
+ tests: list[TestMeta],
11
+ cov_map: CoverageMap,
12
+ min_unique_lines: int = 1,
13
+ ) -> list[DeadTestFinding]:
14
+ """
15
+ Return tests whose unique coverage falls below *min_unique_lines*.
16
+
17
+ A test is considered "dead" if every line it touches is already covered
18
+ by at least one other test.
19
+ """
20
+ all_node_ids = [t.node_id for t in tests]
21
+ findings: list[DeadTestFinding] = []
22
+
23
+ for test in tests:
24
+ total_lines = 0
25
+ unique_lines = 0
26
+
27
+ for fc in cov_map.files.values():
28
+ covered = fc.lines_covered_by(test.node_id)
29
+ unique = fc.unique_lines_for(test.node_id, all_node_ids)
30
+ total_lines += len(covered)
31
+ unique_lines += len(unique)
32
+
33
+ if total_lines > 0 and unique_lines < min_unique_lines:
34
+ findings.append(
35
+ DeadTestFinding(
36
+ node_id=test.node_id,
37
+ file=test.file,
38
+ unique_lines=unique_lines,
39
+ total_lines=total_lines,
40
+ reason="no unique coverage",
41
+ )
42
+ )
43
+
44
+ return findings
@@ -0,0 +1,102 @@
1
+ """Detect duplicate tests using coverage overlap, AST similarity, fixtures and names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import difflib
7
+ from pathlib import Path
8
+
9
+ from testless.models.coverage_map import CoverageMap
10
+ from testless.models.findings import DuplicateFinding, TestMeta
11
+ from testless.collect.fixture_index import FixtureIndex
12
+
13
+
14
+ def _ast_similarity(file_a: str, name_a: str, file_b: str, name_b: str) -> float:
15
+ """Compute normalized edit-distance similarity between two test function bodies."""
16
+
17
+ def _get_source(file: str, name: str) -> str:
18
+ path = Path(file)
19
+ if not path.exists():
20
+ return ""
21
+ try:
22
+ source = path.read_text(encoding="utf-8")
23
+ tree = ast.parse(source)
24
+ except (SyntaxError, UnicodeDecodeError):
25
+ return ""
26
+ for node in ast.walk(tree):
27
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.name == name:
28
+ return ast.unparse(node)
29
+ return ""
30
+
31
+ src_a = _get_source(file_a, name_a)
32
+ src_b = _get_source(file_b, name_b)
33
+ if not src_a or not src_b:
34
+ return 0.0
35
+ return difflib.SequenceMatcher(None, src_a, src_b).ratio()
36
+
37
+
38
+ def _name_similarity(name_a: str, name_b: str) -> float:
39
+ """Normalized edit-distance similarity between two test names."""
40
+ return difflib.SequenceMatcher(None, name_a, name_b).ratio()
41
+
42
+
43
+ def _duplicate_score(
44
+ cov_overlap: float,
45
+ ast_sim: float,
46
+ fix_overlap: float,
47
+ name_sim: float,
48
+ ) -> float:
49
+ return (
50
+ 0.45 * cov_overlap
51
+ + 0.25 * ast_sim
52
+ + 0.15 * fix_overlap
53
+ + 0.15 * name_sim
54
+ )
55
+
56
+
57
+ def find_duplicates(
58
+ tests: list[TestMeta],
59
+ cov_map: CoverageMap,
60
+ fixture_index: FixtureIndex,
61
+ min_score: float = 0.85,
62
+ ) -> list[DuplicateFinding]:
63
+ """Return pairs of tests that are likely duplicates."""
64
+ findings: list[DuplicateFinding] = []
65
+ seen: set[frozenset[str]] = set()
66
+
67
+ for i, test_a in enumerate(tests):
68
+ for test_b in tests[i + 1 :]:
69
+ pair = frozenset({test_a.node_id, test_b.node_id})
70
+ if pair in seen:
71
+ continue
72
+ seen.add(pair)
73
+
74
+ cov_overlap = cov_map.overlap(test_a.node_id, test_b.node_id)
75
+ fix_overlap = fixture_index.fixture_overlap(test_a.node_id, test_b.node_id)
76
+
77
+ # Derive file and function name from node_id
78
+ parts_a = test_a.node_id.split("::")
79
+ parts_b = test_b.node_id.split("::")
80
+ file_a = parts_a[0] if parts_a else test_a.file
81
+ file_b = parts_b[0] if parts_b else test_b.file
82
+ name_a = parts_a[-1] if parts_a else test_a.name
83
+ name_b = parts_b[-1] if parts_b else test_b.name
84
+
85
+ ast_sim = _ast_similarity(file_a, name_a, file_b, name_b)
86
+ name_sim = _name_similarity(name_a, name_b)
87
+
88
+ score = _duplicate_score(cov_overlap, ast_sim, fix_overlap, name_sim)
89
+ if score >= min_score:
90
+ findings.append(
91
+ DuplicateFinding(
92
+ test_a=test_a.node_id,
93
+ test_b=test_b.node_id,
94
+ score=round(score, 3),
95
+ coverage_overlap=round(cov_overlap, 3),
96
+ ast_similarity=round(ast_sim, 3),
97
+ fixture_overlap=round(fix_overlap, 3),
98
+ name_similarity=round(name_sim, 3),
99
+ )
100
+ )
101
+
102
+ return sorted(findings, key=lambda f: f.score, reverse=True)
@@ -0,0 +1,98 @@
1
+ """Suggest missing tests based on endpoints, services, and modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from testless.collect.endpoint_inventory import EndpointInfo, ServiceInfo
6
+ from testless.models.findings import MissingTestFinding, TestMeta
7
+
8
+
9
+ # Endpoint paths that should always have smoke test coverage
10
+ _CRITICAL_PATHS = {"/health", "/login", "/logout", "/search", "/checkout", "/billing", "/register"}
11
+
12
+
13
+ def _has_smoke_test(endpoint: EndpointInfo, tests: list[TestMeta]) -> bool:
14
+ """Heuristic: check if any test name references the endpoint path."""
15
+ slug = endpoint.path.strip("/").replace("/", "_").replace("-", "_")
16
+ for test in tests:
17
+ if slug.lower() in test.name.lower() or slug.lower() in test.file.lower():
18
+ return True
19
+ return False
20
+
21
+
22
+ def _has_service_test(service: ServiceInfo, tests: list[TestMeta]) -> bool:
23
+ """Heuristic: check if any test imports or references the service module."""
24
+ module_slug = service.module.split(".")[-1]
25
+ for test in tests:
26
+ if module_slug.lower() in test.name.lower() or module_slug.lower() in test.file.lower():
27
+ return True
28
+ return False
29
+
30
+
31
+ def find_missing_tests(
32
+ tests: list[TestMeta],
33
+ endpoints: list[EndpointInfo],
34
+ services: list[ServiceInfo],
35
+ ) -> list[MissingTestFinding]:
36
+ """Return suggested missing tests based on inventory."""
37
+ findings: list[MissingTestFinding] = []
38
+
39
+ for endpoint in endpoints:
40
+ if not _has_smoke_test(endpoint, tests):
41
+ priority = "high" if endpoint.path in _CRITICAL_PATHS else "medium"
42
+ findings.append(
43
+ MissingTestFinding(
44
+ target=f"{endpoint.method} {endpoint.path}",
45
+ test_type="smoke",
46
+ description=(
47
+ f"No smoke test found for {endpoint.method} {endpoint.path} "
48
+ f"(handler: {endpoint.handler}). "
49
+ "Add tests for 2xx, 4xx, and 5xx responses."
50
+ ),
51
+ priority=priority,
52
+ suggested_file=f"tests/smoke/test_{endpoint.handler}.py",
53
+ )
54
+ )
55
+
56
+ for service in services:
57
+ if not _has_service_test(service, tests):
58
+ if service.has_sql:
59
+ findings.append(
60
+ MissingTestFinding(
61
+ target=service.module,
62
+ test_type="testql",
63
+ description=(
64
+ f"Service {service.module} uses SQL but has no detected tests. "
65
+ "Consider adding TestQL contract tests for critical queries."
66
+ ),
67
+ priority="high",
68
+ suggested_file=f"tests/service/test_{service.module.split('.')[-1]}.py",
69
+ )
70
+ )
71
+ if service.has_http_client:
72
+ findings.append(
73
+ MissingTestFinding(
74
+ target=service.module,
75
+ test_type="contract",
76
+ description=(
77
+ f"Service {service.module} makes HTTP calls but has no contract tests. "
78
+ "Add service contract tests for timeout and dependency failure scenarios."
79
+ ),
80
+ priority="medium",
81
+ suggested_file=f"tests/service/test_{service.module.split('.')[-1]}_contract.py",
82
+ )
83
+ )
84
+ if service.has_retry:
85
+ findings.append(
86
+ MissingTestFinding(
87
+ target=service.module,
88
+ test_type="resilience",
89
+ description=(
90
+ f"Service {service.module} uses retry/backoff but has no resilience tests. "
91
+ "Add tests for circuit-breaker and retry-exhaustion scenarios."
92
+ ),
93
+ priority="medium",
94
+ suggested_file=f"tests/service/test_{service.module.split('.')[-1]}_resilience.py",
95
+ )
96
+ )
97
+
98
+ return findings
@@ -0,0 +1,87 @@
1
+ """Identify tests that are good candidates for refactoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+
8
+ from testless.models.findings import RefactorFinding, TestMeta
9
+
10
+
11
+ _MAX_ASSERTIONS = 10
12
+ _MAX_DURATION_S = 5.0
13
+ _MAX_LINES = 80
14
+
15
+
16
+ def _count_assertions(file: str, func_name: str) -> int:
17
+ path = Path(file)
18
+ if not path.exists():
19
+ return 0
20
+ try:
21
+ source = path.read_text(encoding="utf-8")
22
+ tree = ast.parse(source)
23
+ except (SyntaxError, UnicodeDecodeError):
24
+ return 0
25
+ for node in ast.walk(tree):
26
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.name == func_name:
27
+ return sum(
28
+ 1
29
+ for child in ast.walk(node)
30
+ if isinstance(child, ast.Assert)
31
+ or (
32
+ isinstance(child, ast.Call)
33
+ and isinstance(child.func, ast.Attribute)
34
+ and child.func.attr.startswith("assert")
35
+ )
36
+ )
37
+ return 0
38
+
39
+
40
+ def _count_lines(file: str, func_name: str) -> int:
41
+ path = Path(file)
42
+ if not path.exists():
43
+ return 0
44
+ try:
45
+ source = path.read_text(encoding="utf-8")
46
+ tree = ast.parse(source)
47
+ except (SyntaxError, UnicodeDecodeError):
48
+ return 0
49
+ for node in ast.walk(tree):
50
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.name == func_name:
51
+ end = getattr(node, "end_lineno", node.lineno)
52
+ return end - node.lineno + 1
53
+ return 0
54
+
55
+
56
+ def find_refactor_candidates(tests: list[TestMeta]) -> list[RefactorFinding]:
57
+ """Return tests that warrant refactoring."""
58
+ findings: list[RefactorFinding] = []
59
+
60
+ for test in tests:
61
+ parts = test.node_id.split("::")
62
+ file = parts[0] if parts else test.file
63
+ func_name = parts[-1] if len(parts) > 1 else test.name
64
+
65
+ reasons: list[str] = []
66
+
67
+ if test.duration > _MAX_DURATION_S:
68
+ reasons.append(f"slow test ({test.duration:.1f}s > {_MAX_DURATION_S}s threshold)")
69
+
70
+ assertions = _count_assertions(file, func_name)
71
+ if assertions > _MAX_ASSERTIONS:
72
+ reasons.append(f"too many assertions ({assertions} > {_MAX_ASSERTIONS})")
73
+
74
+ lines = _count_lines(file, func_name)
75
+ if lines > _MAX_LINES:
76
+ reasons.append(f"test function too long ({lines} lines > {_MAX_LINES})")
77
+
78
+ if reasons:
79
+ findings.append(
80
+ RefactorFinding(
81
+ node_id=test.node_id,
82
+ file=file,
83
+ reason="; ".join(reasons),
84
+ )
85
+ )
86
+
87
+ return findings
@@ -0,0 +1,41 @@
1
+ """Assess service risk based on missing tests and critical characteristics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from testless.collect.endpoint_inventory import ServiceInfo
6
+ from testless.models.findings import MissingTestFinding
7
+
8
+
9
+ def score_service_risk(
10
+ service: ServiceInfo,
11
+ missing: list[MissingTestFinding],
12
+ ) -> dict[str, object]:
13
+ """
14
+ Return a risk assessment dict for a service.
15
+
16
+ Higher score = higher risk of undetected failures.
17
+ """
18
+ risk_score = 0.0
19
+ reasons: list[str] = []
20
+
21
+ # Missing tests for this service
22
+ service_missing = [m for m in missing if m.target == service.module]
23
+ if service_missing:
24
+ risk_score += 0.4 * len(service_missing)
25
+ reasons.append(f"{len(service_missing)} missing test type(s)")
26
+
27
+ if service.has_sql:
28
+ risk_score += 0.3
29
+ reasons.append("uses SQL without verified query tests")
30
+ if service.has_http_client:
31
+ risk_score += 0.2
32
+ reasons.append("makes external HTTP calls")
33
+ if service.has_retry:
34
+ risk_score += 0.1
35
+ reasons.append("uses retry/backoff logic")
36
+
37
+ return {
38
+ "module": service.module,
39
+ "risk_score": round(min(risk_score, 1.0), 2),
40
+ "reasons": reasons,
41
+ }
testless/cli.py ADDED
@@ -0,0 +1,306 @@
1
+ """CLI entry-point for testless."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from testless.config import load_config
11
+
12
+
13
+ @click.group()
14
+ @click.option("--config", "-c", default=None, help="Path to .testless.yml config file.")
15
+ @click.pass_context
16
+ def main(ctx: click.Context, config: str | None) -> None:
17
+ """testless — analyze test value, coverage, duplication, and generate LLM planfiles."""
18
+ ctx.ensure_object(dict)
19
+ ctx.obj["config"] = load_config(config)
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # pretest scan
24
+ # ---------------------------------------------------------------------------
25
+
26
+ @main.command()
27
+ @click.option("--out", default=None, help="Override coverage output directory.")
28
+ @click.pass_context
29
+ def scan(ctx: click.Context, out: str | None) -> None:
30
+ """Run pytest with coverage contexts and collect test metadata."""
31
+ from testless.collect.pytest_runner import run_pytest
32
+
33
+ cfg = ctx.obj["config"]
34
+ coverage_dir = out or cfg.coverage_dir
35
+
36
+ click.echo(f"Running pytest with coverage contexts → {coverage_dir}")
37
+ tests, cov_json = run_pytest(
38
+ packages=cfg.packages,
39
+ test_dirs=cfg.test_dirs,
40
+ coverage_dir=coverage_dir,
41
+ extra_args=cfg.pytest_args,
42
+ )
43
+ click.echo(f"Collected {len(tests)} tests. Coverage JSON: {cov_json}")
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # pretest duplicates
48
+ # ---------------------------------------------------------------------------
49
+
50
+ @main.command()
51
+ @click.option("--min-overlap", default=None, type=float, help="Minimum duplicate score (0–1).")
52
+ @click.option("--coverage-json", default=None, help="Path to coverage.json (default: auto-detect).")
53
+ @click.pass_context
54
+ def duplicates(ctx: click.Context, min_overlap: float | None, coverage_json: str | None) -> None:
55
+ """Detect duplicate tests based on coverage overlap, AST similarity, and fixture use."""
56
+ from testless.collect.coverage_loader import load_coverage_json
57
+ from testless.collect.fixture_index import FixtureIndex
58
+ from testless.analyze.duplicate_tests import find_duplicates
59
+ from testless.reporters.console import print_report
60
+ from testless.models.findings import AnalysisReport, TestMeta
61
+
62
+ cfg = ctx.obj["config"]
63
+ min_score = min_overlap if min_overlap is not None else cfg.min_duplicate_score
64
+
65
+ # Load coverage
66
+ cov_path = coverage_json or str(Path(cfg.coverage_dir) / "coverage.json")
67
+ cov_map = load_coverage_json(cov_path)
68
+
69
+ # Build minimal test list from coverage map contexts
70
+ all_node_ids: set[str] = set()
71
+ for fc in cov_map.files.values():
72
+ for tests_list in fc.line_to_tests.values():
73
+ all_node_ids.update(tests_list)
74
+
75
+ tests = [
76
+ TestMeta(
77
+ node_id=nid,
78
+ file=nid.split("::")[0],
79
+ name=nid.split("::")[-1],
80
+ )
81
+ for nid in sorted(all_node_ids)
82
+ ]
83
+
84
+ fixture_index = FixtureIndex()
85
+ for td in cfg.test_dirs:
86
+ if Path(td).exists():
87
+ fixture_index.scan_directory(td)
88
+
89
+ dups = find_duplicates(tests, cov_map, fixture_index, min_score=min_score)
90
+
91
+ report = AnalysisReport(duplicates=dups, tests=tests)
92
+ print_report(report)
93
+
94
+ if dups:
95
+ sys.exit(1)
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # pretest missing
100
+ # ---------------------------------------------------------------------------
101
+
102
+ @main.command()
103
+ @click.option(
104
+ "--services",
105
+ "-s",
106
+ multiple=True,
107
+ help="Source directories to scan for endpoints/services.",
108
+ )
109
+ @click.pass_context
110
+ def missing(ctx: click.Context, services: tuple[str, ...]) -> None:
111
+ """Suggest missing smoke, e2e, contract, and TestQL tests."""
112
+ from testless.collect.endpoint_inventory import EndpointInventory
113
+ from testless.analyze.missing_tests import find_missing_tests
114
+ from testless.reporters.console import print_report
115
+ from testless.models.findings import AnalysisReport
116
+
117
+ cfg = ctx.obj["config"]
118
+ dirs = list(services) if services else cfg.packages or ["."]
119
+
120
+ inventory = EndpointInventory()
121
+ for d in dirs:
122
+ if Path(d).exists():
123
+ inventory.scan_directory(d)
124
+ else:
125
+ click.echo(f"Warning: directory not found: {d}", err=True)
126
+
127
+ missing_findings = find_missing_tests([], inventory.endpoints, inventory.services)
128
+ report = AnalysisReport(missing_tests=missing_findings)
129
+ print_report(report)
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # pretest planfiles
134
+ # ---------------------------------------------------------------------------
135
+
136
+ @main.command()
137
+ @click.option("--out", default=None, help="Output directory for planfile YAML files.")
138
+ @click.option("--coverage-json", default=None, help="Path to coverage.json.")
139
+ @click.option(
140
+ "--services",
141
+ "-s",
142
+ multiple=True,
143
+ help="Source directories to scan for endpoints/services.",
144
+ )
145
+ @click.option("--with-prompts", is_flag=True, default=False, help="Embed LLM system prompts.")
146
+ @click.pass_context
147
+ def planfiles(
148
+ ctx: click.Context,
149
+ out: str | None,
150
+ coverage_json: str | None,
151
+ services: tuple[str, ...],
152
+ with_prompts: bool,
153
+ ) -> None:
154
+ """Generate planfile YAML tickets for LLM from all findings."""
155
+ from testless.collect.coverage_loader import load_coverage_json
156
+ from testless.collect.fixture_index import FixtureIndex
157
+ from testless.collect.endpoint_inventory import EndpointInventory
158
+ from testless.analyze.duplicate_tests import find_duplicates
159
+ from testless.analyze.dead_tests import find_dead_tests
160
+ from testless.analyze.missing_tests import find_missing_tests
161
+ from testless.analyze.refactor_candidates import find_refactor_candidates
162
+ from testless.tickets.builder import build_planfiles
163
+ from testless.tickets.serializer import write_planfiles, write_summary_json
164
+ from testless.tickets.prompts import attach_prompt
165
+ from testless.models.findings import AnalysisReport, TestMeta
166
+
167
+ cfg = ctx.obj["config"]
168
+ output_dir = out or cfg.planfiles_dir
169
+
170
+ # Load coverage
171
+ cov_path = coverage_json or str(Path(cfg.coverage_dir) / "coverage.json")
172
+ cov_map = load_coverage_json(cov_path)
173
+
174
+ all_node_ids: set[str] = set()
175
+ for fc in cov_map.files.values():
176
+ for tests_list in fc.line_to_tests.values():
177
+ all_node_ids.update(tests_list)
178
+
179
+ tests = [
180
+ TestMeta(
181
+ node_id=nid,
182
+ file=nid.split("::")[0],
183
+ name=nid.split("::")[-1],
184
+ )
185
+ for nid in sorted(all_node_ids)
186
+ ]
187
+
188
+ fixture_index = FixtureIndex()
189
+ for td in cfg.test_dirs:
190
+ if Path(td).exists():
191
+ fixture_index.scan_directory(td)
192
+
193
+ dirs = list(services) if services else cfg.packages or ["."]
194
+ inventory = EndpointInventory()
195
+ for d in dirs:
196
+ if Path(d).exists():
197
+ inventory.scan_directory(d)
198
+
199
+ report = AnalysisReport(
200
+ duplicates=find_duplicates(tests, cov_map, fixture_index, min_score=cfg.min_duplicate_score),
201
+ dead_tests=find_dead_tests(tests, cov_map),
202
+ missing_tests=find_missing_tests(tests, inventory.endpoints, inventory.services),
203
+ refactor_candidates=find_refactor_candidates(tests),
204
+ tests=tests,
205
+ )
206
+
207
+ tickets = build_planfiles(report)
208
+ if with_prompts:
209
+ tickets = [attach_prompt(t) for t in tickets]
210
+
211
+ written = write_planfiles(tickets, output_dir)
212
+ write_summary_json(tickets, output_dir)
213
+
214
+ click.echo(f"Generated {len(written)} planfile(s) in {output_dir}/")
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # pretest doctor
219
+ # ---------------------------------------------------------------------------
220
+
221
+ @main.command()
222
+ @click.argument("question", required=False)
223
+ @click.option("--coverage-json", default=None, help="Path to coverage.json.")
224
+ @click.option(
225
+ "--services",
226
+ "-s",
227
+ multiple=True,
228
+ help="Source directories to scan for endpoints/services.",
229
+ )
230
+ @click.pass_context
231
+ def doctor(
232
+ ctx: click.Context,
233
+ question: str | None,
234
+ coverage_json: str | None,
235
+ services: tuple[str, ...],
236
+ ) -> None:
237
+ """Answer questions about test health (what to remove, add, or fix)."""
238
+ from testless.collect.coverage_loader import load_coverage_json
239
+ from testless.collect.fixture_index import FixtureIndex
240
+ from testless.collect.endpoint_inventory import EndpointInventory
241
+ from testless.analyze.duplicate_tests import find_duplicates
242
+ from testless.analyze.dead_tests import find_dead_tests
243
+ from testless.analyze.missing_tests import find_missing_tests
244
+ from testless.analyze.refactor_candidates import find_refactor_candidates
245
+ from testless.reporters.console import print_report
246
+ from testless.models.findings import AnalysisReport, TestMeta
247
+
248
+ cfg = ctx.obj["config"]
249
+
250
+ cov_path = coverage_json or str(Path(cfg.coverage_dir) / "coverage.json")
251
+ cov_map = load_coverage_json(cov_path)
252
+
253
+ all_node_ids: set[str] = set()
254
+ for fc in cov_map.files.values():
255
+ for tests_list in fc.line_to_tests.values():
256
+ all_node_ids.update(tests_list)
257
+
258
+ tests = [
259
+ TestMeta(
260
+ node_id=nid,
261
+ file=nid.split("::")[0],
262
+ name=nid.split("::")[-1],
263
+ )
264
+ for nid in sorted(all_node_ids)
265
+ ]
266
+
267
+ fixture_index = FixtureIndex()
268
+ for td in cfg.test_dirs:
269
+ if Path(td).exists():
270
+ fixture_index.scan_directory(td)
271
+
272
+ dirs = list(services) if services else cfg.packages or ["."]
273
+ inventory = EndpointInventory()
274
+ for d in dirs:
275
+ if Path(d).exists():
276
+ inventory.scan_directory(d)
277
+
278
+ report = AnalysisReport(
279
+ duplicates=find_duplicates(tests, cov_map, fixture_index, min_score=cfg.min_duplicate_score),
280
+ dead_tests=find_dead_tests(tests, cov_map),
281
+ missing_tests=find_missing_tests(tests, inventory.endpoints, inventory.services),
282
+ refactor_candidates=find_refactor_candidates(tests),
283
+ tests=tests,
284
+ )
285
+
286
+ if question:
287
+ q = question.lower()
288
+ click.echo(click.style(f"\nQuestion: {question}\n", bold=True))
289
+ if any(kw in q for kw in ("usuń", "remove", "delete", "usunąć")):
290
+ click.echo("Tests to consider removing:")
291
+ for d in report.dead_tests:
292
+ click.echo(f" • {d.node_id} — {d.reason}")
293
+ for dup in report.duplicates:
294
+ click.echo(f" • {dup.test_b} — duplicate of {dup.test_a} (score={dup.score:.2f})")
295
+ if not report.dead_tests and not report.duplicates:
296
+ click.echo(" Nothing to remove — suite looks clean.")
297
+ elif any(kw in q for kw in ("dodaj", "add", "missing", "brakuje")):
298
+ click.echo("Tests to consider adding:")
299
+ for m in report.missing_tests:
300
+ click.echo(f" • [{m.test_type}] {m.target}: {m.description}")
301
+ if not report.missing_tests:
302
+ click.echo(" No obvious missing tests detected.")
303
+ else:
304
+ print_report(report)
305
+ else:
306
+ print_report(report)
File without changes