devcouncil 0.1.0 → 0.1.1
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.
- package/LICENSE +201 -201
- package/README.md +62 -543
- package/package.json +1 -1
- package/pyproject.toml +29 -26
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +135 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +143 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- package/src/devcouncil/cli/commands/artifacts.py +51 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/config.py +76 -54
- package/src/devcouncil/cli/commands/dashboard.py +26 -0
- package/src/devcouncil/cli/commands/doctor.py +86 -42
- package/src/devcouncil/cli/commands/go.py +237 -0
- package/src/devcouncil/cli/commands/hook.py +96 -29
- package/src/devcouncil/cli/commands/init.py +67 -56
- package/src/devcouncil/cli/commands/integrate.py +320 -14
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +25 -21
- package/src/devcouncil/cli/commands/plan.py +257 -206
- package/src/devcouncil/cli/commands/prompt.py +36 -33
- package/src/devcouncil/cli/commands/repair.py +72 -69
- package/src/devcouncil/cli/commands/report.py +112 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +31 -28
- package/src/devcouncil/cli/commands/rollback.py +49 -47
- package/src/devcouncil/cli/commands/run.py +252 -207
- package/src/devcouncil/cli/commands/setup.py +159 -18
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/status.py +117 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +2 -1
- package/src/devcouncil/cli/commands/verify.py +158 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/main.py +42 -24
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +27 -27
- package/src/devcouncil/domain/gap.py +26 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +26 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/hook_policy.py +24 -3
- package/src/devcouncil/execution/patch.py +28 -28
- package/src/devcouncil/execution/permissions.py +44 -44
- package/src/devcouncil/execution/prompt_builder.py +23 -23
- package/src/devcouncil/execution/task_runner.py +63 -63
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/coding_cli.py +112 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +81 -81
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +50 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +34 -34
- package/src/devcouncil/gating/policy.py +157 -157
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +120 -0
- package/src/devcouncil/indexing/repo_mapper.py +208 -204
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/gitnexus.py +27 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +549 -96
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +207 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +138 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +38 -38
- package/src/devcouncil/llm/provider.py +146 -125
- package/src/devcouncil/llm/router.py +111 -111
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/critique_service.py +66 -66
- package/src/devcouncil/planning/plan_service.py +46 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +86 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +44 -44
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +20 -17
- package/src/devcouncil/reporting/markdown_report.py +68 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/storage/db.py +66 -66
- package/src/devcouncil/storage/models.py +83 -83
- package/src/devcouncil/storage/repositories.py +299 -222
- package/src/devcouncil/telemetry/cost.py +34 -34
- package/src/devcouncil/telemetry/tracker.py +49 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +122 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +141 -141
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/verifier.py +319 -302
- package/uv.lock +1 -1
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
from typing import List, Dict, Any, Set
|
|
2
|
-
from pydantic import BaseModel, Field
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
class GraphNode(BaseModel):
|
|
6
|
-
id: str
|
|
7
|
-
type: str # "file", "symbol", "requirement", "task"
|
|
8
|
-
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
9
|
-
|
|
10
|
-
class GraphEdge(BaseModel):
|
|
11
|
-
source: str
|
|
12
|
-
target: str
|
|
13
|
-
relation: str # "imports", "implements", "validates", "contains"
|
|
14
|
-
|
|
15
|
-
class KnowledgeGraph(BaseModel):
|
|
16
|
-
nodes: List[GraphNode] = []
|
|
17
|
-
edges: List[GraphEdge] = []
|
|
18
|
-
|
|
19
|
-
class GraphIndex:
|
|
20
|
-
def __init__(self, project_root: Path):
|
|
21
|
-
self.project_root = project_root
|
|
22
|
-
self.graph = KnowledgeGraph()
|
|
23
|
-
|
|
24
|
-
def build_initial_graph(self, files: List[str]):
|
|
25
|
-
"""
|
|
26
|
-
Bootstrap the graph from file list.
|
|
27
|
-
"""
|
|
28
|
-
for f in files:
|
|
29
|
-
self.graph.nodes.append(GraphNode(
|
|
30
|
-
id=f,
|
|
31
|
-
type="file",
|
|
32
|
-
metadata={"extension": Path(f).suffix}
|
|
33
|
-
))
|
|
34
|
-
|
|
35
|
-
def add_relation(self, source: str, target: str, relation: str):
|
|
36
|
-
self.graph.edges.append(GraphEdge(source=source, target=target, relation=relation))
|
|
37
|
-
|
|
38
|
-
def get_context_for_file(self, file_path: str) -> Set[str]:
|
|
39
|
-
"""
|
|
40
|
-
Retrieve related paths for a given file.
|
|
41
|
-
"""
|
|
42
|
-
related = {file_path}
|
|
43
|
-
for edge in self.graph.edges:
|
|
44
|
-
if edge.source == file_path:
|
|
45
|
-
related.add(edge.target)
|
|
46
|
-
if edge.target == file_path:
|
|
47
|
-
related.add(edge.source)
|
|
48
|
-
return related
|
|
1
|
+
from typing import List, Dict, Any, Set
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
class GraphNode(BaseModel):
|
|
6
|
+
id: str
|
|
7
|
+
type: str # "file", "symbol", "requirement", "task"
|
|
8
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
9
|
+
|
|
10
|
+
class GraphEdge(BaseModel):
|
|
11
|
+
source: str
|
|
12
|
+
target: str
|
|
13
|
+
relation: str # "imports", "implements", "validates", "contains"
|
|
14
|
+
|
|
15
|
+
class KnowledgeGraph(BaseModel):
|
|
16
|
+
nodes: List[GraphNode] = []
|
|
17
|
+
edges: List[GraphEdge] = []
|
|
18
|
+
|
|
19
|
+
class GraphIndex:
|
|
20
|
+
def __init__(self, project_root: Path):
|
|
21
|
+
self.project_root = project_root
|
|
22
|
+
self.graph = KnowledgeGraph()
|
|
23
|
+
|
|
24
|
+
def build_initial_graph(self, files: List[str]):
|
|
25
|
+
"""
|
|
26
|
+
Bootstrap the graph from file list.
|
|
27
|
+
"""
|
|
28
|
+
for f in files:
|
|
29
|
+
self.graph.nodes.append(GraphNode(
|
|
30
|
+
id=f,
|
|
31
|
+
type="file",
|
|
32
|
+
metadata={"extension": Path(f).suffix}
|
|
33
|
+
))
|
|
34
|
+
|
|
35
|
+
def add_relation(self, source: str, target: str, relation: str):
|
|
36
|
+
self.graph.edges.append(GraphEdge(source=source, target=target, relation=relation))
|
|
37
|
+
|
|
38
|
+
def get_context_for_file(self, file_path: str) -> Set[str]:
|
|
39
|
+
"""
|
|
40
|
+
Retrieve related paths for a given file.
|
|
41
|
+
"""
|
|
42
|
+
related = {file_path}
|
|
43
|
+
for edge in self.graph.edges:
|
|
44
|
+
if edge.source == file_path:
|
|
45
|
+
related.add(edge.target)
|
|
46
|
+
if edge.target == file_path:
|
|
47
|
+
related.add(edge.source)
|
|
48
|
+
return related
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class LspServerCandidate:
|
|
12
|
+
language: str
|
|
13
|
+
command: list[str]
|
|
14
|
+
available: bool
|
|
15
|
+
reason: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LspInspector:
|
|
19
|
+
"""Starter LSP integration focused on discovery and safe initialize payloads."""
|
|
20
|
+
|
|
21
|
+
_LANGUAGE_SERVERS: dict[str, list[list[str]]] = {
|
|
22
|
+
"python": [["pyright-langserver", "--stdio"], ["pylsp"]],
|
|
23
|
+
"typescript": [["typescript-language-server", "--stdio"]],
|
|
24
|
+
"javascript": [["typescript-language-server", "--stdio"]],
|
|
25
|
+
"go": [["gopls"]],
|
|
26
|
+
"rust": [["rust-analyzer"]],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_EXTENSIONS: dict[str, str] = {
|
|
30
|
+
".py": "python",
|
|
31
|
+
".ts": "typescript",
|
|
32
|
+
".tsx": "typescript",
|
|
33
|
+
".js": "javascript",
|
|
34
|
+
".jsx": "javascript",
|
|
35
|
+
".go": "go",
|
|
36
|
+
".rs": "rust",
|
|
37
|
+
}
|
|
38
|
+
_IGNORED_DIRS = {".git", ".devcouncil", "__pycache__", ".venv", "node_modules", "dist", "build", "target", "vendor"}
|
|
39
|
+
|
|
40
|
+
def __init__(self, project_root: Path):
|
|
41
|
+
self.project_root = project_root
|
|
42
|
+
|
|
43
|
+
def _is_ignored_path(self, file: str) -> bool:
|
|
44
|
+
return any(part in self._IGNORED_DIRS for part in Path(file).parts)
|
|
45
|
+
|
|
46
|
+
def detect_languages(self, files: list[str] | None = None) -> list[str]:
|
|
47
|
+
if files is None:
|
|
48
|
+
try:
|
|
49
|
+
discovered: list[str] = []
|
|
50
|
+
for path in self.project_root.rglob("*"):
|
|
51
|
+
if path.is_file() and not any(part in self._IGNORED_DIRS for part in path.parts):
|
|
52
|
+
discovered.append(str(path.relative_to(self.project_root)))
|
|
53
|
+
files = discovered
|
|
54
|
+
except OSError:
|
|
55
|
+
files = []
|
|
56
|
+
languages = {
|
|
57
|
+
self._EXTENSIONS[Path(file).suffix.lower()]
|
|
58
|
+
for file in files
|
|
59
|
+
if Path(file).suffix.lower() in self._EXTENSIONS and not self._is_ignored_path(file)
|
|
60
|
+
}
|
|
61
|
+
return sorted(languages)
|
|
62
|
+
|
|
63
|
+
def server_candidates(self, files: list[str] | None = None) -> list[LspServerCandidate]:
|
|
64
|
+
candidates: list[LspServerCandidate] = []
|
|
65
|
+
for language in self.detect_languages(files):
|
|
66
|
+
for command in self._LANGUAGE_SERVERS.get(language, []):
|
|
67
|
+
executable = command[0]
|
|
68
|
+
available = shutil.which(executable) is not None
|
|
69
|
+
candidates.append(
|
|
70
|
+
LspServerCandidate(
|
|
71
|
+
language=language,
|
|
72
|
+
command=command,
|
|
73
|
+
available=available,
|
|
74
|
+
reason="found on PATH" if available else "not found on PATH",
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return candidates
|
|
78
|
+
|
|
79
|
+
def initialize_request(self, language: str) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"jsonrpc": "2.0",
|
|
82
|
+
"id": 1,
|
|
83
|
+
"method": "initialize",
|
|
84
|
+
"params": {
|
|
85
|
+
"processId": None,
|
|
86
|
+
"rootUri": self.project_root.resolve().as_uri(),
|
|
87
|
+
"capabilities": {
|
|
88
|
+
"textDocument": {
|
|
89
|
+
"publishDiagnostics": {"relatedInformation": True},
|
|
90
|
+
"definition": {"linkSupport": True},
|
|
91
|
+
"references": {},
|
|
92
|
+
"documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
|
|
93
|
+
},
|
|
94
|
+
"workspace": {"symbol": {}},
|
|
95
|
+
},
|
|
96
|
+
"initializationOptions": {"language": language},
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
def summary(self, files: list[str] | None = None) -> dict[str, Any]:
|
|
101
|
+
candidates = self.server_candidates(files)
|
|
102
|
+
return {
|
|
103
|
+
"languages": self.detect_languages(files),
|
|
104
|
+
"servers": [
|
|
105
|
+
{
|
|
106
|
+
"language": candidate.language,
|
|
107
|
+
"command": candidate.command,
|
|
108
|
+
"available": candidate.available,
|
|
109
|
+
"reason": candidate.reason,
|
|
110
|
+
}
|
|
111
|
+
for candidate in candidates
|
|
112
|
+
],
|
|
113
|
+
"initialize_requests": {
|
|
114
|
+
language: self.initialize_request(language)
|
|
115
|
+
for language in sorted({candidate.language for candidate in candidates})
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def summary_json(self, files: list[str] | None = None) -> str:
|
|
120
|
+
return json.dumps(self.summary(files), indent=2)
|
|
@@ -1,204 +1,208 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import logging
|
|
3
|
-
import os
|
|
4
|
-
import subprocess
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import List, Dict
|
|
7
|
-
|
|
8
|
-
from pydantic import BaseModel
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
".
|
|
60
|
-
".
|
|
61
|
-
".
|
|
62
|
-
".
|
|
63
|
-
".
|
|
64
|
-
".
|
|
65
|
-
".
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if "
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
if
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Dict
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from devcouncil.indexing.lsp import LspInspector
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
class RepoMap(BaseModel):
|
|
15
|
+
languages: List[str]
|
|
16
|
+
frameworks: List[str]
|
|
17
|
+
package_managers: List[str]
|
|
18
|
+
test_commands: List[str]
|
|
19
|
+
important_files: List[str]
|
|
20
|
+
candidate_files: List[Dict[str, str]]
|
|
21
|
+
lsp: Dict[str, object] = Field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
class RepoMapper:
|
|
24
|
+
def __init__(self, project_root: Path):
|
|
25
|
+
self.project_root = project_root
|
|
26
|
+
|
|
27
|
+
def _is_runtime_or_generated_file(self, path: str) -> bool:
|
|
28
|
+
normalized = path.replace("\\", "/")
|
|
29
|
+
parts = set(normalized.split("/"))
|
|
30
|
+
if "__pycache__" in parts or normalized.endswith(".pyc"):
|
|
31
|
+
return True
|
|
32
|
+
if parts.intersection({".git", ".devcouncil", ".pytest_cache", ".ruff_cache", ".mypy_cache", ".venv"}):
|
|
33
|
+
return True
|
|
34
|
+
if normalized.startswith("dist/") or normalized.startswith("build/"):
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
def get_git_files(self) -> List[str]:
|
|
39
|
+
try:
|
|
40
|
+
output = subprocess.check_output(
|
|
41
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
42
|
+
cwd=self.project_root,
|
|
43
|
+
stderr=subprocess.DEVNULL
|
|
44
|
+
).decode().splitlines()
|
|
45
|
+
return [path for path in output if not self._is_runtime_or_generated_file(path)]
|
|
46
|
+
except Exception:
|
|
47
|
+
# Fallback to os.walk if not a git repo or git missing
|
|
48
|
+
files = []
|
|
49
|
+
for root, _, filenames in os.walk(self.project_root):
|
|
50
|
+
for f in filenames:
|
|
51
|
+
rel_path = os.path.relpath(os.path.join(root, f), self.project_root)
|
|
52
|
+
if not rel_path.startswith(".") and not self._is_runtime_or_generated_file(rel_path):
|
|
53
|
+
files.append(rel_path)
|
|
54
|
+
return files
|
|
55
|
+
|
|
56
|
+
def detect_languages(self, files: List[str]) -> List[str]:
|
|
57
|
+
exts = {os.path.splitext(f)[1] for f in files}
|
|
58
|
+
lang_map = {
|
|
59
|
+
".py": "python",
|
|
60
|
+
".ts": "typescript",
|
|
61
|
+
".tsx": "typescript",
|
|
62
|
+
".js": "javascript",
|
|
63
|
+
".jsx": "javascript",
|
|
64
|
+
".go": "go",
|
|
65
|
+
".rs": "rust",
|
|
66
|
+
".java": "java",
|
|
67
|
+
".c": "c",
|
|
68
|
+
".cpp": "cpp",
|
|
69
|
+
}
|
|
70
|
+
return sorted(list({lang_map[ext] for ext in exts if ext in lang_map}))
|
|
71
|
+
|
|
72
|
+
def detect_frameworks(self, files: List[str]) -> List[str]:
|
|
73
|
+
frameworks = []
|
|
74
|
+
file_set = set(files)
|
|
75
|
+
if "package.json" in file_set:
|
|
76
|
+
content = (self.project_root / "package.json").read_text()
|
|
77
|
+
if "next" in content:
|
|
78
|
+
frameworks.append("nextjs")
|
|
79
|
+
if "react" in content:
|
|
80
|
+
frameworks.append("react")
|
|
81
|
+
if "vue" in content:
|
|
82
|
+
frameworks.append("vue")
|
|
83
|
+
if "express" in content:
|
|
84
|
+
frameworks.append("express")
|
|
85
|
+
|
|
86
|
+
if "requirements.txt" in file_set or "pyproject.toml" in file_set:
|
|
87
|
+
try:
|
|
88
|
+
content = ""
|
|
89
|
+
if "requirements.txt" in file_set:
|
|
90
|
+
content += (self.project_root / "requirements.txt").read_text()
|
|
91
|
+
if "pyproject.toml" in file_set:
|
|
92
|
+
content += (self.project_root / "pyproject.toml").read_text()
|
|
93
|
+
|
|
94
|
+
if "fastapi" in content.lower():
|
|
95
|
+
frameworks.append("fastapi")
|
|
96
|
+
if "flask" in content.lower():
|
|
97
|
+
frameworks.append("flask")
|
|
98
|
+
if "django" in content.lower():
|
|
99
|
+
frameworks.append("django")
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.debug("Failed to read Python config files: %s", e)
|
|
102
|
+
return frameworks
|
|
103
|
+
|
|
104
|
+
def detect_package_managers(self, files: List[str]) -> List[str]:
|
|
105
|
+
managers = []
|
|
106
|
+
file_set = set(files)
|
|
107
|
+
if "package-lock.json" in file_set:
|
|
108
|
+
managers.append("npm")
|
|
109
|
+
elif "package.json" in file_set:
|
|
110
|
+
managers.append("npm")
|
|
111
|
+
if "yarn.lock" in file_set:
|
|
112
|
+
managers.append("yarn")
|
|
113
|
+
if "pnpm-lock.yaml" in file_set:
|
|
114
|
+
managers.append("pnpm")
|
|
115
|
+
if "requirements.txt" in file_set:
|
|
116
|
+
managers.append("pip")
|
|
117
|
+
if "uv.lock" in file_set:
|
|
118
|
+
managers.append("uv")
|
|
119
|
+
if "go.sum" in file_set:
|
|
120
|
+
managers.append("go mod")
|
|
121
|
+
return managers
|
|
122
|
+
|
|
123
|
+
def detect_test_commands(self, files: List[str]) -> List[str]:
|
|
124
|
+
"""Detect test, lint, and typecheck commands from project config."""
|
|
125
|
+
commands: List[str] = []
|
|
126
|
+
file_set = set(files)
|
|
127
|
+
|
|
128
|
+
# Node.js projects: read scripts from package.json
|
|
129
|
+
if "package.json" in file_set:
|
|
130
|
+
try:
|
|
131
|
+
pkg = json.loads((self.project_root / "package.json").read_text())
|
|
132
|
+
scripts = pkg.get("scripts", {})
|
|
133
|
+
pm = "pnpm" if "pnpm-lock.yaml" in file_set else (
|
|
134
|
+
"yarn" if "yarn.lock" in file_set else "npm"
|
|
135
|
+
)
|
|
136
|
+
for key in ["test", "lint", "typecheck", "check", "type-check"]:
|
|
137
|
+
if key in scripts:
|
|
138
|
+
if pm == "npm" and key != "test":
|
|
139
|
+
commands.append(f"npm run {key}")
|
|
140
|
+
else:
|
|
141
|
+
commands.append(f"{pm} {key}")
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.debug("Failed to parse package.json scripts: %s", e)
|
|
144
|
+
|
|
145
|
+
# Python projects
|
|
146
|
+
if "pyproject.toml" in file_set or "setup.py" in file_set:
|
|
147
|
+
if any(f.startswith("tests/") or f.startswith("test_") for f in files):
|
|
148
|
+
commands.append("pytest")
|
|
149
|
+
commands.append("ruff check .")
|
|
150
|
+
commands.append("mypy .")
|
|
151
|
+
|
|
152
|
+
# Go projects
|
|
153
|
+
if "go.mod" in file_set:
|
|
154
|
+
commands.append("go test ./...")
|
|
155
|
+
commands.append("go vet ./...")
|
|
156
|
+
|
|
157
|
+
# Rust projects
|
|
158
|
+
if "Cargo.toml" in file_set:
|
|
159
|
+
commands.append("cargo test")
|
|
160
|
+
commands.append("cargo clippy")
|
|
161
|
+
|
|
162
|
+
return commands
|
|
163
|
+
|
|
164
|
+
def _ripgrep_search(self, goal: str, files: List[str]) -> List[Dict[str, str]]:
|
|
165
|
+
"""Use ripgrep for goal-keyword search if available, else fall back to naive matching."""
|
|
166
|
+
candidates: List[Dict[str, str]] = []
|
|
167
|
+
try:
|
|
168
|
+
# Try ripgrep first for better matching
|
|
169
|
+
result = subprocess.run(
|
|
170
|
+
["rg", "--files-with-matches", "--ignore-case", "--glob", "!.git", goal],
|
|
171
|
+
capture_output=True, text=True, cwd=self.project_root, timeout=10,
|
|
172
|
+
)
|
|
173
|
+
if result.returncode == 0:
|
|
174
|
+
for line in result.stdout.strip().splitlines()[:10]:
|
|
175
|
+
candidates.append({"path": line.strip(), "reason": f"ripgrep match for '{goal}'"})
|
|
176
|
+
return candidates
|
|
177
|
+
except Exception:
|
|
178
|
+
pass # Fall back to naive matching
|
|
179
|
+
|
|
180
|
+
# Naive keyword matching fallback
|
|
181
|
+
goal_words = set(goal.lower().split())
|
|
182
|
+
for f in files:
|
|
183
|
+
f_lower = f.lower()
|
|
184
|
+
score = sum(1 for word in goal_words if word in f_lower)
|
|
185
|
+
if score > 0:
|
|
186
|
+
candidates.append({"path": f, "reason": f"Matches goal keywords (score: {score})"})
|
|
187
|
+
candidates = sorted(candidates, key=lambda x: x.get("reason", ""), reverse=True)[:10]
|
|
188
|
+
return candidates
|
|
189
|
+
|
|
190
|
+
def map_repo(self, goal: str = "") -> RepoMap:
|
|
191
|
+
files = self.get_git_files()
|
|
192
|
+
|
|
193
|
+
candidates: List[Dict[str, str]] = []
|
|
194
|
+
if goal:
|
|
195
|
+
candidates = self._ripgrep_search(goal, files)
|
|
196
|
+
|
|
197
|
+
return RepoMap(
|
|
198
|
+
languages=self.detect_languages(files),
|
|
199
|
+
frameworks=self.detect_frameworks(files),
|
|
200
|
+
package_managers=self.detect_package_managers(files),
|
|
201
|
+
test_commands=self.detect_test_commands(files),
|
|
202
|
+
important_files=[f for f in files if f in [
|
|
203
|
+
"package.json", "pyproject.toml", "README.md", "go.mod",
|
|
204
|
+
"Cargo.toml", "Makefile", "Dockerfile", ".github/workflows",
|
|
205
|
+
]],
|
|
206
|
+
candidate_files=candidates,
|
|
207
|
+
lsp=LspInspector(self.project_root).summary(files),
|
|
208
|
+
)
|