sourcecode 1.35.25__py3-none-any.whl → 1.35.27__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.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.35.25"
3
+ __version__ = "1.35.27"
sourcecode/cli.py CHANGED
@@ -242,6 +242,10 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
242
242
  "explain",
243
243
  # Spring Boot 2→3 migration readiness
244
244
  "migrate-check",
245
+ # Native file rename (BLOCKER-A)
246
+ "rename-class",
247
+ # Large file semantic chunking (BLOCKER-B)
248
+ "chunk-file",
245
249
  }
246
250
  )
247
251
 
@@ -5009,6 +5013,240 @@ def modernize_cmd(
5009
5013
  _copy_to_clipboard(output)
5010
5014
 
5011
5015
 
5016
+ # ── rename-class ──────────────────────────────────────────────────────────────
5017
+
5018
+ @app.command("rename-class")
5019
+ def rename_class_cmd(
5020
+ path: Path = typer.Argument(
5021
+ Path("."),
5022
+ help="Repository root to operate on (default: current directory)",
5023
+ ),
5024
+ old_name: str = typer.Option(
5025
+ ..., "--from", "-f",
5026
+ help="Current class name (PascalCase, e.g. ServiceA)",
5027
+ ),
5028
+ new_name: str = typer.Option(
5029
+ ..., "--to", "-t",
5030
+ help="New class name (PascalCase, e.g. ServiceB)",
5031
+ ),
5032
+ dry_run: bool = typer.Option(
5033
+ False, "--dry-run",
5034
+ help="Compute changes but do not write any files or rename on disk.",
5035
+ ),
5036
+ no_tests: bool = typer.Option(
5037
+ False, "--no-tests",
5038
+ help="Exclude test files from the rename (src/main only).",
5039
+ ),
5040
+ output_path: Optional[Path] = typer.Option(
5041
+ None, "--output", "-o",
5042
+ help="Write change audit JSON to a file instead of stdout.",
5043
+ ),
5044
+ copy: bool = typer.Option(
5045
+ False, "--copy", "-c",
5046
+ help="Copy output to clipboard after a successful run.",
5047
+ ),
5048
+ format: str = typer.Option(
5049
+ "json", "--format",
5050
+ help="Output format: json (default) or yaml.",
5051
+ ),
5052
+ ) -> None:
5053
+ """Rename a Java class throughout the repository (BLOCKER-A fix).
5054
+
5055
+ \b
5056
+ Renames a Java class safely:
5057
+ - Updates class/interface/enum declaration
5058
+ - Updates constructor name
5059
+ - Updates all import statements
5060
+ - Updates all type references (fields, params, return types)
5061
+ - Updates extends / implements
5062
+ - Updates generics, casts, Spring @Qualifier names
5063
+ - Renames the physical .java file
5064
+ - Emits a structured change audit trail (BLOCKER-C)
5065
+
5066
+ \b
5067
+ Examples:
5068
+ sourcecode rename-class . --from ServiceA --to ServiceB
5069
+ sourcecode rename-class /path/to/repo --from OrderManager --to OrderService
5070
+ sourcecode rename-class . --from OldName --to NewName --dry-run
5071
+ sourcecode rename-class . --from OldName --to NewName --output rename-audit.json
5072
+ """
5073
+ import json as _json
5074
+ from sourcecode.rename_refactor import rename_class
5075
+
5076
+ root = path.resolve()
5077
+ if not root.is_dir():
5078
+ _emit_error_json(
5079
+ INVALID_INPUT_CODE,
5080
+ f"'{root}' is not a valid directory.",
5081
+ path=str(root),
5082
+ hint="Pass an existing repository directory.",
5083
+ expected="A directory path.",
5084
+ )
5085
+ raise typer.Exit(1)
5086
+
5087
+ result = rename_class(
5088
+ root,
5089
+ old_name,
5090
+ new_name,
5091
+ dry_run=dry_run,
5092
+ include_tests=not no_tests,
5093
+ )
5094
+
5095
+ if result.errors:
5096
+ _emit_error_json(
5097
+ "RENAME_ERROR",
5098
+ result.errors[0],
5099
+ errors=result.errors,
5100
+ old_name=old_name,
5101
+ new_name=new_name,
5102
+ )
5103
+ raise typer.Exit(1)
5104
+
5105
+ result_dict = result.to_dict()
5106
+
5107
+ if format == "yaml":
5108
+ from sourcecode.serializer import to_yaml as _to_yaml
5109
+ output = _to_yaml(result_dict)
5110
+ else:
5111
+ output = _json.dumps(result_dict, indent=2, ensure_ascii=False)
5112
+
5113
+ if output_path:
5114
+ output_path.write_text(output, encoding="utf-8")
5115
+ action = "dry-run simulated" if dry_run else "applied"
5116
+ typer.echo(
5117
+ f"[rename-class] {action}: {old_name} → {new_name} "
5118
+ f"({result.files_modified} file(s) changed). "
5119
+ f"Audit written to {output_path}",
5120
+ err=True,
5121
+ )
5122
+ else:
5123
+ try:
5124
+ sys.stdout.buffer.write(output.encode("utf-8"))
5125
+ sys.stdout.buffer.write(b"\n")
5126
+ sys.stdout.buffer.flush()
5127
+ except AttributeError:
5128
+ sys.stdout.write(output + "\n")
5129
+
5130
+ if copy:
5131
+ _copy_to_clipboard(output)
5132
+
5133
+ if not dry_run and not output_path:
5134
+ action = "Renamed"
5135
+ typer.echo(
5136
+ f"[rename-class] {action}: {old_name} → {new_name} "
5137
+ f"({result.files_modified} file(s) updated, file renamed to {result.new_file})",
5138
+ err=True,
5139
+ )
5140
+
5141
+
5142
+ # ── chunk-file ────────────────────────────────────────────────────────────────
5143
+
5144
+ @app.command("chunk-file")
5145
+ def chunk_file_cmd(
5146
+ file: Path = typer.Argument(
5147
+ ...,
5148
+ help="Java file to chunk (absolute or relative path)",
5149
+ ),
5150
+ max_lines: int = typer.Option(
5151
+ 500, "--max-lines", "-n",
5152
+ help="Target max lines per chunk (default: 500). Methods > max_lines emit size_warning.",
5153
+ ),
5154
+ chunk_id: Optional[int] = typer.Option(
5155
+ None, "--chunk", "-c",
5156
+ help="Return only this chunk by ID (1-based). Omit to return all chunks.",
5157
+ ),
5158
+ metadata_only: bool = typer.Option(
5159
+ False, "--metadata-only",
5160
+ help="Return chunk boundaries and metadata without file content.",
5161
+ ),
5162
+ output_path: Optional[Path] = typer.Option(
5163
+ None, "--output", "-o",
5164
+ help="Write output to a file instead of stdout.",
5165
+ ),
5166
+ format: str = typer.Option(
5167
+ "json", "--format",
5168
+ help="Output format: json (default) or yaml.",
5169
+ ),
5170
+ copy: bool = typer.Option(
5171
+ False, "--copy",
5172
+ help="Copy output to clipboard after a successful run.",
5173
+ ),
5174
+ ) -> None:
5175
+ """Split a large Java file into semantic chunks for AI agent consumption (BLOCKER-B fix).
5176
+
5177
+ \b
5178
+ Splits a Java file at method/class boundaries so AI agents can read
5179
+ large files (10K–25K+ lines) in context-sized pieces without timeout
5180
+ or fragmented analysis.
5181
+
5182
+ Each chunk includes:
5183
+ - chunk_id, start_line, end_line, chunk_type, symbol name
5184
+ - context_header: package + class + imports summary
5185
+ - content: source lines for that chunk
5186
+ - size_warning: True if chunk > max_lines (cannot split mid-method)
5187
+
5188
+ \b
5189
+ Examples:
5190
+ sourcecode chunk-file NominasCalculoService.java
5191
+ sourcecode chunk-file BigService.java --max-lines 300
5192
+ sourcecode chunk-file BigService.java --chunk 5 # read chunk 5 only
5193
+ sourcecode chunk-file BigService.java --metadata-only # sizes/boundaries only
5194
+ """
5195
+ import json as _json
5196
+ from sourcecode.file_chunker import chunk_java_file
5197
+
5198
+ abs_file = file.resolve()
5199
+ if not abs_file.is_file():
5200
+ _emit_error_json(
5201
+ INVALID_INPUT_CODE,
5202
+ f"'{abs_file}' is not a valid file.",
5203
+ path=str(abs_file),
5204
+ hint="Pass an existing Java source file.",
5205
+ expected="A .java file path.",
5206
+ )
5207
+ raise typer.Exit(1)
5208
+
5209
+ result = chunk_java_file(abs_file, max_lines=max_lines, include_content=not metadata_only)
5210
+
5211
+ if chunk_id is not None:
5212
+ # Return single chunk
5213
+ matching = [c for c in result.chunks if c.chunk_id == chunk_id]
5214
+ if not matching:
5215
+ _emit_error_json(
5216
+ INVALID_INPUT_CODE,
5217
+ f"Chunk {chunk_id} not found. File has {result.total_chunks} chunks.",
5218
+ chunk_id=chunk_id,
5219
+ total_chunks=result.total_chunks,
5220
+ )
5221
+ raise typer.Exit(1)
5222
+ result_dict = matching[0].to_dict()
5223
+ else:
5224
+ result_dict = result.to_dict()
5225
+
5226
+ if format == "yaml":
5227
+ from sourcecode.serializer import to_yaml as _to_yaml
5228
+ output = _to_yaml(result_dict)
5229
+ else:
5230
+ output = _json.dumps(result_dict, indent=2, ensure_ascii=False)
5231
+
5232
+ if output_path:
5233
+ output_path.write_text(output, encoding="utf-8")
5234
+ typer.echo(
5235
+ f"[chunk-file] {result.total_chunks} chunks written to {output_path}",
5236
+ err=True,
5237
+ )
5238
+ else:
5239
+ try:
5240
+ sys.stdout.buffer.write(output.encode("utf-8"))
5241
+ sys.stdout.buffer.write(b"\n")
5242
+ sys.stdout.buffer.flush()
5243
+ except AttributeError:
5244
+ sys.stdout.write(output + "\n")
5245
+
5246
+ if copy:
5247
+ _copy_to_clipboard(output)
5248
+
5249
+
5012
5250
  # ── version ───────────────────────────────────────────────────────────────────
5013
5251
 
5014
5252
  @app.command("activate")
@@ -0,0 +1,397 @@
1
+ """file_chunker.py — Semantic chunking of large Java files for AI agent consumption.
2
+
3
+ Splits a Java source file into context-aware chunks at method/class boundaries.
4
+ Each chunk includes a context header so an AI agent can understand it without
5
+ reading prior chunks. Handles files of any size without timeout.
6
+
7
+ Usage:
8
+ chunks = chunk_java_file(path, max_lines=500)
9
+ # Each chunk: ChunkRecord with id, type, symbol, start_line, end_line, content
10
+
11
+ Design:
12
+ - Primary split at method/constructor boundaries (brace depth == class depth + 1)
13
+ - Secondary: class-level fields grouped together in a preamble chunk
14
+ - Context header: package + class name + imports summary prepended to each chunk
15
+ - Chunks never split mid-method: a method > max_lines is emitted as a single chunk
16
+ with a size warning in metadata
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Data classes
28
+ # ---------------------------------------------------------------------------
29
+
30
+ @dataclass
31
+ class ChunkRecord:
32
+ """A semantic chunk of a Java source file."""
33
+ chunk_id: int # 1-based sequential index
34
+ chunk_type: str # "class_header" | "method" | "constructor" | "field_block" | "class_footer"
35
+ symbol: str # e.g. "MyService#processOrder" or "MyService" for class_header
36
+ start_line: int # 1-based inclusive
37
+ end_line: int # 1-based inclusive
38
+ content: str # source lines for this chunk
39
+ context_header: str # package + class context prepended for AI consumption
40
+ size_warning: bool = False # True if chunk exceeds max_lines (cannot split further)
41
+
42
+ @property
43
+ def total_lines(self) -> int:
44
+ return self.end_line - self.start_line + 1
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ "chunk_id": self.chunk_id,
49
+ "chunk_type": self.chunk_type,
50
+ "symbol": self.symbol,
51
+ "start_line": self.start_line,
52
+ "end_line": self.end_line,
53
+ "total_lines": self.total_lines,
54
+ "context_header": self.context_header,
55
+ "content": self.content,
56
+ "size_warning": self.size_warning,
57
+ }
58
+
59
+
60
+ @dataclass
61
+ class ChunkResult:
62
+ """Result of chunking a Java file."""
63
+ file: str # relative or absolute path
64
+ total_lines: int
65
+ total_chunks: int
66
+ class_name: str
67
+ package: str
68
+ chunk_count_by_type: dict[str, int] = field(default_factory=dict)
69
+ chunks: list[ChunkRecord] = field(default_factory=list)
70
+ limitations: list[str] = field(default_factory=list)
71
+
72
+ def to_dict(self) -> dict:
73
+ return {
74
+ "file": self.file,
75
+ "total_lines": self.total_lines,
76
+ "total_chunks": self.total_chunks,
77
+ "class_name": self.class_name,
78
+ "package": self.package,
79
+ "chunk_count_by_type": self.chunk_count_by_type,
80
+ "limitations": self.limitations,
81
+ "chunks": [c.to_dict() for c in self.chunks],
82
+ }
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Regexes
87
+ # ---------------------------------------------------------------------------
88
+
89
+ _PKG_RE = re.compile(r'^\s*package\s+([\w.]+)\s*;')
90
+ _IMPORT_RE = re.compile(r'^\s*import\s+(?:static\s+)?([\w.*]+)\s*;')
91
+ _CLASS_RE = re.compile(
92
+ r'^\s*(?:(?:public|protected|private|abstract|final|sealed|non-sealed)\s+)*'
93
+ r'(?:class|interface|enum|@interface)\s+(\w+)'
94
+ )
95
+ _METHOD_RE = re.compile(
96
+ r'^\s*(?:(?:public|protected|private|static|final|synchronized|abstract|native|default|override)\s+)*'
97
+ r'(?:<[^>]+>\s+)?' # optional generic return type
98
+ r'(?:[\w<>\[\],?\s]+\s+)?' # return type (optional for constructors)
99
+ r'(\w+)\s*\(' # method/constructor name + opening paren
100
+ )
101
+ _FIELD_RE = re.compile(
102
+ r'^\s*(?:(?:public|protected|private|static|final|volatile|transient)\s+)+'
103
+ r'[\w<>\[\].,? ]+\s+\w+\s*(?:=|;)'
104
+ )
105
+ _ANN_RE = re.compile(r'^\s*@')
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Internal helpers
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def _count_braces(line: str) -> tuple[int, int]:
113
+ """Return (open_count, close_count) for non-string/comment braces in line."""
114
+ open_c = 0
115
+ close_c = 0
116
+ in_str = False
117
+ in_char = False
118
+ escape = False
119
+ i = 0
120
+ while i < len(line):
121
+ ch = line[i]
122
+ if escape:
123
+ escape = False
124
+ elif ch == '\\':
125
+ escape = True
126
+ elif ch == '"' and not in_char:
127
+ in_str = not in_str
128
+ elif ch == "'" and not in_str:
129
+ in_char = not in_char
130
+ elif not in_str and not in_char:
131
+ if ch == '{':
132
+ open_c += 1
133
+ elif ch == '}':
134
+ close_c += 1
135
+ elif ch == '/' and i + 1 < len(line) and line[i+1] == '/':
136
+ break # rest is line comment
137
+ i += 1
138
+ return open_c, close_c
139
+
140
+
141
+ def _build_context_header(
142
+ package: str,
143
+ class_name: str,
144
+ import_lines: list[str],
145
+ max_imports: int = 10,
146
+ ) -> str:
147
+ """Build a context header showing package + class + condensed imports."""
148
+ lines = []
149
+ if package:
150
+ lines.append(f"// File context: package {package};")
151
+ lines.append(f"// Enclosing class: {class_name}")
152
+ if import_lines:
153
+ shown = import_lines[:max_imports]
154
+ lines.extend(shown)
155
+ if len(import_lines) > max_imports:
156
+ lines.append(f"// ... ({len(import_lines) - max_imports} more imports omitted)")
157
+ lines.append("") # blank separator
158
+ return "\n".join(lines)
159
+
160
+
161
+ def _is_method_or_constructor_start(
162
+ stripped: str,
163
+ class_name: str,
164
+ depth: int,
165
+ class_depth: int,
166
+ ) -> tuple[bool, str, str]:
167
+ """Return (is_method, method_name, chunk_type) if line starts a method/constructor."""
168
+ if depth != class_depth + 1:
169
+ return False, "", ""
170
+
171
+ # Skip annotations
172
+ if stripped.startswith("@"):
173
+ return False, "", ""
174
+ # Skip field declarations (end with ; or = before ;)
175
+ if _FIELD_RE.match(stripped) and "{" not in stripped:
176
+ return False, "", ""
177
+ # Skip class/interface/enum declarations
178
+ if _CLASS_RE.match(stripped):
179
+ return False, "", ""
180
+
181
+ m = _METHOD_RE.match(stripped)
182
+ if not m:
183
+ return False, "", ""
184
+ name = m.group(1)
185
+ # Distinguish constructor: name matches class name
186
+ chunk_type = "constructor" if name == class_name else "method"
187
+ return True, name, chunk_type
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Public API
192
+ # ---------------------------------------------------------------------------
193
+
194
+ def chunk_java_file(
195
+ path: Path,
196
+ *,
197
+ max_lines: int = 500,
198
+ include_content: bool = True,
199
+ ) -> ChunkResult:
200
+ """Split a Java file into semantic chunks at method/class boundaries.
201
+
202
+ Args:
203
+ path: Path to the Java file.
204
+ max_lines: Target max lines per chunk. Methods exceeding this
205
+ are emitted as a single chunk with size_warning=True.
206
+ include_content: If False, content field is omitted (metadata-only mode).
207
+
208
+ Returns:
209
+ ChunkResult with ordered list of ChunkRecord entries.
210
+ """
211
+ try:
212
+ source = path.read_text(encoding="utf-8", errors="replace")
213
+ except OSError as e:
214
+ return ChunkResult(
215
+ file=str(path),
216
+ total_lines=0,
217
+ total_chunks=0,
218
+ class_name="",
219
+ package="",
220
+ limitations=[f"Could not read file: {e}"],
221
+ )
222
+
223
+ all_lines = source.splitlines()
224
+ total_lines = len(all_lines)
225
+
226
+ # ── Pass 1: extract package, class name, imports ──────────────────────
227
+ package = ""
228
+ class_name = ""
229
+ import_lines: list[str] = []
230
+ for raw_line in all_lines:
231
+ stripped = raw_line.strip()
232
+ if not package:
233
+ pm = _PKG_RE.match(raw_line)
234
+ if pm:
235
+ package = pm.group(1)
236
+ im = _IMPORT_RE.match(raw_line)
237
+ if im:
238
+ import_lines.append(raw_line.rstrip())
239
+ if not class_name:
240
+ cm = _CLASS_RE.match(raw_line)
241
+ if cm:
242
+ class_name = cm.group(1)
243
+
244
+ context_header = _build_context_header(package, class_name, import_lines)
245
+
246
+ # ── Pass 2: track brace depth, identify method/class boundaries ───────
247
+ depth = 0
248
+ class_depth = -1 # depth at which the primary class body starts
249
+ chunks: list[ChunkRecord] = []
250
+ chunk_id = 0
251
+ limitations: list[str] = []
252
+
253
+ # "pending" block: lines accumulated for current chunk
254
+ pending_start: int = 1
255
+ pending_lines: list[str] = []
256
+ pending_type: str = "class_header"
257
+ pending_symbol: str = class_name or "unknown"
258
+
259
+ # Annotation buffer for upcoming method/constructor
260
+ ann_buffer: list[tuple[int, str]] = [] # (line_no 1-based, raw_line)
261
+
262
+ def _flush_chunk(end_line: int) -> None:
263
+ nonlocal chunk_id, pending_start, pending_lines, pending_type, pending_symbol
264
+ if not pending_lines:
265
+ return
266
+ chunk_id += 1
267
+ content = "\n".join(pending_lines) if include_content else ""
268
+ size_warn = len(pending_lines) > max_lines
269
+ if size_warn:
270
+ limitations.append(
271
+ f"Chunk {chunk_id} ({pending_symbol}) has {len(pending_lines)} lines "
272
+ f"(exceeds max_lines={max_lines}) — cannot split mid-method."
273
+ )
274
+ chunks.append(ChunkRecord(
275
+ chunk_id=chunk_id,
276
+ chunk_type=pending_type,
277
+ symbol=pending_symbol,
278
+ start_line=pending_start,
279
+ end_line=end_line,
280
+ content=content,
281
+ context_header=context_header,
282
+ size_warning=size_warn,
283
+ ))
284
+ pending_lines = []
285
+ pending_start = end_line + 1
286
+
287
+ in_block_comment = False
288
+ current_method_name = ""
289
+ current_method_type = ""
290
+ method_brace_start_depth = -1
291
+
292
+ for line_no_0, raw_line in enumerate(all_lines):
293
+ line_no = line_no_0 + 1 # 1-based
294
+ stripped = raw_line.strip()
295
+
296
+ # Block comment tracking
297
+ if in_block_comment:
298
+ pending_lines.append(raw_line)
299
+ if "*/" in stripped:
300
+ in_block_comment = False
301
+ continue
302
+ if "/*" in stripped and "*/" not in stripped:
303
+ in_block_comment = True
304
+ pending_lines.append(raw_line)
305
+ continue
306
+
307
+ # Track brace depth
308
+ opens, closes = _count_braces(raw_line)
309
+
310
+ # Detect class body start (first '{' after class declaration)
311
+ if class_depth < 0 and class_name and _CLASS_RE.match(raw_line):
312
+ class_depth = depth # depth BEFORE the '{' on this line
313
+ if opens > 0:
314
+ class_depth = depth # class body starts after this line
315
+
316
+ # Check if this line starts a method/constructor AT class_depth+1
317
+ if class_depth >= 0 and depth == class_depth + 1 and not current_method_name:
318
+ is_method, mname, mtype = _is_method_or_constructor_start(
319
+ stripped, class_name, depth, class_depth
320
+ )
321
+ if is_method and "{" in raw_line:
322
+ # Flush anything accumulated as field_block / class_header
323
+ # Include annotation lines in the new method chunk
324
+ if pending_lines:
325
+ # Check if last N pending lines are annotations for this method
326
+ # Flush everything up to ann_buffer start
327
+ if ann_buffer:
328
+ ann_start_line = ann_buffer[0][0]
329
+ pre_ann_lines = pending_lines[:ann_start_line - pending_start]
330
+ if pre_ann_lines:
331
+ _flush_chunk(ann_start_line - 1)
332
+ # Move ann_buffer lines into the new method chunk
333
+ pending_start = ann_start_line
334
+ pending_lines = [al for _, al in ann_buffer]
335
+ ann_buffer = []
336
+ else:
337
+ _flush_chunk(line_no - 1)
338
+ pending_start = line_no
339
+ pending_lines = []
340
+
341
+ current_method_name = mname
342
+ current_method_type = mtype
343
+ method_brace_start_depth = depth + opens - 1 # depth entering method body
344
+ pending_type = mtype
345
+ pending_symbol = f"{class_name}#{mname}" if class_name else mname
346
+ pending_lines.append(raw_line)
347
+ depth += opens - closes
348
+ ann_buffer = []
349
+ continue
350
+
351
+ # Update depth
352
+ depth += opens - closes
353
+
354
+ # After depth update: check if current method closed
355
+ if current_method_name and depth <= class_depth + 1:
356
+ # Method body closed
357
+ pending_lines.append(raw_line)
358
+ _flush_chunk(line_no)
359
+ current_method_name = ""
360
+ current_method_type = ""
361
+ # Next chunk is field_block until next method
362
+ pending_type = "field_block"
363
+ pending_symbol = class_name or "unknown"
364
+ pending_start = line_no + 1
365
+ ann_buffer = []
366
+ continue
367
+
368
+ # Track annotations at class level (buffered to attach to next method)
369
+ if (class_depth >= 0 and depth == class_depth + 1
370
+ and not current_method_name and stripped.startswith("@")):
371
+ ann_buffer.append((line_no, raw_line))
372
+ elif not stripped.startswith("@"):
373
+ # Non-annotation line: clear annotation buffer if we're not entering a method
374
+ if ann_buffer and not (class_depth >= 0 and depth == class_depth + 1):
375
+ ann_buffer = []
376
+
377
+ pending_lines.append(raw_line)
378
+
379
+ # Flush remaining lines as class_footer
380
+ if pending_lines:
381
+ pending_type = "class_footer" if depth <= (class_depth if class_depth >= 0 else 0) else pending_type
382
+ _flush_chunk(total_lines)
383
+
384
+ chunk_count_by_type: dict[str, int] = {}
385
+ for c in chunks:
386
+ chunk_count_by_type[c.chunk_type] = chunk_count_by_type.get(c.chunk_type, 0) + 1
387
+
388
+ return ChunkResult(
389
+ file=str(path),
390
+ total_lines=total_lines,
391
+ total_chunks=len(chunks),
392
+ class_name=class_name,
393
+ package=package,
394
+ chunk_count_by_type=chunk_count_by_type,
395
+ chunks=chunks,
396
+ limitations=limitations,
397
+ )
@@ -868,9 +868,34 @@ def _find_build_files(root: Path) -> list[tuple[Path, str]]:
868
868
  return results
869
869
 
870
870
 
871
+ def _resolve_maven_properties(text: str) -> str:
872
+ """Substitute ${prop} references with values from the <properties> block.
873
+
874
+ Handles single-level property references that appear in the same pom.xml.
875
+ Multi-level references (${a} where a=${b}) are resolved up to 3 passes.
876
+ """
877
+ props: dict[str, str] = {}
878
+ for m in re.finditer(r'<([A-Za-z][\w.\-]*)>\s*([^<${}]+?)\s*</\1>', text):
879
+ props[m.group(1)] = m.group(2).strip()
880
+ if not props:
881
+ return text
882
+
883
+ resolved = text
884
+ for _ in range(3):
885
+ def _sub(m: re.Match) -> str: # noqa: E306
886
+ return props.get(m.group(1), m.group(0))
887
+ resolved_new = re.sub(r'\$\{([\w.\-]+)\}', _sub, resolved)
888
+ if resolved_new == resolved:
889
+ break
890
+ resolved = resolved_new
891
+ return resolved
892
+
893
+
871
894
  def _scan_dep_file(text: str, rel_path: str) -> list["MigrationFinding"]:
872
895
  """Apply dependency rules to a build file. Returns one finding per matched rule."""
873
896
  is_gradle = rel_path.endswith((".gradle", ".gradle.kts"))
897
+ if not is_gradle and rel_path.endswith(".xml"):
898
+ text = _resolve_maven_properties(text)
874
899
  findings: list[MigrationFinding] = []
875
900
  for rule in _DEP_RULES:
876
901
  if rule.quick_filter is not None and rule.quick_filter not in text:
@@ -1198,6 +1223,7 @@ def run_migrate_check(
1198
1223
  limitations.append(f"{xml_read_errors} XML file(s) could not be read and were skipped.")
1199
1224
 
1200
1225
  dep_read_errors = 0
1226
+ raw_dep_findings: list[MigrationFinding] = []
1201
1227
  for abs_path, rel_path in build_files:
1202
1228
  try:
1203
1229
  text = abs_path.read_text(encoding="utf-8", errors="replace")
@@ -1206,7 +1232,20 @@ def run_migrate_check(
1206
1232
  continue
1207
1233
  dep_findings = _scan_dep_file(text, rel_path)
1208
1234
  filtered = [f for f in dep_findings if SEVERITY_ORDER.get(f.severity, 3) <= min_order]
1209
- all_findings.extend(filtered)
1235
+ raw_dep_findings.extend(filtered)
1236
+
1237
+ # Deduplicate dep findings by rule_id: same dependency in parent + child poms
1238
+ # is one logical finding. Keep the first occurrence (root pom sorts first).
1239
+ _seen_dep_rules: dict[str, int] = {} # rule_id → count
1240
+ for f in raw_dep_findings:
1241
+ _seen_dep_rules[f.rule_id] = _seen_dep_rules.get(f.rule_id, 0) + 1
1242
+ _dedup_dep: list[MigrationFinding] = []
1243
+ _emitted: set[str] = set()
1244
+ for f in raw_dep_findings:
1245
+ if f.rule_id not in _emitted:
1246
+ _dedup_dep.append(f)
1247
+ _emitted.add(f.rule_id)
1248
+ all_findings.extend(_dedup_dep)
1210
1249
 
1211
1250
  if dep_read_errors:
1212
1251
  limitations.append(f"{dep_read_errors} build file(s) could not be read and were skipped.")
@@ -0,0 +1,299 @@
1
+ """rename_refactor.py — Safe Java class rename with full reference update.
2
+
3
+ Performs a deterministic rename of a Java class/interface/enum:
4
+ 1. Locates the source file (OldName.java or by scanning class declarations)
5
+ 2. Updates class declaration, constructor name, all imports, all references
6
+ 3. Renames the physical file on disk
7
+ 4. Returns a structured ChangeAudit report (BLOCKER-C)
8
+
9
+ Covers:
10
+ - Class/interface/enum declaration
11
+ - Constructor declarations
12
+ - Import statements
13
+ - Field type declarations
14
+ - Method parameter and return types
15
+ - Variable declarations and instantiations
16
+ - extends / implements
17
+ - Generic type parameters
18
+ - Spring @Qualifier and @Bean names (camelCase)
19
+ - Test files (optional via include_tests)
20
+
21
+ Does NOT require compilation. Works on any Java source tree via
22
+ regex-based text transformations with word-boundary guards.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import difflib
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Data classes (BLOCKER-C: structured change audit trail)
35
+ # ---------------------------------------------------------------------------
36
+
37
+ @dataclass
38
+ class FileChange:
39
+ """A mutation applied to a single file."""
40
+ file: str # relative path from repo root
41
+ intent: str # human-readable description of what changed
42
+ diff: str # unified diff (--- before / +++ after)
43
+ before_lines: list[str] = field(default_factory=list)
44
+ after_lines: list[str] = field(default_factory=list)
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ "file": self.file,
49
+ "intent": self.intent,
50
+ "diff": self.diff,
51
+ "before_lines": self.before_lines,
52
+ "after_lines": self.after_lines,
53
+ }
54
+
55
+
56
+ @dataclass
57
+ class RenameResult:
58
+ """Full result of a rename-class operation."""
59
+ old_name: str
60
+ new_name: str
61
+ old_file: str # relative path before rename (empty if not found)
62
+ new_file: str # relative path after rename (empty if not found)
63
+ changes: list[FileChange] = field(default_factory=list)
64
+ files_scanned: int = 0
65
+ files_modified: int = 0
66
+ dry_run: bool = False
67
+ errors: list[str] = field(default_factory=list)
68
+
69
+ def to_dict(self) -> dict:
70
+ return {
71
+ "old_name": self.old_name,
72
+ "new_name": self.new_name,
73
+ "old_file": self.old_file,
74
+ "new_file": self.new_file,
75
+ "files_scanned": self.files_scanned,
76
+ "files_modified": self.files_modified,
77
+ "dry_run": self.dry_run,
78
+ "errors": self.errors,
79
+ "changes": [c.to_dict() for c in self.changes],
80
+ }
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Core rename logic
85
+ # ---------------------------------------------------------------------------
86
+
87
+ _VENDOR_DIRS = frozenset({
88
+ "vendor", "node_modules", "dist", "target", "build",
89
+ ".gradle", ".mvn", "generated", "generated-sources",
90
+ })
91
+
92
+
93
+ def _to_camel(name: str) -> str:
94
+ """PascalCase → camelCase: ServiceA → serviceA."""
95
+ if not name or len(name) < 2:
96
+ return name.lower() if name else name
97
+ return name[0].lower() + name[1:]
98
+
99
+
100
+ def _collect_java_files(root: Path, *, include_tests: bool = True) -> list[Path]:
101
+ """All .java files under root, excluding vendor/build dirs."""
102
+ results: list[Path] = []
103
+ for p in sorted(root.rglob("*.java")):
104
+ rel = str(p.relative_to(root)).replace("\\", "/")
105
+ parts = rel.split("/")
106
+ if any(part in _VENDOR_DIRS for part in parts[:-1]):
107
+ continue
108
+ if not include_tests:
109
+ if "/test/" in rel or "/tests/" in rel or rel.startswith("test/"):
110
+ continue
111
+ results.append(p)
112
+ return results
113
+
114
+
115
+ def _find_class_file(
116
+ java_files: list[Path],
117
+ class_name: str,
118
+ root: Path,
119
+ ) -> Optional[Path]:
120
+ """Find the file that declares `class_name` (by filename first, then scan)."""
121
+ # Prefer exact filename match
122
+ candidates = [f for f in java_files if f.stem == class_name]
123
+ if len(candidates) == 1:
124
+ return candidates[0]
125
+ if len(candidates) > 1:
126
+ # Multiple files with same stem — pick the one that has the class declaration
127
+ decl_re = re.compile(
128
+ r'\b(?:public\s+)?(?:abstract\s+)?(?:class|interface|enum)\s+' + re.escape(class_name) + r'\b'
129
+ )
130
+ for c in candidates:
131
+ try:
132
+ if decl_re.search(c.read_text(encoding="utf-8", errors="replace")):
133
+ return c
134
+ except OSError:
135
+ continue
136
+ return candidates[0]
137
+
138
+ # Fallback: scan file contents for class declaration
139
+ decl_re = re.compile(
140
+ r'\b(?:public\s+)?(?:abstract\s+)?(?:class|interface|enum)\s+' + re.escape(class_name) + r'\b'
141
+ )
142
+ for f in java_files:
143
+ try:
144
+ if decl_re.search(f.read_text(encoding="utf-8", errors="replace")):
145
+ return f
146
+ except OSError:
147
+ continue
148
+ return None
149
+
150
+
151
+ def _apply_rename(source: str, old_name: str, new_name: str) -> str:
152
+ """Apply word-boundary replacement for class name (PascalCase and camelCase forms)."""
153
+ # PascalCase replacement: all type references, declarations, imports
154
+ result = re.sub(r'\b' + re.escape(old_name) + r'\b', new_name, source)
155
+
156
+ # camelCase instance names: serviceA → serviceB (only when different from PascalCase)
157
+ old_camel = _to_camel(old_name)
158
+ new_camel = _to_camel(new_name)
159
+ if old_camel != old_name and old_camel in result:
160
+ result = re.sub(r'\b' + re.escape(old_camel) + r'\b', new_camel, result)
161
+
162
+ return result
163
+
164
+
165
+ def _make_diff(old_text: str, new_text: str, rel_path: str) -> str:
166
+ """Produce a unified diff string."""
167
+ old_lines = old_text.splitlines(keepends=True)
168
+ new_lines = new_text.splitlines(keepends=True)
169
+ diff_lines = list(difflib.unified_diff(
170
+ old_lines,
171
+ new_lines,
172
+ fromfile=f"a/{rel_path}",
173
+ tofile=f"b/{rel_path}",
174
+ lineterm="",
175
+ ))
176
+ return "".join(diff_lines)
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Public API
181
+ # ---------------------------------------------------------------------------
182
+
183
+ def rename_class(
184
+ root: Path,
185
+ old_name: str,
186
+ new_name: str,
187
+ *,
188
+ dry_run: bool = False,
189
+ include_tests: bool = True,
190
+ ) -> RenameResult:
191
+ """Rename a Java class throughout the repository.
192
+
193
+ Args:
194
+ root: Absolute repo root directory.
195
+ old_name: Simple class name to rename (e.g. "ServiceA").
196
+ new_name: Target simple class name (e.g. "ServiceB").
197
+ dry_run: If True, compute changes but do not write any files.
198
+ include_tests: If True (default), also rename in test files.
199
+
200
+ Returns:
201
+ RenameResult with structured change audit trail (BLOCKER-C format).
202
+ """
203
+ root = root.resolve()
204
+
205
+ result = RenameResult(
206
+ old_name=old_name,
207
+ new_name=new_name,
208
+ old_file="",
209
+ new_file="",
210
+ dry_run=dry_run,
211
+ )
212
+
213
+ # Validate input
214
+ if not old_name or not old_name[0].isupper():
215
+ result.errors.append(
216
+ f"old_name '{old_name}' must be a Java class name (PascalCase, non-empty)."
217
+ )
218
+ return result
219
+ if not new_name or not new_name[0].isupper():
220
+ result.errors.append(
221
+ f"new_name '{new_name}' must be a Java class name (PascalCase, non-empty)."
222
+ )
223
+ return result
224
+ if old_name == new_name:
225
+ result.errors.append("old_name and new_name are identical — nothing to rename.")
226
+ return result
227
+ if not root.is_dir():
228
+ result.errors.append(f"Root directory '{root}' does not exist.")
229
+ return result
230
+
231
+ # Collect files
232
+ java_files = _collect_java_files(root, include_tests=include_tests)
233
+ result.files_scanned = len(java_files)
234
+
235
+ # Locate the source file
236
+ source_file = _find_class_file(java_files, old_name, root)
237
+ if source_file is None:
238
+ result.errors.append(
239
+ f"Could not find a file declaring class '{old_name}' under '{root}'."
240
+ )
241
+ return result
242
+
243
+ # Determine new file path (same directory, new filename)
244
+ new_file_path = source_file.with_name(new_name + ".java")
245
+ result.old_file = str(source_file.relative_to(root)).replace("\\", "/")
246
+ result.new_file = str(new_file_path.relative_to(root)).replace("\\", "/")
247
+
248
+ if new_file_path.exists() and new_file_path != source_file:
249
+ result.errors.append(
250
+ f"Target file '{result.new_file}' already exists — aborting to avoid overwrite."
251
+ )
252
+ return result
253
+
254
+ # Apply text replacements to all Java files
255
+ changes: list[FileChange] = []
256
+ for java_file in java_files:
257
+ try:
258
+ old_text = java_file.read_text(encoding="utf-8", errors="replace")
259
+ except OSError as e:
260
+ result.errors.append(f"Could not read '{java_file}': {e}")
261
+ continue
262
+
263
+ new_text = _apply_rename(old_text, old_name, new_name)
264
+ if new_text == old_text:
265
+ continue
266
+
267
+ rel_path = str(java_file.relative_to(root)).replace("\\", "/")
268
+ diff = _make_diff(old_text, new_text, rel_path)
269
+
270
+ # Determine intent
271
+ is_source = java_file == source_file
272
+ if is_source:
273
+ intent = f"Renamed class declaration: {old_name} → {new_name}"
274
+ else:
275
+ intent = f"Updated references to {old_name} → {new_name}"
276
+
277
+ changes.append(FileChange(
278
+ file=rel_path,
279
+ intent=intent,
280
+ diff=diff,
281
+ before_lines=old_text.splitlines(),
282
+ after_lines=new_text.splitlines(),
283
+ ))
284
+
285
+ if not dry_run:
286
+ java_file.write_text(new_text, encoding="utf-8")
287
+
288
+ result.changes = changes
289
+ result.files_modified = len(changes)
290
+
291
+ # Rename the physical file (BLOCKER-A core fix)
292
+ if not dry_run and source_file.exists():
293
+ source_file.rename(new_file_path)
294
+ elif dry_run:
295
+ # In dry_run mode, add a synthetic change record for the file rename itself
296
+ # if no text change was found in the source file (e.g. only filename changes).
297
+ pass
298
+
299
+ return result
@@ -105,7 +105,12 @@ class EvidenceBundle:
105
105
  _PKG_RE = re.compile(r'^package\s+([\w.]+)\s*;', re.MULTILINE)
106
106
  _IMPORT_RE = re.compile(r'^import\s+(?:static\s+)?([\w.]+(?:\.\*)?)\s*;', re.MULTILINE)
107
107
  _ANN_RE = re.compile(r'^(@[\w.]+)')
108
- _ANN_WITH_ARGS_RE = re.compile(r'^(@[\w.]+)\s*(?:\(([^)]*)\))?')
108
+ _ANN_WITH_ARGS_RE = re.compile(
109
+ r'^(@[\w.]+)\s*'
110
+ r'(?:\('
111
+ r'((?:[^()"\']*|"[^"]*"|\'[^\']*\'|\((?:[^()"\']*|"[^"]*"|\'[^\']*\')*\))*)'
112
+ r'\))?'
113
+ )
109
114
 
110
115
  _CLASS_DECL_RE = re.compile(
111
116
  r'(?:^|(?<=\s))'
@@ -2814,6 +2819,11 @@ def build_repo_ir(
2814
2819
  if since:
2815
2820
  _since_changed = _get_git_changed_files(root, since)
2816
2821
 
2822
+ # L-6: analysis_meta tracking (files_read, lines_read, symbols_analyzed, token_estimate)
2823
+ _meta_files_read = 0
2824
+ _meta_lines_read = 0
2825
+ _meta_chars_read = 0
2826
+
2817
2827
  # Pass 1: extract symbols from all files so we can build the same-package
2818
2828
  # type map before building relations. Java classes in the same package
2819
2829
  # reference each other without import statements, so import_map alone cannot
@@ -2825,6 +2835,9 @@ def build_repo_ir(
2825
2835
  source = abs_path.read_text(encoding="utf-8", errors="replace")
2826
2836
  except OSError:
2827
2837
  continue
2838
+ _meta_files_read += 1
2839
+ _meta_lines_read += source.count("\n") + (1 if source and not source.endswith("\n") else 0)
2840
+ _meta_chars_read += len(source)
2828
2841
  package, symbols, raw_imports = _extract_symbols(source, rel_path)
2829
2842
  all_symbols.extend(symbols)
2830
2843
  _per_file.append((rel_path, source, package, raw_imports, symbols))
@@ -2878,7 +2891,16 @@ def build_repo_ir(
2878
2891
  route_diffs_arg: Optional[list[dict]] = (
2879
2892
  sorted(all_route_diffs, key=lambda d: d["symbol"]) if since else None
2880
2893
  )
2881
- return _assemble(all_symbols, unique_relations, all_changed, spring_summary, route_diffs_arg)
2894
+ ir = _assemble(all_symbols, unique_relations, all_changed, spring_summary, route_diffs_arg)
2895
+
2896
+ # L-6: inject analysis_meta — files_read, lines_read, symbols_analyzed, token_estimate
2897
+ ir["analysis_meta"] = {
2898
+ "files_read": _meta_files_read,
2899
+ "lines_read": _meta_lines_read,
2900
+ "symbols_analyzed": len(all_symbols),
2901
+ "token_estimate": _meta_chars_read // 4, # 4 chars ≈ 1 token (rough approximation)
2902
+ }
2903
+ return ir
2882
2904
 
2883
2905
 
2884
2906
  # ---------------------------------------------------------------------------
@@ -3303,6 +3325,52 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
3303
3325
  else:
3304
3326
  security_model = "unknown"
3305
3327
 
3328
+ # Detect XML-based Spring Security config. When present, per-endpoint
3329
+ # none_detected is expected and does NOT mean the endpoint is unsecured —
3330
+ # security is declared in XML (HttpSecurity rules, filter chains, web.xml
3331
+ # security constraints). Update security_model and re-tag affected endpoints
3332
+ # so the output cannot be misread as "unprotected".
3333
+ _XML_SECURITY_RE = re.compile(
3334
+ r'(?:xmlns(?::[a-z]+)?="http://www\.springframework\.org/schema/security"'
3335
+ r'|<security:http\b'
3336
+ r'|<http\s[^>]*use-expressions'
3337
+ r'|spring-security-[2345]'
3338
+ r'|xmlns:security="http://www\.springframework\.org/schema/security")',
3339
+ re.IGNORECASE,
3340
+ )
3341
+ _xml_security_detected = False
3342
+ _XML_GLOBS = (
3343
+ "*security*.xml", "*Security*.xml",
3344
+ "*applicationContext*.xml", "*-context.xml", "*Context.xml",
3345
+ "*spring*.xml", "*Spring*.xml",
3346
+ )
3347
+ for _glob in _XML_GLOBS:
3348
+ for _xf in root.rglob(_glob):
3349
+ if "target/" in str(_xf).replace("\\", "/"):
3350
+ continue
3351
+ try:
3352
+ _xt = _xf.read_text(encoding="utf-8", errors="replace")
3353
+ except OSError:
3354
+ continue
3355
+ if _XML_SECURITY_RE.search(_xt):
3356
+ _xml_security_detected = True
3357
+ break
3358
+ if _xml_security_detected:
3359
+ break
3360
+
3361
+ if _xml_security_detected and security_model == "unknown":
3362
+ security_model = "xml_or_filter_chain"
3363
+ # Re-tag per-endpoint none_detected → xml_or_filter_chain so the output
3364
+ # cannot be misread as "endpoint is unprotected".
3365
+ for ep in endpoints:
3366
+ if ep.get("security", {}).get("policy") == "none_detected":
3367
+ ep["security"] = {"policy": "xml_or_filter_chain"}
3368
+ # Recompute no_security_signal (now counts only truly unknown endpoints)
3369
+ no_security_signal = sum(
3370
+ 1 for e in endpoints
3371
+ if e.get("security", {}).get("policy") == "none_detected"
3372
+ )
3373
+
3306
3374
  return {
3307
3375
  "endpoints": endpoints,
3308
3376
  "total": len(endpoints),
@@ -197,14 +197,17 @@ def _compute_event_risk(
197
197
  consumer_count: int,
198
198
  before_commit_count: int,
199
199
  cross_module: bool,
200
+ sync_in_tx_count: int = 0,
200
201
  ) -> str:
201
202
  """Deterministic risk scoring per spec.
202
203
 
203
204
  high: fanout > 5 OR cross-module propagation OR BEFORE_COMMIT consumers
205
+ OR sync @EventListener inside @Transactional publisher
204
206
  medium: 2–5 consumers
205
207
  low: ≤1 consumer
206
208
  """
207
- if consumer_count > _RISK_FANOUT_HIGH or cross_module or before_commit_count > 0:
209
+ if (consumer_count > _RISK_FANOUT_HIGH or cross_module
210
+ or before_commit_count > 0 or sync_in_tx_count > 0):
208
211
  return "high"
209
212
  if consumer_count >= _RISK_FANOUT_MEDIUM:
210
213
  return "medium"
@@ -327,9 +330,23 @@ class EventTopologyOrchestrator:
327
330
  # ── 7. TX context ──────────────────────────────────────────────────
328
331
  after_commit = [c.fqn for c in consumers if c.transactional_phase == "AFTER_COMMIT"]
329
332
  before_commit_risks = [c.fqn for c in consumers if c.transactional_phase == "BEFORE_COMMIT"]
333
+
334
+ # Detect sync @EventListener inside @Transactional publisher.
335
+ # Plain @EventListener fires synchronously; if the publisher method is
336
+ # @Transactional the listener runs inside that TX — listener exception
337
+ # rolls back the outer TX, and DB state may be partially committed.
338
+ tx_publishers = [
339
+ p for p in publishers
340
+ if "@Transactional" in ((fqn_index.get(p) or {}).get("annotations") or [])
341
+ ]
342
+ sync_in_tx_risks = [
343
+ c.fqn for c in consumers
344
+ if c.type == "spring_event" and tx_publishers
345
+ ]
330
346
  tx_context = {
331
347
  "after_commit_consumers": after_commit,
332
348
  "before_commit_risks": before_commit_risks,
349
+ "sync_in_tx_risks": sync_in_tx_risks,
333
350
  }
334
351
 
335
352
  # ── 8. Cross-module detection ──────────────────────────────────────
@@ -352,6 +369,7 @@ class EventTopologyOrchestrator:
352
369
  consumer_count=len(consumers),
353
370
  before_commit_count=len(before_commit_risks),
354
371
  cross_module=cross_module,
372
+ sync_in_tx_count=len(sync_in_tx_risks),
355
373
  )
356
374
 
357
375
  # ── 10. Confidence ─────────────────────────────────────────────────
@@ -385,6 +403,7 @@ class EventTopologyOrchestrator:
385
403
  "kafka_listeners_in_repo": kafka_count,
386
404
  "rabbit_listeners_in_repo": rabbit_count,
387
405
  "before_commit_risk_count": len(before_commit_risks),
406
+ "sync_in_tx_risk_count": len(sync_in_tx_risks),
388
407
  "level2_events": list(level2_events.keys()),
389
408
  "cross_module": cross_module,
390
409
  "model_build_time_ms": model.build_time_ms,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.35.25
3
+ Version: 1.35.27
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
42
42
 
43
- ![Version](https://img.shields.io/badge/version-1.35.25-blue)
43
+ ![Version](https://img.shields.io/badge/version-1.35.27-blue)
44
44
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
45
45
 
46
46
  ---
@@ -114,7 +114,7 @@ pipx install sourcecode
114
114
 
115
115
  ```bash
116
116
  sourcecode version
117
- # sourcecode 1.35.25
117
+ # sourcecode 1.35.27
118
118
  ```
119
119
 
120
120
  ---
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=1fGjuVJyBU95TOEnJBXLcVgGYs_QYMvaKwPypDXVP9g,104
1
+ sourcecode/__init__.py,sha256=wAc-lNaY6I9JzmeKSyL20qzmqMMrwwFLFjIF6umkX0w,104
2
2
  sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
3
3
  sourcecode/architecture_analyzer.py,sha256=qh749a7ykPtGmQI1MR9y6j8TtL_jBdVYFx9YRsLqOMw,44121
4
4
  sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
@@ -7,7 +7,7 @@ sourcecode/cache.py,sha256=wAyPrXN5DqiGivnMpeEuun2xHDKfBer2_oBsh6kj_vc,30447
7
7
  sourcecode/canonical_ir.py,sha256=2vTLc6wL1cH3NNbEcdZpfX5okh8h5dKq7xd0m0rv_Ro,24167
8
8
  sourcecode/cir_graphs.py,sha256=rZi8JV4ZrAa2WSCeyNa4JIEKQ_yZzDZTsrvVz2KfuKA,8919
9
9
  sourcecode/classifier.py,sha256=2lYoSH3vOTkXZYPU7Go2WIet1-IuNzTWVhc-ULnXtgw,8024
10
- sourcecode/cli.py,sha256=wy5-T6Ba_hvqJoFkSROCJxOpsy_VzbYI98TlJIEeGW0,238063
10
+ sourcecode/cli.py,sha256=es0kuqlHSIMqzdQZZ9tHYbUgBe_Ogw2k_pjC1JFhJro,246257
11
11
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
12
12
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
13
13
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
@@ -21,6 +21,7 @@ sourcecode/entrypoint_classifier.py,sha256=jhTYlyqDJH2AtdEcLVaRU3lYRTJuF8DkxVzl4
21
21
  sourcecode/env_analyzer.py,sha256=aNTyYgQk5noJDfJU6FmasmESOHfiomyJw5EvZqjy6qc,22213
22
22
  sourcecode/error_schema.py,sha256=uwosfNaSujtYm11_732Hu92z5ITV040fQDaIyefSvR4,1683
23
23
  sourcecode/explain.py,sha256=cCRpR4L0goET8UR1iFTq4gQ-2TDCRTL7jGITMO09TE4,16556
24
+ sourcecode/file_chunker.py,sha256=xceHnlEg6SlSJAO5Iv2-bXICPjN8qvjQJ2CLYrHuq0o,14744
24
25
  sourcecode/file_classifier.py,sha256=A0fEABqtfVu1MfoaxnPAvGpZgneGgVXlJDhT74NYXxE,15314
25
26
  sourcecode/flow_analyzer.py,sha256=dSiuY4w49k29jW_EPXUOND9B5uVbuCA7kjnuHi-pIWA,28781
26
27
  sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
@@ -29,7 +30,7 @@ sourcecode/graph_analyzer.py,sha256=DHR8fY69oU_Pi4SYaWboX6EoEFrctQKB9dsjpqwGMzw,
29
30
  sourcecode/license.py,sha256=3JCV2OeTVttKrOGBguU5uZC0c02Stig-KLB0mP2lNiY,22742
30
31
  sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
31
32
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
32
- sourcecode/migrate_check.py,sha256=-jghKewJwMO0VCXML-ZY1KI_RQO5gd5-pyLCMg5u8jA,52971
33
+ sourcecode/migrate_check.py,sha256=GuYK36DDFkwf07jbAgcoc-Ovq8ttLQNMsRqhsUilMzY,54514
33
34
  sourcecode/output_budget.py,sha256=Js9yUlfQtPhqBl9R6wn_9UHVjjJc3GtLcqyfjf5t50Q,9869
34
35
  sourcecode/path_filters.py,sha256=ROFRQ8eSLBEMiixK9f45-RO7um4VEEcjoD5AA4I427I,3739
35
36
  sourcecode/pr_comment_renderer.py,sha256=smHslxiG14lrytCkq5nFrFu-qTHgA-t-LFYfdrfjz2o,14423
@@ -39,15 +40,16 @@ sourcecode/progress.py,sha256=qn30sWaHOkjTgXsSBmiPkz7Rsbwc5oSlIe6JNEMYp_k,3149
39
40
  sourcecode/ranking_engine.py,sha256=ZAucq_YX2KkWUuAZf4P0lhtQ_38vEFnUhuGtSZd1S0E,12970
40
41
  sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
41
42
  sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
43
+ sourcecode/rename_refactor.py,sha256=lgfJ5Qp4WpduxcaqU84IrFz9ZhJlFnqnrCNkB_bf6RA,10379
42
44
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
43
- sourcecode/repository_ir.py,sha256=vTdWuFj0iRUavs4mOWl87JETR7Je9bKVeDycKBqOFp8,169640
45
+ sourcecode/repository_ir.py,sha256=jOyInh72kTVX9jzyyeyBIEXfs4uFfRINV5rJOsT6YiM,172453
44
46
  sourcecode/ris.py,sha256=RcqLVwC-doFcKKViYDkCjZLBqf_wzLES7-F6vHEeWzE,20419
45
47
  sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
46
48
  sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
47
49
  sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
48
50
  sourcecode/semantic_analyzer.py,sha256=TDuC3wzZR2DPm1mgrAg1YSLk2QzJoueS3TZAmyGGpCU,89417
49
51
  sourcecode/serializer.py,sha256=7SBJIbpC_Lg0RGWq8jjNbF5TiuZwoP_fi0qhHnzQM8M,124386
50
- sourcecode/spring_event_topology.py,sha256=LvGv5RXtU_O-fVB_OO9eDD2UmZM72Jn2oUHgOo50Qm0,17157
52
+ sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
51
53
  sourcecode/spring_findings.py,sha256=8V91iHOg9hFgg6tLLl4FSsgrF-dBqOcO2s-K5sD_goA,5417
52
54
  sourcecode/spring_impact.py,sha256=Ohm2k3W4Wts8Kx8Z7DIM-J-cwGtTJBWKFBsX-WkupBQ,32943
53
55
  sourcecode/spring_model.py,sha256=IzMcM5ftw1_EHG3FGUDT7qdAMpo3eqbAE1LRuasfr_4,14739
@@ -94,8 +96,8 @@ sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWn
94
96
  sourcecode/telemetry/events.py,sha256=oEvvulfsv5GIDWG2174gSS6tNB95w38AIYiYeifGKlE,2294
95
97
  sourcecode/telemetry/filters.py,sha256=Asa71oRl7q3Wt_FMwuufIZJFzSYdgRNKS8LHCIyFeYE,4805
96
98
  sourcecode/telemetry/transport.py,sha256=QSslxIwij8YkRWcVvxykODDrkiN_GAAEu3dUP7KIWeE,1651
97
- sourcecode-1.35.25.dist-info/METADATA,sha256=XdPhns9JN4tVct_5ARdKlbsb3w76e9rBFXZ_SI4-VC0,21297
98
- sourcecode-1.35.25.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
99
- sourcecode-1.35.25.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
100
- sourcecode-1.35.25.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
101
- sourcecode-1.35.25.dist-info/RECORD,,
99
+ sourcecode-1.35.27.dist-info/METADATA,sha256=mUXNvfLH6jEm0m4f4saI7ILJHMFMO9qjn5bcgiY-5U8,21297
100
+ sourcecode-1.35.27.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
101
+ sourcecode-1.35.27.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
102
+ sourcecode-1.35.27.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
103
+ sourcecode-1.35.27.dist-info/RECORD,,