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,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
+ }