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,76 @@
1
+ """Link integrity validator — dead link and anchor detection.
2
+
3
+ Checks all markdown links in documents:
4
+ - File links: [text](./file.md) → verify file exists
5
+ - Anchor links: [text](./file.md#heading) → verify heading exists in target
6
+
7
+ Usage:
8
+ from core.validators.links import LinksValidator
9
+ result = LinksValidator().validate(module_dir)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from core.interfaces import Validator, ValidationResult
19
+ from core.validators import register_validator
20
+
21
+ LINK_PATTERN = re.compile(r'\[([^\]]*)\]\((\./[^)]+)\)')
22
+
23
+
24
+ @register_validator
25
+ class LinksValidator(Validator):
26
+ """Check markdown link integrity."""
27
+
28
+ name = "links"
29
+
30
+ def validate(self, module_dir: Path, **kwargs: Any) -> ValidationResult:
31
+ result = ValidationResult()
32
+
33
+ for md_file in sorted(module_dir.glob("*.md")):
34
+ if md_file.name.startswith("."):
35
+ continue
36
+ content = md_file.read_text(encoding="utf-8")
37
+
38
+ for match in LINK_PATTERN.finditer(content):
39
+ link_text = match.group(1)
40
+ link_target = match.group(2)
41
+
42
+ # Split path and anchor
43
+ if "#" in link_target:
44
+ file_part, anchor = link_target.rsplit("#", 1)
45
+ else:
46
+ file_part, anchor = link_target, None
47
+
48
+ # Resolve target file
49
+ target = (md_file.parent / file_part).resolve()
50
+ if not target.exists():
51
+ result.errors.append(
52
+ f"links: {md_file.name} → dead link '{link_target}' (file not found)"
53
+ )
54
+ continue
55
+
56
+ # Check anchor if present
57
+ if anchor and target.suffix == ".md":
58
+ target_content = target.read_text(encoding="utf-8")
59
+ if not _has_heading(target_content, anchor):
60
+ result.warnings.append(
61
+ f"links: {md_file.name} → anchor '#{anchor}' not found in {target.name}"
62
+ )
63
+
64
+ return result
65
+
66
+
67
+ def _has_heading(content: str, anchor: str) -> bool:
68
+ """Check if a document contains a heading matching the anchor slug."""
69
+ anchor_lower = anchor.lower()
70
+ for line in content.splitlines():
71
+ if line.startswith("#"):
72
+ heading_text = line.lstrip("#").strip()
73
+ slug = re.sub(r'[^\w\u4e00-\u9fff-]', '-', heading_text).strip('-').lower()
74
+ if slug == anchor_lower:
75
+ return True
76
+ return False
@@ -0,0 +1,169 @@
1
+ """Numeric sampling validator — spot-check values from skeleton in docs.
2
+
3
+ Validates that skeleton-derived values (enum names, field names) actually appear
4
+ in generated documents. Catches fabricated content.
5
+
6
+ The actual sampling logic is now in core/validators/engine.py (sampling_check handler).
7
+ This module provides the SamplingValidator class for the registry and the optional
8
+ LLM-enhanced semantic check.
9
+
10
+ Usage:
11
+ from core.validators.sampling import SamplingValidator
12
+ result = SamplingValidator().validate(module_dir, preset=preset)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import random
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from core.interfaces import Validator, ValidationResult
24
+ from core.validators import register_validator
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @register_validator
30
+ class SamplingValidator(Validator):
31
+ """Spot-check skeleton values against generated documents.
32
+
33
+ Delegates to the validator engine for preset-configured sampling checks.
34
+ Falls back to generic field_names sampling if no validators configured.
35
+ """
36
+
37
+ name = "sampling"
38
+
39
+ def validate(self, module_dir: Path, **kwargs: Any) -> ValidationResult:
40
+ result = ValidationResult()
41
+ preset = kwargs.get("preset", {})
42
+
43
+ from core.paths import resolve_skeleton_summary
44
+ summary_file = resolve_skeleton_summary(module_dir)
45
+ if not summary_file:
46
+ return result
47
+
48
+ try:
49
+ entries = json.loads(summary_file.read_text(encoding="utf-8"))
50
+ except (OSError, json.JSONDecodeError):
51
+ return result
52
+
53
+ # Delegate to engine for preset-configured validators
54
+ from core.validators.engine import run_doc_validators
55
+ doc_types = preset.get("doc_types", {})
56
+ for dt_key, dt_config in doc_types.items():
57
+ if not isinstance(dt_config, dict):
58
+ continue
59
+ validators = dt_config.get("validators", [])
60
+ sampling_validators = [v for v in validators if v.get("type") == "sampling_check"]
61
+ if not sampling_validators:
62
+ continue
63
+ filename = dt_config.get("filename", f"{dt_key}.md")
64
+ doc_path = module_dir / filename
65
+ if doc_path.exists():
66
+ engine_result = run_doc_validators(doc_path, dt_key, preset, entries)
67
+ result = result.merge(engine_result)
68
+
69
+ return result
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # LLM-enhanced semantic validation (optional)
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def llm_semantic_check(
78
+ module_dir: Path,
79
+ entries: list[dict],
80
+ preset: dict | None = None,
81
+ strategy: Any = None,
82
+ sample_count: int = 3,
83
+ ) -> list[str]:
84
+ """Use LLM to verify that document descriptions match source code reality.
85
+
86
+ Picks a few enum values or field names, extracts their description from docs,
87
+ and asks LLM to verify accuracy against skeleton data.
88
+
89
+ Args:
90
+ module_dir: Module documentation directory
91
+ entries: Skeleton entries
92
+ preset: Preset config (for resolving doc filenames)
93
+ strategy: LlmStrategy instance (None = skip)
94
+ sample_count: Number of items to verify
95
+
96
+ Returns:
97
+ List of warning strings for inaccurate descriptions.
98
+ Empty if no LLM available or all descriptions are accurate.
99
+ """
100
+ if strategy is None:
101
+ return []
102
+
103
+ # Sample enum values with their skeleton context
104
+ enum_samples = []
105
+ for entry in entries:
106
+ for cls in entry.get("classes", []):
107
+ if cls.get("type", "").lower() == "enum":
108
+ for m in entry.get("methods", []):
109
+ if m.get("name", "").isupper():
110
+ enum_samples.append({
111
+ "name": m["name"],
112
+ "file": entry.get("file", ""),
113
+ "class": cls.get("name", ""),
114
+ })
115
+
116
+ if not enum_samples:
117
+ return []
118
+
119
+ samples = random.sample(enum_samples, min(sample_count, len(enum_samples)))
120
+
121
+ # Resolve enum doc filename from preset
122
+ enum_filename = "enums-and-constants.md"
123
+ if preset:
124
+ try:
125
+ from core.preset import get_doc_filename
126
+ fn = get_doc_filename(preset, "enums-and-constants", strict=False)
127
+ if fn:
128
+ enum_filename = fn
129
+ except Exception:
130
+ pass
131
+
132
+ enum_doc = module_dir / enum_filename
133
+ if not enum_doc.exists():
134
+ return []
135
+ doc_content = enum_doc.read_text(encoding="utf-8")
136
+
137
+ # Build verification prompt
138
+ checks = []
139
+ for s in samples:
140
+ idx = doc_content.find(s["name"])
141
+ if idx < 0:
142
+ checks.append(f"- `{s['class']}.{s['name']}`: not found in doc")
143
+ continue
144
+ context = doc_content[max(0, idx-200):idx+200]
145
+ checks.append(f"- `{s['class']}.{s['name']}` (from {s['file']})\n Doc excerpt: {context[:150]}")
146
+
147
+ if not checks:
148
+ return []
149
+
150
+ from core.interfaces import LlmRequest
151
+ try:
152
+ resp = strategy.call(LlmRequest(
153
+ system=(
154
+ "You are a documentation accuracy reviewer. "
155
+ "Check whether the following enum values are accurately described in the document. "
156
+ "Only output items with issues, format: `enum_name: issue description`. "
157
+ "If all are accurate, output 'all accurate'."
158
+ ),
159
+ user="\n".join(checks),
160
+ max_tokens=500,
161
+ temperature=0.1,
162
+ ))
163
+ except Exception:
164
+ return []
165
+
166
+ if resp.status != "done" or not resp.content or "all accurate" in resp.content.lower():
167
+ return []
168
+
169
+ return [line.strip() for line in resp.content.strip().splitlines() if line.strip()]
@@ -0,0 +1,144 @@
1
+ """Structure validator — document heading and section checks.
2
+
3
+ Validates document structure: heading levels, required sections, format rules.
4
+ Migrated from engine/skeleton/validate_output.py.
5
+
6
+ Usage:
7
+ from core.validators.structure import StructureValidator
8
+ result = StructureValidator().validate(module_dir, preset=preset)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from core.interfaces import Validator, ValidationResult
18
+ from core.validators import register_validator
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Default section requirements (overridden by preset section_checks)
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ @register_validator
26
+ class StructureValidator(Validator):
27
+ """Validate document heading structure and required sections."""
28
+
29
+ name = "structure"
30
+
31
+ def validate(self, module_dir: Path, **kwargs: Any) -> ValidationResult:
32
+ result = ValidationResult()
33
+ preset = kwargs.get("preset", {})
34
+ section_checks = _load_section_checks(preset)
35
+
36
+ for md_file in sorted(module_dir.glob("*.md")):
37
+ if md_file.name.startswith("."):
38
+ continue
39
+ headings = extract_headings(md_file)
40
+ _check_heading_structure(md_file.name, headings, result)
41
+ _check_required_sections(md_file.name, headings, section_checks, result)
42
+
43
+ return result
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Heading extraction (code-block and front-matter aware)
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def extract_headings(path: Path) -> list[dict]:
52
+ """Extract markdown headings, skipping code blocks and front-matter.
53
+
54
+ Returns list of {level, text, line}.
55
+ """
56
+ headings: list[dict] = []
57
+ try:
58
+ lines = path.read_text(encoding="utf-8").splitlines()
59
+ except OSError:
60
+ return headings
61
+
62
+ in_code_block = False
63
+ in_frontmatter = False
64
+
65
+ for i, line in enumerate(lines, 1):
66
+ stripped = line.strip()
67
+ # Front-matter detection (only at file start)
68
+ if i == 1 and stripped == "---":
69
+ in_frontmatter = True
70
+ continue
71
+ if in_frontmatter:
72
+ if stripped == "---":
73
+ in_frontmatter = False
74
+ continue
75
+ # Code block detection
76
+ if stripped.startswith("```") or stripped.startswith("~~~"):
77
+ in_code_block = not in_code_block
78
+ continue
79
+ if in_code_block:
80
+ continue
81
+ m = re.match(r"^(#{1,4})\s+(.+)", line)
82
+ if m:
83
+ headings.append({
84
+ "level": len(m.group(1)),
85
+ "text": m.group(2).strip(),
86
+ "line": i,
87
+ })
88
+ return headings
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Validation helpers
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def _check_heading_structure(
97
+ fname: str, headings: list[dict], result: ValidationResult
98
+ ) -> None:
99
+ """Check heading level rules: single H1, H1 first, no skipped levels."""
100
+ if not headings:
101
+ result.errors.append(f"structure: {fname}: no headings found")
102
+ return
103
+
104
+ if headings[0]["level"] != 1:
105
+ result.warnings.append(f"structure: {fname}: first heading is not H1")
106
+
107
+ h1s = [h for h in headings if h["level"] == 1]
108
+ if len(h1s) > 1:
109
+ result.warnings.append(
110
+ f"structure: {fname}: multiple H1 headings ({len(h1s)}), expected 1"
111
+ )
112
+
113
+
114
+ def _check_required_sections(
115
+ fname: str,
116
+ headings: list[dict],
117
+ section_checks: dict[str, dict],
118
+ result: ValidationResult,
119
+ ) -> None:
120
+ """Check must_have and should_have section keywords."""
121
+ rules = section_checks.get(fname)
122
+ if not rules:
123
+ return
124
+
125
+ h2_texts = " ".join(h["text"] for h in headings if h["level"] <= 2)
126
+
127
+ for keyword_spec in rules.get("must_have", []):
128
+ aliases = keyword_spec if isinstance(keyword_spec, list) else [keyword_spec]
129
+ if not any(kw in h2_texts for kw in aliases):
130
+ result.errors.append(
131
+ f"structure: {fname}: missing required section keyword '{aliases[0]}'"
132
+ )
133
+
134
+ for keyword_spec in rules.get("should_have", []):
135
+ aliases = keyword_spec if isinstance(keyword_spec, list) else [keyword_spec]
136
+ if not any(kw in h2_texts for kw in aliases):
137
+ result.warnings.append(
138
+ f"structure: {fname}: recommended section keyword '{aliases[0]}' not found"
139
+ )
140
+
141
+
142
+ def _load_section_checks(preset: dict) -> dict[str, dict]:
143
+ """Load section checks from preset config."""
144
+ return preset.get("section_checks", {})
engine/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """cli — CLI mode execution layer for source-kb.
2
+
3
+ Assembles core/ tools into executable pipelines with LLM API integration,
4
+ batch execution, retry, heartbeat monitoring, and interactive confirmation.
5
+
6
+ Entry point: python -m cli.main init --kb my-kb
7
+ """
engine/assembler.py ADDED
@@ -0,0 +1,231 @@
1
+ """Inline prompt assembler — CLI mode (output-only).
2
+
3
+ Reads source files and inlines their content into prompts.
4
+ Respects byte limits and priority ordering (high-complexity files first).
5
+
6
+ This is the CLI-specific PromptAssembler implementation.
7
+ Agent mode uses ReferenceAssembler (built into core/prompt/renderer.py).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ from pathlib import Path
15
+
16
+ from core.interfaces import PromptAssembler
17
+ from core.paths import resolve_file_list, resolve_skeleton, resolve_skeleton_summary
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Configurable limits (loaded from config in production)
22
+ DEFAULT_MAX_SOURCE_BYTES = 300_000
23
+ DEFAULT_MAX_SKELETON_BYTES = 50_000
24
+
25
+
26
+ class InlinePromptAssembler(PromptAssembler):
27
+ """Inlines source code and skeleton content directly into prompts.
28
+
29
+ Used in CLI mode where the LLM cannot read files itself.
30
+ """
31
+
32
+ def __init__(self, max_source_bytes: int = DEFAULT_MAX_SOURCE_BYTES,
33
+ max_skeleton_bytes: int = DEFAULT_MAX_SKELETON_BYTES,
34
+ preset: dict | None = None):
35
+ limits = preset.get("limits", {}) if preset else {}
36
+ self._max_source = limits.get("max_source_inline_bytes", max_source_bytes)
37
+ self._max_skeleton = limits.get("max_skeleton_inline_bytes", max_skeleton_bytes)
38
+ self._global_view_types = self._load_global_view_types(preset)
39
+
40
+ @staticmethod
41
+ def _load_global_view_types(preset: dict | None) -> set[str]:
42
+ """Load global_view doc types from preset config dynamically."""
43
+ if not preset:
44
+ return {"source-tree-analysis", "index"}
45
+ from core.preset import get_global_view_types
46
+ return set(get_global_view_types(preset))
47
+
48
+ def resolve_file_list(self, module_dir: Path, doc_type: str,
49
+ file_list_override: str | None = None) -> str:
50
+ """Return full file list content (inlined).
51
+
52
+ For global_view doc types, returns skeleton content instead of file paths.
53
+ """
54
+ # Global_View_Doc: LLM needs skeleton content, not file path list
55
+ if doc_type in self._global_view_types:
56
+ return self._inline_skeleton_for_global_view(module_dir)
57
+
58
+ # Shard override: read the override file directly
59
+ if file_list_override:
60
+ override_path = Path(file_list_override)
61
+ if override_path.exists():
62
+ content = override_path.read_text(encoding="utf-8").strip()
63
+ return content if content else "(no matching files)"
64
+
65
+ fl = resolve_file_list(module_dir, doc_type)
66
+ if fl is None:
67
+ return "(file list does not exist)"
68
+ content = fl.read_text(encoding="utf-8").strip()
69
+ return content if content else "(no matching files)"
70
+
71
+ def _inline_skeleton_for_global_view(self, module_dir: Path) -> str:
72
+ """Inline skeleton summary content for global_view doc types in CLI mode."""
73
+ summary = resolve_skeleton_summary(module_dir)
74
+ if summary and summary.exists():
75
+ try:
76
+ raw = summary.read_text(encoding="utf-8")
77
+ size_kb = len(raw.encode("utf-8")) / 1024
78
+ if size_kb <= self._max_skeleton / 1024:
79
+ return f"Below is the skeleton summary content ({size_kb:.1f}KB), generate the document based on this:\n\n```json\n{raw}\n```"
80
+ # Truncate if too large
81
+ truncated = raw.encode("utf-8")[:self._max_skeleton].decode("utf-8", errors="ignore")
82
+ return f"Below is the skeleton summary content (truncated to {self._max_skeleton//1024}KB):\n\n```json\n{truncated}\n```\n[skeleton truncated]"
83
+ except (OSError, UnicodeDecodeError):
84
+ pass
85
+ # Fallback to full skeleton
86
+ resolved = resolve_skeleton(module_dir)
87
+ if resolved and resolved.exists():
88
+ try:
89
+ raw = resolved.read_text(encoding="utf-8")
90
+ if len(raw.encode("utf-8")) <= self._max_skeleton:
91
+ return f"Below is the skeleton content:\n\n```json\n{raw}\n```"
92
+ except (OSError, UnicodeDecodeError):
93
+ pass
94
+ return "(skeleton does not exist, cannot generate global view document)"
95
+
96
+ def resolve_source_content(self, module_dir: Path, doc_type: str, source_cache: Path) -> str:
97
+ """Read and inline source files with priority ordering.
98
+
99
+ For global_view doc types, returns empty (skeleton is the input, not source).
100
+ """
101
+ # Global_View_Doc: skeleton is the input, not source files
102
+ if doc_type in self._global_view_types:
103
+ return ""
104
+ fl = resolve_file_list(module_dir, doc_type)
105
+ if fl is None or not fl.exists():
106
+ return ""
107
+
108
+ file_paths = [l.strip() for l in fl.read_text(encoding="utf-8").splitlines()
109
+ if l.strip() and not l.strip().startswith("#")]
110
+ if not file_paths:
111
+ return ""
112
+
113
+ return self._inline_files(file_paths, source_cache, module_dir)
114
+
115
+ def resolve_source_content_from_paths(
116
+ self, module_dir: Path, doc_type: str, source_cache: Path, file_paths: list[str]
117
+ ) -> str:
118
+ """Read and inline source files from an explicit file path list (shard override)."""
119
+ if doc_type in self._global_view_types:
120
+ return ""
121
+ cleaned = [l.strip() for l in file_paths if l.strip() and not l.strip().startswith("#")]
122
+ if not cleaned:
123
+ return ""
124
+ return self._inline_files(cleaned, source_cache, module_dir)
125
+
126
+ def _inline_files(self, file_paths: list[str], source_cache: Path, module_dir: Path) -> str:
127
+ """Shared logic to inline source files with byte limit."""
128
+
129
+ # Build priority map from skeleton
130
+ priority = _build_priority_map(module_dir)
131
+ source_str = str(source_cache.resolve())
132
+
133
+ def sort_key(fpath: str):
134
+ rel = fpath
135
+ if source_str in fpath:
136
+ rel = fpath[len(source_str):].lstrip("/\\")
137
+ info = priority.get(rel, {})
138
+ return (-info.get("high_methods", 0), -info.get("total_lines", 0), fpath)
139
+
140
+ file_paths.sort(key=sort_key)
141
+
142
+ # Read and concatenate with byte limit
143
+ parts: list[str] = []
144
+ total_bytes = 0
145
+ max_single = self._max_source // 6
146
+
147
+ for fpath in file_paths:
148
+ src = source_cache / fpath if not Path(fpath).is_absolute() else Path(fpath)
149
+ if not src.exists():
150
+ continue
151
+ try:
152
+ content = src.read_text(encoding="utf-8")
153
+ except (OSError, UnicodeDecodeError):
154
+ continue
155
+
156
+ if len(content.encode("utf-8")) > max_single:
157
+ lines = content.splitlines(keepends=True)
158
+ truncated = []
159
+ acc = 0
160
+ for line in lines:
161
+ acc += len(line.encode("utf-8"))
162
+ if acc > max_single:
163
+ break
164
+ truncated.append(line)
165
+ content = "".join(truncated) + f"\n// [truncated: {len(truncated)}/{len(lines)} lines]\n"
166
+
167
+ ext = Path(fpath).suffix.lstrip(".")
168
+ block = f"### {Path(fpath).name}\n```{ext or 'text'}\n{content}\n```\n"
169
+ block_bytes = len(block.encode("utf-8"))
170
+
171
+ if total_bytes + block_bytes > self._max_source:
172
+ remaining = len(file_paths) - len(parts)
173
+ parts.append(f"\n[truncated — {remaining} files omitted]\n")
174
+ break
175
+
176
+ parts.append(block)
177
+ total_bytes += block_bytes
178
+
179
+ return "\n".join(parts)
180
+
181
+ def resolve_skeleton_content(self, module_dir: Path) -> str:
182
+ """Read skeleton content (summary preferred for large skeletons)."""
183
+ resolved = resolve_skeleton(module_dir)
184
+ if resolved is None:
185
+ return "(skeleton does not exist)"
186
+
187
+ # Try full skeleton first
188
+ if resolved.is_file():
189
+ try:
190
+ raw = resolved.read_text(encoding="utf-8")
191
+ if len(raw.encode("utf-8")) <= self._max_skeleton:
192
+ return f"```json\n{raw}\n```"
193
+ except (OSError, UnicodeDecodeError):
194
+ pass
195
+
196
+ # Fallback to summary
197
+ summary = resolve_skeleton_summary(module_dir)
198
+ if summary:
199
+ try:
200
+ raw = summary.read_text(encoding="utf-8")
201
+ if len(raw.encode("utf-8")) <= self._max_skeleton:
202
+ return f"```json\n{raw}\n```"
203
+ truncated = raw.encode("utf-8")[:self._max_skeleton].decode("utf-8", errors="ignore")
204
+ return f"```json\n{truncated}\n```\n[skeleton truncated]"
205
+ except (OSError, UnicodeDecodeError):
206
+ pass
207
+
208
+ return "(skeleton too large and no summary available)"
209
+
210
+ def should_append_source(self) -> bool:
211
+ """CLI mode appends source as appendix if not already in template."""
212
+ return True
213
+
214
+
215
+ def _build_priority_map(module_dir: Path) -> dict[str, dict]:
216
+ """Build file priority map from skeleton summary."""
217
+ summary = resolve_skeleton_summary(module_dir)
218
+ if not summary:
219
+ return {}
220
+ try:
221
+ entries = json.loads(summary.read_text(encoding="utf-8"))
222
+ except (OSError, ValueError):
223
+ return {}
224
+
225
+ priority: dict[str, dict] = {}
226
+ for entry in entries:
227
+ fpath = entry.get("file", "")
228
+ if fpath:
229
+ high = len(entry.get("high_complexity_methods", []))
230
+ priority[fpath] = {"high_methods": high, "total_lines": entry.get("total_lines", 0)}
231
+ return priority
engine/confirm.py ADDED
@@ -0,0 +1,65 @@
1
+ """Interactive confirmation — dispatch plan review and rule approval.
2
+
3
+ Provides terminal-based confirmation prompts for CLI pipeline gates.
4
+ Supports --no-confirm bypass for CI/automation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+
12
+ def confirm_dispatch_plan(plan_text: str, auto_confirm: bool = False) -> bool:
13
+ """Display dispatch plan and wait for user confirmation.
14
+
15
+ Args:
16
+ plan_text: Formatted dispatch plan to display
17
+ auto_confirm: If True, skip confirmation (--no-confirm mode)
18
+
19
+ Returns:
20
+ True if confirmed, False if rejected
21
+ """
22
+ if auto_confirm:
23
+ return True
24
+
25
+ print("\n" + "=" * 70)
26
+ print(" Dispatch Preview (Gate 3A)")
27
+ print("=" * 70)
28
+ print(plan_text)
29
+ print("=" * 70)
30
+ print()
31
+
32
+ try:
33
+ answer = input("Confirm execution? (y/yes to continue, anything else to abort): ").strip().lower()
34
+ except (EOFError, KeyboardInterrupt):
35
+ return False
36
+
37
+ return answer in ("y", "yes")
38
+
39
+
40
+ def confirm_classification_rules(rules_text: str, auto_confirm: bool = False) -> bool:
41
+ """Display proposed classification rules and wait for confirmation.
42
+
43
+ Args:
44
+ rules_text: Formatted rules to display
45
+ auto_confirm: If True, skip confirmation
46
+
47
+ Returns:
48
+ True if user approves writing rules to custom_rules.yaml
49
+ """
50
+ if auto_confirm:
51
+ return True
52
+
53
+ print("\n" + "-" * 50)
54
+ print(" LLM Classification Rule Suggestions")
55
+ print("-" * 50)
56
+ print(rules_text)
57
+ print("-" * 50)
58
+ print()
59
+
60
+ try:
61
+ answer = input("Write to custom_rules.yaml? (y/yes to confirm, n/no for this run only): ").strip().lower()
62
+ except (EOFError, KeyboardInterrupt):
63
+ return False
64
+
65
+ return answer in ("y", "yes")