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.
Files changed (48) hide show
  1. codedebrief/__init__.py +12 -0
  2. codedebrief/analysis/__init__.py +16 -0
  3. codedebrief/analysis/common.py +527 -0
  4. codedebrief/analysis/discovery.py +100 -0
  5. codedebrief/analysis/languages/__init__.py +6 -0
  6. codedebrief/analysis/languages/_common.py +68 -0
  7. codedebrief/analysis/languages/c.py +96 -0
  8. codedebrief/analysis/languages/cpp.py +146 -0
  9. codedebrief/analysis/languages/csharp.py +137 -0
  10. codedebrief/analysis/languages/go.py +157 -0
  11. codedebrief/analysis/languages/java.py +158 -0
  12. codedebrief/analysis/languages/php.py +83 -0
  13. codedebrief/analysis/languages/ruby.py +75 -0
  14. codedebrief/analysis/languages/rust.py +96 -0
  15. codedebrief/analysis/project.py +373 -0
  16. codedebrief/analysis/python.py +939 -0
  17. codedebrief/analysis/registry.py +320 -0
  18. codedebrief/analysis/treesitter.py +884 -0
  19. codedebrief/analysis/typescript.py +1019 -0
  20. codedebrief/artifacts.py +49 -0
  21. codedebrief/cli.py +585 -0
  22. codedebrief/config.py +226 -0
  23. codedebrief/doctor.py +175 -0
  24. codedebrief/install.py +441 -0
  25. codedebrief/mcp_server.py +2720 -0
  26. codedebrief/model.py +189 -0
  27. codedebrief/py.typed +1 -0
  28. codedebrief/quality.py +392 -0
  29. codedebrief/query.py +641 -0
  30. codedebrief/render/__init__.py +6 -0
  31. codedebrief/render/assets/generated/codedebrief-viewer-runtime.iife.js +10 -0
  32. codedebrief/render/assets/panels.js +462 -0
  33. codedebrief/render/assets/shell.js +1649 -0
  34. codedebrief/render/assets/styles.css +1715 -0
  35. codedebrief/render/assets/tree.js +616 -0
  36. codedebrief/render/html.py +191 -0
  37. codedebrief/render/markdown.py +153 -0
  38. codedebrief/render/payload.py +326 -0
  39. codedebrief/render/snapshot.py +769 -0
  40. codedebrief/schema/codedebrief.schema.json +449 -0
  41. codedebrief/util.py +65 -0
  42. codedebrief/validation.py +214 -0
  43. codedebrief-0.11.0.dist-info/METADATA +426 -0
  44. codedebrief-0.11.0.dist-info/RECORD +48 -0
  45. codedebrief-0.11.0.dist-info/WHEEL +4 -0
  46. codedebrief-0.11.0.dist-info/entry_points.txt +2 -0
  47. codedebrief-0.11.0.dist-info/licenses/LICENSE +176 -0
  48. 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