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,884 @@
1
+ """A profile-driven tree-sitter analyzer.
2
+
3
+ Most languages share the same control-flow shape (functions, ``if``, ``switch``/
4
+ ``match``, loops, ``return``, ``throw``/``raise``, ``try``/``catch``, calls). This module
5
+ runs that common walk once, parameterized by a :class:`LanguageProfile` that names the
6
+ grammar node types and supplies small per-language extractors. A new control-flow
7
+ language becomes a profile (see ``analysis/languages/``), not a bespoke analyzer.
8
+
9
+ It produces the same IR (flows, nodes, edges, ``branches``, decision identity, effects,
10
+ qualified calls) as the dedicated Python/TypeScript analyzers, so linking, rendering, and
11
+ agent navigation stay consistent.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable, Iterable
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from tree_sitter import Language, Parser
22
+
23
+ from codedebrief.analysis.common import (
24
+ CONTINUES,
25
+ EMPTY,
26
+ FALLS_THROUGH,
27
+ RAISES,
28
+ RETURNS,
29
+ SUCCESS,
30
+ SWITCH,
31
+ YES,
32
+ FlowBuilder,
33
+ PendingEdge,
34
+ annotate_reachability,
35
+ attach_qualified_calls,
36
+ branch,
37
+ call_is_boundary,
38
+ decision_identity,
39
+ decision_metadata,
40
+ dependency_paths_from_import_map,
41
+ domain_from_subject,
42
+ is_functional_condition,
43
+ require_tree_sitter_parse_ok,
44
+ tag_call_effects,
45
+ tree_sitter_parse_error,
46
+ value_namespace,
47
+ )
48
+ from codedebrief.analysis.common import DEFAULT as DEFAULT_LABEL
49
+ from codedebrief.analysis.common import NO as NO_LABEL
50
+ from codedebrief.config import CodeDebriefConfig
51
+ from codedebrief.model import Evidence, FileAnalysis, Flow, NodeKind, SourceLocation
52
+ from codedebrief.util import compact_text, file_sha256, relpath, stable_id
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class TSDefinition:
57
+ """One function/method to turn into a flow."""
58
+
59
+ name: str
60
+ node: Any
61
+ body: Any
62
+ owner: str = ""
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class LanguageProfile:
67
+ """The grammar vocabulary + extractors that make a language analyzable.
68
+
69
+ Defaults describe a typical C-family grammar; a profile overrides only what differs.
70
+ Callables keep the per-language bits (which functions are entry points, what a test
71
+ file looks like, how imports resolve) out of the generic walk.
72
+ """
73
+
74
+ language: str
75
+ grammar_loader: Callable[[], Any]
76
+ function_types: frozenset[str]
77
+ definitions: Callable[[Any, bytes, str, LanguageProfile], Iterable[TSDefinition]]
78
+ classify: Callable[[TSDefinition, str, str, CodeDebriefConfig], tuple[str, str, bool]]
79
+ is_test: Callable[[str, str], bool]
80
+ module_name: Callable[[str], str]
81
+ import_map: Callable[[Any, bytes, str], dict[str, str]] = lambda root, src, rel: {}
82
+ dependency_module_suffixes: tuple[str, ...] = ()
83
+ dependency_package_files: tuple[str, ...] = ()
84
+ dependency_package_directories: bool = False
85
+ dependency_path_filter: Callable[[str], bool] = lambda relative: True
86
+ entry_label: Callable[[Flow], str] | None = None
87
+ harvest_enums: Callable[[Any, bytes], dict[str, list[str]]] | None = None
88
+ # Node-type vocabulary (C-family defaults).
89
+ block_types: frozenset[str] = frozenset({"block"})
90
+ if_type: str = "if_statement"
91
+ condition_field: str = "condition"
92
+ consequence_field: str = "consequence"
93
+ alternative_field: str = "alternative"
94
+ # Else-branch node types when the else is a child rather than a field (Ruby).
95
+ alternative_types: frozenset[str] = frozenset()
96
+ switch_types: frozenset[str] = frozenset()
97
+ switch_value_field: str = "value"
98
+ switch_body_field: str | None = "body"
99
+ case_types: frozenset[str] = frozenset()
100
+ case_value_field: str = "value"
101
+ default_types: frozenset[str] = frozenset()
102
+ # A case with no value is the default (C: `default:` is a valueless case_statement).
103
+ default_when_no_value: bool = False
104
+ # Case values that mean "match anything" (a match `_` arm acts as the default).
105
+ wildcard_values: frozenset[str] = frozenset()
106
+ # The switch/match is compiler-exhaustive (e.g. Rust `match`): no explicit default does
107
+ # not mean an unhandled case, so it must not be flagged as a missing fallback.
108
+ exhaustive_switch: bool = False
109
+ # C-style fall-through: a case whose body does not break/return/raise/continue runs on
110
+ # into the next case (C/PHP/TS/JS/Java colon labels). Go/Ruby/Rust/Python implicitly
111
+ # terminate each case, so an empty body must NOT chain into the next case there.
112
+ case_fall_through: bool = False
113
+ loop_types: frozenset[str] = frozenset()
114
+ return_type: str = "return_statement"
115
+ return_keyword: str = "return"
116
+ throw_types: frozenset[str] = frozenset()
117
+ throw_keyword: str = "throw"
118
+ continue_types: frozenset[str] = frozenset({"continue_statement"})
119
+ break_types: frozenset[str] = frozenset({"break_statement"})
120
+ call_types: frozenset[str] = frozenset({"call_expression"})
121
+ call_function_field: str = "function"
122
+ call_name: Callable[[Any, bytes], str] | None = None
123
+ try_type: str | None = None
124
+ try_body_field: str = "body"
125
+ catch_types: frozenset[str] = frozenset()
126
+ catch_body_field: str = "body"
127
+ finally_types: frozenset[str] = frozenset()
128
+ # Override case extraction for grammars that don't fit the simple "case nodes with a
129
+ # value field" shape (e.g. Java's switch_block groups).
130
+ switch_cases: Callable[[Any, bytes, LanguageProfile], list[CaseInfo]] | None = None
131
+ assignment_types: frozenset[str] = frozenset()
132
+ assignment_target_field: str = "left"
133
+ nested_def_types: frozenset[str] = field(default_factory=frozenset)
134
+ inert_types: frozenset[str] = frozenset({"comment"})
135
+ # Wrapper statements unwrapped to their inner expression before dispatch (e.g. Rust
136
+ # wraps an if/match used as a statement in an expression_statement).
137
+ unwrap_types: frozenset[str] = frozenset()
138
+
139
+
140
+ @dataclass(slots=True)
141
+ class CaseInfo:
142
+ """One switch/case branch: its label, dispatched values, and body statements."""
143
+
144
+ label: str
145
+ is_default: bool
146
+ values: list[str]
147
+ body: list[Any]
148
+
149
+
150
+ class TreeSitterAnalyzer:
151
+ def __init__(self, root: Path, config: CodeDebriefConfig, profile: LanguageProfile) -> None:
152
+ self.root = root
153
+ self.config = config
154
+ self.profile = profile
155
+ self.parser = Parser(Language(profile.grammar_loader()))
156
+
157
+ def analyze(self, path: Path) -> FileAnalysis:
158
+ # Strip a leading UTF-8 BOM so a file an editor saved as UTF-8-with-BOM parses;
159
+ # the byte offsets the walk reports stay correct because the BOM is dropped
160
+ # before parsing (it is never part of a real token).
161
+ source = path.read_bytes().removeprefix(b"\xef\xbb\xbf")
162
+ relative = relpath(path, self.root)
163
+ tree = self.parser.parse(source)
164
+ parse_error = tree_sitter_parse_error(tree.root_node, relative, self.profile.language)
165
+ definitions = list(self.profile.definitions(tree.root_node, source, relative, self.profile))
166
+ if parse_error is not None and not definitions:
167
+ require_tree_sitter_parse_ok(tree.root_node, relative, self.profile.language)
168
+ flows = [self._analyze_definition(item, source, relative) for item in definitions]
169
+ if parse_error is not None:
170
+ for flow in flows:
171
+ flow.metadata["parse_error"] = parse_error
172
+ import_map = self.profile.import_map(tree.root_node, source, relative)
173
+ module_name = self.profile.module_name(relative)
174
+ dependencies = [
175
+ item
176
+ for item in dependency_paths_from_import_map(
177
+ import_map,
178
+ self.root,
179
+ module_suffixes=self.profile.dependency_module_suffixes,
180
+ package_files=self.profile.dependency_package_files,
181
+ package_directories=self.profile.dependency_package_directories,
182
+ include_path=self.profile.dependency_path_filter,
183
+ )
184
+ if item != relative
185
+ ]
186
+ for flow in flows:
187
+ attach_qualified_calls(flow, import_map, module_name)
188
+ tag_call_effects(flow)
189
+ harvest = self.profile.harvest_enums
190
+ enums = harvest(tree.root_node, source) if harvest else {}
191
+ return FileAnalysis(
192
+ path=relative,
193
+ language=self.profile.language,
194
+ sha256=file_sha256(path),
195
+ enums=enums,
196
+ dependencies=dependencies,
197
+ flows=flows,
198
+ )
199
+
200
+ def _analyze_definition(self, definition: TSDefinition, source: bytes, relative: str) -> Flow:
201
+ owner_prefix = f"{definition.owner}." if definition.owner else ""
202
+ qualified_name = f"{owner_prefix}{definition.name}"
203
+ symbol = f"{self.profile.module_name(relative)}:{qualified_name}"
204
+ framework, entry_kind, is_entrypoint = self.profile.classify(
205
+ definition, relative, source.decode("utf-8", "replace"), self.config
206
+ )
207
+ is_test = self.profile.is_test(relative, definition.name)
208
+ if is_test:
209
+ is_entrypoint = False
210
+ entry_kind = "test"
211
+
212
+ location = _location(relative, definition.node)
213
+ flow = Flow(
214
+ id=f"flow-{stable_id(symbol)}",
215
+ name=qualified_name,
216
+ symbol=symbol,
217
+ language=self.profile.language,
218
+ framework=framework,
219
+ entry_kind=entry_kind,
220
+ is_entrypoint=is_entrypoint,
221
+ location=location,
222
+ metadata={"test": is_test},
223
+ )
224
+ builder = FlowBuilder(flow)
225
+ entry = builder.add_node(
226
+ NodeKind.ENTRY, self._entry_label(flow), location, [], metadata={"symbol": symbol}
227
+ )
228
+ outgoing = self._walk_statements(
229
+ self._statement_children(definition.body),
230
+ [PendingEdge(entry.id)],
231
+ builder,
232
+ source,
233
+ relative,
234
+ )
235
+ if outgoing:
236
+ builder.add_node(
237
+ NodeKind.TERMINAL, "Complete", location, outgoing, evidence=Evidence.INFERRED
238
+ )
239
+ annotate_reachability(flow)
240
+ tag_call_effects(flow)
241
+ return flow
242
+
243
+ def _entry_label(self, flow: Flow) -> str:
244
+ if self.profile.entry_label is not None:
245
+ return self.profile.entry_label(flow)
246
+ return flow.name
247
+
248
+ def _walk_statements(
249
+ self,
250
+ statements: list[Any],
251
+ incoming: list[PendingEdge],
252
+ builder: FlowBuilder,
253
+ source: bytes,
254
+ relative: str,
255
+ ) -> list[PendingEdge]:
256
+ profile = self.profile
257
+ endpoints = incoming
258
+ for raw in statements:
259
+ if not endpoints:
260
+ break
261
+ statement = raw
262
+ if statement.type in profile.unwrap_types:
263
+ inner = next((c for c in statement.children if c.is_named), None)
264
+ if inner is not None:
265
+ statement = inner
266
+ node_type = statement.type
267
+ if node_type == profile.if_type:
268
+ endpoints = self._walk_if(statement, endpoints, builder, source, relative)
269
+ elif node_type in profile.switch_types:
270
+ endpoints = self._walk_switch(statement, endpoints, builder, source, relative)
271
+ elif profile.try_type is not None and node_type == profile.try_type:
272
+ endpoints = self._walk_try(statement, endpoints, builder, source, relative)
273
+ elif node_type in profile.loop_types:
274
+ endpoints = self._walk_loop(statement, endpoints, builder, source, relative)
275
+ elif node_type == profile.return_type:
276
+ endpoints = self._walk_return(statement, endpoints, builder, source, relative)
277
+ elif node_type in profile.throw_types:
278
+ value = _text(statement, source).removeprefix(profile.throw_keyword).strip(" ;")
279
+ builder.add_node(
280
+ NodeKind.ERROR,
281
+ f"Raise {value}".strip(),
282
+ _location(relative, statement),
283
+ endpoints,
284
+ detail=_text(statement, source),
285
+ )
286
+ endpoints = []
287
+ elif node_type in profile.break_types:
288
+ node = builder.add_node(
289
+ NodeKind.ACTION,
290
+ "Break loop",
291
+ _location(relative, statement),
292
+ endpoints,
293
+ detail=_text(statement, source),
294
+ metadata={"loop_control": "break"},
295
+ )
296
+ endpoints = [PendingEdge(node.id)]
297
+ elif node_type in profile.continue_types:
298
+ builder.add_node(
299
+ NodeKind.ACTION,
300
+ "Continue loop",
301
+ _location(relative, statement),
302
+ endpoints,
303
+ detail=_text(statement, source),
304
+ metadata={"loop_control": "continue"},
305
+ )
306
+ endpoints = []
307
+ elif node_type in profile.function_types or node_type in profile.nested_def_types:
308
+ continue
309
+ else:
310
+ kind, label, calls = self._statement_summary(statement, source)
311
+ node = builder.add_node(
312
+ kind,
313
+ label,
314
+ _location(relative, statement),
315
+ endpoints,
316
+ detail=_text(statement, source),
317
+ metadata={"calls": calls} if calls else {},
318
+ )
319
+ endpoints = [PendingEdge(node.id)]
320
+ return endpoints
321
+
322
+ def _walk_return(
323
+ self,
324
+ statement: Any,
325
+ incoming: list[PendingEdge],
326
+ builder: FlowBuilder,
327
+ source: bytes,
328
+ relative: str,
329
+ ) -> list[PendingEdge]:
330
+ value = _text(statement, source).removeprefix(self.profile.return_keyword).strip(" ;")
331
+ calls = self._calls_in(statement, source)
332
+ endpoints = incoming
333
+ if calls:
334
+ call_node = builder.add_node(
335
+ NodeKind.CALL,
336
+ f"Call {calls[0]}()",
337
+ _location(relative, statement),
338
+ endpoints,
339
+ detail=_text(statement, source),
340
+ metadata={"calls": calls},
341
+ )
342
+ endpoints = [PendingEdge(call_node.id)]
343
+ builder.add_node(
344
+ NodeKind.TERMINAL,
345
+ f"Return {value}".strip(),
346
+ _location(relative, statement),
347
+ endpoints,
348
+ detail=_text(statement, source),
349
+ )
350
+ return []
351
+
352
+ def _walk_loop(
353
+ self,
354
+ statement: Any,
355
+ incoming: list[PendingEdge],
356
+ builder: FlowBuilder,
357
+ source: bytes,
358
+ relative: str,
359
+ ) -> list[PendingEdge]:
360
+ body = self._loop_body(statement)
361
+ body_statements = self._statement_children(body)
362
+ node = builder.add_node(
363
+ NodeKind.ACTION,
364
+ _loop_label(statement, source),
365
+ _location(relative, statement),
366
+ incoming,
367
+ detail=_text(statement, source),
368
+ evidence=Evidence.INFERRED,
369
+ metadata={
370
+ "loop": True,
371
+ "body_outcome": self._branch_outcome(body_statements),
372
+ "has_else": False,
373
+ },
374
+ )
375
+ body_endpoints = self._walk_statements(
376
+ body_statements,
377
+ [PendingEdge(node.id, "Iteration")],
378
+ builder,
379
+ source,
380
+ relative,
381
+ )
382
+ return [PendingEdge(node.id, "Done"), *body_endpoints]
383
+
384
+ def _walk_if(
385
+ self,
386
+ statement: Any,
387
+ incoming: list[PendingEdge],
388
+ builder: FlowBuilder,
389
+ source: bytes,
390
+ relative: str,
391
+ ) -> list[PendingEdge]:
392
+ profile = self.profile
393
+ condition_node = statement.child_by_field_name(profile.condition_field)
394
+ consequence = statement.child_by_field_name(profile.consequence_field)
395
+ alternative = statement.child_by_field_name(profile.alternative_field)
396
+ if alternative is None and profile.alternative_types:
397
+ # Languages where the else branch is a child node, not a field (Ruby).
398
+ alternative = next(
399
+ (c for c in _named_children(statement) if c.type in profile.alternative_types), None
400
+ )
401
+ condition = _strip_parentheses(_text(condition_node, source))
402
+ branch_text = _text(consequence, source)
403
+
404
+ if not is_functional_condition(condition, branch_text):
405
+ node = builder.add_node(
406
+ NodeKind.ACTION,
407
+ f"Handle internal condition: {condition}",
408
+ _location(relative, statement),
409
+ incoming,
410
+ evidence=Evidence.INFERRED,
411
+ detail=_text(statement, source),
412
+ )
413
+ return [PendingEdge(node.id)]
414
+
415
+ node = builder.add_node(
416
+ NodeKind.DECISION,
417
+ condition,
418
+ _location(relative, condition_node or statement),
419
+ incoming,
420
+ detail=condition,
421
+ metadata=decision_metadata(condition),
422
+ )
423
+ node.metadata["branches"] = [
424
+ branch(YES, self._branch_outcome(self._statement_children(consequence))),
425
+ branch(
426
+ NO_LABEL,
427
+ self._branch_outcome(self._statement_children(alternative))
428
+ if alternative is not None
429
+ else FALLS_THROUGH,
430
+ implicit=alternative is None,
431
+ ),
432
+ ]
433
+ yes_endpoints = self._walk_statements(
434
+ self._statement_children(consequence),
435
+ [PendingEdge(node.id, YES)],
436
+ builder,
437
+ source,
438
+ relative,
439
+ )
440
+ if alternative is not None:
441
+ no_endpoints = self._walk_statements(
442
+ self._statement_children(alternative),
443
+ [PendingEdge(node.id, NO_LABEL)],
444
+ builder,
445
+ source,
446
+ relative,
447
+ )
448
+ else:
449
+ no_endpoints = [PendingEdge(node.id, NO_LABEL)]
450
+ return yes_endpoints + no_endpoints
451
+
452
+ def _walk_switch(
453
+ self,
454
+ statement: Any,
455
+ incoming: list[PendingEdge],
456
+ builder: FlowBuilder,
457
+ source: bytes,
458
+ relative: str,
459
+ ) -> list[PendingEdge]:
460
+ profile = self.profile
461
+ value_node = statement.child_by_field_name(profile.switch_value_field)
462
+ subject = _strip_parentheses(_text(value_node, source)) or "value"
463
+ node = builder.add_node(
464
+ NodeKind.DECISION,
465
+ f"Switch on {subject}",
466
+ _location(relative, statement),
467
+ incoming,
468
+ metadata=decision_identity(
469
+ condition=subject,
470
+ subject=subject,
471
+ operator=SWITCH,
472
+ domain=domain_from_subject(subject),
473
+ namespace="",
474
+ ),
475
+ )
476
+ cases = (
477
+ profile.switch_cases(statement, source, profile)
478
+ if profile.switch_cases
479
+ else (self._default_cases(statement, source))
480
+ )
481
+ endpoints: list[PendingEdge] = []
482
+ values: list[str] = []
483
+ has_default = False
484
+ branches: list[dict[str, Any]] = []
485
+ # C-style fall-through: when a case body neither breaks nor returns/raises and is
486
+ # NOT the last case, its endpoints chain into the NEXT case's body rather than
487
+ # onto the post-switch join. Without this, `case A: case B: return X` would dangle
488
+ # A's endpoint onto "Complete", fabricating a path the real switch never takes.
489
+ carried: list[PendingEdge] = []
490
+ for index, case in enumerate(cases):
491
+ if case.is_default:
492
+ label = DEFAULT_LABEL
493
+ has_default = True
494
+ else:
495
+ label = case.label
496
+ values.extend(case.values)
497
+ branches.append(branch(label, self._branch_outcome(case.body)))
498
+ case_endpoints = self._walk_statements(
499
+ case.body,
500
+ [PendingEdge(node.id, label), *carried],
501
+ builder,
502
+ source,
503
+ relative,
504
+ )
505
+ carried = []
506
+ if (
507
+ profile.case_fall_through
508
+ and index + 1 < len(cases)
509
+ and self._case_falls_through(case.body)
510
+ ):
511
+ carried = case_endpoints
512
+ else:
513
+ endpoints.extend(case_endpoints)
514
+ node.metadata["values"] = sorted(set(values))
515
+ node.metadata["value_namespace"] = value_namespace(sorted(set(values)))
516
+ if not has_default and not profile.exhaustive_switch:
517
+ branches.append(branch(DEFAULT_LABEL, FALLS_THROUGH, implicit=True))
518
+ endpoints.append(PendingEdge(node.id, DEFAULT_LABEL))
519
+ node.metadata["branches"] = branches
520
+ return endpoints
521
+
522
+ def _default_cases(self, statement: Any, source: bytes) -> list[CaseInfo]:
523
+ profile = self.profile
524
+ container = (
525
+ statement.child_by_field_name(profile.switch_body_field)
526
+ if profile.switch_body_field
527
+ else statement
528
+ )
529
+ cases: list[CaseInfo] = []
530
+ for case in _named_children(container):
531
+ case_value = case.child_by_field_name(profile.case_value_field)
532
+ body = self._case_body(case, case_value)
533
+ label = _text(case_value, source)
534
+ is_default = (
535
+ case.type in profile.default_types
536
+ or (profile.default_when_no_value and case_value is None)
537
+ or label in profile.wildcard_values
538
+ )
539
+ if is_default:
540
+ cases.append(CaseInfo(DEFAULT_LABEL, True, [], body))
541
+ elif case.type in profile.case_types:
542
+ # A multi-value case (`case A, B:` in Go) groups several values under one
543
+ # label; split them so each counts toward enum coverage individually.
544
+ values = _split_case_values(case_value, label, source)
545
+ cases.append(CaseInfo(label or "case", False, values, body))
546
+ return cases
547
+
548
+ def _case_body(self, case: Any, case_value: Any) -> list[Any]:
549
+ children = [
550
+ child
551
+ for child in _named_children(case)
552
+ if case_value is None
553
+ or child.start_byte != case_value.start_byte
554
+ or child.end_byte != case_value.end_byte
555
+ ]
556
+ flattened: list[Any] = []
557
+ for child in children:
558
+ if child.type in self.profile.block_types:
559
+ flattened.extend(_named_children(child))
560
+ else:
561
+ flattened.append(child)
562
+ return flattened
563
+
564
+ def _walk_try(
565
+ self,
566
+ statement: Any,
567
+ incoming: list[PendingEdge],
568
+ builder: FlowBuilder,
569
+ source: bytes,
570
+ relative: str,
571
+ ) -> list[PendingEdge]:
572
+ profile = self.profile
573
+ body = statement.child_by_field_name(profile.try_body_field)
574
+ node = builder.add_node(
575
+ NodeKind.DECISION,
576
+ "Operation succeeds?",
577
+ _location(relative, statement),
578
+ incoming,
579
+ evidence=Evidence.INFERRED,
580
+ detail=_text(statement, source),
581
+ metadata=decision_identity(
582
+ condition="exception boundary",
583
+ subject="exception",
584
+ operator="",
585
+ domain="error",
586
+ namespace="",
587
+ ),
588
+ )
589
+ branches: list[dict[str, Any]] = [
590
+ branch(SUCCESS, self._branch_outcome(self._statement_children(body)))
591
+ ]
592
+ endpoints = self._walk_statements(
593
+ self._statement_children(body),
594
+ [PendingEdge(node.id, SUCCESS)],
595
+ builder,
596
+ source,
597
+ relative,
598
+ )
599
+ for catch in (c for c in _named_children(statement) if c.type in profile.catch_types):
600
+ catch_body = self._statement_children(self._block_of(catch))
601
+ branches.append(branch("Error", self._branch_outcome(catch_body)))
602
+ endpoints.extend(
603
+ self._walk_statements(
604
+ catch_body, [PendingEdge(node.id, "Error")], builder, source, relative
605
+ )
606
+ )
607
+ node.metadata["branches"] = branches
608
+ finals = [c for c in _named_children(statement) if c.type in profile.finally_types]
609
+ if finals:
610
+ final_body = self._statement_children(self._block_of(finals[0]))
611
+ body_terminated = not endpoints
612
+ finally_incoming = endpoints or [PendingEdge(node.id, "finally")]
613
+ endpoints = self._walk_statements(
614
+ final_body, finally_incoming, builder, source, relative
615
+ )
616
+ if body_terminated:
617
+ endpoints = []
618
+ return endpoints
619
+
620
+ def _block_of(self, node: Any) -> Any:
621
+ body = node.child_by_field_name(self.profile.catch_body_field)
622
+ if body is not None:
623
+ return body
624
+ for child in _named_children(node):
625
+ if child.type in self.profile.block_types:
626
+ return child
627
+ return node
628
+
629
+ def _case_falls_through(self, statements: list[Any]) -> bool:
630
+ """Whether a C-style switch case runs on into the next case.
631
+
632
+ A case falls through unless it explicitly leaves the switch: a break exits to
633
+ the post-switch join, and a return/raise/continue leaves the function or loop.
634
+ An empty case (`case A: case B: ...`) and a case that simply runs off its end
635
+ both fall through. We require the *terminator to be reached on the straight-line
636
+ body*, so a break/return nested only inside an `if` does not count as an
637
+ unconditional exit (control can still fall through the else side).
638
+ """
639
+ profile = self.profile
640
+ for statement in statements:
641
+ if statement.type in profile.inert_types:
642
+ continue
643
+ if (
644
+ statement.type == profile.return_type
645
+ or statement.type in profile.throw_types
646
+ or statement.type in profile.continue_types
647
+ or statement.type in profile.break_types
648
+ ):
649
+ return False
650
+ if (
651
+ profile.try_type is not None
652
+ and statement.type == profile.try_type
653
+ and not self._try_case_falls_through(statement)
654
+ ):
655
+ return False
656
+ if statement.type == profile.if_type:
657
+ alternative = statement.child_by_field_name(profile.alternative_field)
658
+ if alternative is not None:
659
+ then_falls_through = self._case_falls_through(
660
+ self._statement_children(
661
+ statement.child_by_field_name(profile.consequence_field)
662
+ )
663
+ )
664
+ else_falls_through = self._case_falls_through(
665
+ self._statement_children(alternative)
666
+ )
667
+ if not then_falls_through and not else_falls_through:
668
+ return False
669
+ return True
670
+
671
+ def _try_case_falls_through(self, statement: Any) -> bool:
672
+ profile = self.profile
673
+ finals = [c for c in _named_children(statement) if c.type in profile.finally_types]
674
+ if finals and not self._case_falls_through(
675
+ self._statement_children(self._block_of(finals[0]))
676
+ ):
677
+ return False
678
+
679
+ body = statement.child_by_field_name(profile.try_body_field)
680
+ body_falls_through = self._case_falls_through(self._statement_children(body))
681
+ catches = [c for c in _named_children(statement) if c.type in profile.catch_types]
682
+ if not catches:
683
+ return body_falls_through
684
+ return body_falls_through or any(
685
+ self._case_falls_through(self._statement_children(self._block_of(catch)))
686
+ for catch in catches
687
+ )
688
+
689
+ def _branch_outcome(self, statements: list[Any]) -> str:
690
+ profile = self.profile
691
+ meaningful = [s for s in statements if s.type not in profile.inert_types]
692
+ if not meaningful:
693
+ return EMPTY
694
+ for statement in meaningful:
695
+ if statement.type == profile.return_type:
696
+ return RETURNS
697
+ if statement.type in profile.throw_types:
698
+ return RAISES
699
+ if statement.type in profile.continue_types:
700
+ return CONTINUES
701
+ if statement.type in profile.break_types:
702
+ return FALLS_THROUGH
703
+ if profile.try_type is not None and statement.type == profile.try_type:
704
+ try_outcome = self._try_statement_outcome(statement)
705
+ if _terminates(try_outcome):
706
+ return try_outcome
707
+ if statement.type == profile.if_type:
708
+ alternative = statement.child_by_field_name(profile.alternative_field)
709
+ if alternative is not None:
710
+ then_outcome = self._branch_outcome(
711
+ self._statement_children(
712
+ statement.child_by_field_name(profile.consequence_field)
713
+ )
714
+ )
715
+ else_outcome = self._branch_outcome(self._statement_children(alternative))
716
+ if _terminates(then_outcome) and _terminates(else_outcome):
717
+ return then_outcome if then_outcome == else_outcome else RETURNS
718
+ return FALLS_THROUGH
719
+
720
+ def _try_statement_outcome(self, statement: Any) -> str:
721
+ profile = self.profile
722
+ finals = [c for c in _named_children(statement) if c.type in profile.finally_types]
723
+ if finals:
724
+ final_outcome = self._branch_outcome(
725
+ self._statement_children(self._block_of(finals[0]))
726
+ )
727
+ if _terminates(final_outcome):
728
+ return final_outcome
729
+
730
+ body = statement.child_by_field_name(profile.try_body_field)
731
+ outcomes = [self._branch_outcome(self._statement_children(body))]
732
+ outcomes.extend(
733
+ self._branch_outcome(self._statement_children(self._block_of(catch)))
734
+ for catch in _named_children(statement)
735
+ if catch.type in profile.catch_types
736
+ )
737
+ if outcomes and all(_terminates(outcome) for outcome in outcomes):
738
+ return outcomes[0] if all(outcome == outcomes[0] for outcome in outcomes) else RETURNS
739
+ return FALLS_THROUGH
740
+
741
+ def _statement_summary(self, statement: Any, source: bytes) -> tuple[NodeKind, str, list[str]]:
742
+ calls = self._calls_in(statement, source)
743
+ boundary = next((item for item in calls if call_is_boundary(item)), "")
744
+ if boundary:
745
+ return NodeKind.CALL, f"Call {boundary}()", calls
746
+ if calls:
747
+ return NodeKind.CALL, f"Call {calls[0]}()", calls
748
+ if statement.type in self.profile.assignment_types:
749
+ target = _text(
750
+ statement.child_by_field_name(self.profile.assignment_target_field), source
751
+ )
752
+ if target:
753
+ return NodeKind.ACTION, f"Set {target}", []
754
+ return NodeKind.ACTION, compact_text(_text(statement, source).rstrip(";"), 90), []
755
+
756
+ def _calls_in(self, statement: Any, source: bytes) -> list[str]:
757
+ field_name = self.profile.call_function_field
758
+ extract = self.profile.call_name or (lambda call, src: _call_name(call, src, field_name))
759
+ names = [
760
+ extract(item, source)
761
+ for item in self._descendants(statement)
762
+ if item.type in self.profile.call_types
763
+ ]
764
+ return [name for name in names if name]
765
+
766
+ def _descendants(self, node: Any) -> Iterable[Any]:
767
+ breakers = self.profile.function_types | self.profile.nested_def_types
768
+ stack = [node]
769
+ while stack:
770
+ current = stack.pop()
771
+ yield current
772
+ if current is not node and current.type in breakers:
773
+ continue
774
+ stack.extend(reversed(current.children))
775
+
776
+ def _statement_children(self, node: Any | None) -> list[Any]:
777
+ if node is None:
778
+ return []
779
+ profile = self.profile
780
+ if node.type in profile.block_types:
781
+ return list(_named_children(node))
782
+ # A control-flow statement used directly as a branch body (an `else if`, where the
783
+ # alternative IS the nested if) must be dispatched by the walker, not flattened to
784
+ # one of its blocks - else the middle branch is silently dropped.
785
+ dispatchable = (
786
+ {profile.if_type, profile.return_type}
787
+ | profile.switch_types
788
+ | profile.loop_types
789
+ | profile.throw_types
790
+ )
791
+ if profile.try_type is not None:
792
+ dispatchable.add(profile.try_type)
793
+ if node.type in dispatchable:
794
+ return [node]
795
+ # A wrapper clause (else clause, then clause): descend into the block it holds.
796
+ blocks = [c for c in _named_children(node) if c.type in profile.block_types]
797
+ if blocks:
798
+ return list(_named_children(blocks[-1]))
799
+ return [node]
800
+
801
+ def _loop_body(self, statement: Any) -> Any | None:
802
+ body = statement.child_by_field_name("body")
803
+ if body is not None:
804
+ return body
805
+ blocks = [
806
+ child for child in _named_children(statement) if child.type in self.profile.block_types
807
+ ]
808
+ if blocks:
809
+ return blocks[-1]
810
+ named = list(_named_children(statement))
811
+ return named[-1] if named else None
812
+
813
+
814
+ def _named_children(node: Any | None) -> Iterable[Any]:
815
+ if node is None:
816
+ return []
817
+ return (child for child in node.children if child.is_named)
818
+
819
+
820
+ def _text(node: Any | None, source: bytes) -> str:
821
+ if node is None:
822
+ return ""
823
+ return compact_text(source[node.start_byte : node.end_byte].decode("utf-8", "replace"), 500)
824
+
825
+
826
+ def _location(relative: str, node: Any) -> SourceLocation:
827
+ return SourceLocation(relative, int(node.start_point.row) + 1, int(node.end_point.row) + 1)
828
+
829
+
830
+ def _loop_label(statement: Any, source: bytes) -> str:
831
+ header = _text(statement, source).split("{", 1)[0].strip()
832
+ return compact_text(f"Repeat: {header}", 100)
833
+
834
+
835
+ def _call_name(call: Any, source: bytes, function_field: str) -> str:
836
+ return _text(call.child_by_field_name(function_field), source)
837
+
838
+
839
+ def _strip_parentheses(value: str) -> str:
840
+ value = value.strip()
841
+ while value.startswith("(") and value.endswith(")"):
842
+ value = value[1:-1].strip()
843
+ return value
844
+
845
+
846
+ def _split_case_values(case_value: Any, label: str, source: bytes) -> list[str]:
847
+ """The individual values of a (possibly multi-value) case label.
848
+
849
+ A grammar that groups several values under one case (Go `case A, B:` parses to an
850
+ `expression_list` whose named children are the values) is split into its members.
851
+ Falls back to a top-level comma split of the label text (commas inside (), [], {}
852
+ are not boundaries, so a call/tuple value stays whole). A single-value case yields
853
+ just its label.
854
+ """
855
+ if case_value is None:
856
+ return []
857
+ members = [_text(child, source).strip() for child in case_value.children if child.is_named]
858
+ grammar_split = [text for text in members if text]
859
+ if len(grammar_split) >= 2:
860
+ return grammar_split
861
+ return [piece for piece in _split_top_level(label) if piece] or ([label] if label else [])
862
+
863
+
864
+ def _split_top_level(text: str) -> list[str]:
865
+ """Split on top-level commas, ignoring commas nested in (), [], or {}."""
866
+ parts: list[str] = []
867
+ depth = 0
868
+ current: list[str] = []
869
+ for char in text:
870
+ if char in "([{":
871
+ depth += 1
872
+ elif char in ")]}":
873
+ depth = max(0, depth - 1)
874
+ if char == "," and depth == 0:
875
+ parts.append("".join(current).strip())
876
+ current = []
877
+ else:
878
+ current.append(char)
879
+ parts.append("".join(current).strip())
880
+ return parts
881
+
882
+
883
+ def _terminates(outcome: str) -> bool:
884
+ return outcome in {RETURNS, RAISES, CONTINUES}