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,374 @@
|
|
|
1
|
+
"""Best-effort software-composition analysis (dependency vulnerability awareness).
|
|
2
|
+
|
|
3
|
+
DevCouncil does not bundle a vulnerability database and must never reach the
|
|
4
|
+
network on its own initiative. This module is a thin, *offline-safe* wrapper
|
|
5
|
+
around whatever auditors the developer already has installed (``pip-audit``,
|
|
6
|
+
``npm audit``, ``osv-scanner``): it detects the auditor + the project's
|
|
7
|
+
lockfiles, runs the local tool with a bounded timeout and a sanitized subprocess
|
|
8
|
+
environment, and parses the output into a structured list of dependency risks.
|
|
9
|
+
|
|
10
|
+
Design contract:
|
|
11
|
+
- **Never raises.** Every public entry point swallows tool/parse/OS errors and
|
|
12
|
+
degrades to an empty result, so a missing tool or malformed output can never
|
|
13
|
+
break ``dev map`` / prompt building / CI scaffolding.
|
|
14
|
+
- **Opt-in / local-only by default.** Nothing here runs unless a caller asks for
|
|
15
|
+
it; ``ScaScanner.scan`` is the only thing that shells out.
|
|
16
|
+
- **Injectable runner.** ``ScaScanner`` takes an ``auditor_runner`` callable so
|
|
17
|
+
tests (and offline environments) can feed canned auditor output without any
|
|
18
|
+
network access or installed tools.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import asdict, dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from devcouncil.utils.subprocess_env import clean_subprocess_env
|
|
31
|
+
|
|
32
|
+
# Default per-auditor timeout. Bounded so a slow/hung auditor can't stall the map.
|
|
33
|
+
DEFAULT_TIMEOUT = 60
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class DependencyRisk:
|
|
38
|
+
"""A single dependency vulnerability finding (auditor-agnostic shape)."""
|
|
39
|
+
|
|
40
|
+
package: str
|
|
41
|
+
installed_version: str
|
|
42
|
+
severity: str
|
|
43
|
+
advisory_id: str
|
|
44
|
+
summary: str
|
|
45
|
+
|
|
46
|
+
def as_dict(self) -> dict[str, str]:
|
|
47
|
+
return asdict(self)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class AuditorResult:
|
|
52
|
+
"""Raw output of one auditor invocation; ``returncode`` < 0 means it didn't run."""
|
|
53
|
+
|
|
54
|
+
returncode: int
|
|
55
|
+
stdout: str
|
|
56
|
+
stderr: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# An auditor runner takes the auditor's argv and the project root, and returns the
|
|
60
|
+
# captured result. Injected so tests need no installed tools or network access.
|
|
61
|
+
AuditorRunner = Callable[[list[str], Path], AuditorResult]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class _Auditor:
|
|
66
|
+
name: str # logical auditor name (pip-audit / npm / osv-scanner)
|
|
67
|
+
executable: str # the executable to look for on PATH
|
|
68
|
+
stack: str # which stack it covers (python / node)
|
|
69
|
+
# Lockfiles whose presence makes this auditor relevant for the repo.
|
|
70
|
+
lockfiles: tuple[str, ...]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Ordered so the most specific / preferred auditor for each stack comes first.
|
|
74
|
+
_AUDITORS: tuple[_Auditor, ...] = (
|
|
75
|
+
_Auditor(
|
|
76
|
+
name="pip-audit",
|
|
77
|
+
executable="pip-audit",
|
|
78
|
+
stack="python",
|
|
79
|
+
lockfiles=("uv.lock", "requirements.txt", "poetry.lock"),
|
|
80
|
+
),
|
|
81
|
+
_Auditor(
|
|
82
|
+
name="npm",
|
|
83
|
+
executable="npm",
|
|
84
|
+
stack="node",
|
|
85
|
+
lockfiles=("package-lock.json", "yarn.lock", "pnpm-lock.yaml"),
|
|
86
|
+
),
|
|
87
|
+
_Auditor(
|
|
88
|
+
name="osv-scanner",
|
|
89
|
+
executable="osv-scanner",
|
|
90
|
+
stack="any",
|
|
91
|
+
lockfiles=(
|
|
92
|
+
"uv.lock",
|
|
93
|
+
"requirements.txt",
|
|
94
|
+
"poetry.lock",
|
|
95
|
+
"package-lock.json",
|
|
96
|
+
"yarn.lock",
|
|
97
|
+
"pnpm-lock.yaml",
|
|
98
|
+
"go.sum",
|
|
99
|
+
"Cargo.lock",
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _default_runner(timeout: int) -> AuditorRunner:
|
|
106
|
+
"""Real subprocess runner: bounded timeout + sanitized env; never raises."""
|
|
107
|
+
|
|
108
|
+
def run(argv: list[str], project_root: Path) -> AuditorResult:
|
|
109
|
+
try:
|
|
110
|
+
completed = subprocess.run(
|
|
111
|
+
argv,
|
|
112
|
+
cwd=project_root,
|
|
113
|
+
capture_output=True,
|
|
114
|
+
text=True,
|
|
115
|
+
encoding="utf-8",
|
|
116
|
+
errors="replace",
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
env=clean_subprocess_env(),
|
|
119
|
+
)
|
|
120
|
+
except subprocess.TimeoutExpired:
|
|
121
|
+
return AuditorResult(returncode=-1, stdout="", stderr="timed out")
|
|
122
|
+
except (FileNotFoundError, OSError) as exc:
|
|
123
|
+
return AuditorResult(returncode=-1, stdout="", stderr=str(exc))
|
|
124
|
+
return AuditorResult(
|
|
125
|
+
returncode=completed.returncode,
|
|
126
|
+
stdout=completed.stdout or "",
|
|
127
|
+
stderr=completed.stderr or "",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return run
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ScaScanner:
|
|
134
|
+
"""Detects locally-available auditors + lockfiles and runs them best-effort.
|
|
135
|
+
|
|
136
|
+
Pass ``auditor_runner`` to inject the subprocess behaviour (tests do this to
|
|
137
|
+
return canned auditor output offline). When omitted, a real bounded-timeout,
|
|
138
|
+
clean-env subprocess runner is used.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
project_root: Path,
|
|
144
|
+
*,
|
|
145
|
+
auditor_runner: AuditorRunner | None = None,
|
|
146
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
147
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
148
|
+
):
|
|
149
|
+
self.project_root = Path(project_root)
|
|
150
|
+
self.timeout = timeout
|
|
151
|
+
self._which = which
|
|
152
|
+
# When a runner is injected (tests / offline), the runner itself decides
|
|
153
|
+
# whether a tool "exists"; we skip the PATH gate so canned output flows.
|
|
154
|
+
self._injected = auditor_runner is not None
|
|
155
|
+
self._runner: AuditorRunner = auditor_runner or _default_runner(timeout)
|
|
156
|
+
|
|
157
|
+
# -- detection -----------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def _has_lockfile(self, auditor: _Auditor) -> bool:
|
|
160
|
+
return any((self.project_root / name).exists() for name in auditor.lockfiles)
|
|
161
|
+
|
|
162
|
+
def _is_runnable(self, auditor: _Auditor) -> bool:
|
|
163
|
+
"""Relevant to the repo (lockfile present) and runnable (on PATH or injected)."""
|
|
164
|
+
if not self._has_lockfile(auditor):
|
|
165
|
+
return False
|
|
166
|
+
return self._injected or self._which(auditor.executable) is not None
|
|
167
|
+
|
|
168
|
+
def available_auditors(self) -> list[str]:
|
|
169
|
+
"""Names of auditors that are both runnable AND relevant to this repo.
|
|
170
|
+
|
|
171
|
+
An auditor counts as runnable when its executable is on PATH (real
|
|
172
|
+
installs) OR when a custom runner is injected (tests/offline).
|
|
173
|
+
"""
|
|
174
|
+
seen: set[str] = set()
|
|
175
|
+
ordered: list[str] = []
|
|
176
|
+
for auditor in _AUDITORS:
|
|
177
|
+
if self._is_runnable(auditor) and auditor.name not in seen:
|
|
178
|
+
seen.add(auditor.name)
|
|
179
|
+
ordered.append(auditor.name)
|
|
180
|
+
return ordered
|
|
181
|
+
|
|
182
|
+
# -- scanning ------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def scan(self) -> list[DependencyRisk]:
|
|
185
|
+
"""Run every available auditor and merge their findings. Never raises."""
|
|
186
|
+
risks: list[DependencyRisk] = []
|
|
187
|
+
seen: set[tuple[str, str, str]] = set()
|
|
188
|
+
for auditor in _AUDITORS:
|
|
189
|
+
if not self._is_runnable(auditor):
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
result = self._runner(self._argv_for(auditor), self.project_root)
|
|
193
|
+
parsed = self._parse(auditor, result)
|
|
194
|
+
except Exception:
|
|
195
|
+
# Best-effort contract: a broken auditor/parse must not propagate.
|
|
196
|
+
continue
|
|
197
|
+
for risk in parsed:
|
|
198
|
+
key = (risk.package, risk.installed_version, risk.advisory_id)
|
|
199
|
+
if key in seen:
|
|
200
|
+
continue
|
|
201
|
+
seen.add(key)
|
|
202
|
+
risks.append(risk)
|
|
203
|
+
return risks
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _argv_for(auditor: _Auditor) -> list[str]:
|
|
207
|
+
if auditor.name == "pip-audit":
|
|
208
|
+
return ["pip-audit", "--format", "json"]
|
|
209
|
+
if auditor.name == "npm":
|
|
210
|
+
return ["npm", "audit", "--json"]
|
|
211
|
+
if auditor.name == "osv-scanner":
|
|
212
|
+
return ["osv-scanner", "--format", "json", "."]
|
|
213
|
+
return [auditor.executable]
|
|
214
|
+
|
|
215
|
+
def _parse(self, auditor: _Auditor, result: AuditorResult) -> list[DependencyRisk]:
|
|
216
|
+
if result.returncode < 0 or not result.stdout.strip():
|
|
217
|
+
return []
|
|
218
|
+
try:
|
|
219
|
+
data = json.loads(result.stdout)
|
|
220
|
+
except (json.JSONDecodeError, ValueError):
|
|
221
|
+
return []
|
|
222
|
+
if auditor.name == "pip-audit":
|
|
223
|
+
return _parse_pip_audit(data)
|
|
224
|
+
if auditor.name == "npm":
|
|
225
|
+
return _parse_npm_audit(data)
|
|
226
|
+
if auditor.name == "osv-scanner":
|
|
227
|
+
return _parse_osv_scanner(data)
|
|
228
|
+
return []
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ----------------------------------------------------------------------------
|
|
232
|
+
# Per-auditor parsers. Each is defensive: unknown shapes yield no risks.
|
|
233
|
+
# ----------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _coerce_str(value: object, default: str = "") -> str:
|
|
237
|
+
if value is None:
|
|
238
|
+
return default
|
|
239
|
+
if isinstance(value, str):
|
|
240
|
+
return value
|
|
241
|
+
return str(value)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _parse_pip_audit(data: object) -> list[DependencyRisk]:
|
|
245
|
+
"""pip-audit ``--format json`` -> dependencies[].vulns[]."""
|
|
246
|
+
risks: list[DependencyRisk] = []
|
|
247
|
+
deps: object
|
|
248
|
+
if isinstance(data, dict):
|
|
249
|
+
deps = data.get("dependencies", [])
|
|
250
|
+
elif isinstance(data, list):
|
|
251
|
+
deps = data # older pip-audit emitted a bare list
|
|
252
|
+
else:
|
|
253
|
+
return []
|
|
254
|
+
if not isinstance(deps, list):
|
|
255
|
+
return []
|
|
256
|
+
for dep in deps:
|
|
257
|
+
if not isinstance(dep, dict):
|
|
258
|
+
continue
|
|
259
|
+
name = _coerce_str(dep.get("name"))
|
|
260
|
+
version = _coerce_str(dep.get("version"))
|
|
261
|
+
vulns = dep.get("vulns") or []
|
|
262
|
+
if not isinstance(vulns, list):
|
|
263
|
+
continue
|
|
264
|
+
for vuln in vulns:
|
|
265
|
+
if not isinstance(vuln, dict):
|
|
266
|
+
continue
|
|
267
|
+
risks.append(
|
|
268
|
+
DependencyRisk(
|
|
269
|
+
package=name,
|
|
270
|
+
installed_version=version,
|
|
271
|
+
severity=_coerce_str(vuln.get("severity"), "unknown") or "unknown",
|
|
272
|
+
advisory_id=_coerce_str(vuln.get("id"), "UNKNOWN") or "UNKNOWN",
|
|
273
|
+
summary=_coerce_str(vuln.get("description") or vuln.get("summary")),
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return risks
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _parse_npm_audit(data: object) -> list[DependencyRisk]:
|
|
280
|
+
"""npm audit ``--json`` (npm v7+) -> vulnerabilities{ name: {...} }."""
|
|
281
|
+
if not isinstance(data, dict):
|
|
282
|
+
return []
|
|
283
|
+
vulnerabilities = data.get("vulnerabilities")
|
|
284
|
+
if not isinstance(vulnerabilities, dict):
|
|
285
|
+
return []
|
|
286
|
+
risks: list[DependencyRisk] = []
|
|
287
|
+
for name, info in vulnerabilities.items():
|
|
288
|
+
if not isinstance(info, dict):
|
|
289
|
+
continue
|
|
290
|
+
severity = _coerce_str(info.get("severity"), "unknown") or "unknown"
|
|
291
|
+
version = _coerce_str(info.get("range"))
|
|
292
|
+
via = info.get("via") or []
|
|
293
|
+
advisory_id = "UNKNOWN"
|
|
294
|
+
summary = ""
|
|
295
|
+
if isinstance(via, list):
|
|
296
|
+
for entry in via:
|
|
297
|
+
if isinstance(entry, dict):
|
|
298
|
+
source = entry.get("source") or entry.get("url")
|
|
299
|
+
advisory_id = _coerce_str(source, "UNKNOWN") or "UNKNOWN"
|
|
300
|
+
summary = _coerce_str(entry.get("title"))
|
|
301
|
+
break
|
|
302
|
+
risks.append(
|
|
303
|
+
DependencyRisk(
|
|
304
|
+
package=_coerce_str(name),
|
|
305
|
+
installed_version=version,
|
|
306
|
+
severity=severity,
|
|
307
|
+
advisory_id=advisory_id,
|
|
308
|
+
summary=summary,
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
return risks
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _parse_osv_scanner(data: object) -> list[DependencyRisk]:
|
|
315
|
+
"""osv-scanner ``--format json`` -> results[].packages[].vulnerabilities[]."""
|
|
316
|
+
if not isinstance(data, dict):
|
|
317
|
+
return []
|
|
318
|
+
results = data.get("results")
|
|
319
|
+
if not isinstance(results, list):
|
|
320
|
+
return []
|
|
321
|
+
risks: list[DependencyRisk] = []
|
|
322
|
+
for result in results:
|
|
323
|
+
if not isinstance(result, dict):
|
|
324
|
+
continue
|
|
325
|
+
packages = result.get("packages") or []
|
|
326
|
+
if not isinstance(packages, list):
|
|
327
|
+
continue
|
|
328
|
+
for package in packages:
|
|
329
|
+
if not isinstance(package, dict):
|
|
330
|
+
continue
|
|
331
|
+
pkg_info = package.get("package") or {}
|
|
332
|
+
name = _coerce_str(pkg_info.get("name")) if isinstance(pkg_info, dict) else ""
|
|
333
|
+
version = _coerce_str(pkg_info.get("version")) if isinstance(pkg_info, dict) else ""
|
|
334
|
+
vulns = package.get("vulnerabilities") or []
|
|
335
|
+
if not isinstance(vulns, list):
|
|
336
|
+
continue
|
|
337
|
+
for vuln in vulns:
|
|
338
|
+
if not isinstance(vuln, dict):
|
|
339
|
+
continue
|
|
340
|
+
risks.append(
|
|
341
|
+
DependencyRisk(
|
|
342
|
+
package=name,
|
|
343
|
+
installed_version=version,
|
|
344
|
+
severity=_osv_severity(vuln),
|
|
345
|
+
advisory_id=_coerce_str(vuln.get("id"), "UNKNOWN") or "UNKNOWN",
|
|
346
|
+
summary=_coerce_str(vuln.get("summary") or vuln.get("details")),
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
return risks
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _osv_severity(vuln: dict) -> str:
|
|
353
|
+
severity = vuln.get("severity")
|
|
354
|
+
if isinstance(severity, list) and severity:
|
|
355
|
+
first = severity[0]
|
|
356
|
+
if isinstance(first, dict):
|
|
357
|
+
return _coerce_str(first.get("type") or first.get("score"), "unknown") or "unknown"
|
|
358
|
+
if isinstance(severity, str) and severity:
|
|
359
|
+
return severity
|
|
360
|
+
return "unknown"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def scan_dependency_risks(
|
|
364
|
+
project_root: Path,
|
|
365
|
+
*,
|
|
366
|
+
auditor_runner: AuditorRunner | None = None,
|
|
367
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
368
|
+
) -> list[dict[str, str]]:
|
|
369
|
+
"""Convenience entry point: returns dependency risks as plain dicts. Never raises."""
|
|
370
|
+
try:
|
|
371
|
+
scanner = ScaScanner(project_root, auditor_runner=auditor_runner, timeout=timeout)
|
|
372
|
+
return [risk.as_dict() for risk in scanner.scan()]
|
|
373
|
+
except Exception:
|
|
374
|
+
return []
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
from devcouncil.artifacts.graph import ArtifactGraph
|
|
2
|
-
|
|
3
|
-
class GitHubCheckGenerator:
|
|
4
|
-
"""Generates GitHub Checks API payloads."""
|
|
5
|
-
|
|
6
|
-
@staticmethod
|
|
7
|
-
def generate(graph: ArtifactGraph) -> dict:
|
|
8
|
-
summary = graph.coverage_summary()
|
|
9
|
-
blocking_gaps = graph.blocking_gaps()
|
|
10
|
-
|
|
11
|
-
status = "completed"
|
|
12
|
-
conclusion = "failure" if summary["blocking_gaps"] > 0 else "success"
|
|
13
|
-
|
|
14
|
-
text = f"**Requirements**: {summary['total_requirements']} | "
|
|
15
|
-
text += f"**Tasks**: {summary['total_tasks']} | "
|
|
16
|
-
text += f"**Gaps**: {summary['blocking_gaps']} blocking\n\n"
|
|
17
|
-
|
|
18
|
-
if blocking_gaps:
|
|
19
|
-
text += "### Blocking Gaps\n"
|
|
20
|
-
for gap in blocking_gaps:
|
|
21
|
-
text += f"- **{gap.id}**: {gap.description}\n"
|
|
22
|
-
|
|
23
|
-
return {
|
|
24
|
-
"name": "DevCouncil Verification",
|
|
25
|
-
"status": status,
|
|
26
|
-
"conclusion": conclusion,
|
|
27
|
-
"output": {
|
|
28
|
-
"title": f"DevCouncil: {conclusion.capitalize()}",
|
|
29
|
-
"summary": f"Found {summary['blocking_gaps']} blocking gaps.",
|
|
30
|
-
"text": text
|
|
31
|
-
}
|
|
32
|
-
}
|
|
1
|
+
from devcouncil.artifacts.graph import ArtifactGraph
|
|
2
|
+
|
|
3
|
+
class GitHubCheckGenerator:
|
|
4
|
+
"""Generates GitHub Checks API payloads."""
|
|
5
|
+
|
|
6
|
+
@staticmethod
|
|
7
|
+
def generate(graph: ArtifactGraph) -> dict:
|
|
8
|
+
summary = graph.coverage_summary()
|
|
9
|
+
blocking_gaps = graph.blocking_gaps()
|
|
10
|
+
|
|
11
|
+
status = "completed"
|
|
12
|
+
conclusion = "failure" if summary["blocking_gaps"] > 0 else "success"
|
|
13
|
+
|
|
14
|
+
text = f"**Requirements**: {summary['total_requirements']} | "
|
|
15
|
+
text += f"**Tasks**: {summary['total_tasks']} | "
|
|
16
|
+
text += f"**Gaps**: {summary['blocking_gaps']} blocking\n\n"
|
|
17
|
+
|
|
18
|
+
if blocking_gaps:
|
|
19
|
+
text += "### Blocking Gaps\n"
|
|
20
|
+
for gap in blocking_gaps:
|
|
21
|
+
text += f"- **{gap.id}**: {gap.description}\n"
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
"name": "DevCouncil Verification",
|
|
25
|
+
"status": status,
|
|
26
|
+
"conclusion": conclusion,
|
|
27
|
+
"output": {
|
|
28
|
+
"title": f"DevCouncil: {conclusion.capitalize()}",
|
|
29
|
+
"summary": f"Found {summary['blocking_gaps']} blocking gaps.",
|
|
30
|
+
"text": text
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -1,17 +1,30 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from devcouncil.artifacts.graph import ArtifactGraph
|
|
3
|
-
|
|
4
|
-
class JsonReportGenerator:
|
|
5
|
-
"""Generates a JSON evidence report."""
|
|
6
|
-
|
|
7
|
-
@staticmethod
|
|
8
|
-
def generate(graph: ArtifactGraph) -> str:
|
|
9
|
-
summary = graph.coverage_summary()
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
import json
|
|
2
|
+
from devcouncil.artifacts.graph import ArtifactGraph
|
|
3
|
+
|
|
4
|
+
class JsonReportGenerator:
|
|
5
|
+
"""Generates a JSON evidence report."""
|
|
6
|
+
|
|
7
|
+
@staticmethod
|
|
8
|
+
def generate(graph: ArtifactGraph, live_review: dict | None = None) -> str:
|
|
9
|
+
summary = graph.coverage_summary()
|
|
10
|
+
live_blockers = len((live_review or {}).get("blocking_cards", []))
|
|
11
|
+
|
|
12
|
+
# Three honest states (see markdown_report for rationale):
|
|
13
|
+
# blocked - positive evidence of a problem.
|
|
14
|
+
# incomplete - nothing failing, but not every AC has passing evidence.
|
|
15
|
+
# passed - no blocking gaps and every AC proven.
|
|
16
|
+
if summary["blocking_gaps"] > 0 or live_blockers > 0:
|
|
17
|
+
verdict = "blocked"
|
|
18
|
+
elif summary["ac_without_evidence"] > 0:
|
|
19
|
+
verdict = "incomplete"
|
|
20
|
+
else:
|
|
21
|
+
verdict = "passed"
|
|
22
|
+
report = {
|
|
23
|
+
"verdict": verdict,
|
|
24
|
+
"coverage_summary": summary,
|
|
25
|
+
"blocking_gaps": [g.model_dump() for g in graph.blocking_gaps()]
|
|
26
|
+
}
|
|
27
|
+
if live_review is not None:
|
|
28
|
+
report["live_review"] = live_review
|
|
29
|
+
|
|
30
|
+
return json.dumps(report, indent=2)
|
|
@@ -1,46 +1,83 @@
|
|
|
1
|
-
from devcouncil.artifacts.graph import ArtifactGraph
|
|
2
|
-
|
|
3
|
-
class MarkdownReportGenerator:
|
|
4
|
-
"""Generates a Markdown evidence report."""
|
|
5
|
-
|
|
6
|
-
MAX_INLINE_GAPS = 25
|
|
7
|
-
|
|
8
|
-
@staticmethod
|
|
9
|
-
def generate(graph: ArtifactGraph) -> str:
|
|
10
|
-
summary = graph.coverage_summary()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
md_output +=
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
1
|
+
from devcouncil.artifacts.graph import ArtifactGraph
|
|
2
|
+
|
|
3
|
+
class MarkdownReportGenerator:
|
|
4
|
+
"""Generates a Markdown evidence report."""
|
|
5
|
+
|
|
6
|
+
MAX_INLINE_GAPS = 25
|
|
7
|
+
|
|
8
|
+
@staticmethod
|
|
9
|
+
def generate(graph: ArtifactGraph, live_review: dict | None = None) -> str:
|
|
10
|
+
summary = graph.coverage_summary()
|
|
11
|
+
live_blockers = (live_review or {}).get("blocking_cards", [])
|
|
12
|
+
|
|
13
|
+
unverified_ac = summary["ac_without_evidence"]
|
|
14
|
+
|
|
15
|
+
md_output = "# DevCouncil Report\n\n"
|
|
16
|
+
md_output += "## Verdict\n"
|
|
17
|
+
# Three honest states:
|
|
18
|
+
# Blocked - positive evidence of a problem (blocking gaps / live blockers).
|
|
19
|
+
# Incomplete - nothing is failing, but not every acceptance criterion has
|
|
20
|
+
# passing evidence yet (un-run, or could not be verified). NOT
|
|
21
|
+
# a failure — distinguishing this from Blocked is what keeps the
|
|
22
|
+
# "blocked" signal trustworthy (no false negatives on correct work).
|
|
23
|
+
# Passed - no blocking gaps and every acceptance criterion is proven.
|
|
24
|
+
if summary["blocking_gaps"] > 0 or live_blockers:
|
|
25
|
+
parts = []
|
|
26
|
+
if summary["blocking_gaps"] > 0:
|
|
27
|
+
parts.append(f"{summary['blocking_gaps']} high-severity gap(s)")
|
|
28
|
+
if live_blockers:
|
|
29
|
+
parts.append(f"{len(live_blockers)} live-review blocker(s)")
|
|
30
|
+
md_output += f"**Blocked**: {', '.join(parts)} remain.\n\n"
|
|
31
|
+
elif unverified_ac > 0:
|
|
32
|
+
md_output += (
|
|
33
|
+
f"**Incomplete**: nothing is failing, but {unverified_ac} acceptance "
|
|
34
|
+
"criterion(s) lack passing evidence (un-run or unverifiable). Not ready "
|
|
35
|
+
"for release.\n\n"
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
md_output += "**Passed**: Ready for release.\n\n"
|
|
39
|
+
|
|
40
|
+
md_output += "## Coverage Summary\n"
|
|
41
|
+
md_output += f"- **Requirements**: {summary['total_requirements']} ({summary['requirements_without_tasks']} unmapped)\n"
|
|
42
|
+
md_output += f"- **Tasks**: {summary['total_tasks']} ({summary['tasks_without_requirements']} orphaned)\n"
|
|
43
|
+
md_output += f"- **Evidence**: {summary['total_ac'] - summary['ac_without_evidence']}/{summary['total_ac']} AC verified\n\n"
|
|
44
|
+
|
|
45
|
+
md_output += "## Requirements Coverage Table\n"
|
|
46
|
+
md_output += "| Requirement | Task Mapping | Status |\n"
|
|
47
|
+
md_output += "|---|---|---|\n"
|
|
48
|
+
|
|
49
|
+
for req in graph.requirements.values():
|
|
50
|
+
linked_tasks = [t for t in graph.tasks.values() if req.id in t.requirement_ids]
|
|
51
|
+
task_str = ", ".join([t.id for t in linked_tasks]) if linked_tasks else "*None*"
|
|
52
|
+
status_str = "Covered" if linked_tasks else "**Unmapped**"
|
|
53
|
+
md_output += f"| {req.id} {req.title} | {task_str} | {status_str} |\n"
|
|
54
|
+
|
|
55
|
+
md_output += "\n## Blocking Gaps\n"
|
|
56
|
+
blocking_gaps = graph.blocking_gaps()
|
|
57
|
+
if not blocking_gaps:
|
|
58
|
+
md_output += "None.\n"
|
|
59
|
+
else:
|
|
60
|
+
for gap in blocking_gaps[:MarkdownReportGenerator.MAX_INLINE_GAPS]:
|
|
61
|
+
md_output += f"### {gap.id}: {gap.description}\n"
|
|
62
|
+
md_output += f"**Recommended fix**: {gap.recommended_fix}\n\n"
|
|
63
|
+
if len(blocking_gaps) > MarkdownReportGenerator.MAX_INLINE_GAPS:
|
|
64
|
+
remaining = len(blocking_gaps) - MarkdownReportGenerator.MAX_INLINE_GAPS
|
|
65
|
+
md_output += f"_Omitted {remaining} additional blocking gap(s). Use JSON output for the full list._\n"
|
|
66
|
+
|
|
67
|
+
if live_review is not None:
|
|
68
|
+
md_output += "\n## Live Review\n"
|
|
69
|
+
cards = live_review.get("cards", {})
|
|
70
|
+
md_output += f"- **Pending signals**: {live_review.get('pending_signals', 0)}\n"
|
|
71
|
+
md_output += f"- **Open cards**: {cards.get('open', 0)}\n"
|
|
72
|
+
md_output += f"- **Open critical cards**: {cards.get('critical_open', 0)}\n"
|
|
73
|
+
if not live_blockers:
|
|
74
|
+
md_output += "- **Blocking cards in scope**: None.\n"
|
|
75
|
+
else:
|
|
76
|
+
md_output += "\n### Blocking Live-Review Cards\n"
|
|
77
|
+
for card in live_blockers[:MarkdownReportGenerator.MAX_INLINE_GAPS]:
|
|
78
|
+
md_output += f"- **{card['id']}**"
|
|
79
|
+
if card.get("task_id"):
|
|
80
|
+
md_output += f" (`{card['task_id']}`)"
|
|
81
|
+
md_output += f": {card['summary']}\n"
|
|
82
|
+
|
|
83
|
+
return md_output
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
from devcouncil.artifacts.graph import ArtifactGraph
|
|
2
|
-
from devcouncil.reporting.markdown_report import MarkdownReportGenerator
|
|
3
|
-
from devcouncil.reporting.json_report import JsonReportGenerator
|
|
4
|
-
|
|
5
|
-
class ReportBuilder:
|
|
6
|
-
"""Builds reports in various formats from the artifact graph."""
|
|
7
|
-
|
|
8
|
-
@staticmethod
|
|
9
|
-
def build_markdown(graph: ArtifactGraph) -> str:
|
|
10
|
-
return MarkdownReportGenerator.generate(graph)
|
|
11
|
-
|
|
12
|
-
@staticmethod
|
|
13
|
-
def build_json(graph: ArtifactGraph) -> str:
|
|
14
|
-
return JsonReportGenerator.generate(graph)
|
|
1
|
+
from devcouncil.artifacts.graph import ArtifactGraph
|
|
2
|
+
from devcouncil.reporting.markdown_report import MarkdownReportGenerator
|
|
3
|
+
from devcouncil.reporting.json_report import JsonReportGenerator
|
|
4
|
+
|
|
5
|
+
class ReportBuilder:
|
|
6
|
+
"""Builds reports in various formats from the artifact graph."""
|
|
7
|
+
|
|
8
|
+
@staticmethod
|
|
9
|
+
def build_markdown(graph: ArtifactGraph, live_review: dict | None = None) -> str:
|
|
10
|
+
return MarkdownReportGenerator.generate(graph, live_review=live_review)
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def build_json(graph: ArtifactGraph, live_review: dict | None = None) -> str:
|
|
14
|
+
return JsonReportGenerator.generate(graph, live_review=live_review)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""DevCouncil skills library."""
|
|
2
|
+
|
|
3
|
+
from devcouncil.skills.registry import (
|
|
4
|
+
Skill,
|
|
5
|
+
get_skill,
|
|
6
|
+
load_skills,
|
|
7
|
+
render_preamble,
|
|
8
|
+
scaffold_skills,
|
|
9
|
+
select_skills,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Skill",
|
|
14
|
+
"get_skill",
|
|
15
|
+
"load_skills",
|
|
16
|
+
"render_preamble",
|
|
17
|
+
"scaffold_skills",
|
|
18
|
+
"select_skills",
|
|
19
|
+
]
|