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
core/skeleton/query.py ADDED
@@ -0,0 +1,195 @@
1
+ """Skeleton query tool — stats, search, and high-complexity method listing.
2
+
3
+ Provides programmatic access to skeleton data for diagnostics.
4
+ Migrated from engine/skeleton/query_skeleton.py.
5
+
6
+ No CLI I/O in this module — returns structured data only.
7
+ CLI layer handles formatting and output.
8
+
9
+ Usage:
10
+ from core.skeleton.query import stats, search, high_methods
11
+ result = stats(entries)
12
+ matches = search(entries, "OrderService")
13
+ methods = high_methods(entries)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Data loading
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def load_skeleton(path: Path) -> list[dict]:
30
+ """Load skeleton JSON, supporting both bare list and dict wrapper formats.
31
+
32
+ Args:
33
+ path: Path to skeleton JSON file or directory of shard files.
34
+
35
+ Returns:
36
+ List of skeleton entries.
37
+
38
+ Raises:
39
+ FileNotFoundError: If path does not exist.
40
+ json.JSONDecodeError: If JSON is malformed.
41
+ """
42
+ if path.is_dir():
43
+ return _load_from_directory(path)
44
+
45
+ raw = json.loads(path.read_text(encoding="utf-8"))
46
+ if isinstance(raw, dict):
47
+ return raw.get("data", raw.get("files", []))
48
+ if isinstance(raw, list):
49
+ return raw
50
+ return []
51
+
52
+
53
+ def _load_from_directory(directory: Path) -> list[dict]:
54
+ """Load and merge skeleton entries from a directory of shard files."""
55
+ entries: list[dict] = []
56
+ for f in sorted(directory.glob("*.json")):
57
+ try:
58
+ data = json.loads(f.read_text(encoding="utf-8"))
59
+ if isinstance(data, list):
60
+ entries.extend(data)
61
+ elif isinstance(data, dict):
62
+ entries.extend(data.get("data", data.get("files", [])))
63
+ except (json.JSONDecodeError, OSError):
64
+ pass
65
+ return entries
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Stats subcommand
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def stats(entries: list[dict]) -> dict[str, Any]:
74
+ """Compute skeleton statistics.
75
+
76
+ Returns:
77
+ {
78
+ "files": int,
79
+ "total_lines": int,
80
+ "methods": int,
81
+ "complexity": {"high": n, "medium": n, "low": n},
82
+ }
83
+ """
84
+ total_files = len(entries)
85
+ total_methods = 0
86
+ total_lines = 0
87
+ complexity_counts: dict[str, int] = {"high": 0, "medium": 0, "low": 0}
88
+
89
+ for entry in entries:
90
+ total_lines += entry.get("total_lines", 0)
91
+ for m in entry.get("methods", []):
92
+ total_methods += 1
93
+ c = m.get("complexity", "unknown")
94
+ if c in complexity_counts:
95
+ complexity_counts[c] += 1
96
+
97
+ return {
98
+ "files": total_files,
99
+ "total_lines": total_lines,
100
+ "methods": total_methods,
101
+ "complexity": {k: v for k, v in complexity_counts.items() if v > 0},
102
+ }
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # High-methods subcommand
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def high_methods(entries: list[dict]) -> list[dict[str, Any]]:
111
+ """List all high-complexity methods.
112
+
113
+ Returns:
114
+ List of {"file": str, "method": str, "line_count": int}
115
+ """
116
+ results: list[dict[str, Any]] = []
117
+ for entry in entries:
118
+ filepath = entry.get("file", "")
119
+ for m in entry.get("methods", []):
120
+ if m.get("complexity") == "high":
121
+ results.append({
122
+ "file": filepath,
123
+ "method": m.get("name", "?"),
124
+ "line_count": m.get("line_count", 0),
125
+ })
126
+ return results
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Search subcommand (fuzzy match)
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ def search(entries: list[dict], query: str) -> list[dict[str, Any]]:
135
+ """Fuzzy search for methods or classes matching query.
136
+
137
+ Performs case-insensitive substring match on class names, method names,
138
+ and file paths.
139
+
140
+ Returns:
141
+ List of {"file": str, "type": "class"|"method", "name": str, "line": int}
142
+ """
143
+ results: list[dict[str, Any]] = []
144
+ pattern = re.compile(re.escape(query), re.IGNORECASE)
145
+
146
+ for entry in entries:
147
+ filepath = entry.get("file", "")
148
+
149
+ # Search in file path
150
+ if pattern.search(filepath):
151
+ results.append({
152
+ "file": filepath,
153
+ "type": "file",
154
+ "name": filepath,
155
+ "line": 0,
156
+ })
157
+
158
+ # Search in class names
159
+ for cls in entry.get("classes", []):
160
+ name = cls.get("name", "")
161
+ if name and pattern.search(name):
162
+ results.append({
163
+ "file": filepath,
164
+ "type": "class",
165
+ "name": name,
166
+ "line": cls.get("line", 0),
167
+ })
168
+
169
+ # Search in method names
170
+ for m in entry.get("methods", []):
171
+ name = m.get("name", "")
172
+ if name and pattern.search(name):
173
+ results.append({
174
+ "file": filepath,
175
+ "type": "method",
176
+ "name": name,
177
+ "line": m.get("line", 0),
178
+ })
179
+
180
+ return results
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # File list and method count utilities
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ def file_list(entries: list[dict]) -> list[str]:
189
+ """Extract all file paths from skeleton entries."""
190
+ return [entry.get("file", "") for entry in entries if entry.get("file")]
191
+
192
+
193
+ def method_count(entries: list[dict]) -> int:
194
+ """Count total methods across all entries."""
195
+ return sum(len(e.get("methods", [])) for e in entries)
@@ -0,0 +1,177 @@
1
+ """Shard context module — builds context for N+1 shard from completed shards.
2
+
3
+ Provides prior shard summaries and neighbor file lists to avoid information
4
+ silos between shards of the same doc type.
5
+
6
+ No CLI I/O, no LLM calls. Pure file reading and formatting.
7
+
8
+ Usage:
9
+ from core.skeleton.shard_context import build_shard_context
10
+
11
+ context = build_shard_context(
12
+ module_dir=Path("knowledge/point/point-base"),
13
+ doc_type="business-logic",
14
+ current_shard_name="payment",
15
+ completed_shards=["order", "inventory"],
16
+ )
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Public API
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def build_shard_context(
30
+ module_dir: Path,
31
+ doc_type: str,
32
+ current_shard_name: str,
33
+ completed_shards: list[str] | None = None,
34
+ max_chars: int = 1500,
35
+ preset: dict | None = None,
36
+ ) -> str:
37
+ """Build context summary from prior shards of the same doc type.
38
+
39
+ Args:
40
+ module_dir: Module knowledge directory
41
+ doc_type: Document type (e.g., "business-logic")
42
+ current_shard_name: Current shard name (excluded from context)
43
+ completed_shards: List of completed shard names (in completion order)
44
+ max_chars: Maximum injected character count
45
+ preset: Preset configuration dict (for doc_type → filename resolution)
46
+
47
+ Returns:
48
+ Formatted context text for prompt injection
49
+ """
50
+ if not completed_shards:
51
+ return ""
52
+
53
+ doc_basename = _doc_type_to_filename(doc_type, preset=preset).replace(".md", "")
54
+ shard_summaries: list[str] = []
55
+
56
+ for shard_name in completed_shards:
57
+ if shard_name == current_shard_name:
58
+ continue
59
+
60
+ # Try multiple naming patterns
61
+ candidates = [
62
+ module_dir / f"{doc_basename}-{shard_name}.md",
63
+ module_dir / f".shard-{doc_basename}-{shard_name}.md",
64
+ ]
65
+
66
+ for path in candidates:
67
+ if path.exists():
68
+ summary = _extract_shard_summary(path)
69
+ if summary:
70
+ shard_summaries.append(f"### {shard_name}\n{summary}")
71
+ break
72
+
73
+ if not shard_summaries:
74
+ return ""
75
+
76
+ header = "## Content already covered by prior shards (avoid duplication)\n\n"
77
+ body = "\n\n".join(shard_summaries)
78
+ full_text = header + body
79
+
80
+ if len(full_text) > max_chars:
81
+ full_text = full_text[:max_chars - 30] + "\n\n[Context truncated]"
82
+
83
+ return full_text
84
+
85
+
86
+ def build_neighbor_file_list(
87
+ module_dir: Path,
88
+ doc_type: str,
89
+ current_shard_name: str,
90
+ all_shards: list[str],
91
+ ) -> str:
92
+ """Build neighbor shard file list summary for cross-shard awareness.
93
+
94
+ Args:
95
+ module_dir: Module knowledge directory
96
+ doc_type: Document type
97
+ current_shard_name: Current shard name
98
+ all_shards: All shard names in the plan
99
+
100
+ Returns:
101
+ Formatted neighbor file list text
102
+ """
103
+ from core.paths import file_list_dir
104
+
105
+ fl_dir = file_list_dir(module_dir)
106
+ neighbors: list[str] = []
107
+
108
+ for shard_name in all_shards:
109
+ if shard_name == current_shard_name:
110
+ continue
111
+
112
+ fl_path = fl_dir / f"{doc_type}-{shard_name}.txt"
113
+ if not fl_path.exists():
114
+ continue
115
+
116
+ try:
117
+ lines = fl_path.read_text(encoding="utf-8").splitlines()
118
+ file_count = sum(1 for l in lines if l.strip())
119
+ # Show first few files as preview
120
+ preview = [l.strip().rsplit("/", 1)[-1] for l in lines[:3] if l.strip()]
121
+ preview_str = ", ".join(preview)
122
+ if file_count > 3:
123
+ preview_str += f" ... (+{file_count - 3})"
124
+ neighbors.append(f"- **{shard_name}** ({file_count} files): {preview_str}")
125
+ except OSError:
126
+ continue
127
+
128
+ if not neighbors:
129
+ return ""
130
+
131
+ return "### Neighbor shard file lists\n\n" + "\n".join(neighbors)
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Internal helpers
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ def _extract_shard_summary(path: Path, max_lines: int = 12) -> str:
140
+ """Extract shard document summary: headings + first few content lines."""
141
+ try:
142
+ content = path.read_text(encoding="utf-8")
143
+ except (OSError, UnicodeDecodeError):
144
+ return ""
145
+
146
+ lines = content.splitlines()
147
+ if not lines:
148
+ return ""
149
+
150
+ # Extract ## headings
151
+ headings = [l.strip() for l in lines if l.strip().startswith("## ")]
152
+
153
+ # Take first N non-empty lines as summary
154
+ summary_lines: list[str] = []
155
+ for line in lines[:max_lines]:
156
+ stripped = line.strip()
157
+ if stripped:
158
+ summary_lines.append(stripped)
159
+
160
+ result_parts: list[str] = []
161
+ if summary_lines:
162
+ result_parts.append("\n".join(summary_lines[:5]))
163
+ if headings:
164
+ result_parts.append("Sections: " + " | ".join(h.lstrip("#").strip() for h in headings[:8]))
165
+
166
+ return "\n".join(result_parts)
167
+
168
+
169
+ def _doc_type_to_filename(doc_type: str, preset: dict | None = None) -> str:
170
+ """Map doc_type key to filename using preset config."""
171
+ if preset:
172
+ from core.preset import get_doc_filename
173
+ filename = get_doc_filename(preset, doc_type, strict=False)
174
+ if filename:
175
+ return filename
176
+ # Fallback: assume doc_type matches filename pattern
177
+ return f"{doc_type}.md"
core/skeleton/split.py ADDED
@@ -0,0 +1,180 @@
1
+ """Split configuration and shard count computation.
2
+
3
+ Provides SplitConfig (all thresholds from yaml) and compute_splits (shard count).
4
+ Split *planning* (files → shards assignment) lives in split_plan.py.
5
+
6
+ Usage:
7
+ from core.skeleton.split import SplitConfig, SplitPlan, compute_splits
8
+
9
+ config = SplitConfig.from_preset(preset, mode="readwrite")
10
+ n = compute_splits(config, total_bytes=500000, total_lines=12000)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import math
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # SplitConfig — all thresholds from configuration
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SplitConfig:
30
+ """Encapsulates all split threshold parameters. Loaded from preset/config yaml."""
31
+
32
+ max_bytes: int = 300 * 1024
33
+ max_lines: int = 8000
34
+ max_files_per_shard: int = 80
35
+ llm_sample_limit: int = 200
36
+ merge_threshold_ratio: float = 0.25 # Splits below this fraction of max are merged
37
+ skip_file_count_doc_types: frozenset[str] = frozenset({"source-tree-analysis", "index"})
38
+ per_doc_type_overrides: dict[str, dict[str, int]] = None # type: ignore[assignment]
39
+ noise_words: frozenset[str] = frozenset({
40
+ "service", "impl", "base", "controller", "manager",
41
+ "handler", "listener", "api", "java", "abstract", "default", "i",
42
+ })
43
+
44
+ def __post_init__(self):
45
+ if self.per_doc_type_overrides is None:
46
+ object.__setattr__(self, "per_doc_type_overrides", {})
47
+
48
+ @classmethod
49
+ def from_preset(cls, preset: dict, mode: str = "output-only", project_split: dict | None = None) -> SplitConfig:
50
+ """Load from preset config with mode-specific defaults and per-doc-type overrides.
51
+
52
+ Args:
53
+ preset: Preset configuration dict (from doc_types.yaml)
54
+ mode: Execution mode ("readwrite" or "output-only")
55
+ project_split: Optional project-level split overrides (from kb-project.yaml)
56
+ These take precedence over preset defaults.
57
+ """
58
+ split_cfg = preset.get("split", {}).get(mode, {})
59
+ defaults = _MODE_DEFAULTS[mode]
60
+
61
+ # Project-level overrides (kb-project.yaml > preset defaults)
62
+ project_mode_cfg = {}
63
+ if project_split:
64
+ project_mode_cfg = project_split.get(mode, project_split)
65
+
66
+ # Collect per-doc-type overrides from doc_types section
67
+ doc_types = preset.get("doc_types", {})
68
+ overrides: dict[str, dict[str, int]] = {}
69
+ for dt_key, dt_config in doc_types.items():
70
+ if isinstance(dt_config, dict) and "split_override" in dt_config:
71
+ mode_override = dt_config["split_override"].get(mode, {})
72
+ if mode_override:
73
+ overrides[dt_key] = mode_override
74
+
75
+ # Determine global_view doc types (skip file count limit)
76
+ from core.preset import get_global_view_types
77
+ global_view_types = get_global_view_types(preset)
78
+
79
+ # Resolve values: project > preset > mode defaults
80
+ def _resolve(key: str, default: int | float):
81
+ if key in project_mode_cfg:
82
+ return project_mode_cfg[key]
83
+ return split_cfg.get(key, default)
84
+
85
+ return cls(
86
+ max_bytes=_resolve("max_bytes", defaults["max_bytes"]),
87
+ max_lines=_resolve("max_lines", defaults["max_lines"]),
88
+ max_files_per_shard=_resolve("max_files_per_shard", defaults["max_files_per_shard"]),
89
+ llm_sample_limit=_resolve("llm_sample_limit", defaults["llm_sample_limit"]),
90
+ merge_threshold_ratio=_resolve("merge_threshold_ratio", 0.25),
91
+ skip_file_count_doc_types=global_view_types or frozenset({"source-tree-analysis", "index"}),
92
+ per_doc_type_overrides=overrides,
93
+ noise_words=frozenset(preset.get("split", {}).get("noise_words", [])) or SplitConfig.noise_words,
94
+ )
95
+
96
+ def effective_max_lines(self, doc_type: str) -> int:
97
+ """Get max_lines for a specific doc type (respects per-doc-type overrides)."""
98
+ override = self.per_doc_type_overrides.get(doc_type, {})
99
+ return override.get("max_lines", self.max_lines)
100
+
101
+ def effective_max_bytes(self, doc_type: str) -> int:
102
+ """Get max_bytes for a specific doc type (respects per-doc-type overrides)."""
103
+ override = self.per_doc_type_overrides.get(doc_type, {})
104
+ return override.get("max_bytes", self.max_bytes)
105
+
106
+
107
+ _MODE_DEFAULTS: dict[str, dict[str, int]] = {
108
+ "output-only": {"max_bytes": 300 * 1024, "max_lines": 8000, "max_files_per_shard": 80, "llm_sample_limit": 200},
109
+ "readwrite": {"max_bytes": 500 * 1024, "max_lines": 10000, "max_files_per_shard": 80, "llm_sample_limit": 200},
110
+ }
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # compute_splits — determine number of shards needed
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ def compute_splits(
119
+ config: SplitConfig,
120
+ *,
121
+ total_bytes: int = 0,
122
+ total_lines: int = 0,
123
+ file_count: int = 0,
124
+ doc_type: str = "",
125
+ ) -> int:
126
+ """Compute number of splits needed based on input size.
127
+
128
+ Priority: bytes > lines > file_count (file count is upper-bound protection only).
129
+ """
130
+ if file_count <= 0 and total_lines <= 0 and total_bytes <= 0:
131
+ return 1
132
+
133
+ max_lines = config.effective_max_lines(doc_type)
134
+ max_bytes = config.effective_max_bytes(doc_type)
135
+
136
+ # Per-doc-type max_files override
137
+ override = config.per_doc_type_overrides.get(doc_type, {})
138
+ max_files = override.get("max_files_per_shard", config.max_files_per_shard)
139
+
140
+ # Priority 1: bytes
141
+ splits_by_bytes = math.ceil(total_bytes / max_bytes) if total_bytes > 0 else 1
142
+ # Priority 2: lines
143
+ splits_by_lines = math.ceil(total_lines / max_lines) if total_lines > 0 else 1
144
+ # Take the larger
145
+ splits = max(splits_by_bytes, splits_by_lines)
146
+
147
+ # Priority 3: file count upper-bound protection
148
+ if file_count > 0 and splits > 0 and doc_type not in config.skip_file_count_doc_types:
149
+ files_per_shard = math.ceil(file_count / splits)
150
+ if files_per_shard > max_files:
151
+ splits = math.ceil(file_count / max_files)
152
+
153
+ return max(1, splits)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # SplitPlan — result dataclass (used by split_plan.py)
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ @dataclass
162
+ class SplitPlan:
163
+ """Result of split planning."""
164
+ splits: list[dict[str, Any]] = field(default_factory=list)
165
+ strategy: str = "single"
166
+ warnings: list[str] = field(default_factory=list)
167
+ pending_agent_grouping: bool = False
168
+ grouping_request_path: str | None = None
169
+
170
+ @property
171
+ def recommended_agents(self) -> int:
172
+ return len(self.splits)
173
+
174
+
175
+ # Cache functions re-exported from split_cache module
176
+ from core.skeleton.split_cache import ( # noqa: E402, F401
177
+ compute_skeleton_hash,
178
+ get_cached_plan,
179
+ save_plan_cache,
180
+ )
@@ -0,0 +1,107 @@
1
+ """Split plan caching — persist and retrieve split decisions.
2
+
3
+ Caches split plans keyed by skeleton hash to avoid recomputing when source
4
+ hasn't changed. Supports hysteresis by providing previous split counts.
5
+
6
+ Usage:
7
+ from core.skeleton.split_cache import compute_skeleton_hash, get_cached_plan, save_plan_cache
8
+
9
+ hash = compute_skeleton_hash(module_dir)
10
+ cached = get_cached_plan(module_dir, "business-logic", hash)
11
+ if not cached:
12
+ plan = compute_new_plan(...)
13
+ save_plan_cache(module_dir, hash, "business-logic", plan)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ from pathlib import Path
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ _CACHE_FILE = "split-plan-cache.json"
27
+
28
+
29
+ def compute_skeleton_hash(module_dir: Path) -> str:
30
+ """SHA-256 hash of skeleton + file-lists (for cache invalidation)."""
31
+ from core.paths import resolve_skeleton, file_list_dir
32
+
33
+ h = hashlib.sha256()
34
+ skel = resolve_skeleton(module_dir)
35
+ if skel:
36
+ if skel.is_dir():
37
+ for f in sorted(skel.glob("*.json")):
38
+ try:
39
+ h.update(f.read_bytes())
40
+ except OSError:
41
+ pass
42
+ elif skel.is_file():
43
+ try:
44
+ h.update(skel.read_bytes())
45
+ except OSError:
46
+ pass
47
+
48
+ fl_dir = file_list_dir(module_dir)
49
+ if fl_dir.is_dir():
50
+ for f in sorted(fl_dir.iterdir()):
51
+ if f.is_file():
52
+ try:
53
+ h.update(f.read_bytes())
54
+ except OSError:
55
+ pass
56
+
57
+ return h.hexdigest()[:16]
58
+
59
+
60
+ def get_cached_plan(module_dir: Path, doc_type: str, current_hash: str) -> dict | None:
61
+ """Get cached split plan if skeleton hash matches. Returns None on miss."""
62
+ cache = _load_cache(module_dir)
63
+ if cache.get("skeleton_hash") != current_hash:
64
+ return None
65
+ return cache.get("plans", {}).get(doc_type)
66
+
67
+
68
+ def get_cached_split_count(module_dir: Path, doc_type: str, current_hash: str) -> int | None:
69
+ """Get previous split count. Returns None if no cache or hash changed."""
70
+ cache = _load_cache(module_dir)
71
+ if cache.get("skeleton_hash") != current_hash:
72
+ return None
73
+ return cache.get("split_counts", {}).get(doc_type)
74
+
75
+
76
+ def save_plan_cache(module_dir: Path, skeleton_hash: str, doc_type: str, plan: dict, num_splits: int = 0) -> None:
77
+ """Save split plan to cache. Clears old cache if hash changed."""
78
+ from core.paths import meta_dir, ensure_dir
79
+
80
+ cache_path = meta_dir(module_dir) / _CACHE_FILE
81
+ ensure_dir(cache_path.parent)
82
+
83
+ cache = _load_cache(module_dir)
84
+ if cache.get("skeleton_hash") != skeleton_hash:
85
+ cache = {"skeleton_hash": skeleton_hash, "plans": {}, "split_counts": {}}
86
+
87
+ cache.setdefault("plans", {})[doc_type] = plan
88
+ if num_splits:
89
+ cache.setdefault("split_counts", {})[doc_type] = num_splits
90
+
91
+ try:
92
+ cache_path.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
93
+ except OSError as e:
94
+ logger.debug("Split cache write failed: %s", e)
95
+
96
+
97
+ def _load_cache(module_dir: Path) -> dict:
98
+ """Load cache file. Returns empty dict on any error."""
99
+ from core.paths import meta_dir
100
+
101
+ cache_path = meta_dir(module_dir) / _CACHE_FILE
102
+ if not cache_path.exists():
103
+ return {}
104
+ try:
105
+ return json.loads(cache_path.read_text(encoding="utf-8"))
106
+ except (json.JSONDecodeError, OSError):
107
+ return {}