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,319 @@
1
+ """Dispatch plan rendering — Markdown preview table and statistics formatting.
2
+
3
+ Renders a DispatchPlan into human-readable Markdown tables for Gate 3A preview.
4
+ No CLI I/O, no LLM calls.
5
+
6
+ Usage:
7
+ from core.skeleton.dispatch import compute_dispatch_plan
8
+ from core.skeleton.dispatch_render import render_markdown, render_note
9
+
10
+ plan = compute_dispatch_plan(...)
11
+ markdown = render_markdown(plan)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from core.skeleton.dispatch import DispatchEntry, DispatchPlan
19
+
20
+
21
+ def write_shard_files(plan: DispatchPlan, module_dir: Path) -> int:
22
+ """Write shard file lists to .meta/shards/ for split entries.
23
+
24
+ Returns the number of shard files written.
25
+ """
26
+ count = 0
27
+ shards_dir = module_dir / ".meta" / "shards"
28
+ for entry in plan.entries:
29
+ if entry.split_count > 1 and entry.shards:
30
+ shards_dir.mkdir(parents=True, exist_ok=True)
31
+ for idx, shard in enumerate(entry.shards, 1):
32
+ shard_path = shards_dir / f"{entry.doc_type}-shard-{idx:02d}.txt"
33
+ file_paths = shard.files if shard.files else []
34
+ if file_paths:
35
+ shard_path.write_text("\n".join(file_paths) + "\n", encoding="utf-8")
36
+ count += 1
37
+ return count
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Note computation
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def compute_note(
46
+ entry: DispatchEntry,
47
+ mode: str = "readwrite",
48
+ skeleton_size_kb: int = 0,
49
+ ) -> str:
50
+ """Generate meaningful note content for a dispatch entry.
51
+
52
+ Priority: risk warning > execution constraint > doc-type hint.
53
+ """
54
+ notes: list[str] = []
55
+ # Use mode-specific defaults (these match doc_types.yaml split config)
56
+ _DISPLAY_MAX_BYTES = {"readwrite": 2097152, "output-only": 307200}
57
+ max_bytes = _DISPLAY_MAX_BYTES.get(mode, 512000)
58
+
59
+ # 1. Risk: approaching byte limit
60
+ if entry.total_bytes > 0 and entry.doc_type not in ("source-tree-analysis", "index"):
61
+ usage_pct = entry.total_bytes / max_bytes
62
+ if usage_pct > 0.9:
63
+ notes.append(f"⚠️ Approaching limit ({entry.total_bytes // 1024}KB/{max_bytes // 1024}KB)")
64
+ elif usage_pct > 0.75:
65
+ notes.append(f"Large input ({int(usage_pct * 100)}%)")
66
+
67
+ # 2. Doc-type specific constraints
68
+ if entry.doc_type == "architecture":
69
+ notes.append("Must not produce redis/kafka/business-logic content")
70
+ elif entry.doc_type == "index":
71
+ notes.append("Depends on all prior tasks completing")
72
+ elif entry.doc_type == "source-tree-analysis" and skeleton_size_kb > 50:
73
+ notes.append("Use skeleton-summary (skeleton too large)")
74
+ elif entry.doc_type == "business-logic":
75
+ notes.append("Organize by business flow, not by class name")
76
+
77
+ # 3. Large file count warning
78
+ if entry.file_count > 80 and entry.doc_type not in ("source-tree-analysis", "index"):
79
+ notes.append(f"{entry.file_count} files, requires batched reading")
80
+
81
+ return " | ".join(notes) if notes else ""
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Markdown rendering
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def render_markdown(plan: DispatchPlan, mode: str = "readwrite") -> str:
90
+ """Render a DispatchPlan as a Markdown document.
91
+
92
+ Args:
93
+ plan: Computed dispatch plan
94
+ mode: Execution mode for note computation
95
+
96
+ Returns:
97
+ Formatted Markdown string with header, table, shard details, and summary
98
+ """
99
+ lines: list[str] = []
100
+
101
+ # Title
102
+ lines.append(f"# Dispatch Plan: {plan.module_name}")
103
+ lines.append("")
104
+ lines.append(f"Module: {plan.module_name} ({plan.source_file_count} source files, "
105
+ f"{plan.source_total_lines} lines)")
106
+ lines.append(f"Skeleton size: {plan.skeleton_size_kb}KB")
107
+ lines.append(f"Mode: {'Agent (readwrite)' if mode == 'readwrite' else 'CLI (output-only)'}")
108
+ lines.append("")
109
+
110
+ # Dispatch table
111
+ lines.append("## Dispatch Plan")
112
+ lines.append("")
113
+ lines.append("| Batch | Sub-agent | Output Doc | Input Files | Input Lines | Input Bytes | Split Strategy | Notes |")
114
+ lines.append("|------|--------|---------|-----------|-----------|-----------|---------|------|")
115
+
116
+ for entry in plan.entries:
117
+ note = compute_note(entry, mode=mode, skeleton_size_kb=plan.skeleton_size_kb)
118
+ row = _format_row(entry, note)
119
+ lines.append(row)
120
+
121
+ # Shard details (for entries with split_count > 1)
122
+ sharded = [e for e in plan.entries if e.split_count > 1]
123
+ if sharded:
124
+ lines.append("")
125
+ lines.append("## Shard Details")
126
+ lines.append("")
127
+ for entry in sharded:
128
+ lines.append(f"### {entry.doc_type} (split into {entry.split_count})")
129
+ lines.append("")
130
+ if entry.pending_grouping:
131
+ lines.append(f"> **Pending Agent grouping**: Read the file list in `{entry.pending_grouping}`,")
132
+ lines.append(f"> split into {entry.split_count} groups by business domain, then run `split-apply` to backfill.")
133
+ lines.append("")
134
+ lines.append("| Shard | Domain | Files |")
135
+ lines.append("|------|--------|--------|")
136
+ if entry.shards:
137
+ for i, shard in enumerate(entry.shards, 1):
138
+ name = shard.name or f"group-{i}"
139
+ lines.append(f"| shard-{i:02d} | {name} | {shard.file_count} |")
140
+ else:
141
+ per_shard = entry.file_count // entry.split_count if entry.file_count else 0
142
+ for i in range(1, entry.split_count + 1):
143
+ lines.append(f"| shard-{i:02d} | (pending Agent grouping) | ~{per_shard} |")
144
+ lines.append("")
145
+
146
+ # Summary
147
+ lines.append("## Summary")
148
+ lines.append("")
149
+ lines.append(f"- Sub-agent tasks: {plan.total_shards()}")
150
+ lines.append(f"- Total input files: {plan.source_file_count}")
151
+ lines.append(f"- Total input lines: {plan.source_total_lines}")
152
+
153
+ return "\n".join(lines)
154
+
155
+
156
+ def render_summary(plan: DispatchPlan) -> str:
157
+ """Render a brief summary of the dispatch plan.
158
+
159
+ Returns:
160
+ Short summary string (e.g., "12 tasks across 4 batches, 3 need splitting")
161
+ """
162
+ total = len(plan.entries)
163
+ batches = len(set(e.batch_id for e in plan.entries))
164
+ splits = sum(1 for e in plan.entries if e.split_count > 1)
165
+
166
+ parts = [f"{total} tasks", f"{batches} batches"]
167
+ if splits:
168
+ parts.append(f"{splits} need splitting")
169
+ return ", ".join(parts)
170
+
171
+
172
+ def render_batch_groups(plan: DispatchPlan) -> list[tuple[str, list[DispatchEntry]]]:
173
+ """Group entries by batch for sequential execution.
174
+
175
+ Returns:
176
+ List of (batch_id, entries) tuples in batch order
177
+ """
178
+ groups: dict[str, list[DispatchEntry]] = {}
179
+ for entry in plan.entries:
180
+ groups.setdefault(entry.batch_id, []).append(entry)
181
+
182
+ # Maintain batch_order ordering
183
+ result: list[tuple[str, list[DispatchEntry]]] = []
184
+ seen: set[str] = set()
185
+ for batch_id, _ in plan.batch_order:
186
+ if batch_id in groups and batch_id not in seen:
187
+ result.append((batch_id, groups[batch_id]))
188
+ seen.add(batch_id)
189
+
190
+ # Append any remaining
191
+ for batch_id, entries in groups.items():
192
+ if batch_id not in seen:
193
+ result.append((batch_id, entries))
194
+
195
+ return result
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Task manifest generation (shared by CLI and Agent modes)
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ def plan_to_tasks(
204
+ plan: DispatchPlan,
205
+ kb_name: str,
206
+ preset_name: str,
207
+ preset: dict,
208
+ knowledge_dir,
209
+ mode: str = "readwrite",
210
+ ) -> dict:
211
+ """Convert DispatchPlan to task manifest JSON.
212
+
213
+ Shared by CLI DispatchPlanStep and Agent dispatch-preview command.
214
+ """
215
+ from pathlib import Path
216
+ from core.preset import get_template_path
217
+
218
+ module_name = plan.module_name
219
+ module_dir = Path(knowledge_dir) / module_name
220
+
221
+ tasks: list[dict] = []
222
+ for entry in plan.entries:
223
+ dt = entry.doc_type
224
+ template_name = get_template_path(preset, dt, preset_name) or ""
225
+
226
+ if entry.split_count <= 1:
227
+ output_file = str((module_dir / entry.doc_filename).resolve())
228
+ render_cmd = (
229
+ f"py -3.12 -X utf8 -m core.prompt render"
230
+ f" --template \"{template_name}\""
231
+ f" --module {module_name}"
232
+ f" --config \"kb-project.yaml\""
233
+ f" --kb {kb_name}"
234
+ f" --doc-type {dt}"
235
+ f" --mode {mode}"
236
+ )
237
+ tasks.append({
238
+ "id": dt,
239
+ "batch": entry.batch_id,
240
+ "doc_type": dt,
241
+ "doc_filename": entry.doc_filename,
242
+ "shard": None,
243
+ "total_shards": 1,
244
+ "file_count": entry.file_count,
245
+ "line_count": entry.total_lines,
246
+ "output_file": output_file,
247
+ "render_cmd": render_cmd,
248
+ "template": template_name,
249
+ })
250
+ else:
251
+ for shard_idx in range(1, entry.split_count + 1):
252
+ shard_filename = f"{entry.doc_filename.replace('.md', '')}-shard-{shard_idx:02d}.md"
253
+ output_file = str((module_dir / shard_filename).resolve())
254
+ fl_override = str(
255
+ (module_dir / ".meta" / "shards" / f"{dt}-shard-{shard_idx:02d}.txt").resolve()
256
+ )
257
+ render_cmd = (
258
+ f"py -3.12 -X utf8 -m core.prompt render"
259
+ f" --template \"{template_name}\""
260
+ f" --module {module_name}"
261
+ f" --config \"kb-project.yaml\""
262
+ f" --kb {kb_name}"
263
+ f" --doc-type {dt}"
264
+ f" --mode {mode}"
265
+ f" --extra file_list_override={fl_override}"
266
+ )
267
+ # Use actual shard data when available
268
+ if entry.shards and shard_idx <= len(entry.shards):
269
+ shard_info = entry.shards[shard_idx - 1]
270
+ shard_file_count = shard_info.file_count
271
+ shard_line_count = shard_info.lines
272
+ else:
273
+ shard_file_count = entry.file_count // entry.split_count
274
+ shard_line_count = entry.total_lines // entry.split_count
275
+ tasks.append({
276
+ "id": f"{dt}-shard-{shard_idx:02d}",
277
+ "batch": entry.batch_id,
278
+ "doc_type": dt,
279
+ "doc_filename": entry.doc_filename,
280
+ "shard": shard_idx,
281
+ "total_shards": entry.split_count,
282
+ "file_count": shard_file_count,
283
+ "line_count": shard_line_count,
284
+ "output_file": output_file,
285
+ "render_cmd": render_cmd,
286
+ "template": template_name,
287
+ "shard_file_list": fl_override,
288
+ })
289
+
290
+ return {
291
+ "module": module_name,
292
+ "kb": kb_name,
293
+ "mode": mode,
294
+ "preset": preset_name,
295
+ "tasks": tasks,
296
+ }
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # Internal helpers
301
+ # ---------------------------------------------------------------------------
302
+
303
+
304
+ def _format_row(entry: DispatchEntry, note: str) -> str:
305
+ """Format a single table row."""
306
+ fc = str(entry.file_count) if entry.file_count else "—"
307
+ lc = str(entry.total_lines) if entry.total_lines else "—"
308
+ bc = f"{entry.total_bytes // 1024}KB" if entry.total_bytes > 0 else "—"
309
+
310
+ return (
311
+ f"| {entry.batch_id:<4} "
312
+ f"| {entry.doc_type:<14} "
313
+ f"| {entry.doc_filename:<28} "
314
+ f"| {fc:<9} "
315
+ f"| {lc:<9} "
316
+ f"| {bc:<9} "
317
+ f"| {entry.strategy:<7} "
318
+ f"| {note} |"
319
+ )
@@ -0,0 +1,111 @@
1
+ """Dispatch source helpers — file list resolution and source statistics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def get_file_list_with_stats(
11
+ module_dir: Path, doc_type: str, source_cache: Path, dt_mapping: dict[str, str],
12
+ ) -> list[dict[str, Any]]:
13
+ """Get file list with path, lines, and bytes info for a doc type."""
14
+ from core.paths import resolve_file_list
15
+ if doc_type not in dt_mapping:
16
+ return []
17
+ fl = resolve_file_list(module_dir, doc_type)
18
+ if fl is None:
19
+ doc_basename = dt_mapping[doc_type].replace(".md", "")
20
+ if doc_basename != doc_type:
21
+ fl = resolve_file_list(module_dir, doc_basename)
22
+ if fl is None:
23
+ return []
24
+ files: list[dict[str, Any]] = []
25
+ for line in fl.read_text(encoding="utf-8").splitlines():
26
+ line = line.strip()
27
+ if not line:
28
+ continue
29
+ rel_path = line.replace("\\", "/")
30
+ file_path = source_cache / rel_path
31
+ if not file_path.exists() and Path(line).is_absolute():
32
+ file_path = Path(line)
33
+ file_bytes, line_count = 0, 0
34
+ if file_path.exists():
35
+ try:
36
+ file_bytes = file_path.stat().st_size
37
+ except OSError:
38
+ pass
39
+ try:
40
+ with open(file_path, encoding="utf-8", errors="replace") as f:
41
+ line_count = sum(1 for _ in f)
42
+ except OSError:
43
+ pass
44
+ package = _extract_package_from_path(rel_path)
45
+ files.append({"path": str(file_path), "name": Path(rel_path).name,
46
+ "lines": line_count, "bytes": file_bytes, "rel_path": rel_path,
47
+ "package": package})
48
+ return files
49
+
50
+
51
+ def _extract_package_from_path(rel_path: str) -> str:
52
+ """Extract package name from a relative file path.
53
+
54
+ Skips standard source directory prefixes (src/main/java/, etc.)
55
+ and joins remaining directory parts with '.' as package name.
56
+ """
57
+ parts = rel_path.replace("\\", "/").split("/")
58
+ skip_dirs = {"src", "main", "java", "python", "go", "kotlin", "scala",
59
+ "groovy", "lib", "app", "pkg", "internal", "cmd", "resources", "test"}
60
+
61
+ start = 0
62
+ i = 0
63
+ while i < len(parts) - 1:
64
+ if parts[i].lower() in skip_dirs:
65
+ j = i
66
+ while j < len(parts) - 1 and parts[j].lower() in skip_dirs:
67
+ j += 1
68
+ start = j
69
+ i = j
70
+ else:
71
+ i += 1
72
+ segments = [p for p in parts[start:-1] if p]
73
+ return ".".join(segments)
74
+
75
+
76
+ def count_source_lines(source_cache: Path, hooks=None) -> tuple[int, int]:
77
+ """Return (source_file_count, total_lines) for source files."""
78
+ if hooks:
79
+ result = hooks.count_source_files(source_cache)
80
+ if result != (0, 0):
81
+ return result
82
+
83
+ total_files, total_lines = 0, 0
84
+ if not source_cache.is_dir():
85
+ return 0, 0
86
+ extensions = hooks.get_source_extensions() if hooks else [".java"]
87
+ test_patterns = hooks.get_test_path_patterns() if hooks else ["/test/"]
88
+ for root, _, filenames in os.walk(source_cache):
89
+ root_str = str(Path(root)).replace("\\", "/")
90
+ if any(pat in root_str for pat in test_patterns):
91
+ continue
92
+ for fn in filenames:
93
+ if any(fn.endswith(ext) for ext in extensions):
94
+ total_files += 1
95
+ try:
96
+ with open(Path(root) / fn, encoding="utf-8", errors="replace") as f:
97
+ total_lines += sum(1 for _ in f)
98
+ except OSError:
99
+ pass
100
+ return total_files, total_lines
101
+
102
+
103
+ def skeleton_total_size_kb(module_dir: Path) -> int:
104
+ """Get total skeleton size in KB."""
105
+ from core.paths import resolve_skeleton
106
+ skel = resolve_skeleton(module_dir)
107
+ if skel is None:
108
+ return 0
109
+ if skel.is_dir():
110
+ return sum(f.stat().st_size for f in skel.glob("*.json")) // 1024
111
+ return skel.stat().st_size // 1024
@@ -0,0 +1,218 @@
1
+ """Skeleton extraction orchestrator.
2
+
3
+ Tries parsers in priority order (from preset config) until one succeeds.
4
+ Handles output modes: single file, split-by-package, summary generation.
5
+
6
+ Usage:
7
+ from core.skeleton.extract import extract_skeleton
8
+
9
+ entries = extract_skeleton(repo_path, preset, ref="origin/main")
10
+ # entries: list of skeleton dicts [{file, total_lines, methods, classes, ...}]
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from core.paths import (
21
+ skeleton_path, skeleton_shards_dir, skeleton_summary_path, skeleton_stats_path, ensure_dir,
22
+ )
23
+ from core.skeleton.parsers import get_parser_chain
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def extract_skeleton(
29
+ repo_path: Path,
30
+ preset: dict[str, Any],
31
+ *,
32
+ ref: str = "HEAD",
33
+ subpath: str | None = None,
34
+ output_dir: Path | None = None,
35
+ split_by_package: bool = False,
36
+ compact: bool = True,
37
+ files: list[str] | None = None,
38
+ ) -> list[dict[str, Any]]:
39
+ """Extract skeleton from a repository using the parser chain.
40
+
41
+ Tries each parser in preset-declared order until one succeeds.
42
+
43
+ Args:
44
+ repo_path: Path to the git repository
45
+ preset: Preset configuration dict
46
+ ref: Git reference to parse
47
+ subpath: Subdirectory to scope extraction (monorepo module path)
48
+ output_dir: Module dir to write output (None = don't write)
49
+ split_by_package: Split output into per-package shard files
50
+ compact: Remove low-value entries (empty methods, etc.)
51
+ files: If provided, only extract these file paths (incremental mode)
52
+
53
+ Returns:
54
+ List of skeleton entries
55
+ """
56
+ chain = get_parser_chain(preset)
57
+ entries: list[dict[str, Any]] = []
58
+ used_parser = "none"
59
+
60
+ for parser in chain:
61
+ if not parser.can_parse(repo_path, preset):
62
+ logger.debug("Parser '%s' cannot parse %s, skipping", parser.name, repo_path)
63
+ continue
64
+
65
+ logger.info("Using parser '%s' for %s", parser.name, repo_path.name)
66
+ try:
67
+ entries = parser.parse(repo_path, preset, ref=ref, subpath=subpath)
68
+ used_parser = parser.name
69
+ break
70
+ except Exception as e:
71
+ logger.warning("Parser '%s' failed for %s: %s", parser.name, repo_path.name, e)
72
+ continue
73
+
74
+ if not entries:
75
+ logger.warning("No parser produced results for %s", repo_path.name)
76
+ return []
77
+
78
+ # Filter to specified files if in incremental mode
79
+ if files:
80
+ file_set = set(files)
81
+ before_count = len(entries)
82
+ entries = [e for e in entries if e.get("file", "") in file_set]
83
+ skipped = file_set - {e.get("file", "") for e in entries}
84
+ if skipped:
85
+ logger.warning("[extract] %d files not found in skeleton: %s",
86
+ len(skipped), list(skipped)[:5])
87
+ logger.info("[extract] Incremental: %d/%d entries (from %d requested files)",
88
+ len(entries), before_count, len(files))
89
+
90
+ if compact:
91
+ entries = _compact(entries)
92
+
93
+ # Write output if requested
94
+ if output_dir:
95
+ _write_output(entries, output_dir, split_by_package)
96
+ _write_summary(entries, output_dir)
97
+ _write_stats(entries, output_dir)
98
+ _write_parser_info(output_dir, used_parser, preset.get("preset_name", ""))
99
+
100
+ logger.info("Extracted %d entries (%d methods) using '%s'",
101
+ len(entries), sum(len(e.get("methods", [])) for e in entries), used_parser)
102
+ return entries
103
+
104
+
105
+ def _compact(entries: list[dict]) -> list[dict]:
106
+ """Remove entries with no methods and no classes (low value)."""
107
+ return [e for e in entries if e.get("methods") or e.get("classes")]
108
+
109
+
110
+ def _write_output(entries: list[dict], module_dir: Path, split_by_package: bool) -> None:
111
+ """Write skeleton entries to disk."""
112
+ if split_by_package and len(entries) > 50:
113
+ _write_shards(entries, module_dir)
114
+ else:
115
+ _write_single(entries, module_dir)
116
+
117
+
118
+ def _write_single(entries: list[dict], module_dir: Path) -> None:
119
+ """Write all entries to a single skeleton.json file."""
120
+ out = skeleton_path(module_dir)
121
+ ensure_dir(out.parent)
122
+ out.write_text(json.dumps(entries, ensure_ascii=False, indent=1), encoding="utf-8")
123
+ logger.debug("Wrote skeleton: %s (%d entries)", out, len(entries))
124
+
125
+
126
+ def _write_shards(entries: list[dict], module_dir: Path) -> None:
127
+ """Split entries by package prefix and write per-package shard files."""
128
+ shards_dir = skeleton_shards_dir(module_dir)
129
+ ensure_dir(shards_dir)
130
+
131
+ # Group by top-level package
132
+ groups: dict[str, list[dict]] = {}
133
+ for entry in entries:
134
+ filepath = entry.get("file", "")
135
+ pkg = _extract_package(filepath)
136
+ groups.setdefault(pkg, []).append(entry)
137
+
138
+ for pkg_name, pkg_entries in groups.items():
139
+ safe_name = pkg_name.replace(".", "_").replace("/", "_") or "root"
140
+ shard_file = shards_dir / f"{safe_name}.json"
141
+ shard_file.write_text(
142
+ json.dumps(pkg_entries, ensure_ascii=False, indent=1), encoding="utf-8"
143
+ )
144
+
145
+ logger.debug("Wrote %d skeleton shards to %s", len(groups), shards_dir)
146
+
147
+
148
+ def _write_summary(entries: list[dict], module_dir: Path) -> None:
149
+ """Write a compact summary (file + method count + high methods) for large skeletons."""
150
+ summary = []
151
+ for entry in entries:
152
+ methods = entry.get("methods", [])
153
+ high_methods = [m for m in methods if m.get("complexity") == "high"]
154
+ summary.append({
155
+ "file": entry.get("file", ""),
156
+ "total_lines": entry.get("total_lines", 0),
157
+ "method_count": len(methods),
158
+ "high_complexity_methods": [
159
+ {"name": m["name"], "line_count": m.get("line_count", 0)}
160
+ for m in high_methods
161
+ ],
162
+ })
163
+
164
+ out = skeleton_summary_path(module_dir)
165
+ ensure_dir(out.parent)
166
+ out.write_text(json.dumps(summary, ensure_ascii=False, indent=1), encoding="utf-8")
167
+
168
+
169
+ def _write_stats(entries: list[dict], module_dir: Path) -> None:
170
+ """Write global statistics for downstream consumers."""
171
+ total_methods = sum(len(e.get("methods", [])) for e in entries)
172
+ total_classes = sum(len(e.get("classes", [])) for e in entries)
173
+ total_fields = sum(len(e.get("fields", [])) for e in entries)
174
+ total_lines = sum(e.get("total_lines", 0) for e in entries)
175
+ high_complexity = sum(
176
+ 1 for e in entries
177
+ for m in e.get("methods", [])
178
+ if m.get("complexity") == "high"
179
+ )
180
+
181
+ stats = {
182
+ "files": len(entries),
183
+ "methods": total_methods,
184
+ "classes": total_classes,
185
+ "fields": total_fields,
186
+ "total_lines": total_lines,
187
+ "high_complexity_methods": high_complexity,
188
+ }
189
+
190
+ out = skeleton_stats_path(module_dir)
191
+ ensure_dir(out.parent)
192
+ out.write_text(json.dumps(stats, ensure_ascii=False, indent=2), encoding="utf-8")
193
+
194
+
195
+ def _write_parser_info(module_dir: Path, parser_name: str, preset_name: str) -> None:
196
+ """Write parser metadata for audit trail."""
197
+ from core.paths import meta_dir
198
+ info_path = meta_dir(module_dir) / "parser-info.json"
199
+ ensure_dir(info_path.parent)
200
+ info_path.write_text(
201
+ json.dumps({"parser": parser_name, "preset": preset_name}, ensure_ascii=False, indent=2),
202
+ encoding="utf-8",
203
+ )
204
+
205
+
206
+ def _extract_package(filepath: str, depth: int = 2) -> str:
207
+ """Extract top-level package from file path for sharding."""
208
+ parts = filepath.replace("\\", "/").split("/")
209
+ # Skip standard source prefixes
210
+ skip = {"src", "main", "java", "python", "go", "kotlin", "lib", "app", "pkg"}
211
+ start = 0
212
+ for i, p in enumerate(parts[:-1]):
213
+ if p.lower() in skip:
214
+ start = i + 1
215
+ else:
216
+ break
217
+ remaining = parts[start:-1]
218
+ return ".".join(remaining[:depth]) if remaining else "root"