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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Consistency validator — reference path and code snippet checks.
|
|
2
|
+
|
|
3
|
+
Validates that document references (file paths, code snippets) are consistent
|
|
4
|
+
with actual source code. Migrated from engine/skeleton/validate_output.py.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from core.validators.consistency import ConsistencyValidator
|
|
8
|
+
result = ConsistencyValidator().validate(module_dir, source_cache=path)
|
|
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
|
+
# Pattern to match file path references in docs (e.g., `src/main/java/...`)
|
|
21
|
+
_PATH_REF_PATTERN = re.compile(
|
|
22
|
+
r"`((?:src|main|java|com|org|net)/[a-zA-Z0-9_/.-]+\.(?:java|kt|py|go|ts|js))`"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Pattern to match markdown links to local files
|
|
26
|
+
_LINK_PATTERN = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
|
27
|
+
|
|
28
|
+
# Pattern to match code block language hints
|
|
29
|
+
_CODE_BLOCK_PATTERN = re.compile(r"^```(\w+)?")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@register_validator
|
|
33
|
+
class ConsistencyValidator(Validator):
|
|
34
|
+
"""Validate reference paths and code snippet consistency."""
|
|
35
|
+
|
|
36
|
+
name = "consistency"
|
|
37
|
+
|
|
38
|
+
def validate(self, module_dir: Path, **kwargs: Any) -> ValidationResult:
|
|
39
|
+
result = ValidationResult()
|
|
40
|
+
source_cache = kwargs.get("source_cache")
|
|
41
|
+
skeleton_entries = kwargs.get("skeleton_entries")
|
|
42
|
+
|
|
43
|
+
for md_file in sorted(module_dir.glob("*.md")):
|
|
44
|
+
if md_file.name.startswith("."):
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
content = md_file.read_text(encoding="utf-8")
|
|
48
|
+
except OSError:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
# 1. Check cross-document link validity
|
|
52
|
+
_check_cross_doc_links(md_file, module_dir, content, result)
|
|
53
|
+
|
|
54
|
+
# 2. Check source path references (if source_cache provided)
|
|
55
|
+
if source_cache:
|
|
56
|
+
_check_path_references(
|
|
57
|
+
md_file.name, content, Path(source_cache), result
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# 3. Check code snippet consistency (if skeleton available)
|
|
61
|
+
if skeleton_entries:
|
|
62
|
+
_check_code_snippets(
|
|
63
|
+
md_file.name, content, skeleton_entries, result
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Cross-document link checks
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_cross_doc_links(
|
|
75
|
+
md_file: Path, module_dir: Path, content: str, result: ValidationResult
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Verify that local markdown links point to existing files."""
|
|
78
|
+
for match in _LINK_PATTERN.finditer(content):
|
|
79
|
+
target = match.group(2)
|
|
80
|
+
# Skip external links and anchors-only
|
|
81
|
+
if target.startswith("http") or target.startswith("#"):
|
|
82
|
+
continue
|
|
83
|
+
# Strip anchor part
|
|
84
|
+
file_part = target.split("#")[0]
|
|
85
|
+
if not file_part:
|
|
86
|
+
continue
|
|
87
|
+
# Only check .md links
|
|
88
|
+
if not file_part.endswith(".md"):
|
|
89
|
+
continue
|
|
90
|
+
link_path = module_dir / file_part
|
|
91
|
+
if not link_path.exists():
|
|
92
|
+
result.warnings.append(
|
|
93
|
+
f"consistency: {md_file.name}: broken link to '{file_part}'"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Source path reference checks
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_path_references(
|
|
103
|
+
fname: str, content: str, source_cache: Path, result: ValidationResult
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Verify that referenced source file paths exist in source cache."""
|
|
106
|
+
refs = _PATH_REF_PATTERN.findall(content)
|
|
107
|
+
if not refs:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Sample up to 10 references to avoid excessive I/O
|
|
111
|
+
sample = refs[:10] if len(refs) > 10 else refs
|
|
112
|
+
missing_count = 0
|
|
113
|
+
|
|
114
|
+
for ref_path in sample:
|
|
115
|
+
# Try to find the file in source_cache (search subdirectories)
|
|
116
|
+
found = _find_in_source_cache(ref_path, source_cache)
|
|
117
|
+
if not found:
|
|
118
|
+
missing_count += 1
|
|
119
|
+
|
|
120
|
+
if missing_count > 0:
|
|
121
|
+
result.warnings.append(
|
|
122
|
+
f"consistency: {fname}: {missing_count}/{len(sample)} "
|
|
123
|
+
f"referenced source paths not found in source cache"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _find_in_source_cache(ref_path: str, source_cache: Path) -> bool:
|
|
128
|
+
"""Check if a referenced path exists somewhere under source_cache."""
|
|
129
|
+
# Direct check
|
|
130
|
+
if (source_cache / ref_path).exists():
|
|
131
|
+
return True
|
|
132
|
+
# Check under any subdirectory (module repos)
|
|
133
|
+
for subdir in source_cache.iterdir():
|
|
134
|
+
if subdir.is_dir() and (subdir / ref_path).exists():
|
|
135
|
+
return True
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Code snippet consistency checks
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_code_snippets(
|
|
145
|
+
fname: str, content: str, skeleton_entries: list[dict], result: ValidationResult
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Verify code snippets reference real methods/classes from skeleton."""
|
|
148
|
+
# Extract method names mentioned in code blocks
|
|
149
|
+
code_methods = _extract_code_block_identifiers(content)
|
|
150
|
+
if not code_methods:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# Build set of known method/class names from skeleton
|
|
154
|
+
known_names = _build_known_names(skeleton_entries)
|
|
155
|
+
if not known_names:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Check that at least some code block identifiers match skeleton
|
|
159
|
+
matched = sum(1 for m in code_methods if m in known_names)
|
|
160
|
+
total = len(code_methods)
|
|
161
|
+
|
|
162
|
+
if total >= 5 and matched == 0:
|
|
163
|
+
result.warnings.append(
|
|
164
|
+
f"consistency: {fname}: code snippets contain {total} identifiers "
|
|
165
|
+
f"but none match skeleton data"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _extract_code_block_identifiers(content: str) -> list[str]:
|
|
170
|
+
"""Extract method/class-like identifiers from code blocks."""
|
|
171
|
+
identifiers: list[str] = []
|
|
172
|
+
in_code = False
|
|
173
|
+
for line in content.splitlines():
|
|
174
|
+
stripped = line.strip()
|
|
175
|
+
if _CODE_BLOCK_PATTERN.match(stripped):
|
|
176
|
+
in_code = not in_code
|
|
177
|
+
continue
|
|
178
|
+
if in_code:
|
|
179
|
+
# Look for method declarations or class names
|
|
180
|
+
m = re.search(
|
|
181
|
+
r"\b(?:public|private|protected)?\s*(?:static\s+)?(?:\w+\s+)?(\w+)\s*\(",
|
|
182
|
+
stripped,
|
|
183
|
+
)
|
|
184
|
+
if m:
|
|
185
|
+
name = m.group(1)
|
|
186
|
+
if name and len(name) > 2 and name[0].islower():
|
|
187
|
+
identifiers.append(name)
|
|
188
|
+
return identifiers
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _build_known_names(entries: list[dict]) -> set[str]:
|
|
192
|
+
"""Build set of known method and class names from skeleton."""
|
|
193
|
+
names: set[str] = set()
|
|
194
|
+
for entry in entries:
|
|
195
|
+
for cls in entry.get("classes", []):
|
|
196
|
+
name = cls.get("name", "")
|
|
197
|
+
if name:
|
|
198
|
+
names.add(name)
|
|
199
|
+
for method in entry.get("methods", []):
|
|
200
|
+
name = method.get("name", "")
|
|
201
|
+
if name:
|
|
202
|
+
names.add(name)
|
|
203
|
+
return names
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Coverage validator — file and method coverage calculation.
|
|
2
|
+
|
|
3
|
+
Validates that generated documents cover the expected source files and methods
|
|
4
|
+
based on skeleton data. Migrated from engine/skeleton/validate_output.py.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from core.validators.coverage import CoverageValidator
|
|
8
|
+
result = CoverageValidator().validate(module_dir, preset=preset, skeleton_entries=entries)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from core.interfaces import Validator, ValidationResult
|
|
20
|
+
from core.validators import register_validator
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_size_limits(preset: dict | None = None) -> dict[str, dict[str, int]]:
|
|
26
|
+
"""Get document size expectations from preset config.
|
|
27
|
+
|
|
28
|
+
Reads from each doc_type's `size_expectations` field if available,
|
|
29
|
+
then falls back to preset-level `size_expectations`.
|
|
30
|
+
Returns empty dict if no preset provided.
|
|
31
|
+
"""
|
|
32
|
+
result: dict[str, dict[str, int]] = {}
|
|
33
|
+
|
|
34
|
+
if not preset:
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
# Layer 1: preset-level size_expectations (backward compat)
|
|
38
|
+
if "size_expectations" in preset:
|
|
39
|
+
for filename, limits in preset["size_expectations"].items():
|
|
40
|
+
if isinstance(limits, dict):
|
|
41
|
+
result[filename] = {**result.get(filename, {}), **limits}
|
|
42
|
+
|
|
43
|
+
# Layer 2: per-doc-type size_expectations (preferred, more granular)
|
|
44
|
+
doc_types = preset.get("doc_types", {})
|
|
45
|
+
for dt_key, dt_config in doc_types.items():
|
|
46
|
+
if not isinstance(dt_config, dict):
|
|
47
|
+
continue
|
|
48
|
+
if "size_expectations" in dt_config and "filename" in dt_config:
|
|
49
|
+
filename = dt_config["filename"]
|
|
50
|
+
limits = dt_config["size_expectations"]
|
|
51
|
+
if isinstance(limits, dict):
|
|
52
|
+
result[filename] = {**result.get(filename, {}), **limits}
|
|
53
|
+
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@register_validator
|
|
58
|
+
class CoverageValidator(Validator):
|
|
59
|
+
"""Validate file completeness and size coverage."""
|
|
60
|
+
|
|
61
|
+
name = "coverage"
|
|
62
|
+
|
|
63
|
+
def validate(self, module_dir: Path, **kwargs: Any) -> ValidationResult:
|
|
64
|
+
result = ValidationResult()
|
|
65
|
+
preset = kwargs.get("preset", {})
|
|
66
|
+
module_type = kwargs.get("module_type", "service")
|
|
67
|
+
size_limits = get_size_limits(preset)
|
|
68
|
+
|
|
69
|
+
skip_list = _get_module_type_skip(preset, module_type)
|
|
70
|
+
if "*" in skip_list:
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
existing_files = {f.name for f in module_dir.glob("*.md")}
|
|
74
|
+
|
|
75
|
+
# 1. File completeness check
|
|
76
|
+
required = _get_required_files(preset)
|
|
77
|
+
_check_file_completeness(existing_files, skip_list, required, result)
|
|
78
|
+
|
|
79
|
+
# 2. Size coverage check per file
|
|
80
|
+
for md_file in sorted(module_dir.glob("*.md")):
|
|
81
|
+
if md_file.name.startswith("."):
|
|
82
|
+
continue
|
|
83
|
+
_check_file_size(md_file, size_limits, result)
|
|
84
|
+
|
|
85
|
+
# 3. Preset-configured validators (skeleton_count_match, sampling, etc.)
|
|
86
|
+
skeleton_entries = kwargs.get("skeleton_entries")
|
|
87
|
+
if skeleton_entries:
|
|
88
|
+
from core.validators.engine import run_doc_validators
|
|
89
|
+
doc_types = preset.get("doc_types", {})
|
|
90
|
+
for dt_key, dt_config in doc_types.items():
|
|
91
|
+
if not isinstance(dt_config, dict):
|
|
92
|
+
continue
|
|
93
|
+
filename = dt_config.get("filename", f"{dt_key}.md")
|
|
94
|
+
doc_path = module_dir / filename
|
|
95
|
+
if doc_path.exists() and dt_config.get("validators"):
|
|
96
|
+
engine_result = run_doc_validators(
|
|
97
|
+
doc_path, dt_key, preset, skeleton_entries
|
|
98
|
+
)
|
|
99
|
+
result = result.merge(engine_result)
|
|
100
|
+
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Validation helpers
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _check_file_completeness(
|
|
110
|
+
existing: set[str], skip_list: list[str], required: list[str], result: ValidationResult
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Check that all required files exist."""
|
|
113
|
+
for fname in required:
|
|
114
|
+
if fname in skip_list:
|
|
115
|
+
continue
|
|
116
|
+
if fname not in existing:
|
|
117
|
+
result.errors.append(f"coverage: missing required file: {fname}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _get_required_files(preset: dict) -> list[str]:
|
|
121
|
+
"""Get required files from preset doc_types (conditional=false entries)."""
|
|
122
|
+
doc_types = preset.get("doc_types", {})
|
|
123
|
+
if not doc_types:
|
|
124
|
+
return []
|
|
125
|
+
required = []
|
|
126
|
+
for dt_key, dt_config in doc_types.items():
|
|
127
|
+
if isinstance(dt_config, dict) and not dt_config.get("conditional", True):
|
|
128
|
+
filename = dt_config.get("filename", f"{dt_key}.md")
|
|
129
|
+
required.append(filename)
|
|
130
|
+
return required
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_module_type_skip(preset: dict, module_type: str) -> list[str]:
|
|
134
|
+
"""Get skip list from preset module_types config."""
|
|
135
|
+
module_types = preset.get("module_types", {})
|
|
136
|
+
if module_types and module_type in module_types:
|
|
137
|
+
return list(module_types[module_type].get("skip", []))
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_file_size(
|
|
142
|
+
md_file: Path, size_limits: dict[str, dict[str, int]], result: ValidationResult
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Check file line count against size limits."""
|
|
145
|
+
fname = md_file.name
|
|
146
|
+
limits = size_limits.get(fname)
|
|
147
|
+
if not limits:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
lines = len(md_file.read_text(encoding="utf-8").splitlines())
|
|
152
|
+
except OSError:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
min_lines = limits.get("min", 0)
|
|
156
|
+
hard_max = limits.get("hard_max", 999999)
|
|
157
|
+
warn_max = limits.get("warn_max", hard_max)
|
|
158
|
+
|
|
159
|
+
if min_lines and lines < min_lines:
|
|
160
|
+
result.errors.append(
|
|
161
|
+
f"coverage: {fname}: too short ({lines} lines < {min_lines} minimum)"
|
|
162
|
+
)
|
|
163
|
+
elif lines > hard_max:
|
|
164
|
+
result.warnings.append(
|
|
165
|
+
f"coverage: {fname}: too large ({lines} lines > {hard_max}), consider splitting"
|
|
166
|
+
)
|
|
167
|
+
elif lines > warn_max:
|
|
168
|
+
result.warnings.append(
|
|
169
|
+
f"coverage: {fname}: approaching size limit ({lines} lines)"
|
|
170
|
+
)
|
|
171
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Duplicate content detector — rule-based cross-doc redundancy check.
|
|
2
|
+
|
|
3
|
+
Detects content that belongs in one document but appears in another,
|
|
4
|
+
using ownership keywords defined in preset doc_types config.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from core.validators.duplicates import DuplicatesValidator
|
|
8
|
+
result = DuplicatesValidator().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
|
+
# Minimum doc size to trigger duplicate check (KB)
|
|
21
|
+
MIN_SIZE_KB = 50
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@register_validator
|
|
25
|
+
class DuplicatesValidator(Validator):
|
|
26
|
+
"""Detect cross-document content duplication using preset ownership keywords."""
|
|
27
|
+
|
|
28
|
+
name = "duplicates"
|
|
29
|
+
|
|
30
|
+
def validate(self, module_dir: Path, **kwargs: Any) -> ValidationResult:
|
|
31
|
+
result = ValidationResult()
|
|
32
|
+
|
|
33
|
+
# Get ownership keywords from preset config
|
|
34
|
+
preset = kwargs.get("preset", {})
|
|
35
|
+
ownership = _get_ownership_signals(preset)
|
|
36
|
+
if not ownership:
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
# Check business-logic.md (most common source of duplication)
|
|
40
|
+
# and any other large docs against ownership rules
|
|
41
|
+
for md_file in sorted(module_dir.glob("*.md")):
|
|
42
|
+
if md_file.name.startswith("."):
|
|
43
|
+
continue
|
|
44
|
+
if md_file.stat().st_size < MIN_SIZE_KB * 1024:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
content = md_file.read_text(encoding="utf-8")
|
|
48
|
+
|
|
49
|
+
for owner_doc, keywords in ownership.items():
|
|
50
|
+
if md_file.name == owner_doc:
|
|
51
|
+
continue # Don't check a doc against itself
|
|
52
|
+
|
|
53
|
+
for keyword in keywords:
|
|
54
|
+
matches = re.findall(re.escape(keyword), content, re.IGNORECASE)
|
|
55
|
+
if len(matches) > 3:
|
|
56
|
+
result.warnings.append(
|
|
57
|
+
f"duplicates: {md_file.name} contains {len(matches)} instances of "
|
|
58
|
+
f"'{keyword}' (belongs in {owner_doc})"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_ownership_signals(preset: dict) -> dict[str, list[str]]:
|
|
65
|
+
"""Extract ownership keywords from preset doc_types config.
|
|
66
|
+
|
|
67
|
+
Returns {doc_filename: [keyword1, keyword2, ...]}
|
|
68
|
+
"""
|
|
69
|
+
if not preset:
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
from core.preset import get_ownership_keywords
|
|
74
|
+
return get_ownership_keywords(preset)
|
|
75
|
+
except (ImportError, Exception):
|
|
76
|
+
return {}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Validator engine — configurable, preset-driven document validation.
|
|
2
|
+
|
|
3
|
+
Dispatches validators declared in doc_types.yaml. Each doc_type can declare
|
|
4
|
+
a list of validators with type + params. The engine looks up the handler
|
|
5
|
+
from a registry and runs it.
|
|
6
|
+
|
|
7
|
+
Built-in validator types:
|
|
8
|
+
- skeleton_count_match: Compare skeleton annotation count vs doc heading count
|
|
9
|
+
- sampling_check: Spot-check values from skeleton appear in doc
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from core.validators.engine import run_doc_validators
|
|
13
|
+
results = run_doc_validators(doc_path, doc_type, preset, skeleton_entries)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import random
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
from core.interfaces import ValidationResult
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
ValidatorHandler = Callable[..., ValidationResult]
|
|
30
|
+
|
|
31
|
+
_HANDLER_REGISTRY: dict[str, ValidatorHandler] = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_handler(name: str):
|
|
35
|
+
"""Decorator to register a validator handler function."""
|
|
36
|
+
def decorator(fn: ValidatorHandler) -> ValidatorHandler:
|
|
37
|
+
_HANDLER_REGISTRY[name] = fn
|
|
38
|
+
return fn
|
|
39
|
+
return decorator
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_doc_validators(
|
|
43
|
+
doc_path: Path,
|
|
44
|
+
doc_type: str,
|
|
45
|
+
preset: dict,
|
|
46
|
+
skeleton_entries: list[dict] | None = None,
|
|
47
|
+
) -> ValidationResult:
|
|
48
|
+
"""Run all validators configured for this doc_type in preset.
|
|
49
|
+
|
|
50
|
+
Reads validators from preset doc_types[doc_type].validators list.
|
|
51
|
+
Each entry has {type, params}. Dispatches to registered handlers.
|
|
52
|
+
"""
|
|
53
|
+
combined = ValidationResult()
|
|
54
|
+
|
|
55
|
+
from core.preset import get_doc_type_config
|
|
56
|
+
dt_config = get_doc_type_config(preset, doc_type)
|
|
57
|
+
validators = dt_config.get("validators", [])
|
|
58
|
+
|
|
59
|
+
if not validators:
|
|
60
|
+
return combined
|
|
61
|
+
|
|
62
|
+
for v_spec in validators:
|
|
63
|
+
v_type = v_spec.get("type", "")
|
|
64
|
+
params = v_spec.get("params", {})
|
|
65
|
+
|
|
66
|
+
handler = _HANDLER_REGISTRY.get(v_type)
|
|
67
|
+
if not handler:
|
|
68
|
+
logger.warning("unknown validator type '%s' for doc_type=%s", v_type, doc_type)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = handler(
|
|
73
|
+
doc_path=doc_path,
|
|
74
|
+
skeleton_entries=skeleton_entries or [],
|
|
75
|
+
**params,
|
|
76
|
+
)
|
|
77
|
+
combined = combined.merge(result)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
combined.warnings.append(f"validator:{v_type}: {e}")
|
|
80
|
+
|
|
81
|
+
return combined
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Built-in validator handlers
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@register_handler("skeleton_count_match")
|
|
90
|
+
def _skeleton_count_match(
|
|
91
|
+
doc_path: Path,
|
|
92
|
+
skeleton_entries: list[dict],
|
|
93
|
+
*,
|
|
94
|
+
annotations: list[str] | None = None,
|
|
95
|
+
skeleton_field: str = "controllers",
|
|
96
|
+
doc_heading_level: int = 2,
|
|
97
|
+
min_ratio: float = 0.5,
|
|
98
|
+
**_kwargs: Any,
|
|
99
|
+
) -> ValidationResult:
|
|
100
|
+
"""Compare count of annotated classes in skeleton vs heading count in doc.
|
|
101
|
+
|
|
102
|
+
Params:
|
|
103
|
+
annotations: List of annotation strings to match (e.g. ["@RestController"])
|
|
104
|
+
skeleton_field: Label for the count (used in messages)
|
|
105
|
+
doc_heading_level: Heading level to count in doc (default H2)
|
|
106
|
+
min_ratio: Minimum ratio of headings/annotations before warning
|
|
107
|
+
"""
|
|
108
|
+
result = ValidationResult()
|
|
109
|
+
|
|
110
|
+
if not doc_path.exists():
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
# Count annotated classes in skeleton
|
|
114
|
+
anno_count = 0
|
|
115
|
+
if annotations:
|
|
116
|
+
for entry in skeleton_entries:
|
|
117
|
+
for cls in entry.get("classes", []):
|
|
118
|
+
annos_str = " ".join(
|
|
119
|
+
a.get("name", "") if isinstance(a, dict) else str(a)
|
|
120
|
+
for a in cls.get("annotations", [])
|
|
121
|
+
)
|
|
122
|
+
if any(anno in annos_str for anno in annotations):
|
|
123
|
+
anno_count += 1
|
|
124
|
+
|
|
125
|
+
if anno_count == 0:
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
# Count headings in doc
|
|
129
|
+
try:
|
|
130
|
+
content = doc_path.read_text(encoding="utf-8")
|
|
131
|
+
except OSError:
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
heading_pattern = re.compile(r"^#{" + str(doc_heading_level) + r"}\s+", re.MULTILINE)
|
|
135
|
+
heading_count = len(heading_pattern.findall(content))
|
|
136
|
+
|
|
137
|
+
if heading_count > 0:
|
|
138
|
+
ratio = heading_count / anno_count
|
|
139
|
+
if ratio < min_ratio:
|
|
140
|
+
result.warnings.append(
|
|
141
|
+
f"skeleton_count_match: {doc_path.name}: "
|
|
142
|
+
f"H{doc_heading_level} sections ({heading_count}) much fewer than "
|
|
143
|
+
f"skeleton {skeleton_field} ({anno_count})"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@register_handler("sampling_check")
|
|
150
|
+
def _sampling_check(
|
|
151
|
+
doc_path: Path,
|
|
152
|
+
skeleton_entries: list[dict],
|
|
153
|
+
*,
|
|
154
|
+
source: str = "field_names",
|
|
155
|
+
sample_size: int = 5,
|
|
156
|
+
**_kwargs: Any,
|
|
157
|
+
) -> ValidationResult:
|
|
158
|
+
"""Spot-check that values from skeleton appear in the document.
|
|
159
|
+
|
|
160
|
+
Params:
|
|
161
|
+
source: What to sample — "enum_values" or "field_names"
|
|
162
|
+
sample_size: Number of items to sample
|
|
163
|
+
"""
|
|
164
|
+
result = ValidationResult()
|
|
165
|
+
|
|
166
|
+
if not doc_path.exists():
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
content = doc_path.read_text(encoding="utf-8")
|
|
171
|
+
except OSError:
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
extractor = _EXTRACTORS.get(source)
|
|
175
|
+
if not extractor:
|
|
176
|
+
logger.warning("unknown sampling source '%s'", source)
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
values = extractor(skeleton_entries)
|
|
180
|
+
if not values:
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
samples = random.sample(values, min(sample_size, len(values)))
|
|
184
|
+
for val in samples:
|
|
185
|
+
if val not in content:
|
|
186
|
+
result.warnings.append(
|
|
187
|
+
f"sampling_check: '{val}' (from {source}) not found in {doc_path.name}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Extractors for sampling_check
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _extract_enum_values(entries: list[dict]) -> list[str]:
|
|
199
|
+
"""Extract enum constant names from skeleton entries."""
|
|
200
|
+
values: list[str] = []
|
|
201
|
+
for entry in entries:
|
|
202
|
+
for cls in entry.get("classes", []):
|
|
203
|
+
if cls.get("type", "").lower() == "enum":
|
|
204
|
+
for m in entry.get("methods", []):
|
|
205
|
+
if m.get("name", "").isupper():
|
|
206
|
+
values.append(m["name"])
|
|
207
|
+
return values
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _extract_field_names(entries: list[dict]) -> list[str]:
|
|
211
|
+
"""Extract field names from skeleton entries."""
|
|
212
|
+
names: list[str] = []
|
|
213
|
+
for entry in entries:
|
|
214
|
+
for field in entry.get("fields", []):
|
|
215
|
+
name = field.get("name", "")
|
|
216
|
+
if name and len(name) > 2:
|
|
217
|
+
names.append(name)
|
|
218
|
+
return names
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_EXTRACTORS: dict[str, Callable[[list[dict]], list[str]]] = {
|
|
222
|
+
"enum_values": _extract_enum_values,
|
|
223
|
+
"field_names": _extract_field_names,
|
|
224
|
+
}
|