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