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,298 @@
1
+ """Method-level source extraction — complexity-graded content extraction.
2
+
3
+ Extracts source at method granularity: high=full body, medium=signature+20 lines,
4
+ low=signature only. Falls back to head truncation when tree-sitter is unavailable.
5
+
6
+ Usage:
7
+ from core.skeleton.extract_methods import extract_methods_from_file, is_available
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Public API
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ def is_available() -> bool:
25
+ """Check if tree-sitter is available for AST-based extraction."""
26
+ try:
27
+ import tree_sitter_languages # noqa: F401
28
+ return True
29
+ except ImportError:
30
+ return False
31
+
32
+
33
+ def extract_methods_from_file(
34
+ content: str,
35
+ filepath: str | Path,
36
+ max_bytes: int = 30_000,
37
+ priority_info: dict[str, Any] | None = None,
38
+ ) -> str:
39
+ """Extract content from a source file at method granularity.
40
+
41
+ Returns formatted method-level content or head-truncated fallback.
42
+ """
43
+ if not content:
44
+ return ""
45
+
46
+ content_bytes = len(content.encode("utf-8"))
47
+
48
+ # File within budget — return as-is
49
+ if content_bytes <= max_bytes:
50
+ return content
51
+
52
+ # Try tree-sitter method-level extraction
53
+ if is_available():
54
+ extracted = _extract_by_methods(content, filepath, priority_info, max_bytes)
55
+ if extracted:
56
+ return extracted
57
+
58
+ # Fallback: head truncation
59
+ return _truncate_head(content, max_bytes)
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Internal: tree-sitter extraction
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def _extract_by_methods(
68
+ content: str, filepath: str | Path,
69
+ priority_info: dict[str, Any] | None, max_bytes: int,
70
+ ) -> str | None:
71
+ """Use tree-sitter for method-level extraction."""
72
+ try:
73
+ import tree_sitter_languages
74
+ except ImportError:
75
+ return None
76
+
77
+ lang = _detect_language(str(filepath))
78
+ if not lang:
79
+ return None
80
+
81
+ try:
82
+ parser = tree_sitter_languages.get_parser(lang)
83
+ tree = parser.parse(content.encode("utf-8"))
84
+ except Exception as e:
85
+ logger.debug("tree-sitter parse failed for %s: %s", filepath, e)
86
+ return None
87
+
88
+ lines = content.splitlines(keepends=True)
89
+ methods = _find_methods(tree.root_node, lang)
90
+ if not methods:
91
+ return None
92
+
93
+ # Get high-priority method names from skeleton
94
+ high_method_names: set[str] = set()
95
+ if priority_info:
96
+ for m in priority_info.get("method_details", []):
97
+ if m.get("complexity") == "high":
98
+ high_method_names.add(m.get("name", ""))
99
+
100
+ # Classify methods
101
+ high_methods = []
102
+ medium_methods = []
103
+ low_methods = []
104
+
105
+ for m in methods:
106
+ if m["name"] in high_method_names or m["complexity"] == "high":
107
+ high_methods.append(m)
108
+ elif m["complexity"] == "medium":
109
+ medium_methods.append(m)
110
+ else:
111
+ low_methods.append(m)
112
+
113
+ # Build output
114
+ parts: list[str] = []
115
+ total = 0
116
+
117
+ # 1. File header (package/import + class declaration)
118
+ header_end = _find_header_end(lines, lang)
119
+ header = "".join(lines[:header_end])
120
+ header_bytes = len(header.encode("utf-8"))
121
+ if header_bytes < max_bytes * 0.3:
122
+ parts.append(header)
123
+ total += header_bytes
124
+ else:
125
+ header = "".join(lines[:20])
126
+ parts.append(header + "\n// ... [imports truncated]\n\n")
127
+ total += len(header.encode("utf-8")) + 30
128
+
129
+ # 2. High methods: full body
130
+ for m in high_methods:
131
+ body = "".join(lines[m["start"]:m["end"] + 1])
132
+ body_bytes = len(body.encode("utf-8"))
133
+ if total + body_bytes > max_bytes:
134
+ remaining = max_bytes - total - 100
135
+ if remaining > 500:
136
+ truncated = body.encode("utf-8")[:remaining].decode("utf-8", errors="ignore")
137
+ parts.append(f"\n // === HIGH: {m['name']} (truncated) ===\n{truncated}\n // ...\n")
138
+ total = max_bytes
139
+ break
140
+ parts.append(f"\n // === HIGH: {m['name']} ({m['line_count']} lines) ===\n{body}")
141
+ total += body_bytes
142
+
143
+ # 3. Medium methods: signature + first 20 lines
144
+ for m in medium_methods:
145
+ if total >= max_bytes:
146
+ break
147
+ preview_end = min(m["start"] + 20, m["end"] + 1)
148
+ body = "".join(lines[m["start"]:preview_end])
149
+ suffix = ""
150
+ if preview_end < m["end"] + 1:
151
+ suffix = f"\n // ... [{m['line_count'] - 20} more lines]\n"
152
+ block = f"\n // === MEDIUM: {m['name']} ({m['line_count']} lines) ===\n" + body + suffix
153
+ block_bytes = len(block.encode("utf-8"))
154
+ if total + block_bytes > max_bytes:
155
+ break
156
+ parts.append(block)
157
+ total += block_bytes
158
+
159
+ # 4. Low methods: signatures only
160
+ if low_methods and total < max_bytes - 500:
161
+ sig_lines = ["\n // === LOW complexity methods (signatures only) ===\n"]
162
+ for m in low_methods:
163
+ sig = "".join(lines[m["start"]:m["start"] + 1]).rstrip()
164
+ sig_lines.append(f" {sig}\n")
165
+ sig_block = "".join(sig_lines)
166
+ sig_bytes = len(sig_block.encode("utf-8"))
167
+ if total + sig_bytes <= max_bytes:
168
+ parts.append(sig_block)
169
+
170
+ result = "".join(parts)
171
+ if len(result.encode("utf-8")) < 200:
172
+ return None
173
+
174
+ return result
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Internal: Method finding
179
+ # ---------------------------------------------------------------------------
180
+
181
+ _METHOD_NODE_TYPES: dict[str, list[str]] = {
182
+ "java": ["method_declaration", "constructor_declaration"],
183
+ "python": ["function_definition"], "go": ["function_declaration", "method_declaration"],
184
+ "typescript": ["function_declaration", "method_definition"],
185
+ "javascript": ["function_declaration", "method_definition"],
186
+ "kotlin": ["function_declaration"], "rust": ["function_item"],
187
+ }
188
+
189
+
190
+ def _find_methods(root_node: Any, lang: str) -> list[dict[str, Any]]:
191
+ """Extract method info from AST."""
192
+ node_types = _METHOD_NODE_TYPES.get(lang, [])
193
+ methods: list[dict] = []
194
+
195
+ def walk(node: Any) -> None:
196
+ if node.type in node_types:
197
+ name = _get_method_name(node)
198
+ if name:
199
+ start = node.start_point[0]
200
+ end = node.end_point[0]
201
+ line_count = end - start + 1
202
+ branches = _count_branches(node)
203
+ if line_count > 50 or branches > 10:
204
+ complexity = "high"
205
+ elif line_count > 25 or branches > 5:
206
+ complexity = "medium"
207
+ else:
208
+ complexity = "low"
209
+ methods.append({
210
+ "name": name, "start": start, "end": end,
211
+ "line_count": line_count, "complexity": complexity,
212
+ })
213
+ for child in node.children:
214
+ walk(child)
215
+
216
+ walk(root_node)
217
+ return methods
218
+
219
+
220
+ def _get_method_name(node: Any) -> str | None:
221
+ """Extract method name from AST node."""
222
+ for child in node.children:
223
+ if child.type in ("identifier", "name"):
224
+ return child.text.decode("utf-8") if child.text else None
225
+ if child.type == "constructor_declarator":
226
+ for sub in child.children:
227
+ if sub.type == "identifier":
228
+ return sub.text.decode("utf-8") if sub.text else None
229
+ return None
230
+
231
+
232
+ def _count_branches(node: Any) -> int:
233
+ """Count branch statements in a method."""
234
+ branch_types = {"if_statement", "else_clause", "switch_expression", "for_statement",
235
+ "while_statement", "try_statement", "catch_clause",
236
+ "ternary_expression", "conditional_expression", "enhanced_for_statement"}
237
+ count = 0
238
+
239
+ def walk(n: Any) -> None:
240
+ nonlocal count
241
+ if n.type in branch_types:
242
+ count += 1
243
+ for child in n.children:
244
+ walk(child)
245
+
246
+ walk(node)
247
+ return count
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Internal: Helpers
252
+ # ---------------------------------------------------------------------------
253
+
254
+ _LANG_EXTENSIONS: dict[str, str] = {
255
+ ".java": "java", ".py": "python", ".go": "go", ".ts": "typescript",
256
+ ".tsx": "typescript", ".js": "javascript", ".jsx": "javascript",
257
+ ".kt": "kotlin", ".rs": "rust",
258
+ }
259
+
260
+
261
+ def _detect_language(filepath: str) -> str | None:
262
+ """Detect language from file extension."""
263
+ ext = Path(filepath).suffix.lower()
264
+ return _LANG_EXTENSIONS.get(ext)
265
+
266
+
267
+ def _find_header_end(lines: list[str], lang: str) -> int:
268
+ """Find end of file header (package/import section)."""
269
+ if lang == "java":
270
+ last_import = 0
271
+ for i, line in enumerate(lines):
272
+ stripped = line.strip()
273
+ if stripped.startswith(("import ", "package ")):
274
+ last_import = i
275
+ if any(kw in stripped for kw in ("class ", "interface ", "enum ")):
276
+ if i > last_import + 1:
277
+ return i
278
+ return min(last_import + 2, len(lines))
279
+ for i, line in enumerate(lines[:50]):
280
+ stripped = line.strip()
281
+ if lang == "python" and stripped.startswith(("def ", "class ")):
282
+ return i
283
+ if lang in ("go", "rust") and stripped.startswith("func "):
284
+ return i
285
+ return min(30, len(lines))
286
+
287
+
288
+ def _truncate_head(content: str, max_bytes: int) -> str:
289
+ """Fallback: truncate to budget, preserving file head."""
290
+ lines = content.splitlines(keepends=True)
291
+ result: list[str] = []
292
+ acc = 0
293
+ for line in lines:
294
+ acc += len(line.encode("utf-8"))
295
+ if acc > max_bytes:
296
+ break
297
+ result.append(line)
298
+ return "".join(result) + f"\n// ... [truncated, showing {len(result)}/{len(lines)} lines]\n"
@@ -0,0 +1,239 @@
1
+ """File list extraction and coverage checking.
2
+
3
+ Generates per-doc-type file lists from skeleton entries using preset classification rules.
4
+ Also provides coverage checking to identify unclassified files.
5
+
6
+ Usage:
7
+ from core.skeleton.file_list import extract_file_list, check_coverage, load_skeleton
8
+
9
+ entries = load_skeleton(module_dir)
10
+ files = extract_file_list(entries, preset, "business-logic", source_cache)
11
+ report = check_coverage(entries, preset, file_lists_dir)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Skeleton loading
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ def load_skeleton(module_dir: Path) -> list[dict[str, Any]]:
31
+ """Load skeleton entries from module directory (auto-detects format).
32
+
33
+ Handles: single file, shards directory, legacy paths.
34
+ Falls back to shards if single file is empty/corrupted.
35
+ """
36
+ from core.paths import resolve_skeleton
37
+
38
+ resolved = resolve_skeleton(module_dir)
39
+ if resolved is None:
40
+ return []
41
+
42
+ if resolved.is_dir():
43
+ return _load_dir(resolved)
44
+
45
+ # Single file
46
+ try:
47
+ data = json.loads(resolved.read_text(encoding="utf-8"))
48
+ entries = data if isinstance(data, list) else data.get("data", data.get("files", []))
49
+ if entries:
50
+ return entries
51
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError):
52
+ pass
53
+
54
+ # Fallback to shards directory
55
+ shards_dir = resolved.parent / "shards"
56
+ if shards_dir.is_dir():
57
+ return _load_dir(shards_dir)
58
+ return []
59
+
60
+
61
+ def _load_dir(skeleton_dir: Path) -> list[dict]:
62
+ """Load all JSON shards from a directory."""
63
+ entries: list[dict] = []
64
+ for f in sorted(skeleton_dir.glob("*.json")):
65
+ try:
66
+ data = json.loads(f.read_text(encoding="utf-8"))
67
+ if isinstance(data, list):
68
+ entries.extend(data)
69
+ elif isinstance(data, dict):
70
+ entries.extend(data.get("data", data.get("files", [])))
71
+ except (json.JSONDecodeError, OSError):
72
+ continue
73
+ return entries
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # File list extraction
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def extract_file_list(
82
+ entries: list[dict],
83
+ preset: dict,
84
+ doc_type: str,
85
+ source_cache: str | Path,
86
+ ) -> list[str]:
87
+ """Extract absolute file paths for a given doc type.
88
+
89
+ Uses preset classification rules to determine which files belong to each doc type.
90
+ Returns sorted list of absolute paths (forward-slash normalized).
91
+ """
92
+ from core.preset import classify_file, get_affected_docs, get_doc_filename
93
+
94
+ doc_filename = get_doc_filename(preset, doc_type, strict=False)
95
+ enums_filename = get_doc_filename(preset, "enums-and-constants", strict=False)
96
+ matched: set[str] = set()
97
+
98
+ for entry in entries:
99
+ file_path = entry.get("file", "")
100
+ if not file_path:
101
+ continue
102
+
103
+ categories = classify_file(preset, file_path, entry)
104
+ affected_docs = get_affected_docs(preset, categories)
105
+
106
+ if doc_filename not in affected_docs:
107
+ continue
108
+
109
+ # Enum purity check: mixed files (class + inner enum) go to data-models
110
+ if doc_filename == enums_filename:
111
+ has_enum = "enum" in categories
112
+ has_constant = "constant" in categories
113
+ if has_enum and not has_constant and not is_pure_enum_file(entry):
114
+ continue
115
+
116
+ matched.add(file_path)
117
+
118
+ return sorted(
119
+ p.replace("\\", "/")
120
+ for p in matched
121
+ )
122
+
123
+
124
+ def is_pure_enum_file(entry: dict) -> bool:
125
+ """Check if all classes in the entry are enum type."""
126
+ classes = entry.get("classes", [])
127
+ if not classes:
128
+ return False
129
+ return all(c.get("type", "").lower() == "enum" for c in classes)
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Coverage checking
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ @dataclass
138
+ class CoverageReport:
139
+ """Result of a coverage check."""
140
+ total_files: int = 0
141
+ covered: int = 0
142
+ uncovered_files: list[str] = field(default_factory=list)
143
+ distribution: dict[str, int] = field(default_factory=dict)
144
+
145
+ @property
146
+ def uncovered_count(self) -> int:
147
+ return len(self.uncovered_files)
148
+
149
+ @property
150
+ def coverage_pct(self) -> float:
151
+ return (self.covered / self.total_files * 100) if self.total_files > 0 else 100.0
152
+
153
+
154
+ def check_coverage(
155
+ entries: list[dict],
156
+ preset: dict,
157
+ file_lists_dir: Path | None = None,
158
+ ) -> CoverageReport:
159
+ """Check which files are not covered by any doc-type classification.
160
+
161
+ A file is considered covered if:
162
+ 1. Preset classification rules match it to at least one meaningful category, OR
163
+ 2. It already appears in a file-list (manually added)
164
+
165
+ Args:
166
+ entries: Skeleton entries
167
+ preset: Preset configuration
168
+ file_lists_dir: Directory containing existing .txt file lists (for manual coverage)
169
+
170
+ Returns:
171
+ CoverageReport with uncovered files and distribution stats
172
+ """
173
+ from core.preset import classify_file, get_affected_docs, get_file_classification
174
+
175
+ classification = get_file_classification(preset)
176
+ skip_categories = {
177
+ name for name, cfg in classification.items()
178
+ if cfg.get("trigger") == "add-delete-only"
179
+ }
180
+
181
+ # Load existing file-list paths for manual coverage detection
182
+ manual_covered = _load_file_list_paths(file_lists_dir) if file_lists_dir else set()
183
+
184
+ report = CoverageReport(total_files=len(entries))
185
+ distribution: dict[str, int] = {}
186
+
187
+ for entry in entries:
188
+ file_path = entry.get("file", "")
189
+ if not file_path:
190
+ continue
191
+
192
+ categories = classify_file(preset, file_path, entry)
193
+ meaningful = [c for c in categories if c not in skip_categories]
194
+
195
+ if not meaningful:
196
+ if _is_in_file_lists(file_path, manual_covered):
197
+ distribution["(manual)"] = distribution.get("(manual)", 0) + 1
198
+ else:
199
+ report.uncovered_files.append(file_path)
200
+ continue
201
+
202
+ affected = get_affected_docs(preset, meaningful)
203
+ for doc in affected:
204
+ distribution[doc] = distribution.get(doc, 0) + 1
205
+
206
+ report.covered = report.total_files - report.uncovered_count
207
+ report.distribution = distribution
208
+ return report
209
+
210
+
211
+ def _load_file_list_paths(file_lists_dir: Path) -> set[str]:
212
+ """Load all file paths from existing file-list .txt files."""
213
+ covered: set[str] = set()
214
+ if not file_lists_dir.is_dir():
215
+ return covered
216
+ for f in file_lists_dir.iterdir():
217
+ if not f.name.endswith(".txt") or f.name.startswith("coverage"):
218
+ continue
219
+ for line in f.read_text(encoding="utf-8").splitlines():
220
+ line = line.strip()
221
+ if line:
222
+ covered.add(line)
223
+ # Also store module-relative path
224
+ parts = line.replace("\\", "/").split(".source-cache/")
225
+ if len(parts) > 1:
226
+ module_rel = "/".join(parts[-1].split("/")[1:])
227
+ covered.add(module_rel)
228
+ return covered
229
+
230
+
231
+ def _is_in_file_lists(file_path: str, covered: set[str]) -> bool:
232
+ """Check if a skeleton file path is already in file-lists."""
233
+ normalized = file_path.replace("\\", "/")
234
+ if normalized in covered:
235
+ return True
236
+ for p in covered:
237
+ if p.endswith(normalized) or normalized.endswith(p):
238
+ return True
239
+ return False