source-kb 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. cli/__init__.py +50 -0
  2. cli/__main__.py +5 -0
  3. cli/commands/__init__.py +1 -0
  4. cli/commands/anchor_fix.py +47 -0
  5. cli/commands/diff_doc.py +52 -0
  6. cli/commands/dispatch.py +77 -0
  7. cli/commands/extract.py +72 -0
  8. cli/commands/file_list.py +74 -0
  9. cli/commands/index.py +84 -0
  10. cli/commands/lock.py +89 -0
  11. cli/commands/merge.py +60 -0
  12. cli/commands/merge_delta.py +19 -0
  13. cli/commands/metadata.py +24 -0
  14. cli/commands/pipeline.py +45 -0
  15. cli/commands/post_merge.py +43 -0
  16. cli/commands/query.py +52 -0
  17. cli/commands/render.py +101 -0
  18. cli/commands/scan_repos.py +46 -0
  19. cli/commands/setup.py +94 -0
  20. cli/commands/split.py +196 -0
  21. cli/commands/stale_files.py +98 -0
  22. cli/commands/validate.py +191 -0
  23. core/__init__.py +32 -0
  24. core/config.py +261 -0
  25. core/docs/__init__.py +7 -0
  26. core/docs/section_updater.py +286 -0
  27. core/docs/shared.py +149 -0
  28. core/git.py +294 -0
  29. core/interfaces.py +249 -0
  30. core/monitor/__init__.py +5 -0
  31. core/monitor/progress.py +83 -0
  32. core/monitor/prompt_store.py +49 -0
  33. core/paths.py +141 -0
  34. core/preset.py +237 -0
  35. core/preset_accessors.py +202 -0
  36. core/preset_classify.py +132 -0
  37. core/preset_hooks.py +129 -0
  38. core/preset_profile.py +89 -0
  39. core/prompt/__init__.py +7 -0
  40. core/prompt/__main__.py +147 -0
  41. core/prompt/content.py +320 -0
  42. core/prompt/context_manager.py +164 -0
  43. core/prompt/renderer.py +236 -0
  44. core/prompt/response_parser.py +274 -0
  45. core/prompt/templates.py +357 -0
  46. core/prompt/validate_parity.py +162 -0
  47. core/prompt/variables.py +339 -0
  48. core/rag/__init__.py +22 -0
  49. core/rag/__main__.py +136 -0
  50. core/rag/bm25_index.py +268 -0
  51. core/rag/chunker.py +273 -0
  52. core/rag/embedder.py +151 -0
  53. core/rag/indexer.py +292 -0
  54. core/rag/loader.py +89 -0
  55. core/rag/retriever.py +82 -0
  56. core/skeleton/__init__.py +11 -0
  57. core/skeleton/__main__.py +934 -0
  58. core/skeleton/anchor_fix.py +250 -0
  59. core/skeleton/classify.py +331 -0
  60. core/skeleton/cmd_anchor_fix.py +43 -0
  61. core/skeleton/cmd_diff_doc.py +44 -0
  62. core/skeleton/cmd_lock.py +87 -0
  63. core/skeleton/cmd_merge_delta.py +41 -0
  64. core/skeleton/community.py +233 -0
  65. core/skeleton/dependency_graph.py +306 -0
  66. core/skeleton/diff_doc.py +248 -0
  67. core/skeleton/dispatch.py +273 -0
  68. core/skeleton/dispatch_render.py +319 -0
  69. core/skeleton/dispatch_source.py +111 -0
  70. core/skeleton/extract.py +218 -0
  71. core/skeleton/extract_methods.py +298 -0
  72. core/skeleton/file_list.py +239 -0
  73. core/skeleton/impact.py +278 -0
  74. core/skeleton/jar_download.py +177 -0
  75. core/skeleton/jar_resolver.py +186 -0
  76. core/skeleton/loader.py +162 -0
  77. core/skeleton/merge.py +278 -0
  78. core/skeleton/merge_delta.py +229 -0
  79. core/skeleton/metadata.py +96 -0
  80. core/skeleton/metadata_builders.py +264 -0
  81. core/skeleton/module_dag.py +330 -0
  82. core/skeleton/parsers/__init__.py +71 -0
  83. core/skeleton/parsers/jqassistant.py +300 -0
  84. core/skeleton/parsers/jqassistant_cypher.py +225 -0
  85. core/skeleton/parsers/regex.py +171 -0
  86. core/skeleton/parsers/treesitter.py +324 -0
  87. core/skeleton/parsers/treesitter_java.py +284 -0
  88. core/skeleton/parsers/treesitter_multi.py +289 -0
  89. core/skeleton/pom_parser.py +299 -0
  90. core/skeleton/post_merge.py +295 -0
  91. core/skeleton/post_merge_llm.py +82 -0
  92. core/skeleton/query.py +195 -0
  93. core/skeleton/shard_context.py +177 -0
  94. core/skeleton/split.py +180 -0
  95. core/skeleton/split_cache.py +107 -0
  96. core/skeleton/split_feedback.py +174 -0
  97. core/skeleton/split_plan.py +219 -0
  98. core/skeleton/split_plan_helpers.py +305 -0
  99. core/skeleton/split_plan_llm.py +274 -0
  100. core/utils.py +135 -0
  101. core/validators/__init__.py +65 -0
  102. core/validators/__main__.py +215 -0
  103. core/validators/consistency.py +203 -0
  104. core/validators/coverage.py +171 -0
  105. core/validators/duplicates.py +76 -0
  106. core/validators/engine.py +224 -0
  107. core/validators/links.py +76 -0
  108. core/validators/sampling.py +169 -0
  109. core/validators/structure.py +144 -0
  110. engine/__init__.py +7 -0
  111. engine/assembler.py +231 -0
  112. engine/confirm.py +65 -0
  113. engine/dedup.py +106 -0
  114. engine/main.py +211 -0
  115. engine/pipeline/__init__.py +163 -0
  116. engine/pipeline/recovery.py +250 -0
  117. engine/pipeline/steps/__init__.py +23 -0
  118. engine/pipeline/steps/audit.py +220 -0
  119. engine/pipeline/steps/audit_apply.py +195 -0
  120. engine/pipeline/steps/audit_helpers.py +155 -0
  121. engine/pipeline/steps/classify_llm.py +236 -0
  122. engine/pipeline/steps/classify_prompt.py +223 -0
  123. engine/pipeline/steps/finalize.py +160 -0
  124. engine/pipeline/steps/generate.py +169 -0
  125. engine/pipeline/steps/generate_batch.py +197 -0
  126. engine/pipeline/steps/generate_recovery.py +170 -0
  127. engine/pipeline/steps/llm_plan_split.py +253 -0
  128. engine/pipeline/steps/lock.py +64 -0
  129. engine/pipeline/steps/preflight.py +237 -0
  130. engine/pipeline/steps/preflight_adjust.py +147 -0
  131. engine/pipeline/steps/pregenerate.py +130 -0
  132. engine/pipeline/steps/quality.py +81 -0
  133. engine/pipeline/steps/skeleton.py +149 -0
  134. engine/pipeline/steps/source.py +163 -0
  135. engine/pipeline/steps/sync.py +117 -0
  136. engine/pipeline/steps/sync_finalize.py +237 -0
  137. engine/pipeline/steps/sync_update.py +341 -0
  138. engine/pipelines.py +91 -0
  139. engine/runner.py +335 -0
  140. engine/strategies/__init__.py +86 -0
  141. engine/strategies/api.py +128 -0
  142. engine/strategies/delegated.py +50 -0
  143. engine/strategies/dryrun.py +25 -0
  144. engine/two_phase.py +143 -0
  145. mcp_server/__init__.py +73 -0
  146. mcp_server/__main__.py +5 -0
  147. mcp_server/tools/__init__.py +1 -0
  148. mcp_server/tools/config.py +63 -0
  149. mcp_server/tools/discovery.py +276 -0
  150. mcp_server/tools/generation.py +184 -0
  151. mcp_server/tools/planning.py +144 -0
  152. mcp_server/tools/source.py +175 -0
  153. mcp_server/tools/validation.py +140 -0
  154. mcp_server/tools/workflow.py +166 -0
  155. mcp_server/workflow_loader.py +204 -0
  156. presets/generic/audit_dimensions.md +132 -0
  157. presets/generic/doc_types.yaml +152 -0
  158. presets/generic/preset.yaml +115 -0
  159. presets/java-spring/audit_dimensions.md +228 -0
  160. presets/java-spring/audit_dimensions.yaml +203 -0
  161. presets/java-spring/doc_types.yaml +269 -0
  162. presets/java-spring/hooks.py +122 -0
  163. presets/java-spring/preset.yaml +341 -0
  164. presets/java-spring/templates/README.md +34 -0
  165. presets/java-spring/templates/audit-system.md +15 -0
  166. presets/java-spring/templates/subagent-aop.md +105 -0
  167. presets/java-spring/templates/subagent-api.md +63 -0
  168. presets/java-spring/templates/subagent-architecture.md +111 -0
  169. presets/java-spring/templates/subagent-async-events.md +107 -0
  170. presets/java-spring/templates/subagent-audit-api-contracts.md +40 -0
  171. presets/java-spring/templates/subagent-audit-architecture.md +38 -0
  172. presets/java-spring/templates/subagent-audit-business.md +40 -0
  173. presets/java-spring/templates/subagent-audit-data-models.md +40 -0
  174. presets/java-spring/templates/subagent-business.md +129 -0
  175. presets/java-spring/templates/subagent-caching.md +75 -0
  176. presets/java-spring/templates/subagent-database-access.md +114 -0
  177. presets/java-spring/templates/subagent-enum.md +75 -0
  178. presets/java-spring/templates/subagent-error-handling.md +91 -0
  179. presets/java-spring/templates/subagent-external-integrations.md +80 -0
  180. presets/java-spring/templates/subagent-index.md +122 -0
  181. presets/java-spring/templates/subagent-messaging.md +97 -0
  182. presets/java-spring/templates/subagent-model.md +88 -0
  183. presets/java-spring/templates/subagent-observability.md +91 -0
  184. presets/java-spring/templates/subagent-scheduled.md +81 -0
  185. presets/java-spring/templates/subagent-security.md +102 -0
  186. presets/java-spring/templates/subagent-structure.md +101 -0
  187. presets/java-spring/templates/subagent-sync-section.md +34 -0
  188. presets/java-spring/templates/subagent-utils.md +73 -0
  189. presets/java-spring/templates/sync-system.md +8 -0
  190. presets/java-spring/workflow-extensions.md +112 -0
  191. skills/__init__.py +1 -0
  192. skills/_shared/README.md +30 -0
  193. skills/_shared/doc-coverage-shared.md +134 -0
  194. skills/_shared/doc-quality-standard.md +1058 -0
  195. skills/_shared/doc-subagent-rules.md +762 -0
  196. skills/_shared/windows-compat.md +89 -0
  197. skills/kb-audit/SKILL.md +52 -0
  198. skills/kb-audit/rules.md +88 -0
  199. skills/kb-audit/steps/step-01-prepare.md +75 -0
  200. skills/kb-audit/steps/step-02-audit.md +96 -0
  201. skills/kb-audit/steps/step-03-verify.md +65 -0
  202. skills/kb-audit/steps/step-04-report.md +64 -0
  203. skills/kb-init/SKILL.md +146 -0
  204. skills/kb-init/rules.md +187 -0
  205. skills/kb-init/steps/step-01-scope.md +62 -0
  206. skills/kb-init/steps/step-02-source.md +410 -0
  207. skills/kb-init/steps/step-03-generate.md +307 -0
  208. skills/kb-init/steps/step-04-quality.md +92 -0
  209. skills/kb-init/steps/step-05-finalize.md +132 -0
  210. skills/kb-init/templates/core/execution-modes.md +29 -0
  211. skills/kb-init/templates/core/output-only.md +4 -0
  212. skills/kb-init/templates/core/readwrite.md +33 -0
  213. skills/kb-search/SKILL.md +138 -0
  214. skills/kb-search/rules.md +64 -0
  215. skills/kb-sync/SKILL.md +43 -0
  216. skills/kb-sync/rules.md +70 -0
  217. skills/kb-sync/scripts/rebuild_module.py +91 -0
  218. skills/kb-sync/scripts/scan_repos.py +687 -0
  219. skills/kb-sync/steps/step-01-detect.md +72 -0
  220. skills/kb-sync/steps/step-02-update.md +71 -0
  221. skills/kb-sync/steps/step-03-verify.md +47 -0
  222. skills/kb-sync/steps/step-04-finalize.md +52 -0
  223. source_kb-0.2.2.dist-info/METADATA +194 -0
  224. source_kb-0.2.2.dist-info/RECORD +228 -0
  225. source_kb-0.2.2.dist-info/WHEEL +5 -0
  226. source_kb-0.2.2.dist-info/entry_points.txt +3 -0
  227. source_kb-0.2.2.dist-info/licenses/LICENSE +21 -0
  228. source_kb-0.2.2.dist-info/top_level.txt +6 -0
@@ -0,0 +1,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
+ )