sourcecode 1.35.26__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.26"
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
+ )
@@ -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
@@ -2819,6 +2819,11 @@ def build_repo_ir(
2819
2819
  if since:
2820
2820
  _since_changed = _get_git_changed_files(root, since)
2821
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
+
2822
2827
  # Pass 1: extract symbols from all files so we can build the same-package
2823
2828
  # type map before building relations. Java classes in the same package
2824
2829
  # reference each other without import statements, so import_map alone cannot
@@ -2830,6 +2835,9 @@ def build_repo_ir(
2830
2835
  source = abs_path.read_text(encoding="utf-8", errors="replace")
2831
2836
  except OSError:
2832
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)
2833
2841
  package, symbols, raw_imports = _extract_symbols(source, rel_path)
2834
2842
  all_symbols.extend(symbols)
2835
2843
  _per_file.append((rel_path, source, package, raw_imports, symbols))
@@ -2883,7 +2891,16 @@ def build_repo_ir(
2883
2891
  route_diffs_arg: Optional[list[dict]] = (
2884
2892
  sorted(all_route_diffs, key=lambda d: d["symbol"]) if since else None
2885
2893
  )
2886
- 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
2887
2904
 
2888
2905
 
2889
2906
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.35.26
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.26-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.26
117
+ # sourcecode 1.35.27
118
118
  ```
119
119
 
120
120
  ---
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=HYA42OYABbKZ43Trr1VCdpUkXMtz0AqpwPt0ENMTTjo,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
@@ -39,8 +40,9 @@ 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=hl7Vc7o5LD0xWZ6Er7-x2IDrLurJZIfBKGPD-3cfraU,171754
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
@@ -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.26.dist-info/METADATA,sha256=M8Y5dAsVOUGs7ij_YJC26hy4SJtJ_4fFGZZ6NpuabKQ,21297
98
- sourcecode-1.35.26.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
99
- sourcecode-1.35.26.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
100
- sourcecode-1.35.26.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
101
- sourcecode-1.35.26.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,,