devcouncil 0.2.0 → 0.2.2
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/README.md +144 -308
- package/bin/devcouncil.js +130 -32
- package/package.json +22 -19
- package/pyproject.toml +0 -66
- package/src/devcouncil/__init__.py +0 -0
- package/src/devcouncil/__main__.py +0 -4
- package/src/devcouncil/app/__init__.py +0 -28
- package/src/devcouncil/app/config.py +0 -320
- package/src/devcouncil/app/errors.py +0 -23
- package/src/devcouncil/app/events.py +0 -44
- package/src/devcouncil/app/orchestrator.py +0 -92
- package/src/devcouncil/app/project_status.py +0 -29
- package/src/devcouncil/app/run_context.py +0 -39
- package/src/devcouncil/app/state_machine.py +0 -108
- package/src/devcouncil/artifacts/__init__.py +0 -1
- package/src/devcouncil/artifacts/coverage.py +0 -96
- package/src/devcouncil/artifacts/graph.py +0 -163
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/artifacts/validators.py +0 -27
- package/src/devcouncil/assets/__init__.py +0 -1
- package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/__init__.py +0 -0
- package/src/devcouncil/cli/commands/__init__.py +0 -0
- package/src/devcouncil/cli/commands/agents.py +0 -292
- package/src/devcouncil/cli/commands/artifacts.py +0 -54
- package/src/devcouncil/cli/commands/ast.py +0 -22
- package/src/devcouncil/cli/commands/baseline.py +0 -35
- package/src/devcouncil/cli/commands/check.py +0 -209
- package/src/devcouncil/cli/commands/config.py +0 -115
- package/src/devcouncil/cli/commands/cost.py +0 -57
- package/src/devcouncil/cli/commands/dashboard.py +0 -31
- package/src/devcouncil/cli/commands/doctor.py +0 -340
- package/src/devcouncil/cli/commands/evidence.py +0 -48
- package/src/devcouncil/cli/commands/go.py +0 -656
- package/src/devcouncil/cli/commands/handoff.py +0 -69
- package/src/devcouncil/cli/commands/hook.py +0 -237
- package/src/devcouncil/cli/commands/init.py +0 -289
- package/src/devcouncil/cli/commands/integrate.py +0 -1515
- package/src/devcouncil/cli/commands/lsp.py +0 -20
- package/src/devcouncil/cli/commands/map.py +0 -112
- package/src/devcouncil/cli/commands/mcp_server.py +0 -18
- package/src/devcouncil/cli/commands/plan.py +0 -488
- package/src/devcouncil/cli/commands/prompt.py +0 -61
- package/src/devcouncil/cli/commands/repair.py +0 -89
- package/src/devcouncil/cli/commands/report.py +0 -137
- package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
- package/src/devcouncil/cli/commands/rollback.py +0 -59
- package/src/devcouncil/cli/commands/run.py +0 -289
- package/src/devcouncil/cli/commands/runs.py +0 -223
- package/src/devcouncil/cli/commands/scaffold.py +0 -32
- package/src/devcouncil/cli/commands/semantic.py +0 -47
- package/src/devcouncil/cli/commands/setup.py +0 -362
- package/src/devcouncil/cli/commands/shell.py +0 -73
- package/src/devcouncil/cli/commands/show.py +0 -76
- package/src/devcouncil/cli/commands/skills.py +0 -88
- package/src/devcouncil/cli/commands/status.py +0 -141
- package/src/devcouncil/cli/commands/tasks.py +0 -55
- package/src/devcouncil/cli/commands/trace.py +0 -88
- package/src/devcouncil/cli/commands/verify.py +0 -328
- package/src/devcouncil/cli/commands/version.py +0 -20
- package/src/devcouncil/cli/commands/watch.py +0 -574
- package/src/devcouncil/cli/commands/watch_fs.py +0 -40
- package/src/devcouncil/cli/main.py +0 -137
- package/src/devcouncil/council/__init__.py +0 -0
- package/src/devcouncil/council/prompts/__init__.py +0 -0
- package/src/devcouncil/council/prompts/arbiter.md +0 -19
- package/src/devcouncil/council/prompts/critic_a.md +0 -10
- package/src/devcouncil/council/prompts/critic_b.md +0 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
- package/src/devcouncil/council/prompts/planner_a.md +0 -16
- package/src/devcouncil/council/prompts/planner_b.md +0 -16
- package/src/devcouncil/council/prompts/rebuttal.md +0 -10
- package/src/devcouncil/council/prompts/spec_writer.md +0 -12
- package/src/devcouncil/domain/__init__.py +0 -0
- package/src/devcouncil/domain/assumption.py +0 -17
- package/src/devcouncil/domain/critique.py +0 -32
- package/src/devcouncil/domain/evidence.py +0 -47
- package/src/devcouncil/domain/gap.py +0 -52
- package/src/devcouncil/domain/requirement.py +0 -22
- package/src/devcouncil/domain/task.py +0 -55
- package/src/devcouncil/execution/__init__.py +0 -1
- package/src/devcouncil/execution/checkpoints.py +0 -246
- package/src/devcouncil/execution/context_builder.py +0 -60
- package/src/devcouncil/execution/executor.py +0 -15
- package/src/devcouncil/execution/fs_watcher.py +0 -180
- package/src/devcouncil/execution/handoff.py +0 -102
- package/src/devcouncil/execution/hook_policy.py +0 -253
- package/src/devcouncil/execution/patch.py +0 -77
- package/src/devcouncil/execution/paths.py +0 -14
- package/src/devcouncil/execution/permissions.py +0 -85
- package/src/devcouncil/execution/policy_engine.py +0 -343
- package/src/devcouncil/execution/prompt_builder.py +0 -671
- package/src/devcouncil/execution/shell_session.py +0 -225
- package/src/devcouncil/execution/task_runner.py +0 -170
- package/src/devcouncil/executors/__init__.py +0 -1
- package/src/devcouncil/executors/agent_registry.py +0 -575
- package/src/devcouncil/executors/coding_cli.py +0 -736
- package/src/devcouncil/executors/mini_swe.py +0 -73
- package/src/devcouncil/executors/native/__init__.py +0 -0
- package/src/devcouncil/executors/native/agent.py +0 -208
- package/src/devcouncil/executors/openhands.py +0 -71
- package/src/devcouncil/gating/__init__.py +0 -1
- package/src/devcouncil/gating/checks/__init__.py +0 -0
- package/src/devcouncil/gating/checks/clean_git.py +0 -52
- package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
- package/src/devcouncil/gating/policy.py +0 -338
- package/src/devcouncil/hardware.py +0 -184
- package/src/devcouncil/indexing/__init__.py +0 -1
- package/src/devcouncil/indexing/ast_matcher.py +0 -168
- package/src/devcouncil/indexing/graph_index.py +0 -48
- package/src/devcouncil/indexing/lsp.py +0 -161
- package/src/devcouncil/indexing/repo_mapper.py +0 -1455
- package/src/devcouncil/indexing/semantic_index.py +0 -205
- package/src/devcouncil/integrations/actions.py +0 -146
- package/src/devcouncil/integrations/check.py +0 -423
- package/src/devcouncil/integrations/code_review_graph.py +0 -163
- package/src/devcouncil/integrations/github.py +0 -39
- package/src/devcouncil/integrations/github_intent.py +0 -142
- package/src/devcouncil/integrations/gitnexus.py +0 -62
- package/src/devcouncil/integrations/graphify.py +0 -34
- package/src/devcouncil/integrations/mcp/__init__.py +0 -0
- package/src/devcouncil/integrations/mcp/server.py +0 -2122
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
- package/src/devcouncil/integrations/pr_comments.py +0 -62
- package/src/devcouncil/live/__init__.py +0 -2
- package/src/devcouncil/live/cards.py +0 -349
- package/src/devcouncil/live/models.py +0 -63
- package/src/devcouncil/live/repair_prompt.py +0 -83
- package/src/devcouncil/live/reviewer.py +0 -70
- package/src/devcouncil/live/signals.py +0 -135
- package/src/devcouncil/live/summary.py +0 -34
- package/src/devcouncil/live/tasks.py +0 -18
- package/src/devcouncil/live/transcripts.py +0 -141
- package/src/devcouncil/llm/__init__.py +0 -1
- package/src/devcouncil/llm/cache.py +0 -42
- package/src/devcouncil/llm/model_defaults.yaml +0 -44
- package/src/devcouncil/llm/provider.py +0 -627
- package/src/devcouncil/llm/router.py +0 -310
- package/src/devcouncil/optimization/__init__.py +0 -1
- package/src/devcouncil/optimization/gepa_agent.py +0 -318
- package/src/devcouncil/planning/__init__.py +0 -1
- package/src/devcouncil/planning/arbiter_service.py +0 -57
- package/src/devcouncil/planning/correction_manifest.py +0 -303
- package/src/devcouncil/planning/critique_service.py +0 -71
- package/src/devcouncil/planning/plan_service.py +0 -60
- package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
- package/src/devcouncil/planning/repair_service.py +0 -39
- package/src/devcouncil/planning/spec_service.py +0 -70
- package/src/devcouncil/repo/__init__.py +0 -0
- package/src/devcouncil/repo/ci_scaffold.py +0 -157
- package/src/devcouncil/repo/gitignore.py +0 -123
- package/src/devcouncil/repo/sca.py +0 -374
- package/src/devcouncil/reporting/__init__.py +0 -0
- package/src/devcouncil/reporting/github_check.py +0 -32
- package/src/devcouncil/reporting/json_report.py +0 -30
- package/src/devcouncil/reporting/markdown_report.py +0 -83
- package/src/devcouncil/reporting/report_builder.py +0 -14
- package/src/devcouncil/skills/__init__.py +0 -19
- package/src/devcouncil/skills/library/README.md +0 -46
- package/src/devcouncil/skills/library/ai-training.md +0 -50
- package/src/devcouncil/skills/library/android.md +0 -50
- package/src/devcouncil/skills/library/backend.md +0 -52
- package/src/devcouncil/skills/library/core-engineering.md +0 -95
- package/src/devcouncil/skills/library/data-engineering.md +0 -47
- package/src/devcouncil/skills/library/desktop.md +0 -46
- package/src/devcouncil/skills/library/devops.md +0 -48
- package/src/devcouncil/skills/library/game-dev.md +0 -46
- package/src/devcouncil/skills/library/ios.md +0 -48
- package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
- package/src/devcouncil/skills/library/security.md +0 -48
- package/src/devcouncil/skills/library/systems.md +0 -48
- package/src/devcouncil/skills/library/web.md +0 -47
- package/src/devcouncil/skills/library/windows.md +0 -47
- package/src/devcouncil/skills/registry.py +0 -330
- package/src/devcouncil/storage/__init__.py +0 -0
- package/src/devcouncil/storage/db.py +0 -147
- package/src/devcouncil/storage/models.py +0 -204
- package/src/devcouncil/storage/native.py +0 -557
- package/src/devcouncil/storage/repositories.py +0 -485
- package/src/devcouncil/telemetry/__init__.py +0 -0
- package/src/devcouncil/telemetry/cost.py +0 -140
- package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
- package/src/devcouncil/telemetry/pricing.py +0 -28
- package/src/devcouncil/telemetry/traces.py +0 -146
- package/src/devcouncil/telemetry/tracker.py +0 -52
- package/src/devcouncil/ui/__init__.py +0 -1
- package/src/devcouncil/ui/dashboard.py +0 -423
- package/src/devcouncil/utils/__init__.py +0 -1
- package/src/devcouncil/utils/redaction.py +0 -147
- package/src/devcouncil/utils/subprocess_env.py +0 -69
- package/src/devcouncil/verification/__init__.py +0 -1
- package/src/devcouncil/verification/acceptance_compiler.py +0 -125
- package/src/devcouncil/verification/ad_hoc_check.py +0 -129
- package/src/devcouncil/verification/diff_coverage.py +0 -353
- package/src/devcouncil/verification/implementation_reviewer.py +0 -55
- package/src/devcouncil/verification/next_actions.py +0 -189
- package/src/devcouncil/verification/sandbox.py +0 -178
- package/src/devcouncil/verification/test_resolver.py +0 -91
- package/src/devcouncil/verification/verifier.py +0 -1548
- package/uv.lock +0 -1226
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
from pydantic import BaseModel
|
|
2
|
-
from typing import Any, List, Optional
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from devcouncil.domain.requirement import Requirement
|
|
6
|
-
from devcouncil.domain.task import Task
|
|
7
|
-
from devcouncil.domain.gap import Gap
|
|
8
|
-
from devcouncil.domain.assumption import Assumption
|
|
9
|
-
from devcouncil.domain.critique import CritiqueFinding
|
|
10
|
-
from devcouncil.gating.checks.requirement_coverage import RequirementCoverageCheck
|
|
11
|
-
from devcouncil.gating.checks.planned_files_check import PlannedFilesCheck
|
|
12
|
-
from devcouncil.gating.checks.clean_git import CleanGitCheck
|
|
13
|
-
|
|
14
|
-
class GateResult(BaseModel):
|
|
15
|
-
passed: bool
|
|
16
|
-
gaps: List[Gap]
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _find_dependency_cycle(tasks: List[Task]) -> Optional[List[str]]:
|
|
20
|
-
"""Return one dependency cycle as an id path (e.g. [A, B, A]), or None. Only edges
|
|
21
|
-
to known task ids are followed; unknown deps are reported separately."""
|
|
22
|
-
ids = {t.id for t in tasks}
|
|
23
|
-
graph = {t.id: [d for d in t.depends_on if d in ids] for t in tasks}
|
|
24
|
-
WHITE, GREY, BLACK = 0, 1, 2
|
|
25
|
-
color = {tid: WHITE for tid in graph}
|
|
26
|
-
stack: List[str] = []
|
|
27
|
-
|
|
28
|
-
def visit(node: str) -> Optional[List[str]]:
|
|
29
|
-
color[node] = GREY
|
|
30
|
-
stack.append(node)
|
|
31
|
-
for nxt in graph.get(node, []):
|
|
32
|
-
if color[nxt] == GREY:
|
|
33
|
-
return stack[stack.index(nxt):] + [nxt]
|
|
34
|
-
if color[nxt] == WHITE:
|
|
35
|
-
found = visit(nxt)
|
|
36
|
-
if found:
|
|
37
|
-
return found
|
|
38
|
-
stack.pop()
|
|
39
|
-
color[node] = BLACK
|
|
40
|
-
return None
|
|
41
|
-
|
|
42
|
-
for tid in graph:
|
|
43
|
-
if color[tid] == WHITE:
|
|
44
|
-
found = visit(tid)
|
|
45
|
-
if found:
|
|
46
|
-
return found
|
|
47
|
-
return None
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def topological_order(tasks: List[Task]) -> List[Task]:
|
|
51
|
-
"""Order tasks so every task follows the ones it depends on. Stable: preserves the
|
|
52
|
-
given order among independent tasks. Falls back to the original order if a cycle
|
|
53
|
-
makes a full ordering impossible (the plan gate blocks cycles separately)."""
|
|
54
|
-
by_id = {t.id: t for t in tasks}
|
|
55
|
-
indegree = {t.id: 0 for t in tasks}
|
|
56
|
-
dependents: dict[str, List[str]] = {t.id: [] for t in tasks}
|
|
57
|
-
for task in tasks:
|
|
58
|
-
for dep in task.depends_on:
|
|
59
|
-
if dep in by_id:
|
|
60
|
-
indegree[task.id] += 1
|
|
61
|
-
dependents[dep].append(task.id)
|
|
62
|
-
# Kahn's algorithm, seeded in original order for stability.
|
|
63
|
-
ready = [t.id for t in tasks if indegree[t.id] == 0]
|
|
64
|
-
ordered: List[str] = []
|
|
65
|
-
while ready:
|
|
66
|
-
current = ready.pop(0)
|
|
67
|
-
ordered.append(current)
|
|
68
|
-
for child in dependents[current]:
|
|
69
|
-
indegree[child] -= 1
|
|
70
|
-
if indegree[child] == 0:
|
|
71
|
-
ready.append(child)
|
|
72
|
-
if len(ordered) != len(tasks): # cycle — fall back to original order
|
|
73
|
-
return list(tasks)
|
|
74
|
-
return [by_id[tid] for tid in ordered]
|
|
75
|
-
|
|
76
|
-
class GatePolicy:
|
|
77
|
-
"""Central engine for executing project and task level quality gates."""
|
|
78
|
-
|
|
79
|
-
def __init__(self):
|
|
80
|
-
self.req_coverage = RequirementCoverageCheck()
|
|
81
|
-
self.planned_files = PlannedFilesCheck()
|
|
82
|
-
self.clean_git = CleanGitCheck()
|
|
83
|
-
|
|
84
|
-
def check_plan_approval(
|
|
85
|
-
self,
|
|
86
|
-
requirements: List[Requirement],
|
|
87
|
-
tasks: List[Task],
|
|
88
|
-
assumptions: Optional[List[Assumption]] = None,
|
|
89
|
-
findings: Optional[List[CritiqueFinding]] = None,
|
|
90
|
-
blocking_questions: Optional[List[Any]] = None,
|
|
91
|
-
) -> GateResult:
|
|
92
|
-
"""Determines if the overall project plan is ready for execution."""
|
|
93
|
-
gaps = []
|
|
94
|
-
known_req_ids = {req.id for req in requirements}
|
|
95
|
-
known_ac_ids = {
|
|
96
|
-
ac.id
|
|
97
|
-
for req in requirements
|
|
98
|
-
for ac in req.acceptance_criteria
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
# 1. Check requirement coverage
|
|
102
|
-
gaps.extend(self.req_coverage.check(requirements, tasks))
|
|
103
|
-
|
|
104
|
-
# 2. Check for acceptance criteria presence
|
|
105
|
-
for req in requirements:
|
|
106
|
-
if not req.acceptance_criteria:
|
|
107
|
-
gaps.append(Gap(
|
|
108
|
-
id=f"GAP-PLAN-{req.id}-NO-AC",
|
|
109
|
-
severity="high",
|
|
110
|
-
gap_type="requirement_not_planned",
|
|
111
|
-
requirement_id=req.id,
|
|
112
|
-
description=f"Requirement {req.id} has no acceptance criteria.",
|
|
113
|
-
recommended_fix="Define at least one measurable AC.",
|
|
114
|
-
blocking=True
|
|
115
|
-
))
|
|
116
|
-
|
|
117
|
-
for ac in req.acceptance_criteria:
|
|
118
|
-
if not ac.verification_method:
|
|
119
|
-
gaps.append(Gap(
|
|
120
|
-
id=f"GAP-PLAN-{ac.id}-NO-VERIFY",
|
|
121
|
-
severity="high",
|
|
122
|
-
gap_type="acceptance_criteria_unproven",
|
|
123
|
-
requirement_id=req.id,
|
|
124
|
-
description=f"Acceptance criterion {ac.id} has no verification method.",
|
|
125
|
-
recommended_fix="Define a deterministic verification method for this AC.",
|
|
126
|
-
blocking=True,
|
|
127
|
-
))
|
|
128
|
-
|
|
129
|
-
for task in tasks:
|
|
130
|
-
if not task.requirement_ids:
|
|
131
|
-
gaps.append(Gap(
|
|
132
|
-
id=f"GAP-PLAN-{task.id}-NO-REQ",
|
|
133
|
-
severity="high",
|
|
134
|
-
gap_type="requirement_not_planned",
|
|
135
|
-
task_id=task.id,
|
|
136
|
-
description=f"Task {task.id} is not mapped to any requirement.",
|
|
137
|
-
recommended_fix="Map each task to at least one requirement.",
|
|
138
|
-
blocking=True,
|
|
139
|
-
))
|
|
140
|
-
|
|
141
|
-
unknown_req_ids = [req_id for req_id in task.requirement_ids if req_id not in known_req_ids]
|
|
142
|
-
if unknown_req_ids:
|
|
143
|
-
gaps.append(Gap(
|
|
144
|
-
id=f"GAP-PLAN-{task.id}-UNKNOWN-REQ",
|
|
145
|
-
severity="high",
|
|
146
|
-
gap_type="requirement_not_planned",
|
|
147
|
-
task_id=task.id,
|
|
148
|
-
description=f"Task {task.id} references unknown requirement(s): {', '.join(unknown_req_ids)}.",
|
|
149
|
-
recommended_fix="Remove invalid requirement links or add the missing requirements.",
|
|
150
|
-
blocking=True,
|
|
151
|
-
))
|
|
152
|
-
|
|
153
|
-
unknown_ac_ids = [ac_id for ac_id in task.acceptance_criterion_ids if ac_id not in known_ac_ids]
|
|
154
|
-
if unknown_ac_ids:
|
|
155
|
-
gaps.append(Gap(
|
|
156
|
-
id=f"GAP-PLAN-{task.id}-UNKNOWN-AC",
|
|
157
|
-
severity="high",
|
|
158
|
-
gap_type="acceptance_criteria_unproven",
|
|
159
|
-
task_id=task.id,
|
|
160
|
-
description=f"Task {task.id} references unknown acceptance criteria: {', '.join(unknown_ac_ids)}.",
|
|
161
|
-
recommended_fix="Link tasks only to acceptance criteria declared by requirements.",
|
|
162
|
-
blocking=True,
|
|
163
|
-
))
|
|
164
|
-
|
|
165
|
-
# Surface read-only-only tasks at PLANNING time (advisory): a task that declares
|
|
166
|
-
# planned files but none writable can implement nothing, and was previously only
|
|
167
|
-
# caught at execution by the task-readiness gate.
|
|
168
|
-
for task in tasks:
|
|
169
|
-
if task.planned_files and not any(
|
|
170
|
-
pf.allowed_change in ("create", "modify", "delete") for pf in task.planned_files
|
|
171
|
-
):
|
|
172
|
-
gaps.append(Gap(
|
|
173
|
-
id=f"GAP-PLAN-{task.id}-READ-ONLY",
|
|
174
|
-
severity="medium",
|
|
175
|
-
gap_type="task_not_implemented",
|
|
176
|
-
task_id=task.id,
|
|
177
|
-
description=(
|
|
178
|
-
f"Task {task.id} declares planned files but none are writable "
|
|
179
|
-
"(all read_only); it cannot implement any change. Expected only if "
|
|
180
|
-
"this is an analysis-only task."
|
|
181
|
-
),
|
|
182
|
-
recommended_fix=(
|
|
183
|
-
"Grant 'create', 'modify', or 'delete' to at least one planned file, "
|
|
184
|
-
"or confirm the task is intentionally analysis-only."
|
|
185
|
-
),
|
|
186
|
-
blocking=False,
|
|
187
|
-
))
|
|
188
|
-
|
|
189
|
-
# Surface overlapping ownership (advisory): when 2+ tasks each declare a
|
|
190
|
-
# writable (create/modify/delete) change to the SAME file, the plan is
|
|
191
|
-
# over-decomposed — the later task tends to duplicate or conflict with the
|
|
192
|
-
# earlier one (e.g. both add the same function), which then fails per-task
|
|
193
|
-
# verification. Consolidating a file's work into one task avoids this.
|
|
194
|
-
writers_by_file: dict[str, list[str]] = {}
|
|
195
|
-
for task in tasks:
|
|
196
|
-
for pf in task.planned_files:
|
|
197
|
-
if pf.allowed_change in ("create", "modify", "delete"):
|
|
198
|
-
path = pf.path.replace("\\", "/")
|
|
199
|
-
writers_by_file.setdefault(path, [])
|
|
200
|
-
if task.id not in writers_by_file[path]:
|
|
201
|
-
writers_by_file[path].append(task.id)
|
|
202
|
-
for path, owners in writers_by_file.items():
|
|
203
|
-
if len(owners) > 1:
|
|
204
|
-
gaps.append(Gap(
|
|
205
|
-
id=f"GAP-PLAN-OVERLAP-{owners[0]}-{path.replace('/', '_')}",
|
|
206
|
-
severity="medium",
|
|
207
|
-
gap_type="task_not_implemented",
|
|
208
|
-
description=(
|
|
209
|
-
f"{len(owners)} tasks ({', '.join(owners)}) each declare writable "
|
|
210
|
-
f"changes to {path}; overlapping ownership over-decomposes the plan "
|
|
211
|
-
"and tends to cause duplicate/conflicting edits at execution."
|
|
212
|
-
),
|
|
213
|
-
recommended_fix=(
|
|
214
|
-
f"Consolidate the work on {path} into a single task, or scope the "
|
|
215
|
-
"others to read_only."
|
|
216
|
-
),
|
|
217
|
-
blocking=False,
|
|
218
|
-
))
|
|
219
|
-
|
|
220
|
-
# Validate the task dependency DAG: unknown depends_on ids and cycles would make
|
|
221
|
-
# execution ordering impossible / stall the run, so block the plan on them.
|
|
222
|
-
gaps.extend(self._validate_task_dependencies(tasks))
|
|
223
|
-
|
|
224
|
-
for assumption in assumptions or []:
|
|
225
|
-
if (
|
|
226
|
-
assumption.impact == "high"
|
|
227
|
-
and assumption.status == "open"
|
|
228
|
-
and assumption.requires_user_confirmation
|
|
229
|
-
):
|
|
230
|
-
gaps.append(Gap(
|
|
231
|
-
id=f"GAP-PLAN-{assumption.id}-OPEN",
|
|
232
|
-
severity="high",
|
|
233
|
-
gap_type="assumption_violated",
|
|
234
|
-
requirement_id=assumption.linked_requirement_ids[0] if assumption.linked_requirement_ids else None,
|
|
235
|
-
description=f"High-impact assumption {assumption.id} is still open: {assumption.statement}",
|
|
236
|
-
recommended_fix="Confirm, reject, or convert this assumption before approving the plan.",
|
|
237
|
-
blocking=True,
|
|
238
|
-
))
|
|
239
|
-
|
|
240
|
-
for finding in findings or []:
|
|
241
|
-
if finding.severity in {"high", "critical"} and finding.status == "open":
|
|
242
|
-
gaps.append(Gap(
|
|
243
|
-
id=f"GAP-PLAN-{finding.id}-OPEN",
|
|
244
|
-
severity=finding.severity,
|
|
245
|
-
gap_type="architecture_drift",
|
|
246
|
-
requirement_id=finding.linked_requirement_id,
|
|
247
|
-
description=f"Open {finding.severity} critique finding remains: {finding.claim}",
|
|
248
|
-
recommended_fix="Convert, rebut with evidence, or mark this finding resolved before approval.",
|
|
249
|
-
blocking=True,
|
|
250
|
-
))
|
|
251
|
-
|
|
252
|
-
for question in blocking_questions or []:
|
|
253
|
-
question_id = getattr(question, "id", "QUESTION")
|
|
254
|
-
question_text = getattr(question, "question", str(question))
|
|
255
|
-
gaps.append(Gap(
|
|
256
|
-
id=f"GAP-PLAN-{question_id}-BLOCKING",
|
|
257
|
-
severity="high",
|
|
258
|
-
gap_type="requirement_not_planned",
|
|
259
|
-
description=f"Blocking question remains unanswered: {question_text}",
|
|
260
|
-
recommended_fix="Answer or convert the blocking question before approving the plan.",
|
|
261
|
-
blocking=True,
|
|
262
|
-
))
|
|
263
|
-
|
|
264
|
-
return GateResult(
|
|
265
|
-
passed=len([g for g in gaps if g.blocking]) == 0,
|
|
266
|
-
gaps=gaps
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
def check_task_ready(self, task: Task, project_root: Path) -> GateResult:
|
|
270
|
-
"""Determines if a specific task can begin execution."""
|
|
271
|
-
gaps = []
|
|
272
|
-
|
|
273
|
-
# 1. Check Git state
|
|
274
|
-
gaps.extend(self.clean_git.check(project_root, task.id))
|
|
275
|
-
|
|
276
|
-
# 2. Check planned files
|
|
277
|
-
gaps.extend(self.planned_files.check(task))
|
|
278
|
-
|
|
279
|
-
# 3. Surface a missing execution/verification contract — but do NOT block
|
|
280
|
-
# execution on it. The executor still needs to run to implement the code,
|
|
281
|
-
# and the evidence requirement is genuinely enforced at verify time
|
|
282
|
-
# (acceptance_criteria_unproven / NOAC gaps). Blocking here only prevents
|
|
283
|
-
# implementation and stalls multi-task plans when the planner under-specs a
|
|
284
|
-
# task; these stay advisory so the work can proceed and be judged on output.
|
|
285
|
-
if not task.allowed_commands and not task.expected_tests:
|
|
286
|
-
gaps.append(Gap(
|
|
287
|
-
id=f"GAP-{task.id}-NO-COMMANDS",
|
|
288
|
-
severity="medium",
|
|
289
|
-
gap_type="missing_test",
|
|
290
|
-
task_id=task.id,
|
|
291
|
-
description=f"Task {task.id} has no allowed commands for execution or verification.",
|
|
292
|
-
recommended_fix="Add explicit allowed_commands or expected_tests so verification can prove the acceptance criteria.",
|
|
293
|
-
blocking=False,
|
|
294
|
-
))
|
|
295
|
-
|
|
296
|
-
if not task.expected_tests:
|
|
297
|
-
gaps.append(Gap(
|
|
298
|
-
id=f"GAP-{task.id}-NO-EXPECTED-EVIDENCE",
|
|
299
|
-
severity="medium",
|
|
300
|
-
gap_type="missing_test",
|
|
301
|
-
task_id=task.id,
|
|
302
|
-
description=f"Task {task.id} has no expected verification evidence.",
|
|
303
|
-
recommended_fix="Add expected_tests or targeted static/manual review commands that prove the acceptance criteria.",
|
|
304
|
-
blocking=False,
|
|
305
|
-
))
|
|
306
|
-
|
|
307
|
-
return GateResult(
|
|
308
|
-
passed=len([g for g in gaps if g.blocking]) == 0,
|
|
309
|
-
gaps=gaps
|
|
310
|
-
)
|
|
311
|
-
|
|
312
|
-
def _validate_task_dependencies(self, tasks: List[Task]) -> List[Gap]:
|
|
313
|
-
"""Block on a malformed dependency DAG: unknown depends_on ids and cycles."""
|
|
314
|
-
gaps: List[Gap] = []
|
|
315
|
-
ids = {t.id for t in tasks}
|
|
316
|
-
for task in tasks:
|
|
317
|
-
unknown = [dep for dep in task.depends_on if dep not in ids]
|
|
318
|
-
if unknown:
|
|
319
|
-
gaps.append(Gap(
|
|
320
|
-
id=f"GAP-PLAN-{task.id}-UNKNOWN-DEP",
|
|
321
|
-
severity="high",
|
|
322
|
-
gap_type="task_not_implemented",
|
|
323
|
-
task_id=task.id,
|
|
324
|
-
description=f"Task {task.id} depends on unknown task(s): {', '.join(unknown)}.",
|
|
325
|
-
recommended_fix="Reference only task IDs that exist in this plan, or remove the dependency.",
|
|
326
|
-
blocking=True,
|
|
327
|
-
))
|
|
328
|
-
cycle = _find_dependency_cycle(tasks)
|
|
329
|
-
if cycle:
|
|
330
|
-
gaps.append(Gap(
|
|
331
|
-
id="GAP-PLAN-DEP-CYCLE",
|
|
332
|
-
severity="high",
|
|
333
|
-
gap_type="task_not_implemented",
|
|
334
|
-
description=f"Task dependency cycle detected: {' -> '.join(cycle)}.",
|
|
335
|
-
recommended_fix="Break the cycle so the tasks can be ordered and executed.",
|
|
336
|
-
blocking=True,
|
|
337
|
-
))
|
|
338
|
-
return gaps
|
|
@@ -1,184 +0,0 @@
|
|
|
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 +0,0 @@
|
|
|
1
|
-
|
|
@@ -1,168 +0,0 @@
|
|
|
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"
|