code-review-ai-cli 2.2.5__tar.gz → 2.2.6__tar.gz

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 (32) hide show
  1. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/PKG-INFO +3 -4
  2. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/code_review_ai_cli.egg-info/PKG-INFO +3 -4
  3. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/code_review_ai_cli.egg-info/requires.txt +2 -4
  4. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/pyproject.toml +3 -3
  5. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/config.py +2 -2
  6. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/context_extractor.py +96 -190
  7. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/git_utils.py +23 -11
  8. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/llm_client.py +77 -76
  9. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/prompts/config.yaml.template +3 -3
  10. code_review_ai_cli-2.2.6/src/prompts/review_prompt.md.template +21 -0
  11. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/tfs_client.py +9 -13
  12. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/tests/test_context_extractor.py +94 -88
  13. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/tests/test_git_utils.py +26 -0
  14. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/tests/test_llm_client.py +87 -17
  15. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/tests/test_tfs_client.py +36 -5
  16. code_review_ai_cli-2.2.5/src/prompts/review_prompt.md.template +0 -85
  17. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/README.md +0 -0
  18. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/code_review_ai_cli.egg-info/SOURCES.txt +0 -0
  19. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/code_review_ai_cli.egg-info/dependency_links.txt +0 -0
  20. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/code_review_ai_cli.egg-info/entry_points.txt +0 -0
  21. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/code_review_ai_cli.egg-info/top_level.txt +0 -0
  22. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/setup.cfg +0 -0
  23. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/__init__.py +0 -0
  24. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/ai_review.py +0 -0
  25. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/formatter.py +0 -0
  26. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/prompt_utils.py +0 -0
  27. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/src/token_estimator.py +0 -0
  28. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/tests/test_ai_review.py +0 -0
  29. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/tests/test_config.py +0 -0
  30. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/tests/test_formatter.py +0 -0
  31. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/tests/test_prompt_utils.py +0 -0
  32. {code_review_ai_cli-2.2.5 → code_review_ai_cli-2.2.6}/tests/test_token_estimator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-review-ai-cli
3
- Version: 2.2.5
3
+ Version: 2.2.6
4
4
  Summary: Automated AI-powered code review CLI for Azure DevOps / TFS Pull Requests
5
5
  License: MIT
6
6
  Keywords: code-review,ai,azure-devops,tfs,pull-request,llm
@@ -19,6 +19,8 @@ Requires-Dist: requests>=2.28.0
19
19
  Requires-Dist: PyYAML>=6.0
20
20
  Requires-Dist: litellm>=1.40.0
21
21
  Requires-Dist: tiktoken>=0.7.0
22
+ Requires-Dist: tree-sitter<0.22,>=0.20
23
+ Requires-Dist: tree-sitter-languages>=1.10.2
22
24
  Provides-Extra: bedrock
23
25
  Requires-Dist: boto3>=1.34.0; extra == "bedrock"
24
26
  Provides-Extra: openai
@@ -27,8 +29,6 @@ Provides-Extra: gemini
27
29
  Requires-Dist: google-generativeai>=0.3.0; extra == "gemini"
28
30
  Provides-Extra: claude
29
31
  Requires-Dist: anthropic>=0.18.0; extra == "claude"
30
- Provides-Extra: tree-sitter
31
- Requires-Dist: tree-sitter-languages>=1.10.2; extra == "tree-sitter"
32
32
  Provides-Extra: all
33
33
  Requires-Dist: boto3>=1.34.0; extra == "all"
34
34
  Requires-Dist: litellm>=1.40.0; extra == "all"
@@ -36,7 +36,6 @@ Requires-Dist: tiktoken>=0.7.0; extra == "all"
36
36
  Requires-Dist: openai>=1.0.0; extra == "all"
37
37
  Requires-Dist: google-generativeai>=0.3.0; extra == "all"
38
38
  Requires-Dist: anthropic>=0.18.0; extra == "all"
39
- Requires-Dist: tree-sitter-languages>=1.10.2; extra == "all"
40
39
  Provides-Extra: dev
41
40
  Requires-Dist: pytest>=8.3.0; extra == "dev"
42
41
  Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-review-ai-cli
3
- Version: 2.2.5
3
+ Version: 2.2.6
4
4
  Summary: Automated AI-powered code review CLI for Azure DevOps / TFS Pull Requests
5
5
  License: MIT
6
6
  Keywords: code-review,ai,azure-devops,tfs,pull-request,llm
@@ -19,6 +19,8 @@ Requires-Dist: requests>=2.28.0
19
19
  Requires-Dist: PyYAML>=6.0
20
20
  Requires-Dist: litellm>=1.40.0
21
21
  Requires-Dist: tiktoken>=0.7.0
22
+ Requires-Dist: tree-sitter<0.22,>=0.20
23
+ Requires-Dist: tree-sitter-languages>=1.10.2
22
24
  Provides-Extra: bedrock
23
25
  Requires-Dist: boto3>=1.34.0; extra == "bedrock"
24
26
  Provides-Extra: openai
@@ -27,8 +29,6 @@ Provides-Extra: gemini
27
29
  Requires-Dist: google-generativeai>=0.3.0; extra == "gemini"
28
30
  Provides-Extra: claude
29
31
  Requires-Dist: anthropic>=0.18.0; extra == "claude"
30
- Provides-Extra: tree-sitter
31
- Requires-Dist: tree-sitter-languages>=1.10.2; extra == "tree-sitter"
32
32
  Provides-Extra: all
33
33
  Requires-Dist: boto3>=1.34.0; extra == "all"
34
34
  Requires-Dist: litellm>=1.40.0; extra == "all"
@@ -36,7 +36,6 @@ Requires-Dist: tiktoken>=0.7.0; extra == "all"
36
36
  Requires-Dist: openai>=1.0.0; extra == "all"
37
37
  Requires-Dist: google-generativeai>=0.3.0; extra == "all"
38
38
  Requires-Dist: anthropic>=0.18.0; extra == "all"
39
- Requires-Dist: tree-sitter-languages>=1.10.2; extra == "all"
40
39
  Provides-Extra: dev
41
40
  Requires-Dist: pytest>=8.3.0; extra == "dev"
42
41
  Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
@@ -2,6 +2,8 @@ requests>=2.28.0
2
2
  PyYAML>=6.0
3
3
  litellm>=1.40.0
4
4
  tiktoken>=0.7.0
5
+ tree-sitter<0.22,>=0.20
6
+ tree-sitter-languages>=1.10.2
5
7
 
6
8
  [all]
7
9
  boto3>=1.34.0
@@ -10,7 +12,6 @@ tiktoken>=0.7.0
10
12
  openai>=1.0.0
11
13
  google-generativeai>=0.3.0
12
14
  anthropic>=0.18.0
13
- tree-sitter-languages>=1.10.2
14
15
 
15
16
  [bedrock]
16
17
  boto3>=1.34.0
@@ -28,6 +29,3 @@ google-generativeai>=0.3.0
28
29
 
29
30
  [openai]
30
31
  openai>=1.0.0
31
-
32
- [tree-sitter]
33
- tree-sitter-languages>=1.10.2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "code-review-ai-cli"
7
- version = "2.2.5"
7
+ version = "2.2.6"
8
8
  description = "Automated AI-powered code review CLI for Azure DevOps / TFS Pull Requests"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -26,6 +26,8 @@ dependencies = [
26
26
  "PyYAML>=6.0",
27
27
  "litellm>=1.40.0",
28
28
  "tiktoken>=0.7.0",
29
+ "tree-sitter>=0.20,<0.22",
30
+ "tree-sitter-languages>=1.10.2",
29
31
  ]
30
32
 
31
33
  [project.optional-dependencies]
@@ -33,7 +35,6 @@ bedrock = ["boto3>=1.34.0"]
33
35
  openai = ["openai>=1.0.0"]
34
36
  gemini = ["google-generativeai>=0.3.0"]
35
37
  claude = ["anthropic>=0.18.0"]
36
- tree-sitter = ["tree-sitter-languages>=1.10.2"]
37
38
  all = [
38
39
  "boto3>=1.34.0",
39
40
  "litellm>=1.40.0",
@@ -41,7 +42,6 @@ all = [
41
42
  "openai>=1.0.0",
42
43
  "google-generativeai>=0.3.0",
43
44
  "anthropic>=0.18.0",
44
- "tree-sitter-languages>=1.10.2",
45
45
  ]
46
46
  dev = [
47
47
  "pytest>=8.3.0",
@@ -92,8 +92,8 @@ class ReviewConfig:
92
92
  review_scope: str = "diff_only" # "diff_only" | "full_code"
93
93
  max_diff_files: int = 50 # Max diff files sent to LLM
94
94
  max_diff_lines: int = 2000 # Max diff lines
95
- max_scope_blocks: int = 3 # Max changed blocks for Enclosing Scopes strategy
96
- max_scope_lines: int = 250 # Max lines in a single scope block before fallback to file_skeleton
95
+ max_scope_blocks: int = 8 # Max changed blocks for Enclosing Scopes strategy
96
+ max_scope_lines: int = 250 # Max lines in a scope before Adaptive Diff fallback
97
97
  adaptive_context_lines: int = 10 # Context lines for Adaptive Diff fallback
98
98
  custom_prompt_file: str = "review_prompt.md" # Markdown file with extra rules/context
99
99
  file_extensions_filter: list = field(default_factory=list) # File extensions to include (empty = all)
@@ -7,20 +7,19 @@ Context Extractor Module - AI Code Review
7
7
  Provides AST-based context extraction for diff payloads sent to the LLM.
8
8
 
9
9
  Strategy selection per file:
10
- - SQL files → full SQL statement containing the changed line(s)
11
- - ≤ ``MAX_SCOPE_BLOCKS`` changed logical blocks → Enclosing Scopes (Scenario A)
12
- - > ``MAX_SCOPE_BLOCKS`` changed logical blocks → File Skeleton (Scenario B)
13
- - Tree-sitter unavailable / language unsupported → Adaptive Diff fallback
10
+ - AST scopes within the configured limits → Enclosing Scopes
11
+ - Tree-sitter unavailable, unsupported language, parse failure, or scopes
12
+ outside the configured limits → Adaptive Diff fallback
14
13
 
15
14
  Output is always wrapped in XML tags so the LLM can distinguish code context
16
15
  from the actual diff:
17
16
 
18
17
  ``<enclosing_scopes file="src/auth.py">...</enclosing_scopes>``
19
- ``<file_skeleton file="src/module.py">...</file_skeleton>``
20
18
  ``<adaptive_diff file="src/other.py">...</adaptive_diff>``
21
19
 
22
- Tree-sitter is an *optional* dependency. If ``tree-sitter-languages`` is not
23
- installed the module automatically falls back to :func:`_build_adaptive_diff`.
20
+ Tree-sitter is installed as a core dependency. The availability probe remains
21
+ defensive: reduced or broken environments fall back to
22
+ :func:`_build_adaptive_diff` instead of failing the review.
24
23
  """
25
24
 
26
25
  from __future__ import annotations
@@ -68,13 +67,12 @@ except Exception: # pragma: no cover — optional dependency
68
67
  # Constants
69
68
  # ---------------------------------------------------------------------------
70
69
 
71
- #: Maximum number of distinct logical blocks that triggers Scenario A
72
- #: (Enclosing Scopes). When more blocks are detected Scenario B (File
73
- #: Skeleton) is used instead.
74
- MAX_SCOPE_BLOCKS: int = 3
70
+ #: Maximum number of distinct logical blocks included as Enclosing Scopes.
71
+ #: When more blocks are detected the extractor uses Adaptive Diff instead.
72
+ MAX_SCOPE_BLOCKS: int = 8
75
73
 
76
74
  #: Maximum number of lines in a single enclosing scope. When a scope
77
- #: exceeds this limit the strategy falls back to File Skeleton to avoid
75
+ #: exceeds this limit the strategy falls back to Adaptive Diff to avoid
78
76
  #: sending large class bodies to the LLM.
79
77
  MAX_SCOPE_LINES: int = 250
80
78
 
@@ -370,147 +368,76 @@ def _extract_enclosing_scopes_ts(
370
368
  return scopes
371
369
 
372
370
 
373
- def _format_scope_block(scope: ScopeInfo) -> str:
374
- """Render a :class:`ScopeInfo` with absolute 1-based line numbers."""
375
- lines: list[str] = []
376
- for offset, src_line in enumerate(scope.source_lines):
377
- abs_line = scope.start_line + offset
378
- lines.append(f"{abs_line:4d}: {src_line}")
379
- return "\n".join(lines)
380
-
371
+ def _non_whitespace_changed_lines(content: str, changed_lines: list[int]) -> list[int]:
372
+ """Return changed lines that contain meaningful code in the new file.
381
373
 
382
- # ---------------------------------------------------------------------------
383
- # File Skeleton helpers
384
- # ---------------------------------------------------------------------------
385
-
386
- #: Regex patterns used to identify structural lines in each language.
387
- _SKELETON_PATTERNS: dict[str, list[str]] = {
388
- "python": [
389
- r"^\s*(?:import|from)\s+", # imports
390
- r"^\s*(?:def|async\s+def|class)\s+", # definitions
391
- r"^\s*@\w", # decorators
392
- ],
393
- "javascript": [
394
- r"^\s*(?:import|export|require)\s*",
395
- r"^\s*(?:function|class|const|let|var)\s+\w+\s*(?:=\s*(?:function|\(|\()|\()",
396
- r"^\s*(?:async\s+function|export\s+(?:default\s+)?(?:function|class))",
397
- ],
398
- "typescript": [
399
- r"^\s*(?:import|export|require)\s*",
400
- r"^\s*(?:function|class|const|let|var|interface|type|enum|abstract)\s+\w+",
401
- r"^\s*(?:async\s+function|export\s+(?:default\s+)?(?:function|class|interface))",
402
- ],
403
- "tsx": [
404
- r"^\s*(?:import|export|require)\s*",
405
- r"^\s*(?:function|class|const|let|var|interface|type|enum|abstract)\s+\w+",
406
- ],
407
- "c_sharp": [
408
- r"^\s*(?:using)\s+",
409
- r"^\s*(?:namespace|public|private|protected|internal|static|sealed|abstract|partial|override|virtual)\s+",
410
- r"^\s*(?:\[[\w\.\(\)\"]+\])", # attributes
411
- ],
412
- "java": [
413
- r"^\s*(?:import|package)\s+",
414
- r"^\s*(?:public|private|protected|static|final|abstract|synchronized|@)\s*",
415
- ],
416
- "sql": [], # SQL skeleton falls back to individual statements
417
- }
418
-
419
- # Body-start patterns (lines after which we can insert "...")
420
- _BODY_START: dict[str, list[str]] = {
421
- "python": [r":\s*$"],
422
- "javascript": [r"\{\s*$"],
423
- "typescript": [r"\{\s*$"],
424
- "tsx": [r"\{\s*$"],
425
- "c_sharp": [r"\{\s*$"],
426
- "java": [r"\{\s*$"],
427
- "sql": [],
428
- }
429
-
430
-
431
- def _build_file_skeleton(
432
- content: str,
433
- changed_lines: list[int],
434
- lang: str,
435
- ) -> str:
436
- """Build a file skeleton: structural lines + changed blocks + ``...`` placeholders.
437
-
438
- The skeleton includes:
439
- - All structural/declaration lines (imports, signatures, class headers)
440
- - Full content of lines inside changed regions (± ADAPTIVE_CONTEXT_LINES)
441
- - ``...`` for bodies of unchanged functions/methods
442
-
443
- Args:
444
- content: Full source file content.
445
- changed_lines: 1-based line numbers that were changed.
446
- lang: Tree-sitter language name for pattern selection.
447
-
448
- Returns:
449
- Formatted skeleton string with 1-based line numbers.
374
+ Whitespace-only additions must not influence AST scope selection. For
375
+ example, a blank line immediately after a method belongs to its containing
376
+ class and would otherwise cause the entire class to be selected.
450
377
  """
451
378
  all_lines = content.splitlines()
452
- total = len(all_lines)
453
-
454
- if not all_lines:
455
- return ""
456
-
457
- # Build set of "always include" lines (context around changes)
458
- always: set[int] = set()
459
- for ln in changed_lines:
460
- lo = max(1, ln - ADAPTIVE_CONTEXT_LINES)
461
- hi = min(total, ln + ADAPTIVE_CONTEXT_LINES)
462
- always.update(range(lo, hi + 1))
463
-
464
- # Structural patterns for this language
465
- struct_patterns = [re.compile(p) for p in _SKELETON_PATTERNS.get(lang, [])]
466
-
467
- result_lines: list[str] = []
468
- skipping = False
469
- prev_shown: Optional[int] = None
470
-
471
- for idx, src in enumerate(all_lines, start=1):
472
- is_structural = any(p.search(src) for p in struct_patterns)
473
- in_changed = idx in always
474
-
475
- if in_changed or is_structural:
476
- if skipping and prev_shown is not None:
477
- result_lines.append(" ...")
478
- skipping = False
479
- result_lines.append(f"{idx:4d}: {src}")
480
- prev_shown = idx
481
- else:
482
- skipping = True
483
-
484
- if skipping:
485
- result_lines.append(" ...")
379
+ return [
380
+ line_number
381
+ for line_number in changed_lines
382
+ if 1 <= line_number <= len(all_lines)
383
+ and all_lines[line_number - 1].strip()
384
+ ]
486
385
 
487
- return "\n".join(result_lines)
488
386
 
489
-
490
- # ---------------------------------------------------------------------------
491
- # SQL-specific extraction
492
- # ---------------------------------------------------------------------------
493
-
494
-
495
- def _extract_sql_statement(
496
- content: str,
387
+ def _remove_redundant_ancestor_scopes(
388
+ scopes: list[ScopeInfo],
497
389
  changed_lines: list[int],
498
- lang: str,
499
390
  ) -> list[ScopeInfo]:
500
- """Find the SQL statement(s) covering each changed line via Tree-sitter.
391
+ """Drop ancestor scopes whose changed lines are covered by child scopes.
501
392
 
502
- Args:
503
- content: Full SQL file content.
504
- changed_lines: 1-based line numbers that were changed.
505
- lang: Always ``"sql"`` for this path.
393
+ An ancestor is retained when it contains at least one meaningful changed
394
+ line outside every selected descendant. This avoids duplicate class and
395
+ method bodies without losing class-level changes.
396
+ """
397
+ retained: list[ScopeInfo] = []
398
+
399
+ for scope in scopes:
400
+ descendants = [
401
+ candidate
402
+ for candidate in scopes
403
+ if candidate is not scope
404
+ and scope.start_line <= candidate.start_line
405
+ and candidate.end_line <= scope.end_line
406
+ and (
407
+ scope.start_line < candidate.start_line
408
+ or candidate.end_line < scope.end_line
409
+ )
410
+ ]
411
+ scope_changes = [
412
+ line_number
413
+ for line_number in changed_lines
414
+ if scope.start_line <= line_number <= scope.end_line
415
+ ]
416
+ changes_covered_by_descendants = (
417
+ bool(scope_changes)
418
+ and bool(descendants)
419
+ and all(
420
+ any(
421
+ child.start_line <= line_number <= child.end_line
422
+ for child in descendants
423
+ )
424
+ for line_number in scope_changes
425
+ )
426
+ )
506
427
 
507
- Returns:
508
- List of :class:`ScopeInfo` for each affected SQL statement.
428
+ if not changes_covered_by_descendants:
429
+ retained.append(scope)
509
430
 
510
- Raises:
511
- Exception: On Tree-sitter parse failure (caller activates fallback).
512
- """
513
- return _extract_enclosing_scopes_ts(content, changed_lines, lang)
431
+ return retained
432
+
433
+
434
+ def _format_scope_block(scope: ScopeInfo) -> str:
435
+ """Render a :class:`ScopeInfo` with absolute 1-based line numbers."""
436
+ lines: list[str] = []
437
+ for offset, src_line in enumerate(scope.source_lines):
438
+ abs_line = scope.start_line + offset
439
+ lines.append(f"{abs_line:4d}: {src_line}")
440
+ return "\n".join(lines)
514
441
 
515
442
 
516
443
  # ---------------------------------------------------------------------------
@@ -533,9 +460,10 @@ class ContextExtractor:
533
460
  The returned *xml_block* is ready to be embedded in the LLM user message.
534
461
 
535
462
  Attributes:
536
- max_scope_blocks: Threshold for Scenario A vs Scenario B selection.
463
+ max_scope_blocks: Maximum number of AST scopes before falling back to
464
+ adaptive_diff.
537
465
  max_scope_lines: Maximum lines per scope block before falling back to
538
- file_skeleton (avoids sending entire large classes to the LLM).
466
+ adaptive_diff (avoids sending entire large classes to the LLM).
539
467
  adaptive_context_lines: Lines of context for the fallback strategy.
540
468
  """
541
469
 
@@ -565,9 +493,8 @@ class ContextExtractor:
565
493
 
566
494
  1. If Tree-sitter is unavailable → ``adaptive_diff``
567
495
  2. If the language is not supported → ``adaptive_diff``
568
- 3. If the language is SQL → ``sql_statement`` (falls back on failure)
569
- 4. If ≤ ``max_scope_blocks`` distinct scopes found → ``enclosing_scopes``
570
- 5. Otherwise → ``file_skeleton``
496
+ 3. If the extracted scopes fit the configured limits → ``enclosing_scopes``
497
+ 4. Otherwise → ``adaptive_diff``
571
498
 
572
499
  Args:
573
500
  file_path: Path (or name) of the file being reviewed.
@@ -576,9 +503,8 @@ class ContextExtractor:
576
503
 
577
504
  Returns:
578
505
  A tuple ``(strategy, xml_block)`` where *strategy* is one of
579
- ``"enclosing_scopes"``, ``"file_skeleton"``, ``"sql_statement"``,
580
- or ``"adaptive_diff"``, and *xml_block* is the XML-tagged string
581
- ready for the LLM payload.
506
+ ``"enclosing_scopes"`` or ``"adaptive_diff"``, and *xml_block*
507
+ is the XML-tagged string ready for the LLM payload.
582
508
  """
583
509
  if not changed_lines:
584
510
  return "adaptive_diff", ""
@@ -592,24 +518,18 @@ class ContextExtractor:
592
518
  return self._adaptive(file_path, new_content, changed_lines)
593
519
 
594
520
  # ------------------------------------------------------------------
595
- # SQL path
521
+ # Generic AST path
596
522
  # ------------------------------------------------------------------
597
- if lang == "sql":
598
- try:
599
- scopes = _extract_sql_statement(new_content, changed_lines, lang)
600
- if scopes:
601
- return self._format_enclosing_scopes(
602
- file_path, scopes, strategy="sql_statement"
603
- )
604
- except Exception:
605
- pass
523
+ ast_changed_lines = _non_whitespace_changed_lines(new_content, changed_lines)
524
+ if not ast_changed_lines:
606
525
  return self._adaptive(file_path, new_content, changed_lines)
607
526
 
608
- # ------------------------------------------------------------------
609
- # Generic AST path
610
- # ------------------------------------------------------------------
611
527
  try:
612
- scopes = _extract_enclosing_scopes_ts(new_content, changed_lines, lang)
528
+ scopes = _extract_enclosing_scopes_ts(
529
+ new_content,
530
+ ast_changed_lines,
531
+ lang,
532
+ )
613
533
  except Exception:
614
534
  return self._adaptive(file_path, new_content, changed_lines)
615
535
 
@@ -617,29 +537,17 @@ class ContextExtractor:
617
537
  # No enclosing scope found (top-level code) → adaptive diff
618
538
  return self._adaptive(file_path, new_content, changed_lines)
619
539
 
620
- if len(scopes) <= self.max_scope_blocks:
621
- # Check if any single scope is too large — if so, fall back to
622
- # file_skeleton to avoid sending a 900-line class body to the LLM.
623
- oversized = any(
624
- (s.end_line - s.start_line) > self.max_scope_lines
625
- for s in scopes
626
- )
627
- if not oversized:
628
- # Scenario A: Enclosing Scopes
629
- return self._format_enclosing_scopes(file_path, scopes)
540
+ scopes = _remove_redundant_ancestor_scopes(scopes, ast_changed_lines)
630
541
 
631
- # Scenario B: File Skeleton
632
- try:
633
- skeleton = _build_file_skeleton(new_content, changed_lines, lang)
634
- except Exception:
542
+ too_many_scopes = len(scopes) > self.max_scope_blocks
543
+ oversized_scope = any(
544
+ (s.end_line - s.start_line + 1) > self.max_scope_lines
545
+ for s in scopes
546
+ )
547
+ if too_many_scopes or oversized_scope:
635
548
  return self._adaptive(file_path, new_content, changed_lines)
636
549
 
637
- xml = (
638
- f'<file_skeleton file="{file_path}">\n'
639
- f"{skeleton}\n"
640
- f"</file_skeleton>"
641
- )
642
- return "file_skeleton", xml
550
+ return self._format_enclosing_scopes(file_path, scopes)
643
551
 
644
552
  # ------------------------------------------------------------------
645
553
  # Internal formatters
@@ -666,7 +574,6 @@ class ContextExtractor:
666
574
  self,
667
575
  file_path: str,
668
576
  scopes: list[ScopeInfo],
669
- strategy: str = "enclosing_scopes",
670
577
  ) -> tuple[str, str]:
671
578
  """Render a list of :class:`ScopeInfo` into an XML block."""
672
579
  parts: list[str] = []
@@ -679,13 +586,12 @@ class ContextExtractor:
679
586
  parts.append(_format_scope_block(scope))
680
587
 
681
588
  body = "\n".join(parts)
682
- tag = "enclosing_scopes" if strategy == "enclosing_scopes" else strategy
683
589
  xml = (
684
- f'<{tag} file="{file_path}">\n'
590
+ f'<enclosing_scopes file="{file_path}">\n'
685
591
  f"{body}\n"
686
- f"</{tag}>"
592
+ f"</enclosing_scopes>"
687
593
  )
688
- return strategy, xml
594
+ return "enclosing_scopes", xml
689
595
 
690
596
  # ------------------------------------------------------------------
691
597
  # Utility: extract changed lines from a diff hunk for one file
@@ -100,7 +100,7 @@ class GitUtils:
100
100
  - ``@@ ... @@``
101
101
  - ``+ <content>`` (added lines)
102
102
  - `` <content>`` (context lines — unchanged surrounding code)
103
- - Lines inside XML context blocks (``<enclosing_scopes>``, ``<file_skeleton>``, etc.)
103
+ - Lines inside XML context blocks (``<enclosing_scopes>`` or ``<adaptive_diff>``)
104
104
 
105
105
  Lines removed:
106
106
  - ``- <content>`` (deleted lines)
@@ -114,8 +114,8 @@ class GitUtils:
114
114
  """
115
115
  result = []
116
116
  in_xml_block = False
117
- _xml_start_tags = ("<enclosing_scopes", "<file_skeleton", "<adaptive_diff", "<sql_statement")
118
- _xml_end_tags = ("</enclosing_scopes>", "</file_skeleton>", "</adaptive_diff>", "</sql_statement>")
117
+ _xml_start_tags = ("<enclosing_scopes", "<adaptive_diff")
118
+ _xml_end_tags = ("</enclosing_scopes>", "</adaptive_diff>")
119
119
 
120
120
  for line in diff.split("\n"):
121
121
  if any(line.startswith(tag) for tag in _xml_start_tags):
@@ -204,7 +204,7 @@ class GitUtils:
204
204
 
205
205
  def truncate_diff_per_file(self, diff: str, max_lines: int = 2000) -> tuple[str, bool]:
206
206
  """
207
- Truncates the diff per file if it exceeds the maximum lines per section.
207
+ Truncates diff lines per file while preserving complete XML context blocks.
208
208
  Falls back to global truncation if no file sections are present.
209
209
 
210
210
  Returns:
@@ -227,18 +227,30 @@ class GitUtils:
227
227
  truncated_any = False
228
228
  output_sections: list[str] = []
229
229
  for section in sections:
230
- if len(section) <= max_lines:
230
+ xml_start = next(
231
+ (
232
+ index
233
+ for index, line in enumerate(section)
234
+ if line.startswith(("<enclosing_scopes", "<adaptive_diff"))
235
+ ),
236
+ len(section),
237
+ )
238
+ diff_lines = section[:xml_start]
239
+ context_lines = section[xml_start:]
240
+
241
+ if len(diff_lines) <= max_lines:
231
242
  output_sections.append("\n".join(section))
232
243
  continue
233
244
 
234
245
  truncated_any = True
235
- omitted = len(section) - max_lines
236
- part = "\n".join(section[:max_lines])
237
- part += (
238
- f"\n... [TRUNCATED IN THIS FILE: {omitted} lines omitted. "
239
- f"Original section: {len(section)} lines] ..."
246
+ omitted = len(diff_lines) - max_lines
247
+ truncated_section = diff_lines[:max_lines]
248
+ truncated_section.append(
249
+ f"... [TRUNCATED IN THIS FILE: {omitted} diff lines omitted. "
250
+ f"Original diff section: {len(diff_lines)} lines] ..."
240
251
  )
241
- output_sections.append(part)
252
+ truncated_section.extend(context_lines)
253
+ output_sections.append("\n".join(truncated_section))
242
254
 
243
255
  return "\n".join(output_sections), truncated_any
244
256