sourcecode 1.35.26__py3-none-any.whl → 1.35.28__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.28"
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")
@@ -5159,10 +5397,13 @@ def cold_start_cmd(
5159
5397
  result = _gcs(target)
5160
5398
  if compact:
5161
5399
  # P1-C: cap at ~10K tokens — keep only fields essential for orientation.
5162
- _cs_keys = {"status", "git_head", "stacks", "entry_points",
5163
- "key_dependencies", "project_type", "project_summary",
5164
- "validation", "_meta"}
5400
+ # BUG-6 fix: use actual RIS key names (summary/entrypoints, not stacks/entry_points)
5401
+ _cs_keys = {"status", "git_head", "summary", "entrypoints", "endpoints",
5402
+ "project_type", "validation", "_meta"}
5165
5403
  result = {k: v for k, v in result.items() if k in _cs_keys}
5404
+ # Truncate endpoints to first 30 to stay within ~10K token budget
5405
+ if isinstance(result.get("endpoints"), list):
5406
+ result["endpoints"] = result["endpoints"][:30]
5166
5407
  result["_meta"] = {**(result.get("_meta") or {}), "compact_mode": True,
5167
5408
  "full_available": "sourcecode cold-start (without --compact)"}
5168
5409
  _out = _json.dumps(result, indent=2, ensure_ascii=False)
sourcecode/explain.py CHANGED
@@ -28,6 +28,9 @@ _STEREOTYPE_DESC: dict[str, str] = {
28
28
  "component": "Spring @Component — general-purpose bean",
29
29
  "configuration": "Spring @Configuration — bean factory / config",
30
30
  "bean": "Spring @Bean — managed component",
31
+ "entity": "JPA @Entity — persistent domain object mapped to a database table",
32
+ "mappedsuperclass": "JPA @MappedSuperclass — base class sharing persistent state with subclasses",
33
+ "embeddable": "JPA @Embeddable — value object embedded in owning entity table",
31
34
  }
32
35
 
33
36
  _SECURITY_ANNOTATION_PREFIXES = (
@@ -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,351 @@
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 (
110
+ "/src/test/" in rel or rel.startswith("src/test/")
111
+ or "/src/tests/" in rel or rel.startswith("src/tests/")
112
+ or rel.startswith("test/") or rel.startswith("tests/")
113
+ ):
114
+ continue
115
+ results.append(p)
116
+ return results
117
+
118
+
119
+ def _find_class_file(
120
+ java_files: list[Path],
121
+ class_name: str,
122
+ root: Path,
123
+ ) -> Optional[Path]:
124
+ """Find the file that declares `class_name` (by filename first, then scan)."""
125
+ # Prefer exact filename match
126
+ candidates = [f for f in java_files if f.stem == class_name]
127
+ if len(candidates) == 1:
128
+ return candidates[0]
129
+ if len(candidates) > 1:
130
+ # Multiple files with same stem — pick the one that has the class declaration
131
+ decl_re = re.compile(
132
+ r'\b(?:public\s+)?(?:abstract\s+)?(?:class|interface|enum)\s+' + re.escape(class_name) + r'\b'
133
+ )
134
+ for c in candidates:
135
+ try:
136
+ if decl_re.search(c.read_text(encoding="utf-8", errors="replace")):
137
+ return c
138
+ except OSError:
139
+ continue
140
+ return candidates[0]
141
+
142
+ # Fallback: scan file contents for class declaration
143
+ decl_re = re.compile(
144
+ r'\b(?:public\s+)?(?:abstract\s+)?(?:class|interface|enum)\s+' + re.escape(class_name) + r'\b'
145
+ )
146
+ for f in java_files:
147
+ try:
148
+ if decl_re.search(f.read_text(encoding="utf-8", errors="replace")):
149
+ return f
150
+ except OSError:
151
+ continue
152
+ return None
153
+
154
+
155
+ def _apply_rename(source: str, old_name: str, new_name: str) -> str:
156
+ """Apply word-boundary replacement for class name (PascalCase and camelCase forms)."""
157
+ result = re.sub(r'\b' + re.escape(old_name) + r'\b', new_name, source)
158
+
159
+ old_camel = _to_camel(old_name)
160
+ new_camel = _to_camel(new_name)
161
+ if old_camel != old_name and old_camel in result:
162
+ result = re.sub(r'\b' + re.escape(old_camel) + r'\b', new_camel, result)
163
+
164
+ return result
165
+
166
+
167
+ # Matches a class/interface/enum/record declaration of a given name
168
+ _CLASS_DECL_RE_TMPL = r'\b(?:class|interface|enum|record)\s+{name}\b'
169
+ # Matches a constructor declaration: optional access modifier + ClassName + (
170
+ _CTOR_DECL_RE_TMPL = r'^\s*(?:(?:public|protected|private)\s+)?' + r'{name}\s*\('
171
+
172
+
173
+ def _apply_rename_refs_only(source: str, old_name: str, new_name: str) -> str:
174
+ """Rename old_name→new_name in a non-source file (import/type references only).
175
+
176
+ Skips lines containing a class/interface/enum/record declaration or constructor
177
+ declaration of old_name, so that a class sharing the simple name in another
178
+ package is not corrupted.
179
+ """
180
+ class_decl_re = re.compile(_CLASS_DECL_RE_TMPL.format(name=re.escape(old_name)))
181
+ ctor_decl_re = re.compile(_CTOR_DECL_RE_TMPL.format(name=re.escape(old_name)))
182
+ ref_re = re.compile(r'\b' + re.escape(old_name) + r'\b')
183
+
184
+ lines = source.splitlines(keepends=True)
185
+ result_lines = []
186
+ for line in lines:
187
+ if class_decl_re.search(line) or ctor_decl_re.search(line):
188
+ result_lines.append(line)
189
+ else:
190
+ result_lines.append(ref_re.sub(new_name, line))
191
+ result = ''.join(result_lines)
192
+
193
+ old_camel = _to_camel(old_name)
194
+ new_camel = _to_camel(new_name)
195
+ if old_camel != old_name and old_camel in result:
196
+ result = re.sub(r'\b' + re.escape(old_camel) + r'\b', new_camel, result)
197
+
198
+ return result
199
+
200
+
201
+ def _make_diff(old_text: str, new_text: str, rel_path: str) -> str:
202
+ """Produce a unified diff string."""
203
+ old_lines = old_text.splitlines(keepends=True)
204
+ new_lines = new_text.splitlines(keepends=True)
205
+ diff_lines = list(difflib.unified_diff(
206
+ old_lines,
207
+ new_lines,
208
+ fromfile=f"a/{rel_path}",
209
+ tofile=f"b/{rel_path}",
210
+ lineterm="",
211
+ ))
212
+ return "".join(diff_lines)
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Public API
217
+ # ---------------------------------------------------------------------------
218
+
219
+ def rename_class(
220
+ root: Path,
221
+ old_name: str,
222
+ new_name: str,
223
+ *,
224
+ dry_run: bool = False,
225
+ include_tests: bool = True,
226
+ ) -> RenameResult:
227
+ """Rename a Java class throughout the repository.
228
+
229
+ Args:
230
+ root: Absolute repo root directory.
231
+ old_name: Simple class name to rename (e.g. "ServiceA").
232
+ new_name: Target simple class name (e.g. "ServiceB").
233
+ dry_run: If True, compute changes but do not write any files.
234
+ include_tests: If True (default), also rename in test files.
235
+
236
+ Returns:
237
+ RenameResult with structured change audit trail (BLOCKER-C format).
238
+ """
239
+ root = root.resolve()
240
+
241
+ result = RenameResult(
242
+ old_name=old_name,
243
+ new_name=new_name,
244
+ old_file="",
245
+ new_file="",
246
+ dry_run=dry_run,
247
+ )
248
+
249
+ # Validate input
250
+ if not old_name or not old_name[0].isupper():
251
+ result.errors.append(
252
+ f"old_name '{old_name}' must be a Java class name (PascalCase, non-empty)."
253
+ )
254
+ return result
255
+ if not new_name or not new_name[0].isupper():
256
+ result.errors.append(
257
+ f"new_name '{new_name}' must be a Java class name (PascalCase, non-empty)."
258
+ )
259
+ return result
260
+ if old_name == new_name:
261
+ result.errors.append("old_name and new_name are identical — nothing to rename.")
262
+ return result
263
+ if not root.is_dir():
264
+ result.errors.append(f"Root directory '{root}' does not exist.")
265
+ return result
266
+
267
+ # Collect files
268
+ java_files = _collect_java_files(root, include_tests=include_tests)
269
+ result.files_scanned = len(java_files)
270
+
271
+ # Locate the source file
272
+ source_file = _find_class_file(java_files, old_name, root)
273
+ if source_file is None:
274
+ result.errors.append(
275
+ f"Could not find a file declaring class '{old_name}' under '{root}'."
276
+ )
277
+ return result
278
+
279
+ # Determine new file path (same directory, new filename)
280
+ new_file_path = source_file.with_name(new_name + ".java")
281
+ result.old_file = str(source_file.relative_to(root)).replace("\\", "/")
282
+ result.new_file = str(new_file_path.relative_to(root)).replace("\\", "/")
283
+
284
+ # BUG-2: check for collision anywhere in the repo, not just same directory
285
+ collision = next(
286
+ (f for f in java_files if f.stem == new_name and f.resolve() != new_file_path.resolve()),
287
+ None,
288
+ )
289
+ if collision is not None:
290
+ collision_rel = str(collision.relative_to(root)).replace("\\", "/")
291
+ result.errors.append(
292
+ f"'{new_name}' already exists at '{collision_rel}' — "
293
+ f"rename would create a duplicate class name. Pass --force to override."
294
+ )
295
+ return result
296
+
297
+ if new_file_path.exists() and new_file_path != source_file:
298
+ result.errors.append(
299
+ f"Target file '{result.new_file}' already exists — aborting to avoid overwrite."
300
+ )
301
+ return result
302
+
303
+ # Apply text replacements to all Java files
304
+ changes: list[FileChange] = []
305
+ for java_file in java_files:
306
+ try:
307
+ old_text = java_file.read_text(encoding="utf-8", errors="replace")
308
+ except OSError as e:
309
+ result.errors.append(f"Could not read '{java_file}': {e}")
310
+ continue
311
+
312
+ is_source = java_file == source_file
313
+ if is_source:
314
+ new_text = _apply_rename(old_text, old_name, new_name)
315
+ else:
316
+ # BUG-4: use refs-only variant to avoid clobbering same-named class in other package
317
+ new_text = _apply_rename_refs_only(old_text, old_name, new_name)
318
+ if new_text == old_text:
319
+ continue
320
+
321
+ rel_path = str(java_file.relative_to(root)).replace("\\", "/")
322
+ diff = _make_diff(old_text, new_text, rel_path)
323
+
324
+ if is_source:
325
+ intent = f"Renamed class declaration: {old_name} → {new_name}"
326
+ else:
327
+ intent = f"Updated references to {old_name} → {new_name}"
328
+
329
+ changes.append(FileChange(
330
+ file=rel_path,
331
+ intent=intent,
332
+ diff=diff,
333
+ before_lines=old_text.splitlines(),
334
+ after_lines=new_text.splitlines(),
335
+ ))
336
+
337
+ if not dry_run:
338
+ java_file.write_text(new_text, encoding="utf-8")
339
+
340
+ result.changes = changes
341
+ result.files_modified = len(changes)
342
+
343
+ # Rename the physical file (BLOCKER-A core fix)
344
+ if not dry_run and source_file.exists():
345
+ source_file.rename(new_file_path)
346
+ elif dry_run:
347
+ # In dry_run mode, add a synthetic change record for the file rename itself
348
+ # if no text change was found in the source file (e.g. only filename changes).
349
+ pass
350
+
351
+ return result
@@ -202,8 +202,9 @@ _SECURITY_MARKER_ANNOTATIONS: frozenset[str] = frozenset({
202
202
  # is expected and does NOT mean endpoints are unprotected.
203
203
  _FILTER_SECURITY_ANNOTATIONS: frozenset[str] = frozenset({
204
204
  "@EnableWebSecurity",
205
- "@EnableMethodSecurity",
206
- "@EnableGlobalMethodSecurity",
205
+ # @EnableMethodSecurity / @EnableGlobalMethodSecurity enable per-method annotation
206
+ # security (@PreAuthorize/@Secured), NOT a filter chain — must NOT be treated as
207
+ # filter_based or SEC-001 is suppressed for every unannotated endpoint.
207
208
  })
208
209
 
209
210
  # Programmatic security: method-call patterns that indicate runtime auth enforcement.
@@ -2819,6 +2820,11 @@ def build_repo_ir(
2819
2820
  if since:
2820
2821
  _since_changed = _get_git_changed_files(root, since)
2821
2822
 
2823
+ # L-6: analysis_meta tracking (files_read, lines_read, symbols_analyzed, token_estimate)
2824
+ _meta_files_read = 0
2825
+ _meta_lines_read = 0
2826
+ _meta_chars_read = 0
2827
+
2822
2828
  # Pass 1: extract symbols from all files so we can build the same-package
2823
2829
  # type map before building relations. Java classes in the same package
2824
2830
  # reference each other without import statements, so import_map alone cannot
@@ -2830,6 +2836,9 @@ def build_repo_ir(
2830
2836
  source = abs_path.read_text(encoding="utf-8", errors="replace")
2831
2837
  except OSError:
2832
2838
  continue
2839
+ _meta_files_read += 1
2840
+ _meta_lines_read += source.count("\n") + (1 if source and not source.endswith("\n") else 0)
2841
+ _meta_chars_read += len(source)
2833
2842
  package, symbols, raw_imports = _extract_symbols(source, rel_path)
2834
2843
  all_symbols.extend(symbols)
2835
2844
  _per_file.append((rel_path, source, package, raw_imports, symbols))
@@ -2883,7 +2892,58 @@ def build_repo_ir(
2883
2892
  route_diffs_arg: Optional[list[dict]] = (
2884
2893
  sorted(all_route_diffs, key=lambda d: d["symbol"]) if since else None
2885
2894
  )
2886
- return _assemble(all_symbols, unique_relations, all_changed, spring_summary, route_diffs_arg)
2895
+ ir = _assemble(all_symbols, unique_relations, all_changed, spring_summary, route_diffs_arg)
2896
+
2897
+ # BUG-7: XML Spring Security detection for the canonical CIR pipeline.
2898
+ # _assemble only sees Java symbols — XML config is invisible to it.
2899
+ # Scan here (where root is available) and retag route_surface entries so
2900
+ # build_canonical_ir produces correct CanonicalEndpoint.security values.
2901
+ _xml_sec_re = re.compile(
2902
+ r'(?:xmlns(?::[a-z]+)?="http://www\.springframework\.org/schema/security"'
2903
+ r'|<security:http\b'
2904
+ r'|<http\s[^>]*use-expressions'
2905
+ r'|spring-security-[2345]'
2906
+ r'|xmlns:security="http://www\.springframework\.org/schema/security")',
2907
+ re.IGNORECASE,
2908
+ )
2909
+ _xml_sec_detected = False
2910
+ for _xml_glob in (
2911
+ "*security*.xml", "*Security*.xml",
2912
+ "*applicationContext*.xml", "*-context.xml", "*Context.xml",
2913
+ "*spring*.xml", "*Spring*.xml",
2914
+ ):
2915
+ for _xf in root.rglob(_xml_glob):
2916
+ if "target/" in str(_xf).replace("\\", "/"):
2917
+ continue
2918
+ try:
2919
+ _xt = _xf.read_text(encoding="utf-8", errors="replace")
2920
+ except OSError:
2921
+ continue
2922
+ if _xml_sec_re.search(_xt):
2923
+ _xml_sec_detected = True
2924
+ break
2925
+ if _xml_sec_detected:
2926
+ break
2927
+ if _xml_sec_detected:
2928
+ _sec_model = ir.get("security_model", "unknown")
2929
+ if _sec_model == "unknown":
2930
+ ir["security_model"] = "xml_or_filter_chain"
2931
+ elif _sec_model in ("annotation_based", "mixed"):
2932
+ ir["security_model"] = "mixed"
2933
+ # Retag route_surface entries that have no security (would become none_detected in CIR)
2934
+ for _r in ir.get("route_surface") or []:
2935
+ _r_sec = _r.get("security_annotations")
2936
+ if _r_sec is None or (isinstance(_r_sec, dict) and _r_sec.get("policy") == "none_detected"):
2937
+ _r["security_annotations"] = {"policy": "xml_or_filter_chain"}
2938
+
2939
+ # L-6: inject analysis_meta — files_read, lines_read, symbols_analyzed, token_estimate
2940
+ ir["analysis_meta"] = {
2941
+ "files_read": _meta_files_read,
2942
+ "lines_read": _meta_lines_read,
2943
+ "symbols_analyzed": len(all_symbols),
2944
+ "token_estimate": _meta_chars_read // 4, # 4 chars ≈ 1 token (rough approximation)
2945
+ }
2946
+ return ir
2887
2947
 
2888
2948
 
2889
2949
  # ---------------------------------------------------------------------------
@@ -3341,13 +3401,18 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
3341
3401
  if _xml_security_detected:
3342
3402
  break
3343
3403
 
3344
- if _xml_security_detected and security_model == "unknown":
3345
- security_model = "xml_or_filter_chain"
3346
- # Re-tag per-endpoint none_detected xml_or_filter_chain so the output
3347
- # cannot be misread as "endpoint is unprotected".
3404
+ if _xml_security_detected:
3405
+ # Re-tag per-endpoint none_detected → xml_or_filter_chain regardless of security_model.
3406
+ # BUG-7 fix: previously only ran when model == "unknown", causing false-positive SEC-001
3407
+ # when annotation security (@PreAuthorize) coexisted with XML security config.
3348
3408
  for ep in endpoints:
3349
3409
  if ep.get("security", {}).get("policy") == "none_detected":
3350
3410
  ep["security"] = {"policy": "xml_or_filter_chain"}
3411
+ if security_model == "unknown":
3412
+ security_model = "xml_or_filter_chain"
3413
+ elif security_model in ("annotation_based", "mixed"):
3414
+ security_model = "mixed"
3415
+ # filter_based stays filter_based — XML + filter chain is still filter_based
3351
3416
  # Recompute no_security_signal (now counts only truly unknown endpoints)
3352
3417
  no_security_signal = sum(
3353
3418
  1 for e in endpoints
@@ -3378,7 +3443,11 @@ def find_java_files(root: Path, *, max_files: int = 8000, limitations: list[str]
3378
3443
  continue
3379
3444
  parts = rel.split("/")
3380
3445
  # Skip test dirs
3381
- if "/test/" in rel or "/tests/" in rel or rel.startswith("test/"):
3446
+ if (
3447
+ "/src/test/" in rel or rel.startswith("src/test/")
3448
+ or "/src/tests/" in rel or rel.startswith("src/tests/")
3449
+ or rel.startswith("test/") or rel.startswith("tests/")
3450
+ ):
3382
3451
  continue
3383
3452
  # Skip vendor/generated/build dirs
3384
3453
  if any(part in _VENDOR_DIRS for part in parts[:-1]):
@@ -41,6 +41,8 @@ _CALL_SKIP: frozenset[str] = frozenset({"annotated_with", "mapped_to", "containe
41
41
  _BEAN_ANNOTATIONS: frozenset[str] = frozenset({
42
42
  "@Component", "@Service", "@Repository",
43
43
  "@Controller", "@RestController", "@Configuration", "@Bean",
44
+ # JPA persistence annotations — not Spring beans but need stereotype recognition in explain
45
+ "@Entity", "@MappedSuperclass", "@Embeddable",
44
46
  })
45
47
 
46
48
  _GENERIC_PARAM_RE = re.compile(r"<[A-Z][\w,\s<>?]*>")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.35.26
3
+ Version: 1.35.28
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.28-blue)
44
44
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
45
45
 
46
46
  ---
@@ -114,7 +114,9 @@ pipx install sourcecode
114
114
 
115
115
  ```bash
116
116
  sourcecode version
117
- # sourcecode 1.35.26
117
+ # sourcecode 1.35.28
118
+
119
+ **v1.35.28** — 7 bug fixes: `rename-class` cross-package disambiguation (BUG-4), `rename-class` collision detection (BUG-2), `find_java_files` false positive on `com/test/` package paths (BUG-1), `cold-start --compact` correct key names (BUG-6), `@EnableMethodSecurity` no longer suppresses SEC-001 (BUG-3), `explain` @Entity stereotype detection (BUG-5), XML+annotation mixed security retagging (BUG-7).
118
120
  ```
119
121
 
120
122
  ---
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=HYA42OYABbKZ43Trr1VCdpUkXMtz0AqpwPt0ENMTTjo,104
1
+ sourcecode/__init__.py,sha256=6hJmVTMbA3xWMi_K8kDZKhf82Qh1AoNAv1zZtaf2IEg,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=SZDc7biuDWEXYGn1kvvN4RqmWOA-GHnmJbGdHnYBdh0,246491
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
@@ -20,7 +20,8 @@ sourcecode/doc_analyzer.py,sha256=05bjTUbDbmnbajD_cgRnACzS8T7xxBKVX4CjkJlhZg8,24
20
20
  sourcecode/entrypoint_classifier.py,sha256=jhTYlyqDJH2AtdEcLVaRU3lYRTJuF8DkxVzl4-W3zWE,5322
21
21
  sourcecode/env_analyzer.py,sha256=aNTyYgQk5noJDfJU6FmasmESOHfiomyJw5EvZqjy6qc,22213
22
22
  sourcecode/error_schema.py,sha256=uwosfNaSujtYm11_732Hu92z5ITV040fQDaIyefSvR4,1683
23
- sourcecode/explain.py,sha256=cCRpR4L0goET8UR1iFTq4gQ-2TDCRTL7jGITMO09TE4,16556
23
+ sourcecode/explain.py,sha256=N5189hO8Ydbunr431zWDpSueSTdgBZh9l2xU-fH-AO8,16832
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=rWCsXoDxJNdsmkUXjPtHphT5CjYOgEPmcc817_8Gu-Y,12538
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=n--piFig_NjiGFRzQF8p2-UkLnspHh-4ZFIhUURg2ik,175044
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
@@ -50,7 +52,7 @@ sourcecode/serializer.py,sha256=7SBJIbpC_Lg0RGWq8jjNbF5TiuZwoP_fi0qhHnzQM8M,1243
50
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
- sourcecode/spring_model.py,sha256=IzMcM5ftw1_EHG3FGUDT7qdAMpo3eqbAE1LRuasfr_4,14739
55
+ sourcecode/spring_model.py,sha256=6Lk3rGGFy2suq867S8Da_aCNAXtSGJ36XBaQd9VNTFc,14888
54
56
  sourcecode/spring_security_audit.py,sha256=AmUkqoExkNZ3YxxZf9TwkwX-f7P_SETm0QC7VqEAqh4,20618
55
57
  sourcecode/spring_semantic.py,sha256=CiAf77p48-RFrUF0zbgww4w2Xigrbo1t5M3ZCDIfV_g,12032
56
58
  sourcecode/spring_tx_analyzer.py,sha256=u4_ckdEFZUiIsHdUX4OaIhnvoTdAwrxNTFweG6vc7wE,30526
@@ -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.28.dist-info/METADATA,sha256=2IgUuaTJL4deTV1f5JN_uZoyxnHCr_pj4nSptbPpnfo,21705
100
+ sourcecode-1.35.28.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
101
+ sourcecode-1.35.28.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
102
+ sourcecode-1.35.28.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
103
+ sourcecode-1.35.28.dist-info/RECORD,,