sourcecode 1.30.28__py3-none-any.whl → 1.30.30__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.30"
@@ -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
@@ -188,6 +188,7 @@ _OPTIONS_WITH_VALUE: frozenset[str] = frozenset({
188
188
  "--rank-by",
189
189
  "--symbol",
190
190
  "--max-importers",
191
+ "--exclude",
191
192
  })
192
193
 
193
194
 
@@ -592,6 +593,16 @@ def main(
592
593
  "-c",
593
594
  help="Copy output to system clipboard after a successful run. No-op when --output is used or clipboard is unavailable.",
594
595
  ),
596
+ exclude: Optional[str] = typer.Option(
597
+ None,
598
+ "--exclude",
599
+ help="Additional directories/patterns to exclude, comma-separated (e.g. 'legacy,generated').",
600
+ ),
601
+ no_cache: bool = typer.Option(
602
+ False,
603
+ "--no-cache",
604
+ help="Bypass the scan cache and force a fresh analysis.",
605
+ ),
595
606
  ) -> None:
596
607
  """Analyze a repository and produce structured context for AI coding agents.
597
608
 
@@ -798,10 +809,67 @@ def main(
798
809
  no_tree = True # agents never need the raw file tree
799
810
  architecture = True # agents need full architectural signal (M4)
800
811
 
812
+ # ── GAP-9: Cache check — serve from .sourcecode-cache when git SHA unchanged ──
813
+ import hashlib as _hashlib
814
+ import subprocess as _sub
815
+ _cache_dir = target / ".sourcecode-cache"
816
+ _cache_hit_content: Optional[str] = None
817
+ _git_sha = ""
818
+ _cache_key = ""
819
+ _cache_file: Optional[Path] = None
820
+ if not no_cache:
821
+ try:
822
+ _sha_r = _sub.run(
823
+ ["git", "-C", str(target), "rev-parse", "--short", "HEAD"],
824
+ capture_output=True, text=True, timeout=3,
825
+ )
826
+ _git_sha = _sha_r.stdout.strip()
827
+ # Only cache when target IS the git repo root (not a subdir of one),
828
+ # to avoid polluting sub-project directories used in tests.
829
+ if _git_sha and (target / ".git").exists():
830
+ # Include every output-affecting flag so different flag combos never collide
831
+ _flags_str = (
832
+ f"c={compact},ag={agent},fmt={format},full={full},"
833
+ f"co={changed_only},dep={dependencies},gm={graph_modules},"
834
+ f"docs={docs},fm={full_metrics},sem={semantics},"
835
+ f"arch={architecture},gc={git_context},em={env_map},"
836
+ f"cn={code_notes},tree={tree},mode={mode}"
837
+ )
838
+ _flags_h = _hashlib.md5(_flags_str.encode()).hexdigest()[:8]
839
+ _cache_key = f"{_git_sha}-{_flags_h}"
840
+ _cache_file = _cache_dir / f"snapshot-{_cache_key}.json"
841
+ if _cache_file.exists():
842
+ _cache_hit_content = _cache_file.read_text(encoding="utf-8")
843
+ except Exception:
844
+ _git_sha = ""
845
+ _cache_key = ""
846
+ _cache_file = None
847
+
848
+ if _cache_hit_content is not None:
849
+ from sourcecode.serializer import write_output
850
+ write_output(_cache_hit_content, output=output)
851
+ if copy and not output:
852
+ _copy_to_clipboard(_cache_hit_content)
853
+ return
854
+
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
+ # IMP-2: warn if the exclude value looks like it was swallowed as a path
859
+ # (BUG-2 symptom in older versions: --exclude value consumed as repo path).
860
+ import sys as _sys_warn
861
+ if len(_extra_excludes) == 1 and Path(list(_extra_excludes)[0]).is_dir():
862
+ _sys_warn.stderr.write(
863
+ f"[sourcecode] Warning: --exclude value '{list(_extra_excludes)[0]}' is a directory path. "
864
+ "If this was meant as a pattern, use --exclude=pattern or --exclude pattern (both are supported).\n"
865
+ )
866
+ _sys_warn.stderr.flush()
867
+
801
868
  _progress = Progress()
802
869
  _progress.start("scanning files")
803
870
 
804
- scanner = AdaptiveScanner(target, topology=_topology, base_depth=effective_depth)
871
+ scanner = AdaptiveScanner(target, topology=_topology, base_depth=effective_depth,
872
+ extra_excludes=_extra_excludes)
805
873
  raw_tree = scanner.scan_tree()
806
874
 
807
875
  # 2. Filter .env and *.secret entries from file tree (SEC-02, all levels)
@@ -1514,9 +1582,10 @@ def main(
1514
1582
  content = json.dumps(data, indent=2, ensure_ascii=False)
1515
1583
  elif compact:
1516
1584
  if changed_only and _allowed_changed_files:
1585
+ # GAP-5: preserve full entry_points for architecture context even in
1586
+ # --changed-only mode. Only filter file_paths and code_notes.
1517
1587
  sm = _replace(sm,
1518
1588
  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
1589
  code_notes=[n for n in sm.code_notes if n.path in _allowed_changed_files],
1521
1590
  )
1522
1591
  data = compact_view(sm, no_tree=no_tree, full=full)
@@ -1585,6 +1654,14 @@ def main(
1585
1654
  _progress.finish()
1586
1655
  write_output(content, output=output)
1587
1656
 
1657
+ # GAP-9: Persist to cache for future identical runs (git SHA unchanged)
1658
+ if not no_cache and _cache_key and _cache_file is not None and not _pipeline_error:
1659
+ try:
1660
+ _cache_dir.mkdir(parents=True, exist_ok=True)
1661
+ _cache_file.write_text(content, encoding="utf-8")
1662
+ except Exception:
1663
+ pass
1664
+
1588
1665
  if _pipeline_error:
1589
1666
  raise typer.Exit(code=2)
1590
1667
 
@@ -1739,6 +1816,11 @@ def prepare_context_cmd(
1739
1816
  "--fast",
1740
1817
  help="Skip deep analysis (content search, test gap discovery, code annotations). Uses manifest/metadata only. Target: < 6 s.",
1741
1818
  ),
1819
+ include_config: bool = typer.Option(
1820
+ False,
1821
+ "--include-config",
1822
+ help="(generate-tests) Include tooling config files (*.conf.js, .eslintrc*, etc.) in test_gaps. Excluded by default.",
1823
+ ),
1742
1824
  ) -> None:
1743
1825
  """Task-specific context for AI coding agents.
1744
1826
 
@@ -1820,7 +1902,7 @@ def prepare_context_cmd(
1820
1902
  _sys.stderr.flush()
1821
1903
  _t0 = _time.perf_counter()
1822
1904
  try:
1823
- output = builder.build(task, since=since, symptom=symptom, fast=fast)
1905
+ output = builder.build(task, since=since, symptom=symptom, fast=fast, include_config=include_config)
1824
1906
  finally:
1825
1907
  _progress.finish()
1826
1908
  _t_total = (_time.perf_counter() - _t0) * 1000
@@ -2291,8 +2373,23 @@ def repo_ir_cmd(
2291
2373
  err=True,
2292
2374
  )
2293
2375
  else:
2294
- _sys.stdout.write(output)
2295
- _sys.stdout.write("\n")
2376
+ try:
2377
+ _sys.stdout.buffer.write(output.encode("utf-8"))
2378
+ _sys.stdout.buffer.write(b"\n")
2379
+ _sys.stdout.buffer.flush()
2380
+ except UnicodeEncodeError as _ue:
2381
+ # IMP-2: emit workaround before re-raising so the user knows what to do.
2382
+ _sys.stderr.write(
2383
+ f"[sourcecode] UnicodeEncodeError on stdout ({_ue.encoding}): "
2384
+ "your console codec cannot encode this output.\n"
2385
+ "Workaround: sourcecode repo-ir --output ir.json\n"
2386
+ )
2387
+ _sys.stderr.flush()
2388
+ raise
2389
+ except AttributeError:
2390
+ # Fallback for wrapped stdout without buffer (e.g. some test harnesses)
2391
+ _sys.stdout.write(output)
2392
+ _sys.stdout.write("\n")
2296
2393
 
2297
2394
 
2298
2395
  # ── version ───────────────────────────────────────────────────────────────────
@@ -2345,5 +2442,13 @@ def main_entry() -> None:
2345
2442
  can consume them as positional arguments (which would prevent subcommand
2346
2443
  routing for tokens like 'version' or 'config').
2347
2444
  """
2445
+ import sys as _sys
2446
+ # Force UTF-8 on stdout so Unicode characters (arrows, etc.) survive on
2447
+ # Windows where the default console codec is cp1252 (BUG-1).
2448
+ if hasattr(_sys.stdout, "reconfigure"):
2449
+ try:
2450
+ _sys.stdout.reconfigure(encoding="utf-8")
2451
+ except Exception:
2452
+ pass
2348
2453
  _preprocess_argv()
2349
2454
  app()
@@ -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,
@@ -685,7 +712,7 @@ class TaskContextBuilder:
685
712
  def __init__(self, root: Path) -> None:
686
713
  self.root = root
687
714
 
688
- def build(self, task_name: str, *, since: Optional[str] = None, symptom: Optional[str] = None, fast: bool = False) -> TaskOutput:
715
+ def build(self, task_name: str, *, since: Optional[str] = None, symptom: Optional[str] = None, fast: bool = False, include_config: bool = False) -> TaskOutput:
689
716
  if task_name not in TASKS:
690
717
  raise ValueError(
691
718
  f"Unknown task '{task_name}'. Available: {', '.join(TASKS)}"
@@ -761,6 +788,29 @@ 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 task_name == "onboard":
792
+ # Onboard fast: always use shallow scan so manifests and entry points
793
+ # are discoverable — git-changed-only mode would return only dirty files
794
+ # (e.g. .idea/vcs.xml) which yields no useful entry points (BUG-3).
795
+ scanner = AdaptiveScanner(self.root, base_depth=2)
796
+ file_tree = scanner.scan_tree()
797
+ manifests = scanner.find_manifests()
798
+ all_paths = [p.replace("\\", "/") for p in flatten_file_tree(file_tree)]
799
+ elif fast and _count_files_bounded(self.root) > MAX_FILES_FAST:
800
+ # Fast mode on large repo: git-index-only — only scan git-changed files.
801
+ # Skips full AdaptiveScanner traversal which takes 35s+ on 7k+ file repos.
802
+ _git_files = _git_changed_files_fast(self.root)
803
+ _git_files = [f for f in _git_files if (self.root / f).exists()]
804
+ if not _git_files:
805
+ # Fallback: use a shallow scan (depth 2) to get some context
806
+ scanner = AdaptiveScanner(self.root, base_depth=2)
807
+ file_tree = scanner.scan_tree()
808
+ manifests = scanner.find_manifests()
809
+ all_paths = [p.replace("\\", "/") for p in flatten_file_tree(file_tree)]
810
+ else:
811
+ file_tree = {}
812
+ all_paths = _git_files
813
+ # Keep manifests from shallow pre-scan
764
814
  else:
765
815
  _topology = RepoClassifier().classify(self.root)
766
816
  _base_depth = 12 if _is_java else 6
@@ -1413,8 +1463,9 @@ class TaskContextBuilder:
1413
1463
  _existing_paths.add(_p)
1414
1464
  _sx_direct_path.append(_p)
1415
1465
 
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.
1466
+ # Pass 4b: grep-based injection for frontend→backend synonym terms.
1467
+ # Runs parallel grep for each backend term to find files not yet in
1468
+ # the candidate pool (e.g. AkitaBaseService containing setLoading).
1418
1469
  _src_exts = frozenset({".java", ".py", ".ts", ".js", ".kt", ".go"})
1419
1470
  _frontend_kws = [kw for kw in symptom_keywords if kw in _FRONTEND_SYMPTOM_MAP]
1420
1471
  _backend_terms_set: list[str] = []
@@ -1424,6 +1475,40 @@ class TaskContextBuilder:
1424
1475
  _bt.extend(_FRONTEND_SYMPTOM_MAP[_fkw])
1425
1476
  _backend_terms_set = list(dict.fromkeys(_bt))
1426
1477
 
1478
+ if _backend_terms_set and not fast:
1479
+ import subprocess as _sp2
1480
+ _grepped: set[str] = set()
1481
+ for _term in _backend_terms_set[:6]:
1482
+ try:
1483
+ _gr = _sp2.run(
1484
+ ["grep", "-r", "-l", "-i", _term,
1485
+ "--include=*.ts", "--include=*.java",
1486
+ str(self.root)],
1487
+ capture_output=True, text=True, timeout=4,
1488
+ )
1489
+ for _line in _gr.stdout.splitlines():
1490
+ try:
1491
+ _rel = str(Path(_line.strip()).relative_to(self.root)).replace("\\", "/")
1492
+ _grepped.add(_rel)
1493
+ except ValueError:
1494
+ pass
1495
+ except Exception:
1496
+ pass
1497
+ _existing_paths_now = {rf.path for rf in relevant_files}
1498
+ for _gf in sorted(_grepped):
1499
+ if _gf in _existing_paths_now:
1500
+ continue
1501
+ if Path(_gf).suffix.lower() not in _src_exts:
1502
+ continue
1503
+ relevant_files.append(RelevantFile(
1504
+ path=_gf,
1505
+ role="symptom_match",
1506
+ score=0.45,
1507
+ reason="content contains backend symptom term (grep)",
1508
+ why=f"grep injection: {', '.join(_backend_terms_set[:3])}",
1509
+ ))
1510
+ _existing_paths_now.add(_gf)
1511
+
1427
1512
  # Sort before content scan so top candidates get read first
1428
1513
  relevant_files = sorted(relevant_files, key=lambda rf: -rf.score)
1429
1514
  _CONTENT_SCAN_LIMIT = 80
@@ -1575,11 +1660,26 @@ class TaskContextBuilder:
1575
1660
  # Python/JS: test_foo / foo_test
1576
1661
  return stem.removeprefix("test_").removesuffix("_test")
1577
1662
 
1663
+ # Patterns excluded from test_gaps by default (IMP-1): tooling config
1664
+ # files have no business logic to test. --include-config overrides.
1665
+ _CONFIG_EXCLUDE_PATTERNS = (
1666
+ ".eslintrc", ".prettierrc", "eslint.config",
1667
+ "karma.conf", "jest.config", "babel.config",
1668
+ "webpack.config", "vite.config", "rollup.config",
1669
+ "tsconfig", "angular.json", ".claude/",
1670
+ )
1671
+
1672
+ def _is_config_file(p: str) -> bool:
1673
+ name = Path(p).name.lower()
1674
+ norm = p.replace("\\", "/")
1675
+ return any(pat in name or pat in norm for pat in _CONFIG_EXCLUDE_PATTERNS)
1676
+
1578
1677
  test_stems = {_normalize_test_stem(Path(p).stem) for p in test_set}
1579
1678
  untested = [
1580
1679
  p for p in source_set
1581
1680
  if Path(p).stem not in test_stems
1582
1681
  and not any(pen in p for pen in spec.ranking_penalties)
1682
+ and (include_config or not _is_config_file(p))
1583
1683
  ]
1584
1684
  untested.sort(key=lambda p: (len(p.split("/")), p))
1585
1685
  test_gaps = untested[:15]
@@ -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,110 @@ 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
+ # Count lazy route patterns: `loadChildren:` (property syntax used in route
1647
+ # configs) and `loadComponent:` (standalone component lazy loading). The old
1648
+ # `loadChildren(` form counted zero because Angular uses property syntax, not
1649
+ # a function call (BUG-5).
1650
+ fname_lower = rel.replace("\\", "/").split("/")[-1].lower()
1651
+ _is_routing_file = (
1652
+ "routing" in fname_lower
1653
+ or fname_lower in ("app.routes.ts", "app-routing.module.ts")
1654
+ or fname_lower.endswith(".routes.ts")
1655
+ )
1656
+ lazy_routes_count += content.count("loadChildren:")
1657
+ lazy_routes_count += content.count("loadComponent:")
1658
+ if _is_routing_file:
1659
+ # Also count standalone dynamic imports that aren't already caught above
1660
+ _lc_imports = content.count("import(") - content.count("loadChildren:") - content.count("loadComponent:")
1661
+ if _lc_imports > 0:
1662
+ lazy_routes_count += _lc_imports
1663
+ akita_stores += content.count("@StoreConfig(")
1664
+ if not standalone_components and "bootstrapApplication(" in content:
1665
+ standalone_components = True
1666
+ # Route tree: parse path: '...' in routing files
1667
+ fname = rel.replace("\\", "/").split("/")[-1]
1668
+ if "routing" in fname or fname in ("app.routes.ts",):
1669
+ for m in _re.finditer(r"path\s*:\s*['\"]([^'\"]*)['\"]", content):
1670
+ val = m.group(1)
1671
+ if val and val not in route_paths:
1672
+ route_paths.append(val)
1673
+
1674
+ # Angular version from package.json
1675
+ angular_version: Optional[str] = None
1676
+ pkg_json = root / "package.json"
1677
+ if pkg_json.exists():
1678
+ try:
1679
+ pkg = _json.loads(pkg_json.read_text(encoding="utf-8", errors="replace"))
1680
+ # Use `or {}` so explicit `null` values in package.json don't
1681
+ # raise TypeError when unpacking (BUG-4).
1682
+ deps = {
1683
+ **(pkg.get("dependencies") or {}),
1684
+ **(pkg.get("devDependencies") or {}),
1685
+ **(pkg.get("peerDependencies") or {}),
1686
+ }
1687
+ av = deps.get("@angular/core")
1688
+ if av:
1689
+ angular_version = av.lstrip("^~>=")
1690
+ except Exception:
1691
+ pass
1692
+
1693
+ # Also check angular.json for entry point
1694
+ entry_point: Optional[str] = None
1695
+ angular_json = root / "angular.json"
1696
+ if angular_json.exists():
1697
+ try:
1698
+ aj = _json.loads(angular_json.read_text(encoding="utf-8", errors="replace"))
1699
+ projects = aj.get("projects") or {}
1700
+ for proj in projects.values():
1701
+ main = (
1702
+ (proj.get("architect") or {})
1703
+ .get("build", {})
1704
+ .get("options", {})
1705
+ .get("main")
1706
+ )
1707
+ if main:
1708
+ entry_point = main
1709
+ break
1710
+ except Exception:
1711
+ pass
1712
+
1713
+ return {
1714
+ "angular_version": angular_version,
1715
+ "standalone_components": standalone_components,
1716
+ "lazy_routes_count": lazy_routes_count,
1717
+ "akita_stores": akita_stores,
1718
+ "route_tree": route_paths[:20],
1719
+ "component_count": component_count,
1720
+ "service_count": service_count,
1721
+ **({"entry_point": entry_point} if entry_point else {}),
1722
+ }
1723
+
1724
+
1602
1725
  def agent_view(sm: SourceMap, *, full: bool = False) -> dict[str, Any]:
1603
1726
  """Opinionated output for AI agents — structured, noise-free, gap-aware.
1604
1727
 
@@ -1823,6 +1946,14 @@ def agent_view(sm: SourceMap, *, full: bool = False) -> dict[str, Any]:
1823
1946
  if signals:
1824
1947
  result["signals"] = signals
1825
1948
 
1949
+ # ── 8b. Angular structural analysis (GAP-10) ──────────────────────────────
1950
+ if sm.project_type in ("angular-spa", "webapp") or any(
1951
+ any(f.name == "Angular" for f in s.frameworks) for s in sm.stacks
1952
+ ):
1953
+ _ang = _angular_analysis(sm)
1954
+ if _ang and (_ang.get("component_count", 0) > 0 or _ang.get("angular_version")):
1955
+ result["angular_analysis"] = _ang
1956
+
1826
1957
  # ── 9. Git context — lightweight (top-5 hotspots, branch, uncommitted count)
1827
1958
  _gc = _compact_git_context(sm)
1828
1959
  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.30
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.30-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.30
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=ANLn7vd3QuDTWulbelwm9eIp93h5ZEZKi-nKm911_so,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=_WsbkJWvI_xv4aSh3mdPvTbh-eKGV5DZbPj2ZFuA2GI,100189
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=QNCl8uKk9PQpgXxPHBNSDXXkc1s2wTZwU6H0REC5Qms,173487
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=FM4xklb9Ywg9KNdNpo8QXR50izuml5FkbeQgL2uS1HY,111611
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.30.dist-info/METADATA,sha256=TRgm7Qpvohc3ofAWDRDEfmcF2lCQ3b-dqvEAn-Rd6qw,28956
68
+ sourcecode-1.30.30.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
69
+ sourcecode-1.30.30.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
70
+ sourcecode-1.30.30.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
71
+ sourcecode-1.30.30.dist-info/RECORD,,