codedebrief 0.11.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.
- codedebrief/__init__.py +12 -0
- codedebrief/analysis/__init__.py +16 -0
- codedebrief/analysis/common.py +527 -0
- codedebrief/analysis/discovery.py +100 -0
- codedebrief/analysis/languages/__init__.py +6 -0
- codedebrief/analysis/languages/_common.py +68 -0
- codedebrief/analysis/languages/c.py +96 -0
- codedebrief/analysis/languages/cpp.py +146 -0
- codedebrief/analysis/languages/csharp.py +137 -0
- codedebrief/analysis/languages/go.py +157 -0
- codedebrief/analysis/languages/java.py +158 -0
- codedebrief/analysis/languages/php.py +83 -0
- codedebrief/analysis/languages/ruby.py +75 -0
- codedebrief/analysis/languages/rust.py +96 -0
- codedebrief/analysis/project.py +373 -0
- codedebrief/analysis/python.py +939 -0
- codedebrief/analysis/registry.py +320 -0
- codedebrief/analysis/treesitter.py +884 -0
- codedebrief/analysis/typescript.py +1019 -0
- codedebrief/artifacts.py +49 -0
- codedebrief/cli.py +585 -0
- codedebrief/config.py +226 -0
- codedebrief/doctor.py +175 -0
- codedebrief/install.py +441 -0
- codedebrief/mcp_server.py +2720 -0
- codedebrief/model.py +189 -0
- codedebrief/py.typed +1 -0
- codedebrief/quality.py +392 -0
- codedebrief/query.py +641 -0
- codedebrief/render/__init__.py +6 -0
- codedebrief/render/assets/generated/codedebrief-viewer-runtime.iife.js +10 -0
- codedebrief/render/assets/panels.js +462 -0
- codedebrief/render/assets/shell.js +1649 -0
- codedebrief/render/assets/styles.css +1715 -0
- codedebrief/render/assets/tree.js +616 -0
- codedebrief/render/html.py +191 -0
- codedebrief/render/markdown.py +153 -0
- codedebrief/render/payload.py +326 -0
- codedebrief/render/snapshot.py +769 -0
- codedebrief/schema/codedebrief.schema.json +449 -0
- codedebrief/util.py +65 -0
- codedebrief/validation.py +214 -0
- codedebrief-0.11.0.dist-info/METADATA +426 -0
- codedebrief-0.11.0.dist-info/RECORD +48 -0
- codedebrief-0.11.0.dist-info/WHEEL +4 -0
- codedebrief-0.11.0.dist-info/entry_points.txt +2 -0
- codedebrief-0.11.0.dist-info/licenses/LICENSE +176 -0
- codedebrief-0.11.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import posixpath
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import tree_sitter_typescript
|
|
11
|
+
from tree_sitter import Language, Parser
|
|
12
|
+
|
|
13
|
+
from codedebrief.analysis.common import (
|
|
14
|
+
CONTINUES,
|
|
15
|
+
DEFAULT,
|
|
16
|
+
DEFAULT_EXPORT_MARKER,
|
|
17
|
+
EMPTY,
|
|
18
|
+
FALLS_THROUGH,
|
|
19
|
+
NO,
|
|
20
|
+
RAISES,
|
|
21
|
+
RETURNS,
|
|
22
|
+
SUCCESS,
|
|
23
|
+
SWITCH,
|
|
24
|
+
YES,
|
|
25
|
+
FlowBuilder,
|
|
26
|
+
PendingEdge,
|
|
27
|
+
annotate_reachability,
|
|
28
|
+
attach_qualified_calls,
|
|
29
|
+
branch,
|
|
30
|
+
call_is_boundary,
|
|
31
|
+
decision_identity,
|
|
32
|
+
decision_metadata,
|
|
33
|
+
dependency_paths_from_import_map,
|
|
34
|
+
domain_from_subject,
|
|
35
|
+
is_functional_condition,
|
|
36
|
+
require_tree_sitter_parse_ok,
|
|
37
|
+
tag_call_effects,
|
|
38
|
+
tree_sitter_parse_error,
|
|
39
|
+
value_namespace,
|
|
40
|
+
)
|
|
41
|
+
from codedebrief.config import CodeDebriefConfig
|
|
42
|
+
from codedebrief.model import Evidence, FileAnalysis, Flow, NodeKind, SourceLocation
|
|
43
|
+
from codedebrief.util import compact_text, file_sha256, relpath, stable_id
|
|
44
|
+
|
|
45
|
+
HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}
|
|
46
|
+
LOOP_TYPES = {"for_statement", "for_in_statement", "while_statement", "do_statement"}
|
|
47
|
+
# JavaScript variants the TypeScript grammar also parses; labelled "javascript" in the IR.
|
|
48
|
+
_JS_SUFFIXES = {".js", ".jsx", ".mjs", ".cjs"}
|
|
49
|
+
# Next.js convention files, in their TS and JS spellings.
|
|
50
|
+
_ROUTE_FILES = ("/route.ts", "/route.tsx", "/route.js", "/route.jsx", "/route.mjs")
|
|
51
|
+
_PAGE_FILES = (
|
|
52
|
+
"/page.tsx",
|
|
53
|
+
"/page.jsx",
|
|
54
|
+
"/page.js",
|
|
55
|
+
"/layout.tsx",
|
|
56
|
+
"/layout.jsx",
|
|
57
|
+
"/layout.js",
|
|
58
|
+
)
|
|
59
|
+
FUNCTION_TYPES = {"function_declaration", "generator_function_declaration"}
|
|
60
|
+
CALLABLE_VALUE_TYPES = {"arrow_function", "function_expression", "generator_function"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class TypeScriptDefinition:
|
|
65
|
+
name: str
|
|
66
|
+
node: Any
|
|
67
|
+
body: Any
|
|
68
|
+
owner: str
|
|
69
|
+
exported: bool
|
|
70
|
+
default_export: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TypeScriptAnalyzer:
|
|
74
|
+
def __init__(self, root: Path, config: CodeDebriefConfig) -> None:
|
|
75
|
+
self.root = root
|
|
76
|
+
self.config = config
|
|
77
|
+
|
|
78
|
+
def analyze(self, path: Path) -> FileAnalysis:
|
|
79
|
+
source_bytes = path.read_bytes()
|
|
80
|
+
source = source_bytes.decode("utf-8")
|
|
81
|
+
relative = relpath(path, self.root)
|
|
82
|
+
# TypeScript grammar is a JS superset, so this analyzer also handles
|
|
83
|
+
# .js/.jsx/.mjs/.cjs; only the grammar variant (JSX) and IR label differ.
|
|
84
|
+
jsx = path.suffix in {".tsx", ".jsx"}
|
|
85
|
+
ir_language = "javascript" if path.suffix in _JS_SUFFIXES else "typescript"
|
|
86
|
+
grammar = (
|
|
87
|
+
tree_sitter_typescript.language_tsx()
|
|
88
|
+
if jsx
|
|
89
|
+
else tree_sitter_typescript.language_typescript()
|
|
90
|
+
)
|
|
91
|
+
parser = Parser(Language(grammar))
|
|
92
|
+
tree = parser.parse(source_bytes)
|
|
93
|
+
parse_error = tree_sitter_parse_error(tree.root_node, relative, ir_language)
|
|
94
|
+
definitions = list(_definitions(tree.root_node, source_bytes, relative))
|
|
95
|
+
if parse_error is not None and not definitions:
|
|
96
|
+
require_tree_sitter_parse_ok(tree.root_node, relative, ir_language)
|
|
97
|
+
flows = [
|
|
98
|
+
self._analyze_definition(item, source_bytes, source, relative, ir_language)
|
|
99
|
+
for item in definitions
|
|
100
|
+
]
|
|
101
|
+
if parse_error is not None:
|
|
102
|
+
for flow in flows:
|
|
103
|
+
flow.metadata["parse_error"] = parse_error
|
|
104
|
+
import_map = _import_map(tree.root_node, source_bytes, relative)
|
|
105
|
+
dependencies = [
|
|
106
|
+
item
|
|
107
|
+
for item in dependency_paths_from_import_map(
|
|
108
|
+
import_map,
|
|
109
|
+
self.root,
|
|
110
|
+
module_suffixes=(".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"),
|
|
111
|
+
package_files=(
|
|
112
|
+
"index.ts",
|
|
113
|
+
"index.tsx",
|
|
114
|
+
"index.js",
|
|
115
|
+
"index.jsx",
|
|
116
|
+
"index.mjs",
|
|
117
|
+
"index.cjs",
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
if item != relative
|
|
121
|
+
]
|
|
122
|
+
module_name = _module_name(relative)
|
|
123
|
+
for flow in flows:
|
|
124
|
+
attach_qualified_calls(flow, import_map, module_name)
|
|
125
|
+
return FileAnalysis(
|
|
126
|
+
path=relative,
|
|
127
|
+
language=ir_language,
|
|
128
|
+
sha256=file_sha256(path),
|
|
129
|
+
enums=_harvest_enums(tree.root_node, source_bytes),
|
|
130
|
+
dependencies=dependencies,
|
|
131
|
+
flows=flows,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _analyze_definition(
|
|
135
|
+
self,
|
|
136
|
+
definition: TypeScriptDefinition,
|
|
137
|
+
source_bytes: bytes,
|
|
138
|
+
source: str,
|
|
139
|
+
relative: str,
|
|
140
|
+
ir_language: str,
|
|
141
|
+
) -> Flow:
|
|
142
|
+
qualified_name = (
|
|
143
|
+
f"{definition.owner}.{definition.name}" if definition.owner else definition.name
|
|
144
|
+
)
|
|
145
|
+
symbol = f"{_module_name(relative)}:{qualified_name}"
|
|
146
|
+
framework, entry_kind, is_entrypoint = _classify_entrypoint(
|
|
147
|
+
definition, relative, source, self.config
|
|
148
|
+
)
|
|
149
|
+
is_test = _is_test(relative, definition.name)
|
|
150
|
+
if is_test:
|
|
151
|
+
is_entrypoint = False
|
|
152
|
+
entry_kind = "test"
|
|
153
|
+
|
|
154
|
+
location = _location(relative, definition.node)
|
|
155
|
+
flow = Flow(
|
|
156
|
+
id=f"flow-{stable_id(symbol)}",
|
|
157
|
+
name=qualified_name,
|
|
158
|
+
symbol=symbol,
|
|
159
|
+
language=ir_language,
|
|
160
|
+
framework=framework,
|
|
161
|
+
entry_kind=entry_kind,
|
|
162
|
+
is_entrypoint=is_entrypoint,
|
|
163
|
+
location=location,
|
|
164
|
+
metadata={
|
|
165
|
+
"exported": definition.exported,
|
|
166
|
+
"default_export": definition.default_export,
|
|
167
|
+
"test": is_test,
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
builder = FlowBuilder(flow)
|
|
171
|
+
entry = builder.add_node(
|
|
172
|
+
NodeKind.ENTRY,
|
|
173
|
+
_entry_label(flow),
|
|
174
|
+
location,
|
|
175
|
+
[],
|
|
176
|
+
metadata={"symbol": symbol},
|
|
177
|
+
)
|
|
178
|
+
if definition.body.type == "statement_block":
|
|
179
|
+
outgoing = self._walk_statements(
|
|
180
|
+
list(_named_children(definition.body)),
|
|
181
|
+
[PendingEdge(entry.id)],
|
|
182
|
+
builder,
|
|
183
|
+
source_bytes,
|
|
184
|
+
relative,
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
outgoing = self._walk_expression_body(
|
|
188
|
+
definition.body,
|
|
189
|
+
[PendingEdge(entry.id)],
|
|
190
|
+
builder,
|
|
191
|
+
source_bytes,
|
|
192
|
+
relative,
|
|
193
|
+
)
|
|
194
|
+
if outgoing:
|
|
195
|
+
builder.add_node(
|
|
196
|
+
NodeKind.TERMINAL,
|
|
197
|
+
"Complete",
|
|
198
|
+
location,
|
|
199
|
+
outgoing,
|
|
200
|
+
evidence=Evidence.INFERRED,
|
|
201
|
+
)
|
|
202
|
+
annotate_reachability(flow)
|
|
203
|
+
# Tag call effects for downstream navigation and explanation metadata.
|
|
204
|
+
tag_call_effects(flow)
|
|
205
|
+
return flow
|
|
206
|
+
|
|
207
|
+
def _walk_statements(
|
|
208
|
+
self,
|
|
209
|
+
statements: list[Any],
|
|
210
|
+
incoming: list[PendingEdge],
|
|
211
|
+
builder: FlowBuilder,
|
|
212
|
+
source: bytes,
|
|
213
|
+
relative: str,
|
|
214
|
+
) -> list[PendingEdge]:
|
|
215
|
+
endpoints = incoming
|
|
216
|
+
for statement in statements:
|
|
217
|
+
if not endpoints:
|
|
218
|
+
break
|
|
219
|
+
node_type = statement.type
|
|
220
|
+
if node_type == "if_statement":
|
|
221
|
+
endpoints = self._walk_if(statement, endpoints, builder, source, relative)
|
|
222
|
+
elif node_type == "switch_statement":
|
|
223
|
+
endpoints = self._walk_switch(statement, endpoints, builder, source, relative)
|
|
224
|
+
elif node_type == "try_statement":
|
|
225
|
+
endpoints = self._walk_try(statement, endpoints, builder, source, relative)
|
|
226
|
+
elif node_type in LOOP_TYPES:
|
|
227
|
+
endpoints = self._walk_loop(statement, endpoints, builder, source, relative)
|
|
228
|
+
elif node_type == "return_statement":
|
|
229
|
+
value = _text(statement, source).removeprefix("return").rstrip(";").strip()
|
|
230
|
+
calls = [
|
|
231
|
+
_call_name(item, source)
|
|
232
|
+
for item in _descendants(statement)
|
|
233
|
+
if item.type == "call_expression"
|
|
234
|
+
]
|
|
235
|
+
calls = [item for item in calls if item]
|
|
236
|
+
if calls:
|
|
237
|
+
call_node = builder.add_node(
|
|
238
|
+
NodeKind.CALL,
|
|
239
|
+
f"Call {calls[0]}()",
|
|
240
|
+
_location(relative, statement),
|
|
241
|
+
endpoints,
|
|
242
|
+
detail=_text(statement, source),
|
|
243
|
+
metadata={"calls": calls},
|
|
244
|
+
)
|
|
245
|
+
endpoints = [PendingEdge(call_node.id)]
|
|
246
|
+
builder.add_node(
|
|
247
|
+
NodeKind.TERMINAL,
|
|
248
|
+
f"Return {value}".strip(),
|
|
249
|
+
_location(relative, statement),
|
|
250
|
+
endpoints,
|
|
251
|
+
detail=_text(statement, source),
|
|
252
|
+
)
|
|
253
|
+
endpoints = []
|
|
254
|
+
elif node_type == "throw_statement":
|
|
255
|
+
value = _text(statement, source).removeprefix("throw").rstrip(";").strip()
|
|
256
|
+
builder.add_node(
|
|
257
|
+
NodeKind.ERROR,
|
|
258
|
+
f"Throw {value}".strip(),
|
|
259
|
+
_location(relative, statement),
|
|
260
|
+
endpoints,
|
|
261
|
+
detail=_text(statement, source),
|
|
262
|
+
)
|
|
263
|
+
endpoints = []
|
|
264
|
+
elif node_type == "break_statement":
|
|
265
|
+
node = builder.add_node(
|
|
266
|
+
NodeKind.ACTION,
|
|
267
|
+
"Break loop",
|
|
268
|
+
_location(relative, statement),
|
|
269
|
+
endpoints,
|
|
270
|
+
detail=_text(statement, source),
|
|
271
|
+
metadata={"loop_control": "break"},
|
|
272
|
+
)
|
|
273
|
+
endpoints = [PendingEdge(node.id)]
|
|
274
|
+
elif node_type == "continue_statement":
|
|
275
|
+
builder.add_node(
|
|
276
|
+
NodeKind.ACTION,
|
|
277
|
+
"Continue loop",
|
|
278
|
+
_location(relative, statement),
|
|
279
|
+
endpoints,
|
|
280
|
+
detail=_text(statement, source),
|
|
281
|
+
metadata={"loop_control": "continue"},
|
|
282
|
+
)
|
|
283
|
+
endpoints = []
|
|
284
|
+
elif node_type in {"function_declaration", "class_declaration"}:
|
|
285
|
+
continue
|
|
286
|
+
else:
|
|
287
|
+
kind, label, calls = _statement_summary(statement, source)
|
|
288
|
+
node = builder.add_node(
|
|
289
|
+
kind,
|
|
290
|
+
label,
|
|
291
|
+
_location(relative, statement),
|
|
292
|
+
endpoints,
|
|
293
|
+
detail=_text(statement, source),
|
|
294
|
+
metadata={"calls": calls} if calls else {},
|
|
295
|
+
)
|
|
296
|
+
endpoints = [PendingEdge(node.id)]
|
|
297
|
+
return endpoints
|
|
298
|
+
|
|
299
|
+
def _walk_loop(
|
|
300
|
+
self,
|
|
301
|
+
statement: Any,
|
|
302
|
+
incoming: list[PendingEdge],
|
|
303
|
+
builder: FlowBuilder,
|
|
304
|
+
source: bytes,
|
|
305
|
+
relative: str,
|
|
306
|
+
) -> list[PendingEdge]:
|
|
307
|
+
body = _loop_body(statement)
|
|
308
|
+
node = builder.add_node(
|
|
309
|
+
NodeKind.ACTION,
|
|
310
|
+
_loop_label(statement, source),
|
|
311
|
+
_location(relative, statement),
|
|
312
|
+
incoming,
|
|
313
|
+
detail=_text(statement, source),
|
|
314
|
+
evidence=Evidence.INFERRED,
|
|
315
|
+
metadata={
|
|
316
|
+
"loop": True,
|
|
317
|
+
"body_outcome": _branch_outcome(_statement_children(body)),
|
|
318
|
+
"has_else": False,
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
body_endpoints = self._walk_statements(
|
|
322
|
+
_statement_children(body),
|
|
323
|
+
[PendingEdge(node.id, "Iteration")],
|
|
324
|
+
builder,
|
|
325
|
+
source,
|
|
326
|
+
relative,
|
|
327
|
+
)
|
|
328
|
+
return [PendingEdge(node.id, "Done"), *body_endpoints]
|
|
329
|
+
|
|
330
|
+
def _walk_expression_body(
|
|
331
|
+
self,
|
|
332
|
+
expression: Any,
|
|
333
|
+
incoming: list[PendingEdge],
|
|
334
|
+
builder: FlowBuilder,
|
|
335
|
+
source: bytes,
|
|
336
|
+
relative: str,
|
|
337
|
+
) -> list[PendingEdge]:
|
|
338
|
+
if expression.type == "ternary_expression":
|
|
339
|
+
condition_node = expression.child_by_field_name("condition")
|
|
340
|
+
consequence = expression.child_by_field_name("consequence")
|
|
341
|
+
alternative = expression.child_by_field_name("alternative")
|
|
342
|
+
condition = _strip_parentheses(_text(condition_node or expression, source))
|
|
343
|
+
node = builder.add_node(
|
|
344
|
+
NodeKind.DECISION,
|
|
345
|
+
condition,
|
|
346
|
+
_location(relative, condition_node or expression),
|
|
347
|
+
incoming,
|
|
348
|
+
detail=_text(expression, source),
|
|
349
|
+
metadata=decision_metadata(condition),
|
|
350
|
+
)
|
|
351
|
+
node.metadata["branches"] = [
|
|
352
|
+
branch(YES, RETURNS),
|
|
353
|
+
branch(NO, RETURNS),
|
|
354
|
+
]
|
|
355
|
+
self._walk_expression_return(
|
|
356
|
+
consequence,
|
|
357
|
+
[PendingEdge(node.id, YES)],
|
|
358
|
+
builder,
|
|
359
|
+
source,
|
|
360
|
+
relative,
|
|
361
|
+
)
|
|
362
|
+
self._walk_expression_return(
|
|
363
|
+
alternative,
|
|
364
|
+
[PendingEdge(node.id, NO)],
|
|
365
|
+
builder,
|
|
366
|
+
source,
|
|
367
|
+
relative,
|
|
368
|
+
)
|
|
369
|
+
return []
|
|
370
|
+
return self._walk_expression_return(expression, incoming, builder, source, relative)
|
|
371
|
+
|
|
372
|
+
def _walk_expression_return(
|
|
373
|
+
self,
|
|
374
|
+
expression: Any | None,
|
|
375
|
+
incoming: list[PendingEdge],
|
|
376
|
+
builder: FlowBuilder,
|
|
377
|
+
source: bytes,
|
|
378
|
+
relative: str,
|
|
379
|
+
) -> list[PendingEdge]:
|
|
380
|
+
if expression is None:
|
|
381
|
+
return incoming
|
|
382
|
+
calls = [
|
|
383
|
+
_call_name(item, source)
|
|
384
|
+
for item in _descendants(expression)
|
|
385
|
+
if item.type == "call_expression"
|
|
386
|
+
]
|
|
387
|
+
calls = [item for item in calls if item]
|
|
388
|
+
endpoints = incoming
|
|
389
|
+
if calls:
|
|
390
|
+
call_node = builder.add_node(
|
|
391
|
+
NodeKind.CALL,
|
|
392
|
+
f"Call {calls[0]}()",
|
|
393
|
+
_location(relative, expression),
|
|
394
|
+
endpoints,
|
|
395
|
+
detail=_text(expression, source),
|
|
396
|
+
metadata={"calls": calls},
|
|
397
|
+
)
|
|
398
|
+
endpoints = [PendingEdge(call_node.id)]
|
|
399
|
+
builder.add_node(
|
|
400
|
+
NodeKind.TERMINAL,
|
|
401
|
+
f"Return {_text(expression, source)}".strip(),
|
|
402
|
+
_location(relative, expression),
|
|
403
|
+
endpoints,
|
|
404
|
+
detail=_text(expression, source),
|
|
405
|
+
)
|
|
406
|
+
return []
|
|
407
|
+
|
|
408
|
+
def _walk_if(
|
|
409
|
+
self,
|
|
410
|
+
statement: Any,
|
|
411
|
+
incoming: list[PendingEdge],
|
|
412
|
+
builder: FlowBuilder,
|
|
413
|
+
source: bytes,
|
|
414
|
+
relative: str,
|
|
415
|
+
) -> list[PendingEdge]:
|
|
416
|
+
condition_node = statement.child_by_field_name("condition")
|
|
417
|
+
consequence = statement.child_by_field_name("consequence")
|
|
418
|
+
alternative = statement.child_by_field_name("alternative")
|
|
419
|
+
condition = _strip_parentheses(_text(condition_node, source))
|
|
420
|
+
branch_text = _text(consequence, source)
|
|
421
|
+
|
|
422
|
+
if not is_functional_condition(condition, branch_text):
|
|
423
|
+
node = builder.add_node(
|
|
424
|
+
NodeKind.ACTION,
|
|
425
|
+
f"Handle internal condition: {condition}",
|
|
426
|
+
_location(relative, statement),
|
|
427
|
+
incoming,
|
|
428
|
+
evidence=Evidence.INFERRED,
|
|
429
|
+
detail=_text(statement, source),
|
|
430
|
+
)
|
|
431
|
+
return [PendingEdge(node.id)]
|
|
432
|
+
|
|
433
|
+
node = builder.add_node(
|
|
434
|
+
NodeKind.DECISION,
|
|
435
|
+
condition,
|
|
436
|
+
_location(relative, condition_node or statement),
|
|
437
|
+
incoming,
|
|
438
|
+
detail=condition,
|
|
439
|
+
metadata=decision_metadata(condition),
|
|
440
|
+
)
|
|
441
|
+
node.metadata["branches"] = [
|
|
442
|
+
branch(YES, _branch_outcome(_statement_children(consequence))),
|
|
443
|
+
branch(
|
|
444
|
+
NO,
|
|
445
|
+
(
|
|
446
|
+
_branch_outcome(_statement_children(alternative))
|
|
447
|
+
if alternative is not None
|
|
448
|
+
else FALLS_THROUGH
|
|
449
|
+
),
|
|
450
|
+
implicit=alternative is None,
|
|
451
|
+
),
|
|
452
|
+
]
|
|
453
|
+
yes_endpoints = self._walk_statements(
|
|
454
|
+
_statement_children(consequence),
|
|
455
|
+
[PendingEdge(node.id, YES)],
|
|
456
|
+
builder,
|
|
457
|
+
source,
|
|
458
|
+
relative,
|
|
459
|
+
)
|
|
460
|
+
if alternative is not None:
|
|
461
|
+
no_endpoints = self._walk_statements(
|
|
462
|
+
_statement_children(alternative),
|
|
463
|
+
[PendingEdge(node.id, NO)],
|
|
464
|
+
builder,
|
|
465
|
+
source,
|
|
466
|
+
relative,
|
|
467
|
+
)
|
|
468
|
+
else:
|
|
469
|
+
no_endpoints = [PendingEdge(node.id, NO)]
|
|
470
|
+
return yes_endpoints + no_endpoints
|
|
471
|
+
|
|
472
|
+
def _walk_switch(
|
|
473
|
+
self,
|
|
474
|
+
statement: Any,
|
|
475
|
+
incoming: list[PendingEdge],
|
|
476
|
+
builder: FlowBuilder,
|
|
477
|
+
source: bytes,
|
|
478
|
+
relative: str,
|
|
479
|
+
) -> list[PendingEdge]:
|
|
480
|
+
value_node = statement.child_by_field_name("value")
|
|
481
|
+
subject = _strip_parentheses(_text(value_node, source))
|
|
482
|
+
node = builder.add_node(
|
|
483
|
+
NodeKind.DECISION,
|
|
484
|
+
f"Switch on {subject}",
|
|
485
|
+
_location(relative, statement),
|
|
486
|
+
incoming,
|
|
487
|
+
metadata=decision_identity(
|
|
488
|
+
condition=subject,
|
|
489
|
+
subject=subject,
|
|
490
|
+
operator=SWITCH,
|
|
491
|
+
domain=domain_from_subject(subject),
|
|
492
|
+
namespace="",
|
|
493
|
+
),
|
|
494
|
+
)
|
|
495
|
+
body = statement.child_by_field_name("body")
|
|
496
|
+
endpoints: list[PendingEdge] = []
|
|
497
|
+
values: list[str] = []
|
|
498
|
+
has_default = False
|
|
499
|
+
branches: list[dict[str, Any]] = []
|
|
500
|
+
cases = [c for c in _named_children(body) if c.type in ("switch_case", "switch_default")]
|
|
501
|
+
# C-style fall-through: a case body that neither breaks nor returns/raises runs on
|
|
502
|
+
# into the NEXT case (`case 'a': case 'b': return X` makes 'a' reach X), so chain
|
|
503
|
+
# its endpoints into that case instead of onto the post-switch join.
|
|
504
|
+
carried: list[PendingEdge] = []
|
|
505
|
+
for index, case in enumerate(cases):
|
|
506
|
+
value_node = case.child_by_field_name("value")
|
|
507
|
+
if case.type == "switch_default":
|
|
508
|
+
label = DEFAULT
|
|
509
|
+
has_default = True
|
|
510
|
+
else:
|
|
511
|
+
label = _text(value_node, source) or "case"
|
|
512
|
+
values.append(label)
|
|
513
|
+
children = [
|
|
514
|
+
child
|
|
515
|
+
for child in _named_children(case)
|
|
516
|
+
if value_node is None
|
|
517
|
+
or (
|
|
518
|
+
child.start_byte != value_node.start_byte
|
|
519
|
+
or child.end_byte != value_node.end_byte
|
|
520
|
+
)
|
|
521
|
+
]
|
|
522
|
+
branches.append(branch(label, _branch_outcome(children)))
|
|
523
|
+
case_endpoints = self._walk_statements(
|
|
524
|
+
children,
|
|
525
|
+
[PendingEdge(node.id, label), *carried],
|
|
526
|
+
builder,
|
|
527
|
+
source,
|
|
528
|
+
relative,
|
|
529
|
+
)
|
|
530
|
+
carried = []
|
|
531
|
+
if index + 1 < len(cases) and _case_falls_through(children):
|
|
532
|
+
carried = case_endpoints
|
|
533
|
+
else:
|
|
534
|
+
endpoints.extend(case_endpoints)
|
|
535
|
+
node.metadata["values"] = sorted(set(values))
|
|
536
|
+
node.metadata["value_namespace"] = value_namespace(sorted(set(values)))
|
|
537
|
+
if not has_default:
|
|
538
|
+
branches.append(branch(DEFAULT, FALLS_THROUGH, implicit=True))
|
|
539
|
+
# An unmatched value falls through to whatever follows the switch.
|
|
540
|
+
endpoints.append(PendingEdge(node.id, DEFAULT))
|
|
541
|
+
node.metadata["branches"] = branches
|
|
542
|
+
return endpoints
|
|
543
|
+
|
|
544
|
+
def _walk_try(
|
|
545
|
+
self,
|
|
546
|
+
statement: Any,
|
|
547
|
+
incoming: list[PendingEdge],
|
|
548
|
+
builder: FlowBuilder,
|
|
549
|
+
source: bytes,
|
|
550
|
+
relative: str,
|
|
551
|
+
) -> list[PendingEdge]:
|
|
552
|
+
body = statement.child_by_field_name("body")
|
|
553
|
+
handler = statement.child_by_field_name("handler")
|
|
554
|
+
finalizer = statement.child_by_field_name("finalizer")
|
|
555
|
+
node = builder.add_node(
|
|
556
|
+
NodeKind.DECISION,
|
|
557
|
+
"Operation succeeds?",
|
|
558
|
+
_location(relative, statement),
|
|
559
|
+
incoming,
|
|
560
|
+
evidence=Evidence.INFERRED,
|
|
561
|
+
detail=_text(statement, source),
|
|
562
|
+
metadata=decision_identity(
|
|
563
|
+
condition="exception boundary",
|
|
564
|
+
subject="exception",
|
|
565
|
+
operator="",
|
|
566
|
+
domain="error",
|
|
567
|
+
namespace="",
|
|
568
|
+
),
|
|
569
|
+
)
|
|
570
|
+
branches: list[dict[str, Any]] = [
|
|
571
|
+
branch(SUCCESS, _branch_outcome(_statement_children(body)))
|
|
572
|
+
]
|
|
573
|
+
endpoints = self._walk_statements(
|
|
574
|
+
_statement_children(body),
|
|
575
|
+
[PendingEdge(node.id, SUCCESS)],
|
|
576
|
+
builder,
|
|
577
|
+
source,
|
|
578
|
+
relative,
|
|
579
|
+
)
|
|
580
|
+
if handler is not None:
|
|
581
|
+
branches.append(branch("Error", _branch_outcome(_statement_children(handler))))
|
|
582
|
+
endpoints.extend(
|
|
583
|
+
self._walk_statements(
|
|
584
|
+
_statement_children(handler),
|
|
585
|
+
[PendingEdge(node.id, "Error")],
|
|
586
|
+
builder,
|
|
587
|
+
source,
|
|
588
|
+
relative,
|
|
589
|
+
)
|
|
590
|
+
)
|
|
591
|
+
node.metadata["branches"] = branches
|
|
592
|
+
if finalizer is not None:
|
|
593
|
+
# A finally block always runs, even when the body/handler returned.
|
|
594
|
+
body_terminated = not endpoints
|
|
595
|
+
finally_incoming = endpoints or [PendingEdge(node.id, "finally")]
|
|
596
|
+
endpoints = self._walk_statements(
|
|
597
|
+
_statement_children(finalizer),
|
|
598
|
+
finally_incoming,
|
|
599
|
+
builder,
|
|
600
|
+
source,
|
|
601
|
+
relative,
|
|
602
|
+
)
|
|
603
|
+
if body_terminated:
|
|
604
|
+
# The try/handler already returned/raised; once finally runs that
|
|
605
|
+
# terminator resumes, so anything after the try is unreachable.
|
|
606
|
+
endpoints = []
|
|
607
|
+
return endpoints
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _definitions(root: Any, source: bytes, relative: str) -> Iterable[TypeScriptDefinition]:
|
|
611
|
+
yield from _walk_definitions(root, source, relative, owner="", exported=False, default=False)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _walk_definitions(
|
|
615
|
+
node: Any,
|
|
616
|
+
source: bytes,
|
|
617
|
+
relative: str,
|
|
618
|
+
owner: str,
|
|
619
|
+
exported: bool,
|
|
620
|
+
default: bool,
|
|
621
|
+
) -> Iterable[TypeScriptDefinition]:
|
|
622
|
+
node_text = _text(node, source)
|
|
623
|
+
if node.type == "export_statement":
|
|
624
|
+
exported = True
|
|
625
|
+
default = bool(re.match(r"\s*export\s+default\b", node_text))
|
|
626
|
+
|
|
627
|
+
if node.type == "class_declaration":
|
|
628
|
+
name_node = node.child_by_field_name("name")
|
|
629
|
+
class_name = _text(name_node, source) or owner
|
|
630
|
+
body = node.child_by_field_name("body")
|
|
631
|
+
for child in _named_children(body):
|
|
632
|
+
yield from _walk_definitions(
|
|
633
|
+
child, source, relative, owner=class_name, exported=exported, default=default
|
|
634
|
+
)
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
if node.type in FUNCTION_TYPES:
|
|
638
|
+
name_node = node.child_by_field_name("name")
|
|
639
|
+
name = _text(name_node, source)
|
|
640
|
+
if not name and default:
|
|
641
|
+
name = _default_export_name(relative)
|
|
642
|
+
body = node.child_by_field_name("body")
|
|
643
|
+
if name and body is not None:
|
|
644
|
+
yield TypeScriptDefinition(name, node, body, owner, exported, default)
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
if node.type == "method_definition":
|
|
648
|
+
name = _text(node.child_by_field_name("name"), source)
|
|
649
|
+
body = node.child_by_field_name("body")
|
|
650
|
+
if name and body is not None:
|
|
651
|
+
yield TypeScriptDefinition(name, node, body, owner, exported, default)
|
|
652
|
+
return
|
|
653
|
+
|
|
654
|
+
if node.type == "variable_declarator":
|
|
655
|
+
value = node.child_by_field_name("value")
|
|
656
|
+
name = _text(node.child_by_field_name("name"), source)
|
|
657
|
+
if value is not None and value.type in CALLABLE_VALUE_TYPES and name:
|
|
658
|
+
body = value.child_by_field_name("body")
|
|
659
|
+
if body is not None:
|
|
660
|
+
yield TypeScriptDefinition(name, node, body, owner, exported, default)
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
for child in _named_children(node):
|
|
664
|
+
yield from _walk_definitions(child, source, relative, owner, exported, default)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _classify_entrypoint(
|
|
668
|
+
definition: TypeScriptDefinition,
|
|
669
|
+
relative: str,
|
|
670
|
+
source: str,
|
|
671
|
+
config: CodeDebriefConfig,
|
|
672
|
+
) -> tuple[str, str, bool]:
|
|
673
|
+
owner_prefix = f"{definition.owner}." if definition.owner else ""
|
|
674
|
+
symbol_hint = f"{relative}:{owner_prefix}{definition.name}"
|
|
675
|
+
override = config.entrypoint_override(symbol_hint)
|
|
676
|
+
normalized = "/" + relative.replace("\\", "/")
|
|
677
|
+
|
|
678
|
+
if (
|
|
679
|
+
definition.name in HTTP_METHODS
|
|
680
|
+
and definition.exported
|
|
681
|
+
and normalized.endswith(_ROUTE_FILES)
|
|
682
|
+
):
|
|
683
|
+
return "nextjs", "route", override if override is not None else True
|
|
684
|
+
if definition.name == "middleware" and definition.exported:
|
|
685
|
+
return "nextjs", "middleware", override if override is not None else True
|
|
686
|
+
if ('"use server"' in source or "'use server'" in source) and definition.exported:
|
|
687
|
+
return "nextjs", "server_action", override if override is not None else True
|
|
688
|
+
if relative.endswith(_PAGE_FILES) and (definition.default_export or definition.exported):
|
|
689
|
+
return "nextjs", "component", override if override is not None else True
|
|
690
|
+
if re.match(r"^(on|handle)[A-Z_]", definition.name):
|
|
691
|
+
return "react", "event_handler", override if override is not None else True
|
|
692
|
+
if definition.name.startswith("use") and len(definition.name) > 3:
|
|
693
|
+
return "react", "hook", override if override is not None else definition.exported
|
|
694
|
+
if relative.endswith((".tsx", ".jsx")) and definition.name[:1].isupper():
|
|
695
|
+
return "react", "component", override if override is not None else definition.exported
|
|
696
|
+
if definition.owner:
|
|
697
|
+
return "generic", "method", override if override is not None else False
|
|
698
|
+
public = config.include_public_functions and definition.exported
|
|
699
|
+
return "generic", "function", override if override is not None else public
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _statement_summary(statement: Any, source: bytes) -> tuple[NodeKind, str, list[str]]:
|
|
703
|
+
calls = [
|
|
704
|
+
_call_name(item, source)
|
|
705
|
+
for item in _descendants(statement)
|
|
706
|
+
if item.type == "call_expression"
|
|
707
|
+
]
|
|
708
|
+
calls = [item for item in calls if item]
|
|
709
|
+
boundary = next((item for item in calls if call_is_boundary(item)), "")
|
|
710
|
+
if boundary:
|
|
711
|
+
return NodeKind.CALL, f"Call {boundary}()", calls
|
|
712
|
+
if calls:
|
|
713
|
+
return NodeKind.CALL, f"Call {calls[0]}()", calls
|
|
714
|
+
text = _text(statement, source).rstrip(";")
|
|
715
|
+
if statement.type in {"lexical_declaration", "variable_declaration"}:
|
|
716
|
+
names = [
|
|
717
|
+
_text(item.child_by_field_name("name"), source)
|
|
718
|
+
for item in _descendants(statement)
|
|
719
|
+
if item.type == "variable_declarator"
|
|
720
|
+
]
|
|
721
|
+
label = f"Set {', '.join(item for item in names if item)}"
|
|
722
|
+
return NodeKind.ACTION, label or compact_text(text, 90), []
|
|
723
|
+
return NodeKind.ACTION, compact_text(text, 90), []
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _call_name(call: Any, source: bytes) -> str:
|
|
727
|
+
function = call.child_by_field_name("function")
|
|
728
|
+
return _text(function, source)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _statement_children(node: Any | None) -> list[Any]:
|
|
732
|
+
if node is None:
|
|
733
|
+
return []
|
|
734
|
+
if node.type in {"statement_block", "switch_body"}:
|
|
735
|
+
return list(_named_children(node))
|
|
736
|
+
if node.type == "else_clause":
|
|
737
|
+
children = list(_named_children(node))
|
|
738
|
+
return _statement_children(children[-1]) if children else []
|
|
739
|
+
if node.type == "catch_clause":
|
|
740
|
+
body = node.child_by_field_name("body")
|
|
741
|
+
return _statement_children(body)
|
|
742
|
+
if node.type == "finally_clause":
|
|
743
|
+
children = list(_named_children(node))
|
|
744
|
+
return _statement_children(children[-1]) if children else []
|
|
745
|
+
return [node]
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _loop_body(statement: Any) -> Any | None:
|
|
749
|
+
body = statement.child_by_field_name("body")
|
|
750
|
+
if body is not None:
|
|
751
|
+
return body
|
|
752
|
+
blocks = [child for child in _named_children(statement) if child.type == "statement_block"]
|
|
753
|
+
if blocks:
|
|
754
|
+
return blocks[-1]
|
|
755
|
+
named = list(_named_children(statement))
|
|
756
|
+
return named[-1] if named else None
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _named_children(node: Any | None) -> Iterable[Any]:
|
|
760
|
+
if node is None:
|
|
761
|
+
return []
|
|
762
|
+
return (child for child in node.children if child.is_named)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _descendants(node: Any) -> Iterable[Any]:
|
|
766
|
+
stack = [node]
|
|
767
|
+
while stack:
|
|
768
|
+
current = stack.pop()
|
|
769
|
+
yield current
|
|
770
|
+
if current is not node and current.type in FUNCTION_TYPES | CALLABLE_VALUE_TYPES:
|
|
771
|
+
continue
|
|
772
|
+
stack.extend(reversed(current.children))
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _text(node: Any | None, source: bytes) -> str:
|
|
776
|
+
if node is None:
|
|
777
|
+
return ""
|
|
778
|
+
return compact_text(source[node.start_byte : node.end_byte].decode("utf-8"), 500)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _location(relative: str, node: Any) -> SourceLocation:
|
|
782
|
+
return SourceLocation(
|
|
783
|
+
relative,
|
|
784
|
+
int(node.start_point.row) + 1,
|
|
785
|
+
int(node.end_point.row) + 1,
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _loop_label(statement: Any, source: bytes) -> str:
|
|
790
|
+
text = _text(statement, source)
|
|
791
|
+
header = text.split("{", 1)[0].strip()
|
|
792
|
+
return compact_text(f"Repeat: {header}", 100)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _entry_label(flow: Flow) -> str:
|
|
796
|
+
labels = {
|
|
797
|
+
"route": "Route",
|
|
798
|
+
"middleware": "Middleware",
|
|
799
|
+
"server_action": "Server action",
|
|
800
|
+
"component": "Component",
|
|
801
|
+
"hook": "Hook",
|
|
802
|
+
"event_handler": "Event",
|
|
803
|
+
"test": "Test",
|
|
804
|
+
}
|
|
805
|
+
prefix = labels.get(flow.entry_kind)
|
|
806
|
+
return f"{prefix}: {flow.name}" if prefix else flow.name
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _module_name(relative: str) -> str:
|
|
810
|
+
for suffix in (".tsx", ".ts", ".jsx", ".mjs", ".cjs", ".js"):
|
|
811
|
+
if relative.endswith(suffix):
|
|
812
|
+
relative = relative[: -len(suffix)]
|
|
813
|
+
break
|
|
814
|
+
return relative.replace("/", ".")
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _default_export_name(relative: str) -> str:
|
|
818
|
+
stem = Path(relative).stem
|
|
819
|
+
return stem[:1].upper() + stem[1:] if stem else "DefaultExport"
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _import_map(root: Any, source: bytes, relative: str) -> dict[str, str]:
|
|
823
|
+
"""Map each imported binding to a fully-qualified target module symbol.
|
|
824
|
+
|
|
825
|
+
Relative specifiers resolve against the importing file; bare/external ones
|
|
826
|
+
(e.g. ``react``) are skipped so only first-party calls resolve.
|
|
827
|
+
"""
|
|
828
|
+
mapping: dict[str, str] = {}
|
|
829
|
+
for node in root.children:
|
|
830
|
+
if node.type != "import_statement":
|
|
831
|
+
continue
|
|
832
|
+
source_node = node.child_by_field_name("source")
|
|
833
|
+
if source_node is None:
|
|
834
|
+
continue
|
|
835
|
+
module = _resolve_module(_text(source_node, source).strip("'\"`"), relative)
|
|
836
|
+
if module is None:
|
|
837
|
+
continue
|
|
838
|
+
clause = next((child for child in node.children if child.type == "import_clause"), None)
|
|
839
|
+
if clause is None:
|
|
840
|
+
mapping[f"__side_effect_import__:{module}"] = f"{module}:"
|
|
841
|
+
continue
|
|
842
|
+
for child in clause.children:
|
|
843
|
+
if child.type == "identifier": # default import -> resolve via marker
|
|
844
|
+
mapping[_text(child, source)] = f"{module}:{DEFAULT_EXPORT_MARKER}"
|
|
845
|
+
elif child.type == "namespace_import": # import * as ns -> binds the module
|
|
846
|
+
alias = next((c for c in child.children if c.type == "identifier"), None)
|
|
847
|
+
if alias is not None:
|
|
848
|
+
mapping[_text(alias, source)] = f"{module}:"
|
|
849
|
+
elif child.type == "named_imports":
|
|
850
|
+
for spec in child.children:
|
|
851
|
+
if spec.type != "import_specifier":
|
|
852
|
+
continue
|
|
853
|
+
name = _text(spec.child_by_field_name("name"), source)
|
|
854
|
+
alias_node = spec.child_by_field_name("alias")
|
|
855
|
+
bound = _text(alias_node, source) if alias_node is not None else name
|
|
856
|
+
if name:
|
|
857
|
+
mapping[bound] = f"{module}:{name}"
|
|
858
|
+
return mapping
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _resolve_module(specifier: str, relative: str) -> str | None:
|
|
862
|
+
if not specifier.startswith("."):
|
|
863
|
+
return None
|
|
864
|
+
target = posixpath.normpath(posixpath.join(posixpath.dirname(relative), specifier))
|
|
865
|
+
target = re.sub(r"\.(tsx?|jsx?)$", "", target)
|
|
866
|
+
return target.replace("/", ".")
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _harvest_enums(root: Any, source: bytes) -> dict[str, list[str]]:
|
|
870
|
+
"""Map each TS enum / string-literal union to its members - the value universe."""
|
|
871
|
+
enums: dict[str, list[str]] = {}
|
|
872
|
+
for top in root.children:
|
|
873
|
+
nodes = list(_named_children(top)) if top.type == "export_statement" else [top]
|
|
874
|
+
for node in nodes:
|
|
875
|
+
if node.type == "enum_declaration":
|
|
876
|
+
name = _text(node.child_by_field_name("name"), source)
|
|
877
|
+
members = [
|
|
878
|
+
f"{name}.{_text(child.child_by_field_name('name') or child, source)}"
|
|
879
|
+
for child in _named_children(node.child_by_field_name("body"))
|
|
880
|
+
if child.type in {"enum_assignment", "property_identifier"}
|
|
881
|
+
]
|
|
882
|
+
if name and members:
|
|
883
|
+
enums[name] = members
|
|
884
|
+
elif node.type == "type_alias_declaration":
|
|
885
|
+
name = _text(node.child_by_field_name("name"), source)
|
|
886
|
+
members = _union_string_members(node.child_by_field_name("value"), source)
|
|
887
|
+
if name and members:
|
|
888
|
+
enums[name] = members
|
|
889
|
+
return enums
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _union_string_members(value: Any, source: bytes) -> list[str]:
|
|
893
|
+
"""String members of a union type, flattening nested and parenthesized unions."""
|
|
894
|
+
if value is None:
|
|
895
|
+
return []
|
|
896
|
+
if value.type in {"union_type", "parenthesized_type"}:
|
|
897
|
+
members: list[str] = []
|
|
898
|
+
for child in _named_children(value):
|
|
899
|
+
members.extend(_union_string_members(child, source))
|
|
900
|
+
return members
|
|
901
|
+
if value.type == "literal_type":
|
|
902
|
+
inner = next(iter(_named_children(value)), None)
|
|
903
|
+
if inner is not None and inner.type == "string":
|
|
904
|
+
return [_text(inner, source).strip("'\"`")]
|
|
905
|
+
return []
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _is_test(relative: str, name: str) -> bool:
|
|
909
|
+
# Only the file path classifies a TS/JS test. A name like `testConnection`,
|
|
910
|
+
# `testimonial`, or `shouldRetry` is a real function outside a test file, so a bare
|
|
911
|
+
# name prefix must not mark it a test (and drop it from the entry-point set).
|
|
912
|
+
path = Path(relative)
|
|
913
|
+
return "__tests__" in path.parts or ".test." in path.name or ".spec." in path.name
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
_INERT_STATEMENTS = {"empty_statement", "comment"}
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _branch_outcome(statements: list[Any]) -> str:
|
|
920
|
+
"""Classify how control leaves a branch body: one of common.BRANCH_OUTCOMES."""
|
|
921
|
+
meaningful = [stmt for stmt in statements if stmt.type not in _INERT_STATEMENTS]
|
|
922
|
+
if not meaningful:
|
|
923
|
+
return EMPTY
|
|
924
|
+
for stmt in meaningful:
|
|
925
|
+
if stmt.type == "return_statement":
|
|
926
|
+
return RETURNS
|
|
927
|
+
if stmt.type == "throw_statement":
|
|
928
|
+
return RAISES
|
|
929
|
+
if stmt.type == "continue_statement":
|
|
930
|
+
return CONTINUES
|
|
931
|
+
if stmt.type == "break_statement":
|
|
932
|
+
# break exits the enclosing loop/switch; control resumes after it.
|
|
933
|
+
return FALLS_THROUGH
|
|
934
|
+
if stmt.type == "try_statement":
|
|
935
|
+
try_outcome = _try_statement_outcome(stmt)
|
|
936
|
+
if _terminates(try_outcome):
|
|
937
|
+
return try_outcome
|
|
938
|
+
if stmt.type == "if_statement":
|
|
939
|
+
alternative = stmt.child_by_field_name("alternative")
|
|
940
|
+
if alternative is not None:
|
|
941
|
+
then_outcome = _branch_outcome(
|
|
942
|
+
_statement_children(stmt.child_by_field_name("consequence"))
|
|
943
|
+
)
|
|
944
|
+
else_outcome = _branch_outcome(_statement_children(alternative))
|
|
945
|
+
if _terminates(then_outcome) and _terminates(else_outcome):
|
|
946
|
+
return then_outcome if then_outcome == else_outcome else RETURNS
|
|
947
|
+
return FALLS_THROUGH
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _try_statement_outcome(statement: Any) -> str:
|
|
951
|
+
finalizer = statement.child_by_field_name("finalizer")
|
|
952
|
+
final_outcome = _branch_outcome(_statement_children(finalizer))
|
|
953
|
+
if _terminates(final_outcome):
|
|
954
|
+
return final_outcome
|
|
955
|
+
|
|
956
|
+
outcomes = [_branch_outcome(_statement_children(statement.child_by_field_name("body")))]
|
|
957
|
+
handler = statement.child_by_field_name("handler")
|
|
958
|
+
if handler is not None:
|
|
959
|
+
outcomes.append(_branch_outcome(_statement_children(handler)))
|
|
960
|
+
if outcomes and all(_terminates(outcome) for outcome in outcomes):
|
|
961
|
+
return outcomes[0] if all(outcome == outcomes[0] for outcome in outcomes) else RETURNS
|
|
962
|
+
return FALLS_THROUGH
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _case_falls_through(statements: list[Any]) -> bool:
|
|
966
|
+
"""Whether a switch case runs on into the next case.
|
|
967
|
+
|
|
968
|
+
A case leaves the switch only via an explicit break (to the post-switch join) or a
|
|
969
|
+
return/raise/continue (out of the function/loop). An empty case and a case that runs
|
|
970
|
+
off its end both fall through. Only straight-line terminators count, so a break or
|
|
971
|
+
return nested inside an `if` is not treated as an unconditional exit.
|
|
972
|
+
"""
|
|
973
|
+
for stmt in statements:
|
|
974
|
+
if stmt.type in _INERT_STATEMENTS:
|
|
975
|
+
continue
|
|
976
|
+
if stmt.type in (
|
|
977
|
+
"return_statement",
|
|
978
|
+
"throw_statement",
|
|
979
|
+
"continue_statement",
|
|
980
|
+
"break_statement",
|
|
981
|
+
):
|
|
982
|
+
return False
|
|
983
|
+
if stmt.type == "try_statement" and not _try_case_falls_through(stmt):
|
|
984
|
+
return False
|
|
985
|
+
if stmt.type == "if_statement":
|
|
986
|
+
alternative = stmt.child_by_field_name("alternative")
|
|
987
|
+
if alternative is not None:
|
|
988
|
+
then_falls_through = _case_falls_through(
|
|
989
|
+
_statement_children(stmt.child_by_field_name("consequence"))
|
|
990
|
+
)
|
|
991
|
+
else_falls_through = _case_falls_through(_statement_children(alternative))
|
|
992
|
+
if not then_falls_through and not else_falls_through:
|
|
993
|
+
return False
|
|
994
|
+
return True
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _try_case_falls_through(statement: Any) -> bool:
|
|
998
|
+
finalizer = statement.child_by_field_name("finalizer")
|
|
999
|
+
if finalizer is not None and not _case_falls_through(_statement_children(finalizer)):
|
|
1000
|
+
return False
|
|
1001
|
+
|
|
1002
|
+
body_falls_through = _case_falls_through(
|
|
1003
|
+
_statement_children(statement.child_by_field_name("body"))
|
|
1004
|
+
)
|
|
1005
|
+
handler = statement.child_by_field_name("handler")
|
|
1006
|
+
if handler is None:
|
|
1007
|
+
return body_falls_through
|
|
1008
|
+
return body_falls_through or _case_falls_through(_statement_children(handler))
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _terminates(outcome: str) -> bool:
|
|
1012
|
+
return outcome in {RETURNS, RAISES, CONTINUES}
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _strip_parentheses(value: str) -> str:
|
|
1016
|
+
value = value.strip()
|
|
1017
|
+
while value.startswith("(") and value.endswith(")"):
|
|
1018
|
+
value = value[1:-1].strip()
|
|
1019
|
+
return value
|