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/__init__.py +10 -0
- testpick/__main__.py +3 -0
- testpick/cli.py +228 -0
- testpick/git/__init__.py +21 -0
- testpick/git/commit.py +28 -0
- testpick/git/diff.py +59 -0
- testpick/git/interactive.py +106 -0
- testpick/graph/__init__.py +7 -0
- testpick/graph/builder.py +201 -0
- testpick/graph/edge.py +13 -0
- testpick/graph/node.py +16 -0
- testpick/parser/__init__.py +18 -0
- testpick/parser/base.py +46 -0
- testpick/parser/python.py +225 -0
- testpick/parser/registry.py +24 -0
- testpick/parser/typescript.py +207 -0
- testpick/reporter/__init__.py +7 -0
- testpick/reporter/github.py +58 -0
- testpick/reporter/json_reporter.py +49 -0
- testpick/reporter/terminal.py +108 -0
- testpick/selector/__init__.py +6 -0
- testpick/selector/matcher.py +58 -0
- testpick/selector/walker.py +42 -0
- testpick-0.2.0.dist-info/METADATA +211 -0
- testpick-0.2.0.dist-info/RECORD +28 -0
- testpick-0.2.0.dist-info/WHEEL +4 -0
- testpick-0.2.0.dist-info/entry_points.txt +2 -0
- testpick-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
testpick/parser/base.py
ADDED
|
@@ -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,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)
|