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,278 @@
1
+ """Change impact analysis — maps source file changes to document sections.
2
+
3
+ Determines which document sections need updating based on changed files
4
+ and their classifications.
5
+
6
+ Usage:
7
+ from core.skeleton.impact import analyze_impact, ImpactPlan, SectionImpact
8
+
9
+ plan = analyze_impact(
10
+ changed_files=["src/main/.../UserService.java"],
11
+ module_dir=knowledge_dir / "point-base",
12
+ preset=preset,
13
+ source_cache=cache_dir / "point-base",
14
+ )
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any, Literal
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Data classes
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ @dataclass
34
+ class SectionImpact:
35
+ """A single section that needs updating."""
36
+
37
+ doc_path: str # relative filename: "business-logic.md"
38
+ section_heading: str # "## Points Issuance Flow"
39
+ change_type: Literal["update", "create", "delete"]
40
+ affected_files: list[str] # source files that triggered this
41
+ confidence: float = 1.0 # mapping confidence (1.0 = exact match)
42
+
43
+
44
+ @dataclass
45
+ class ImpactPlan:
46
+ """Complete impact analysis result."""
47
+
48
+ sections: list[SectionImpact] = field(default_factory=list)
49
+ unmapped_files: list[str] = field(default_factory=list)
50
+
51
+ @property
52
+ def total_updates(self) -> int:
53
+ return sum(1 for s in self.sections if s.change_type == "update")
54
+
55
+ @property
56
+ def total_creates(self) -> int:
57
+ return sum(1 for s in self.sections if s.change_type == "create")
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Public API
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ def analyze_impact(
66
+ changed_files: list[str],
67
+ module_dir: Path,
68
+ preset: dict[str, Any],
69
+ source_cache: Path,
70
+ *,
71
+ change_details: list[dict] | None = None,
72
+ ) -> ImpactPlan:
73
+ """Analyze which document sections are affected by source changes.
74
+
75
+ Mapping strategy:
76
+ 1. Classify each changed file using preset file_classification rules
77
+ 2. Determine target document(s) via 'affects' field
78
+ 3. Extract class/method names from file path
79
+ 4. Scan target document headings for matches
80
+ 5. If no heading match, flag as "new content"
81
+
82
+ Args:
83
+ changed_files: List of changed file paths (relative to repo root)
84
+ module_dir: Module knowledge directory (contains .md docs)
85
+ preset: Preset configuration dict
86
+ source_cache: Source code cache directory
87
+ change_details: Optional git diff details [{file, status, additions, deletions}]
88
+
89
+ Returns:
90
+ ImpactPlan with affected sections and unmapped files
91
+ """
92
+ plan = ImpactPlan()
93
+ classification_rules = preset.get("file_classification", {})
94
+
95
+ # Build deletion set for special handling
96
+ deleted_files: set[str] = set()
97
+ if change_details:
98
+ for detail in change_details:
99
+ if detail.get("status") == "D":
100
+ deleted_files.add(detail["file"])
101
+
102
+ # Group impacts by (doc_path, section_heading) to deduplicate
103
+ seen: dict[tuple[str, str], SectionImpact] = {}
104
+
105
+ for filepath in changed_files:
106
+ # Classify file → determine target docs
107
+ target_docs = _classify_to_docs(filepath, classification_rules, preset)
108
+
109
+ if not target_docs:
110
+ plan.unmapped_files.append(filepath)
111
+ continue
112
+
113
+ # Extract search terms from file path/name
114
+ search_terms = _extract_search_terms(filepath)
115
+
116
+ mapped = False
117
+ for doc_filename in target_docs:
118
+ doc_path = module_dir / doc_filename
119
+ if not doc_path.exists():
120
+ # Doc doesn't exist yet — can't map to sections
121
+ plan.unmapped_files.append(filepath)
122
+ mapped = True
123
+ break
124
+
125
+ # Scan document headings for matches
126
+ matches = _scan_doc_headings_for_match(doc_path, search_terms)
127
+
128
+ if matches:
129
+ for heading, confidence in matches:
130
+ change_type: Literal["update", "create", "delete"] = "update"
131
+ if filepath in deleted_files:
132
+ change_type = "delete"
133
+
134
+ key = (doc_filename, heading)
135
+ if key in seen:
136
+ seen[key].affected_files.append(filepath)
137
+ seen[key].confidence = max(seen[key].confidence, confidence)
138
+ else:
139
+ impact = SectionImpact(
140
+ doc_path=doc_filename,
141
+ section_heading=heading,
142
+ change_type=change_type,
143
+ affected_files=[filepath],
144
+ confidence=confidence,
145
+ )
146
+ seen[key] = impact
147
+ mapped = True
148
+ else:
149
+ # File classified but no section match → new content needed
150
+ key = (doc_filename, f"## [NEW] {search_terms[0] if search_terms else filepath}")
151
+ if key not in seen:
152
+ impact = SectionImpact(
153
+ doc_path=doc_filename,
154
+ section_heading=key[1],
155
+ change_type="create",
156
+ affected_files=[filepath],
157
+ confidence=0.5,
158
+ )
159
+ seen[key] = impact
160
+ else:
161
+ seen[key].affected_files.append(filepath)
162
+ mapped = True
163
+
164
+ if not mapped:
165
+ plan.unmapped_files.append(filepath)
166
+
167
+ plan.sections = list(seen.values())
168
+ # Sort by confidence descending, then by doc_path
169
+ plan.sections.sort(key=lambda s: (-s.confidence, s.doc_path, s.section_heading))
170
+
171
+ logger.info(
172
+ "[impact] %d changed files → %d section impacts, %d unmapped",
173
+ len(changed_files), len(plan.sections), len(plan.unmapped_files),
174
+ )
175
+ return plan
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Internal helpers
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def _classify_to_docs(
184
+ filepath: str,
185
+ classification_rules: dict[str, Any],
186
+ preset: dict[str, Any],
187
+ ) -> list[str]:
188
+ """Classify a file and return target document filenames.
189
+
190
+ Uses the 'affects' field from file_classification rules.
191
+ Falls back to pattern matching if no skeleton entry available.
192
+ """
193
+ from core.preset_classify import classify_file
194
+
195
+ # Classify using full rules (without skeleton entry, pattern-only)
196
+ categories = classify_file(preset, filepath, skeleton_entry=None)
197
+
198
+ # Map categories → affects → doc filenames
199
+ doc_filenames: list[str] = []
200
+ for cat in categories:
201
+ cfg = classification_rules.get(cat, {})
202
+ affects = cfg.get("affects", [])
203
+ for doc_file in affects:
204
+ if doc_file not in doc_filenames:
205
+ doc_filenames.append(doc_file)
206
+
207
+ return doc_filenames
208
+
209
+
210
+ def _extract_search_terms(filepath: str) -> list[str]:
211
+ """Extract search terms from a file path for heading matching.
212
+
213
+ Extracts:
214
+ - Class name (from filename without extension)
215
+ - Package-derived terms
216
+ - Endpoint URLs (from common patterns)
217
+ """
218
+ terms: list[str] = []
219
+
220
+ # Extract filename without extension
221
+ basename = filepath.rsplit("/", 1)[-1] if "/" in filepath else filepath
222
+ basename = basename.rsplit("\\", 1)[-1] if "\\" in basename else basename
223
+ name_no_ext = basename.rsplit(".", 1)[0] if "." in basename else basename
224
+
225
+ if name_no_ext:
226
+ terms.append(name_no_ext)
227
+
228
+ # Split CamelCase into words for fuzzy matching
229
+ words = re.sub(r"([a-z])([A-Z])", r"\1 \2", name_no_ext).split()
230
+ # Remove common suffixes for better matching
231
+ clean_words = [w for w in words if w.lower() not in (
232
+ "service", "impl", "controller", "mapper", "dao",
233
+ "repository", "config", "configuration", "handler",
234
+ )]
235
+ if clean_words and clean_words != [name_no_ext]:
236
+ terms.append("".join(clean_words))
237
+
238
+ return terms
239
+
240
+
241
+ def _scan_doc_headings_for_match(
242
+ doc_path: Path,
243
+ search_terms: list[str],
244
+ ) -> list[tuple[str, float]]:
245
+ """Scan document headings for terms matching changed entities.
246
+
247
+ Returns: [(heading_line, confidence_score)]
248
+ """
249
+ try:
250
+ content = doc_path.read_text(encoding="utf-8", errors="replace")
251
+ except OSError:
252
+ return []
253
+
254
+ headings: list[str] = []
255
+ for line in content.splitlines():
256
+ if line.startswith("## ") or line.startswith("### "):
257
+ headings.append(line)
258
+
259
+ matches: list[tuple[str, float]] = []
260
+
261
+ for term in search_terms:
262
+ term_lower = term.lower()
263
+ for heading in headings:
264
+ heading_lower = heading.lower()
265
+ # Exact class name match in heading
266
+ if term_lower in heading_lower:
267
+ # Higher confidence for exact match
268
+ confidence = 0.95 if term == search_terms[0] else 0.8
269
+ if (heading, confidence) not in matches:
270
+ matches.append((heading, confidence))
271
+
272
+ # Deduplicate, keep highest confidence per heading
273
+ best: dict[str, float] = {}
274
+ for heading, conf in matches:
275
+ if heading not in best or conf > best[heading]:
276
+ best[heading] = conf
277
+
278
+ return [(h, c) for h, c in best.items()]
@@ -0,0 +1,177 @@
1
+ """JAR download — HTTP download of Maven source JARs with caching.
2
+
3
+ Downloads sources.jar files from Maven repositories (Nexus/Artifactory)
4
+ with retry logic, authentication support, and local caching.
5
+
6
+ On failure: logs warning and returns None (never aborts the pipeline).
7
+
8
+ Usage:
9
+ from core.skeleton.jar_download import download_source_jar, is_cached
10
+
11
+ if not is_cached(group_id, artifact_id, version, cache_dir):
12
+ path = download_source_jar(group_id, artifact_id, version, repo_url, cache_dir)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import logging
19
+ from pathlib import Path
20
+ from urllib.error import HTTPError, URLError
21
+ from urllib.request import Request, urlopen
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Constants
26
+ DOWNLOAD_TIMEOUT = 60 # seconds
27
+ MAX_RETRIES = 2
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Public API
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def download_source_jar(
36
+ group_id: str,
37
+ artifact_id: str,
38
+ version: str,
39
+ repo_url: str,
40
+ cache_dir: Path,
41
+ auth: str | None = None,
42
+ ) -> Path | None:
43
+ """Download a sources.jar from Maven repository.
44
+
45
+ Tries sources.jar first, then falls back to regular .jar.
46
+ Results are cached locally to avoid repeated downloads.
47
+
48
+ Args:
49
+ group_id: Maven groupId (e.g. "com.example")
50
+ artifact_id: Maven artifactId (e.g. "my-lib")
51
+ version: Maven version (e.g. "1.2.3")
52
+ repo_url: Repository base URL
53
+ cache_dir: Local cache directory
54
+ auth: Optional "user:password" for Basic auth
55
+
56
+ Returns:
57
+ Path to downloaded JAR file, or None on failure.
58
+ """
59
+ # Check cache first
60
+ cached = _get_cache_path(group_id, artifact_id, version, cache_dir, "sources")
61
+ if cached.exists() and cached.stat().st_size > 0:
62
+ logger.debug("Cache hit: %s", cached)
63
+ return cached
64
+
65
+ # Try sources.jar
66
+ sources_url = _build_maven_url(repo_url, group_id, artifact_id, version, "sources")
67
+ if _download_file(sources_url, cached, auth):
68
+ logger.info("Downloaded sources.jar: %s:%s:%s", group_id, artifact_id, version)
69
+ return cached
70
+
71
+ # Try regular .jar as fallback
72
+ regular_cached = _get_cache_path(group_id, artifact_id, version, cache_dir, "")
73
+ regular_url = _build_maven_url(repo_url, group_id, artifact_id, version, "")
74
+ if _download_file(regular_url, regular_cached, auth):
75
+ logger.info("Downloaded regular jar: %s:%s:%s", group_id, artifact_id, version)
76
+ return regular_cached
77
+
78
+ logger.warning("Failed to download: %s:%s:%s", group_id, artifact_id, version)
79
+ return None
80
+
81
+
82
+ def is_cached(
83
+ group_id: str,
84
+ artifact_id: str,
85
+ version: str,
86
+ cache_dir: Path,
87
+ ) -> bool:
88
+ """Check if a JAR is already cached locally.
89
+
90
+ Args:
91
+ group_id: Maven groupId
92
+ artifact_id: Maven artifactId
93
+ version: Maven version
94
+ cache_dir: Local cache directory
95
+
96
+ Returns:
97
+ True if sources.jar or regular .jar exists in cache.
98
+ """
99
+ sources = _get_cache_path(group_id, artifact_id, version, cache_dir, "sources")
100
+ if sources.exists() and sources.stat().st_size > 0:
101
+ return True
102
+ regular = _get_cache_path(group_id, artifact_id, version, cache_dir, "")
103
+ return regular.exists() and regular.stat().st_size > 0
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Internal
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ def _get_cache_path(
112
+ group_id: str, artifact_id: str, version: str,
113
+ cache_dir: Path, classifier: str,
114
+ ) -> Path:
115
+ """Compute local cache path for a JAR."""
116
+ group_path = group_id.replace(".", "/")
117
+ filename = f"{artifact_id}-{version}"
118
+ if classifier:
119
+ filename += f"-{classifier}"
120
+ filename += ".jar"
121
+ return cache_dir / group_path / artifact_id / version / filename
122
+
123
+
124
+ def _build_maven_url(
125
+ repo_url: str, group_id: str, artifact_id: str,
126
+ version: str, classifier: str,
127
+ ) -> str:
128
+ """Build Maven repository download URL."""
129
+ repo_url = repo_url.rstrip("/")
130
+ group_path = group_id.replace(".", "/")
131
+ filename = f"{artifact_id}-{version}"
132
+ if classifier:
133
+ filename += f"-{classifier}"
134
+ filename += ".jar"
135
+ return f"{repo_url}/{group_path}/{artifact_id}/{version}/{filename}"
136
+
137
+
138
+ def _download_file(url: str, dest: Path, auth: str | None = None) -> bool:
139
+ """HTTP download with retry. Returns True on success."""
140
+ for attempt in range(MAX_RETRIES + 1):
141
+ try:
142
+ req = Request(url)
143
+ if auth:
144
+ credentials = base64.b64encode(auth.encode()).decode()
145
+ req.add_header("Authorization", f"Basic {credentials}")
146
+
147
+ with urlopen(req, timeout=DOWNLOAD_TIMEOUT) as resp:
148
+ dest.parent.mkdir(parents=True, exist_ok=True)
149
+ with open(dest, "wb") as f:
150
+ while True:
151
+ chunk = resp.read(8192)
152
+ if not chunk:
153
+ break
154
+ f.write(chunk)
155
+ return True
156
+
157
+ except HTTPError as e:
158
+ if e.code == 404:
159
+ return False
160
+ if attempt < MAX_RETRIES:
161
+ logger.debug("HTTP %d on attempt %d: %s", e.code, attempt + 1, url)
162
+ continue
163
+ logger.warning("HTTP error %d: %s", e.code, url)
164
+ return False
165
+
166
+ except URLError as e:
167
+ if attempt < MAX_RETRIES:
168
+ logger.debug("Network error on attempt %d: %s", attempt + 1, e.reason)
169
+ continue
170
+ logger.warning("Network error: %s — %s", e.reason, url)
171
+ return False
172
+
173
+ except OSError as e:
174
+ logger.warning("IO error downloading %s: %s", url, e)
175
+ return False
176
+
177
+ return False
@@ -0,0 +1,186 @@
1
+ """JAR resolver — orchestrate POM parsing, JAR download, and extraction.
2
+
3
+ Coordinates the full workflow: parse POM → identify internal dependencies →
4
+ download source JARs → extract .java files → return paths.
5
+
6
+ Uses pom_parser.py for coordinate extraction and jar_download.py for downloading.
7
+
8
+ Usage:
9
+ from core.skeleton.jar_resolver import resolve_jar_sources, extract_jar
10
+
11
+ paths = resolve_jar_sources(pom_path, cache_dir, repo_url, auth)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import zipfile
18
+ from pathlib import Path
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Public API
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ def resolve_jar_sources(
29
+ pom_path: Path,
30
+ cache_dir: Path,
31
+ repo_url: str,
32
+ auth: str | None = None,
33
+ internal_group_ids: list[str] | None = None,
34
+ known_modules: set[str] | None = None,
35
+ exclude_artifacts: set[str] | None = None,
36
+ ) -> list[Path]:
37
+ """Orchestrate: parse POM → download JARs → extract → return paths.
38
+
39
+ Args:
40
+ pom_path: Path to pom.xml
41
+ cache_dir: Directory for caching downloaded JARs and extracted sources
42
+ repo_url: Maven repository URL
43
+ auth: Optional "user:password" for Basic auth
44
+ internal_group_ids: Group ID prefixes to consider internal (auto-inferred if None)
45
+ known_modules: Module names that already have source (skip these)
46
+ exclude_artifacts: Artifact IDs to exclude
47
+
48
+ Returns:
49
+ List of directories containing extracted .java source files.
50
+ """
51
+ from core.skeleton.pom_parser import parse_pom, extract_dependencies
52
+ from core.skeleton.jar_download import download_source_jar, is_cached
53
+
54
+ if not pom_path.exists():
55
+ logger.warning("POM file not found: %s", pom_path)
56
+ return []
57
+
58
+ known_modules = known_modules or set()
59
+ exclude_artifacts = exclude_artifacts or set()
60
+
61
+ # Parse POM and get dependencies
62
+ pom = parse_pom(pom_path)
63
+ all_deps = extract_dependencies(pom_path)
64
+
65
+ # Auto-infer internal group IDs from POM's own groupId
66
+ if not internal_group_ids:
67
+ own_gid = pom.get("group_id", "")
68
+ if own_gid:
69
+ internal_group_ids = [own_gid]
70
+ else:
71
+ logger.warning("Cannot infer internal groupId from POM")
72
+ return []
73
+
74
+ # Filter to internal dependencies
75
+ internal_deps = _filter_internal_deps(
76
+ all_deps, internal_group_ids, known_modules, exclude_artifacts
77
+ )
78
+
79
+ if not internal_deps:
80
+ logger.info("No internal dependencies to resolve")
81
+ return []
82
+
83
+ # Download and extract each dependency
84
+ result_paths: list[Path] = []
85
+ for dep in internal_deps:
86
+ gid = dep["groupId"]
87
+ aid = dep["artifactId"]
88
+ version = dep.get("version", "")
89
+
90
+ if not version:
91
+ logger.warning("No version for %s:%s, skipping", gid, aid)
92
+ continue
93
+
94
+ output_dir = cache_dir / f"{aid}-sources"
95
+
96
+ # Skip if already extracted
97
+ if output_dir.exists() and any(output_dir.rglob("*.java")):
98
+ logger.debug("Already extracted: %s", aid)
99
+ result_paths.append(output_dir)
100
+ continue
101
+
102
+ # Download JAR
103
+ jar_path = download_source_jar(gid, aid, version, repo_url, cache_dir, auth)
104
+ if not jar_path:
105
+ logger.warning("Failed to download %s:%s:%s", gid, aid, version)
106
+ continue
107
+
108
+ # Extract
109
+ count = extract_jar(jar_path, output_dir)
110
+ if count > 0:
111
+ logger.info("Extracted %d .java files from %s", count, aid)
112
+ result_paths.append(output_dir)
113
+ else:
114
+ logger.warning("No .java files in %s:%s:%s", gid, aid, version)
115
+
116
+ return result_paths
117
+
118
+
119
+ def extract_jar(jar_path: Path, dest_dir: Path) -> int:
120
+ """Extract .java files from a JAR (sources.jar or regular .jar).
121
+
122
+ Args:
123
+ jar_path: Path to the JAR file
124
+ dest_dir: Destination directory for extracted files
125
+
126
+ Returns:
127
+ Number of .java files extracted.
128
+ """
129
+ if not jar_path.exists():
130
+ return 0
131
+
132
+ try:
133
+ count = 0
134
+ dest_dir.mkdir(parents=True, exist_ok=True)
135
+
136
+ with zipfile.ZipFile(jar_path, "r") as zf:
137
+ for info in zf.infolist():
138
+ if info.filename.endswith(".java") and not info.is_dir():
139
+ zf.extract(info, dest_dir)
140
+ count += 1
141
+
142
+ return count
143
+
144
+ except (zipfile.BadZipFile, OSError) as e:
145
+ logger.warning("Failed to extract %s: %s", jar_path, e)
146
+ return 0
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Internal
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def _filter_internal_deps(
155
+ all_deps: list[dict],
156
+ internal_group_ids: list[str],
157
+ known_modules: set[str],
158
+ exclude_artifacts: set[str],
159
+ ) -> list[dict]:
160
+ """Filter dependencies to only internal ones that need resolution."""
161
+ result: list[dict] = []
162
+
163
+ for dep in all_deps:
164
+ gid = dep.get("groupId", "")
165
+ aid = dep.get("artifactId", "")
166
+ scope = dep.get("scope", "compile")
167
+
168
+ # Skip test/provided scope
169
+ if scope in ("test", "provided"):
170
+ continue
171
+
172
+ # Check groupId matches internal prefix
173
+ if not any(gid.startswith(prefix) for prefix in internal_group_ids):
174
+ continue
175
+
176
+ # Skip already-known modules
177
+ if aid in known_modules:
178
+ continue
179
+
180
+ # Skip excluded artifacts
181
+ if aid in exclude_artifacts:
182
+ continue
183
+
184
+ result.append(dep)
185
+
186
+ return result