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,71 @@
|
|
|
1
|
+
"""Skeleton parser registry.
|
|
2
|
+
|
|
3
|
+
Parsers are registered by name and selected based on preset configuration.
|
|
4
|
+
The parser chain tries each parser in priority order until one succeeds.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from core.skeleton.parsers import get_parser_chain, register_parser
|
|
8
|
+
|
|
9
|
+
chain = get_parser_chain(preset)
|
|
10
|
+
for parser in chain:
|
|
11
|
+
if parser.can_parse(repo_path, preset):
|
|
12
|
+
entries = parser.parse(repo_path, preset)
|
|
13
|
+
break
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from core.interfaces import SkeletonParser
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_REGISTRY: dict[str, type[SkeletonParser]] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register_parser(cls: type[SkeletonParser]) -> type[SkeletonParser]:
|
|
30
|
+
"""Decorator to register a parser class by its `name` attribute."""
|
|
31
|
+
_REGISTRY[cls.name] = cls
|
|
32
|
+
return cls
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_parser_chain(preset: dict[str, Any]) -> list[SkeletonParser]:
|
|
36
|
+
"""Return parser instances in preset-declared priority order.
|
|
37
|
+
|
|
38
|
+
Falls back to ["treesitter", "regex"] if preset doesn't declare parsers.
|
|
39
|
+
Skips unknown parser names with a warning.
|
|
40
|
+
"""
|
|
41
|
+
names = preset.get("parsers", ["treesitter", "regex"])
|
|
42
|
+
chain: list[SkeletonParser] = []
|
|
43
|
+
for name in names:
|
|
44
|
+
cls = _REGISTRY.get(name)
|
|
45
|
+
if cls is None:
|
|
46
|
+
logger.warning("Unknown parser '%s' in preset config, skipping", name)
|
|
47
|
+
continue
|
|
48
|
+
chain.append(cls())
|
|
49
|
+
return chain
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def available_parsers() -> list[str]:
|
|
53
|
+
"""Return names of all registered parsers."""
|
|
54
|
+
return sorted(_REGISTRY.keys())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_parser(name: str) -> SkeletonParser:
|
|
58
|
+
"""Get a specific parser by name. Raises KeyError if not found."""
|
|
59
|
+
cls = _REGISTRY.get(name)
|
|
60
|
+
if cls is None:
|
|
61
|
+
raise KeyError(f"Parser '{name}' not registered. Available: {available_parsers()}")
|
|
62
|
+
return cls()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Auto-import parser submodules to trigger @register_parser decorators.
|
|
67
|
+
# Without these imports, the decorator never executes and _REGISTRY stays empty.
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
from core.skeleton.parsers import treesitter # noqa: F401, E402
|
|
70
|
+
from core.skeleton.parsers import jqassistant # noqa: F401, E402
|
|
71
|
+
from core.skeleton.parsers import regex # noqa: F401, E402
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""jQAssistant-based skeleton parser — Java bytecode analysis.
|
|
2
|
+
|
|
3
|
+
Uses jQAssistant + embedded Neo4j to extract precise class/method/field/annotation
|
|
4
|
+
data from compiled .class files. Falls back gracefully if unavailable.
|
|
5
|
+
|
|
6
|
+
Requires: jQAssistant CLI on PATH (or JQASSISTANT_HOME), Java 11+,
|
|
7
|
+
compiled .class files (target/classes or build/classes).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from core.interfaces import SkeletonParser
|
|
23
|
+
from core.skeleton.parsers import register_parser
|
|
24
|
+
from core.skeleton.parsers.jqassistant_cypher import (
|
|
25
|
+
CYPHER_ANNOTATIONS, CYPHER_CALL_GRAPH, CYPHER_CLASSES,
|
|
26
|
+
CYPHER_FIELDS, CYPHER_METHODS,
|
|
27
|
+
apply_annotations, apply_fields, apply_methods,
|
|
28
|
+
build_call_graph_edges, build_class_entries, parse_cypher_output,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_available() -> bool:
|
|
35
|
+
"""Check if jQAssistant CLI is available."""
|
|
36
|
+
return _find_jqa_command() is not None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_jqa_command() -> str | None:
|
|
40
|
+
"""Locate jQAssistant CLI executable."""
|
|
41
|
+
for cmd in ("jqassistant", "jqassistant.cmd", "jqassistant.bat"):
|
|
42
|
+
if shutil.which(cmd):
|
|
43
|
+
return cmd
|
|
44
|
+
jqa_home = os.environ.get("JQASSISTANT_HOME")
|
|
45
|
+
if jqa_home:
|
|
46
|
+
for suffix in ("bin/jqassistant", "bin/jqassistant.cmd", "bin/jqassistant.bat"):
|
|
47
|
+
full = os.path.join(jqa_home, suffix)
|
|
48
|
+
if os.path.isfile(full):
|
|
49
|
+
return full
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@register_parser
|
|
54
|
+
class JQAssistantParser(SkeletonParser):
|
|
55
|
+
"""jQAssistant parser. Registered as 'jqassistant'. Java-only, highest priority."""
|
|
56
|
+
|
|
57
|
+
name = "jqassistant"
|
|
58
|
+
priority = 100
|
|
59
|
+
|
|
60
|
+
def can_parse(self, repo_path: Path, preset: dict[str, Any]) -> bool:
|
|
61
|
+
"""Available only if jQAssistant CLI exists AND compiled classes are present."""
|
|
62
|
+
if not is_available():
|
|
63
|
+
return False
|
|
64
|
+
candidates = [
|
|
65
|
+
repo_path / "target" / "classes",
|
|
66
|
+
repo_path / "build" / "classes" / "java" / "main",
|
|
67
|
+
]
|
|
68
|
+
# Multi-module: check sub-modules
|
|
69
|
+
for sub in repo_path.iterdir():
|
|
70
|
+
if sub.is_dir() and (sub / "pom.xml").exists():
|
|
71
|
+
candidates.append(sub / "target" / "classes")
|
|
72
|
+
return any(d.is_dir() and any(d.rglob("*.class")) for d in candidates)
|
|
73
|
+
|
|
74
|
+
def parse(self, repo_path: Path, preset: dict[str, Any], **kwargs: Any) -> list[dict[str, Any]]:
|
|
75
|
+
"""Scan compiled classes and extract skeleton entries via Cypher queries."""
|
|
76
|
+
jqa_cmd = _find_jqa_command()
|
|
77
|
+
if not jqa_cmd:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
classes_dir = self._find_classes_dir(repo_path)
|
|
81
|
+
if not classes_dir:
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
store_dir = tempfile.mkdtemp(prefix="jqa-")
|
|
85
|
+
try:
|
|
86
|
+
if not self._run_scan(jqa_cmd, store_dir, classes_dir):
|
|
87
|
+
return []
|
|
88
|
+
return self._query_all_skeleton(jqa_cmd, store_dir, repo_path)
|
|
89
|
+
finally:
|
|
90
|
+
shutil.rmtree(store_dir, ignore_errors=True)
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# Internal helpers
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _find_classes_dir(self, repo_path: Path) -> Path | None:
|
|
97
|
+
"""Find the compiled classes directory."""
|
|
98
|
+
candidates = [
|
|
99
|
+
repo_path / "target" / "classes",
|
|
100
|
+
repo_path / "build" / "classes" / "java" / "main",
|
|
101
|
+
]
|
|
102
|
+
for d in candidates:
|
|
103
|
+
if d.is_dir() and any(d.rglob("*.class")):
|
|
104
|
+
return d
|
|
105
|
+
# Multi-module: find first sub-module with classes
|
|
106
|
+
for sub in sorted(repo_path.iterdir()):
|
|
107
|
+
if sub.is_dir() and (sub / "target" / "classes").is_dir():
|
|
108
|
+
return sub / "target" / "classes"
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def _run_scan(self, jqa_cmd: str, store_dir: str, classes_dir: Path) -> bool:
|
|
112
|
+
"""Run jQAssistant scan on classes directory."""
|
|
113
|
+
try:
|
|
114
|
+
result = subprocess.run(
|
|
115
|
+
[jqa_cmd, "scan", "-f", str(classes_dir), "-s", store_dir],
|
|
116
|
+
capture_output=True, encoding="utf-8", errors="replace", timeout=300,
|
|
117
|
+
)
|
|
118
|
+
return result.returncode == 0
|
|
119
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
120
|
+
logger.warning("jQAssistant scan failed: %s", e)
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
def _query_all_skeleton(
|
|
124
|
+
self, jqa_cmd: str, store_dir: str, repo_path: Path
|
|
125
|
+
) -> list[dict[str, Any]]:
|
|
126
|
+
"""Query Neo4j store for all skeleton data and assemble entries."""
|
|
127
|
+
classes_data = self._run_cypher(jqa_cmd, store_dir, repo_path, CYPHER_CLASSES)
|
|
128
|
+
methods_data = self._run_cypher(jqa_cmd, store_dir, repo_path, CYPHER_METHODS)
|
|
129
|
+
fields_data = self._run_cypher(jqa_cmd, store_dir, repo_path, CYPHER_FIELDS)
|
|
130
|
+
annotations_data = self._run_cypher(jqa_cmd, store_dir, repo_path, CYPHER_ANNOTATIONS)
|
|
131
|
+
|
|
132
|
+
cache = build_class_entries(classes_data)
|
|
133
|
+
apply_annotations(cache, annotations_data)
|
|
134
|
+
apply_methods(cache, methods_data)
|
|
135
|
+
apply_fields(cache, fields_data)
|
|
136
|
+
|
|
137
|
+
return list(cache.values())
|
|
138
|
+
|
|
139
|
+
def _run_cypher(
|
|
140
|
+
self, jqa_cmd: str, store_dir: str, repo_path: Path, query: str
|
|
141
|
+
) -> list[dict]:
|
|
142
|
+
"""Execute a Cypher query against the jQAssistant store."""
|
|
143
|
+
with tempfile.NamedTemporaryFile(
|
|
144
|
+
mode="w", suffix=".cypher", delete=False
|
|
145
|
+
) as f:
|
|
146
|
+
f.write(query)
|
|
147
|
+
query_file = f.name
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
cmd = [
|
|
151
|
+
jqa_cmd, "report",
|
|
152
|
+
"-s", store_dir,
|
|
153
|
+
"-concepts", query_file,
|
|
154
|
+
"-reportFormat", "json",
|
|
155
|
+
]
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
cmd,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
encoding="utf-8",
|
|
160
|
+
errors="replace",
|
|
161
|
+
timeout=120,
|
|
162
|
+
cwd=str(repo_path),
|
|
163
|
+
)
|
|
164
|
+
if result.returncode != 0:
|
|
165
|
+
logger.warning("Cypher query failed: %s", result.stderr[:500])
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
data = json.loads(result.stdout)
|
|
170
|
+
return data if isinstance(data, list) else data.get("rows", [])
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
return parse_cypher_output(result.stdout)
|
|
173
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
174
|
+
logger.warning("Cypher query error: %s", e)
|
|
175
|
+
return []
|
|
176
|
+
finally:
|
|
177
|
+
os.unlink(query_file)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
# Source-only fallback parsing (when jQAssistant scan is unavailable)
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
_CLASS_PATTERN = re.compile(
|
|
185
|
+
r'(?:public|protected|private)?\s*(?:abstract|final|static)?\s*'
|
|
186
|
+
r'(class|interface|enum|record)\s+(\w+)'
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
_METHOD_PATTERN = re.compile(
|
|
190
|
+
r'(?:public|protected|private|static|\s)+[\w<>\[\],\s]+\s+(\w+)\s*\('
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
_CONSTANT_PATTERN = re.compile(
|
|
194
|
+
r'(?:public|private|protected)?\s*static\s+final\s+'
|
|
195
|
+
r'(\w+)\s+([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*;'
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def parse_source_only(content: str, filepath: str) -> dict[str, Any] | None:
|
|
200
|
+
"""Fallback: extract basic skeleton from source when jQAssistant data unavailable."""
|
|
201
|
+
lines = content.split("\n")
|
|
202
|
+
if not lines:
|
|
203
|
+
return None
|
|
204
|
+
entry: dict[str, Any] = {
|
|
205
|
+
"file": filepath, "total_lines": len(lines),
|
|
206
|
+
"classes": [], "methods": [], "fields": [],
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for line in lines:
|
|
210
|
+
m = _CLASS_PATTERN.search(line)
|
|
211
|
+
if m:
|
|
212
|
+
entry["classes"].append({
|
|
213
|
+
"name": m.group(2),
|
|
214
|
+
"type": m.group(1),
|
|
215
|
+
"annotations": [],
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
method_metrics = _extract_method_line_counts(content)
|
|
219
|
+
for name, metrics in method_metrics.items():
|
|
220
|
+
entry["methods"].append({
|
|
221
|
+
"name": name, "access": "public", "static": False,
|
|
222
|
+
"return_type": "", "params": "", "annotations": [],
|
|
223
|
+
"line_count": metrics["line_count"],
|
|
224
|
+
"complexity": metrics["complexity"],
|
|
225
|
+
"branches": metrics["branches"], "constants": [],
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
return entry if entry["classes"] or entry["methods"] else None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def enrich_with_source_metrics(entry: dict[str, Any], content: str) -> None:
|
|
232
|
+
"""Enrich jQAssistant entry with source-level metrics (line counts, complexity)."""
|
|
233
|
+
entry["total_lines"] = len(content.split("\n"))
|
|
234
|
+
method_lines = _extract_method_line_counts(content)
|
|
235
|
+
for method in entry.get("methods", []):
|
|
236
|
+
name = method.get("name", "")
|
|
237
|
+
if name in method_lines:
|
|
238
|
+
method["line_count"] = method_lines[name]["line_count"]
|
|
239
|
+
method["complexity"] = method_lines[name]["complexity"]
|
|
240
|
+
method["branches"] = method_lines[name]["branches"]
|
|
241
|
+
constants = _extract_constants(content)
|
|
242
|
+
if constants:
|
|
243
|
+
entry.setdefault("constants", []).extend(constants)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _extract_method_line_counts(content: str) -> dict[str, dict]:
|
|
247
|
+
"""Extract method line counts and complexity from source."""
|
|
248
|
+
results: dict[str, dict] = {}
|
|
249
|
+
lines = content.split("\n")
|
|
250
|
+
branch_keywords = (
|
|
251
|
+
"if ", "if(", "else ", "for ", "for(", "while ", "while(",
|
|
252
|
+
"switch ", "switch(", "catch ", "catch(", "case ",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
i = 0
|
|
256
|
+
while i < len(lines):
|
|
257
|
+
match = _METHOD_PATTERN.search(lines[i])
|
|
258
|
+
if match:
|
|
259
|
+
name = match.group(1)
|
|
260
|
+
start = i
|
|
261
|
+
brace_count = 0
|
|
262
|
+
found_open = False
|
|
263
|
+
branches = 0
|
|
264
|
+
|
|
265
|
+
for j in range(i, len(lines)):
|
|
266
|
+
line = lines[j]
|
|
267
|
+
brace_count += line.count("{") - line.count("}")
|
|
268
|
+
if "{" in line:
|
|
269
|
+
found_open = True
|
|
270
|
+
if found_open and brace_count <= 0:
|
|
271
|
+
line_count = j - start + 1
|
|
272
|
+
complexity = (
|
|
273
|
+
"low" if branches <= 2
|
|
274
|
+
else ("medium" if branches <= 5 else "high")
|
|
275
|
+
)
|
|
276
|
+
results[name] = {
|
|
277
|
+
"line_count": line_count,
|
|
278
|
+
"complexity": complexity,
|
|
279
|
+
"branches": branches,
|
|
280
|
+
}
|
|
281
|
+
i = j
|
|
282
|
+
break
|
|
283
|
+
|
|
284
|
+
if any(kw in line for kw in branch_keywords):
|
|
285
|
+
branches += 1
|
|
286
|
+
i += 1
|
|
287
|
+
|
|
288
|
+
return results
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _extract_constants(content: str) -> list[dict[str, str]]:
|
|
292
|
+
"""Extract static final constants from source."""
|
|
293
|
+
constants: list[dict[str, str]] = []
|
|
294
|
+
for match in _CONSTANT_PATTERN.finditer(content):
|
|
295
|
+
constants.append({
|
|
296
|
+
"name": match.group(2),
|
|
297
|
+
"type": match.group(1),
|
|
298
|
+
"value": match.group(3)[:100],
|
|
299
|
+
})
|
|
300
|
+
return constants
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Cypher query templates and data transformation for jQAssistant parser.
|
|
2
|
+
|
|
3
|
+
Contains all Cypher queries used to extract skeleton data from jQAssistant's
|
|
4
|
+
embedded Neo4j graph database, plus helper functions that transform raw query
|
|
5
|
+
results into the skeleton entry format expected by the parser chain.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
# ============================================================
|
|
13
|
+
# Cypher Query Templates
|
|
14
|
+
# ============================================================
|
|
15
|
+
|
|
16
|
+
CYPHER_CLASSES = """
|
|
17
|
+
MATCH (t:Type:Class)
|
|
18
|
+
WHERE NOT t:Inner
|
|
19
|
+
RETURN t.fqn AS fqn, t.name AS name, 'class' AS type,
|
|
20
|
+
COALESCE(t.superClass, '') AS superClass,
|
|
21
|
+
[(t)-[:IMPLEMENTS]->(i) | i.fqn] AS interfaces
|
|
22
|
+
UNION ALL
|
|
23
|
+
MATCH (t:Type:Interface)
|
|
24
|
+
RETURN t.fqn AS fqn, t.name AS name, 'interface' AS type,
|
|
25
|
+
'' AS superClass, [] AS interfaces
|
|
26
|
+
UNION ALL
|
|
27
|
+
MATCH (t:Type:Enum)
|
|
28
|
+
RETURN t.fqn AS fqn, t.name AS name, 'enum' AS type,
|
|
29
|
+
'' AS superClass, [] AS interfaces
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
CYPHER_METHODS = """
|
|
33
|
+
MATCH (t:Type)-[:DECLARES]->(m:Method)
|
|
34
|
+
WHERE NOT m.name STARTS WITH '<' AND NOT t:Inner
|
|
35
|
+
RETURN t.fqn AS classFqn, m.name AS name,
|
|
36
|
+
m.visibility AS visibility,
|
|
37
|
+
m.static AS static,
|
|
38
|
+
COALESCE(m.returnType, 'void') AS returnType,
|
|
39
|
+
[(m)-[:HAS]->(p:Parameter) | p.type + ' ' + p.name] AS params,
|
|
40
|
+
[(m)-[:ANNOTATED_BY]->(a) | '@' + last(split(a.fqn, '.'))] AS annotations
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
CYPHER_FIELDS = """
|
|
44
|
+
MATCH (t:Type)-[:DECLARES]->(f:Field)
|
|
45
|
+
WHERE NOT t:Inner
|
|
46
|
+
RETURN t.fqn AS classFqn, f.name AS name,
|
|
47
|
+
COALESCE(f.type, '') AS type,
|
|
48
|
+
[(f)-[:ANNOTATED_BY]->(a) | '@' + last(split(a.fqn, '.'))] AS annotations
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
CYPHER_ANNOTATIONS = """
|
|
52
|
+
MATCH (t:Type)-[:ANNOTATED_BY]->(a:Annotation)-[:OF_TYPE]->(at:Type)
|
|
53
|
+
WHERE NOT t:Inner
|
|
54
|
+
RETURN t.fqn AS classFqn, t.name AS className,
|
|
55
|
+
at.fqn AS annotationType,
|
|
56
|
+
'class' AS target,
|
|
57
|
+
'' AS methodName,
|
|
58
|
+
COALESCE(a.value, '') AS value
|
|
59
|
+
UNION ALL
|
|
60
|
+
MATCH (t:Type)-[:DECLARES]->(m:Method)-[:ANNOTATED_BY]->(a:Annotation)-[:OF_TYPE]->(at:Type)
|
|
61
|
+
WHERE NOT t:Inner AND NOT m.name STARTS WITH '<'
|
|
62
|
+
RETURN t.fqn AS classFqn, t.name AS className,
|
|
63
|
+
at.fqn AS annotationType,
|
|
64
|
+
'method' AS target,
|
|
65
|
+
m.name AS methodName,
|
|
66
|
+
COALESCE(a.value, '') AS value
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
CYPHER_CALL_GRAPH = """
|
|
70
|
+
MATCH (caller:Type)-[:DECLARES]->(m:Method)-[:INVOKES]->(callee_m:Method)<-[:DECLARES]-(callee:Type)
|
|
71
|
+
WHERE NOT caller:Inner AND NOT callee:Inner
|
|
72
|
+
AND caller <> callee
|
|
73
|
+
AND NOT m.name STARTS WITH '<'
|
|
74
|
+
AND NOT callee_m.name STARTS WITH '<'
|
|
75
|
+
RETURN caller.fqn AS callerClass, callee.fqn AS calleeClass,
|
|
76
|
+
callee_m.name AS calleeMethod
|
|
77
|
+
ORDER BY callerClass, calleeClass
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
# All queries grouped for convenience
|
|
81
|
+
CYPHER_QUERIES = {
|
|
82
|
+
"classes": CYPHER_CLASSES,
|
|
83
|
+
"methods": CYPHER_METHODS,
|
|
84
|
+
"fields": CYPHER_FIELDS,
|
|
85
|
+
"annotations": CYPHER_ANNOTATIONS,
|
|
86
|
+
"call_graph": CYPHER_CALL_GRAPH,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ============================================================
|
|
91
|
+
# Data Transformation Functions
|
|
92
|
+
# ============================================================
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fqn_to_source_path(fqn: str) -> str:
|
|
96
|
+
"""Convert Java FQN to source file path.
|
|
97
|
+
|
|
98
|
+
com.example.service.MyService → src/main/java/com/example/service/MyService.java
|
|
99
|
+
"""
|
|
100
|
+
if not fqn:
|
|
101
|
+
return ""
|
|
102
|
+
parts = fqn.replace(".", "/")
|
|
103
|
+
if "$" in parts:
|
|
104
|
+
parts = parts.split("$")[0]
|
|
105
|
+
return f"src/main/java/{parts}.java"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_class_entries(classes_data: list[dict]) -> dict[str, dict[str, Any]]:
|
|
109
|
+
"""Transform raw class query results into skeleton cache entries.
|
|
110
|
+
|
|
111
|
+
Returns a dict keyed by source file path.
|
|
112
|
+
"""
|
|
113
|
+
cache: dict[str, dict[str, Any]] = {}
|
|
114
|
+
for row in classes_data:
|
|
115
|
+
fqn = row.get("fqn", "")
|
|
116
|
+
source_file = fqn_to_source_path(fqn)
|
|
117
|
+
if not source_file:
|
|
118
|
+
continue
|
|
119
|
+
if source_file not in cache:
|
|
120
|
+
cache[source_file] = {
|
|
121
|
+
"file": source_file,
|
|
122
|
+
"total_lines": 0,
|
|
123
|
+
"classes": [],
|
|
124
|
+
"methods": [],
|
|
125
|
+
"fields": [],
|
|
126
|
+
}
|
|
127
|
+
cache[source_file]["classes"].append({
|
|
128
|
+
"name": row.get("name", ""),
|
|
129
|
+
"type": row.get("type", "class").lower(),
|
|
130
|
+
"annotations": [],
|
|
131
|
+
"extends": row.get("superClass", ""),
|
|
132
|
+
"implements": row.get("interfaces", []),
|
|
133
|
+
})
|
|
134
|
+
return cache
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def apply_annotations(cache: dict[str, dict], annotations_data: list[dict]) -> None:
|
|
138
|
+
"""Apply annotation data to classes and methods in the cache (mutates in place)."""
|
|
139
|
+
for row in annotations_data:
|
|
140
|
+
source_file = fqn_to_source_path(row.get("classFqn", ""))
|
|
141
|
+
if source_file not in cache:
|
|
142
|
+
continue
|
|
143
|
+
anno_name = "@" + row.get("annotationType", "").split(".")[-1]
|
|
144
|
+
anno_value = row.get("value", "")
|
|
145
|
+
anno_str = f"{anno_name}({anno_value})" if anno_value else anno_name
|
|
146
|
+
|
|
147
|
+
target = row.get("target", "class")
|
|
148
|
+
if target == "class":
|
|
149
|
+
for cls in cache[source_file]["classes"]:
|
|
150
|
+
if cls["name"] == row.get("className", ""):
|
|
151
|
+
cls["annotations"].append(anno_str)
|
|
152
|
+
break
|
|
153
|
+
elif target == "method":
|
|
154
|
+
for m in cache[source_file]["methods"]:
|
|
155
|
+
if m["name"] == row.get("methodName", ""):
|
|
156
|
+
m.setdefault("annotations", []).append(anno_str)
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def apply_methods(cache: dict[str, dict], methods_data: list[dict]) -> None:
|
|
161
|
+
"""Apply method data to the cache (mutates in place)."""
|
|
162
|
+
for row in methods_data:
|
|
163
|
+
source_file = fqn_to_source_path(row.get("classFqn", ""))
|
|
164
|
+
if source_file not in cache:
|
|
165
|
+
continue
|
|
166
|
+
cache[source_file]["methods"].append({
|
|
167
|
+
"name": row.get("name", ""),
|
|
168
|
+
"access": row.get("visibility", "public").lower(),
|
|
169
|
+
"static": row.get("static", False),
|
|
170
|
+
"return_type": row.get("returnType", "void"),
|
|
171
|
+
"params": row.get("params", ""),
|
|
172
|
+
"annotations": row.get("annotations", []),
|
|
173
|
+
"line_count": 0,
|
|
174
|
+
"complexity": "low",
|
|
175
|
+
"branches": 0,
|
|
176
|
+
"constants": [],
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def apply_fields(cache: dict[str, dict], fields_data: list[dict]) -> None:
|
|
181
|
+
"""Apply field data to the cache (mutates in place)."""
|
|
182
|
+
for row in fields_data:
|
|
183
|
+
source_file = fqn_to_source_path(row.get("classFqn", ""))
|
|
184
|
+
if source_file not in cache:
|
|
185
|
+
continue
|
|
186
|
+
cache[source_file]["fields"].append({
|
|
187
|
+
"name": row.get("name", ""),
|
|
188
|
+
"type": row.get("type", "").split(".")[-1],
|
|
189
|
+
"annotations": row.get("annotations", []),
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_call_graph_edges(results: list[dict]) -> list[dict[str, Any]]:
|
|
194
|
+
"""Aggregate call graph results into caller-callee edge list.
|
|
195
|
+
|
|
196
|
+
Returns: [{"caller": "OrderService", "callee": "PaymentService", "methods": ["pay"]}]
|
|
197
|
+
"""
|
|
198
|
+
edges: dict[tuple[str, str], list[str]] = {}
|
|
199
|
+
for row in results:
|
|
200
|
+
caller = row.get("callerClass", "").split(".")[-1]
|
|
201
|
+
callee = row.get("calleeClass", "").split(".")[-1]
|
|
202
|
+
method = row.get("calleeMethod", "")
|
|
203
|
+
if caller and callee and caller != callee:
|
|
204
|
+
key = (caller, callee)
|
|
205
|
+
edges.setdefault(key, []).append(method)
|
|
206
|
+
|
|
207
|
+
return [
|
|
208
|
+
{"caller": k[0], "callee": k[1], "methods": sorted(set(v))[:10]}
|
|
209
|
+
for k, v in edges.items()
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_cypher_output(output: str) -> list[dict]:
|
|
214
|
+
"""Parse non-JSON Cypher output (tab-separated or similar)."""
|
|
215
|
+
rows: list[dict] = []
|
|
216
|
+
lines = output.strip().split("\n")
|
|
217
|
+
if len(lines) < 2:
|
|
218
|
+
return rows
|
|
219
|
+
|
|
220
|
+
headers = [h.strip() for h in lines[0].split("\t")]
|
|
221
|
+
for line in lines[1:]:
|
|
222
|
+
values = [v.strip() for v in line.split("\t")]
|
|
223
|
+
if len(values) == len(headers):
|
|
224
|
+
rows.append(dict(zip(headers, values)))
|
|
225
|
+
return rows
|