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/loader.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Skeleton loading module — unified entry point for loading skeleton entries + metadata.
|
|
2
|
+
|
|
3
|
+
Wraps core.skeleton.file_list.load_skeleton() and adds metadata loading
|
|
4
|
+
from .meta/parser-info.json.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from core.skeleton.loader import load_skeleton_entries
|
|
8
|
+
|
|
9
|
+
entries, metadata = load_skeleton_entries(module_dir)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from core.paths import meta_dir, resolve_skeleton
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Public API
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_skeleton_entries(
|
|
31
|
+
module_dir: Path,
|
|
32
|
+
*,
|
|
33
|
+
max_shards: int = 50,
|
|
34
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
35
|
+
"""Load skeleton entries and associated metadata from a module directory.
|
|
36
|
+
|
|
37
|
+
Handles:
|
|
38
|
+
- New paths (.meta/skeleton/) and legacy paths (.skeleton.json / .skeleton/)
|
|
39
|
+
- Single file vs shards directory
|
|
40
|
+
- dict format (data/files/classes key) vs list format
|
|
41
|
+
- Metadata loading from .meta/parser-info.json
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
module_dir: Module knowledge base directory.
|
|
45
|
+
max_shards: Maximum number of shard files to read from a shards directory.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Tuple of (entries, metadata):
|
|
49
|
+
- entries: Flat list of skeleton file entry dicts.
|
|
50
|
+
- metadata: Dict with parser info, timestamp, entry count, total size, etc.
|
|
51
|
+
"""
|
|
52
|
+
resolved = resolve_skeleton(module_dir)
|
|
53
|
+
if resolved is None:
|
|
54
|
+
return [], _empty_metadata()
|
|
55
|
+
|
|
56
|
+
if resolved.is_dir():
|
|
57
|
+
entries, total_size = _load_from_dir(resolved, max_shards)
|
|
58
|
+
else:
|
|
59
|
+
entries, total_size = _load_from_file(resolved)
|
|
60
|
+
|
|
61
|
+
# Load parser metadata
|
|
62
|
+
metadata = _load_parser_metadata(module_dir)
|
|
63
|
+
metadata["entry_count"] = len(entries)
|
|
64
|
+
metadata["total_size_bytes"] = total_size
|
|
65
|
+
metadata["source_path"] = str(resolved)
|
|
66
|
+
|
|
67
|
+
return entries, metadata
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Internal: skeleton loading
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_from_file(path: Path) -> tuple[list[dict], int]:
|
|
76
|
+
"""Load skeleton from a single JSON file."""
|
|
77
|
+
try:
|
|
78
|
+
raw = path.read_text(encoding="utf-8")
|
|
79
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
80
|
+
logger.warning("Failed to read skeleton file %s: %s", path, e)
|
|
81
|
+
return [], 0
|
|
82
|
+
|
|
83
|
+
total_size = len(raw.encode("utf-8"))
|
|
84
|
+
entries = _parse_skeleton_data(raw)
|
|
85
|
+
return entries, total_size
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _load_from_dir(dir_path: Path, max_shards: int) -> tuple[list[dict], int]:
|
|
89
|
+
"""Load skeleton from a shards directory."""
|
|
90
|
+
entries: list[dict] = []
|
|
91
|
+
total_size = 0
|
|
92
|
+
|
|
93
|
+
for f in sorted(dir_path.glob("*.json"))[:max_shards]:
|
|
94
|
+
try:
|
|
95
|
+
raw = f.read_text(encoding="utf-8")
|
|
96
|
+
except (OSError, UnicodeDecodeError):
|
|
97
|
+
continue
|
|
98
|
+
total_size += len(raw.encode("utf-8"))
|
|
99
|
+
entries.extend(_parse_skeleton_data(raw))
|
|
100
|
+
|
|
101
|
+
return entries, total_size
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_skeleton_data(raw: str) -> list[dict]:
|
|
105
|
+
"""Parse skeleton JSON string, handling both dict and list formats."""
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(raw)
|
|
108
|
+
except (json.JSONDecodeError, ValueError):
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
if isinstance(data, list):
|
|
112
|
+
return data
|
|
113
|
+
if isinstance(data, dict):
|
|
114
|
+
for key in ("data", "files", "classes"):
|
|
115
|
+
if key in data:
|
|
116
|
+
val = data[key]
|
|
117
|
+
if isinstance(val, list):
|
|
118
|
+
return val
|
|
119
|
+
return []
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Internal: metadata loading
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _load_parser_metadata(module_dir: Path) -> dict[str, Any]:
|
|
129
|
+
"""Load parser metadata from .meta/parser-info.json if available."""
|
|
130
|
+
parser_info_path = meta_dir(module_dir) / "parser-info.json"
|
|
131
|
+
if not parser_info_path.exists():
|
|
132
|
+
return _empty_metadata()
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads(parser_info_path.read_text(encoding="utf-8"))
|
|
136
|
+
if not isinstance(data, dict):
|
|
137
|
+
return _empty_metadata()
|
|
138
|
+
return {
|
|
139
|
+
"parser_name": data.get("parser_name", "unknown"),
|
|
140
|
+
"parser_version": data.get("parser_version", ""),
|
|
141
|
+
"timestamp": data.get("timestamp", ""),
|
|
142
|
+
"parse_duration_ms": data.get("parse_duration_ms", 0),
|
|
143
|
+
"entry_count": data.get("entry_count", 0),
|
|
144
|
+
"total_size_bytes": data.get("total_size_bytes", 0),
|
|
145
|
+
"source_path": "",
|
|
146
|
+
}
|
|
147
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
148
|
+
logger.warning("Failed to read parser-info.json: %s", e)
|
|
149
|
+
return _empty_metadata()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _empty_metadata() -> dict[str, Any]:
|
|
153
|
+
"""Return empty metadata dict with default values."""
|
|
154
|
+
return {
|
|
155
|
+
"parser_name": "unknown",
|
|
156
|
+
"parser_version": "",
|
|
157
|
+
"timestamp": "",
|
|
158
|
+
"parse_duration_ms": 0,
|
|
159
|
+
"entry_count": 0,
|
|
160
|
+
"total_size_bytes": 0,
|
|
161
|
+
"source_path": "",
|
|
162
|
+
}
|
core/skeleton/merge.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Shard merge and post-merge refinement.
|
|
2
|
+
|
|
3
|
+
Fast (deterministic) merge of split document shards into single files.
|
|
4
|
+
Post-merge refinement: duplicate removal, term consistency, anchor addition.
|
|
5
|
+
|
|
6
|
+
LLM-based smart merge lives in cli/dedup.py (requires LlmStrategy).
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from core.skeleton.merge import find_shards, merge_shards, refine_merged_doc
|
|
10
|
+
|
|
11
|
+
shards = find_shards(module_dir, "business-logic")
|
|
12
|
+
content = merge_shards(shards)
|
|
13
|
+
result = refine_merged_doc(merged_path)
|
|
14
|
+
if result.changed:
|
|
15
|
+
result.apply()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Shard discovery and merge
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_shards(module_dir: Path, prefix: str) -> list[Path]:
|
|
31
|
+
"""Find and sort all shard files for a given doc type prefix."""
|
|
32
|
+
shards = sorted(module_dir.glob(f"{prefix}-*.md"))
|
|
33
|
+
if not shards:
|
|
34
|
+
shards = sorted(module_dir.glob(f".shard-{prefix}-*.md"))
|
|
35
|
+
return shards
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def merge_shards(shards: list[Path], order: list[str] | None = None) -> str:
|
|
39
|
+
"""Fast merge: concatenate shards, keeping first title only.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
shards: Ordered list of shard file paths
|
|
43
|
+
order: Optional explicit ordering by shard suffix names
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Merged markdown content
|
|
47
|
+
"""
|
|
48
|
+
if not shards:
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
if order:
|
|
52
|
+
shards = _reorder_shards(shards, order)
|
|
53
|
+
|
|
54
|
+
first = shards[0].read_text(encoding="utf-8")
|
|
55
|
+
lines = first.split("\n", 1)
|
|
56
|
+
title = lines[0] + "\n\n"
|
|
57
|
+
|
|
58
|
+
parts: list[str] = []
|
|
59
|
+
for shard in shards:
|
|
60
|
+
content = shard.read_text(encoding="utf-8")
|
|
61
|
+
# Remove first-level heading (keep only one)
|
|
62
|
+
content = re.sub(r'^#[^#].*\r?\n?', '', content, count=1)
|
|
63
|
+
parts.append(content.strip())
|
|
64
|
+
|
|
65
|
+
return title + "\n\n".join(parts) + "\n"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _reorder_shards(shards: list[Path], order: list[str]) -> list[Path]:
|
|
69
|
+
"""Reorder shards by explicit suffix list."""
|
|
70
|
+
ordered: list[Path] = []
|
|
71
|
+
remaining = list(shards)
|
|
72
|
+
for suffix in order:
|
|
73
|
+
for s in remaining:
|
|
74
|
+
if suffix in s.stem:
|
|
75
|
+
ordered.append(s)
|
|
76
|
+
remaining.remove(s)
|
|
77
|
+
break
|
|
78
|
+
ordered.extend(remaining)
|
|
79
|
+
return ordered
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Post-merge refinement (rule-based, no LLM)
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class RefineResult:
|
|
89
|
+
"""Result of post-merge refinement."""
|
|
90
|
+
original_path: Path
|
|
91
|
+
original_content: str
|
|
92
|
+
refined_content: str
|
|
93
|
+
issues: list[str] = field(default_factory=list)
|
|
94
|
+
duplicates_removed: int = 0
|
|
95
|
+
terms_fixed: int = 0
|
|
96
|
+
anchors_added: int = 0
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def changed(self) -> bool:
|
|
100
|
+
return self.refined_content != self.original_content
|
|
101
|
+
|
|
102
|
+
def apply(self) -> None:
|
|
103
|
+
"""Write refined content back to file."""
|
|
104
|
+
if self.changed:
|
|
105
|
+
self.original_path.write_text(self.refined_content, encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
def summary(self) -> str:
|
|
108
|
+
parts = []
|
|
109
|
+
if self.duplicates_removed:
|
|
110
|
+
parts.append(f"dedup {self.duplicates_removed}")
|
|
111
|
+
if self.terms_fixed:
|
|
112
|
+
parts.append(f"terms {self.terms_fixed}")
|
|
113
|
+
if self.anchors_added:
|
|
114
|
+
parts.append(f"anchors {self.anchors_added}")
|
|
115
|
+
return ", ".join(parts) if parts else "no changes"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def refine_merged_doc(
|
|
119
|
+
merged_path: Path,
|
|
120
|
+
term_aliases: dict[str, list[str]] | None = None,
|
|
121
|
+
) -> RefineResult:
|
|
122
|
+
"""Rule-based refinement: dedup paragraphs, fix terms, add anchors.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
merged_path: Path to the merged document
|
|
126
|
+
term_aliases: {standard_term: [alias1, alias2]} for consistency fixes
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
RefineResult with refined content and issue summary
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
content = merged_path.read_text(encoding="utf-8")
|
|
133
|
+
except (OSError, UnicodeDecodeError):
|
|
134
|
+
return RefineResult(original_path=merged_path, original_content="", refined_content="",
|
|
135
|
+
issues=["file read failed"])
|
|
136
|
+
|
|
137
|
+
result = RefineResult(original_path=merged_path, original_content=content, refined_content=content)
|
|
138
|
+
|
|
139
|
+
# Step 1: Remove duplicate paragraphs
|
|
140
|
+
result.refined_content, dups = _remove_duplicates(result.refined_content)
|
|
141
|
+
result.duplicates_removed = dups
|
|
142
|
+
if dups:
|
|
143
|
+
result.issues.append(f"{dups} duplicate paragraphs removed")
|
|
144
|
+
|
|
145
|
+
# Step 2: Term consistency
|
|
146
|
+
if term_aliases:
|
|
147
|
+
result.refined_content, fixes = _fix_terms(result.refined_content, term_aliases)
|
|
148
|
+
result.terms_fixed = fixes
|
|
149
|
+
if fixes:
|
|
150
|
+
result.issues.append(f"{fixes} term inconsistencies fixed")
|
|
151
|
+
|
|
152
|
+
# Step 3: Add missing anchors
|
|
153
|
+
result.refined_content, anchors = _add_anchors(result.refined_content)
|
|
154
|
+
result.anchors_added = anchors
|
|
155
|
+
if anchors:
|
|
156
|
+
result.issues.append(f"{anchors} anchors added")
|
|
157
|
+
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Internal helpers
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _normalize(text: str) -> str:
|
|
167
|
+
"""Normalize paragraph for comparison."""
|
|
168
|
+
text = re.sub(r'\s+', ' ', text.strip())
|
|
169
|
+
text = re.sub(r"[,。、;:\u201c\u201d\u2018\u2019()\[\]【】]", "", text)
|
|
170
|
+
return text.lower()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _remove_duplicates(content: str) -> tuple[str, int]:
|
|
174
|
+
"""Remove duplicate paragraphs (> 80 chars, keep first occurrence)."""
|
|
175
|
+
paragraphs = content.split("\n\n")
|
|
176
|
+
seen: set[str] = set()
|
|
177
|
+
result: list[str] = []
|
|
178
|
+
removed = 0
|
|
179
|
+
|
|
180
|
+
for para in paragraphs:
|
|
181
|
+
normalized = _normalize(para)
|
|
182
|
+
if len(normalized) < 80 or para.strip().startswith("#"):
|
|
183
|
+
result.append(para)
|
|
184
|
+
continue
|
|
185
|
+
if normalized in seen:
|
|
186
|
+
removed += 1
|
|
187
|
+
continue
|
|
188
|
+
seen.add(normalized)
|
|
189
|
+
result.append(para)
|
|
190
|
+
|
|
191
|
+
return "\n\n".join(result), removed
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _fix_terms(content: str, aliases: dict[str, list[str]]) -> tuple[str, int]:
|
|
195
|
+
"""Replace term aliases with standard terms."""
|
|
196
|
+
fixes = 0
|
|
197
|
+
for standard, alias_list in aliases.items():
|
|
198
|
+
for alias in alias_list:
|
|
199
|
+
if alias == standard:
|
|
200
|
+
continue
|
|
201
|
+
pattern = re.compile(r'(?<![a-zA-Z0-9_])' + re.escape(alias) + r'(?![a-zA-Z0-9_])')
|
|
202
|
+
content, n = pattern.subn(standard, content)
|
|
203
|
+
fixes += n
|
|
204
|
+
return content, fixes
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _add_anchors(content: str) -> tuple[str, int]:
|
|
208
|
+
"""Add HTML anchors to ## headings that don't have one."""
|
|
209
|
+
lines = content.splitlines()
|
|
210
|
+
added = 0
|
|
211
|
+
result: list[str] = []
|
|
212
|
+
|
|
213
|
+
for i, line in enumerate(lines):
|
|
214
|
+
if line.startswith("## ") and not line.startswith("### "):
|
|
215
|
+
title = line.lstrip("#").strip()
|
|
216
|
+
slug = re.sub(r'[^\w\u4e00-\u9fff-]', '-', title).strip('-').lower()
|
|
217
|
+
has_anchor = result and '<a id=' in result[-1]
|
|
218
|
+
if not has_anchor and slug:
|
|
219
|
+
result.append(f'<a id="{slug}"></a>')
|
|
220
|
+
added += 1
|
|
221
|
+
result.append(line)
|
|
222
|
+
|
|
223
|
+
return "\n".join(result), added
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# CLI entry point
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _cli_main() -> None:
|
|
232
|
+
"""CLI: merge shards in a module directory.
|
|
233
|
+
|
|
234
|
+
Usage:
|
|
235
|
+
python -X utf8 core/skeleton/merge.py --dir <module_dir>
|
|
236
|
+
python -X utf8 core/skeleton/merge.py --prefix business-logic --order shard1,shard2 --dir <module_dir>
|
|
237
|
+
"""
|
|
238
|
+
import argparse
|
|
239
|
+
import sys
|
|
240
|
+
|
|
241
|
+
parser = argparse.ArgumentParser(description="Merge shard documents in a module directory")
|
|
242
|
+
parser.add_argument("--dir", required=True, help="Module knowledge directory containing shard files")
|
|
243
|
+
parser.add_argument("--prefix", help="Only merge shards with this doc-type prefix (e.g. business-logic)")
|
|
244
|
+
parser.add_argument("--order", help="Comma-separated shard suffix ordering")
|
|
245
|
+
args = parser.parse_args()
|
|
246
|
+
|
|
247
|
+
module_dir = Path(args.dir)
|
|
248
|
+
if not module_dir.is_dir():
|
|
249
|
+
print(f"Error: directory not found: {module_dir}", file=sys.stderr)
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
|
|
252
|
+
order = args.order.split(",") if args.order else None
|
|
253
|
+
|
|
254
|
+
if args.prefix:
|
|
255
|
+
prefixes = [args.prefix]
|
|
256
|
+
else:
|
|
257
|
+
if order:
|
|
258
|
+
print("Warning: --order ignored without --prefix (order is prefix-specific)", file=sys.stderr)
|
|
259
|
+
order = None
|
|
260
|
+
shard_files = sorted(module_dir.glob("*-shard-*.md"))
|
|
261
|
+
prefixes = sorted({f.stem.rsplit("-shard-", 1)[0] for f in shard_files})
|
|
262
|
+
|
|
263
|
+
if not prefixes:
|
|
264
|
+
print("No shard files found.", file=sys.stderr)
|
|
265
|
+
sys.exit(0)
|
|
266
|
+
|
|
267
|
+
for prefix in prefixes:
|
|
268
|
+
shards = find_shards(module_dir, prefix)
|
|
269
|
+
if not shards:
|
|
270
|
+
continue
|
|
271
|
+
merged = merge_shards(shards, order=order)
|
|
272
|
+
out_path = module_dir / f"{prefix}.md"
|
|
273
|
+
out_path.write_text(merged, encoding="utf-8")
|
|
274
|
+
print(f"Merged {len(shards)} shards \u2192 {out_path.name} ({len(merged)} chars)")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
_cli_main()
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Skeleton delta merge — merges incremental skeleton updates into existing skeleton.
|
|
2
|
+
|
|
3
|
+
Handles both single-file (.skeleton.json) and sharded (.skeleton/ directory) formats.
|
|
4
|
+
Pure logic, no CLI I/O, no LLM calls.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from core.skeleton.merge_delta import merge_delta, merge_entries
|
|
8
|
+
|
|
9
|
+
stats = merge_delta(delta_path, module_dir)
|
|
10
|
+
print(f"Replaced: {stats.replaced}, Added: {stats.added}")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Data classes
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class MergeStats:
|
|
30
|
+
"""Statistics from a delta merge operation."""
|
|
31
|
+
|
|
32
|
+
replaced: int = 0
|
|
33
|
+
added: int = 0
|
|
34
|
+
unmatched: list[str] | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def total_changes(self) -> int:
|
|
38
|
+
return self.replaced + self.added
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Public API
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def merge_delta(
|
|
47
|
+
delta_path: Path,
|
|
48
|
+
module_dir: Path,
|
|
49
|
+
dry_run: bool = False,
|
|
50
|
+
cleanup: bool = True,
|
|
51
|
+
) -> MergeStats:
|
|
52
|
+
"""Merge a skeleton delta file into the existing skeleton.
|
|
53
|
+
|
|
54
|
+
Auto-detects target format (single file vs sharded directory).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
delta_path: Path to .skeleton-delta.json
|
|
58
|
+
module_dir: Module knowledge directory
|
|
59
|
+
dry_run: If True, compute stats without writing
|
|
60
|
+
cleanup: If True, delete delta file after successful merge
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
MergeStats with replaced/added counts
|
|
64
|
+
"""
|
|
65
|
+
from core.paths import resolve_skeleton
|
|
66
|
+
|
|
67
|
+
if not delta_path.exists():
|
|
68
|
+
logger.warning("Delta file not found: %s", delta_path)
|
|
69
|
+
return MergeStats()
|
|
70
|
+
|
|
71
|
+
delta_entries = load_json(delta_path)
|
|
72
|
+
if not delta_entries:
|
|
73
|
+
return MergeStats()
|
|
74
|
+
|
|
75
|
+
skel = resolve_skeleton(module_dir)
|
|
76
|
+
if skel is None:
|
|
77
|
+
logger.warning("No skeleton found in %s", module_dir)
|
|
78
|
+
return MergeStats()
|
|
79
|
+
|
|
80
|
+
if skel.is_dir():
|
|
81
|
+
stats = _merge_to_shards(skel, delta_entries, dry_run)
|
|
82
|
+
else:
|
|
83
|
+
stats = _merge_to_single(skel, delta_entries, dry_run)
|
|
84
|
+
|
|
85
|
+
if not dry_run and cleanup and delta_path.exists():
|
|
86
|
+
delta_path.unlink()
|
|
87
|
+
|
|
88
|
+
return stats
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def merge_entries(
|
|
92
|
+
existing: list[dict],
|
|
93
|
+
delta: list[dict],
|
|
94
|
+
) -> tuple[list[dict], int, int]:
|
|
95
|
+
"""Merge delta entries into existing entries by file path.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
existing: Current skeleton entries
|
|
99
|
+
delta: New/updated entries to merge
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
(merged_entries, replaced_count, added_count)
|
|
103
|
+
"""
|
|
104
|
+
index = {e["file"]: i for i, e in enumerate(existing) if "file" in e}
|
|
105
|
+
replaced = 0
|
|
106
|
+
added = 0
|
|
107
|
+
|
|
108
|
+
for entry in delta:
|
|
109
|
+
fp = entry.get("file", "")
|
|
110
|
+
if not fp:
|
|
111
|
+
continue
|
|
112
|
+
if fp in index:
|
|
113
|
+
existing[index[fp]] = entry
|
|
114
|
+
replaced += 1
|
|
115
|
+
else:
|
|
116
|
+
existing.append(entry)
|
|
117
|
+
index[fp] = len(existing) - 1
|
|
118
|
+
added += 1
|
|
119
|
+
|
|
120
|
+
return existing, replaced, added
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def infer_shard_name(filepath: str, shard_names: list[str]) -> str | None:
|
|
124
|
+
"""Infer which shard a file belongs to based on package path.
|
|
125
|
+
|
|
126
|
+
Shard filenames: com_example_app_service.json (package with underscores)
|
|
127
|
+
File paths: com/example/app/service/SomeClass.java
|
|
128
|
+
|
|
129
|
+
Matches the longest shard prefix.
|
|
130
|
+
"""
|
|
131
|
+
parts = filepath.replace("\\", "/").rsplit("/", 1)
|
|
132
|
+
if len(parts) < 2:
|
|
133
|
+
return None
|
|
134
|
+
dir_path = parts[0]
|
|
135
|
+
dir_key = dir_path.replace("/", "_")
|
|
136
|
+
|
|
137
|
+
best_match = None
|
|
138
|
+
best_length = 0
|
|
139
|
+
|
|
140
|
+
for shard in shard_names:
|
|
141
|
+
shard_key = shard.replace(".json", "")
|
|
142
|
+
if dir_key.startswith(shard_key) and len(shard_key) > best_length:
|
|
143
|
+
best_match = shard
|
|
144
|
+
best_length = len(shard_key)
|
|
145
|
+
|
|
146
|
+
return best_match
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# File I/O helpers
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_json(path: Path) -> list[dict]:
|
|
155
|
+
"""Load skeleton JSON, normalize to entry list."""
|
|
156
|
+
try:
|
|
157
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
158
|
+
except (OSError, ValueError) as e:
|
|
159
|
+
logger.warning("Failed to load JSON %s: %s", path, e)
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
if isinstance(data, list):
|
|
163
|
+
return data
|
|
164
|
+
return data.get("data", data.get("files", []))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def save_json(path: Path, entries: list[dict]) -> None:
|
|
168
|
+
"""Save skeleton entries as JSON."""
|
|
169
|
+
path.write_text(
|
|
170
|
+
json.dumps(entries, ensure_ascii=False, indent=1),
|
|
171
|
+
encoding="utf-8",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Internal merge implementations
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _merge_to_single(skel_path: Path, delta_entries: list[dict], dry_run: bool) -> MergeStats:
|
|
181
|
+
"""Merge delta into a single skeleton file."""
|
|
182
|
+
existing = load_json(skel_path)
|
|
183
|
+
merged, replaced, added = merge_entries(existing, delta_entries)
|
|
184
|
+
|
|
185
|
+
if not dry_run:
|
|
186
|
+
save_json(skel_path, merged)
|
|
187
|
+
|
|
188
|
+
return MergeStats(replaced=replaced, added=added)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _merge_to_shards(shard_dir: Path, delta_entries: list[dict], dry_run: bool) -> MergeStats:
|
|
192
|
+
"""Merge delta into sharded skeleton directory."""
|
|
193
|
+
shard_files = sorted(shard_dir.glob("*.json"))
|
|
194
|
+
shard_names = [f.name for f in shard_files]
|
|
195
|
+
|
|
196
|
+
if not shard_names:
|
|
197
|
+
logger.warning("Skeleton shard directory is empty: %s", shard_dir)
|
|
198
|
+
return MergeStats()
|
|
199
|
+
|
|
200
|
+
# Group delta entries by target shard
|
|
201
|
+
shard_groups: dict[str, list[dict]] = {}
|
|
202
|
+
unmatched: list[str] = []
|
|
203
|
+
|
|
204
|
+
for entry in delta_entries:
|
|
205
|
+
fp = entry.get("file", "")
|
|
206
|
+
shard = infer_shard_name(fp, shard_names)
|
|
207
|
+
if shard:
|
|
208
|
+
shard_groups.setdefault(shard, []).append(entry)
|
|
209
|
+
else:
|
|
210
|
+
unmatched.append(fp)
|
|
211
|
+
|
|
212
|
+
total_replaced = 0
|
|
213
|
+
total_added = 0
|
|
214
|
+
|
|
215
|
+
for shard_name, entries in shard_groups.items():
|
|
216
|
+
shard_path = shard_dir / shard_name
|
|
217
|
+
existing = load_json(shard_path)
|
|
218
|
+
merged, replaced, added = merge_entries(existing, entries)
|
|
219
|
+
total_replaced += replaced
|
|
220
|
+
total_added += added
|
|
221
|
+
|
|
222
|
+
if not dry_run:
|
|
223
|
+
save_json(shard_path, merged)
|
|
224
|
+
|
|
225
|
+
return MergeStats(
|
|
226
|
+
replaced=total_replaced,
|
|
227
|
+
added=total_added,
|
|
228
|
+
unmatched=unmatched if unmatched else None,
|
|
229
|
+
)
|