sourcecode 1.30.17__py3-none-any.whl → 1.30.19__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.30.17"
3
+ __version__ = "1.30.19"
sourcecode/cli.py CHANGED
@@ -1648,6 +1648,12 @@ def prepare_context_cmd(
1648
1648
  "--format",
1649
1649
  help="Output format: json (default) | github-comment (Markdown PR comment for review-pr task)",
1650
1650
  ),
1651
+ debug_perf: bool = typer.Option(
1652
+ False,
1653
+ "--debug-perf",
1654
+ help="Emit per-phase timing to stderr (git scan ms, symptom scoring ms, total ms)",
1655
+ hidden=True,
1656
+ ),
1651
1657
  ) -> None:
1652
1658
  """Task-specific context for AI coding agents.
1653
1659
 
@@ -1714,9 +1720,21 @@ def prepare_context_cmd(
1714
1720
  raise typer.Exit()
1715
1721
 
1716
1722
  from dataclasses import asdict
1723
+ import time as _time
1717
1724
 
1718
1725
  builder = TaskContextBuilder(target)
1726
+ _t0 = _time.perf_counter()
1719
1727
  output = builder.build(task, since=since, symptom=symptom)
1728
+ _t_total = (_time.perf_counter() - _t0) * 1000
1729
+
1730
+ if debug_perf:
1731
+ _perf = getattr(output, "_perf_ms", {}) or {}
1732
+ typer.echo(
1733
+ f"[debug-perf] total={_t_total:.0f}ms"
1734
+ + (f" git_scan={_perf.get('git_scan_ms', '?')}ms" if "git_scan_ms" in _perf else "")
1735
+ + (f" symptom={_perf.get('symptom_ms', '?')}ms" if "symptom_ms" in _perf else ""),
1736
+ err=True,
1737
+ )
1720
1738
 
1721
1739
  # Task-specific content-filter: each task emphasizes different output fields.
1722
1740
  # Fields marked False are suppressed from this task's output to reduce noise.
@@ -1918,6 +1936,8 @@ def prepare_context_cmd(
1918
1936
  out["related_notes"] = output.related_notes
1919
1937
  if output.symptom_note:
1920
1938
  out["symptom_note"] = output.symptom_note
1939
+ if output.symptom_explain:
1940
+ out["symptom_explain"] = output.symptom_explain
1921
1941
  if llm_prompt:
1922
1942
  out["llm_prompt"] = builder.render_prompt(output)
1923
1943
 
@@ -2012,12 +2032,33 @@ def repo_ir_cmd(
2012
2032
  "--include-tests",
2013
2033
  help="Include test files in analysis (excluded by default)",
2014
2034
  ),
2035
+ max_nodes: Optional[int] = typer.Option(
2036
+ None,
2037
+ "--max-nodes",
2038
+ help="Limit graph.nodes to top N by impact score (reduces output size)",
2039
+ ),
2040
+ max_edges: Optional[int] = typer.Option(
2041
+ None,
2042
+ "--max-edges",
2043
+ help="Limit graph.edges to N (priority: edges between kept nodes)",
2044
+ ),
2045
+ summary_only: bool = typer.Option(
2046
+ False,
2047
+ "--summary-only",
2048
+ help="Omit full graph.nodes/edges; keep analysis summary, impact, and change_set (<300KB typical)",
2049
+ ),
2015
2050
  ) -> None:
2016
2051
  """Deterministic symbol-level IR for Java repositories.
2017
2052
 
2018
2053
  \b
2019
2054
  Extracts symbols, relations, Spring roles, and (with --since) symbol-level diffs.
2020
- Output is JSON: symbols[], relations[], changed_symbols[], spring_summary, graph_metadata.
2055
+ Output is JSON: graph{nodes,edges}, analysis, impact, subsystems, change_set.
2056
+
2057
+ \b
2058
+ Size control:
2059
+ --summary-only Omit full graph; keep analysis + impact (smallest output)
2060
+ --max-nodes N Keep top N nodes by score
2061
+ --max-edges N Keep top N edges (priority: both endpoints kept)
2021
2062
 
2022
2063
  \b
2023
2064
  Examples:
@@ -2025,11 +2066,13 @@ def repo_ir_cmd(
2025
2066
  sourcecode repo-ir /path/to/repo --since HEAD~1
2026
2067
  sourcecode repo-ir --files src/main/java/UserService.java
2027
2068
  sourcecode repo-ir --since main --output ir.json
2069
+ sourcecode repo-ir --since HEAD~3 --summary-only --output ir-small.json
2070
+ sourcecode repo-ir --max-nodes 200 --max-edges 500
2028
2071
  """
2029
2072
  import json as _json
2030
2073
  import sys as _sys
2031
2074
 
2032
- from sourcecode.repository_ir import build_repo_ir, find_java_files
2075
+ from sourcecode.repository_ir import apply_ir_size_limits, build_repo_ir, find_java_files
2033
2076
 
2034
2077
  root = path.resolve()
2035
2078
  if not root.is_dir():
@@ -2063,11 +2106,24 @@ def repo_ir_cmd(
2063
2106
  return
2064
2107
 
2065
2108
  ir = build_repo_ir(file_list, root, since=since)
2109
+ ir = apply_ir_size_limits(
2110
+ ir,
2111
+ max_nodes=max_nodes,
2112
+ max_edges=max_edges,
2113
+ summary_only=summary_only,
2114
+ )
2066
2115
  output = _json.dumps(ir, indent=2, ensure_ascii=False)
2067
2116
 
2068
2117
  if output_path:
2069
2118
  output_path.write_text(output, encoding="utf-8")
2070
- typer.echo(f"IR written to {output_path}", err=True)
2119
+ n_nodes = len((ir.get("graph") or {}).get("nodes") or [])
2120
+ n_edges = len((ir.get("graph") or {}).get("edges") or [])
2121
+ size_kb = len(output.encode("utf-8")) // 1024
2122
+ typer.echo(
2123
+ f"IR written to {output_path} "
2124
+ f"({size_kb}KB, {n_nodes} nodes, {n_edges} edges)",
2125
+ err=True,
2126
+ )
2071
2127
  else:
2072
2128
  _sys.stdout.write(output)
2073
2129
  _sys.stdout.write("\n")
@@ -623,6 +623,8 @@ def _find_symbol_files(
623
623
  cwd=str(root),
624
624
  capture_output=True,
625
625
  text=True,
626
+ encoding="utf-8",
627
+ errors="replace",
626
628
  timeout=20,
627
629
  )
628
630
  for line in result.stdout.splitlines():
@@ -52,8 +52,15 @@ _HOTSPOT_AUX_DIRS: frozenset[str] = frozenset({
52
52
  "sandbox", "sandboxes",
53
53
  "ci", "translations", "locales", "locale", "i18n", "l10n",
54
54
  ".planning",
55
+ # vendor / build dirs: high path count, zero operational signal
56
+ "vendor", "node_modules", "dist", "target", "build",
57
+ ".gradle", ".mvn", "generated-sources", "generated-resources",
55
58
  })
56
59
 
60
+ # When git diff returns more changed files than this, degrade gracefully:
61
+ # truncate hotspot analysis depth and emit a structured warning.
62
+ _CHANGED_FILES_DEGRADATION_THRESHOLD = 200
63
+
57
64
 
58
65
  def _run_git(args: list[str], cwd: Path, timeout: int = 15) -> tuple[str, int]:
59
66
  result = subprocess.run(
@@ -333,6 +333,7 @@ class TaskOutput:
333
333
  symptom: Optional[str] = None # fix-bug only
334
334
  related_notes: list[dict] = field(default_factory=list) # fix-bug + symptom only
335
335
  symptom_note: Optional[str] = None # fix-bug: cross-layer synonym note
336
+ symptom_explain: Optional[dict] = None # fix-bug: structured evidence breakdown
336
337
  # delta-specific impact fields
337
338
  impact_summary: Optional[str] = None
338
339
  affected_modules: list[str] = field(default_factory=list)
@@ -561,17 +562,23 @@ _ARTIFACT_CHANGE_EFFECT: dict[str, str] = {
561
562
  # Maps frontend symptom keywords → backend terms likely to contain the root cause.
562
563
  # Used to boost service/interceptor files when the symptom is UI-only.
563
564
  _FRONTEND_SYMPTOM_MAP: dict[str, list[str]] = {
564
- "spinner": ["loading", "setloading", "finalize", "httpinterceptor", "interceptor", "service"],
565
- "loading": ["loading", "setloading", "finalize", "httpinterceptor", "interceptor", "service"],
566
- "login": ["authcontroller", "securityconfig", "filterconfig", "jwtfilter", "auth", "authentication"],
567
- "logout": ["authcontroller", "securityconfig", "jwtfilter", "auth", "session"],
568
- "dropdown": ["getmapping", "findall", "obtenertodos", "listall", "findby"],
569
- "modal": ["controller", "getmapping", "findby", "search"],
570
- "popup": ["controller", "getmapping", "findby", "search"],
571
- "table": ["paginated", "findby", "search", "getmapping", "listall"],
572
- "grid": ["paginated", "findby", "search", "getmapping"],
573
- "button": ["postmapping", "putmapping", "deletemapping", "controller", "service"],
574
- "form": ["postmapping", "putmapping", "controller", "service", "dto"],
565
+ "spinner": ["loading", "setloading", "finalize", "httpinterceptor", "interceptor", "service"],
566
+ "loading": ["loading", "setloading", "finalize", "httpinterceptor", "interceptor", "service"],
567
+ "login": ["authcontroller", "securityconfig", "filterconfig", "jwtfilter", "auth", "authentication"],
568
+ "logout": ["authcontroller", "securityconfig", "jwtfilter", "auth", "session"],
569
+ "dropdown": ["getmapping", "findall", "obtenertodos", "listall", "findby"],
570
+ "modal": ["controller", "getmapping", "findby", "search"],
571
+ "popup": ["controller", "getmapping", "findby", "search"],
572
+ "table": ["paginated", "findby", "search", "getmapping", "listall"],
573
+ "grid": ["paginated", "findby", "search", "getmapping"],
574
+ "button": ["postmapping", "putmapping", "deletemapping", "controller", "service"],
575
+ "form": ["postmapping", "putmapping", "controller", "service", "dto"],
576
+ # session-related symptoms (Spanish + English)
577
+ "sesion": ["httpsession", "sessionmanager", "sessionservice", "sessionrepository", "sessionfactory", "authentication"],
578
+ "sesiones": ["httpsession", "sessionmanager", "sessionservice", "sessionrepository", "sessionfactory", "authentication"],
579
+ "session": ["httpsession", "sessionmanager", "sessionservice", "sessionrepository", "sessionfactory", "authentication"],
580
+ # worker/assignment domain terms (common in RRHH/HR systems)
581
+ "trabajador": ["trabajador", "empleado", "worker", "asignacion", "trabajadordao", "trabajadorservice"],
575
582
  }
576
583
 
577
584
 
@@ -812,7 +819,7 @@ class TaskContextBuilder:
812
819
  if not self._is_source(_p) or self._is_test(_p):
813
820
  continue
814
821
  try:
815
- _loc = len((self.root / _p).read_text(errors="replace").splitlines())
822
+ _loc = len((self.root / _p).read_text(encoding="utf-8", errors="replace").splitlines())
816
823
  if _loc > 200:
817
824
  _large.append((_loc, _p))
818
825
  except OSError:
@@ -826,12 +833,14 @@ class TaskContextBuilder:
826
833
  # ── 5b. Git signals for ranking ────────────────────────────────────
827
834
  git_hotspots: dict[str, int] = {}
828
835
  uncommitted_files: set[str] = set()
836
+ _recent_commits_for_symptom: list = []
829
837
  try:
830
838
  from sourcecode.git_analyzer import GitAnalyzer
831
839
  _gc = GitAnalyzer().analyze(self.root, depth=30, days=90)
832
840
  _bad = {"no_git_repo", "git_not_found", "git_timeout"}
833
841
  if _gc and not (_bad & set(_gc.limitations)):
834
842
  git_hotspots = {h.file: h.commit_count for h in _gc.change_hotspots}
843
+ _recent_commits_for_symptom = list(_gc.recent_commits)
835
844
  if _gc.uncommitted_changes:
836
845
  _uc = _gc.uncommitted_changes
837
846
  uncommitted_files = set(_uc.staged) | set(_uc.unstaged)
@@ -1199,6 +1208,7 @@ class TaskContextBuilder:
1199
1208
  symptom_keywords: list[str] = []
1200
1209
  related_notes: list[dict] = []
1201
1210
  symptom_note: Optional[str] = None
1211
+ symptom_explain: Optional[dict] = None
1202
1212
  if task_name == "fix-bug" and symptom:
1203
1213
  import re as _re
1204
1214
  _camel_expanded = _re.sub(r'([a-z])([A-Z])', r'\1 \2', symptom)
@@ -1208,19 +1218,68 @@ class TaskContextBuilder:
1208
1218
  if len(w) > 2
1209
1219
  ]
1210
1220
  if symptom_keywords:
1211
- # Surface code notes whose text contains any keyword
1221
+ # Pre-compile combined keyword pattern for fast content scanning
1222
+ _kw_re = _re.compile(
1223
+ "|".join(_re.escape(kw) for kw in symptom_keywords),
1224
+ _re.IGNORECASE,
1225
+ )
1226
+
1227
+ # Structured evidence collectors for symptom_explain
1228
+ _sx_direct_path: list[str] = []
1229
+ _sx_content: list[str] = []
1230
+ _sx_commits: list[dict] = []
1231
+ _sx_synonyms: list[str] = []
1232
+ _sx_boosts: list[dict] = []
1233
+
1234
+ # Pass 1: surface code notes whose text contains any keyword
1235
+ _note_matched_paths: dict[str, int] = {} # path → count of matching notes
1212
1236
  for _n in cn_notes_for_ranking:
1213
1237
  _text = (getattr(_n, "text", "") or "").lower()
1214
- if any(kw in _text for kw in symptom_keywords):
1238
+ if _kw_re.search(_text):
1239
+ _np = getattr(_n, "path", "")
1215
1240
  related_notes.append({
1216
1241
  "kind": getattr(_n, "kind", ""),
1217
- "path": getattr(_n, "path", ""),
1242
+ "path": _np,
1218
1243
  "line": getattr(_n, "line", None),
1219
1244
  "text": getattr(_n, "text", ""),
1220
1245
  })
1221
- # Secondary pass: inject files whose path matches symptom keywords
1222
- # but weren't in the candidate pool (no structural/git signals).
1246
+ _note_matched_paths[_np] = _note_matched_paths.get(_np, 0) + 1
1247
+
1248
+ # Pass 2: build commit message index — cap at 60 most-recent commits.
1249
+ # Files touched in commits whose message matches a symptom keyword get
1250
+ # a strong recency signal. Primary signal for domain keywords ("sesiones")
1251
+ # that appear in commit messages but not file paths.
1252
+ _commit_file_hits: dict[str, int] = {} # path → n matching commits
1253
+ _commits_scanned = _recent_commits_for_symptom[:60]
1254
+ for _cr in _commits_scanned:
1255
+ _msg_lower = (_cr.message or "").lower()
1256
+ if _kw_re.search(_msg_lower):
1257
+ for _cf in (_cr.files_changed or []):
1258
+ _cf_norm = _cf.replace("\\", "/")
1259
+ _commit_file_hits[_cf_norm] = _commit_file_hits.get(_cf_norm, 0) + 1
1260
+ _sx_commits.append({
1261
+ "message": (_cr.message or "")[:80],
1262
+ "files": list((_cr.files_changed or [])[:5]),
1263
+ })
1264
+
1265
+ # Pass 3: inject files from commit index not yet in candidate pool
1223
1266
  _existing_paths = {rf.path for rf in relevant_files}
1267
+ for _cp, _nhits in _commit_file_hits.items():
1268
+ if _cp in _existing_paths:
1269
+ continue
1270
+ if Path(_cp).suffix.lower() not in _ALL_EXTENSIONS:
1271
+ continue
1272
+ _ci_score = round(min(0.5 + 0.15 * _nhits, 0.85), 2)
1273
+ relevant_files.append(RelevantFile(
1274
+ path=_cp,
1275
+ role="symptom_match",
1276
+ score=_ci_score,
1277
+ reason=f"commit message matches symptom ({_nhits} commit{'s' if _nhits > 1 else ''})",
1278
+ why=f"symptom commit-index: {', '.join(symptom_keywords)}",
1279
+ ))
1280
+ _existing_paths.add(_cp)
1281
+
1282
+ # Pass 4: inject files whose path matches symptom keywords
1224
1283
  for _p in all_paths:
1225
1284
  if _p in _existing_paths:
1226
1285
  continue
@@ -1241,72 +1300,134 @@ class TaskContextBuilder:
1241
1300
  why=f"symptom injection: {', '.join(_matching_kws)}",
1242
1301
  ))
1243
1302
  _existing_paths.add(_p)
1303
+ _sx_direct_path.append(_p)
1244
1304
 
1245
- # Re-rank all relevant_files: boost files whose path matches keywords
1246
- def _symptom_score(rf: "RelevantFile") -> float:
1247
- path_lower = rf.path.lower()
1248
- return rf.score + 0.2 * sum(1.0 for kw in symptom_keywords if kw in path_lower)
1249
- relevant_files = sorted(relevant_files, key=lambda rf: -_symptom_score(rf))
1250
-
1251
- # Content scan boost: read file body for symptom keywords
1305
+ # Pass 5+6 combined: single file read per candidate.
1306
+ # Limit content scan to top 80 candidates by current score to bound I/O.
1252
1307
  _src_exts = frozenset({".java", ".py", ".ts", ".js", ".kt", ".go"})
1253
- _content_boosted: list[RelevantFile] = []
1254
- for _rf in relevant_files:
1308
+ _frontend_kws = [kw for kw in symptom_keywords if kw in _FRONTEND_SYMPTOM_MAP]
1309
+ _backend_terms_set: list[str] = []
1310
+ if _frontend_kws:
1311
+ _bt: list[str] = []
1312
+ for _fkw in _frontend_kws:
1313
+ _bt.extend(_FRONTEND_SYMPTOM_MAP[_fkw])
1314
+ _backend_terms_set = list(dict.fromkeys(_bt))
1315
+
1316
+ # Sort before content scan so top candidates get read first
1317
+ relevant_files = sorted(relevant_files, key=lambda rf: -rf.score)
1318
+ _CONTENT_SCAN_LIMIT = 80
1319
+ _scan_candidates = relevant_files[:_CONTENT_SCAN_LIMIT]
1320
+ _no_scan_candidates = relevant_files[_CONTENT_SCAN_LIMIT:]
1321
+
1322
+ _boosted: list[RelevantFile] = []
1323
+ for _rf in _scan_candidates:
1255
1324
  _extra = 0.0
1325
+ _extra_syn = 0.0
1326
+ _reasons: list[str] = []
1327
+ _p_lower = _rf.path.lower()
1328
+
1329
+ # Commit message boost: +0.25/commit, cap +0.40
1330
+ _c_hits = _commit_file_hits.get(_rf.path, 0)
1331
+ if _c_hits:
1332
+ _cb = min(0.40, _c_hits * 0.25)
1333
+ _extra += _cb
1334
+ _reasons.append(f"commit-msg symptom ×{_c_hits} (+{_cb:.2f})")
1335
+ _sx_boosts.append({"type": "commit_message", "value": round(_cb, 3), "evidence": _rf.path})
1336
+
1337
+ # Code note boost: +0.20/note, cap +0.30
1338
+ _n_hits = _note_matched_paths.get(_rf.path, 0)
1339
+ if _n_hits:
1340
+ _nb = min(0.30, _n_hits * 0.20)
1341
+ _extra += _nb
1342
+ _reasons.append(f"note-match symptom ×{_n_hits} (+{_nb:.2f})")
1343
+ _sx_boosts.append({"type": "code_note", "value": round(_nb, 3), "evidence": _rf.path})
1344
+
1345
+ # Path keyword boost for pre-existing candidates
1346
+ _path_kws = [kw for kw in symptom_keywords if kw in _p_lower]
1347
+ if _path_kws and _rf.role != "symptom_match":
1348
+ _pb = 0.20 * len(_path_kws)
1349
+ _extra += _pb
1350
+ _reasons.append(f"path-kw symptom ({', '.join(_path_kws)}) (+{_pb:.2f})")
1351
+ _sx_boosts.append({"type": "path_match", "value": round(_pb, 3), "evidence": _rf.path})
1352
+
1353
+ # Single file read — covers both content scan and synonym scan
1354
+ _body_lower = ""
1256
1355
  if Path(_rf.path).suffix.lower() in _src_exts:
1257
1356
  try:
1258
- _lines = (self.root / _rf.path).read_text(
1357
+ _body_lower = (self.root / _rf.path).read_text(
1259
1358
  encoding="utf-8", errors="replace"
1260
- ).splitlines()[:300]
1261
- _body = "\n".join(_lines).lower()
1262
- _hits = sum(_body.count(kw) for kw in symptom_keywords)
1263
- _extra = min(0.30, _hits * 0.02)
1359
+ )[:12000].lower() # ~300 lines avg
1264
1360
  except OSError:
1265
1361
  pass
1266
- _content_boosted.append(RelevantFile(
1362
+
1363
+ # Content scan boost: +0.05/hit, cap +0.50
1364
+ if _body_lower:
1365
+ _hits = len(_kw_re.findall(_body_lower))
1366
+ _content_b = min(0.50, _hits * 0.05)
1367
+ if _content_b > 0:
1368
+ _extra += _content_b
1369
+ _reasons.append(f"content-match symptom ×{_hits} (+{_content_b:.2f})")
1370
+ _sx_boosts.append({"type": "content_match", "value": round(_content_b, 3), "evidence": _rf.path})
1371
+ _sx_content.append(_rf.path)
1372
+
1373
+ # Synonym scan (Pass 6): only apply when file has prior non-synonym
1374
+ # evidence (commit hit, note, path, or content) — prevents boosting
1375
+ # arbitrary interceptors/configs with no other signal.
1376
+ if _backend_terms_set and _body_lower:
1377
+ _prior_boost = _extra # boost accumulated before synonym
1378
+ if _prior_boost >= 0.10: # min threshold: must have real prior signal
1379
+ _hits_syn = sum(_body_lower.count(t) for t in _backend_terms_set)
1380
+ _extra_syn = min(0.20, _hits_syn * 0.02)
1381
+ if _extra_syn > 0:
1382
+ _sx_synonyms.append(_rf.path)
1383
+ _sx_boosts.append({"type": "synonym_match", "value": round(_extra_syn, 3), "evidence": _rf.path})
1384
+
1385
+ _total_extra = _extra + _extra_syn
1386
+ _new_reason = _rf.reason
1387
+ if _reasons:
1388
+ _syn_suffix = f", synonym-match backend (+{_extra_syn:.2f})" if _extra_syn > 0 else ""
1389
+ _new_reason = _rf.reason + ", " + ", ".join(_reasons) + _syn_suffix
1390
+ elif _extra_syn > 0:
1391
+ _new_reason = _rf.reason + f", synonym-match backend (+{_extra_syn:.2f})"
1392
+
1393
+ _boosted.append(RelevantFile(
1267
1394
  path=_rf.path,
1268
1395
  role=_rf.role,
1269
- score=round(min(_rf.score + _extra, 1.0), 2),
1270
- reason=_rf.reason + (f", content-match symptom (+{_extra:.2f})" if _extra > 0 else ""),
1396
+ score=round(min(_rf.score + _total_extra, 1.0), 2),
1397
+ reason=_new_reason,
1271
1398
  why=_rf.why,
1272
1399
  ))
1273
- relevant_files = sorted(_content_boosted, key=lambda rf: -rf.score)
1274
1400
 
1275
- # Cross-layer synonym boost: frontend keywords backend equivalents
1276
- _synonym_note: Optional[str] = None
1277
- _frontend_kws = [kw for kw in symptom_keywords if kw in _FRONTEND_SYMPTOM_MAP]
1278
- if _frontend_kws:
1279
- _backend_terms: list[str] = []
1280
- for _fkw in _frontend_kws:
1281
- _backend_terms.extend(_FRONTEND_SYMPTOM_MAP[_fkw])
1282
- _backend_terms_set = list(dict.fromkeys(_backend_terms)) # dedup, preserve order
1283
- _synonym_boosted: list[RelevantFile] = []
1284
- for _rf in relevant_files:
1285
- _extra_syn = 0.0
1286
- if Path(_rf.path).suffix.lower() in _src_exts:
1287
- try:
1288
- _lines_syn = (self.root / _rf.path).read_text(
1289
- encoding="utf-8", errors="replace"
1290
- ).splitlines()[:300]
1291
- _body_syn = "\n".join(_lines_syn).lower()
1292
- _hits_syn = sum(_body_syn.count(t) for t in _backend_terms_set)
1293
- _extra_syn = min(0.20, _hits_syn * 0.02)
1294
- except OSError:
1295
- pass
1296
- _synonym_boosted.append(RelevantFile(
1297
- path=_rf.path,
1298
- role=_rf.role,
1299
- score=round(min(_rf.score + _extra_syn, 1.0), 2),
1300
- reason=_rf.reason + (f", synonym-match backend (+{_extra_syn:.2f})" if _extra_syn > 0 else ""),
1301
- why=_rf.why,
1302
- ))
1303
- relevant_files = sorted(_synonym_boosted, key=lambda rf: -rf.score)
1304
- _synonym_note = (
1401
+ relevant_files = sorted(_boosted + _no_scan_candidates, key=lambda rf: -rf.score)
1402
+
1403
+ # Synonym note (only when synonyms actually fired)
1404
+ if _frontend_kws and _sx_synonyms:
1405
+ symptom_note = (
1305
1406
  f"Frontend concept detected ({', '.join(_frontend_kws)}). "
1306
- "Backend service-layer and interceptor files boosted by symptom keyword match "
1407
+ "Backend service-layer files boosted by synonym match "
1307
1408
  "[INFERRED (LOW CONFIDENCE) — pattern heuristic, not structural proof]."
1308
1409
  )
1309
- symptom_note = _synonym_note
1410
+
1411
+ # Confidence: based on richest signal type present
1412
+ if _commit_file_hits:
1413
+ _sx_confidence = "HIGH"
1414
+ elif _sx_direct_path or _sx_content:
1415
+ _sx_confidence = "MEDIUM"
1416
+ else:
1417
+ _sx_confidence = "LOW"
1418
+
1419
+ symptom_explain = {
1420
+ "keywords": symptom_keywords,
1421
+ "confidence": _sx_confidence,
1422
+ "direct_path_matches": _sx_direct_path[:10],
1423
+ "content_matches": _sx_content[:10],
1424
+ "commit_matches": _sx_commits[:10],
1425
+ "synonym_matches": _sx_synonyms[:10],
1426
+ "boosts": _sx_boosts[:30],
1427
+ "final_boost": round(
1428
+ sum(b["value"] for b in _sx_boosts), 3
1429
+ ),
1430
+ }
1310
1431
 
1311
1432
  # ── 7. Test gaps (generate-tests only) ────────────────────────────
1312
1433
  test_gaps: list[str] = []
@@ -1405,6 +1526,7 @@ class TaskContextBuilder:
1405
1526
  symptom=symptom if task_name == "fix-bug" and symptom else None,
1406
1527
  related_notes=related_notes,
1407
1528
  symptom_note=symptom_note,
1529
+ symptom_explain=symptom_explain if task_name == "fix-bug" else None,
1408
1530
  impact_summary=_delta_impact_summary,
1409
1531
  affected_modules=_delta_affected_modules,
1410
1532
  risk_areas=_delta_risk_areas,
@@ -997,6 +997,8 @@ def _get_git_old_content(git_root: Path, rel_path: str, since: str) -> Optional[
997
997
  cwd=str(git_root),
998
998
  capture_output=True,
999
999
  text=True,
1000
+ encoding="utf-8",
1001
+ errors="replace",
1000
1002
  timeout=5,
1001
1003
  )
1002
1004
  if result.returncode == 0:
@@ -1542,12 +1544,99 @@ def build_repo_ir(
1542
1544
  return _assemble(all_symbols, unique_relations, all_changed, spring_summary, all_route_diffs_sorted)
1543
1545
 
1544
1546
 
1547
+ # ---------------------------------------------------------------------------
1548
+ # Output size limits
1549
+ # ---------------------------------------------------------------------------
1550
+
1551
+ # Vendor/generated dirs to skip when finding Java files and in git analysis.
1552
+ _VENDOR_DIRS: frozenset[str] = frozenset({
1553
+ "vendor", "node_modules", "dist", "target", "build",
1554
+ ".gradle", ".mvn", "generated", "generated-sources",
1555
+ "generated-resources",
1556
+ })
1557
+
1558
+
1559
+ def apply_ir_size_limits(
1560
+ ir: dict,
1561
+ *,
1562
+ max_nodes: Optional[int] = None,
1563
+ max_edges: Optional[int] = None,
1564
+ summary_only: bool = False,
1565
+ ) -> dict:
1566
+ """Apply size limits to a repo-ir output dict. Non-destructive: returns new dict.
1567
+
1568
+ Node ordering: top-ranked (by impact score) nodes are kept first.
1569
+ Edge priority: edges connecting two kept nodes over cross-boundary edges.
1570
+ """
1571
+ if not max_nodes and not max_edges and not summary_only:
1572
+ return ir
1573
+
1574
+ out = dict(ir)
1575
+ graph = ir.get("graph") or {}
1576
+ nodes: list[dict] = list(graph.get("nodes") or [])
1577
+ edges: list[dict] = list(graph.get("edges") or [])
1578
+ ranked: list[dict] = list((ir.get("impact") or {}).get("ranked_nodes") or [])
1579
+ analysis: dict = ir.get("analysis") or {}
1580
+
1581
+ if summary_only:
1582
+ n_nodes, n_edges = len(nodes), len(edges)
1583
+ out["graph"] = {
1584
+ "nodes": [],
1585
+ "edges": [],
1586
+ "_omitted": (
1587
+ f"{n_nodes} nodes and {n_edges} edges omitted — "
1588
+ "remove --summary-only to restore full graph"
1589
+ ),
1590
+ }
1591
+ out["reverse_graph"] = {}
1592
+ out["impact"] = {
1593
+ "global_score": (ir.get("impact") or {}).get("global_score", 0),
1594
+ "ranked_nodes": ranked[:20],
1595
+ }
1596
+ out["analysis"] = {
1597
+ "changed_entities": analysis.get("changed_entities") or [],
1598
+ "impacted_entities": (analysis.get("impacted_entities") or [])[:20],
1599
+ "isolated_changes": analysis.get("isolated_changes") or [],
1600
+ "validated_changes": analysis.get("validated_changes") or [],
1601
+ }
1602
+ return out
1603
+
1604
+ # Build score map from ranked_nodes (already sorted -score, fqn)
1605
+ score_map: dict[str, float] = {rn["entity"]: rn["score"] for rn in ranked}
1606
+ kept_fqns: Optional[set[str]] = None
1607
+
1608
+ if max_nodes is not None and len(nodes) > max_nodes:
1609
+ nodes_sorted = sorted(
1610
+ nodes,
1611
+ key=lambda n: (-score_map.get(n["fqn"], 0.0), n["fqn"]),
1612
+ )
1613
+ nodes = nodes_sorted[:max_nodes]
1614
+ kept_fqns = {n["fqn"] for n in nodes}
1615
+ ranked = [rn for rn in ranked if rn["entity"] in kept_fqns]
1616
+
1617
+ if kept_fqns is not None or max_edges is not None:
1618
+ if kept_fqns is not None:
1619
+ # Priority: edges where both endpoints are kept nodes
1620
+ priority = [e for e in edges if e["from"] in kept_fqns and e["to"] in kept_fqns]
1621
+ rest = [e for e in edges if not (e["from"] in kept_fqns and e["to"] in kept_fqns)]
1622
+ edges = priority + rest
1623
+ if max_edges is not None:
1624
+ edges = edges[:max_edges]
1625
+
1626
+ out["graph"] = {"nodes": nodes, "edges": edges}
1627
+ out["impact"] = {
1628
+ "global_score": (ir.get("impact") or {}).get("global_score", 0),
1629
+ "ranked_nodes": ranked,
1630
+ }
1631
+ return out
1632
+
1633
+
1545
1634
  # ---------------------------------------------------------------------------
1546
1635
  # Convenience: find Java files in a repo
1547
1636
  # ---------------------------------------------------------------------------
1548
1637
 
1549
1638
  def find_java_files(root: Path, *, max_files: int = 500) -> list[str]:
1550
- """Return relative paths to Java files under root, excluding test dirs."""
1639
+ """Return relative paths to Java files under root, excluding test dirs and vendor."""
1551
1640
  results: list[str] = []
1552
1641
  for p in sorted(root.rglob("*.java")):
1553
1642
  if len(results) >= max_files:
@@ -1556,7 +1645,12 @@ def find_java_files(root: Path, *, max_files: int = 500) -> list[str]:
1556
1645
  rel = str(p.relative_to(root)).replace("\\", "/")
1557
1646
  except ValueError:
1558
1647
  continue
1648
+ parts = rel.split("/")
1649
+ # Skip test dirs
1559
1650
  if "/test/" in rel or "/tests/" in rel or rel.startswith("test/"):
1560
1651
  continue
1652
+ # Skip vendor/generated/build dirs
1653
+ if any(part in _VENDOR_DIRS for part in parts[:-1]):
1654
+ continue
1561
1655
  results.append(rel)
1562
1656
  return results
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.30.17
3
+ Version: 1.30.19
4
4
  Summary: Deterministic codebase context for AI coding agents
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -221,7 +221,7 @@ Description-Content-Type: text/markdown
221
221
 
222
222
  **Deterministic, behavior-aware codebase context for AI agents and PR review.**
223
223
 
224
- ![Version](https://img.shields.io/badge/version-1.30.17-blue)
224
+ ![Version](https://img.shields.io/badge/version-1.30.19-blue)
225
225
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
226
226
 
227
227
  ---
@@ -257,7 +257,7 @@ pipx install sourcecode
257
257
 
258
258
  ```bash
259
259
  sourcecode version
260
- # sourcecode 1.30.17
260
+ # sourcecode 1.30.19
261
261
  ```
262
262
 
263
263
  ---
@@ -1,16 +1,16 @@
1
- sourcecode/__init__.py,sha256=C0dqdKsiSL2QvbpNXAjIuRvmS92c_QLcSsSuvX42hRE,104
1
+ sourcecode/__init__.py,sha256=INKPCnhh5ICq3yZ7MYdKW9iFC8jUinfGzMfJk94LSC0,104
2
2
  sourcecode/adaptive_scanner.py,sha256=RTNExwWPXzjgLaRueT7UuxkPj5ZEToWjGbx1j0LSZ9E,10250
3
3
  sourcecode/architecture_analyzer.py,sha256=MyBa0Hf5HmkudZQDLKrjcWDKETXETXl0mQX1swtTwAA,39091
4
4
  sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
5
5
  sourcecode/ast_extractor.py,sha256=XgrZg2DcWcUm9r87cRG3KGO7IK2TIL_N-CvhSbUmmh4,49901
6
6
  sourcecode/classifier.py,sha256=pYve2J1LqtYssU3lYLMDz18PT-CjN5c18QYE7R_IG1Q,7507
7
- sourcecode/cli.py,sha256=SQfchQoeVZB7ATLOTL_fimtgqmUORZE6rUG-vSiySJc,85829
7
+ sourcecode/cli.py,sha256=an8qIJRnWekBUSyaLod6pD7VyU56V38EotFHDnXlNs8,87996
8
8
  sourcecode/code_notes_analyzer.py,sha256=y1MJBnPZHYp4i6cQCXUb9ATIyifS_qMQWjw_8lPkpsU,9215
9
9
  sourcecode/confidence_analyzer.py,sha256=xw_Jv8pAd0wd8t2vvQlorw8Ih0rSF3YCoFS8K-_4aXg,15762
10
10
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
11
11
  sourcecode/context_summarizer.py,sha256=CiQrfBEzun949bWvmLabWoj2HhPn6Lw62ofqnsy0FlQ,6503
12
12
  sourcecode/contract_model.py,sha256=nRxJKPMs1VHwFTa8AVXhGmaLjti3Lr2sjHDpWgv1bfE,3917
13
- sourcecode/contract_pipeline.py,sha256=VEOvmj-emKuUT-GUosde9FRYyeH8szNW1WSnisNSs2o,27592
13
+ sourcecode/contract_pipeline.py,sha256=w18t_MdbrkIeLcCW-VMQYeb9hlWenAOB-NMiME_Fo-Y,27652
14
14
  sourcecode/coverage_parser.py,sha256=q0LeZJaX1bnntLu-ImksdBsMlpsVmk_iUfSaB4eaJGo,19702
15
15
  sourcecode/dependency_analyzer.py,sha256=p4ljXhkcGBbFlhaZuPrsjOVjDXaKLTg0Gor2p4qFPP0,56208
16
16
  sourcecode/doc_analyzer.py,sha256=afA4uJFwXZ_uR2l4J0pQwbeTkRkGmKdN9KhRVYePBUw,24331
@@ -18,17 +18,17 @@ sourcecode/entrypoint_classifier.py,sha256=gvKgl0f5T8ol1r4JMmkeqGHuZTfZJiOwFOWdc
18
18
  sourcecode/env_analyzer.py,sha256=GxCidahAAIptTdDFIlVB6URd4HBnBlIX_SqUov3MBRQ,22076
19
19
  sourcecode/file_classifier.py,sha256=48ly5Z6exkzBy8lNy1AkdP4-oJqIA1zT3LZfffuTyDo,11572
20
20
  sourcecode/flow_analyzer.py,sha256=dSiuY4w49k29jW_EPXUOND9B5uVbuCA7kjnuHi-pIWA,28781
21
- sourcecode/git_analyzer.py,sha256=_pCg2V4d2aa17k9hayTzpexAj8syvyk4y9NYNvvgOAI,12802
21
+ sourcecode/git_analyzer.py,sha256=0Gyj-vMpIIN4nfriKXVRouNYBeJ59s6pQDX2Xu9Pq-U,13177
22
22
  sourcecode/graph_analyzer.py,sha256=iUK-7pSV-cvGqqD2hENdYmhnm0wcXFEyK-xnu5ul8OU,62515
23
23
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
24
24
  sourcecode/pr_comment_renderer.py,sha256=k5pCIP6iBNwy5UYPxu47CAq-62j4E9QZZOPyL3trH80,14799
25
- sourcecode/prepare_context.py,sha256=pStv1z7bO0_p7YmUTH6NSnc7xjmkNrA9Atdvp6y8UiY,152165
25
+ sourcecode/prepare_context.py,sha256=8-xithuC0-HG12tF6qWW7-3vNtZET9UC2NRbuJkaFnE,158731
26
26
  sourcecode/progress.py,sha256=qn30sWaHOkjTgXsSBmiPkz7Rsbwc5oSlIe6JNEMYp_k,3149
27
27
  sourcecode/ranking_engine.py,sha256=virVglafZufioHpZpwktjMvUiL0TZELWQCQnQNV8dFo,9360
28
28
  sourcecode/redactor.py,sha256=xuGcadGEHaPw4qZXlMDvzMCsr4VOkdp3oBQptHyJk8c,2884
29
29
  sourcecode/relevance_scorer.py,sha256=MYF4FFkveAQps9SmTeTlh6ODiBz2F--_hWNeHMLtUHQ,8405
30
30
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
31
- sourcecode/repository_ir.py,sha256=uwi6-mJPh1E2-RYjf-ndqUXTvN32-7Nfv6oFqwHQi1c,57566
31
+ sourcecode/repository_ir.py,sha256=e9TzNbfo_2Cyh3aASFZBq--rPjBMIYMuw5smIWJ5hx8,61099
32
32
  sourcecode/runtime_classifier.py,sha256=zWX3r3HCKHc-qtIobErOa8aKMmaoPYREtJKvPcBGPjQ,14792
33
33
  sourcecode/scanner.py,sha256=aM3h9-DCQ3xKpeHpHYdo2vX6T5P95HA_YwZbkAVNwmo,8288
34
34
  sourcecode/schema.py,sha256=fj3BZ3IcnNV4j21BFIEvz8Qnw_vZoqIbzzRg-qQ-nd0,24530
@@ -64,8 +64,8 @@ sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWn
64
64
  sourcecode/telemetry/events.py,sha256=oEvvulfsv5GIDWG2174gSS6tNB95w38AIYiYeifGKlE,2294
65
65
  sourcecode/telemetry/filters.py,sha256=Asa71oRl7q3Wt_FMwuufIZJFzSYdgRNKS8LHCIyFeYE,4805
66
66
  sourcecode/telemetry/transport.py,sha256=KJeIPCPWMdmbCP3ySGs2iUlia34U6vWne2dZsUezesw,1560
67
- sourcecode-1.30.17.dist-info/METADATA,sha256=4flsXx7vygTH8S2_G_Dfq1CocZivzyUNQFLBNxY90ZY,28956
68
- sourcecode-1.30.17.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
69
- sourcecode-1.30.17.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
70
- sourcecode-1.30.17.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
71
- sourcecode-1.30.17.dist-info/RECORD,,
67
+ sourcecode-1.30.19.dist-info/METADATA,sha256=MNkVBrHuR2xR9onBA950wqcFlHu13VRcjAjRQKsqlAo,28956
68
+ sourcecode-1.30.19.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
69
+ sourcecode-1.30.19.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
70
+ sourcecode-1.30.19.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
71
+ sourcecode-1.30.19.dist-info/RECORD,,