testpick 0.2.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.
testpick/graph/node.py ADDED
@@ -0,0 +1,16 @@
1
+ """Module node representing a parsed source file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from ..parser.base import ImportInfo
9
+
10
+
11
+ @dataclass
12
+ class ModuleNode:
13
+ filepath: Path
14
+ language: str
15
+ exports: list[str] = field(default_factory=list)
16
+ imports: list[ImportInfo] = field(default_factory=list)
@@ -0,0 +1,18 @@
1
+ """Parser package for testpick: AST-based code analysis."""
2
+
3
+ from .base import BaseParser, ImportInfo, ParseResult, Symbol, SymbolKind
4
+ from .python import PythonParser
5
+ from .registry import LANGUAGES, get_parser
6
+ from .typescript import TypeScriptParser
7
+
8
+ __all__ = [
9
+ "BaseParser",
10
+ "ImportInfo",
11
+ "ParseResult",
12
+ "Symbol",
13
+ "SymbolKind",
14
+ "PythonParser",
15
+ "TypeScriptParser",
16
+ "LANGUAGES",
17
+ "get_parser",
18
+ ]
@@ -0,0 +1,46 @@
1
+ """Base parser interface and data models for testpick."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from pathlib import Path
9
+
10
+
11
+ class SymbolKind(Enum):
12
+ FUNCTION = "function"
13
+ CLASS = "class"
14
+ VARIABLE = "variable"
15
+ UNKNOWN = "unknown"
16
+
17
+
18
+ @dataclass
19
+ class Symbol:
20
+ name: str
21
+ kind: SymbolKind
22
+ line: int
23
+ is_exported: bool = False
24
+
25
+
26
+ @dataclass
27
+ class ImportInfo:
28
+ module: str
29
+ names: list[str] = field(default_factory=list)
30
+ kind: str = "import" # "import" or "from"
31
+
32
+
33
+ @dataclass
34
+ class ParseResult:
35
+ symbols: list[Symbol] = field(default_factory=list)
36
+ imports: list[ImportInfo] = field(default_factory=list)
37
+ language: str = ""
38
+
39
+
40
+ class BaseParser(ABC):
41
+ """Abstract base class for language-specific parsers."""
42
+
43
+ @abstractmethod
44
+ def parse(self, filepath: Path) -> ParseResult:
45
+ """Parse a source file and return extracted symbols and imports."""
46
+ ...
@@ -0,0 +1,225 @@
1
+ """Python parser using tree-sitter AST."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from .base import BaseParser, ImportInfo, ParseResult, Symbol, SymbolKind
9
+
10
+ try:
11
+ from tree_sitter import Language, Parser
12
+
13
+ import tree_sitter_python
14
+
15
+ _PY_LANGUAGE = Language(tree_sitter_python.language())
16
+ _HAS_TREE_SITTER = True
17
+ except ImportError:
18
+ _HAS_TREE_SITTER = False
19
+
20
+
21
+ def _node_text(node, source: bytes) -> str:
22
+ return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
23
+
24
+
25
+ def _get_name(node, source: bytes) -> str | None:
26
+ """Extract the name (identifier) from a definition node."""
27
+ for child in node.children:
28
+ if child.type == "identifier":
29
+ return _node_text(child, source)
30
+ return None
31
+
32
+
33
+ class PythonParser(BaseParser):
34
+ def parse(self, filepath: Path) -> ParseResult:
35
+ source = filepath.read_bytes()
36
+ if _HAS_TREE_SITTER:
37
+ return self._parse_with_tree_sitter(source)
38
+ return self._parse_with_regex(source)
39
+
40
+ # ── tree-sitter path ──
41
+
42
+ def _parse_with_tree_sitter(self, source: bytes) -> ParseResult:
43
+ parser = Parser(_PY_LANGUAGE)
44
+ tree = parser.parse(source)
45
+ root = tree.root_node
46
+
47
+ symbols: list[Symbol] = []
48
+ imports: list[ImportInfo] = []
49
+ all_names: list[str] | None = None
50
+
51
+ for node in root.children:
52
+ ntype = node.type
53
+
54
+ if ntype == "function_definition":
55
+ name = _get_name(node, source)
56
+ if name:
57
+ is_pub = not name.startswith("_") or name in ("__init__", "__str__", "__repr__")
58
+ symbols.append(Symbol(name, SymbolKind.FUNCTION, node.start_point[0], is_pub))
59
+
60
+ elif ntype == "class_definition":
61
+ name = _get_name(node, source)
62
+ if name:
63
+ is_pub = not name.startswith("_")
64
+ symbols.append(Symbol(name, SymbolKind.CLASS, node.start_point[0], is_pub))
65
+
66
+ elif ntype == "expression_statement":
67
+ self._extract_assignment(node, source, symbols)
68
+
69
+ elif ntype == "import_statement":
70
+ imports.append(self._extract_import(node, source))
71
+
72
+ elif ntype == "import_from_statement":
73
+ imports.append(self._extract_from_import(node, source))
74
+
75
+ # Check __all__ for explicit exports
76
+ all_names = self._find_all(root, source)
77
+ if all_names is not None:
78
+ all_set = set(all_names)
79
+ for sym in symbols:
80
+ sym.is_exported = sym.name in all_set
81
+
82
+ return ParseResult(symbols=symbols, imports=imports, language="python")
83
+
84
+ def _extract_assignment(self, node, source: bytes, symbols: list[Symbol]) -> None:
85
+ for child in node.children:
86
+ if child.type == "assignment":
87
+ # left side is the target
88
+ target = child.children[0] if child.children else None
89
+ if target and target.type == "identifier":
90
+ name = _node_text(target, source)
91
+ is_pub = not name.startswith("_")
92
+ symbols.append(Symbol(name, SymbolKind.VARIABLE, node.start_point[0], is_pub))
93
+
94
+ def _extract_import(self, node, source: bytes) -> ImportInfo:
95
+ names: list[str] = []
96
+ for child in node.children:
97
+ if child.type == "dotted_name":
98
+ names.append(_node_text(child, source))
99
+ elif child.type == "aliased_import":
100
+ alias = None
101
+ for sub in child.children:
102
+ if sub.type == "identifier":
103
+ alias = alias or _node_text(sub, source)
104
+ if alias:
105
+ names.append(alias)
106
+ module = names[0] if names else ""
107
+ return ImportInfo(module=module, names=names, kind="import")
108
+
109
+ def _extract_from_import(self, node, source: bytes) -> ImportInfo:
110
+ module = ""
111
+ names: list[str] = []
112
+ first_dotted_seen = False
113
+ for child in node.children:
114
+ if child.type == "dotted_name":
115
+ if not first_dotted_seen:
116
+ module = _node_text(child, source)
117
+ first_dotted_seen = True
118
+ else:
119
+ names.append(_node_text(child, source))
120
+ elif child.type == "relative_import":
121
+ # Handle relative imports like "from .foo import bar"
122
+ prefix = ""
123
+ import_name = ""
124
+ for sub in child.children:
125
+ if sub.type == "import_prefix":
126
+ prefix = _node_text(sub, source)
127
+ elif sub.type == "dotted_name":
128
+ import_name = _node_text(sub, source)
129
+ module = prefix + import_name
130
+ first_dotted_seen = True
131
+ elif child.type == "wildcard_import":
132
+ names.append("*")
133
+ elif child.type == "aliased_import":
134
+ for sub in child.children:
135
+ if sub.type == "identifier":
136
+ names.append(_node_text(sub, source))
137
+ elif child.type == "identifier":
138
+ names.append(_node_text(child, source))
139
+ return ImportInfo(module=module, names=names, kind="from")
140
+
141
+ def _find_all(self, root, source: bytes) -> list[str] | None:
142
+ """Look for __all__ = [...] at module level."""
143
+ for node in root.children:
144
+ if node.type == "expression_statement":
145
+ for child in node.children:
146
+ if child.type == "assignment":
147
+ left = child.children[0] if child.children else None
148
+ if left and _node_text(left, source) == "__all__":
149
+ right = child.children[2] if len(child.children) > 2 else None
150
+ if right and right.type == "list":
151
+ return self._extract_list_elements(right, source)
152
+ return None
153
+
154
+ def _extract_list_elements(self, node, source: bytes) -> list[str]:
155
+ result: list[str] = []
156
+ for child in node.children:
157
+ if child.type == "string":
158
+ text = _node_text(child, source).strip("'\"")
159
+ result.append(text)
160
+ return result
161
+
162
+ # ── regex fallback ──
163
+
164
+ def _parse_with_regex(self, source: bytes) -> ParseResult:
165
+ text = source.decode("utf-8", errors="replace")
166
+ lines = text.splitlines()
167
+
168
+ symbols: list[Symbol] = []
169
+ imports: list[ImportInfo] = []
170
+ all_names: list[str] | None = None
171
+
172
+ for i, line in enumerate(lines, start=1):
173
+ stripped = line.strip()
174
+
175
+ # Function definition
176
+ m = re.match(r"def\s+(\w+)\s*\(", stripped)
177
+ if m:
178
+ name = m.group(1)
179
+ is_pub = not name.startswith("_") or name in ("__init__", "__str__", "__repr__")
180
+ symbols.append(Symbol(name, SymbolKind.FUNCTION, i, is_pub))
181
+ continue
182
+
183
+ # Class definition
184
+ m = re.match(r"class\s+(\w+)", stripped)
185
+ if m:
186
+ name = m.group(1)
187
+ symbols.append(Symbol(name, SymbolKind.CLASS, i, not name.startswith("_")))
188
+ continue
189
+
190
+ # Variable assignment at module level
191
+ m = re.match(r"(\w+)\s*=\s*", stripped)
192
+ if m and not stripped.startswith("def ") and not stripped.startswith("class "):
193
+ name = m.group(1)
194
+ if name.isupper() or not name.startswith("_"):
195
+ symbols.append(Symbol(name, SymbolKind.VARIABLE, i, not name.startswith("_")))
196
+ continue
197
+
198
+ # __all__
199
+ m = re.match(r'__all__\s*=\s*\[(.*?)\]', stripped)
200
+ if m:
201
+ raw = m.group(1)
202
+ all_names = re.findall(r"['\"](\w+)['\"]", raw)
203
+ continue
204
+
205
+ # import
206
+ m = re.match(r"import\s+(.+)", stripped)
207
+ if m:
208
+ names = [n.strip() for n in m.group(1).split(",")]
209
+ imports.append(ImportInfo(module=names[0], names=names, kind="import"))
210
+ continue
211
+
212
+ # from X import Y
213
+ m = re.match(r"from\s+(\S+)\s+import\s+(.+)", stripped)
214
+ if m:
215
+ module = m.group(1)
216
+ names = [n.strip() for n in m.group(2).split(",")]
217
+ imports.append(ImportInfo(module=module, names=names, kind="from"))
218
+ continue
219
+
220
+ if all_names is not None:
221
+ all_set = set(all_names)
222
+ for sym in symbols:
223
+ sym.is_exported = sym.name in all_set
224
+
225
+ return ParseResult(symbols=symbols, imports=imports, language="python")
@@ -0,0 +1,24 @@
1
+ """Parser registry: maps file extensions to parser instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .base import BaseParser
8
+ from .python import PythonParser
9
+ from .typescript import TypeScriptParser
10
+
11
+ LANGUAGES: dict[str, BaseParser] = {
12
+ ".py": PythonParser(),
13
+ ".pyi": PythonParser(),
14
+ ".js": TypeScriptParser(),
15
+ ".jsx": TypeScriptParser(),
16
+ ".ts": TypeScriptParser(),
17
+ ".tsx": TypeScriptParser(),
18
+ ".mjs": TypeScriptParser(),
19
+ ".cjs": TypeScriptParser(),
20
+ }
21
+
22
+
23
+ def get_parser(filepath: Path) -> BaseParser | None:
24
+ return LANGUAGES.get(filepath.suffix.lower())
@@ -0,0 +1,207 @@
1
+ """TypeScript / JavaScript parser using tree-sitter AST."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from .base import BaseParser, ImportInfo, ParseResult, Symbol, SymbolKind
9
+
10
+ try:
11
+ from tree_sitter import Language, Parser
12
+
13
+ import tree_sitter_javascript
14
+ import tree_sitter_typescript
15
+
16
+ _TS_LANGUAGE = Language(tree_sitter_typescript.language_typescript())
17
+ _JS_LANGUAGE = Language(tree_sitter_javascript.language())
18
+ _HAS_TREE_SITTER = True
19
+ except ImportError:
20
+ _HAS_TREE_SITTER = False
21
+
22
+
23
+ def _node_text(node, source: bytes) -> str:
24
+ return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
25
+
26
+
27
+ def _get_name(node, source: bytes) -> str | None:
28
+ for child in node.children:
29
+ if child.type in ("identifier", "type_identifier"):
30
+ return _node_text(child, source)
31
+ return None
32
+
33
+
34
+ def _has_export_keyword(node, source: bytes) -> bool:
35
+ """Check if a node is preceded by 'export'."""
36
+ text = source[:node.start_byte].decode("utf-8", errors="replace")
37
+ last_line = text.splitlines()[-1] if text.splitlines() else ""
38
+ return "export" in last_line
39
+
40
+
41
+ class TypeScriptParser(BaseParser):
42
+ def parse(self, filepath: Path) -> ParseResult:
43
+ source = filepath.read_bytes()
44
+ ext = filepath.suffix.lower()
45
+
46
+ if _HAS_TREE_SITTER:
47
+ lang = _JS_LANGUAGE if ext in (".js", ".jsx", ".mjs", ".cjs") else _TS_LANGUAGE
48
+ return self._parse_with_tree_sitter(source, lang, "typescript")
49
+ return self._parse_with_regex(source, "typescript")
50
+
51
+ # ── tree-sitter path ──
52
+
53
+ def _parse_with_tree_sitter(self, source: bytes, language, lang_name: str) -> ParseResult:
54
+ parser = Parser(language)
55
+ tree = parser.parse(source)
56
+ root = tree.root_node
57
+
58
+ symbols: list[Symbol] = []
59
+ imports: list[ImportInfo] = []
60
+
61
+ for node in root.children:
62
+ ntype = node.type
63
+ exported = _has_export_keyword(node, source)
64
+
65
+ if ntype in ("function_declaration", "function"):
66
+ name = _get_name(node, source)
67
+ if name:
68
+ symbols.append(Symbol(name, SymbolKind.FUNCTION, node.start_point[0], exported))
69
+
70
+ elif ntype == "class_declaration":
71
+ name = _get_name(node, source)
72
+ if name:
73
+ symbols.append(Symbol(name, SymbolKind.CLASS, node.start_point[0], exported))
74
+
75
+ elif ntype in ("lexical_declaration", "variable_declaration"):
76
+ self._extract_var_declarations(node, source, symbols, exported, imports)
77
+
78
+ elif ntype == "import_statement":
79
+ imp = self._extract_ts_import(node, source)
80
+ if imp:
81
+ imports.append(imp)
82
+
83
+ elif ntype == "export_statement":
84
+ self._extract_export(node, source, symbols)
85
+
86
+ elif ntype == "expression_statement":
87
+ self._extract_require(node, source, imports)
88
+
89
+ return ParseResult(symbols=symbols, imports=imports, language=lang_name)
90
+
91
+ def _extract_var_declarations(
92
+ self,
93
+ node,
94
+ source: bytes,
95
+ symbols: list[Symbol],
96
+ exported: bool,
97
+ imports: list[ImportInfo] | None = None,
98
+ ) -> None:
99
+ for child in node.children:
100
+ if child.type == "variable_declarator":
101
+ name_node = child.children[0] if child.children else None
102
+ if name_node and name_node.type == "identifier":
103
+ name = _node_text(name_node, source)
104
+ symbols.append(Symbol(name, SymbolKind.VARIABLE, node.start_point[0], exported))
105
+ # Check for require() in initializer
106
+ if imports is not None:
107
+ for sub in child.children:
108
+ if sub.type == "call_expression":
109
+ self._extract_require(sub, source, imports)
110
+
111
+ def _extract_ts_import(self, node, source: bytes) -> ImportInfo | None:
112
+ text = _node_text(node, source)
113
+ # from 'module' import ... OR import ... from 'module'
114
+ m = re.search(r"""from\s+['"]([^'"]+)['"]""", text)
115
+ if not m:
116
+ # import 'module' style
117
+ m = re.search(r"""import\s+['"]([^'"]+)['"]""", text)
118
+ if m:
119
+ return ImportInfo(module=m.group(1), names=[], kind="import")
120
+ return None
121
+ module = m.group(1)
122
+
123
+ # Extract imported names
124
+ names: list[str] = []
125
+ # import { foo, bar } from ...
126
+ brace = re.search(r"\{([^}]*)\}", text)
127
+ if brace:
128
+ names = [n.strip().split(" as ")[-1].strip() for n in brace.group(1).split(",") if n.strip()]
129
+ else:
130
+ # import Foo from ... (default)
131
+ before_from = text[: text.find("from")].strip().replace("import", "").strip()
132
+ if before_from:
133
+ names = [before_from]
134
+
135
+ return ImportInfo(module=module, names=names, kind="from")
136
+
137
+ def _extract_export(self, node, source: bytes, symbols: list[Symbol]) -> None:
138
+ for child in node.children:
139
+ if child.type == "function_declaration":
140
+ name = _get_name(child, source)
141
+ if name:
142
+ symbols.append(Symbol(name, SymbolKind.FUNCTION, node.start_point[0], True))
143
+ elif child.type == "class_declaration":
144
+ name = _get_name(child, source)
145
+ if name:
146
+ symbols.append(Symbol(name, SymbolKind.CLASS, node.start_point[0], True))
147
+ elif child.type in ("lexical_declaration", "variable_declaration"):
148
+ self._extract_var_declarations(child, source, symbols, True)
149
+
150
+ def _extract_require(self, node, source: bytes, imports: list[ImportInfo]) -> None:
151
+ text = _node_text(node, source)
152
+ m = re.search(r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)""", text)
153
+ if m:
154
+ imports.append(ImportInfo(module=m.group(1), names=[], kind="import"))
155
+
156
+ # ── regex fallback ──
157
+
158
+ def _parse_with_regex(self, source: bytes, lang_name: str) -> ParseResult:
159
+ text = source.decode("utf-8", errors="replace")
160
+ lines = text.splitlines()
161
+
162
+ symbols: list[Symbol] = []
163
+ imports: list[ImportInfo] = []
164
+
165
+ for i, line in enumerate(lines, start=1):
166
+ stripped = line.strip()
167
+
168
+ # Function declaration
169
+ m = re.match(r"(?:export\s+)?(?:async\s+)?function\s+(\w+)", stripped)
170
+ if m:
171
+ exported = "export" in stripped
172
+ symbols.append(Symbol(m.group(1), SymbolKind.FUNCTION, i, exported))
173
+ continue
174
+
175
+ # Class declaration
176
+ m = re.match(r"(?:export\s+)?class\s+(\w+)", stripped)
177
+ if m:
178
+ exported = "export" in stripped
179
+ symbols.append(Symbol(m.group(1), SymbolKind.CLASS, i, exported))
180
+ continue
181
+
182
+ # const/let/var declaration
183
+ m = re.match(r"(?:export\s+)?(?:const|let|var)\s+(\w+)", stripped)
184
+ if m:
185
+ exported = "export" in stripped
186
+ symbols.append(Symbol(m.group(1), SymbolKind.VARIABLE, i, exported))
187
+ continue
188
+
189
+ # import statement
190
+ m = re.match(r"""import\s+.*\s+from\s+['"]([^'"]+)['"]""", stripped)
191
+ if m:
192
+ names = re.findall(r"(\w+)", stripped.split("from")[0].replace("import", ""))
193
+ imports.append(ImportInfo(module=m.group(1), names=names, kind="from"))
194
+ continue
195
+
196
+ m = re.match(r"""import\s+['"]([^'"]+)['"]""", stripped)
197
+ if m:
198
+ imports.append(ImportInfo(module=m.group(1), names=[], kind="import"))
199
+ continue
200
+
201
+ # require()
202
+ m = re.search(r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)""", stripped)
203
+ if m:
204
+ imports.append(ImportInfo(module=m.group(1), names=[], kind="import"))
205
+ continue
206
+
207
+ return ParseResult(symbols=symbols, imports=imports, language=lang_name)
@@ -0,0 +1,7 @@
1
+ """Reporter modules for testpick output."""
2
+
3
+ from .json_reporter import JsonReporter
4
+ from .github import GitHubReporter
5
+ from .terminal import TerminalReporter
6
+
7
+ __all__ = ["TerminalReporter", "JsonReporter", "GitHubReporter"]
@@ -0,0 +1,58 @@
1
+ """GitHub Actions annotation reporter for testpick."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ class GitHubReporter:
9
+ """Render results as GitHub Actions workflow annotations."""
10
+
11
+ def render(
12
+ self,
13
+ changed_files: list[tuple[str, str]],
14
+ test_files: list[Path],
15
+ dependency_chains: dict[Path, list[Path]],
16
+ stats: dict,
17
+ ) -> str:
18
+ lines: list[str] = []
19
+
20
+ # Summary header
21
+ lines.append("## testpick Results")
22
+ lines.append("")
23
+ lines.append(f"**Changed files:** {len(changed_files)} ")
24
+ lines.append(f"**Affected tests:** {len(test_files)} ")
25
+ lines.append(f"**Insertions:** +{stats.get('insertions', 0)} ")
26
+ lines.append(f"**Deletions:** -{stats.get('deletions', 0)}")
27
+ lines.append("")
28
+
29
+ # Changed files as annotations
30
+ for status, filepath in changed_files:
31
+ icon = {"A": "🆕", "M": "📝", "D": "🗑️", "R": "🚚"}.get(status, "📄")
32
+ lines.append(f"### {icon} Changed: `{filepath}`")
33
+
34
+ lines.append("")
35
+
36
+ # Affected tests as warning annotations
37
+ if test_files:
38
+ lines.append("### ⚠️ Affected Tests")
39
+ lines.append("")
40
+ for test_file in test_files:
41
+ chain = dependency_chains.get(test_file, [])
42
+ if chain:
43
+ chain_str = " → ".join(f"`{p.as_posix()}`" for p in chain)
44
+ lines.append(f"- `{test_file.as_posix()}` (via {chain_str})")
45
+ else:
46
+ lines.append(f"- `{test_file.as_posix()}`")
47
+ lines.append("")
48
+
49
+ # GitHub Actions annotations for each affected test
50
+ for test_file in test_files:
51
+ lines.append(
52
+ f"::warning file={test_file.as_posix()}::This test may be affected by recent changes"
53
+ )
54
+ else:
55
+ lines.append("### ✅ No affected tests found")
56
+ lines.append("")
57
+
58
+ return "\n".join(lines)
@@ -0,0 +1,49 @@
1
+ """JSON reporter that outputs structured JSON data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ class JsonReporter:
11
+ def render(
12
+ self,
13
+ changed_files: list[tuple[str, str]],
14
+ test_files: list[Path],
15
+ dependency_chains: dict[Path, list[Path]],
16
+ stats: dict,
17
+ ) -> str:
18
+ """Render the test selection results as a JSON string.
19
+
20
+ Args:
21
+ changed_files: List of (status, filepath) tuples from git diff.
22
+ test_files: List of selected test filepaths.
23
+ dependency_chains: Mapping from test file to its dependency chain.
24
+ stats: Statistics about the analysis.
25
+
26
+ Returns:
27
+ JSON string with structured data.
28
+ """
29
+ data: dict[str, Any] = {
30
+ "changed_files": [
31
+ {"status": status, "path": filepath}
32
+ for status, filepath in changed_files
33
+ ],
34
+ "test_files": [
35
+ {
36
+ "path": test_file.as_posix(),
37
+ "dependency_chain": [p.as_posix() for p in dependency_chains.get(test_file, [])],
38
+ }
39
+ for test_file in test_files
40
+ ],
41
+ "stats": {
42
+ "files_changed": stats.get("files_changed", 0),
43
+ "tests_selected": len(test_files),
44
+ "insertions": stats.get("insertions", 0),
45
+ "deletions": stats.get("deletions", 0),
46
+ },
47
+ }
48
+
49
+ return json.dumps(data, indent=2)