devcouncil 0.1.0 → 0.2.0
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 +197 -494
- package/package.json +9 -2
- package/pyproject.toml +62 -27
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +297 -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 +163 -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/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +54 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +115 -54
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +31 -0
- package/src/devcouncil/cli/commands/doctor.py +291 -47
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +656 -0
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +209 -33
- package/src/devcouncil/cli/commands/init.py +204 -57
- package/src/devcouncil/cli/commands/integrate.py +1171 -76
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +96 -22
- package/src/devcouncil/cli/commands/plan.py +422 -210
- package/src/devcouncil/cli/commands/prompt.py +48 -34
- package/src/devcouncil/cli/commands/repair.py +89 -69
- package/src/devcouncil/cli/commands/report.py +120 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
- package/src/devcouncil/cli/commands/rollback.py +55 -54
- package/src/devcouncil/cli/commands/run.py +285 -220
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +300 -20
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +141 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +49 -4
- package/src/devcouncil/cli/commands/verify.py +293 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +92 -25
- 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 +47 -27
- package/src/devcouncil/domain/gap.py +52 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +55 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +186 -77
- package/src/devcouncil/execution/patch.py +77 -28
- package/src/devcouncil/execution/permissions.py +52 -59
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +650 -38
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +68 -64
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +736 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +186 -85
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +52 -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 +53 -34
- package/src/devcouncil/gating/policy.py +315 -167
- package/src/devcouncil/hardware.py +184 -0
- 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 +161 -0
- package/src/devcouncil/indexing/repo_mapper.py +1455 -204
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +62 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +2072 -96
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +349 -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 +141 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +42 -38
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +627 -125
- package/src/devcouncil/llm/router.py +303 -118
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +71 -66
- package/src/devcouncil/planning/plan_service.py +60 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +70 -44
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +30 -17
- package/src/devcouncil/reporting/markdown_report.py +83 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +330 -0
- package/src/devcouncil/storage/db.py +147 -66
- package/src/devcouncil/storage/models.py +204 -83
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +388 -249
- package/src/devcouncil/telemetry/cost.py +140 -34
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +52 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +423 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +147 -141
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1342 -307
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Host hardware detection used to size local (Ollama) models.
|
|
2
|
+
|
|
3
|
+
DevCouncil runs the council roles against whatever LLM provider is configured.
|
|
4
|
+
For the local ``ollama`` provider the model has to fit in host memory. On Apple
|
|
5
|
+
Silicon Macs that memory is *unified* (shared by CPU/GPU/OS), so total RAM is the
|
|
6
|
+
ceiling. On a host with a discrete GPU (NVIDIA), Ollama offloads to VRAM, so the
|
|
7
|
+
*VRAM* is the practical ceiling instead — a 64 GB box with an 8 GB GPU should not be
|
|
8
|
+
told to run a model that only fits in system RAM. This module exposes small, pure
|
|
9
|
+
helpers so ``dev doctor`` and ``dev setup`` can recommend a model that will actually
|
|
10
|
+
run instead of a one-size-fits-all default, on macOS, Linux and Windows.
|
|
11
|
+
|
|
12
|
+
Everything here is best-effort and stdlib-only: detection failures return
|
|
13
|
+
``None`` rather than raising, so callers degrade to the static default.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import platform
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
# Recommended Ollama context window for DevCouncil's large planning prompts.
|
|
25
|
+
# Kept in sync with the value surfaced by `dev doctor`.
|
|
26
|
+
RECOMMENDED_NUM_CTX = 16384
|
|
27
|
+
|
|
28
|
+
# Fallback model when the host is unknown or has little memory. Matches the
|
|
29
|
+
# static defaults in ``llm/model_defaults.yaml``.
|
|
30
|
+
DEFAULT_OLLAMA_MODEL = "qwen2.5-coder:7b"
|
|
31
|
+
|
|
32
|
+
# RAM (GiB) -> recommended qwen2.5-coder size. Highest matching tier wins.
|
|
33
|
+
# Sizes are chosen so the quantized weights plus a 16k context comfortably fit
|
|
34
|
+
# alongside the OS in Apple Silicon unified memory.
|
|
35
|
+
_OLLAMA_MODEL_TIERS: tuple[tuple[float, str], ...] = (
|
|
36
|
+
(48.0, "qwen2.5-coder:32b"),
|
|
37
|
+
(24.0, "qwen2.5-coder:14b"),
|
|
38
|
+
(0.0, "qwen2.5-coder:7b"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_macos() -> bool:
|
|
43
|
+
return platform.system() == "Darwin"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_apple_silicon() -> bool:
|
|
47
|
+
"""True on Apple-Silicon (arm64) Macs."""
|
|
48
|
+
return is_macos() and platform.machine() == "arm64"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def mac_chip_brand() -> str | None:
|
|
52
|
+
"""Marketing CPU string on macOS (e.g. ``Apple M3 Pro``), else None."""
|
|
53
|
+
if not is_macos():
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
out = subprocess.check_output(
|
|
57
|
+
["sysctl", "-n", "machdep.cpu.brand_string"],
|
|
58
|
+
text=True,
|
|
59
|
+
timeout=5,
|
|
60
|
+
).strip()
|
|
61
|
+
return out or None
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def total_ram_gb() -> float | None:
|
|
67
|
+
"""Total physical RAM in GiB, or None if it cannot be determined."""
|
|
68
|
+
try:
|
|
69
|
+
if is_macos():
|
|
70
|
+
out = subprocess.check_output(
|
|
71
|
+
["sysctl", "-n", "hw.memsize"], text=True, timeout=5
|
|
72
|
+
).strip()
|
|
73
|
+
return int(out) / (1024**3)
|
|
74
|
+
# POSIX (Linux): pages * page size.
|
|
75
|
+
pages = os.sysconf("SC_PHYS_PAGES")
|
|
76
|
+
page_size = os.sysconf("SC_PAGE_SIZE")
|
|
77
|
+
return (pages * page_size) / (1024**3)
|
|
78
|
+
except (OSError, ValueError, AttributeError, subprocess.SubprocessError):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def nvidia_vram_gb() -> float | None:
|
|
83
|
+
"""Total VRAM (GiB) of the largest NVIDIA GPU via ``nvidia-smi``, else ``None``.
|
|
84
|
+
|
|
85
|
+
On hosts with a discrete NVIDIA GPU this is the real ceiling for Ollama, since the
|
|
86
|
+
model is offloaded to VRAM. Returns ``None`` when ``nvidia-smi`` is absent (no GPU,
|
|
87
|
+
Apple Silicon, or an unsupported vendor), so callers fall back to total RAM."""
|
|
88
|
+
if not shutil.which("nvidia-smi"):
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
out = subprocess.check_output(
|
|
92
|
+
["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
|
|
93
|
+
text=True,
|
|
94
|
+
timeout=5,
|
|
95
|
+
stderr=subprocess.DEVNULL,
|
|
96
|
+
).strip()
|
|
97
|
+
except (OSError, subprocess.SubprocessError):
|
|
98
|
+
return None
|
|
99
|
+
sizes_mib: list[float] = []
|
|
100
|
+
for line in out.splitlines():
|
|
101
|
+
line = line.strip()
|
|
102
|
+
if not line:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
sizes_mib.append(float(line))
|
|
106
|
+
except ValueError:
|
|
107
|
+
continue
|
|
108
|
+
if not sizes_mib:
|
|
109
|
+
return None
|
|
110
|
+
return max(sizes_mib) / 1024.0 # MiB -> GiB
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def recommend_ollama_model(ram_gb: float | None = None, vram_gb: float | None = None) -> str:
|
|
114
|
+
"""Largest qwen2.5-coder size expected to run on the host.
|
|
115
|
+
|
|
116
|
+
When a discrete GPU's ``vram_gb`` is known it is the ceiling (Ollama offloads to
|
|
117
|
+
VRAM); otherwise total system RAM is used (correct for unified-memory Macs and
|
|
118
|
+
CPU-only hosts)."""
|
|
119
|
+
if ram_gb is None:
|
|
120
|
+
ram_gb = total_ram_gb()
|
|
121
|
+
effective = vram_gb if vram_gb is not None else ram_gb
|
|
122
|
+
if effective is None:
|
|
123
|
+
return DEFAULT_OLLAMA_MODEL
|
|
124
|
+
for floor, model in _OLLAMA_MODEL_TIERS:
|
|
125
|
+
if effective >= floor:
|
|
126
|
+
return model
|
|
127
|
+
return DEFAULT_OLLAMA_MODEL
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class HostSummary:
|
|
132
|
+
"""A snapshot of the host relevant to local-model sizing."""
|
|
133
|
+
|
|
134
|
+
is_macos: bool
|
|
135
|
+
is_apple_silicon: bool
|
|
136
|
+
chip: str | None
|
|
137
|
+
ram_gb: float | None
|
|
138
|
+
recommended_ollama_model: str
|
|
139
|
+
vram_gb: float | None = None
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def ram_label(self) -> str:
|
|
143
|
+
return f"{self.ram_gb:.0f} GB" if self.ram_gb is not None else "unknown RAM"
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def vram_label(self) -> str | None:
|
|
147
|
+
return f"{self.vram_gb:.0f} GB VRAM" if self.vram_gb is not None else None
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def chip_label(self) -> str:
|
|
151
|
+
if self.chip:
|
|
152
|
+
return self.chip
|
|
153
|
+
if self.is_apple_silicon:
|
|
154
|
+
return "Apple Silicon"
|
|
155
|
+
if self.vram_gb is not None:
|
|
156
|
+
return "discrete GPU host"
|
|
157
|
+
return "this host"
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def platform_label(self) -> str:
|
|
161
|
+
"""Human label for the sizing row across OSes."""
|
|
162
|
+
if self.is_macos:
|
|
163
|
+
return "Apple Silicon" if self.is_apple_silicon else "Mac (Intel)"
|
|
164
|
+
system = platform.system()
|
|
165
|
+
return system or "Host"
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def memory_label(self) -> str:
|
|
169
|
+
"""Memory ceiling used for the recommendation (VRAM if discrete GPU, else RAM)."""
|
|
170
|
+
vram = self.vram_label
|
|
171
|
+
return f"{vram} (GPU)" if vram else self.ram_label
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def describe_host() -> HostSummary:
|
|
175
|
+
ram = total_ram_gb()
|
|
176
|
+
vram = nvidia_vram_gb()
|
|
177
|
+
return HostSummary(
|
|
178
|
+
is_macos=is_macos(),
|
|
179
|
+
is_apple_silicon=is_apple_silicon(),
|
|
180
|
+
chip=mac_chip_brand(),
|
|
181
|
+
ram_gb=ram,
|
|
182
|
+
vram_gb=vram,
|
|
183
|
+
recommended_ollama_model=recommend_ollama_model(ram, vram),
|
|
184
|
+
)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class AstMatch:
|
|
11
|
+
path: str
|
|
12
|
+
language: str
|
|
13
|
+
kind: str
|
|
14
|
+
name: str
|
|
15
|
+
line: int
|
|
16
|
+
text: str
|
|
17
|
+
engine: str
|
|
18
|
+
|
|
19
|
+
def model_dump(self) -> dict[str, object]:
|
|
20
|
+
return {
|
|
21
|
+
"path": self.path,
|
|
22
|
+
"language": self.language,
|
|
23
|
+
"kind": self.kind,
|
|
24
|
+
"name": self.name,
|
|
25
|
+
"line": self.line,
|
|
26
|
+
"text": self.text,
|
|
27
|
+
"engine": self.engine,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AstMatcher:
|
|
32
|
+
"""Tree-sitter-style structural search with optional tree_sitter and deterministic fallbacks."""
|
|
33
|
+
|
|
34
|
+
_EXT_LANGUAGE = {
|
|
35
|
+
".py": "python",
|
|
36
|
+
".ts": "typescript",
|
|
37
|
+
".tsx": "typescript",
|
|
38
|
+
".js": "javascript",
|
|
39
|
+
".jsx": "javascript",
|
|
40
|
+
".go": "go",
|
|
41
|
+
".rs": "rust",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_SYMBOL_PATTERNS: dict[str, re.Pattern[str]] = {
|
|
45
|
+
"typescript": re.compile(
|
|
46
|
+
r"^\s*(?:export\s+)?(?:(?:async\s+)?(?:function|class|interface|type)\s+|const\s+)([A-Za-z_$][\w$]*)"
|
|
47
|
+
),
|
|
48
|
+
"javascript": re.compile(
|
|
49
|
+
r"^\s*(?:export\s+)?(?:(?:async\s+)?(?:function|class)\s+|const\s+)([A-Za-z_$][\w$]*)"
|
|
50
|
+
),
|
|
51
|
+
"go": re.compile(r"^\s*func\s+(?:\([^)]+\)\s*)?([A-Za-z_]\w*)\s*\("),
|
|
52
|
+
"rust": re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?(?:fn|struct|enum|trait)\s+([A-Za-z_]\w*)"),
|
|
53
|
+
}
|
|
54
|
+
_IGNORED_DIRS = {".git", ".devcouncil", "__pycache__", ".venv", "node_modules", "dist", "build", "target", "vendor"}
|
|
55
|
+
|
|
56
|
+
def __init__(self, project_root: Path):
|
|
57
|
+
self.project_root = project_root
|
|
58
|
+
self.tree_sitter_available = self._has_tree_sitter()
|
|
59
|
+
|
|
60
|
+
def _has_tree_sitter(self) -> bool:
|
|
61
|
+
try:
|
|
62
|
+
import tree_sitter # type: ignore[import-not-found] # noqa: F401
|
|
63
|
+
except Exception:
|
|
64
|
+
return False
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
def match(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
query: str = "",
|
|
71
|
+
language: str | None = None,
|
|
72
|
+
kind: str | None = None,
|
|
73
|
+
limit: int = 100,
|
|
74
|
+
) -> list[AstMatch]:
|
|
75
|
+
language = language.lower() if language else None
|
|
76
|
+
kind = kind.lower() if kind else None
|
|
77
|
+
limit = max(1, limit)
|
|
78
|
+
matches: list[AstMatch] = []
|
|
79
|
+
for path in self._candidate_files(language):
|
|
80
|
+
try:
|
|
81
|
+
text = path.read_text(encoding="utf-8")
|
|
82
|
+
except (OSError, UnicodeDecodeError):
|
|
83
|
+
continue
|
|
84
|
+
rel = path.relative_to(self.project_root).as_posix()
|
|
85
|
+
file_language = self._EXT_LANGUAGE.get(path.suffix.lower(), path.suffix.lower().lstrip("."))
|
|
86
|
+
matches.extend(self._match_file(rel, file_language, text, query=query, kind=kind))
|
|
87
|
+
if len(matches) >= limit:
|
|
88
|
+
return matches[:limit]
|
|
89
|
+
return matches[:limit]
|
|
90
|
+
|
|
91
|
+
def _candidate_files(self, language: str | None) -> list[Path]:
|
|
92
|
+
allowed_exts = {
|
|
93
|
+
ext for ext, ext_language in self._EXT_LANGUAGE.items()
|
|
94
|
+
if language is None or ext_language == language
|
|
95
|
+
}
|
|
96
|
+
files: list[Path] = []
|
|
97
|
+
try:
|
|
98
|
+
for path in self.project_root.rglob("*"):
|
|
99
|
+
if not path.is_file() or path.suffix.lower() not in allowed_exts:
|
|
100
|
+
continue
|
|
101
|
+
if any(part in self._IGNORED_DIRS for part in path.parts):
|
|
102
|
+
continue
|
|
103
|
+
files.append(path)
|
|
104
|
+
except OSError:
|
|
105
|
+
return files
|
|
106
|
+
return sorted(files)
|
|
107
|
+
|
|
108
|
+
def _match_file(self, rel: str, language: str, text: str, *, query: str, kind: str | None) -> list[AstMatch]:
|
|
109
|
+
if language == "python":
|
|
110
|
+
return self._match_python(rel, text, query=query, kind=kind)
|
|
111
|
+
pattern = self._SYMBOL_PATTERNS.get(language)
|
|
112
|
+
if not pattern:
|
|
113
|
+
return []
|
|
114
|
+
results: list[AstMatch] = []
|
|
115
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
116
|
+
match = pattern.match(line)
|
|
117
|
+
if not match:
|
|
118
|
+
continue
|
|
119
|
+
symbol_name = match.group(1)
|
|
120
|
+
symbol_kind = self._line_kind(line)
|
|
121
|
+
if kind and kind != symbol_kind:
|
|
122
|
+
continue
|
|
123
|
+
if query and query.lower() not in symbol_name.lower() and query.lower() not in line.lower():
|
|
124
|
+
continue
|
|
125
|
+
results.append(AstMatch(rel, language, symbol_kind, symbol_name, lineno, line.strip(), self._engine()))
|
|
126
|
+
return results
|
|
127
|
+
|
|
128
|
+
def _match_python(self, rel: str, text: str, *, query: str, kind: str | None) -> list[AstMatch]:
|
|
129
|
+
try:
|
|
130
|
+
tree = ast.parse(text)
|
|
131
|
+
except SyntaxError:
|
|
132
|
+
return []
|
|
133
|
+
lines = text.splitlines()
|
|
134
|
+
results: list[AstMatch] = []
|
|
135
|
+
for node in ast.walk(tree):
|
|
136
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
137
|
+
symbol_kind = "function"
|
|
138
|
+
elif isinstance(node, ast.ClassDef):
|
|
139
|
+
symbol_kind = "class"
|
|
140
|
+
else:
|
|
141
|
+
continue
|
|
142
|
+
symbol_name = node.name
|
|
143
|
+
source = lines[node.lineno - 1].strip() if 0 < node.lineno <= len(lines) else symbol_name
|
|
144
|
+
if kind and kind != symbol_kind:
|
|
145
|
+
continue
|
|
146
|
+
if query and query.lower() not in symbol_name.lower() and query.lower() not in source.lower():
|
|
147
|
+
continue
|
|
148
|
+
results.append(AstMatch(rel, "python", symbol_kind, symbol_name, node.lineno, source, self._engine()))
|
|
149
|
+
return sorted(results, key=lambda item: (item.path, item.line))
|
|
150
|
+
|
|
151
|
+
def _line_kind(self, line: str) -> str:
|
|
152
|
+
stripped = line.strip()
|
|
153
|
+
if "class " in stripped:
|
|
154
|
+
return "class"
|
|
155
|
+
if stripped.startswith(("type ", "export type ")):
|
|
156
|
+
return "type"
|
|
157
|
+
if stripped.startswith(("interface ", "export interface ")):
|
|
158
|
+
return "interface"
|
|
159
|
+
if stripped.startswith(("struct ", "pub struct ")):
|
|
160
|
+
return "struct"
|
|
161
|
+
if stripped.startswith(("enum ", "pub enum ")):
|
|
162
|
+
return "enum"
|
|
163
|
+
if stripped.startswith(("trait ", "pub trait ")):
|
|
164
|
+
return "trait"
|
|
165
|
+
return "function"
|
|
166
|
+
|
|
167
|
+
def _engine(self) -> str:
|
|
168
|
+
return "tree-sitter-optional" if self.tree_sitter_available else "fallback-ast"
|
|
@@ -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,161 @@
|
|
|
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
|
+
"""Language-server *detection* only — DevCouncil does not run an LSP client.
|
|
20
|
+
|
|
21
|
+
This inspector exists to answer one honest question: which language servers
|
|
22
|
+
for the repo's languages are installed on PATH? It does NOT spawn a server,
|
|
23
|
+
does NOT speak the LSP wire protocol, and does NOT send the ``initialize``
|
|
24
|
+
handshake. ``starter_initialize_payload`` builds the JSON-RPC ``initialize``
|
|
25
|
+
request a *future* client would send, but it is never transmitted — it is
|
|
26
|
+
surfaced purely as a reference/starter payload, clearly labelled as such, so
|
|
27
|
+
no consumer mistakes detection for a live LSP capability.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_LANGUAGE_SERVERS: dict[str, list[list[str]]] = {
|
|
31
|
+
"python": [["pyright-langserver", "--stdio"], ["pylsp"]],
|
|
32
|
+
"typescript": [["typescript-language-server", "--stdio"]],
|
|
33
|
+
"javascript": [["typescript-language-server", "--stdio"]],
|
|
34
|
+
"go": [["gopls"]],
|
|
35
|
+
"rust": [["rust-analyzer"]],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_EXTENSIONS: dict[str, str] = {
|
|
39
|
+
".py": "python",
|
|
40
|
+
".ts": "typescript",
|
|
41
|
+
".tsx": "typescript",
|
|
42
|
+
".js": "javascript",
|
|
43
|
+
".jsx": "javascript",
|
|
44
|
+
".go": "go",
|
|
45
|
+
".rs": "rust",
|
|
46
|
+
}
|
|
47
|
+
_IGNORED_DIRS = {".git", ".devcouncil", "__pycache__", ".venv", "node_modules", "dist", "build", "target", "vendor"}
|
|
48
|
+
|
|
49
|
+
def __init__(self, project_root: Path):
|
|
50
|
+
self.project_root = project_root
|
|
51
|
+
|
|
52
|
+
def _is_ignored_path(self, file: str) -> bool:
|
|
53
|
+
return any(part in self._IGNORED_DIRS for part in Path(file).parts)
|
|
54
|
+
|
|
55
|
+
def detect_languages(self, files: list[str] | None = None) -> list[str]:
|
|
56
|
+
if files is None:
|
|
57
|
+
try:
|
|
58
|
+
discovered: list[str] = []
|
|
59
|
+
for path in self.project_root.rglob("*"):
|
|
60
|
+
if path.is_file() and not any(part in self._IGNORED_DIRS for part in path.parts):
|
|
61
|
+
discovered.append(str(path.relative_to(self.project_root)))
|
|
62
|
+
files = discovered
|
|
63
|
+
except OSError:
|
|
64
|
+
files = []
|
|
65
|
+
languages = {
|
|
66
|
+
self._EXTENSIONS[Path(file).suffix.lower()]
|
|
67
|
+
for file in files
|
|
68
|
+
if Path(file).suffix.lower() in self._EXTENSIONS and not self._is_ignored_path(file)
|
|
69
|
+
}
|
|
70
|
+
return sorted(languages)
|
|
71
|
+
|
|
72
|
+
def server_candidates(self, files: list[str] | None = None) -> list[LspServerCandidate]:
|
|
73
|
+
candidates: list[LspServerCandidate] = []
|
|
74
|
+
for language in self.detect_languages(files):
|
|
75
|
+
for command in self._LANGUAGE_SERVERS.get(language, []):
|
|
76
|
+
executable = command[0]
|
|
77
|
+
available = shutil.which(executable) is not None
|
|
78
|
+
candidates.append(
|
|
79
|
+
LspServerCandidate(
|
|
80
|
+
language=language,
|
|
81
|
+
command=command,
|
|
82
|
+
available=available,
|
|
83
|
+
reason="found on PATH" if available else "not found on PATH",
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return candidates
|
|
87
|
+
|
|
88
|
+
def starter_initialize_payload(self, language: str) -> dict[str, Any]:
|
|
89
|
+
"""Build the JSON-RPC ``initialize`` request a future LSP client *would* send.
|
|
90
|
+
|
|
91
|
+
This payload is NEVER sent — DevCouncil has no LSP client. It is provided
|
|
92
|
+
only as a reference/starter for anyone wiring up real LSP support. See the
|
|
93
|
+
``initialize_requests`` block in :meth:`summary`, which carries the same
|
|
94
|
+
non-sent payloads under an explicit ``"_note"`` disclaimer.
|
|
95
|
+
"""
|
|
96
|
+
return {
|
|
97
|
+
"jsonrpc": "2.0",
|
|
98
|
+
"id": 1,
|
|
99
|
+
"method": "initialize",
|
|
100
|
+
"params": {
|
|
101
|
+
"processId": None,
|
|
102
|
+
"rootUri": self.project_root.resolve().as_uri(),
|
|
103
|
+
"capabilities": {
|
|
104
|
+
"textDocument": {
|
|
105
|
+
"publishDiagnostics": {"relatedInformation": True},
|
|
106
|
+
"definition": {"linkSupport": True},
|
|
107
|
+
"references": {},
|
|
108
|
+
"documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
|
|
109
|
+
},
|
|
110
|
+
"workspace": {"symbol": {}},
|
|
111
|
+
},
|
|
112
|
+
"initializationOptions": {"language": language},
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Made explicit in every summary so no consumer reads this as a live LSP feature.
|
|
117
|
+
_DETECTION_ONLY_NOTE = (
|
|
118
|
+
"Detection only: DevCouncil checks which language servers are installed on "
|
|
119
|
+
"PATH; it does not run an LSP client or send any requests."
|
|
120
|
+
)
|
|
121
|
+
_STARTER_PAYLOAD_NOTE = (
|
|
122
|
+
"Starter payloads only — these initialize requests are NEVER sent. They are "
|
|
123
|
+
"a reference for wiring up a real LSP client later."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def summary(self, files: list[str] | None = None) -> dict[str, Any]:
|
|
127
|
+
candidates = self.server_candidates(files)
|
|
128
|
+
return {
|
|
129
|
+
"mode": "detection-only",
|
|
130
|
+
"note": self._DETECTION_ONLY_NOTE,
|
|
131
|
+
"languages": self.detect_languages(files),
|
|
132
|
+
"detected_servers": [
|
|
133
|
+
{
|
|
134
|
+
"language": candidate.language,
|
|
135
|
+
"command": candidate.command,
|
|
136
|
+
"available": candidate.available,
|
|
137
|
+
"reason": candidate.reason,
|
|
138
|
+
}
|
|
139
|
+
for candidate in candidates
|
|
140
|
+
],
|
|
141
|
+
# Back-compat alias for "detected_servers" (older consumers/tests).
|
|
142
|
+
"servers": [
|
|
143
|
+
{
|
|
144
|
+
"language": candidate.language,
|
|
145
|
+
"command": candidate.command,
|
|
146
|
+
"available": candidate.available,
|
|
147
|
+
"reason": candidate.reason,
|
|
148
|
+
}
|
|
149
|
+
for candidate in candidates
|
|
150
|
+
],
|
|
151
|
+
"initialize_requests": {
|
|
152
|
+
"_note": self._STARTER_PAYLOAD_NOTE,
|
|
153
|
+
**{
|
|
154
|
+
language: self.starter_initialize_payload(language)
|
|
155
|
+
for language in sorted({candidate.language for candidate in candidates})
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def summary_json(self, files: list[str] | None = None) -> str:
|
|
161
|
+
return json.dumps(self.summary(files), indent=2)
|