sourcecode 1.30.28__py3-none-any.whl → 1.30.29__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.28"
3
+ __version__ = "1.30.29"
@@ -19,7 +19,7 @@ from typing import Any, Optional, cast
19
19
  from pathspec import GitIgnoreSpec
20
20
 
21
21
  from sourcecode.repo_classifier import RepoTopology
22
- from sourcecode.scanner import DEFAULT_EXCLUDES, MANIFEST_NAMES
22
+ from sourcecode.scanner import DEFAULT_EXCLUDES, EXCLUDED_FILE_SUFFIXES, MANIFEST_NAMES
23
23
 
24
24
 
25
25
  class AdaptiveScanner:
@@ -88,13 +88,14 @@ class AdaptiveScanner:
88
88
 
89
89
  def _load_gitignore_spec(self) -> GitIgnoreSpec:
90
90
  if self._gitignore_spec is None:
91
- gitignore = self.root / ".gitignore"
92
91
  lines: list[str] = []
93
- if gitignore.exists():
94
- try:
95
- lines = gitignore.read_text(encoding="utf-8", errors="replace").splitlines()
96
- except OSError:
97
- pass
92
+ for ignore_file in (".gitignore", ".sourcecodeIgnore"):
93
+ candidate = self.root / ignore_file
94
+ if candidate.exists():
95
+ try:
96
+ lines.extend(candidate.read_text(encoding="utf-8", errors="replace").splitlines())
97
+ except OSError:
98
+ pass
98
99
  self._gitignore_spec = GitIgnoreSpec.from_lines(lines)
99
100
  return self._gitignore_spec
100
101
 
@@ -202,6 +203,9 @@ class AdaptiveScanner:
202
203
  # Skip flag-shaped names (shell redirect artifacts)
203
204
  if fname.startswith("-"):
204
205
  continue
206
+ # Skip minified/map files
207
+ if any(fname.endswith(sfx) for sfx in EXCLUDED_FILE_SUFFIXES):
208
+ continue
205
209
  fpath = current / fname
206
210
  if fpath.is_symlink():
207
211
  continue
sourcecode/classifier.py CHANGED
@@ -25,7 +25,7 @@ _API_FRAMEWORKS = {
25
25
  "Ktor",
26
26
  "Play",
27
27
  }
28
- _WEB_FRAMEWORKS = {"Next.js", "React", "Vue", "Svelte", "Vite", "Flutter", "Phoenix"}
28
+ _WEB_FRAMEWORKS = {"Next.js", "React", "Vue", "Svelte", "Vite", "Flutter", "Phoenix", "Angular"}
29
29
  _CLI_FRAMEWORKS = {"Typer", "Cobra", "Clap"}
30
30
  _API_STACKS = {"python", "go", "java", "php", "ruby", "dotnet", "kotlin", "scala"}
31
31
  ConfidenceLevel = Literal["high", "medium", "low"]
@@ -105,6 +105,13 @@ class TypeClassifier:
105
105
  if "src/lib.rs" in flat_paths and not any(path.endswith("main.rs") for path in flat_paths):
106
106
  return "library"
107
107
 
108
+ # Angular SPA: angular.json is the canonical signal; framework detection is secondary
109
+ if (
110
+ "angular.json" in flat_paths
111
+ or "Angular" in framework_names
112
+ ):
113
+ return "angular-spa"
114
+
108
115
  if framework_names & _WEB_FRAMEWORKS or any(
109
116
  path.startswith(("app/", "pages/", "components/")) for path in flat_paths
110
117
  ):
sourcecode/cli.py CHANGED
@@ -592,6 +592,16 @@ def main(
592
592
  "-c",
593
593
  help="Copy output to system clipboard after a successful run. No-op when --output is used or clipboard is unavailable.",
594
594
  ),
595
+ exclude: Optional[str] = typer.Option(
596
+ None,
597
+ "--exclude",
598
+ help="Additional directories/patterns to exclude, comma-separated (e.g. 'legacy,generated').",
599
+ ),
600
+ no_cache: bool = typer.Option(
601
+ False,
602
+ "--no-cache",
603
+ help="Bypass the scan cache and force a fresh analysis.",
604
+ ),
595
605
  ) -> None:
596
606
  """Analyze a repository and produce structured context for AI coding agents.
597
607
 
@@ -798,10 +808,59 @@ def main(
798
808
  no_tree = True # agents never need the raw file tree
799
809
  architecture = True # agents need full architectural signal (M4)
800
810
 
811
+ # ── GAP-9: Cache check — serve from .sourcecode-cache when git SHA unchanged ──
812
+ import hashlib as _hashlib
813
+ import subprocess as _sub
814
+ _cache_dir = target / ".sourcecode-cache"
815
+ _cache_hit_content: Optional[str] = None
816
+ _git_sha = ""
817
+ _cache_key = ""
818
+ _cache_file: Optional[Path] = None
819
+ if not no_cache:
820
+ try:
821
+ _sha_r = _sub.run(
822
+ ["git", "-C", str(target), "rev-parse", "--short", "HEAD"],
823
+ capture_output=True, text=True, timeout=3,
824
+ )
825
+ _git_sha = _sha_r.stdout.strip()
826
+ # Only cache when target IS the git repo root (not a subdir of one),
827
+ # to avoid polluting sub-project directories used in tests.
828
+ if _git_sha and (target / ".git").exists():
829
+ # Include every output-affecting flag so different flag combos never collide
830
+ _flags_str = (
831
+ f"c={compact},ag={agent},fmt={format},full={full},"
832
+ f"co={changed_only},dep={dependencies},gm={graph_modules},"
833
+ f"docs={docs},fm={full_metrics},sem={semantics},"
834
+ f"arch={architecture},gc={git_context},em={env_map},"
835
+ f"cn={code_notes},tree={tree},mode={mode}"
836
+ )
837
+ _flags_h = _hashlib.md5(_flags_str.encode()).hexdigest()[:8]
838
+ _cache_key = f"{_git_sha}-{_flags_h}"
839
+ _cache_file = _cache_dir / f"snapshot-{_cache_key}.json"
840
+ if _cache_file.exists():
841
+ _cache_hit_content = _cache_file.read_text(encoding="utf-8")
842
+ except Exception:
843
+ _git_sha = ""
844
+ _cache_key = ""
845
+ _cache_file = None
846
+
847
+ if _cache_hit_content is not None:
848
+ from sourcecode.serializer import write_output
849
+ write_output(_cache_hit_content, output=output)
850
+ if copy and not output:
851
+ _copy_to_clipboard(_cache_hit_content)
852
+ return
853
+
854
+ # BUG-2: parse --exclude into extra_excludes frozenset
855
+ _extra_excludes: Optional[frozenset[str]] = None
856
+ if exclude:
857
+ _extra_excludes = frozenset(e.strip() for e in exclude.split(",") if e.strip())
858
+
801
859
  _progress = Progress()
802
860
  _progress.start("scanning files")
803
861
 
804
- scanner = AdaptiveScanner(target, topology=_topology, base_depth=effective_depth)
862
+ scanner = AdaptiveScanner(target, topology=_topology, base_depth=effective_depth,
863
+ extra_excludes=_extra_excludes)
805
864
  raw_tree = scanner.scan_tree()
806
865
 
807
866
  # 2. Filter .env and *.secret entries from file tree (SEC-02, all levels)
@@ -1514,9 +1573,10 @@ def main(
1514
1573
  content = json.dumps(data, indent=2, ensure_ascii=False)
1515
1574
  elif compact:
1516
1575
  if changed_only and _allowed_changed_files:
1576
+ # GAP-5: preserve full entry_points for architecture context even in
1577
+ # --changed-only mode. Only filter file_paths and code_notes.
1517
1578
  sm = _replace(sm,
1518
1579
  file_paths=[p for p in sm.file_paths if p in _allowed_changed_files],
1519
- entry_points=[ep for ep in sm.entry_points if ep.path in _allowed_changed_files],
1520
1580
  code_notes=[n for n in sm.code_notes if n.path in _allowed_changed_files],
1521
1581
  )
1522
1582
  data = compact_view(sm, no_tree=no_tree, full=full)
@@ -1585,6 +1645,14 @@ def main(
1585
1645
  _progress.finish()
1586
1646
  write_output(content, output=output)
1587
1647
 
1648
+ # GAP-9: Persist to cache for future identical runs (git SHA unchanged)
1649
+ if not no_cache and _cache_key and _cache_file is not None and not _pipeline_error:
1650
+ try:
1651
+ _cache_dir.mkdir(parents=True, exist_ok=True)
1652
+ _cache_file.write_text(content, encoding="utf-8")
1653
+ except Exception:
1654
+ pass
1655
+
1588
1656
  if _pipeline_error:
1589
1657
  raise typer.Exit(code=2)
1590
1658
 
@@ -217,6 +217,7 @@ class NodejsDetector(AbstractDetector):
217
217
  _INDEX_PATHS = {"src/index.js", "src/index.ts", "index.js", "index.ts"}
218
218
  for path in [
219
219
  "server.js", "server.ts",
220
+ "main.ts", "main.js", # Angular/root-level entry (e.g. Angular CLI projects)
220
221
  "src/index.js", "src/index.ts",
221
222
  "src/main.js", "src/main.ts", "src/main.tsx",
222
223
  "app/page.tsx", "pages/index.js",
@@ -604,6 +604,33 @@ _FRONTEND_SYMPTOM_MAP: dict[str, list[str]] = {
604
604
  }
605
605
 
606
606
 
607
+ MAX_FILES_FAST = 2000 # above this threshold --fast uses git-index-only mode
608
+
609
+
610
+ def _count_files_bounded(root: "Path", limit: int = MAX_FILES_FAST + 1) -> int:
611
+ """Count files under root, stopping early once limit reached (O(n) fast exit)."""
612
+ import os as _os
613
+ count = 0
614
+ for _, _, fnames in _os.walk(str(root)):
615
+ count += len(fnames)
616
+ if count >= limit:
617
+ return count
618
+ return count
619
+
620
+
621
+ def _git_changed_files_fast(root: "Path") -> list[str]:
622
+ """Return files reported by git diff --name-only HEAD (for fast-mode scanning)."""
623
+ import subprocess as _sp
624
+ try:
625
+ r = _sp.run(
626
+ ["git", "diff", "--name-only", "HEAD"],
627
+ capture_output=True, text=True, cwd=str(root), timeout=5,
628
+ )
629
+ return [f.strip() for f in r.stdout.splitlines() if f.strip()]
630
+ except Exception:
631
+ return []
632
+
633
+
607
634
  def _build_analysis_scope(
608
635
  *,
609
636
  task_name: str,
@@ -761,6 +788,21 @@ class TaskContextBuilder:
761
788
  # for behavioral_impact reverse lookups without scanning the whole repo).
762
789
  file_tree: dict = {}
763
790
  all_paths = self._expand_scope_for_analysis(_pr_scope_files or [])
791
+ elif fast and _count_files_bounded(self.root) > MAX_FILES_FAST:
792
+ # Fast mode on large repo: git-index-only — only scan git-changed files.
793
+ # Skips full AdaptiveScanner traversal which takes 35s+ on 7k+ file repos.
794
+ _git_files = _git_changed_files_fast(self.root)
795
+ _git_files = [f for f in _git_files if (self.root / f).exists()]
796
+ if not _git_files:
797
+ # Fallback: use a shallow scan (depth 2) to get some context
798
+ scanner = AdaptiveScanner(self.root, base_depth=2)
799
+ file_tree = scanner.scan_tree()
800
+ manifests = scanner.find_manifests()
801
+ all_paths = [p.replace("\\", "/") for p in flatten_file_tree(file_tree)]
802
+ else:
803
+ file_tree = {}
804
+ all_paths = _git_files
805
+ # Keep manifests from shallow pre-scan
764
806
  else:
765
807
  _topology = RepoClassifier().classify(self.root)
766
808
  _base_depth = 12 if _is_java else 6
@@ -1413,8 +1455,9 @@ class TaskContextBuilder:
1413
1455
  _existing_paths.add(_p)
1414
1456
  _sx_direct_path.append(_p)
1415
1457
 
1416
- # Pass 5+6 combined: single file read per candidate.
1417
- # Limit content scan to top 80 candidates by current score to bound I/O.
1458
+ # Pass 4b: grep-based injection for frontend→backend synonym terms.
1459
+ # Runs parallel grep for each backend term to find files not yet in
1460
+ # the candidate pool (e.g. AkitaBaseService containing setLoading).
1418
1461
  _src_exts = frozenset({".java", ".py", ".ts", ".js", ".kt", ".go"})
1419
1462
  _frontend_kws = [kw for kw in symptom_keywords if kw in _FRONTEND_SYMPTOM_MAP]
1420
1463
  _backend_terms_set: list[str] = []
@@ -1424,6 +1467,40 @@ class TaskContextBuilder:
1424
1467
  _bt.extend(_FRONTEND_SYMPTOM_MAP[_fkw])
1425
1468
  _backend_terms_set = list(dict.fromkeys(_bt))
1426
1469
 
1470
+ if _backend_terms_set and not fast:
1471
+ import subprocess as _sp2
1472
+ _grepped: set[str] = set()
1473
+ for _term in _backend_terms_set[:6]:
1474
+ try:
1475
+ _gr = _sp2.run(
1476
+ ["grep", "-r", "-l", "-i", _term,
1477
+ "--include=*.ts", "--include=*.java",
1478
+ str(self.root)],
1479
+ capture_output=True, text=True, timeout=4,
1480
+ )
1481
+ for _line in _gr.stdout.splitlines():
1482
+ try:
1483
+ _rel = str(Path(_line.strip()).relative_to(self.root)).replace("\\", "/")
1484
+ _grepped.add(_rel)
1485
+ except ValueError:
1486
+ pass
1487
+ except Exception:
1488
+ pass
1489
+ _existing_paths_now = {rf.path for rf in relevant_files}
1490
+ for _gf in sorted(_grepped):
1491
+ if _gf in _existing_paths_now:
1492
+ continue
1493
+ if Path(_gf).suffix.lower() not in _src_exts:
1494
+ continue
1495
+ relevant_files.append(RelevantFile(
1496
+ path=_gf,
1497
+ role="symptom_match",
1498
+ score=0.45,
1499
+ reason="content contains backend symptom term (grep)",
1500
+ why=f"grep injection: {', '.join(_backend_terms_set[:3])}",
1501
+ ))
1502
+ _existing_paths_now.add(_gf)
1503
+
1427
1504
  # Sort before content scan so top candidates get read first
1428
1505
  relevant_files = sorted(relevant_files, key=lambda rf: -rf.score)
1429
1506
  _CONTENT_SCAN_LIMIT = 80
@@ -1091,8 +1091,54 @@ def _build_evidence_bundles(
1091
1091
  return bundles
1092
1092
 
1093
1093
 
1094
- def _detect_subsystems(all_fqns: list[str], relations: list[RelationEdge]) -> list[list[str]]:
1095
- """Connected components of the relation graph (Union-Find, graph-only)."""
1094
+ def _common_package_prefix(fqns: list[str]) -> str:
1095
+ """Longest common dot-separated package prefix across a list of FQNs."""
1096
+ if not fqns:
1097
+ return ""
1098
+ # Strip class/method suffix — keep only package parts (lowercase segments)
1099
+ def pkg_parts(fqn: str) -> list[str]:
1100
+ parts = fqn.split(".")
1101
+ # Drop trailing class/method names (PascalCase or after '#')
1102
+ result = []
1103
+ for p in parts:
1104
+ if "#" in p:
1105
+ break
1106
+ if p and p[0].isupper():
1107
+ break
1108
+ result.append(p)
1109
+ return result
1110
+
1111
+ segs = [pkg_parts(f) for f in fqns if pkg_parts(f)]
1112
+ if not segs:
1113
+ return fqns[0].rsplit(".", 1)[0] if "." in fqns[0] else fqns[0]
1114
+ common = segs[0]
1115
+ for s in segs[1:]:
1116
+ new_common = []
1117
+ for a, b in zip(common, s):
1118
+ if a == b:
1119
+ new_common.append(a)
1120
+ else:
1121
+ break
1122
+ common = new_common
1123
+ if not common:
1124
+ break
1125
+ return ".".join(common) if common else fqns[0].rsplit(".", 1)[0]
1126
+
1127
+
1128
+ def _subsystem_label(package_prefix: str) -> str:
1129
+ """Derive short human label from package prefix (last meaningful segment)."""
1130
+ parts = [p for p in package_prefix.split(".") if p]
1131
+ # Skip generic top-level segments
1132
+ _SKIP = {"com", "org", "net", "io", "java", "javax"}
1133
+ meaningful = [p for p in parts if p not in _SKIP]
1134
+ return meaningful[-1] if meaningful else (parts[-1] if parts else package_prefix)
1135
+
1136
+
1137
+ def _detect_subsystems(all_fqns: list[str], relations: list[RelationEdge]) -> list[dict]:
1138
+ """Connected components of the relation graph (Union-Find, graph-only).
1139
+
1140
+ Returns labeled subsystem objects instead of raw FQN arrays.
1141
+ """
1096
1142
  fqn_set = set(all_fqns)
1097
1143
  parent: dict[str, str] = {fqn: fqn for fqn in all_fqns}
1098
1144
 
@@ -1117,7 +1163,23 @@ def _detect_subsystems(all_fqns: list[str], relations: list[RelationEdge]) -> li
1117
1163
  root = find(fqn)
1118
1164
  components.setdefault(root, []).append(fqn)
1119
1165
 
1120
- return [sorted(v) for v in sorted(components.values())]
1166
+ result: list[dict] = []
1167
+ for members in sorted(components.values()):
1168
+ members = sorted(members)
1169
+ pkg_prefix = _common_package_prefix(members)
1170
+ label = _subsystem_label(pkg_prefix)
1171
+ # Summary: first 3 short class names + total count
1172
+ short_names = [m.split(".")[-1].split("#")[0] for m in members[:3]]
1173
+ summary = ", ".join(short_names)
1174
+ if len(members) > 3:
1175
+ summary += f" ... ({len(members)} total)"
1176
+ result.append({
1177
+ "label": label,
1178
+ "package_prefix": pkg_prefix,
1179
+ "member_count": len(members),
1180
+ "summary": summary,
1181
+ })
1182
+ return result
1121
1183
 
1122
1184
 
1123
1185
  _EDGE_REASON_TEMPLATES: dict[str, str] = {
@@ -1287,6 +1349,7 @@ def _assemble(
1287
1349
 
1288
1350
  # Score per node: ir_weight × graph_centrality × diff_intensity × evidence_strength
1289
1351
  # Unchanged nodes: diff_intensity=0 → score=0 (no diff signal)
1352
+ has_diff = bool(sorted_changed)
1290
1353
  node_scores: dict[str, float] = {}
1291
1354
  for sym in sorted_syms:
1292
1355
  fqn = sym.symbol
@@ -1299,6 +1362,28 @@ def _assemble(
1299
1362
  es = bundles[fqn].evidence_strength if fqn in bundles else 0.0
1300
1363
  node_scores[fqn] = round(w * c * di * es, 4) if di > 0 else 0.0
1301
1364
 
1365
+ # No diff signal (no --since): fall back to call-graph centrality scores.
1366
+ # Avoids emitting all-zero scores which mislead agents into thinking the tool is broken.
1367
+ score_basis: str
1368
+ impact_note: Optional[str]
1369
+ if not has_diff and sorted_syms:
1370
+ for sym in sorted_syms:
1371
+ fqn = sym.symbol
1372
+ role = spring_role_map.get(fqn, "other")
1373
+ w = _IR_WEIGHTS.get(role, _IR_WEIGHT_DEFAULT)
1374
+ raw_c = in_deg.get(fqn, 0) + out_deg.get(fqn, 0) + bfs_reach.get(fqn, 0) * 0.1
1375
+ c = min(1.0, raw_c / max_raw)
1376
+ node_scores[fqn] = round(w * c, 4)
1377
+ score_basis = "call_graph_centrality"
1378
+ impact_note = "impact scores based on call-graph centrality (no --since provided)"
1379
+ elif has_diff:
1380
+ score_basis = "diff_impact"
1381
+ impact_note = None
1382
+ else:
1383
+ # No symbols at all — omit scores entirely rather than emit zeros
1384
+ score_basis = "none"
1385
+ impact_note = None
1386
+
1302
1387
  # --- Analysis: classify changed symbols ---
1303
1388
  dropped_fields: list[dict] = []
1304
1389
  changed_entities_out: list[dict] = []
@@ -1435,6 +1520,8 @@ def _assemble(
1435
1520
  },
1436
1521
  "impact": {
1437
1522
  "global_score": global_score,
1523
+ "score_basis": score_basis,
1524
+ **({"impact_note": impact_note} if impact_note else {}),
1438
1525
  "ranked_nodes": ranked_nodes,
1439
1526
  },
1440
1527
  "subsystems": subsystems,
sourcecode/scanner.py CHANGED
@@ -27,6 +27,22 @@ DEFAULT_EXCLUDES: frozenset[str] = frozenset({
27
27
  "dist",
28
28
  "build",
29
29
  "target",
30
+ # Additional generated/IDE/tool dirs
31
+ ".gradle",
32
+ ".planning",
33
+ ".idea",
34
+ "coverage",
35
+ ".sourcecode-cache",
36
+ ".mvn",
37
+ "generated",
38
+ "generated-sources",
39
+ })
40
+
41
+ # File suffixes excluded by default (minified/map files add noise, not signal)
42
+ EXCLUDED_FILE_SUFFIXES: frozenset[str] = frozenset({
43
+ ".min.js",
44
+ ".min.css",
45
+ ".map",
30
46
  })
31
47
 
32
48
  # Nombres de ficheros de manifiesto conocidos (para find_manifests)
@@ -120,13 +136,13 @@ class FileScanner:
120
136
  self._gitignore_spec: Optional[GitIgnoreSpec] = None
121
137
 
122
138
  def _load_gitignore_spec(self) -> GitIgnoreSpec:
123
- """Carga .gitignore del proyecto como GitIgnoreSpec (SCAN-01)."""
139
+ """Carga .gitignore y .sourcecodeIgnore como GitIgnoreSpec (SCAN-01)."""
124
140
  if self._gitignore_spec is None:
125
- gitignore = self.root / ".gitignore"
126
- if gitignore.exists():
127
- lines = gitignore.read_text(encoding="utf-8", errors="replace").splitlines()
128
- else:
129
- lines = []
141
+ lines: list[str] = []
142
+ for ignore_file in (".gitignore", ".sourcecodeIgnore"):
143
+ candidate = self.root / ignore_file
144
+ if candidate.exists():
145
+ lines.extend(candidate.read_text(encoding="utf-8", errors="replace").splitlines())
130
146
  self._gitignore_spec = GitIgnoreSpec.from_lines(lines)
131
147
  return self._gitignore_spec
132
148
 
@@ -183,6 +199,9 @@ class FileScanner:
183
199
  # No legitimate source file starts with "-".
184
200
  if fname.startswith("-"):
185
201
  continue
202
+ # Skip minified/map files — no signal value
203
+ if any(fname.endswith(sfx) for sfx in EXCLUDED_FILE_SUFFIXES):
204
+ continue
186
205
  fpath = current / fname
187
206
  # SCAN-03: no incluir symlinks de fichero
188
207
  if fpath.is_symlink():
sourcecode/serializer.py CHANGED
@@ -563,6 +563,10 @@ def _bootstrap_structured(eps: list) -> "Optional[dict[str, Any]]":
563
563
  if security:
564
564
  result["security"] = security
565
565
  if controllers:
566
+ # Count unique files (classes) vs total entries (methods/endpoints)
567
+ controller_classes = len({c["path"] for c in controllers})
568
+ controller_methods = len(controllers)
569
+
566
570
  # Extract all DDD module names from controller paths and group by domain area.
567
571
  # Path pattern: .../ddd/{module}/infrastructure/rest/*Controller.java
568
572
  _DDD_LAYERS = {"application", "domain", "infrastructure"}
@@ -582,6 +586,10 @@ def _bootstrap_structured(eps: list) -> "Optional[dict[str, Any]]":
582
586
  seen_modules.add(module)
583
587
  module_names.append(module)
584
588
 
589
+ _ctrl_note = (
590
+ f"{controller_methods} @RequestMapping methods across "
591
+ f"{controller_classes} controller classes"
592
+ )
585
593
  if len(module_names) > 30:
586
594
  # Group by first path segment under ddd/ (inferred domain area)
587
595
  domain_groups: dict[str, list[str]] = {}
@@ -600,12 +608,16 @@ def _bootstrap_structured(eps: list) -> "Optional[dict[str, Any]]":
600
608
  if module not in domain_groups[domain_prefix or "other"]:
601
609
  domain_groups[domain_prefix or "other"].append(module)
602
610
  result["controllers"] = {
603
- "count": len(controllers),
611
+ "classes": controller_classes,
612
+ "methods": controller_methods,
613
+ "note": _ctrl_note,
604
614
  "modules": {k: sorted(v) for k, v in sorted(domain_groups.items())},
605
615
  }
606
616
  else:
607
617
  result["controllers"] = {
608
- "count": len(controllers),
618
+ "classes": controller_classes,
619
+ "methods": controller_methods,
620
+ "note": _ctrl_note,
609
621
  "modules": sorted(module_names),
610
622
  }
611
623
  return result
@@ -1315,6 +1327,13 @@ def compact_view(sm: SourceMap, *, no_tree: bool = False, full: bool = False) ->
1315
1327
  result["transactional_boundaries"] = _transactional
1316
1328
  if _git_ctx:
1317
1329
  result["git_context"] = _git_ctx
1330
+ # Angular structural analysis (GAP-10)
1331
+ if sm.project_type in ("angular-spa", "webapp") or any(
1332
+ any(f.name == "Angular" for f in s.frameworks) for s in sm.stacks
1333
+ ):
1334
+ _ang = _angular_analysis(sm)
1335
+ if _ang and (_ang.get("component_count", 0) > 0 or _ang.get("angular_version")):
1336
+ result["angular_analysis"] = _ang
1318
1337
  if _spring_profiles:
1319
1338
  result["spring_profiles"] = _spring_profiles
1320
1339
  _always_include = {"project_type", "project_summary", "architecture_summary", "dependency_summary"}
@@ -1599,6 +1618,88 @@ def validate_cross_analyzer_consistency(
1599
1618
  return findings
1600
1619
 
1601
1620
 
1621
+ def _angular_analysis(sm: "SourceMap") -> "Optional[dict[str, Any]]":
1622
+ """Extract Angular structural metrics for TypeScript/Angular projects (GAP-10)."""
1623
+ import json as _json
1624
+ import re as _re
1625
+
1626
+ ts_files = [p for p in sm.file_paths if p.endswith(".ts") and not p.endswith(".d.ts")]
1627
+ if not ts_files:
1628
+ return None
1629
+
1630
+ root = Path(sm.metadata.analyzed_path) if sm.metadata.analyzed_path else Path(".")
1631
+
1632
+ component_count = 0
1633
+ service_count = 0
1634
+ lazy_routes_count = 0
1635
+ akita_stores = 0
1636
+ standalone_components = False
1637
+ route_paths: list[str] = []
1638
+
1639
+ for rel in ts_files:
1640
+ try:
1641
+ content = (root / rel).read_text(encoding="utf-8", errors="replace")
1642
+ except OSError:
1643
+ continue
1644
+ component_count += content.count("@Component(")
1645
+ service_count += content.count("@Injectable(")
1646
+ lazy_routes_count += content.count("loadChildren(")
1647
+ akita_stores += content.count("@StoreConfig(")
1648
+ if not standalone_components and "bootstrapApplication(" in content:
1649
+ standalone_components = True
1650
+ # Route tree: parse path: '...' in routing files
1651
+ fname = rel.replace("\\", "/").split("/")[-1]
1652
+ if "routing" in fname or fname in ("app.routes.ts",):
1653
+ for m in _re.finditer(r"path\s*:\s*['\"]([^'\"]*)['\"]", content):
1654
+ val = m.group(1)
1655
+ if val and val not in route_paths:
1656
+ route_paths.append(val)
1657
+
1658
+ # Angular version from package.json
1659
+ angular_version: Optional[str] = None
1660
+ pkg_json = root / "package.json"
1661
+ if pkg_json.exists():
1662
+ try:
1663
+ pkg = _json.loads(pkg_json.read_text(encoding="utf-8", errors="replace"))
1664
+ deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
1665
+ av = deps.get("@angular/core")
1666
+ if av:
1667
+ angular_version = av.lstrip("^~>=")
1668
+ except Exception:
1669
+ pass
1670
+
1671
+ # Also check angular.json for entry point
1672
+ entry_point: Optional[str] = None
1673
+ angular_json = root / "angular.json"
1674
+ if angular_json.exists():
1675
+ try:
1676
+ aj = _json.loads(angular_json.read_text(encoding="utf-8", errors="replace"))
1677
+ projects = aj.get("projects") or {}
1678
+ for proj in projects.values():
1679
+ main = (
1680
+ (proj.get("architect") or {})
1681
+ .get("build", {})
1682
+ .get("options", {})
1683
+ .get("main")
1684
+ )
1685
+ if main:
1686
+ entry_point = main
1687
+ break
1688
+ except Exception:
1689
+ pass
1690
+
1691
+ return {
1692
+ "angular_version": angular_version,
1693
+ "standalone_components": standalone_components,
1694
+ "lazy_routes_count": lazy_routes_count,
1695
+ "akita_stores": akita_stores,
1696
+ "route_tree": route_paths[:20],
1697
+ "component_count": component_count,
1698
+ "service_count": service_count,
1699
+ **({"entry_point": entry_point} if entry_point else {}),
1700
+ }
1701
+
1702
+
1602
1703
  def agent_view(sm: SourceMap, *, full: bool = False) -> dict[str, Any]:
1603
1704
  """Opinionated output for AI agents — structured, noise-free, gap-aware.
1604
1705
 
@@ -1823,6 +1924,14 @@ def agent_view(sm: SourceMap, *, full: bool = False) -> dict[str, Any]:
1823
1924
  if signals:
1824
1925
  result["signals"] = signals
1825
1926
 
1927
+ # ── 8b. Angular structural analysis (GAP-10) ──────────────────────────────
1928
+ if sm.project_type in ("angular-spa", "webapp") or any(
1929
+ any(f.name == "Angular" for f in s.frameworks) for s in sm.stacks
1930
+ ):
1931
+ _ang = _angular_analysis(sm)
1932
+ if _ang and (_ang.get("component_count", 0) > 0 or _ang.get("angular_version")):
1933
+ result["angular_analysis"] = _ang
1934
+
1826
1935
  # ── 9. Git context — lightweight (top-5 hotspots, branch, uncommitted count)
1827
1936
  _gc = _compact_git_context(sm)
1828
1937
  if _gc:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.30.28
3
+ Version: 1.30.29
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.28-blue)
224
+ ![Version](https://img.shields.io/badge/version-1.30.29-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.28
260
+ # sourcecode 1.30.29
261
261
  ```
262
262
 
263
263
  ---
@@ -1,10 +1,10 @@
1
- sourcecode/__init__.py,sha256=q-bCo7uGXns3U7O05C-GiuHhFOyvaN-90_kC3JSQMn0,104
2
- sourcecode/adaptive_scanner.py,sha256=RTNExwWPXzjgLaRueT7UuxkPj5ZEToWjGbx1j0LSZ9E,10250
1
+ sourcecode/__init__.py,sha256=dlUu_PMRN5Q_pvsTBF3LbFJMORBN4BVQ0B9j6mA3GWQ,104
2
+ sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
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
- sourcecode/classifier.py,sha256=pYve2J1LqtYssU3lYLMDz18PT-CjN5c18QYE7R_IG1Q,7507
7
- sourcecode/cli.py,sha256=NbVyJAEQ4Q0ngkgW_lXyvlDJcD8TTrxZEXY6RIVkBAI,95261
6
+ sourcecode/classifier.py,sha256=-0t0HLc9L9UleMLfclfLM3AXhBjUb_AYyBPDbvgWtac,7755
7
+ sourcecode/cli.py,sha256=5hKnqahlEko1Z1GV1pg0uzEH2hES3RRqxJ95CRBgXwc,98320
8
8
  sourcecode/code_notes_analyzer.py,sha256=y1MJBnPZHYp4i6cQCXUb9ATIyifS_qMQWjw_8lPkpsU,9215
9
9
  sourcecode/confidence_analyzer.py,sha256=H9VHYRzZhqMFlSCZffjtsMUGYLnDvrq1g5FjzyQ1hxE,16381
10
10
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
@@ -22,18 +22,18 @@ sourcecode/git_analyzer.py,sha256=0Gyj-vMpIIN4nfriKXVRouNYBeJ59s6pQDX2Xu9Pq-U,13
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=smHslxiG14lrytCkq5nFrFu-qTHgA-t-LFYfdrfjz2o,14423
25
- sourcecode/prepare_context.py,sha256=O3gpZIJLzCH2-9zr91zOP0D4gwHND2BbgBOCplwQXj8,168540
25
+ sourcecode/prepare_context.py,sha256=Too1ziyHGGvZfNhCMfgCvrTI01xYQeUrwA4veXtgTSI,172167
26
26
  sourcecode/progress.py,sha256=qn30sWaHOkjTgXsSBmiPkz7Rsbwc5oSlIe6JNEMYp_k,3149
27
27
  sourcecode/ranking_engine.py,sha256=ZAucq_YX2KkWUuAZf4P0lhtQ_38vEFnUhuGtSZd1S0E,12970
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=e9TzNbfo_2Cyh3aASFZBq--rPjBMIYMuw5smIWJ5hx8,61099
31
+ sourcecode/repository_ir.py,sha256=KH5EehbjOh8ZwwTHcbzrAHiKDoquO49wgSvCX4bVq5k,64391
32
32
  sourcecode/runtime_classifier.py,sha256=zWX3r3HCKHc-qtIobErOa8aKMmaoPYREtJKvPcBGPjQ,14792
33
- sourcecode/scanner.py,sha256=aM3h9-DCQ3xKpeHpHYdo2vX6T5P95HA_YwZbkAVNwmo,8288
33
+ sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
34
34
  sourcecode/schema.py,sha256=fj3BZ3IcnNV4j21BFIEvz8Qnw_vZoqIbzzRg-qQ-nd0,24530
35
35
  sourcecode/semantic_analyzer.py,sha256=12TwXYkYbDcBdu0heX_EmfPM2EkO8a_r5osf0SaeQbs,88956
36
- sourcecode/serializer.py,sha256=svaLLYyAeAd3ZWsgh0YBwu7RKm00JaKuyz3bQbLipHA,106064
36
+ sourcecode/serializer.py,sha256=JaEJonNIKaSV6dST3Jn8_Z6alYK9Ba1-M3luh8Xf-yw,110442
37
37
  sourcecode/summarizer.py,sha256=lPlKhMh28nueXkPo2xKeD3DUFYVGRlJMIdY-8TSM-ls,17486
38
38
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
39
39
  sourcecode/workspace.py,sha256=X_6NmNnitvT3_38V-JDChydo_sR68s249hLFlrQskU0,8271
@@ -48,7 +48,7 @@ sourcecode/detectors/heuristic.py,sha256=bCqqgbHavl4Sse3dqT8mwmo1wAdgeJr7VyXOmfC
48
48
  sourcecode/detectors/hybrid.py,sha256=IGFRUVsAZ1ooRlFdznCeJAV6vy1yVDx-VyghvLtddXc,9101
49
49
  sourcecode/detectors/java.py,sha256=ldvaDZJADAKslOpS5qJ0bxexgeY6h_4Yx4NCtHio7J8,24203
50
50
  sourcecode/detectors/jvm_ext.py,sha256=EgHJ5W8EE-ZTN9V607mVzohyKgZE8Mc2jCi-DF8RAZU,2616
51
- sourcecode/detectors/nodejs.py,sha256=7fsyAmrGkkguX6U80HUQpIe9MRaYyi_A7zbaRtmFmGc,13097
51
+ sourcecode/detectors/nodejs.py,sha256=Hg3Gmr7yIMJFiLoDwOTk2wtu00wxIs6kZf-oQujTFUA,13187
52
52
  sourcecode/detectors/parsers.py,sha256=ugPg8yNUf0Ai1gA7Fnn6wAkYGFjTxRodSP3IeViYJJ4,2290
53
53
  sourcecode/detectors/php.py,sha256=W_AQD0WMVDdWHa9h_ilX6W8XSpz0X4ctpMK2WXfXf1I,1887
54
54
  sourcecode/detectors/project.py,sha256=ghWWOlqg2_uywzeZX573CCkFWQPlc8bBGvvX1BfIDYI,8315
@@ -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.28.dist-info/METADATA,sha256=cL4bkJZO4jc-T5k1GtC5OXS99NZLU4bZSl6WxVbo70w,28956
68
- sourcecode-1.30.28.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
69
- sourcecode-1.30.28.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
70
- sourcecode-1.30.28.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
71
- sourcecode-1.30.28.dist-info/RECORD,,
67
+ sourcecode-1.30.29.dist-info/METADATA,sha256=39QsgfOfPa-UUti5nV0iLi-gH7KSIz48owb5RoyX12Q,28956
68
+ sourcecode-1.30.29.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
69
+ sourcecode-1.30.29.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
70
+ sourcecode-1.30.29.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
71
+ sourcecode-1.30.29.dist-info/RECORD,,