sourcecode 1.30.18__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.18"
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")
@@ -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)
@@ -818,7 +819,7 @@ class TaskContextBuilder:
818
819
  if not self._is_source(_p) or self._is_test(_p):
819
820
  continue
820
821
  try:
821
- _loc = len((self.root / _p).read_text(errors="replace").splitlines())
822
+ _loc = len((self.root / _p).read_text(encoding="utf-8", errors="replace").splitlines())
822
823
  if _loc > 200:
823
824
  _large.append((_loc, _p))
824
825
  except OSError:
@@ -1207,6 +1208,7 @@ class TaskContextBuilder:
1207
1208
  symptom_keywords: list[str] = []
1208
1209
  related_notes: list[dict] = []
1209
1210
  symptom_note: Optional[str] = None
1211
+ symptom_explain: Optional[dict] = None
1210
1212
  if task_name == "fix-bug" and symptom:
1211
1213
  import re as _re
1212
1214
  _camel_expanded = _re.sub(r'([a-z])([A-Z])', r'\1 \2', symptom)
@@ -1216,12 +1218,24 @@ class TaskContextBuilder:
1216
1218
  if len(w) > 2
1217
1219
  ]
1218
1220
  if symptom_keywords:
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
+
1219
1234
  # Pass 1: surface code notes whose text contains any keyword
1220
- # Also track which file paths have matching notes (for score boost below).
1221
1235
  _note_matched_paths: dict[str, int] = {} # path → count of matching notes
1222
1236
  for _n in cn_notes_for_ranking:
1223
1237
  _text = (getattr(_n, "text", "") or "").lower()
1224
- if any(kw in _text for kw in symptom_keywords):
1238
+ if _kw_re.search(_text):
1225
1239
  _np = getattr(_n, "path", "")
1226
1240
  related_notes.append({
1227
1241
  "kind": getattr(_n, "kind", ""),
@@ -1231,17 +1245,22 @@ class TaskContextBuilder:
1231
1245
  })
1232
1246
  _note_matched_paths[_np] = _note_matched_paths.get(_np, 0) + 1
1233
1247
 
1234
- # Pass 2: build commit message index — files touched in commits whose
1235
- # message matches a symptom keyword get a strong recency signal.
1236
- # This is the primary signal for functional keywords like "sesiones"
1237
- # that don't appear in file paths but do appear in commit messages.
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.
1238
1252
  _commit_file_hits: dict[str, int] = {} # path → n matching commits
1239
- for _cr in _recent_commits_for_symptom:
1253
+ _commits_scanned = _recent_commits_for_symptom[:60]
1254
+ for _cr in _commits_scanned:
1240
1255
  _msg_lower = (_cr.message or "").lower()
1241
- if any(kw in _msg_lower for kw in symptom_keywords):
1256
+ if _kw_re.search(_msg_lower):
1242
1257
  for _cf in (_cr.files_changed or []):
1243
1258
  _cf_norm = _cf.replace("\\", "/")
1244
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
+ })
1245
1264
 
1246
1265
  # Pass 3: inject files from commit index not yet in candidate pool
1247
1266
  _existing_paths = {rf.path for rf in relevant_files}
@@ -1261,7 +1280,6 @@ class TaskContextBuilder:
1261
1280
  _existing_paths.add(_cp)
1262
1281
 
1263
1282
  # Pass 4: inject files whose path matches symptom keywords
1264
- # but weren't in the candidate pool (no structural/git signals).
1265
1283
  for _p in all_paths:
1266
1284
  if _p in _existing_paths:
1267
1285
  continue
@@ -1282,13 +1300,29 @@ class TaskContextBuilder:
1282
1300
  why=f"symptom injection: {', '.join(_matching_kws)}",
1283
1301
  ))
1284
1302
  _existing_paths.add(_p)
1303
+ _sx_direct_path.append(_p)
1285
1304
 
1286
- # Pass 5: multi-signal boost apply commit, note, content, and path
1287
- # signals in one pass to avoid redundant file reads.
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.
1288
1307
  _src_exts = frozenset({".java", ".py", ".ts", ".js", ".kt", ".go"})
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
+
1289
1322
  _boosted: list[RelevantFile] = []
1290
- for _rf in relevant_files:
1323
+ for _rf in _scan_candidates:
1291
1324
  _extra = 0.0
1325
+ _extra_syn = 0.0
1292
1326
  _reasons: list[str] = []
1293
1327
  _p_lower = _rf.path.lower()
1294
1328
 
@@ -1298,6 +1332,7 @@ class TaskContextBuilder:
1298
1332
  _cb = min(0.40, _c_hits * 0.25)
1299
1333
  _extra += _cb
1300
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})
1301
1336
 
1302
1337
  # Code note boost: +0.20/note, cap +0.30
1303
1338
  _n_hits = _note_matched_paths.get(_rf.path, 0)
@@ -1305,77 +1340,94 @@ class TaskContextBuilder:
1305
1340
  _nb = min(0.30, _n_hits * 0.20)
1306
1341
  _extra += _nb
1307
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})
1308
1344
 
1309
- # Path keyword boost: +0.20/keyword already in score for injected
1310
- # files; re-apply for pre-existing candidates whose path matches.
1345
+ # Path keyword boost for pre-existing candidates
1311
1346
  _path_kws = [kw for kw in symptom_keywords if kw in _p_lower]
1312
1347
  if _path_kws and _rf.role != "symptom_match":
1313
1348
  _pb = 0.20 * len(_path_kws)
1314
1349
  _extra += _pb
1315
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})
1316
1352
 
1317
- # Content scan boost: +0.05/hit, cap +0.50 (was +0.02, cap +0.30)
1353
+ # Single file read covers both content scan and synonym scan
1354
+ _body_lower = ""
1318
1355
  if Path(_rf.path).suffix.lower() in _src_exts:
1319
1356
  try:
1320
- _lines = (self.root / _rf.path).read_text(
1357
+ _body_lower = (self.root / _rf.path).read_text(
1321
1358
  encoding="utf-8", errors="replace"
1322
- ).splitlines()[:300]
1323
- _body = "\n".join(_lines).lower()
1324
- _hits = sum(_body.count(kw) for kw in symptom_keywords)
1325
- _content_b = min(0.50, _hits * 0.05)
1326
- if _content_b > 0:
1327
- _extra += _content_b
1328
- _reasons.append(f"content-match symptom ×{_hits} (+{_content_b:.2f})")
1359
+ )[:12000].lower() # ~300 lines avg
1329
1360
  except OSError:
1330
1361
  pass
1331
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
1332
1386
  _new_reason = _rf.reason
1333
1387
  if _reasons:
1334
- _new_reason = _rf.reason + ", " + ", ".join(_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
+
1335
1393
  _boosted.append(RelevantFile(
1336
1394
  path=_rf.path,
1337
1395
  role=_rf.role,
1338
- score=round(min(_rf.score + _extra, 1.0), 2),
1396
+ score=round(min(_rf.score + _total_extra, 1.0), 2),
1339
1397
  reason=_new_reason,
1340
1398
  why=_rf.why,
1341
1399
  ))
1342
- relevant_files = sorted(_boosted, key=lambda rf: -rf.score)
1343
1400
 
1344
- # Pass 6: cross-layer synonym boost frontend keywords → backend equivalents
1345
- _synonym_note: Optional[str] = None
1346
- _frontend_kws = [kw for kw in symptom_keywords if kw in _FRONTEND_SYMPTOM_MAP]
1347
- if _frontend_kws:
1348
- _backend_terms: list[str] = []
1349
- for _fkw in _frontend_kws:
1350
- _backend_terms.extend(_FRONTEND_SYMPTOM_MAP[_fkw])
1351
- _backend_terms_set = list(dict.fromkeys(_backend_terms))
1352
- _synonym_boosted: list[RelevantFile] = []
1353
- for _rf in relevant_files:
1354
- _extra_syn = 0.0
1355
- if Path(_rf.path).suffix.lower() in _src_exts:
1356
- try:
1357
- _lines_syn = (self.root / _rf.path).read_text(
1358
- encoding="utf-8", errors="replace"
1359
- ).splitlines()[:300]
1360
- _body_syn = "\n".join(_lines_syn).lower()
1361
- _hits_syn = sum(_body_syn.count(t) for t in _backend_terms_set)
1362
- _extra_syn = min(0.20, _hits_syn * 0.02)
1363
- except OSError:
1364
- pass
1365
- _synonym_boosted.append(RelevantFile(
1366
- path=_rf.path,
1367
- role=_rf.role,
1368
- score=round(min(_rf.score + _extra_syn, 1.0), 2),
1369
- reason=_rf.reason + (f", synonym-match backend (+{_extra_syn:.2f})" if _extra_syn > 0 else ""),
1370
- why=_rf.why,
1371
- ))
1372
- relevant_files = sorted(_synonym_boosted, key=lambda rf: -rf.score)
1373
- _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 = (
1374
1406
  f"Frontend concept detected ({', '.join(_frontend_kws)}). "
1375
- "Backend service-layer and interceptor files boosted by symptom keyword match "
1407
+ "Backend service-layer files boosted by synonym match "
1376
1408
  "[INFERRED (LOW CONFIDENCE) — pattern heuristic, not structural proof]."
1377
1409
  )
1378
- 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
+ }
1379
1431
 
1380
1432
  # ── 7. Test gaps (generate-tests only) ────────────────────────────
1381
1433
  test_gaps: list[str] = []
@@ -1474,6 +1526,7 @@ class TaskContextBuilder:
1474
1526
  symptom=symptom if task_name == "fix-bug" and symptom else None,
1475
1527
  related_notes=related_notes,
1476
1528
  symptom_note=symptom_note,
1529
+ symptom_explain=symptom_explain if task_name == "fix-bug" else None,
1477
1530
  impact_summary=_delta_impact_summary,
1478
1531
  affected_modules=_delta_affected_modules,
1479
1532
  risk_areas=_delta_risk_areas,
@@ -1544,12 +1544,99 @@ def build_repo_ir(
1544
1544
  return _assemble(all_symbols, unique_relations, all_changed, spring_summary, all_route_diffs_sorted)
1545
1545
 
1546
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
+
1547
1634
  # ---------------------------------------------------------------------------
1548
1635
  # Convenience: find Java files in a repo
1549
1636
  # ---------------------------------------------------------------------------
1550
1637
 
1551
1638
  def find_java_files(root: Path, *, max_files: int = 500) -> list[str]:
1552
- """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."""
1553
1640
  results: list[str] = []
1554
1641
  for p in sorted(root.rglob("*.java")):
1555
1642
  if len(results) >= max_files:
@@ -1558,7 +1645,12 @@ def find_java_files(root: Path, *, max_files: int = 500) -> list[str]:
1558
1645
  rel = str(p.relative_to(root)).replace("\\", "/")
1559
1646
  except ValueError:
1560
1647
  continue
1648
+ parts = rel.split("/")
1649
+ # Skip test dirs
1561
1650
  if "/test/" in rel or "/tests/" in rel or rel.startswith("test/"):
1562
1651
  continue
1652
+ # Skip vendor/generated/build dirs
1653
+ if any(part in _VENDOR_DIRS for part in parts[:-1]):
1654
+ continue
1563
1655
  results.append(rel)
1564
1656
  return results
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.30.18
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.18-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.18
260
+ # sourcecode 1.30.19
261
261
  ```
262
262
 
263
263
  ---
@@ -1,10 +1,10 @@
1
- sourcecode/__init__.py,sha256=6mHUCAZLGN6amZwcIEqBuvLyiGaJroesk9TR_32ZpWo,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
@@ -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=W4cK5EESAQWyLKWobuzt3X9Ca7kZp1ui8efD0bgTzEE,156227
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=KMB_5sPtV695CaVzH-dOl9j9_zvP45PjzGZJiZzrdAM,57626
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.18.dist-info/METADATA,sha256=lqlAi_PtaPLAr3s8RUw0ULhaKZioUxzdNsVBKKVfRqc,28956
68
- sourcecode-1.30.18.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
69
- sourcecode-1.30.18.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
70
- sourcecode-1.30.18.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
71
- sourcecode-1.30.18.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,,