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.
- cli/__init__.py +50 -0
- cli/__main__.py +5 -0
- cli/commands/__init__.py +1 -0
- cli/commands/anchor_fix.py +47 -0
- cli/commands/diff_doc.py +52 -0
- cli/commands/dispatch.py +77 -0
- cli/commands/extract.py +72 -0
- cli/commands/file_list.py +74 -0
- cli/commands/index.py +84 -0
- cli/commands/lock.py +89 -0
- cli/commands/merge.py +60 -0
- cli/commands/merge_delta.py +19 -0
- cli/commands/metadata.py +24 -0
- cli/commands/pipeline.py +45 -0
- cli/commands/post_merge.py +43 -0
- cli/commands/query.py +52 -0
- cli/commands/render.py +101 -0
- cli/commands/scan_repos.py +46 -0
- cli/commands/setup.py +94 -0
- cli/commands/split.py +196 -0
- cli/commands/stale_files.py +98 -0
- cli/commands/validate.py +191 -0
- core/__init__.py +32 -0
- core/config.py +261 -0
- core/docs/__init__.py +7 -0
- core/docs/section_updater.py +286 -0
- core/docs/shared.py +149 -0
- core/git.py +294 -0
- core/interfaces.py +249 -0
- core/monitor/__init__.py +5 -0
- core/monitor/progress.py +83 -0
- core/monitor/prompt_store.py +49 -0
- core/paths.py +141 -0
- core/preset.py +237 -0
- core/preset_accessors.py +202 -0
- core/preset_classify.py +132 -0
- core/preset_hooks.py +129 -0
- core/preset_profile.py +89 -0
- core/prompt/__init__.py +7 -0
- core/prompt/__main__.py +147 -0
- core/prompt/content.py +320 -0
- core/prompt/context_manager.py +164 -0
- core/prompt/renderer.py +236 -0
- core/prompt/response_parser.py +274 -0
- core/prompt/templates.py +357 -0
- core/prompt/validate_parity.py +162 -0
- core/prompt/variables.py +339 -0
- core/rag/__init__.py +22 -0
- core/rag/__main__.py +136 -0
- core/rag/bm25_index.py +268 -0
- core/rag/chunker.py +273 -0
- core/rag/embedder.py +151 -0
- core/rag/indexer.py +292 -0
- core/rag/loader.py +89 -0
- core/rag/retriever.py +82 -0
- core/skeleton/__init__.py +11 -0
- core/skeleton/__main__.py +934 -0
- core/skeleton/anchor_fix.py +250 -0
- core/skeleton/classify.py +331 -0
- core/skeleton/cmd_anchor_fix.py +43 -0
- core/skeleton/cmd_diff_doc.py +44 -0
- core/skeleton/cmd_lock.py +87 -0
- core/skeleton/cmd_merge_delta.py +41 -0
- core/skeleton/community.py +233 -0
- core/skeleton/dependency_graph.py +306 -0
- core/skeleton/diff_doc.py +248 -0
- core/skeleton/dispatch.py +273 -0
- core/skeleton/dispatch_render.py +319 -0
- core/skeleton/dispatch_source.py +111 -0
- core/skeleton/extract.py +218 -0
- core/skeleton/extract_methods.py +298 -0
- core/skeleton/file_list.py +239 -0
- core/skeleton/impact.py +278 -0
- core/skeleton/jar_download.py +177 -0
- core/skeleton/jar_resolver.py +186 -0
- core/skeleton/loader.py +162 -0
- core/skeleton/merge.py +278 -0
- core/skeleton/merge_delta.py +229 -0
- core/skeleton/metadata.py +96 -0
- core/skeleton/metadata_builders.py +264 -0
- core/skeleton/module_dag.py +330 -0
- core/skeleton/parsers/__init__.py +71 -0
- core/skeleton/parsers/jqassistant.py +300 -0
- core/skeleton/parsers/jqassistant_cypher.py +225 -0
- core/skeleton/parsers/regex.py +171 -0
- core/skeleton/parsers/treesitter.py +324 -0
- core/skeleton/parsers/treesitter_java.py +284 -0
- core/skeleton/parsers/treesitter_multi.py +289 -0
- core/skeleton/pom_parser.py +299 -0
- core/skeleton/post_merge.py +295 -0
- core/skeleton/post_merge_llm.py +82 -0
- core/skeleton/query.py +195 -0
- core/skeleton/shard_context.py +177 -0
- core/skeleton/split.py +180 -0
- core/skeleton/split_cache.py +107 -0
- core/skeleton/split_feedback.py +174 -0
- core/skeleton/split_plan.py +219 -0
- core/skeleton/split_plan_helpers.py +305 -0
- core/skeleton/split_plan_llm.py +274 -0
- core/utils.py +135 -0
- core/validators/__init__.py +65 -0
- core/validators/__main__.py +215 -0
- core/validators/consistency.py +203 -0
- core/validators/coverage.py +171 -0
- core/validators/duplicates.py +76 -0
- core/validators/engine.py +224 -0
- core/validators/links.py +76 -0
- core/validators/sampling.py +169 -0
- core/validators/structure.py +144 -0
- engine/__init__.py +7 -0
- engine/assembler.py +231 -0
- engine/confirm.py +65 -0
- engine/dedup.py +106 -0
- engine/main.py +211 -0
- engine/pipeline/__init__.py +163 -0
- engine/pipeline/recovery.py +250 -0
- engine/pipeline/steps/__init__.py +23 -0
- engine/pipeline/steps/audit.py +220 -0
- engine/pipeline/steps/audit_apply.py +195 -0
- engine/pipeline/steps/audit_helpers.py +155 -0
- engine/pipeline/steps/classify_llm.py +236 -0
- engine/pipeline/steps/classify_prompt.py +223 -0
- engine/pipeline/steps/finalize.py +160 -0
- engine/pipeline/steps/generate.py +169 -0
- engine/pipeline/steps/generate_batch.py +197 -0
- engine/pipeline/steps/generate_recovery.py +170 -0
- engine/pipeline/steps/llm_plan_split.py +253 -0
- engine/pipeline/steps/lock.py +64 -0
- engine/pipeline/steps/preflight.py +237 -0
- engine/pipeline/steps/preflight_adjust.py +147 -0
- engine/pipeline/steps/pregenerate.py +130 -0
- engine/pipeline/steps/quality.py +81 -0
- engine/pipeline/steps/skeleton.py +149 -0
- engine/pipeline/steps/source.py +163 -0
- engine/pipeline/steps/sync.py +117 -0
- engine/pipeline/steps/sync_finalize.py +237 -0
- engine/pipeline/steps/sync_update.py +341 -0
- engine/pipelines.py +91 -0
- engine/runner.py +335 -0
- engine/strategies/__init__.py +86 -0
- engine/strategies/api.py +128 -0
- engine/strategies/delegated.py +50 -0
- engine/strategies/dryrun.py +25 -0
- engine/two_phase.py +143 -0
- mcp_server/__init__.py +73 -0
- mcp_server/__main__.py +5 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/config.py +63 -0
- mcp_server/tools/discovery.py +276 -0
- mcp_server/tools/generation.py +184 -0
- mcp_server/tools/planning.py +144 -0
- mcp_server/tools/source.py +175 -0
- mcp_server/tools/validation.py +140 -0
- mcp_server/tools/workflow.py +166 -0
- mcp_server/workflow_loader.py +204 -0
- presets/generic/audit_dimensions.md +132 -0
- presets/generic/doc_types.yaml +152 -0
- presets/generic/preset.yaml +115 -0
- presets/java-spring/audit_dimensions.md +228 -0
- presets/java-spring/audit_dimensions.yaml +203 -0
- presets/java-spring/doc_types.yaml +269 -0
- presets/java-spring/hooks.py +122 -0
- presets/java-spring/preset.yaml +341 -0
- presets/java-spring/templates/README.md +34 -0
- presets/java-spring/templates/audit-system.md +15 -0
- presets/java-spring/templates/subagent-aop.md +105 -0
- presets/java-spring/templates/subagent-api.md +63 -0
- presets/java-spring/templates/subagent-architecture.md +111 -0
- presets/java-spring/templates/subagent-async-events.md +107 -0
- presets/java-spring/templates/subagent-audit-api-contracts.md +40 -0
- presets/java-spring/templates/subagent-audit-architecture.md +38 -0
- presets/java-spring/templates/subagent-audit-business.md +40 -0
- presets/java-spring/templates/subagent-audit-data-models.md +40 -0
- presets/java-spring/templates/subagent-business.md +129 -0
- presets/java-spring/templates/subagent-caching.md +75 -0
- presets/java-spring/templates/subagent-database-access.md +114 -0
- presets/java-spring/templates/subagent-enum.md +75 -0
- presets/java-spring/templates/subagent-error-handling.md +91 -0
- presets/java-spring/templates/subagent-external-integrations.md +80 -0
- presets/java-spring/templates/subagent-index.md +122 -0
- presets/java-spring/templates/subagent-messaging.md +97 -0
- presets/java-spring/templates/subagent-model.md +88 -0
- presets/java-spring/templates/subagent-observability.md +91 -0
- presets/java-spring/templates/subagent-scheduled.md +81 -0
- presets/java-spring/templates/subagent-security.md +102 -0
- presets/java-spring/templates/subagent-structure.md +101 -0
- presets/java-spring/templates/subagent-sync-section.md +34 -0
- presets/java-spring/templates/subagent-utils.md +73 -0
- presets/java-spring/templates/sync-system.md +8 -0
- presets/java-spring/workflow-extensions.md +112 -0
- skills/__init__.py +1 -0
- skills/_shared/README.md +30 -0
- skills/_shared/doc-coverage-shared.md +134 -0
- skills/_shared/doc-quality-standard.md +1058 -0
- skills/_shared/doc-subagent-rules.md +762 -0
- skills/_shared/windows-compat.md +89 -0
- skills/kb-audit/SKILL.md +52 -0
- skills/kb-audit/rules.md +88 -0
- skills/kb-audit/steps/step-01-prepare.md +75 -0
- skills/kb-audit/steps/step-02-audit.md +96 -0
- skills/kb-audit/steps/step-03-verify.md +65 -0
- skills/kb-audit/steps/step-04-report.md +64 -0
- skills/kb-init/SKILL.md +146 -0
- skills/kb-init/rules.md +187 -0
- skills/kb-init/steps/step-01-scope.md +62 -0
- skills/kb-init/steps/step-02-source.md +410 -0
- skills/kb-init/steps/step-03-generate.md +307 -0
- skills/kb-init/steps/step-04-quality.md +92 -0
- skills/kb-init/steps/step-05-finalize.md +132 -0
- skills/kb-init/templates/core/execution-modes.md +29 -0
- skills/kb-init/templates/core/output-only.md +4 -0
- skills/kb-init/templates/core/readwrite.md +33 -0
- skills/kb-search/SKILL.md +138 -0
- skills/kb-search/rules.md +64 -0
- skills/kb-sync/SKILL.md +43 -0
- skills/kb-sync/rules.md +70 -0
- skills/kb-sync/scripts/rebuild_module.py +91 -0
- skills/kb-sync/scripts/scan_repos.py +687 -0
- skills/kb-sync/steps/step-01-detect.md +72 -0
- skills/kb-sync/steps/step-02-update.md +71 -0
- skills/kb-sync/steps/step-03-verify.md +47 -0
- skills/kb-sync/steps/step-04-finalize.md +52 -0
- source_kb-0.2.2.dist-info/METADATA +194 -0
- source_kb-0.2.2.dist-info/RECORD +228 -0
- source_kb-0.2.2.dist-info/WHEEL +5 -0
- source_kb-0.2.2.dist-info/entry_points.txt +3 -0
- source_kb-0.2.2.dist-info/licenses/LICENSE +21 -0
- 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 {}
|