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,248 @@
1
+ """Document-skeleton structural comparison tool.
2
+
3
+ Compares document content against skeleton entries to identify
4
+ inconsistencies (missing or stale items).
5
+
6
+ Usage:
7
+ python -m core.skeleton diff-doc --doc-path business-logic.md --skeleton-path .skeleton.json
8
+
9
+ from core.skeleton.diff_doc import diff_doc
10
+ result = diff_doc(doc_path, skeleton_path, doc_type="business-logic")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Literal
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Data classes
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ @dataclass
31
+ class DiffFinding:
32
+ """Single diff finding."""
33
+
34
+ item: str # class/method/field name
35
+ category: Literal["missing", "stale"]
36
+ doc_location: str = "" # section heading where found/expected
37
+ skeleton_location: str = "" # file path in skeleton
38
+ detail: str = ""
39
+
40
+
41
+ @dataclass
42
+ class DiffResult:
43
+ """Complete diff comparison result."""
44
+
45
+ doc_type: str
46
+ findings: list[DiffFinding] = field(default_factory=list)
47
+ doc_items_count: int = 0
48
+ skeleton_items_count: int = 0
49
+
50
+ @property
51
+ def missing_count(self) -> int:
52
+ return sum(1 for f in self.findings if f.category == "missing")
53
+
54
+ @property
55
+ def stale_count(self) -> int:
56
+ return sum(1 for f in self.findings if f.category == "stale")
57
+
58
+ def to_json(self) -> list[dict]:
59
+ """Convert to audit-compatible JSON format."""
60
+ return [
61
+ {
62
+ "dimension": f"diff_{f.category}",
63
+ "status": "fail",
64
+ "detail": f"{f.category}: {f.item} — {f.detail}",
65
+ "fix": "",
66
+ }
67
+ for f in self.findings
68
+ ]
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Public API
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def diff_doc(
77
+ doc_path: Path,
78
+ skeleton_path: Path,
79
+ *,
80
+ doc_type: str | None = None,
81
+ ) -> DiffResult:
82
+ """Compare document against skeleton.
83
+
84
+ Comparison rules by doc_type:
85
+ - data-models: field-level (entity class names)
86
+ - api-contracts: endpoint-level (controller method names + URLs)
87
+ - business-logic: method-level (service method names)
88
+ - default: class-level (class names from skeleton files)
89
+
90
+ Args:
91
+ doc_path: Path to the markdown document
92
+ skeleton_path: Path to skeleton JSON file or directory
93
+ doc_type: Document type for type-specific comparison rules
94
+
95
+ Returns:
96
+ DiffResult with findings in audit-compatible format
97
+ """
98
+ result = DiffResult(doc_type=doc_type or "unknown")
99
+
100
+ # Load skeleton entries
101
+ skeleton_entries = _load_skeleton(skeleton_path)
102
+ if not skeleton_entries:
103
+ return result
104
+
105
+ # Load document content
106
+ try:
107
+ doc_content = doc_path.read_text(encoding="utf-8", errors="replace")
108
+ except OSError:
109
+ return result
110
+
111
+ # Extract items from skeleton based on doc_type
112
+ skeleton_items = _extract_skeleton_items(skeleton_entries, doc_type)
113
+ result.skeleton_items_count = len(skeleton_items)
114
+
115
+ # Extract items mentioned in document
116
+ doc_items = _extract_doc_items(doc_content, doc_type)
117
+ result.doc_items_count = len(doc_items)
118
+
119
+ # Compare: skeleton items not in doc → "missing"
120
+ doc_items_lower = {item.lower() for item in doc_items}
121
+ for item, file_path in skeleton_items.items():
122
+ if item.lower() not in doc_items_lower:
123
+ result.findings.append(DiffFinding(
124
+ item=item,
125
+ category="missing",
126
+ skeleton_location=file_path,
127
+ detail=f"In skeleton ({file_path}) but not found in document",
128
+ ))
129
+
130
+ # Compare: doc items not in skeleton → "stale"
131
+ skeleton_items_lower = {item.lower() for item in skeleton_items}
132
+ for item in doc_items:
133
+ if item.lower() not in skeleton_items_lower:
134
+ result.findings.append(DiffFinding(
135
+ item=item,
136
+ category="stale",
137
+ doc_location="document",
138
+ detail=f"In document but not found in skeleton (possibly deleted/renamed)",
139
+ ))
140
+
141
+ return result
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Internal helpers
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def _load_skeleton(skeleton_path: Path) -> list[dict]:
150
+ """Load skeleton entries from file or directory."""
151
+ if skeleton_path.is_dir():
152
+ entries = []
153
+ for f in sorted(skeleton_path.glob("*.json")):
154
+ try:
155
+ data = json.loads(f.read_text(encoding="utf-8"))
156
+ if isinstance(data, list):
157
+ entries.extend(data)
158
+ except (json.JSONDecodeError, OSError):
159
+ continue
160
+ return entries
161
+ elif skeleton_path.exists():
162
+ try:
163
+ data = json.loads(skeleton_path.read_text(encoding="utf-8"))
164
+ if isinstance(data, list):
165
+ return data
166
+ return data.get("data", data.get("files", []))
167
+ except (json.JSONDecodeError, OSError):
168
+ return []
169
+ return []
170
+
171
+
172
+ def _extract_skeleton_items(entries: list[dict], doc_type: str | None) -> dict[str, str]:
173
+ """Extract relevant items from skeleton based on doc_type.
174
+
175
+ Returns: {item_name: file_path}
176
+ """
177
+ items: dict[str, str] = {}
178
+
179
+ for entry in entries:
180
+ file_path = entry.get("file", "")
181
+ classes = entry.get("classes", [])
182
+ methods = entry.get("methods", [])
183
+
184
+ if doc_type in ("data-models",):
185
+ # Extract class names (entities, DTOs)
186
+ for cls in classes:
187
+ name = cls.get("name", "")
188
+ if name:
189
+ items[name] = file_path
190
+ elif doc_type in ("api-contracts",):
191
+ # Extract method names from controllers
192
+ for m in methods:
193
+ name = m.get("name", "")
194
+ annotations = m.get("annotations", [])
195
+ # Look for mapping annotations
196
+ for a in annotations:
197
+ a_name = a if isinstance(a, str) else a.get("name", "")
198
+ if "Mapping" in a_name:
199
+ items[name] = file_path
200
+ break
201
+ elif doc_type in ("business-logic",):
202
+ # Extract service method names (high complexity preferred)
203
+ for m in methods:
204
+ name = m.get("name", "")
205
+ complexity = m.get("complexity", "")
206
+ if complexity == "high" and name:
207
+ items[name] = file_path
208
+ else:
209
+ # Default: class names
210
+ for cls in classes:
211
+ name = cls.get("name", "")
212
+ if name:
213
+ items[name] = file_path
214
+
215
+ return items
216
+
217
+
218
+ def _extract_doc_items(content: str, doc_type: str | None) -> list[str]:
219
+ """Extract item names mentioned in the document."""
220
+ items: list[str] = []
221
+
222
+ # Extract from headings (## and ###)
223
+ for line in content.splitlines():
224
+ if line.startswith("## ") or line.startswith("### "):
225
+ heading_text = line.lstrip("#").strip()
226
+ # Extract class/method names from headings
227
+ # Common patterns: "## ClassName", "## ClassName (description)", "### methodName"
228
+ # Extract the first word/identifier
229
+ match = re.match(r'[`]?(\w+)[`]?', heading_text)
230
+ if match:
231
+ items.append(match.group(1))
232
+
233
+ # Extract from code blocks and inline code
234
+ for match in re.finditer(r'`(\w+(?:\.\w+)?)`', content):
235
+ word = match.group(1)
236
+ # Filter out common non-identifiers
237
+ if len(word) > 2 and word[0].isupper():
238
+ items.append(word)
239
+
240
+ # Deduplicate while preserving order
241
+ seen: set[str] = set()
242
+ unique: list[str] = []
243
+ for item in items:
244
+ if item not in seen:
245
+ seen.add(item)
246
+ unique.append(item)
247
+
248
+ return unique
@@ -0,0 +1,273 @@
1
+ """Dispatch plan computation — module->doc_type mapping, priority, split triggering.
2
+
3
+ Computes the full dispatch plan for document generation: which doc_types to generate,
4
+ how many shards each needs, and in what batch order.
5
+
6
+ No CLI I/O, no LLM calls. Pure computation from config + file system state.
7
+
8
+ Usage:
9
+ from core.skeleton.dispatch import compute_dispatch_plan, DispatchPlan
10
+
11
+ plan = compute_dispatch_plan(
12
+ preset=preset, module_dir=module_dir, source_cache=source_cache,
13
+ mode="readwrite", module_type="service",
14
+ )
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from core.skeleton.dispatch_source import (
26
+ get_file_list_with_stats, count_source_lines, skeleton_total_size_kb,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ @dataclass
33
+ class ShardPlan:
34
+ """Plan for a single shard within a dispatch entry."""
35
+ name: str
36
+ files: list[str] = field(default_factory=list)
37
+ lines: int = 0
38
+ bytes: int = 0
39
+ file_count: int = 0
40
+
41
+
42
+ @dataclass
43
+ class DispatchEntry:
44
+ """Single module dispatch plan entry (one doc_type or shard)."""
45
+ module_name: str = ""
46
+ doc_type: str = ""
47
+ doc_filename: str = ""
48
+ batch_id: str = ""
49
+ file_count: int = 0
50
+ total_lines: int = 0
51
+ total_bytes: int = 0
52
+ split_count: int = 1
53
+ priority: int = 0
54
+ shards: list[ShardPlan] = field(default_factory=list)
55
+ strategy: str = "single-agent"
56
+ note: str = ""
57
+ pending_grouping: str | None = None
58
+
59
+
60
+ @dataclass
61
+ class DispatchPlan:
62
+ """Complete dispatch plan for a module."""
63
+ module_name: str = ""
64
+ entries: list[DispatchEntry] = field(default_factory=list)
65
+ batch_order: list[tuple[str, list[str]]] = field(default_factory=list)
66
+ source_file_count: int = 0
67
+ source_total_lines: int = 0
68
+ skeleton_size_kb: int = 0
69
+
70
+ def total_shards(self) -> int:
71
+ return sum(max(1, e.split_count) for e in self.entries)
72
+
73
+ def total_entries(self) -> int:
74
+ return len(self.entries)
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Batch order inference
79
+ # ---------------------------------------------------------------------------
80
+
81
+ _DEFAULT_BATCH_ORDER: list[tuple[str, list[str]]] = [
82
+ ("1", ["source-tree-analysis"]),
83
+ ("2A", ["data-models", "enums-and-constants"]),
84
+ ("2B", ["api-contracts", "architecture", "caching", "messaging"]),
85
+ ("3", ["business-logic", "utils"]),
86
+ ("4", ["index"]),
87
+ ]
88
+
89
+ _LEVELS: dict[int, list[str]] = {
90
+ 1: ["source-tree", "structure", "tree", "enums", "constants", "enum"],
91
+ 2: ["data-models", "data-model", "entities", "entity", "models", "schema",
92
+ "database-access", "database", "dao", "mapper"],
93
+ 3: ["api", "contract", "architecture", "config", "redis", "kafka", "mq", "cache",
94
+ "middleware", "scheduled", "error-handling", "security", "aop", "interceptor",
95
+ "async", "event", "external"],
96
+ 4: ["business", "logic", "service", "workflow"],
97
+ 5: ["utils", "util", "helper", "tool"],
98
+ 6: ["index", "summary", "overview"],
99
+ }
100
+
101
+
102
+ def infer_batch_order(preset: dict[str, Any]) -> list[tuple[str, list[str]]]:
103
+ """Infer batch order from preset doc_types (language-agnostic)."""
104
+ from core.preset import get_doc_type_mapping
105
+ doc_mapping = get_doc_type_mapping(preset)
106
+ if not doc_mapping:
107
+ return list(_DEFAULT_BATCH_ORDER)
108
+
109
+ def _get_level(doc_type: str) -> int:
110
+ dt_lower = doc_type.lower()
111
+ for level, keywords in _LEVELS.items():
112
+ for kw in keywords:
113
+ if kw in dt_lower:
114
+ return level
115
+ return 4
116
+
117
+ level_groups: dict[int, list[str]] = {}
118
+ for dt in doc_mapping:
119
+ level_groups.setdefault(_get_level(dt), []).append(dt)
120
+
121
+ result = [(str(level), dts) for level, dts in sorted(level_groups.items())]
122
+ return result if result else list(_DEFAULT_BATCH_ORDER)
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Optional doc detection
127
+ # ---------------------------------------------------------------------------
128
+
129
+ _OPTIONAL_DOCS: frozenset[str] = frozenset({
130
+ "caching", "messaging", "external-integrations", "scheduled-tasks",
131
+ "error-handling", "security", "async-and-events", "aop-and-interceptors",
132
+ "database-access", "observability",
133
+ })
134
+
135
+
136
+ def detect_optional_doc(source_cache: Path, preset: dict[str, Any], doc_filename: str) -> bool:
137
+ """Scan source to detect if an optional doc type should be triggered."""
138
+ if not source_cache.is_dir():
139
+ return False
140
+ classification = preset.get("file_classification", {})
141
+ trigger_categories: set[str] = set()
142
+ for cat_name, cat_config in classification.items():
143
+ if isinstance(cat_config, dict) and doc_filename in cat_config.get("affects", []):
144
+ trigger_categories.add(cat_name)
145
+ if not trigger_categories:
146
+ return False
147
+ from core.preset_classify import classify_file
148
+ for root, _, filenames in os.walk(source_cache):
149
+ root_path = Path(root)
150
+ if "/test/" in str(root_path).replace("\\", "/"):
151
+ continue
152
+ for fn in filenames:
153
+ if not fn.endswith(".java"):
154
+ continue
155
+ rel = str((root_path / fn).relative_to(source_cache)).replace("\\", "/")
156
+ if trigger_categories & set(classify_file(preset, rel)):
157
+ return True
158
+ return False
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Compute dispatch plan
163
+ # ---------------------------------------------------------------------------
164
+
165
+ _STRATEGY_LABELS = {
166
+ "community": "community",
167
+ "llm": "llm",
168
+ "package": "package",
169
+ "simple": "simple",
170
+ "cache": "cache",
171
+ }
172
+
173
+
174
+ def compute_dispatch_plan(
175
+ preset: dict[str, Any], module_dir: Path, source_cache: Path,
176
+ mode: str = "readwrite", module_name: str = "", module_type: str = "service",
177
+ ) -> DispatchPlan:
178
+ """Compute the full dispatch plan for a module."""
179
+ from core.skeleton.split import SplitConfig, compute_splits
180
+ from core.skeleton.split_plan import plan_splits
181
+ from core.skeleton.loader import load_skeleton_entries
182
+ from core.preset import get_doc_type_mapping, get_doc_filename
183
+
184
+ dt_mapping = get_doc_type_mapping(preset)
185
+ batch_order = infer_batch_order(preset)
186
+ split_config = SplitConfig.from_preset(preset, mode=mode)
187
+
188
+ module_types_config = preset.get("module_types", {})
189
+ type_config = module_types_config.get(module_type, {})
190
+ skip_docs = set(type_config.get("skip", []))
191
+ skip_all = "*" in skip_docs
192
+
193
+ skel_size_kb = skeleton_total_size_kb(module_dir)
194
+ src_files, src_lines = count_source_lines(source_cache)
195
+ entries: list[DispatchEntry] = []
196
+
197
+ for batch_id, doc_types in batch_order:
198
+ for dt in doc_types:
199
+ if dt not in dt_mapping:
200
+ continue
201
+ doc_filename = dt_mapping[dt]
202
+ if skip_all or doc_filename in skip_docs:
203
+ continue
204
+ files = get_file_list_with_stats(module_dir, dt, source_cache, dt_mapping)
205
+ file_count = len(files)
206
+ if dt in _OPTIONAL_DOCS and file_count == 0:
207
+ doc_fn = get_doc_filename(preset, dt, strict=False)
208
+ if not detect_optional_doc(source_cache, preset, doc_fn):
209
+ continue
210
+ total_lines_dt = sum(f["lines"] for f in files)
211
+ total_bytes_dt = sum(f.get("bytes", 0) for f in files)
212
+
213
+ is_global_view = dt in split_config.skip_file_count_doc_types
214
+ if is_global_view:
215
+ total_bytes_dt = skel_size_kb * 1024
216
+ total_lines_dt = 0
217
+
218
+ n_splits = compute_splits(
219
+ split_config, total_bytes=total_bytes_dt, total_lines=total_lines_dt,
220
+ file_count=file_count, doc_type=dt,
221
+ )
222
+ if is_global_view:
223
+ n_splits = 1
224
+
225
+ shard_plans: list[ShardPlan] = []
226
+ pending_grouping: str | None = None
227
+ split_strategy_name: str = ""
228
+ if n_splits > 1 and files:
229
+ try:
230
+ skel_entries, _ = load_skeleton_entries(module_dir)
231
+ split_result = plan_splits(
232
+ entries=skel_entries, file_list=files,
233
+ split_config=split_config, doc_type=dt,
234
+ module_dir=module_dir,
235
+ )
236
+ split_strategy_name = split_result.strategy
237
+ if split_result.pending_agent_grouping:
238
+ pending_grouping = split_result.grouping_request_path
239
+ else:
240
+ for s in split_result.splits:
241
+ shard_plans.append(ShardPlan(
242
+ name=s.get("name", ""),
243
+ files=s.get("files", []),
244
+ lines=s.get("lines", 0),
245
+ bytes=s.get("bytes", 0),
246
+ file_count=s.get("file_count", 0),
247
+ ))
248
+ if shard_plans:
249
+ n_splits = len(shard_plans)
250
+ except Exception as e:
251
+ logger.warning("plan_splits failed for %s/%s: %s", module_name, dt, e)
252
+
253
+ if n_splits <= 1:
254
+ strategy_display = "single-agent"
255
+ else:
256
+ label = _STRATEGY_LABELS.get(split_strategy_name, split_strategy_name)
257
+ strategy_display = f"{label} x{n_splits}"
258
+
259
+ entries.append(DispatchEntry(
260
+ module_name=module_name, doc_type=dt, doc_filename=doc_filename,
261
+ batch_id=batch_id, file_count=file_count, total_lines=total_lines_dt,
262
+ total_bytes=total_bytes_dt, split_count=n_splits,
263
+ shards=shard_plans,
264
+ strategy=strategy_display,
265
+ note="global_view: skeleton summary only" if is_global_view else "",
266
+ pending_grouping=pending_grouping,
267
+ ))
268
+
269
+ return DispatchPlan(
270
+ module_name=module_name, entries=entries, batch_order=batch_order,
271
+ source_file_count=src_files, source_total_lines=src_lines,
272
+ skeleton_size_kb=skel_size_kb,
273
+ )