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/validators/links.py
ADDED
|
@@ -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
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")
|