source-kb 0.2.2__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.
Files changed (228) hide show
  1. cli/__init__.py +50 -0
  2. cli/__main__.py +5 -0
  3. cli/commands/__init__.py +1 -0
  4. cli/commands/anchor_fix.py +47 -0
  5. cli/commands/diff_doc.py +52 -0
  6. cli/commands/dispatch.py +77 -0
  7. cli/commands/extract.py +72 -0
  8. cli/commands/file_list.py +74 -0
  9. cli/commands/index.py +84 -0
  10. cli/commands/lock.py +89 -0
  11. cli/commands/merge.py +60 -0
  12. cli/commands/merge_delta.py +19 -0
  13. cli/commands/metadata.py +24 -0
  14. cli/commands/pipeline.py +45 -0
  15. cli/commands/post_merge.py +43 -0
  16. cli/commands/query.py +52 -0
  17. cli/commands/render.py +101 -0
  18. cli/commands/scan_repos.py +46 -0
  19. cli/commands/setup.py +94 -0
  20. cli/commands/split.py +196 -0
  21. cli/commands/stale_files.py +98 -0
  22. cli/commands/validate.py +191 -0
  23. core/__init__.py +32 -0
  24. core/config.py +261 -0
  25. core/docs/__init__.py +7 -0
  26. core/docs/section_updater.py +286 -0
  27. core/docs/shared.py +149 -0
  28. core/git.py +294 -0
  29. core/interfaces.py +249 -0
  30. core/monitor/__init__.py +5 -0
  31. core/monitor/progress.py +83 -0
  32. core/monitor/prompt_store.py +49 -0
  33. core/paths.py +141 -0
  34. core/preset.py +237 -0
  35. core/preset_accessors.py +202 -0
  36. core/preset_classify.py +132 -0
  37. core/preset_hooks.py +129 -0
  38. core/preset_profile.py +89 -0
  39. core/prompt/__init__.py +7 -0
  40. core/prompt/__main__.py +147 -0
  41. core/prompt/content.py +320 -0
  42. core/prompt/context_manager.py +164 -0
  43. core/prompt/renderer.py +236 -0
  44. core/prompt/response_parser.py +274 -0
  45. core/prompt/templates.py +357 -0
  46. core/prompt/validate_parity.py +162 -0
  47. core/prompt/variables.py +339 -0
  48. core/rag/__init__.py +22 -0
  49. core/rag/__main__.py +136 -0
  50. core/rag/bm25_index.py +268 -0
  51. core/rag/chunker.py +273 -0
  52. core/rag/embedder.py +151 -0
  53. core/rag/indexer.py +292 -0
  54. core/rag/loader.py +89 -0
  55. core/rag/retriever.py +82 -0
  56. core/skeleton/__init__.py +11 -0
  57. core/skeleton/__main__.py +934 -0
  58. core/skeleton/anchor_fix.py +250 -0
  59. core/skeleton/classify.py +331 -0
  60. core/skeleton/cmd_anchor_fix.py +43 -0
  61. core/skeleton/cmd_diff_doc.py +44 -0
  62. core/skeleton/cmd_lock.py +87 -0
  63. core/skeleton/cmd_merge_delta.py +41 -0
  64. core/skeleton/community.py +233 -0
  65. core/skeleton/dependency_graph.py +306 -0
  66. core/skeleton/diff_doc.py +248 -0
  67. core/skeleton/dispatch.py +273 -0
  68. core/skeleton/dispatch_render.py +319 -0
  69. core/skeleton/dispatch_source.py +111 -0
  70. core/skeleton/extract.py +218 -0
  71. core/skeleton/extract_methods.py +298 -0
  72. core/skeleton/file_list.py +239 -0
  73. core/skeleton/impact.py +278 -0
  74. core/skeleton/jar_download.py +177 -0
  75. core/skeleton/jar_resolver.py +186 -0
  76. core/skeleton/loader.py +162 -0
  77. core/skeleton/merge.py +278 -0
  78. core/skeleton/merge_delta.py +229 -0
  79. core/skeleton/metadata.py +96 -0
  80. core/skeleton/metadata_builders.py +264 -0
  81. core/skeleton/module_dag.py +330 -0
  82. core/skeleton/parsers/__init__.py +71 -0
  83. core/skeleton/parsers/jqassistant.py +300 -0
  84. core/skeleton/parsers/jqassistant_cypher.py +225 -0
  85. core/skeleton/parsers/regex.py +171 -0
  86. core/skeleton/parsers/treesitter.py +324 -0
  87. core/skeleton/parsers/treesitter_java.py +284 -0
  88. core/skeleton/parsers/treesitter_multi.py +289 -0
  89. core/skeleton/pom_parser.py +299 -0
  90. core/skeleton/post_merge.py +295 -0
  91. core/skeleton/post_merge_llm.py +82 -0
  92. core/skeleton/query.py +195 -0
  93. core/skeleton/shard_context.py +177 -0
  94. core/skeleton/split.py +180 -0
  95. core/skeleton/split_cache.py +107 -0
  96. core/skeleton/split_feedback.py +174 -0
  97. core/skeleton/split_plan.py +219 -0
  98. core/skeleton/split_plan_helpers.py +305 -0
  99. core/skeleton/split_plan_llm.py +274 -0
  100. core/utils.py +135 -0
  101. core/validators/__init__.py +65 -0
  102. core/validators/__main__.py +215 -0
  103. core/validators/consistency.py +203 -0
  104. core/validators/coverage.py +171 -0
  105. core/validators/duplicates.py +76 -0
  106. core/validators/engine.py +224 -0
  107. core/validators/links.py +76 -0
  108. core/validators/sampling.py +169 -0
  109. core/validators/structure.py +144 -0
  110. engine/__init__.py +7 -0
  111. engine/assembler.py +231 -0
  112. engine/confirm.py +65 -0
  113. engine/dedup.py +106 -0
  114. engine/main.py +211 -0
  115. engine/pipeline/__init__.py +163 -0
  116. engine/pipeline/recovery.py +250 -0
  117. engine/pipeline/steps/__init__.py +23 -0
  118. engine/pipeline/steps/audit.py +220 -0
  119. engine/pipeline/steps/audit_apply.py +195 -0
  120. engine/pipeline/steps/audit_helpers.py +155 -0
  121. engine/pipeline/steps/classify_llm.py +236 -0
  122. engine/pipeline/steps/classify_prompt.py +223 -0
  123. engine/pipeline/steps/finalize.py +160 -0
  124. engine/pipeline/steps/generate.py +169 -0
  125. engine/pipeline/steps/generate_batch.py +197 -0
  126. engine/pipeline/steps/generate_recovery.py +170 -0
  127. engine/pipeline/steps/llm_plan_split.py +253 -0
  128. engine/pipeline/steps/lock.py +64 -0
  129. engine/pipeline/steps/preflight.py +237 -0
  130. engine/pipeline/steps/preflight_adjust.py +147 -0
  131. engine/pipeline/steps/pregenerate.py +130 -0
  132. engine/pipeline/steps/quality.py +81 -0
  133. engine/pipeline/steps/skeleton.py +149 -0
  134. engine/pipeline/steps/source.py +163 -0
  135. engine/pipeline/steps/sync.py +117 -0
  136. engine/pipeline/steps/sync_finalize.py +237 -0
  137. engine/pipeline/steps/sync_update.py +341 -0
  138. engine/pipelines.py +91 -0
  139. engine/runner.py +335 -0
  140. engine/strategies/__init__.py +86 -0
  141. engine/strategies/api.py +128 -0
  142. engine/strategies/delegated.py +50 -0
  143. engine/strategies/dryrun.py +25 -0
  144. engine/two_phase.py +143 -0
  145. mcp_server/__init__.py +73 -0
  146. mcp_server/__main__.py +5 -0
  147. mcp_server/tools/__init__.py +1 -0
  148. mcp_server/tools/config.py +63 -0
  149. mcp_server/tools/discovery.py +276 -0
  150. mcp_server/tools/generation.py +184 -0
  151. mcp_server/tools/planning.py +144 -0
  152. mcp_server/tools/source.py +175 -0
  153. mcp_server/tools/validation.py +140 -0
  154. mcp_server/tools/workflow.py +166 -0
  155. mcp_server/workflow_loader.py +204 -0
  156. presets/generic/audit_dimensions.md +132 -0
  157. presets/generic/doc_types.yaml +152 -0
  158. presets/generic/preset.yaml +115 -0
  159. presets/java-spring/audit_dimensions.md +228 -0
  160. presets/java-spring/audit_dimensions.yaml +203 -0
  161. presets/java-spring/doc_types.yaml +269 -0
  162. presets/java-spring/hooks.py +122 -0
  163. presets/java-spring/preset.yaml +341 -0
  164. presets/java-spring/templates/README.md +34 -0
  165. presets/java-spring/templates/audit-system.md +15 -0
  166. presets/java-spring/templates/subagent-aop.md +105 -0
  167. presets/java-spring/templates/subagent-api.md +63 -0
  168. presets/java-spring/templates/subagent-architecture.md +111 -0
  169. presets/java-spring/templates/subagent-async-events.md +107 -0
  170. presets/java-spring/templates/subagent-audit-api-contracts.md +40 -0
  171. presets/java-spring/templates/subagent-audit-architecture.md +38 -0
  172. presets/java-spring/templates/subagent-audit-business.md +40 -0
  173. presets/java-spring/templates/subagent-audit-data-models.md +40 -0
  174. presets/java-spring/templates/subagent-business.md +129 -0
  175. presets/java-spring/templates/subagent-caching.md +75 -0
  176. presets/java-spring/templates/subagent-database-access.md +114 -0
  177. presets/java-spring/templates/subagent-enum.md +75 -0
  178. presets/java-spring/templates/subagent-error-handling.md +91 -0
  179. presets/java-spring/templates/subagent-external-integrations.md +80 -0
  180. presets/java-spring/templates/subagent-index.md +122 -0
  181. presets/java-spring/templates/subagent-messaging.md +97 -0
  182. presets/java-spring/templates/subagent-model.md +88 -0
  183. presets/java-spring/templates/subagent-observability.md +91 -0
  184. presets/java-spring/templates/subagent-scheduled.md +81 -0
  185. presets/java-spring/templates/subagent-security.md +102 -0
  186. presets/java-spring/templates/subagent-structure.md +101 -0
  187. presets/java-spring/templates/subagent-sync-section.md +34 -0
  188. presets/java-spring/templates/subagent-utils.md +73 -0
  189. presets/java-spring/templates/sync-system.md +8 -0
  190. presets/java-spring/workflow-extensions.md +112 -0
  191. skills/__init__.py +1 -0
  192. skills/_shared/README.md +30 -0
  193. skills/_shared/doc-coverage-shared.md +134 -0
  194. skills/_shared/doc-quality-standard.md +1058 -0
  195. skills/_shared/doc-subagent-rules.md +762 -0
  196. skills/_shared/windows-compat.md +89 -0
  197. skills/kb-audit/SKILL.md +52 -0
  198. skills/kb-audit/rules.md +88 -0
  199. skills/kb-audit/steps/step-01-prepare.md +75 -0
  200. skills/kb-audit/steps/step-02-audit.md +96 -0
  201. skills/kb-audit/steps/step-03-verify.md +65 -0
  202. skills/kb-audit/steps/step-04-report.md +64 -0
  203. skills/kb-init/SKILL.md +146 -0
  204. skills/kb-init/rules.md +187 -0
  205. skills/kb-init/steps/step-01-scope.md +62 -0
  206. skills/kb-init/steps/step-02-source.md +410 -0
  207. skills/kb-init/steps/step-03-generate.md +307 -0
  208. skills/kb-init/steps/step-04-quality.md +92 -0
  209. skills/kb-init/steps/step-05-finalize.md +132 -0
  210. skills/kb-init/templates/core/execution-modes.md +29 -0
  211. skills/kb-init/templates/core/output-only.md +4 -0
  212. skills/kb-init/templates/core/readwrite.md +33 -0
  213. skills/kb-search/SKILL.md +138 -0
  214. skills/kb-search/rules.md +64 -0
  215. skills/kb-sync/SKILL.md +43 -0
  216. skills/kb-sync/rules.md +70 -0
  217. skills/kb-sync/scripts/rebuild_module.py +91 -0
  218. skills/kb-sync/scripts/scan_repos.py +687 -0
  219. skills/kb-sync/steps/step-01-detect.md +72 -0
  220. skills/kb-sync/steps/step-02-update.md +71 -0
  221. skills/kb-sync/steps/step-03-verify.md +47 -0
  222. skills/kb-sync/steps/step-04-finalize.md +52 -0
  223. source_kb-0.2.2.dist-info/METADATA +194 -0
  224. source_kb-0.2.2.dist-info/RECORD +228 -0
  225. source_kb-0.2.2.dist-info/WHEEL +5 -0
  226. source_kb-0.2.2.dist-info/entry_points.txt +3 -0
  227. source_kb-0.2.2.dist-info/licenses/LICENSE +21 -0
  228. source_kb-0.2.2.dist-info/top_level.txt +6 -0
@@ -0,0 +1,87 @@
1
+ """Lock management command — acquire/release/status for both Agent and CLI modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+
13
+ def cmd_lock(args: argparse.Namespace) -> None:
14
+ """Unified lock management for both Agent and CLI modes."""
15
+ lock_dir = Path(args.dir)
16
+ lock_path = lock_dir / ".kb-lock"
17
+ lock_meta_path = lock_dir / ".kb-lock.json"
18
+
19
+ if args.action == "acquire":
20
+ lock_dir.mkdir(parents=True, exist_ok=True)
21
+
22
+ # Check if already locked
23
+ if lock_path.exists():
24
+ if _is_stale_lock(lock_meta_path):
25
+ lock_path.unlink(missing_ok=True)
26
+ lock_meta_path.unlink(missing_ok=True)
27
+ print(json.dumps({"status": "warning", "message": "Stale lock overridden"},
28
+ ensure_ascii=False), file=sys.stderr)
29
+ else:
30
+ meta = {}
31
+ if lock_meta_path.exists():
32
+ try:
33
+ meta = json.loads(lock_meta_path.read_text(encoding="utf-8"))
34
+ except (json.JSONDecodeError, OSError):
35
+ pass
36
+ print(json.dumps({"status": "error", "message": "Already locked",
37
+ **meta}, ensure_ascii=False), file=sys.stderr)
38
+ sys.exit(1)
39
+
40
+ # Acquire
41
+ lock_path.write_text(str(os.getpid()), encoding="utf-8")
42
+ meta = {
43
+ "pid": os.getpid(),
44
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
45
+ "operation": args.operation or "unknown",
46
+ }
47
+ lock_meta_path.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
48
+ print(json.dumps({"status": "ok", "action": "acquired"}, ensure_ascii=False),
49
+ file=sys.stderr)
50
+
51
+ elif args.action == "release":
52
+ lock_path.unlink(missing_ok=True)
53
+ lock_meta_path.unlink(missing_ok=True)
54
+ print(json.dumps({"status": "ok", "action": "released"}, ensure_ascii=False),
55
+ file=sys.stderr)
56
+
57
+ elif args.action == "status":
58
+ if lock_path.exists():
59
+ meta = {}
60
+ if lock_meta_path.exists():
61
+ try:
62
+ meta = json.loads(lock_meta_path.read_text(encoding="utf-8"))
63
+ except (json.JSONDecodeError, OSError):
64
+ pass
65
+ stale = _is_stale_lock(lock_meta_path)
66
+ print(json.dumps({"status": "locked", "stale": stale, **meta},
67
+ ensure_ascii=False), file=sys.stderr)
68
+ else:
69
+ print(json.dumps({"status": "unlocked"}, ensure_ascii=False), file=sys.stderr)
70
+
71
+
72
+ def _is_stale_lock(lock_meta_path: Path) -> bool:
73
+ """Check if a lock is stale (>30 minutes old)."""
74
+ if not lock_meta_path.exists():
75
+ return True
76
+ try:
77
+ meta = json.loads(lock_meta_path.read_text(encoding="utf-8"))
78
+ ts = meta.get("timestamp", "")
79
+ if not ts:
80
+ return True
81
+ from datetime import datetime, timezone
82
+ lock_time = datetime.fromisoformat(ts.replace("Z", "+00:00"))
83
+ now = datetime.now(timezone.utc)
84
+ age_minutes = (now - lock_time).total_seconds() / 60
85
+ return age_minutes > 30
86
+ except (json.JSONDecodeError, OSError, ValueError):
87
+ return True
@@ -0,0 +1,41 @@
1
+ """Merge delta command — merge skeleton delta into existing skeleton."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def cmd_merge_delta(args: argparse.Namespace) -> None:
12
+ """Merge skeleton delta into existing skeleton."""
13
+ from core.skeleton.merge_delta import merge_delta
14
+
15
+ delta_path = Path(args.delta)
16
+ target_path = Path(args.target)
17
+
18
+ if not delta_path.exists():
19
+ print(json.dumps({"status": "error", "message": f"Delta not found: {delta_path}"},
20
+ ensure_ascii=False), file=sys.stderr)
21
+ sys.exit(1)
22
+
23
+ try:
24
+ stats = merge_delta(
25
+ delta_path=delta_path,
26
+ module_dir=target_path,
27
+ dry_run=args.dry_run,
28
+ cleanup=not args.no_cleanup,
29
+ )
30
+ except (json.JSONDecodeError, ValueError) as e:
31
+ print(json.dumps({"status": "error", "message": f"Malformed delta: {e}"},
32
+ ensure_ascii=False), file=sys.stderr)
33
+ sys.exit(1)
34
+
35
+ result = {"status": "ok", "replaced": stats.replaced, "added": stats.added}
36
+ if stats.unmatched:
37
+ result["unmatched"] = stats.unmatched
38
+ if args.dry_run:
39
+ result["dry_run"] = True
40
+
41
+ print(json.dumps(result, ensure_ascii=False), file=sys.stderr)
@@ -0,0 +1,233 @@
1
+ """Community detection — Louvain-based file grouping for intelligent splitting.
2
+
3
+ Uses dependency graph to identify highly cohesive file groups via community
4
+ detection. Falls back to connected components when networkx is unavailable.
5
+
6
+ Usage:
7
+ from core.skeleton.community import detect_communities, validate_communities, split_by_community
8
+
9
+ graph = build_dependency_graph(skeleton_entries)
10
+ communities = detect_communities(graph, resolution=1.0)
11
+ valid = validate_communities(communities, max_files_per_shard=80)
12
+ plan = split_by_community(entries, graph, resolution=1.0)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from collections import defaultdict
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from core.skeleton.dependency_graph import DependencyGraph
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Public API
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ def detect_communities(
31
+ graph: DependencyGraph,
32
+ resolution: float = 1.0,
33
+ ) -> list[list[str]]:
34
+ """Detect communities in the dependency graph.
35
+
36
+ Uses networkx Louvain algorithm if available, otherwise falls back
37
+ to connected components with weight threshold.
38
+
39
+ Args:
40
+ graph: DependencyGraph instance
41
+ resolution: Louvain resolution parameter (higher = smaller communities)
42
+
43
+ Returns:
44
+ List of file path lists, each sub-list is a community.
45
+ """
46
+ if not graph.adjacency:
47
+ return []
48
+
49
+ try:
50
+ return _detect_communities_networkx(graph, resolution)
51
+ except ImportError:
52
+ return _detect_communities_fallback(graph)
53
+
54
+
55
+ def validate_communities(
56
+ communities: list[list[str]],
57
+ max_files_per_shard: int = 80,
58
+ ) -> bool:
59
+ """Validate that all communities are within size constraints.
60
+
61
+ Args:
62
+ communities: Output from detect_communities
63
+ max_files_per_shard: Maximum files allowed per community
64
+
65
+ Returns:
66
+ True if all communities are within limits.
67
+ """
68
+ if not communities:
69
+ return True
70
+ return all(len(c) <= max_files_per_shard for c in communities)
71
+
72
+
73
+ def split_by_community(
74
+ entries: list[dict[str, Any]],
75
+ graph: DependencyGraph,
76
+ resolution: float = 1.0,
77
+ max_files_per_shard: int = 80,
78
+ max_lines_per_shard: int = 8000,
79
+ ) -> list[list[dict[str, Any]]]:
80
+ """Split skeleton entries into groups based on community detection.
81
+
82
+ Args:
83
+ entries: File info dicts with at least {name, path, lines}
84
+ graph: DependencyGraph built from skeleton
85
+ resolution: Louvain resolution parameter
86
+ max_files_per_shard: Max files per group
87
+ max_lines_per_shard: Max lines per group
88
+
89
+ Returns:
90
+ List of entry groups. Each group is a list of file dicts.
91
+ """
92
+ communities = detect_communities(graph, resolution)
93
+ if not communities or len(communities) <= 1:
94
+ return [entries] if entries else []
95
+
96
+ # Build lookup indices
97
+ path_to_entry = {e.get("path", ""): e for e in entries}
98
+ name_to_entry = {e.get("name", ""): e for e in entries}
99
+
100
+ groups: list[list[dict]] = []
101
+ assigned: set[str] = set()
102
+
103
+ for community_files in communities:
104
+ group: list[dict] = []
105
+ for filepath in community_files:
106
+ entry = path_to_entry.get(filepath)
107
+ if not entry:
108
+ fname = Path(filepath).name
109
+ entry = name_to_entry.get(fname)
110
+ if entry and entry.get("name", "") not in assigned:
111
+ group.append(entry)
112
+ assigned.add(entry.get("name", ""))
113
+ if group:
114
+ # Split oversized communities
115
+ group_lines = sum(e.get("lines", 0) for e in group)
116
+ if len(group) > max_files_per_shard or group_lines > max_lines_per_shard:
117
+ sub_groups = _split_oversized(group, max_lines_per_shard, max_files_per_shard)
118
+ groups.extend(sub_groups)
119
+ else:
120
+ groups.append(group)
121
+
122
+ # Handle unassigned entries
123
+ unassigned = [e for e in entries if e.get("name", "") not in assigned]
124
+ if unassigned:
125
+ unassigned_lines = sum(e.get("lines", 0) for e in unassigned)
126
+ if unassigned_lines < 200 and len(unassigned) < 5 and groups:
127
+ # Merge into smallest group
128
+ smallest = min(groups, key=lambda g: sum(e.get("lines", 0) for e in g))
129
+ smallest.extend(unassigned)
130
+ else:
131
+ groups.append(unassigned)
132
+
133
+ return groups
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Internal: networkx-based detection
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ def _detect_communities_networkx(
142
+ graph: DependencyGraph,
143
+ resolution: float = 1.0,
144
+ ) -> list[list[str]]:
145
+ """Use networkx Louvain community detection."""
146
+ import networkx as nx
147
+
148
+ G = nx.Graph()
149
+ all_nodes = graph.nodes
150
+ G.add_nodes_from(all_nodes)
151
+
152
+ # Add weighted edges (undirected, take max weight for bidirectional)
153
+ for source, targets in graph.adjacency.items():
154
+ for target, weight in targets.items():
155
+ if G.has_edge(source, target):
156
+ existing = G[source][target].get("weight", 0)
157
+ G[source][target]["weight"] = max(existing, weight)
158
+ else:
159
+ G.add_edge(source, target, weight=weight)
160
+
161
+ if G.number_of_edges() == 0:
162
+ return [[n] for n in G.nodes()]
163
+
164
+ communities = nx.community.louvain_communities(
165
+ G, weight="weight", resolution=resolution, seed=42
166
+ )
167
+ return [sorted(list(c)) for c in communities]
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Internal: Fallback detection (no networkx)
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ def _detect_communities_fallback(
176
+ graph: DependencyGraph,
177
+ ) -> list[list[str]]:
178
+ """Fallback: connected components with weight threshold >= 2."""
179
+ adj: dict[str, set[str]] = defaultdict(set)
180
+ all_nodes = graph.nodes
181
+
182
+ for source, targets in graph.adjacency.items():
183
+ for target, weight in targets.items():
184
+ if weight >= 2:
185
+ adj[source].add(target)
186
+ adj[target].add(source)
187
+
188
+ visited: set[str] = set()
189
+ communities: list[list[str]] = []
190
+
191
+ for node in sorted(all_nodes):
192
+ if node in visited:
193
+ continue
194
+ component: list[str] = []
195
+ queue = [node]
196
+ while queue:
197
+ current = queue.pop(0)
198
+ if current in visited:
199
+ continue
200
+ visited.add(current)
201
+ component.append(current)
202
+ for neighbor in adj.get(current, set()):
203
+ if neighbor not in visited:
204
+ queue.append(neighbor)
205
+ communities.append(sorted(component))
206
+
207
+ return communities
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Internal: Helpers
212
+ # ---------------------------------------------------------------------------
213
+
214
+
215
+ def _split_oversized(
216
+ files: list[dict], max_lines: int, max_files: int
217
+ ) -> list[list[dict]]:
218
+ """Split an oversized community using LPT greedy balancing."""
219
+ total_lines = sum(f.get("lines", 0) for f in files)
220
+ n = max(
221
+ math.ceil(len(files) / max_files),
222
+ math.ceil(total_lines / max_lines),
223
+ 2,
224
+ )
225
+ buckets: list[list[dict]] = [[] for _ in range(n)]
226
+ bucket_lines = [0] * n
227
+
228
+ for f in sorted(files, key=lambda x: -x.get("lines", 0)):
229
+ lightest = min(range(n), key=lambda i: bucket_lines[i])
230
+ buckets[lightest].append(f)
231
+ bucket_lines[lightest] += f.get("lines", 0)
232
+
233
+ return [b for b in buckets if b]
@@ -0,0 +1,306 @@
1
+ """Dependency graph construction — file-level dependency analysis from skeleton data.
2
+
3
+ Builds a weighted directed graph from skeleton entries. Supports topological sort
4
+ and cycle detection. Dependency weights: inheritance=4, injection=3, import=2, call=1.
5
+
6
+ Usage:
7
+ from core.skeleton.dependency_graph import (
8
+ DependencyEdge, DependencyGraph, build_dependency_graph, topo_sort, detect_cycles
9
+ )
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from collections import defaultdict, deque
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Data classes
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ @dataclass
27
+ class DependencyEdge:
28
+ """A single dependency edge between two files."""
29
+
30
+ source: str
31
+ target: str
32
+ weight: int = 1
33
+ kind: str = "import" # import | inheritance | injection | call
34
+
35
+
36
+ @dataclass
37
+ class DependencyGraph:
38
+ """File-level dependency graph with adjacency dict and edge list."""
39
+
40
+ adjacency: dict[str, dict[str, int]] = field(default_factory=dict)
41
+ edges: list[DependencyEdge] = field(default_factory=list)
42
+
43
+ @property
44
+ def nodes(self) -> set[str]:
45
+ """All nodes in the graph."""
46
+ nodes = set(self.adjacency.keys())
47
+ for targets in self.adjacency.values():
48
+ nodes.update(targets.keys())
49
+ return nodes
50
+
51
+ def add_edge(self, source: str, target: str, weight: int, kind: str) -> None:
52
+ """Add or accumulate an edge."""
53
+ if source not in self.adjacency:
54
+ self.adjacency[source] = {}
55
+ self.adjacency.setdefault(source, {})[target] = (
56
+ self.adjacency.get(source, {}).get(target, 0) + weight
57
+ )
58
+ self.edges.append(DependencyEdge(source=source, target=target, weight=weight, kind=kind))
59
+
60
+
61
+ # Edge weight constants
62
+ WEIGHT_INHERITANCE = 4
63
+ WEIGHT_INJECTION = 3
64
+ WEIGHT_IMPORT = 2
65
+ WEIGHT_CALL = 1
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Public API
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def build_dependency_graph(
74
+ skeleton_entries: list[dict[str, Any]],
75
+ project_package_prefix: str | None = None,
76
+ ) -> DependencyGraph:
77
+ """Build a file-level dependency graph from skeleton entries."""
78
+ class_to_file = _build_class_to_file_index(skeleton_entries)
79
+ field_type_to_file = _build_field_type_index(skeleton_entries)
80
+ if project_package_prefix is None:
81
+ project_package_prefix = _infer_package_prefix(skeleton_entries)
82
+
83
+ graph = DependencyGraph()
84
+ for entry in skeleton_entries:
85
+ source_file = entry.get("file", "")
86
+ if not source_file:
87
+ continue
88
+ _add_import_edges(entry, source_file, class_to_file, project_package_prefix, graph)
89
+ _add_inheritance_edges(entry, source_file, class_to_file, graph)
90
+ _add_injection_edges(entry, source_file, class_to_file, graph)
91
+ _add_call_edges(entry, source_file, class_to_file, field_type_to_file, graph)
92
+ return graph
93
+
94
+
95
+ def topo_sort(graph: DependencyGraph) -> list[str]:
96
+ """Topological sort (Kahn's algorithm). Dependencies come first."""
97
+ nodes = graph.nodes
98
+ in_degree: dict[str, int] = {n: 0 for n in nodes}
99
+ for source, targets in graph.adjacency.items():
100
+ for target in targets:
101
+ if target in in_degree:
102
+ in_degree[target] += 1
103
+
104
+ queue = deque(sorted(n for n in nodes if in_degree[n] == 0))
105
+ result: list[str] = []
106
+ while queue:
107
+ node = queue.popleft()
108
+ result.append(node)
109
+ for target in graph.adjacency.get(node, {}):
110
+ if target in in_degree:
111
+ in_degree[target] -= 1
112
+ if in_degree[target] == 0:
113
+ queue.append(target)
114
+ # Append remaining (cycle members)
115
+ if len(result) < len(nodes):
116
+ result.extend(sorted(n for n in nodes if n not in set(result)))
117
+ return result
118
+
119
+
120
+ def detect_cycles(graph: DependencyGraph) -> list[list[str]]:
121
+ """Detect cycles in the dependency graph using DFS."""
122
+ nodes = graph.nodes
123
+ visited: set[str] = set()
124
+ rec_stack: set[str] = set()
125
+ cycles: list[list[str]] = []
126
+ path: list[str] = []
127
+
128
+ def dfs(node: str) -> None:
129
+ visited.add(node)
130
+ rec_stack.add(node)
131
+ path.append(node)
132
+ for neighbor in graph.adjacency.get(node, {}):
133
+ if neighbor not in visited:
134
+ dfs(neighbor)
135
+ elif neighbor in rec_stack:
136
+ cycle_start = path.index(neighbor)
137
+ cycles.append(path[cycle_start:] + [neighbor])
138
+ path.pop()
139
+ rec_stack.discard(node)
140
+
141
+ for node in sorted(nodes):
142
+ if node not in visited:
143
+ dfs(node)
144
+ return cycles
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Internal: Index builders
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ def _build_class_to_file_index(entries: list[dict]) -> dict[str, str]:
153
+ """Build class_name → file_path index."""
154
+ index: dict[str, str] = {}
155
+ for entry in entries:
156
+ filepath = entry.get("file", "")
157
+ for cls in entry.get("classes", []):
158
+ name = cls.get("name", "")
159
+ if name:
160
+ index[name] = filepath
161
+ stem = Path(filepath).stem
162
+ if stem and stem not in index:
163
+ index[stem] = filepath
164
+ return index
165
+
166
+
167
+ def _build_field_type_index(entries: list[dict]) -> dict[str, dict[str, str]]:
168
+ """Build file → {field_name: field_type} index."""
169
+ index: dict[str, dict[str, str]] = {}
170
+ for entry in entries:
171
+ filepath = entry.get("file", "")
172
+ fields: dict[str, str] = {}
173
+ for f in entry.get("fields", []):
174
+ if f.get("name") and f.get("type"):
175
+ fields[f["name"]] = f["type"]
176
+ if fields:
177
+ index[filepath] = fields
178
+ return index
179
+
180
+
181
+ def _infer_package_prefix(entries: list[dict]) -> str:
182
+ """Auto-infer project package prefix from skeleton entries."""
183
+ prefix_counts: dict[str, int] = defaultdict(int)
184
+ for entry in entries:
185
+ for imp in entry.get("imports", []):
186
+ parts = imp.split(".")
187
+ if len(parts) >= 3:
188
+ prefix = ".".join(parts[:3])
189
+ prefix_counts[prefix] += 1
190
+ if not prefix_counts:
191
+ for entry in entries:
192
+ filepath = entry.get("file", "").replace("\\", "/")
193
+ idx = filepath.find("/java/")
194
+ if idx >= 0:
195
+ pkg_path = filepath[idx + 6:]
196
+ parts = pkg_path.split("/")
197
+ if len(parts) >= 3:
198
+ prefix = ".".join(parts[:3])
199
+ prefix_counts[prefix] += 1
200
+ if prefix_counts:
201
+ return max(prefix_counts, key=prefix_counts.get)
202
+ return ""
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Internal: Edge builders
207
+ # ---------------------------------------------------------------------------
208
+
209
+ _COMMON_TYPES = frozenset({
210
+ "String", "Integer", "Long", "Double", "Float", "Boolean", "Byte", "Short",
211
+ "Object", "Void", "BigDecimal", "BigInteger", "Date",
212
+ "LocalDate", "LocalDateTime", "Instant", "Duration",
213
+ "List", "Map", "Set", "Collection", "Optional", "Stream",
214
+ "HashMap", "ArrayList", "HashSet", "LinkedList",
215
+ "Serializable", "Comparable", "Iterable", "Iterator",
216
+ "Exception", "RuntimeException", "Throwable", "Logger", "Log",
217
+ "HttpServletRequest", "HttpServletResponse", "ResponseEntity",
218
+ })
219
+
220
+
221
+ def get_common_types(hooks=None) -> frozenset[str]:
222
+ """Get common types to exclude from dependency analysis."""
223
+ if hooks:
224
+ custom = hooks.get_common_types()
225
+ if custom:
226
+ return custom
227
+ return _COMMON_TYPES
228
+
229
+
230
+ def _add_import_edges(entry: dict, source: str, class_to_file: dict, prefix: str, graph: DependencyGraph):
231
+ for imp in entry.get("imports", []):
232
+ if prefix and not imp.startswith(prefix):
233
+ continue
234
+ class_name = imp.split(".")[-1]
235
+ if class_name == "*":
236
+ continue
237
+ target = class_to_file.get(class_name)
238
+ if target and target != source:
239
+ graph.add_edge(source, target, WEIGHT_IMPORT, "import")
240
+
241
+
242
+ def _add_inheritance_edges(entry: dict, source: str, class_to_file: dict, graph: DependencyGraph):
243
+ for cls in entry.get("classes", []):
244
+ extends = cls.get("extends", "")
245
+ if extends:
246
+ target = class_to_file.get(extends.split(".")[-1])
247
+ if target and target != source:
248
+ graph.add_edge(source, target, WEIGHT_INHERITANCE, "inheritance")
249
+ for iface in cls.get("implements", []) or []:
250
+ target = class_to_file.get(iface.split(".")[-1])
251
+ if target and target != source:
252
+ graph.add_edge(source, target, WEIGHT_INHERITANCE, "inheritance")
253
+
254
+
255
+ def _add_injection_edges(entry: dict, source: str, class_to_file: dict, graph: DependencyGraph,
256
+ inject_annotations: set[str] | None = None,
257
+ common_types: frozenset[str] | None = None):
258
+ if inject_annotations is None:
259
+ inject_annotations = {"@Autowired", "@Resource", "@Inject", "@Value"}
260
+ if common_types is None:
261
+ common_types = _COMMON_TYPES
262
+ for f in entry.get("fields", []):
263
+ raw_annotations = f.get("annotations", [])
264
+ field_type = f.get("type", "")
265
+ if not field_type or not field_type[0].isupper():
266
+ continue
267
+ # Normalize annotations: handle both str list and dict list formats
268
+ annotations: list[str] = []
269
+ for a in raw_annotations:
270
+ if isinstance(a, str):
271
+ annotations.append(a)
272
+ elif isinstance(a, dict):
273
+ annotations.append(a.get("name", a.get("annotation", "")))
274
+ is_injected = any(
275
+ any(a.startswith(inj) for inj in inject_annotations) for a in annotations
276
+ )
277
+ if is_injected:
278
+ clean_type = re.sub(r'<.*>', '', field_type).strip()
279
+ target = class_to_file.get(clean_type)
280
+ if target and target != source:
281
+ graph.add_edge(source, target, WEIGHT_INJECTION, "injection")
282
+ elif field_type[0].isupper() and field_type not in common_types:
283
+ clean_type = re.sub(r'<.*>', '', field_type).strip()
284
+ if clean_type not in common_types:
285
+ target = class_to_file.get(clean_type)
286
+ if target and target != source:
287
+ graph.add_edge(source, target, WEIGHT_CALL, "call")
288
+
289
+
290
+ def _add_call_edges(entry: dict, source: str, class_to_file: dict,
291
+ field_type_index: dict, graph: DependencyGraph):
292
+ calls = entry.get("calls", [])
293
+ if not calls:
294
+ return
295
+ file_fields = field_type_index.get(source, {})
296
+ for receiver in calls:
297
+ resolved_type = file_fields.get(receiver)
298
+ if resolved_type:
299
+ clean_type = re.sub(r'<.*>', '', resolved_type).strip()
300
+ target = class_to_file.get(clean_type)
301
+ if target and target != source:
302
+ graph.add_edge(source, target, WEIGHT_CALL, "call")
303
+ elif receiver[0:1].isupper():
304
+ target = class_to_file.get(receiver)
305
+ if target and target != source:
306
+ graph.add_edge(source, target, WEIGHT_CALL, "call")