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,87 @@
|
|
|
1
|
+
"""Lock management command — acquire/release/status for both Agent and CLI modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cmd_lock(args: argparse.Namespace) -> None:
|
|
14
|
+
"""Unified lock management for both Agent and CLI modes."""
|
|
15
|
+
lock_dir = Path(args.dir)
|
|
16
|
+
lock_path = lock_dir / ".kb-lock"
|
|
17
|
+
lock_meta_path = lock_dir / ".kb-lock.json"
|
|
18
|
+
|
|
19
|
+
if args.action == "acquire":
|
|
20
|
+
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
# Check if already locked
|
|
23
|
+
if lock_path.exists():
|
|
24
|
+
if _is_stale_lock(lock_meta_path):
|
|
25
|
+
lock_path.unlink(missing_ok=True)
|
|
26
|
+
lock_meta_path.unlink(missing_ok=True)
|
|
27
|
+
print(json.dumps({"status": "warning", "message": "Stale lock overridden"},
|
|
28
|
+
ensure_ascii=False), file=sys.stderr)
|
|
29
|
+
else:
|
|
30
|
+
meta = {}
|
|
31
|
+
if lock_meta_path.exists():
|
|
32
|
+
try:
|
|
33
|
+
meta = json.loads(lock_meta_path.read_text(encoding="utf-8"))
|
|
34
|
+
except (json.JSONDecodeError, OSError):
|
|
35
|
+
pass
|
|
36
|
+
print(json.dumps({"status": "error", "message": "Already locked",
|
|
37
|
+
**meta}, ensure_ascii=False), file=sys.stderr)
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
# Acquire
|
|
41
|
+
lock_path.write_text(str(os.getpid()), encoding="utf-8")
|
|
42
|
+
meta = {
|
|
43
|
+
"pid": os.getpid(),
|
|
44
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
45
|
+
"operation": args.operation or "unknown",
|
|
46
|
+
}
|
|
47
|
+
lock_meta_path.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
|
|
48
|
+
print(json.dumps({"status": "ok", "action": "acquired"}, ensure_ascii=False),
|
|
49
|
+
file=sys.stderr)
|
|
50
|
+
|
|
51
|
+
elif args.action == "release":
|
|
52
|
+
lock_path.unlink(missing_ok=True)
|
|
53
|
+
lock_meta_path.unlink(missing_ok=True)
|
|
54
|
+
print(json.dumps({"status": "ok", "action": "released"}, ensure_ascii=False),
|
|
55
|
+
file=sys.stderr)
|
|
56
|
+
|
|
57
|
+
elif args.action == "status":
|
|
58
|
+
if lock_path.exists():
|
|
59
|
+
meta = {}
|
|
60
|
+
if lock_meta_path.exists():
|
|
61
|
+
try:
|
|
62
|
+
meta = json.loads(lock_meta_path.read_text(encoding="utf-8"))
|
|
63
|
+
except (json.JSONDecodeError, OSError):
|
|
64
|
+
pass
|
|
65
|
+
stale = _is_stale_lock(lock_meta_path)
|
|
66
|
+
print(json.dumps({"status": "locked", "stale": stale, **meta},
|
|
67
|
+
ensure_ascii=False), file=sys.stderr)
|
|
68
|
+
else:
|
|
69
|
+
print(json.dumps({"status": "unlocked"}, ensure_ascii=False), file=sys.stderr)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_stale_lock(lock_meta_path: Path) -> bool:
|
|
73
|
+
"""Check if a lock is stale (>30 minutes old)."""
|
|
74
|
+
if not lock_meta_path.exists():
|
|
75
|
+
return True
|
|
76
|
+
try:
|
|
77
|
+
meta = json.loads(lock_meta_path.read_text(encoding="utf-8"))
|
|
78
|
+
ts = meta.get("timestamp", "")
|
|
79
|
+
if not ts:
|
|
80
|
+
return True
|
|
81
|
+
from datetime import datetime, timezone
|
|
82
|
+
lock_time = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
83
|
+
now = datetime.now(timezone.utc)
|
|
84
|
+
age_minutes = (now - lock_time).total_seconds() / 60
|
|
85
|
+
return age_minutes > 30
|
|
86
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
87
|
+
return True
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Merge delta command — merge skeleton delta into existing skeleton."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_merge_delta(args: argparse.Namespace) -> None:
|
|
12
|
+
"""Merge skeleton delta into existing skeleton."""
|
|
13
|
+
from core.skeleton.merge_delta import merge_delta
|
|
14
|
+
|
|
15
|
+
delta_path = Path(args.delta)
|
|
16
|
+
target_path = Path(args.target)
|
|
17
|
+
|
|
18
|
+
if not delta_path.exists():
|
|
19
|
+
print(json.dumps({"status": "error", "message": f"Delta not found: {delta_path}"},
|
|
20
|
+
ensure_ascii=False), file=sys.stderr)
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
stats = merge_delta(
|
|
25
|
+
delta_path=delta_path,
|
|
26
|
+
module_dir=target_path,
|
|
27
|
+
dry_run=args.dry_run,
|
|
28
|
+
cleanup=not args.no_cleanup,
|
|
29
|
+
)
|
|
30
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
31
|
+
print(json.dumps({"status": "error", "message": f"Malformed delta: {e}"},
|
|
32
|
+
ensure_ascii=False), file=sys.stderr)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
result = {"status": "ok", "replaced": stats.replaced, "added": stats.added}
|
|
36
|
+
if stats.unmatched:
|
|
37
|
+
result["unmatched"] = stats.unmatched
|
|
38
|
+
if args.dry_run:
|
|
39
|
+
result["dry_run"] = True
|
|
40
|
+
|
|
41
|
+
print(json.dumps(result, ensure_ascii=False), file=sys.stderr)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Community detection — Louvain-based file grouping for intelligent splitting.
|
|
2
|
+
|
|
3
|
+
Uses dependency graph to identify highly cohesive file groups via community
|
|
4
|
+
detection. Falls back to connected components when networkx is unavailable.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from core.skeleton.community import detect_communities, validate_communities, split_by_community
|
|
8
|
+
|
|
9
|
+
graph = build_dependency_graph(skeleton_entries)
|
|
10
|
+
communities = detect_communities(graph, resolution=1.0)
|
|
11
|
+
valid = validate_communities(communities, max_files_per_shard=80)
|
|
12
|
+
plan = split_by_community(entries, graph, resolution=1.0)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from core.skeleton.dependency_graph import DependencyGraph
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Public API
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect_communities(
|
|
31
|
+
graph: DependencyGraph,
|
|
32
|
+
resolution: float = 1.0,
|
|
33
|
+
) -> list[list[str]]:
|
|
34
|
+
"""Detect communities in the dependency graph.
|
|
35
|
+
|
|
36
|
+
Uses networkx Louvain algorithm if available, otherwise falls back
|
|
37
|
+
to connected components with weight threshold.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
graph: DependencyGraph instance
|
|
41
|
+
resolution: Louvain resolution parameter (higher = smaller communities)
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List of file path lists, each sub-list is a community.
|
|
45
|
+
"""
|
|
46
|
+
if not graph.adjacency:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
return _detect_communities_networkx(graph, resolution)
|
|
51
|
+
except ImportError:
|
|
52
|
+
return _detect_communities_fallback(graph)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_communities(
|
|
56
|
+
communities: list[list[str]],
|
|
57
|
+
max_files_per_shard: int = 80,
|
|
58
|
+
) -> bool:
|
|
59
|
+
"""Validate that all communities are within size constraints.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
communities: Output from detect_communities
|
|
63
|
+
max_files_per_shard: Maximum files allowed per community
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if all communities are within limits.
|
|
67
|
+
"""
|
|
68
|
+
if not communities:
|
|
69
|
+
return True
|
|
70
|
+
return all(len(c) <= max_files_per_shard for c in communities)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def split_by_community(
|
|
74
|
+
entries: list[dict[str, Any]],
|
|
75
|
+
graph: DependencyGraph,
|
|
76
|
+
resolution: float = 1.0,
|
|
77
|
+
max_files_per_shard: int = 80,
|
|
78
|
+
max_lines_per_shard: int = 8000,
|
|
79
|
+
) -> list[list[dict[str, Any]]]:
|
|
80
|
+
"""Split skeleton entries into groups based on community detection.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
entries: File info dicts with at least {name, path, lines}
|
|
84
|
+
graph: DependencyGraph built from skeleton
|
|
85
|
+
resolution: Louvain resolution parameter
|
|
86
|
+
max_files_per_shard: Max files per group
|
|
87
|
+
max_lines_per_shard: Max lines per group
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of entry groups. Each group is a list of file dicts.
|
|
91
|
+
"""
|
|
92
|
+
communities = detect_communities(graph, resolution)
|
|
93
|
+
if not communities or len(communities) <= 1:
|
|
94
|
+
return [entries] if entries else []
|
|
95
|
+
|
|
96
|
+
# Build lookup indices
|
|
97
|
+
path_to_entry = {e.get("path", ""): e for e in entries}
|
|
98
|
+
name_to_entry = {e.get("name", ""): e for e in entries}
|
|
99
|
+
|
|
100
|
+
groups: list[list[dict]] = []
|
|
101
|
+
assigned: set[str] = set()
|
|
102
|
+
|
|
103
|
+
for community_files in communities:
|
|
104
|
+
group: list[dict] = []
|
|
105
|
+
for filepath in community_files:
|
|
106
|
+
entry = path_to_entry.get(filepath)
|
|
107
|
+
if not entry:
|
|
108
|
+
fname = Path(filepath).name
|
|
109
|
+
entry = name_to_entry.get(fname)
|
|
110
|
+
if entry and entry.get("name", "") not in assigned:
|
|
111
|
+
group.append(entry)
|
|
112
|
+
assigned.add(entry.get("name", ""))
|
|
113
|
+
if group:
|
|
114
|
+
# Split oversized communities
|
|
115
|
+
group_lines = sum(e.get("lines", 0) for e in group)
|
|
116
|
+
if len(group) > max_files_per_shard or group_lines > max_lines_per_shard:
|
|
117
|
+
sub_groups = _split_oversized(group, max_lines_per_shard, max_files_per_shard)
|
|
118
|
+
groups.extend(sub_groups)
|
|
119
|
+
else:
|
|
120
|
+
groups.append(group)
|
|
121
|
+
|
|
122
|
+
# Handle unassigned entries
|
|
123
|
+
unassigned = [e for e in entries if e.get("name", "") not in assigned]
|
|
124
|
+
if unassigned:
|
|
125
|
+
unassigned_lines = sum(e.get("lines", 0) for e in unassigned)
|
|
126
|
+
if unassigned_lines < 200 and len(unassigned) < 5 and groups:
|
|
127
|
+
# Merge into smallest group
|
|
128
|
+
smallest = min(groups, key=lambda g: sum(e.get("lines", 0) for e in g))
|
|
129
|
+
smallest.extend(unassigned)
|
|
130
|
+
else:
|
|
131
|
+
groups.append(unassigned)
|
|
132
|
+
|
|
133
|
+
return groups
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Internal: networkx-based detection
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _detect_communities_networkx(
|
|
142
|
+
graph: DependencyGraph,
|
|
143
|
+
resolution: float = 1.0,
|
|
144
|
+
) -> list[list[str]]:
|
|
145
|
+
"""Use networkx Louvain community detection."""
|
|
146
|
+
import networkx as nx
|
|
147
|
+
|
|
148
|
+
G = nx.Graph()
|
|
149
|
+
all_nodes = graph.nodes
|
|
150
|
+
G.add_nodes_from(all_nodes)
|
|
151
|
+
|
|
152
|
+
# Add weighted edges (undirected, take max weight for bidirectional)
|
|
153
|
+
for source, targets in graph.adjacency.items():
|
|
154
|
+
for target, weight in targets.items():
|
|
155
|
+
if G.has_edge(source, target):
|
|
156
|
+
existing = G[source][target].get("weight", 0)
|
|
157
|
+
G[source][target]["weight"] = max(existing, weight)
|
|
158
|
+
else:
|
|
159
|
+
G.add_edge(source, target, weight=weight)
|
|
160
|
+
|
|
161
|
+
if G.number_of_edges() == 0:
|
|
162
|
+
return [[n] for n in G.nodes()]
|
|
163
|
+
|
|
164
|
+
communities = nx.community.louvain_communities(
|
|
165
|
+
G, weight="weight", resolution=resolution, seed=42
|
|
166
|
+
)
|
|
167
|
+
return [sorted(list(c)) for c in communities]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Internal: Fallback detection (no networkx)
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _detect_communities_fallback(
|
|
176
|
+
graph: DependencyGraph,
|
|
177
|
+
) -> list[list[str]]:
|
|
178
|
+
"""Fallback: connected components with weight threshold >= 2."""
|
|
179
|
+
adj: dict[str, set[str]] = defaultdict(set)
|
|
180
|
+
all_nodes = graph.nodes
|
|
181
|
+
|
|
182
|
+
for source, targets in graph.adjacency.items():
|
|
183
|
+
for target, weight in targets.items():
|
|
184
|
+
if weight >= 2:
|
|
185
|
+
adj[source].add(target)
|
|
186
|
+
adj[target].add(source)
|
|
187
|
+
|
|
188
|
+
visited: set[str] = set()
|
|
189
|
+
communities: list[list[str]] = []
|
|
190
|
+
|
|
191
|
+
for node in sorted(all_nodes):
|
|
192
|
+
if node in visited:
|
|
193
|
+
continue
|
|
194
|
+
component: list[str] = []
|
|
195
|
+
queue = [node]
|
|
196
|
+
while queue:
|
|
197
|
+
current = queue.pop(0)
|
|
198
|
+
if current in visited:
|
|
199
|
+
continue
|
|
200
|
+
visited.add(current)
|
|
201
|
+
component.append(current)
|
|
202
|
+
for neighbor in adj.get(current, set()):
|
|
203
|
+
if neighbor not in visited:
|
|
204
|
+
queue.append(neighbor)
|
|
205
|
+
communities.append(sorted(component))
|
|
206
|
+
|
|
207
|
+
return communities
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Internal: Helpers
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _split_oversized(
|
|
216
|
+
files: list[dict], max_lines: int, max_files: int
|
|
217
|
+
) -> list[list[dict]]:
|
|
218
|
+
"""Split an oversized community using LPT greedy balancing."""
|
|
219
|
+
total_lines = sum(f.get("lines", 0) for f in files)
|
|
220
|
+
n = max(
|
|
221
|
+
math.ceil(len(files) / max_files),
|
|
222
|
+
math.ceil(total_lines / max_lines),
|
|
223
|
+
2,
|
|
224
|
+
)
|
|
225
|
+
buckets: list[list[dict]] = [[] for _ in range(n)]
|
|
226
|
+
bucket_lines = [0] * n
|
|
227
|
+
|
|
228
|
+
for f in sorted(files, key=lambda x: -x.get("lines", 0)):
|
|
229
|
+
lightest = min(range(n), key=lambda i: bucket_lines[i])
|
|
230
|
+
buckets[lightest].append(f)
|
|
231
|
+
bucket_lines[lightest] += f.get("lines", 0)
|
|
232
|
+
|
|
233
|
+
return [b for b in buckets if b]
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Dependency graph construction — file-level dependency analysis from skeleton data.
|
|
2
|
+
|
|
3
|
+
Builds a weighted directed graph from skeleton entries. Supports topological sort
|
|
4
|
+
and cycle detection. Dependency weights: inheritance=4, injection=3, import=2, call=1.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from core.skeleton.dependency_graph import (
|
|
8
|
+
DependencyEdge, DependencyGraph, build_dependency_graph, topo_sort, detect_cycles
|
|
9
|
+
)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections import defaultdict, deque
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Data classes
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DependencyEdge:
|
|
28
|
+
"""A single dependency edge between two files."""
|
|
29
|
+
|
|
30
|
+
source: str
|
|
31
|
+
target: str
|
|
32
|
+
weight: int = 1
|
|
33
|
+
kind: str = "import" # import | inheritance | injection | call
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DependencyGraph:
|
|
38
|
+
"""File-level dependency graph with adjacency dict and edge list."""
|
|
39
|
+
|
|
40
|
+
adjacency: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
41
|
+
edges: list[DependencyEdge] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def nodes(self) -> set[str]:
|
|
45
|
+
"""All nodes in the graph."""
|
|
46
|
+
nodes = set(self.adjacency.keys())
|
|
47
|
+
for targets in self.adjacency.values():
|
|
48
|
+
nodes.update(targets.keys())
|
|
49
|
+
return nodes
|
|
50
|
+
|
|
51
|
+
def add_edge(self, source: str, target: str, weight: int, kind: str) -> None:
|
|
52
|
+
"""Add or accumulate an edge."""
|
|
53
|
+
if source not in self.adjacency:
|
|
54
|
+
self.adjacency[source] = {}
|
|
55
|
+
self.adjacency.setdefault(source, {})[target] = (
|
|
56
|
+
self.adjacency.get(source, {}).get(target, 0) + weight
|
|
57
|
+
)
|
|
58
|
+
self.edges.append(DependencyEdge(source=source, target=target, weight=weight, kind=kind))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Edge weight constants
|
|
62
|
+
WEIGHT_INHERITANCE = 4
|
|
63
|
+
WEIGHT_INJECTION = 3
|
|
64
|
+
WEIGHT_IMPORT = 2
|
|
65
|
+
WEIGHT_CALL = 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Public API
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_dependency_graph(
|
|
74
|
+
skeleton_entries: list[dict[str, Any]],
|
|
75
|
+
project_package_prefix: str | None = None,
|
|
76
|
+
) -> DependencyGraph:
|
|
77
|
+
"""Build a file-level dependency graph from skeleton entries."""
|
|
78
|
+
class_to_file = _build_class_to_file_index(skeleton_entries)
|
|
79
|
+
field_type_to_file = _build_field_type_index(skeleton_entries)
|
|
80
|
+
if project_package_prefix is None:
|
|
81
|
+
project_package_prefix = _infer_package_prefix(skeleton_entries)
|
|
82
|
+
|
|
83
|
+
graph = DependencyGraph()
|
|
84
|
+
for entry in skeleton_entries:
|
|
85
|
+
source_file = entry.get("file", "")
|
|
86
|
+
if not source_file:
|
|
87
|
+
continue
|
|
88
|
+
_add_import_edges(entry, source_file, class_to_file, project_package_prefix, graph)
|
|
89
|
+
_add_inheritance_edges(entry, source_file, class_to_file, graph)
|
|
90
|
+
_add_injection_edges(entry, source_file, class_to_file, graph)
|
|
91
|
+
_add_call_edges(entry, source_file, class_to_file, field_type_to_file, graph)
|
|
92
|
+
return graph
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def topo_sort(graph: DependencyGraph) -> list[str]:
|
|
96
|
+
"""Topological sort (Kahn's algorithm). Dependencies come first."""
|
|
97
|
+
nodes = graph.nodes
|
|
98
|
+
in_degree: dict[str, int] = {n: 0 for n in nodes}
|
|
99
|
+
for source, targets in graph.adjacency.items():
|
|
100
|
+
for target in targets:
|
|
101
|
+
if target in in_degree:
|
|
102
|
+
in_degree[target] += 1
|
|
103
|
+
|
|
104
|
+
queue = deque(sorted(n for n in nodes if in_degree[n] == 0))
|
|
105
|
+
result: list[str] = []
|
|
106
|
+
while queue:
|
|
107
|
+
node = queue.popleft()
|
|
108
|
+
result.append(node)
|
|
109
|
+
for target in graph.adjacency.get(node, {}):
|
|
110
|
+
if target in in_degree:
|
|
111
|
+
in_degree[target] -= 1
|
|
112
|
+
if in_degree[target] == 0:
|
|
113
|
+
queue.append(target)
|
|
114
|
+
# Append remaining (cycle members)
|
|
115
|
+
if len(result) < len(nodes):
|
|
116
|
+
result.extend(sorted(n for n in nodes if n not in set(result)))
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def detect_cycles(graph: DependencyGraph) -> list[list[str]]:
|
|
121
|
+
"""Detect cycles in the dependency graph using DFS."""
|
|
122
|
+
nodes = graph.nodes
|
|
123
|
+
visited: set[str] = set()
|
|
124
|
+
rec_stack: set[str] = set()
|
|
125
|
+
cycles: list[list[str]] = []
|
|
126
|
+
path: list[str] = []
|
|
127
|
+
|
|
128
|
+
def dfs(node: str) -> None:
|
|
129
|
+
visited.add(node)
|
|
130
|
+
rec_stack.add(node)
|
|
131
|
+
path.append(node)
|
|
132
|
+
for neighbor in graph.adjacency.get(node, {}):
|
|
133
|
+
if neighbor not in visited:
|
|
134
|
+
dfs(neighbor)
|
|
135
|
+
elif neighbor in rec_stack:
|
|
136
|
+
cycle_start = path.index(neighbor)
|
|
137
|
+
cycles.append(path[cycle_start:] + [neighbor])
|
|
138
|
+
path.pop()
|
|
139
|
+
rec_stack.discard(node)
|
|
140
|
+
|
|
141
|
+
for node in sorted(nodes):
|
|
142
|
+
if node not in visited:
|
|
143
|
+
dfs(node)
|
|
144
|
+
return cycles
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Internal: Index builders
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_class_to_file_index(entries: list[dict]) -> dict[str, str]:
|
|
153
|
+
"""Build class_name → file_path index."""
|
|
154
|
+
index: dict[str, str] = {}
|
|
155
|
+
for entry in entries:
|
|
156
|
+
filepath = entry.get("file", "")
|
|
157
|
+
for cls in entry.get("classes", []):
|
|
158
|
+
name = cls.get("name", "")
|
|
159
|
+
if name:
|
|
160
|
+
index[name] = filepath
|
|
161
|
+
stem = Path(filepath).stem
|
|
162
|
+
if stem and stem not in index:
|
|
163
|
+
index[stem] = filepath
|
|
164
|
+
return index
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build_field_type_index(entries: list[dict]) -> dict[str, dict[str, str]]:
|
|
168
|
+
"""Build file → {field_name: field_type} index."""
|
|
169
|
+
index: dict[str, dict[str, str]] = {}
|
|
170
|
+
for entry in entries:
|
|
171
|
+
filepath = entry.get("file", "")
|
|
172
|
+
fields: dict[str, str] = {}
|
|
173
|
+
for f in entry.get("fields", []):
|
|
174
|
+
if f.get("name") and f.get("type"):
|
|
175
|
+
fields[f["name"]] = f["type"]
|
|
176
|
+
if fields:
|
|
177
|
+
index[filepath] = fields
|
|
178
|
+
return index
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _infer_package_prefix(entries: list[dict]) -> str:
|
|
182
|
+
"""Auto-infer project package prefix from skeleton entries."""
|
|
183
|
+
prefix_counts: dict[str, int] = defaultdict(int)
|
|
184
|
+
for entry in entries:
|
|
185
|
+
for imp in entry.get("imports", []):
|
|
186
|
+
parts = imp.split(".")
|
|
187
|
+
if len(parts) >= 3:
|
|
188
|
+
prefix = ".".join(parts[:3])
|
|
189
|
+
prefix_counts[prefix] += 1
|
|
190
|
+
if not prefix_counts:
|
|
191
|
+
for entry in entries:
|
|
192
|
+
filepath = entry.get("file", "").replace("\\", "/")
|
|
193
|
+
idx = filepath.find("/java/")
|
|
194
|
+
if idx >= 0:
|
|
195
|
+
pkg_path = filepath[idx + 6:]
|
|
196
|
+
parts = pkg_path.split("/")
|
|
197
|
+
if len(parts) >= 3:
|
|
198
|
+
prefix = ".".join(parts[:3])
|
|
199
|
+
prefix_counts[prefix] += 1
|
|
200
|
+
if prefix_counts:
|
|
201
|
+
return max(prefix_counts, key=prefix_counts.get)
|
|
202
|
+
return ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Internal: Edge builders
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
_COMMON_TYPES = frozenset({
|
|
210
|
+
"String", "Integer", "Long", "Double", "Float", "Boolean", "Byte", "Short",
|
|
211
|
+
"Object", "Void", "BigDecimal", "BigInteger", "Date",
|
|
212
|
+
"LocalDate", "LocalDateTime", "Instant", "Duration",
|
|
213
|
+
"List", "Map", "Set", "Collection", "Optional", "Stream",
|
|
214
|
+
"HashMap", "ArrayList", "HashSet", "LinkedList",
|
|
215
|
+
"Serializable", "Comparable", "Iterable", "Iterator",
|
|
216
|
+
"Exception", "RuntimeException", "Throwable", "Logger", "Log",
|
|
217
|
+
"HttpServletRequest", "HttpServletResponse", "ResponseEntity",
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def get_common_types(hooks=None) -> frozenset[str]:
|
|
222
|
+
"""Get common types to exclude from dependency analysis."""
|
|
223
|
+
if hooks:
|
|
224
|
+
custom = hooks.get_common_types()
|
|
225
|
+
if custom:
|
|
226
|
+
return custom
|
|
227
|
+
return _COMMON_TYPES
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _add_import_edges(entry: dict, source: str, class_to_file: dict, prefix: str, graph: DependencyGraph):
|
|
231
|
+
for imp in entry.get("imports", []):
|
|
232
|
+
if prefix and not imp.startswith(prefix):
|
|
233
|
+
continue
|
|
234
|
+
class_name = imp.split(".")[-1]
|
|
235
|
+
if class_name == "*":
|
|
236
|
+
continue
|
|
237
|
+
target = class_to_file.get(class_name)
|
|
238
|
+
if target and target != source:
|
|
239
|
+
graph.add_edge(source, target, WEIGHT_IMPORT, "import")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _add_inheritance_edges(entry: dict, source: str, class_to_file: dict, graph: DependencyGraph):
|
|
243
|
+
for cls in entry.get("classes", []):
|
|
244
|
+
extends = cls.get("extends", "")
|
|
245
|
+
if extends:
|
|
246
|
+
target = class_to_file.get(extends.split(".")[-1])
|
|
247
|
+
if target and target != source:
|
|
248
|
+
graph.add_edge(source, target, WEIGHT_INHERITANCE, "inheritance")
|
|
249
|
+
for iface in cls.get("implements", []) or []:
|
|
250
|
+
target = class_to_file.get(iface.split(".")[-1])
|
|
251
|
+
if target and target != source:
|
|
252
|
+
graph.add_edge(source, target, WEIGHT_INHERITANCE, "inheritance")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _add_injection_edges(entry: dict, source: str, class_to_file: dict, graph: DependencyGraph,
|
|
256
|
+
inject_annotations: set[str] | None = None,
|
|
257
|
+
common_types: frozenset[str] | None = None):
|
|
258
|
+
if inject_annotations is None:
|
|
259
|
+
inject_annotations = {"@Autowired", "@Resource", "@Inject", "@Value"}
|
|
260
|
+
if common_types is None:
|
|
261
|
+
common_types = _COMMON_TYPES
|
|
262
|
+
for f in entry.get("fields", []):
|
|
263
|
+
raw_annotations = f.get("annotations", [])
|
|
264
|
+
field_type = f.get("type", "")
|
|
265
|
+
if not field_type or not field_type[0].isupper():
|
|
266
|
+
continue
|
|
267
|
+
# Normalize annotations: handle both str list and dict list formats
|
|
268
|
+
annotations: list[str] = []
|
|
269
|
+
for a in raw_annotations:
|
|
270
|
+
if isinstance(a, str):
|
|
271
|
+
annotations.append(a)
|
|
272
|
+
elif isinstance(a, dict):
|
|
273
|
+
annotations.append(a.get("name", a.get("annotation", "")))
|
|
274
|
+
is_injected = any(
|
|
275
|
+
any(a.startswith(inj) for inj in inject_annotations) for a in annotations
|
|
276
|
+
)
|
|
277
|
+
if is_injected:
|
|
278
|
+
clean_type = re.sub(r'<.*>', '', field_type).strip()
|
|
279
|
+
target = class_to_file.get(clean_type)
|
|
280
|
+
if target and target != source:
|
|
281
|
+
graph.add_edge(source, target, WEIGHT_INJECTION, "injection")
|
|
282
|
+
elif field_type[0].isupper() and field_type not in common_types:
|
|
283
|
+
clean_type = re.sub(r'<.*>', '', field_type).strip()
|
|
284
|
+
if clean_type not in common_types:
|
|
285
|
+
target = class_to_file.get(clean_type)
|
|
286
|
+
if target and target != source:
|
|
287
|
+
graph.add_edge(source, target, WEIGHT_CALL, "call")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _add_call_edges(entry: dict, source: str, class_to_file: dict,
|
|
291
|
+
field_type_index: dict, graph: DependencyGraph):
|
|
292
|
+
calls = entry.get("calls", [])
|
|
293
|
+
if not calls:
|
|
294
|
+
return
|
|
295
|
+
file_fields = field_type_index.get(source, {})
|
|
296
|
+
for receiver in calls:
|
|
297
|
+
resolved_type = file_fields.get(receiver)
|
|
298
|
+
if resolved_type:
|
|
299
|
+
clean_type = re.sub(r'<.*>', '', resolved_type).strip()
|
|
300
|
+
target = class_to_file.get(clean_type)
|
|
301
|
+
if target and target != source:
|
|
302
|
+
graph.add_edge(source, target, WEIGHT_CALL, "call")
|
|
303
|
+
elif receiver[0:1].isupper():
|
|
304
|
+
target = class_to_file.get(receiver)
|
|
305
|
+
if target and target != source:
|
|
306
|
+
graph.add_edge(source, target, WEIGHT_CALL, "call")
|