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
|
@@ -1,51 +1,97 @@
|
|
|
1
1
|
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
2
4
|
import subprocess
|
|
5
|
+
import sys
|
|
3
6
|
import logging
|
|
4
7
|
import uuid
|
|
5
8
|
import fnmatch
|
|
6
9
|
import json
|
|
10
|
+
import re
|
|
7
11
|
import shlex
|
|
12
|
+
from dataclasses import dataclass, asdict
|
|
8
13
|
from pathlib import Path
|
|
9
14
|
from typing import List, Dict, Any, Optional, Tuple
|
|
10
|
-
|
|
11
|
-
from devcouncil.app.config import load_config
|
|
12
|
-
|
|
13
|
-
from devcouncil.domain.task import Task
|
|
14
|
-
from devcouncil.domain.requirement import Requirement
|
|
15
|
-
from devcouncil.domain.gap import Gap
|
|
16
|
-
from devcouncil.domain.evidence import TestEvidence, DiffEvidence, CommandResult
|
|
15
|
+
|
|
16
|
+
from devcouncil.app.config import load_config
|
|
17
|
+
|
|
18
|
+
from devcouncil.domain.task import Task
|
|
19
|
+
from devcouncil.domain.requirement import Requirement
|
|
20
|
+
from devcouncil.domain.gap import Gap
|
|
21
|
+
from devcouncil.domain.evidence import TestEvidence, DiffEvidence, DiffCoverageEvidence, CommandResult
|
|
22
|
+
from devcouncil.verification import diff_coverage as dc
|
|
17
23
|
from devcouncil.gating.checks.secret_scan_check import SecretScanner
|
|
18
24
|
from devcouncil.verification.implementation_reviewer import ImplementationReviewer
|
|
25
|
+
from devcouncil.verification.acceptance_compiler import AcceptanceTestCompiler
|
|
19
26
|
from devcouncil.llm.router import ModelRouter
|
|
20
27
|
from devcouncil.utils.redaction import redact_string
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
from devcouncil.live.cards import unresolved_blocking_cards
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
24
32
|
IGNORED_CHANGE_PATTERNS = (
|
|
25
|
-
"__pycache__/*",
|
|
26
|
-
"*/__pycache__/*",
|
|
27
|
-
"*.pyc",
|
|
28
|
-
"*.pyo",
|
|
29
|
-
".pytest_cache/*",
|
|
30
|
-
".mypy_cache/*",
|
|
31
|
-
".ruff_cache/*",
|
|
33
|
+
"__pycache__/*",
|
|
34
|
+
"*/__pycache__/*",
|
|
35
|
+
"*.pyc",
|
|
36
|
+
"*.pyo",
|
|
37
|
+
".pytest_cache/*",
|
|
38
|
+
".mypy_cache/*",
|
|
39
|
+
".ruff_cache/*",
|
|
32
40
|
".devcouncil/*",
|
|
41
|
+
# DevCouncil manages the root .gitignore itself (ensure_gitignore runs on
|
|
42
|
+
# init and before every task), so its drift is not task work.
|
|
43
|
+
".gitignore",
|
|
33
44
|
)
|
|
34
45
|
|
|
35
46
|
MAX_UNTRACKED_DIFF_BYTES = 256_000
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class VerificationOutcome:
|
|
51
|
+
"""Non-gap metadata about HOW a verification run executed.
|
|
52
|
+
|
|
53
|
+
The pass/fail verdict lives in the gaps; this records the *rigor* of the run so
|
|
54
|
+
an autonomous agent never mistakes ``passed`` for ``proven`` when the gate could
|
|
55
|
+
not actually check. ``mode`` is ``"compiled"`` when DevCouncil's per-criterion
|
|
56
|
+
acceptance checks were available (a model router was supplied) and ``"coarse"``
|
|
57
|
+
on the keyless fallback path. ``diff_empty`` flags a run with nothing to verify,
|
|
58
|
+
and the coverage fields say whether the diff↔coverage gate measured anything.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
mode: str = "coarse"
|
|
62
|
+
compiler_active: bool = False
|
|
63
|
+
diff_empty: bool = True
|
|
64
|
+
coverage_measured: bool = False
|
|
65
|
+
coverage_skipped_reason: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
68
|
+
return asdict(self)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Verifier:
|
|
72
|
+
def __init__(self, project_root: Path, router: Optional[ModelRouter] = None):
|
|
73
|
+
self.project_root = project_root
|
|
74
|
+
self._gap_counter = 0
|
|
75
|
+
self.secret_scanner = SecretScanner()
|
|
76
|
+
self.reviewer = ImplementationReviewer(router) if router else None
|
|
77
|
+
self.acceptance_compiler = AcceptanceTestCompiler(router) if router else None
|
|
78
|
+
# Metadata about the most recent verify_task run (rigor mode, diff/coverage
|
|
79
|
+
# status). Populated at the end of verify_task; read by the MCP/CLI surfaces
|
|
80
|
+
# so the agent knows whether the strong checks actually ran.
|
|
81
|
+
self.last_outcome: Optional[VerificationOutcome] = None
|
|
82
|
+
# Interpreter used to run diff-coverage instrumentation. None -> resolve the
|
|
83
|
+
# target repo's ``python`` from the cleaned PATH (falling back to the current
|
|
84
|
+
# interpreter). Overridable as a seam for deterministic tests.
|
|
85
|
+
self._coverage_python: Optional[str] = None
|
|
86
|
+
# When set, overrides the (measure, enforce, min_ratio) diff-coverage settings
|
|
87
|
+
# that would otherwise come from config. Used by ad-hoc checks and tests.
|
|
88
|
+
self._diff_coverage_override: Optional[Tuple[bool, bool, float]] = None
|
|
89
|
+
|
|
90
|
+
def _next_gap_id(self, task_id: str, suffix: str) -> str:
|
|
91
|
+
"""Generate unique gap IDs to prevent SQLite overwrites."""
|
|
92
|
+
self._gap_counter += 1
|
|
93
|
+
return f"GAP-{task_id}-{suffix}-{uuid.uuid4().hex[:6]}-{self._gap_counter:03d}"
|
|
94
|
+
|
|
49
95
|
def get_diff(self) -> str:
|
|
50
96
|
try:
|
|
51
97
|
if not self._has_head():
|
|
@@ -60,7 +106,7 @@ class Verifier:
|
|
|
60
106
|
return ""
|
|
61
107
|
|
|
62
108
|
def get_changed_files(self) -> List[str]:
|
|
63
|
-
try:
|
|
109
|
+
try:
|
|
64
110
|
if not self._has_head():
|
|
65
111
|
return self._get_status_files()
|
|
66
112
|
output = subprocess.check_output(
|
|
@@ -72,56 +118,92 @@ class Verifier:
|
|
|
72
118
|
except Exception as e:
|
|
73
119
|
logger.warning("Failed to get changed files: %s", e)
|
|
74
120
|
return []
|
|
75
|
-
|
|
76
|
-
def get_task_changed_files(self, task_id: str) -> List[str]:
|
|
77
|
-
changed = set(self.get_changed_files())
|
|
78
|
-
changed.difference_update(self._load_baseline_files())
|
|
79
|
-
changed.difference_update(self._load_task_snapshot_files(task_id))
|
|
80
|
-
return sorted(changed)
|
|
81
|
-
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
121
|
+
|
|
122
|
+
def get_task_changed_files(self, task_id: str) -> List[str]:
|
|
123
|
+
changed = set(self.get_changed_files())
|
|
124
|
+
changed.difference_update(self._load_baseline_files())
|
|
125
|
+
changed.difference_update(self._load_task_snapshot_files(task_id))
|
|
126
|
+
return sorted(changed)
|
|
127
|
+
|
|
128
|
+
def _task_produced_changes(self, task_id: str) -> bool:
|
|
129
|
+
"""True when the task has a footprint beyond the current working-tree diff.
|
|
130
|
+
|
|
131
|
+
Used so the empty-diff guard does not misfire on already-committed work: in
|
|
132
|
+
``dev go`` each task is committed and then re-verified by the reconciliation
|
|
133
|
+
pass, at which point ``git diff HEAD`` is empty even though the task was fully
|
|
134
|
+
implemented. We detect that via the task's ``before`` checkpoint ref (work
|
|
135
|
+
committed since the task started) and a non-empty ``after`` patch. A genuine
|
|
136
|
+
no-op run has neither, so it is still correctly flagged as empty.
|
|
137
|
+
"""
|
|
138
|
+
# Literal of CheckpointService.REF_BEFORE (kept inline to avoid a circular
|
|
139
|
+
# import: checkpoints.py imports Verifier).
|
|
140
|
+
before_ref = f"refs/devcouncil/tasks/{task_id}/before"
|
|
141
|
+
try:
|
|
142
|
+
has_ref = subprocess.run(
|
|
143
|
+
["git", "rev-parse", "--verify", before_ref],
|
|
144
|
+
cwd=self.project_root,
|
|
145
|
+
stdout=subprocess.DEVNULL,
|
|
146
|
+
stderr=subprocess.DEVNULL,
|
|
147
|
+
).returncode == 0
|
|
148
|
+
if has_ref:
|
|
149
|
+
diff = subprocess.check_output(
|
|
150
|
+
["git", "diff", before_ref],
|
|
151
|
+
cwd=self.project_root,
|
|
152
|
+
stderr=subprocess.DEVNULL,
|
|
153
|
+
).decode("utf-8", errors="replace")
|
|
154
|
+
if diff.strip():
|
|
155
|
+
return True
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
after_patch = self.project_root / ".devcouncil" / "checkpoints" / f"{task_id}-after.patch"
|
|
159
|
+
try:
|
|
160
|
+
return after_patch.exists() and bool(after_patch.read_text(encoding="utf-8", errors="replace").strip())
|
|
161
|
+
except Exception:
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
def _has_head(self) -> bool:
|
|
165
|
+
return subprocess.run(
|
|
166
|
+
["git", "rev-parse", "--verify", "HEAD"],
|
|
167
|
+
cwd=self.project_root,
|
|
168
|
+
stdout=subprocess.DEVNULL,
|
|
169
|
+
stderr=subprocess.DEVNULL,
|
|
170
|
+
).returncode == 0
|
|
171
|
+
|
|
90
172
|
def _get_initial_repo_diff(self) -> str:
|
|
91
173
|
parts: List[str] = []
|
|
92
174
|
for cmd in (["git", "diff", "--cached"], ["git", "diff"]):
|
|
93
|
-
result = subprocess.run(
|
|
94
|
-
cmd,
|
|
95
|
-
cwd=self.project_root,
|
|
96
|
-
capture_output=True,
|
|
97
|
-
text=True,
|
|
98
|
-
encoding="utf-8",
|
|
99
|
-
errors="replace",
|
|
100
|
-
)
|
|
175
|
+
result = subprocess.run(
|
|
176
|
+
cmd,
|
|
177
|
+
cwd=self.project_root,
|
|
178
|
+
capture_output=True,
|
|
179
|
+
text=True,
|
|
180
|
+
encoding="utf-8",
|
|
181
|
+
errors="replace",
|
|
182
|
+
)
|
|
101
183
|
if result.returncode == 0 and result.stdout:
|
|
102
184
|
parts.append(result.stdout)
|
|
103
185
|
untracked_diff = self._get_untracked_files_diff()
|
|
104
186
|
if untracked_diff:
|
|
105
187
|
parts.append(untracked_diff)
|
|
106
188
|
return "\n".join(parts)
|
|
107
|
-
|
|
189
|
+
|
|
108
190
|
def _get_status_files(self) -> List[str]:
|
|
109
|
-
files: set[str] = set()
|
|
110
|
-
commands = (
|
|
111
|
-
["git", "diff", "--cached", "--name-only"],
|
|
112
|
-
["git", "diff", "--name-only"],
|
|
113
|
-
["git", "ls-files", "--others", "--exclude-standard"],
|
|
114
|
-
)
|
|
115
|
-
for cmd in commands:
|
|
116
|
-
try:
|
|
117
|
-
output = subprocess.check_output(
|
|
118
|
-
cmd,
|
|
119
|
-
cwd=self.project_root,
|
|
120
|
-
stderr=subprocess.DEVNULL,
|
|
121
|
-
).decode("utf-8", errors="replace").splitlines()
|
|
122
|
-
files.update(path.replace("\\", "/") for path in output if path.strip())
|
|
123
|
-
except subprocess.CalledProcessError:
|
|
124
|
-
continue
|
|
191
|
+
files: set[str] = set()
|
|
192
|
+
commands = (
|
|
193
|
+
["git", "diff", "--cached", "--name-only"],
|
|
194
|
+
["git", "diff", "--name-only"],
|
|
195
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
196
|
+
)
|
|
197
|
+
for cmd in commands:
|
|
198
|
+
try:
|
|
199
|
+
output = subprocess.check_output(
|
|
200
|
+
cmd,
|
|
201
|
+
cwd=self.project_root,
|
|
202
|
+
stderr=subprocess.DEVNULL,
|
|
203
|
+
).decode("utf-8", errors="replace").splitlines()
|
|
204
|
+
files.update(path.replace("\\", "/") for path in output if path.strip())
|
|
205
|
+
except subprocess.CalledProcessError:
|
|
206
|
+
continue
|
|
125
207
|
if not files:
|
|
126
208
|
files.update(self._walk_project_files())
|
|
127
209
|
return self._filter_change_paths(sorted(files))
|
|
@@ -181,124 +263,231 @@ class Verifier:
|
|
|
181
263
|
if truncated:
|
|
182
264
|
diff_lines.append("+[devcouncil: untracked file diff truncated]")
|
|
183
265
|
return "\n".join(diff_lines)
|
|
184
|
-
|
|
185
|
-
def _walk_project_files(self) -> List[str]:
|
|
186
|
-
files: List[str] = []
|
|
187
|
-
for path in self.project_root.rglob("*"):
|
|
188
|
-
if not path.is_file():
|
|
189
|
-
continue
|
|
190
|
-
rel = path.relative_to(self.project_root).as_posix()
|
|
191
|
-
if rel.startswith(".git/"):
|
|
192
|
-
continue
|
|
193
|
-
files.append(rel)
|
|
194
|
-
return files
|
|
195
|
-
|
|
196
|
-
def _filter_change_paths(self, paths: List[str]) -> List[str]:
|
|
197
|
-
return [
|
|
198
|
-
path
|
|
199
|
-
for path in (p.strip().replace("\\", "/") for p in paths)
|
|
200
|
-
if path and not self._is_ignored_change(path)
|
|
201
|
-
]
|
|
202
|
-
|
|
203
|
-
def _is_ignored_change(self, path: str) -> bool:
|
|
204
|
-
return any(fnmatch.fnmatch(path, pattern) for pattern in IGNORED_CHANGE_PATTERNS)
|
|
205
|
-
|
|
206
|
-
def _load_baseline_files(self) -> set[str]:
|
|
207
|
-
return self._load_snapshot_files(self.project_root / ".devcouncil" / "baseline.json")
|
|
208
|
-
|
|
209
|
-
def _load_task_snapshot_files(self, task_id: str) -> set[str]:
|
|
210
|
-
return self._load_snapshot_files(
|
|
211
|
-
self.project_root / ".devcouncil" / "checkpoints" / f"{task_id}-before.json"
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
def _load_snapshot_files(self, path: Path) -> set[str]:
|
|
215
|
-
if not path.exists():
|
|
216
|
-
return set()
|
|
217
|
-
try:
|
|
218
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
219
|
-
return {
|
|
220
|
-
item.replace("\\", "/")
|
|
221
|
-
for item in data.get("changed_files", [])
|
|
222
|
-
if isinstance(item, str)
|
|
223
|
-
}
|
|
224
|
-
except Exception as e:
|
|
225
|
-
logger.warning("Failed to load verification snapshot %s: %s", path, e)
|
|
226
|
-
return set()
|
|
227
|
-
|
|
228
|
-
def _load_commands(self) -> Dict[str, List[str]]:
|
|
229
|
-
try:
|
|
230
|
-
config = load_config(self.project_root)
|
|
231
|
-
return {
|
|
232
|
-
"test": config.commands.test,
|
|
233
|
-
"lint": config.commands.lint,
|
|
234
|
-
"typecheck": config.commands.typecheck,
|
|
235
|
-
}
|
|
236
|
-
except Exception as e:
|
|
237
|
-
logger.warning("Failed to load config commands: %s", e)
|
|
238
|
-
return {}
|
|
239
|
-
|
|
240
|
-
def _save_log(self, label: str, command: str, stream: str, content: str) -> str:
|
|
241
|
-
"""Save command output to a log file and return the path."""
|
|
242
|
-
log_dir = self.project_root / ".devcouncil" / "logs"
|
|
243
|
-
log_dir.mkdir(parents=True, exist_ok=True)
|
|
244
|
-
cmd_hash = hashlib.sha256(command.encode()).hexdigest()[:8]
|
|
245
|
-
filename = f"{label}-{cmd_hash}-{stream}.log"
|
|
266
|
+
|
|
267
|
+
def _walk_project_files(self) -> List[str]:
|
|
268
|
+
files: List[str] = []
|
|
269
|
+
for path in self.project_root.rglob("*"):
|
|
270
|
+
if not path.is_file():
|
|
271
|
+
continue
|
|
272
|
+
rel = path.relative_to(self.project_root).as_posix()
|
|
273
|
+
if rel.startswith(".git/"):
|
|
274
|
+
continue
|
|
275
|
+
files.append(rel)
|
|
276
|
+
return files
|
|
277
|
+
|
|
278
|
+
def _filter_change_paths(self, paths: List[str]) -> List[str]:
|
|
279
|
+
return [
|
|
280
|
+
path
|
|
281
|
+
for path in (p.strip().replace("\\", "/") for p in paths)
|
|
282
|
+
if path and not self._is_ignored_change(path)
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
def _is_ignored_change(self, path: str) -> bool:
|
|
286
|
+
return any(fnmatch.fnmatch(path, pattern) for pattern in IGNORED_CHANGE_PATTERNS)
|
|
287
|
+
|
|
288
|
+
def _load_baseline_files(self) -> set[str]:
|
|
289
|
+
return self._load_snapshot_files(self.project_root / ".devcouncil" / "baseline.json")
|
|
290
|
+
|
|
291
|
+
def _load_task_snapshot_files(self, task_id: str) -> set[str]:
|
|
292
|
+
return self._load_snapshot_files(
|
|
293
|
+
self.project_root / ".devcouncil" / "checkpoints" / f"{task_id}-before.json"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _load_snapshot_files(self, path: Path) -> set[str]:
|
|
297
|
+
if not path.exists():
|
|
298
|
+
return set()
|
|
299
|
+
try:
|
|
300
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
301
|
+
return {
|
|
302
|
+
item.replace("\\", "/")
|
|
303
|
+
for item in data.get("changed_files", [])
|
|
304
|
+
if isinstance(item, str)
|
|
305
|
+
}
|
|
306
|
+
except Exception as e:
|
|
307
|
+
logger.warning("Failed to load verification snapshot %s: %s", path, e)
|
|
308
|
+
return set()
|
|
309
|
+
|
|
310
|
+
def _load_commands(self) -> Dict[str, List[str]]:
|
|
311
|
+
try:
|
|
312
|
+
config = load_config(self.project_root)
|
|
313
|
+
return {
|
|
314
|
+
"test": config.commands.test,
|
|
315
|
+
"lint": config.commands.lint,
|
|
316
|
+
"typecheck": config.commands.typecheck,
|
|
317
|
+
}
|
|
318
|
+
except Exception as e:
|
|
319
|
+
logger.warning("Failed to load config commands: %s", e)
|
|
320
|
+
return {}
|
|
321
|
+
|
|
322
|
+
def _save_log(self, label: str, command: str, stream: str, content: str) -> str:
|
|
323
|
+
"""Save command output to a log file and return the path."""
|
|
324
|
+
log_dir = self.project_root / ".devcouncil" / "logs"
|
|
325
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
cmd_hash = hashlib.sha256(command.encode()).hexdigest()[:8]
|
|
327
|
+
filename = f"{label}-{cmd_hash}-{stream}.log"
|
|
246
328
|
log_path = log_dir / filename
|
|
247
329
|
log_path.write_text(redact_string(content), encoding="utf-8")
|
|
248
330
|
return str(log_path)
|
|
249
|
-
|
|
250
|
-
def
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
331
|
+
|
|
332
|
+
def _verification_env(self) -> Dict[str, str]:
|
|
333
|
+
"""Environment for verification commands that does not leak DevCouncil's
|
|
334
|
+
own virtualenv into the target repository.
|
|
335
|
+
|
|
336
|
+
When DevCouncil is installed/run from a venv (e.g. ``uv tool install`` or
|
|
337
|
+
a project ``.venv``), a bare ``python``/``pytest`` in a task's evidence
|
|
338
|
+
command would otherwise resolve to DevCouncil's interpreter, which lacks
|
|
339
|
+
the target project's dependencies — producing false ``No module named
|
|
340
|
+
pytest`` style failures. Strip DevCouncil's venv from ``PATH`` and unset
|
|
341
|
+
the virtualenv markers so commands resolve the project/system interpreter,
|
|
342
|
+
exactly as they would in a plain terminal at the repo root.
|
|
343
|
+
"""
|
|
344
|
+
env = dict(os.environ)
|
|
345
|
+
venv_prefix = Path(sys.prefix).resolve()
|
|
346
|
+
base_prefix = Path(getattr(sys, "base_prefix", sys.prefix)).resolve()
|
|
347
|
+
if venv_prefix == base_prefix:
|
|
348
|
+
return env # Not running inside a venv; nothing to strip.
|
|
349
|
+
|
|
350
|
+
venv_dirs = {
|
|
351
|
+
str(venv_prefix).lower(),
|
|
352
|
+
str((venv_prefix / "Scripts").resolve()).lower(),
|
|
353
|
+
str((venv_prefix / "bin").resolve()).lower(),
|
|
354
|
+
}
|
|
355
|
+
path = env.get("PATH", "")
|
|
356
|
+
kept = []
|
|
357
|
+
for entry in path.split(os.pathsep):
|
|
358
|
+
if not entry:
|
|
359
|
+
continue
|
|
360
|
+
try:
|
|
361
|
+
normalized = str(Path(entry).resolve()).lower()
|
|
362
|
+
except Exception:
|
|
363
|
+
normalized = entry.lower()
|
|
364
|
+
if normalized in venv_dirs:
|
|
365
|
+
continue
|
|
366
|
+
kept.append(entry)
|
|
367
|
+
env["PATH"] = os.pathsep.join(kept)
|
|
368
|
+
|
|
369
|
+
# Drop the virtualenv-activation markers that would pin a freshly-resolved
|
|
370
|
+
# child ``python`` back to DevCouncil's interpreter. VIRTUAL_ENV points at
|
|
371
|
+
# the venv (sys.prefix); PYTHONHOME — set by uv-managed interpreters — points
|
|
372
|
+
# at the base interpreter (sys.base_prefix) and forcibly overrides the stdlib
|
|
373
|
+
# / site-packages location of ANY python the child invokes, which is what
|
|
374
|
+
# makes ``python -m pytest`` fail with "No module named pytest" even when the
|
|
375
|
+
# project's interpreter has pytest installed.
|
|
376
|
+
own_prefixes = {str(venv_prefix), str(base_prefix)}
|
|
377
|
+
for marker in ("VIRTUAL_ENV", "PYTHONHOME"):
|
|
378
|
+
value = env.get(marker)
|
|
379
|
+
if not value:
|
|
380
|
+
continue
|
|
381
|
+
try:
|
|
382
|
+
resolved = str(Path(value).resolve())
|
|
383
|
+
except Exception:
|
|
384
|
+
resolved = value
|
|
385
|
+
if resolved in own_prefixes:
|
|
386
|
+
env.pop(marker, None)
|
|
387
|
+
# uv stashes the same path here and re-applies it to child pythons.
|
|
388
|
+
env.pop("UV_INTERNAL__PYTHONHOME", None)
|
|
389
|
+
return env
|
|
390
|
+
|
|
391
|
+
@staticmethod
|
|
392
|
+
def _summarize_stream(content: str, budget: int = 360) -> str:
|
|
393
|
+
"""Condense a command's stdout/stderr for the evidence summary so the ACTUAL
|
|
394
|
+
error survives downstream truncation.
|
|
395
|
+
|
|
396
|
+
Plain ``content[-500:]`` kept the tail but the combined summary is later clipped
|
|
397
|
+
to its first 500 chars at the gap-evidence sites, which dropped the exception
|
|
398
|
+
line entirely. We hoist the salient error line (the last non-indented line, where
|
|
399
|
+
Python prints the exception) to the front, then append bounded context."""
|
|
400
|
+
if not content or not content.strip():
|
|
401
|
+
return "(empty)"
|
|
402
|
+
lines = [ln.rstrip() for ln in content.splitlines() if ln.strip()]
|
|
403
|
+
markers = ("error", "exception", "assert", "traceback", "failed", "not found", "no module named")
|
|
404
|
+
salient = ""
|
|
405
|
+
for ln in reversed(lines):
|
|
406
|
+
low = ln.lower()
|
|
407
|
+
if any(m in low for m in markers):
|
|
408
|
+
salient = ln.strip()
|
|
409
|
+
break
|
|
410
|
+
if not salient:
|
|
411
|
+
salient = lines[-1].strip()
|
|
412
|
+
salient = salient[:240] # cap a single huge (e.g. minified) line
|
|
413
|
+
tail = content.strip()[-budget:]
|
|
414
|
+
summary = f"{salient} | {tail}" if salient not in tail[: len(salient) + 5] else tail
|
|
415
|
+
return summary[: budget + len(salient) + 8]
|
|
416
|
+
|
|
417
|
+
def _run_command(self, command: str, task_id: str = "verify") -> CommandResult:
|
|
418
|
+
try:
|
|
419
|
+
config = load_config(self.project_root)
|
|
420
|
+
timeout = config.execution.command_timeout
|
|
421
|
+
except Exception:
|
|
422
|
+
timeout = 300
|
|
423
|
+
|
|
424
|
+
env = self._verification_env()
|
|
425
|
+
argv = self._split_command(command)
|
|
426
|
+
# Resolve the program to an absolute path against the (cleaned) PATH.
|
|
427
|
+
# On Windows, CreateProcess searches the launching executable's own
|
|
428
|
+
# directory before PATH, so a bare ``python`` would otherwise pick up
|
|
429
|
+
# DevCouncil's bundled interpreter (in .venv\Scripts) regardless of PATH.
|
|
430
|
+
# Resolving here pins the command to the project/system interpreter.
|
|
431
|
+
if argv:
|
|
432
|
+
resolved = shutil.which(argv[0], path=env.get("PATH"))
|
|
433
|
+
if resolved:
|
|
434
|
+
argv = [resolved, *argv[1:]]
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
result = subprocess.run(
|
|
438
|
+
argv,
|
|
439
|
+
shell=False,
|
|
440
|
+
capture_output=True,
|
|
441
|
+
text=True,
|
|
442
|
+
encoding="utf-8",
|
|
443
|
+
errors="replace",
|
|
444
|
+
cwd=self.project_root,
|
|
445
|
+
timeout=timeout,
|
|
446
|
+
env=env,
|
|
447
|
+
)
|
|
268
448
|
stdout = result.stdout or ""
|
|
269
449
|
stderr = result.stderr or ""
|
|
270
450
|
stdout_path = self._save_log(task_id, command, "stdout", stdout)
|
|
271
451
|
stderr_path = self._save_log(task_id, command, "stderr", stderr)
|
|
272
|
-
stdout_summary = redact_string(stdout
|
|
273
|
-
stderr_summary = redact_string(stderr
|
|
452
|
+
stdout_summary = redact_string(self._summarize_stream(stdout))
|
|
453
|
+
stderr_summary = redact_string(self._summarize_stream(stderr))
|
|
274
454
|
return CommandResult(
|
|
275
455
|
command=command,
|
|
276
456
|
exit_code=result.returncode,
|
|
277
457
|
stdout_path=stdout_path,
|
|
278
458
|
stderr_path=stderr_path,
|
|
459
|
+
# stderr first: downstream evidence clips summary[:500], so the error
|
|
460
|
+
# line must land in the first 500 chars to stay diagnosable.
|
|
279
461
|
summary=(
|
|
280
462
|
f"Exit code {result.returncode}. "
|
|
281
|
-
f"
|
|
282
|
-
f"
|
|
463
|
+
f"stderr: {stderr_summary}. "
|
|
464
|
+
f"stdout: {stdout_summary}"
|
|
283
465
|
),
|
|
284
466
|
)
|
|
285
|
-
except Exception as e:
|
|
286
|
-
return CommandResult(
|
|
287
|
-
command=command,
|
|
288
|
-
exit_code=-1,
|
|
289
|
-
stdout_path="",
|
|
290
|
-
stderr_path="",
|
|
291
|
-
summary=f"Failed to run command: {e}",
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
def _split_command(self, command: str) -> List[str]:
|
|
295
|
-
|
|
296
|
-
|
|
467
|
+
except Exception as e:
|
|
468
|
+
return CommandResult(
|
|
469
|
+
command=command,
|
|
470
|
+
exit_code=-1,
|
|
471
|
+
stdout_path="",
|
|
472
|
+
stderr_path="",
|
|
473
|
+
summary=f"Failed to run command: {e}",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def _split_command(self, command: str) -> List[str]:
|
|
477
|
+
# Use POSIX splitting so quotes are interpreted, not preserved. With
|
|
478
|
+
# posix=False, `python -c "assert x"` keeps the surrounding quotes, so the
|
|
479
|
+
# interpreter receives the literal string `"assert x"` and treats it as a
|
|
480
|
+
# no-op string expression that exits 0 — every quoted-argument evidence
|
|
481
|
+
# command would then silently "pass" without running, producing false
|
|
482
|
+
# verification. posix=True strips the quotes correctly; planner-generated
|
|
483
|
+
# commands use forward-slash paths, which the interpreter accepts on Windows.
|
|
484
|
+
return shlex.split(command, posix=True)
|
|
485
|
+
|
|
297
486
|
def _check_dependency_changes(self, changed_files: List[str]) -> List[str]:
|
|
298
|
-
dep_files = {
|
|
299
|
-
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
300
|
-
"requirements.txt", "pyproject.toml", "uv.lock", "Pipfile.lock",
|
|
301
|
-
"go.mod", "go.sum", "Cargo.toml", "Cargo.lock",
|
|
487
|
+
dep_files = {
|
|
488
|
+
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
489
|
+
"requirements.txt", "pyproject.toml", "uv.lock", "Pipfile.lock",
|
|
490
|
+
"go.mod", "go.sum", "Cargo.toml", "Cargo.lock",
|
|
302
491
|
}
|
|
303
492
|
return [f for f in changed_files if Path(f).name in dep_files]
|
|
304
493
|
|
|
@@ -325,14 +514,194 @@ class Verifier:
|
|
|
325
514
|
except Exception as e:
|
|
326
515
|
logger.debug("Failed to classify changed files: %s", e)
|
|
327
516
|
return sorted(added & changed_set), sorted(deleted & changed_set)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
517
|
+
|
|
518
|
+
def _diff_coverage_settings(self) -> Tuple[bool, bool, float]:
|
|
519
|
+
"""Return (measure, enforce, min_ratio) with safe defaults when unconfigured."""
|
|
520
|
+
if self._diff_coverage_override is not None:
|
|
521
|
+
return self._diff_coverage_override
|
|
522
|
+
try:
|
|
523
|
+
cfg = load_config(self.project_root).verification.diff_coverage
|
|
524
|
+
return bool(cfg.measure), bool(cfg.enforce), float(cfg.min_ratio)
|
|
525
|
+
except Exception:
|
|
526
|
+
return True, False, 0.0
|
|
527
|
+
|
|
528
|
+
def _resolve_coverage_python(self, env: Dict[str, str]) -> str:
|
|
529
|
+
if self._coverage_python:
|
|
530
|
+
return self._coverage_python
|
|
531
|
+
for name in ("python", "python3", "py"):
|
|
532
|
+
found = shutil.which(name, path=env.get("PATH"))
|
|
533
|
+
if found:
|
|
534
|
+
return found
|
|
535
|
+
return sys.executable
|
|
536
|
+
|
|
537
|
+
def _coverage_available(self, python: str, env: Dict[str, str]) -> bool:
|
|
538
|
+
try:
|
|
539
|
+
result = subprocess.run(
|
|
540
|
+
[python, "-m", "coverage", "--version"],
|
|
541
|
+
cwd=self.project_root,
|
|
542
|
+
capture_output=True,
|
|
543
|
+
text=True,
|
|
544
|
+
encoding="utf-8",
|
|
545
|
+
errors="replace",
|
|
546
|
+
timeout=30,
|
|
547
|
+
env=env,
|
|
548
|
+
)
|
|
549
|
+
return result.returncode == 0
|
|
550
|
+
except Exception:
|
|
551
|
+
return False
|
|
552
|
+
|
|
553
|
+
def _coverage_target_commands(self, task: Task) -> List[str]:
|
|
554
|
+
"""The test command(s) to instrument — the ones that purport to prove the ACs."""
|
|
555
|
+
if task.expected_tests:
|
|
556
|
+
return list(task.expected_tests)
|
|
557
|
+
test_like = [c for c in task.allowed_commands if self._command_can_prove_acceptance("allowed", c)]
|
|
558
|
+
if test_like:
|
|
559
|
+
return test_like
|
|
560
|
+
return list(self._load_commands().get("test", []))
|
|
561
|
+
|
|
562
|
+
def measure_diff_coverage(self, task: Task, diff_content: str) -> dc.DiffCoverageResult:
|
|
563
|
+
"""Run the task's test command(s) under coverage and intersect with the diff.
|
|
564
|
+
|
|
565
|
+
Returns an *unmeasured* result (never a false positive) whenever reliable
|
|
566
|
+
data is unavailable: no measurable Python changes, no instrumentable test
|
|
567
|
+
command, or no coverage tool in the target environment.
|
|
568
|
+
"""
|
|
569
|
+
changed = dc.measurable_python_changes(dc.parse_changed_lines(diff_content))
|
|
570
|
+
if not changed:
|
|
571
|
+
return dc.DiffCoverageResult(measured=False, reason="no measurable Python changes in diff")
|
|
572
|
+
commands = self._coverage_target_commands(task)
|
|
573
|
+
if not commands:
|
|
574
|
+
return dc.DiffCoverageResult(measured=False, reason="no test command to instrument")
|
|
575
|
+
|
|
576
|
+
env = self._verification_env()
|
|
577
|
+
python = self._resolve_coverage_python(env)
|
|
578
|
+
if not self._coverage_available(python, env):
|
|
579
|
+
return dc.DiffCoverageResult(measured=False, reason="coverage tool not available in target environment")
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
timeout = load_config(self.project_root).execution.command_timeout
|
|
583
|
+
except Exception:
|
|
584
|
+
timeout = 300
|
|
585
|
+
|
|
586
|
+
tmp_dir = self.project_root / ".devcouncil" / "tmp"
|
|
587
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
588
|
+
data_file = tmp_dir / f"diffcov-{task.id}.coverage"
|
|
589
|
+
json_file = tmp_dir / f"diffcov-{task.id}.json"
|
|
590
|
+
for stale in (data_file, json_file):
|
|
591
|
+
try:
|
|
592
|
+
stale.unlink()
|
|
593
|
+
except FileNotFoundError:
|
|
594
|
+
pass
|
|
595
|
+
|
|
596
|
+
ran_any = False
|
|
597
|
+
append = False
|
|
598
|
+
inline_scripts: List[Path] = []
|
|
599
|
+
try:
|
|
600
|
+
for idx, cmd in enumerate(commands):
|
|
601
|
+
argv = self._split_command(cmd)
|
|
602
|
+
inline = dc.inline_python_code(argv)
|
|
603
|
+
if inline is not None:
|
|
604
|
+
# Materialise `python -c "CODE"` as a temp script so coverage can
|
|
605
|
+
# instrument it (coverage cannot run a bare -c snippet).
|
|
606
|
+
script = tmp_dir / f"diffcov-inline-{task.id}-{idx}.py"
|
|
607
|
+
try:
|
|
608
|
+
script.write_text(dc.inline_script_content(inline, self.project_root), encoding="utf-8")
|
|
609
|
+
except Exception as exc:
|
|
610
|
+
logger.warning("Diff-coverage inline script write failed for %s: %s", task.id, exc)
|
|
611
|
+
continue
|
|
612
|
+
inline_scripts.append(script)
|
|
613
|
+
cov_argv: Optional[List[str]] = dc.coverage_run_script_argv(
|
|
614
|
+
str(script), python, append=append, data_file=str(data_file)
|
|
615
|
+
)
|
|
616
|
+
else:
|
|
617
|
+
cov_argv = dc.coverage_run_argv(argv, python, append=append, data_file=str(data_file))
|
|
618
|
+
if cov_argv is None:
|
|
619
|
+
continue
|
|
620
|
+
try:
|
|
621
|
+
subprocess.run(
|
|
622
|
+
cov_argv,
|
|
623
|
+
cwd=self.project_root,
|
|
624
|
+
capture_output=True,
|
|
625
|
+
text=True,
|
|
626
|
+
encoding="utf-8",
|
|
627
|
+
errors="replace",
|
|
628
|
+
timeout=timeout,
|
|
629
|
+
env=env,
|
|
630
|
+
)
|
|
631
|
+
except Exception as exc:
|
|
632
|
+
logger.warning("Diff-coverage run failed for %s: %s", task.id, exc)
|
|
633
|
+
continue
|
|
634
|
+
ran_any = True
|
|
635
|
+
append = True
|
|
636
|
+
|
|
637
|
+
if not ran_any:
|
|
638
|
+
return dc.DiffCoverageResult(measured=False, reason="no instrumentable test command")
|
|
639
|
+
if not data_file.exists():
|
|
640
|
+
return dc.DiffCoverageResult(measured=False, reason="coverage produced no data")
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
subprocess.run(
|
|
644
|
+
[python, "-m", "coverage", "json", f"--data-file={data_file}", "-o", str(json_file)],
|
|
645
|
+
cwd=self.project_root,
|
|
646
|
+
capture_output=True,
|
|
647
|
+
text=True,
|
|
648
|
+
encoding="utf-8",
|
|
649
|
+
errors="replace",
|
|
650
|
+
timeout=120,
|
|
651
|
+
env=env,
|
|
652
|
+
)
|
|
653
|
+
data = json.loads(json_file.read_text(encoding="utf-8"))
|
|
654
|
+
except Exception as exc:
|
|
655
|
+
return dc.DiffCoverageResult(measured=False, reason=f"coverage report unreadable: {exc}")
|
|
656
|
+
|
|
657
|
+
coverage = dc.parse_coverage_json(data, self.project_root)
|
|
658
|
+
return dc.intersect(changed, coverage, tool="coverage.py")
|
|
659
|
+
finally:
|
|
660
|
+
for path in [data_file, json_file, *inline_scripts]:
|
|
661
|
+
try:
|
|
662
|
+
path.unlink()
|
|
663
|
+
except OSError:
|
|
664
|
+
pass
|
|
665
|
+
|
|
666
|
+
async def verify_task(self, task: Task, requirements: List[Requirement]) -> Tuple[List[Gap], List[Any]]:
|
|
667
|
+
self._gap_counter = 0
|
|
668
|
+
gaps: List[Gap] = []
|
|
669
|
+
evidence_to_save: List[Any] = []
|
|
670
|
+
changed_files = self.get_task_changed_files(task.id)
|
|
671
|
+
diff_content = self.get_diff()
|
|
672
|
+
diff_empty = not bool(diff_content.strip())
|
|
673
|
+
# "Work present" is broader than the current working-tree diff: a task whose
|
|
674
|
+
# changes were already committed (e.g. `dev go`'s per-task commit, then the
|
|
675
|
+
# final reconciliation pass where `git diff HEAD` is empty) still counts as
|
|
676
|
+
# implemented. A genuine no-op run has neither a working diff nor committed
|
|
677
|
+
# changes since the task's checkpoint.
|
|
678
|
+
work_present = (not diff_empty) or self._task_produced_changes(task.id)
|
|
679
|
+
|
|
680
|
+
# Empty-diff guard. If the task declares files to create or modify but produced
|
|
681
|
+
# NO work at all, there is nothing to prove — an agent must not be able to
|
|
682
|
+
# declare victory having written nothing (or after a transient git error that
|
|
683
|
+
# degraded the diff to ""). This is the single most dangerous false-pass for
|
|
684
|
+
# autonomy, so it blocks regardless of which commands ran.
|
|
685
|
+
expects_change = any(pf.allowed_change != "read_only" for pf in task.planned_files)
|
|
686
|
+
if not work_present and expects_change:
|
|
687
|
+
gaps.append(Gap(
|
|
688
|
+
id=self._next_gap_id(task.id, "NODIFF"),
|
|
689
|
+
severity="high",
|
|
690
|
+
gap_type="task_not_implemented",
|
|
691
|
+
task_id=task.id,
|
|
692
|
+
description=(
|
|
693
|
+
f"Task {task.id} declares files to create or modify, but produced no "
|
|
694
|
+
"changes. Verification cannot prove work that does not exist."
|
|
695
|
+
),
|
|
696
|
+
evidence=[f"planned files expecting change: {sorted(p.path for p in task.planned_files if p.allowed_change != 'read_only')}"],
|
|
697
|
+
recommended_fix=(
|
|
698
|
+
"Implement the planned changes so the diff is non-empty, then re-verify. "
|
|
699
|
+
"If you did make changes, ensure they are saved and visible to git "
|
|
700
|
+
"(not reverted, stashed, or written outside the project root)."
|
|
701
|
+
),
|
|
702
|
+
blocking=True,
|
|
703
|
+
))
|
|
704
|
+
|
|
336
705
|
if diff_content:
|
|
337
706
|
added_files, deleted_files = self._classify_change_paths(changed_files)
|
|
338
707
|
diff_ev = DiffEvidence(
|
|
@@ -342,141 +711,748 @@ class Verifier:
|
|
|
342
711
|
deleted_files=deleted_files,
|
|
343
712
|
diff_summary=f"Diff captured for {len(changed_files)} files."
|
|
344
713
|
)
|
|
345
|
-
evidence_to_save.append(diff_ev)
|
|
346
|
-
|
|
347
|
-
# 1. Planned-file coverage check
|
|
348
|
-
planned_paths = {pf.path for pf in task.planned_files}
|
|
349
|
-
changed_set = set(changed_files)
|
|
350
|
-
for pf in task.planned_files:
|
|
351
|
-
if pf.path not in changed_set and pf.allowed_change != "read_only":
|
|
352
|
-
gaps.append(Gap(
|
|
353
|
-
id=self._next_gap_id(task.id, "FILE"),
|
|
354
|
-
severity="medium",
|
|
355
|
-
gap_type="planned_file_not_changed",
|
|
356
|
-
task_id=task.id,
|
|
357
|
-
description=f"Planned file {pf.path} was not modified.",
|
|
358
|
-
recommended_fix=f"Modify {pf.path} as planned or update the task.",
|
|
359
|
-
blocking=False,
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
714
|
+
evidence_to_save.append(diff_ev)
|
|
715
|
+
|
|
716
|
+
# 1. Planned-file coverage check
|
|
717
|
+
planned_paths = {pf.path for pf in task.planned_files}
|
|
718
|
+
changed_set = set(changed_files)
|
|
719
|
+
for pf in task.planned_files:
|
|
720
|
+
if pf.path not in changed_set and pf.allowed_change != "read_only":
|
|
721
|
+
gaps.append(Gap(
|
|
722
|
+
id=self._next_gap_id(task.id, "FILE"),
|
|
723
|
+
severity="medium",
|
|
724
|
+
gap_type="planned_file_not_changed",
|
|
725
|
+
task_id=task.id,
|
|
726
|
+
description=f"Planned file {pf.path} was not modified.",
|
|
727
|
+
recommended_fix=f"Modify {pf.path} as planned or update the task.",
|
|
728
|
+
blocking=False,
|
|
729
|
+
file=pf.path,
|
|
730
|
+
))
|
|
731
|
+
|
|
732
|
+
# 2. Orphan-diff detection
|
|
733
|
+
for cf in changed_files:
|
|
734
|
+
if cf not in planned_paths:
|
|
735
|
+
gaps.append(Gap(
|
|
736
|
+
id=self._next_gap_id(task.id, "ORPHAN"),
|
|
737
|
+
severity="high",
|
|
738
|
+
gap_type="orphan_diff",
|
|
739
|
+
task_id=task.id,
|
|
740
|
+
description=f"File {cf} was modified but not planned for this task.",
|
|
741
|
+
evidence=[cf],
|
|
742
|
+
recommended_fix=f"Revert changes to {cf} or add it to the task's planned files.",
|
|
743
|
+
blocking=True,
|
|
744
|
+
file=cf,
|
|
745
|
+
))
|
|
746
|
+
|
|
747
|
+
gaps.extend(self._check_semantic_diff(task))
|
|
748
|
+
|
|
749
|
+
# 3. Dependency change detection
|
|
750
|
+
dep_changes = self._check_dependency_changes(changed_files)
|
|
751
|
+
for dep_file in dep_changes:
|
|
752
|
+
if dep_file not in planned_paths:
|
|
753
|
+
gaps.append(Gap(
|
|
754
|
+
id=self._next_gap_id(task.id, "DEP"),
|
|
755
|
+
severity="high",
|
|
756
|
+
gap_type="dependency_risk",
|
|
757
|
+
task_id=task.id,
|
|
758
|
+
description=f"Dependency file {dep_file} was modified without being in planned files.",
|
|
759
|
+
evidence=[dep_file],
|
|
760
|
+
recommended_fix=f"Justify the dependency change or revert {dep_file}.",
|
|
761
|
+
blocking=True,
|
|
762
|
+
file=dep_file,
|
|
763
|
+
))
|
|
764
|
+
|
|
765
|
+
# When DevCouncil can compile its own per-criterion checks, THOSE are the
|
|
766
|
+
# authority and the planner's expected_tests are demoted to advisory — so a
|
|
767
|
+
# bogus planner command (irrelevant linters, npm on a Python project, tests
|
|
768
|
+
# that reference missing files) can no longer block correct work.
|
|
769
|
+
compiler_active = bool(self.acceptance_compiler and diff_content and task.acceptance_criterion_ids)
|
|
770
|
+
|
|
391
771
|
# 4. Run verification commands
|
|
392
772
|
command_results: List[CommandResult] = []
|
|
393
773
|
evidence_results: List[CommandResult] = []
|
|
774
|
+
genuine_failure = False # a command that actually ran and failed (real defect signal)
|
|
775
|
+
had_unrunnable = False # a command that could not run (missing tool / missing tests)
|
|
776
|
+
# Genuine test failures demoted to non-blocking only because a compiler is active.
|
|
777
|
+
# That demotion is legitimate ONLY if the compiler actually produces per-criterion
|
|
778
|
+
# checks to take authority; re-promoted below if it produces none.
|
|
779
|
+
demoted_failures: List[Gap] = []
|
|
394
780
|
for cmd_type, cmds in self._commands_for_task(task).items():
|
|
395
781
|
for cmd in cmds:
|
|
782
|
+
applicable, skip_reason = self._command_applicable(cmd)
|
|
783
|
+
if not applicable:
|
|
784
|
+
# Wrong-stack command (e.g. `npm test` on a Python repo): skip it
|
|
785
|
+
# entirely rather than running and failing for a stack reason — an
|
|
786
|
+
# advisory note so the skip is visible (no silent drop).
|
|
787
|
+
gaps.append(Gap(
|
|
788
|
+
id=self._next_gap_id(task.id, "SKIP"),
|
|
789
|
+
severity="low",
|
|
790
|
+
gap_type="skipped_verification_command",
|
|
791
|
+
task_id=task.id,
|
|
792
|
+
description=f"Skipped verification command '{cmd}': {skip_reason}.",
|
|
793
|
+
evidence=[skip_reason],
|
|
794
|
+
recommended_fix=(
|
|
795
|
+
"Replace it with a command for this repo's stack, or remove it "
|
|
796
|
+
"from .devcouncil/config.yaml / the task's expected_tests."
|
|
797
|
+
),
|
|
798
|
+
blocking=False,
|
|
799
|
+
suggested_command=cmd,
|
|
800
|
+
))
|
|
801
|
+
continue
|
|
396
802
|
result = self._run_command(cmd, task_id=task.id)
|
|
397
803
|
command_results.append(result)
|
|
398
804
|
evidence_to_save.append(result)
|
|
399
805
|
if self._command_can_prove_acceptance(cmd_type, cmd):
|
|
400
806
|
evidence_results.append(result)
|
|
401
807
|
if result.exit_code != 0:
|
|
808
|
+
if self._command_is_malformed(result):
|
|
809
|
+
had_unrunnable = True
|
|
810
|
+
# The verification command itself could not run (e.g. a
|
|
811
|
+
# SyntaxError in a `python -c` one-liner, or a missing test
|
|
812
|
+
# tool). This proves nothing about the implementation, so do
|
|
813
|
+
# not report it as a code failure — surface it as a plan/
|
|
814
|
+
# command defect the user can regenerate instead.
|
|
815
|
+
gaps.append(Gap(
|
|
816
|
+
id=self._next_gap_id(task.id, "BADCMD"),
|
|
817
|
+
severity="medium",
|
|
818
|
+
gap_type="invalid_verification_command",
|
|
819
|
+
task_id=task.id,
|
|
820
|
+
description=(
|
|
821
|
+
f"Verification command could not run (not a code failure): '{cmd}'. "
|
|
822
|
+
"It appears malformed or its tooling is unavailable, so this command "
|
|
823
|
+
"proves nothing either way."
|
|
824
|
+
),
|
|
825
|
+
evidence=[result.summary[:500]],
|
|
826
|
+
recommended_fix=(
|
|
827
|
+
"Regenerate the task's verification commands with 'dev repair', or edit "
|
|
828
|
+
"them to be a single runnable command (e.g. 'python -m pytest <file>')."
|
|
829
|
+
),
|
|
830
|
+
# Non-blocking: a command that cannot run is not evidence of a
|
|
831
|
+
# defect. If it was the *only* check for an acceptance criterion,
|
|
832
|
+
# that criterion is independently caught as unproven (blocking).
|
|
833
|
+
blocking=False,
|
|
834
|
+
suggested_command=cmd,
|
|
835
|
+
stdout_path=result.stdout_path or None,
|
|
836
|
+
stderr_path=result.stderr_path or None,
|
|
837
|
+
))
|
|
838
|
+
else:
|
|
839
|
+
# A verification command that genuinely failed. Lint/typecheck
|
|
840
|
+
# commands (from the config fallback) report style/type opinion,
|
|
841
|
+
# not a correctness defect, so they are ADVISORY — blocking a
|
|
842
|
+
# behaviorally-correct task on `flake8`/`mypy`/`ruff` is the
|
|
843
|
+
# false-block the benchmark surfaced. A real test failure still
|
|
844
|
+
# gates (unless compiled checks supersede it).
|
|
845
|
+
is_quality_gate = cmd_type in {"lint", "typecheck"} or self._is_quality_only_command(cmd)
|
|
846
|
+
blocking = (not compiler_active) and not is_quality_gate
|
|
847
|
+
if blocking:
|
|
848
|
+
genuine_failure = True
|
|
849
|
+
fail_file, fail_line = self._failure_location(result)
|
|
850
|
+
gap = Gap(
|
|
851
|
+
id=self._next_gap_id(task.id, cmd_type.upper()),
|
|
852
|
+
severity="high" if blocking else "medium",
|
|
853
|
+
gap_type="quality_gate_failed" if is_quality_gate else "test_failed",
|
|
854
|
+
task_id=task.id,
|
|
855
|
+
description=(
|
|
856
|
+
f"{'Quality gate' if is_quality_gate else 'Command'} '{cmd}' "
|
|
857
|
+
f"failed with exit code {result.exit_code}"
|
|
858
|
+
+ (" (advisory: style/type, not a correctness gate)." if is_quality_gate else ".")
|
|
859
|
+
),
|
|
860
|
+
evidence=[result.summary[:500]],
|
|
861
|
+
recommended_fix=f"Fix the issues reported by '{cmd}'.",
|
|
862
|
+
blocking=blocking,
|
|
863
|
+
suggested_command=cmd,
|
|
864
|
+
file=fail_file,
|
|
865
|
+
line=fail_line,
|
|
866
|
+
stdout_path=result.stdout_path or None,
|
|
867
|
+
stderr_path=result.stderr_path or None,
|
|
868
|
+
)
|
|
869
|
+
gaps.append(gap)
|
|
870
|
+
# A real test failure demoted only because the compiler is active:
|
|
871
|
+
# remember it so we can re-promote if the compiler yields no checks.
|
|
872
|
+
if compiler_active and not is_quality_gate and not blocking:
|
|
873
|
+
demoted_failures.append(gap)
|
|
874
|
+
|
|
875
|
+
# 4b. Compiled acceptance checks — precise, DevCouncil-owned per-criterion
|
|
876
|
+
# evidence. Derive one runnable check per acceptance criterion from the
|
|
877
|
+
# criterion text + the diff, instead of trusting planner-authored
|
|
878
|
+
# expected_tests (which the benchmark showed often reference absent tools or
|
|
879
|
+
# test files). Each check maps 1:1 to its criterion, replacing the coarse
|
|
880
|
+
# "any command passed -> every criterion proven" mapping.
|
|
881
|
+
compiled_pass: Dict[str, bool] = {}
|
|
882
|
+
# Per-AC bookkeeping so the unproven-AC gap can attach ONLY the check(s) that
|
|
883
|
+
# targeted that criterion (and the specific failing result), instead of dumping
|
|
884
|
+
# every command summary. Keys are AC ids; values track the compiled command(s)
|
|
885
|
+
# and any failing CommandResults for that AC.
|
|
886
|
+
compiled_cmds_by_ac: Dict[str, List[str]] = {}
|
|
887
|
+
failing_results_by_ac: Dict[str, List[CommandResult]] = {}
|
|
888
|
+
if self.acceptance_compiler and diff_content and task.acceptance_criterion_ids:
|
|
889
|
+
try:
|
|
890
|
+
compiled = await self.acceptance_compiler.compile(task, requirements, diff_content)
|
|
891
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
892
|
+
logger.warning("Acceptance compiler failed for %s: %s", task.id, exc)
|
|
893
|
+
compiled = {}
|
|
894
|
+
for ac_id, cmds in compiled.items():
|
|
895
|
+
# Defensive: drop any wrong-stack compiled check so it can't fail an AC
|
|
896
|
+
# for a stack reason (the compiler is told not to emit these).
|
|
897
|
+
cmds = [c for c in cmds if self._command_applicable(c)[0]]
|
|
898
|
+
ac_ok = bool(cmds)
|
|
899
|
+
compiled_cmds_by_ac[ac_id] = list(cmds)
|
|
900
|
+
for cmd in cmds:
|
|
901
|
+
result = self._run_command(cmd, task_id=task.id)
|
|
902
|
+
command_results.append(result)
|
|
903
|
+
evidence_to_save.append(result)
|
|
904
|
+
if result.exit_code != 0:
|
|
905
|
+
ac_ok = False
|
|
906
|
+
failing_results_by_ac.setdefault(ac_id, []).append(result)
|
|
907
|
+
if self._command_is_malformed(result):
|
|
908
|
+
had_unrunnable = True
|
|
909
|
+
else:
|
|
910
|
+
genuine_failure = True
|
|
911
|
+
fail_file, fail_line = self._failure_location(result)
|
|
912
|
+
gaps.append(Gap(
|
|
913
|
+
id=self._next_gap_id(task.id, "ACCHK"),
|
|
914
|
+
severity="high",
|
|
915
|
+
gap_type="test_failed",
|
|
916
|
+
task_id=task.id,
|
|
917
|
+
description=f"Acceptance check for {ac_id} failed: '{cmd}' (exit {result.exit_code}).",
|
|
918
|
+
evidence=[result.summary[:500]],
|
|
919
|
+
recommended_fix=f"Fix the implementation so acceptance criterion {ac_id} holds.",
|
|
920
|
+
blocking=True,
|
|
921
|
+
acceptance_criterion_id=ac_id,
|
|
922
|
+
suggested_command=cmd,
|
|
923
|
+
file=fail_file,
|
|
924
|
+
line=fail_line,
|
|
925
|
+
stdout_path=result.stdout_path or None,
|
|
926
|
+
stderr_path=result.stderr_path or None,
|
|
927
|
+
))
|
|
928
|
+
compiled_pass[ac_id] = ac_ok
|
|
929
|
+
|
|
930
|
+
# The compiler only earns the authority to demote a genuinely-failing planner
|
|
931
|
+
# test if it produced a per-criterion check for EVERY targeted AC. A partial
|
|
932
|
+
# compile is not enough: the uncovered ACs fall back to the coarse signal, so a
|
|
933
|
+
# demoted real failure + coarse-proven remainder would otherwise slip past the
|
|
934
|
+
# gate. If coverage is incomplete (or zero — empty compile / all-wrong-stack /
|
|
935
|
+
# a compile exception swallowed to {}), re-promote the demoted failures.
|
|
936
|
+
compiler_covered_all = bool(task.acceptance_criterion_ids) and all(
|
|
937
|
+
compiled_cmds_by_ac.get(ac_id) for ac_id in task.acceptance_criterion_ids
|
|
938
|
+
)
|
|
939
|
+
if compiler_active and not compiler_covered_all and demoted_failures:
|
|
940
|
+
for gap in demoted_failures:
|
|
941
|
+
gap.blocking = True
|
|
942
|
+
gap.severity = "high"
|
|
943
|
+
genuine_failure = True
|
|
944
|
+
logger.info(
|
|
945
|
+
"Re-promoted demoted test failure %s to blocking: acceptance compiler "
|
|
946
|
+
"did not produce a check for every criterion of task %s.",
|
|
947
|
+
gap.id, task.id,
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
# 5. Acceptance-criteria evidence mapping (precise, per criterion).
|
|
951
|
+
# Quality-only commands (lint/typecheck) are excluded: a passing `mypy`/`ruff
|
|
952
|
+
# check`/`tsc` exercises no behavior, so it must not coarse-prove a behavioral AC
|
|
953
|
+
# — the same false-confidence the per-criterion checks exist to prevent.
|
|
954
|
+
successful_commands = [
|
|
955
|
+
result for result in evidence_results
|
|
956
|
+
if result.exit_code == 0 and not self._is_quality_only_command(result.command)
|
|
957
|
+
]
|
|
958
|
+
# Coarse fallback (used only when no compiled per-criterion check exists for an
|
|
959
|
+
# AC): a criterion may be marked proven by a passing acceptance-capable command
|
|
960
|
+
# ONLY when the task actually produced work. Without this guard a no-op run
|
|
961
|
+
# whose unrelated command happens to pass would "prove" every criterion against
|
|
962
|
+
# zero changes.
|
|
963
|
+
coarse_proof_available = work_present and bool(successful_commands)
|
|
964
|
+
if task.acceptance_criterion_ids:
|
|
965
|
+
req_by_ac = {ac.id: req.id for req in requirements for ac in req.acceptance_criteria}
|
|
966
|
+
unproven_acs: List[str] = []
|
|
967
|
+
coarse_proven_acs: List[str] = []
|
|
968
|
+
for ac_id in task.acceptance_criterion_ids:
|
|
969
|
+
# An AC is proven if its compiled check passed; if no compiled check
|
|
970
|
+
# exists for it, fall back to the coarse signal (any expected_test passed).
|
|
971
|
+
proven = compiled_pass.get(ac_id)
|
|
972
|
+
coarse = False
|
|
973
|
+
if proven is None:
|
|
974
|
+
proven = coarse_proof_available
|
|
975
|
+
coarse = proven # proven only by the coarse, not-AC-specific signal
|
|
976
|
+
if proven:
|
|
977
|
+
if coarse:
|
|
978
|
+
coarse_proven_acs.append(ac_id)
|
|
979
|
+
# Don't persist a "passed" record for a coarse-proven criterion during a
|
|
980
|
+
# run that also has a genuine blocking failure — the gate already fails,
|
|
981
|
+
# and a stored "passed" would mislead audits that read evidence directly.
|
|
982
|
+
if not (coarse and genuine_failure):
|
|
983
|
+
evidence_to_save.append(TestEvidence(
|
|
984
|
+
requirement_id=req_by_ac.get(ac_id, task.requirement_ids[0] if task.requirement_ids else ""),
|
|
985
|
+
acceptance_criterion_id=ac_id,
|
|
986
|
+
command="(devcouncil acceptance check)",
|
|
987
|
+
status="passed",
|
|
988
|
+
evidence_summary=(
|
|
989
|
+
"Acceptance criterion proven only by a COARSE signal (a passing "
|
|
990
|
+
"acceptance-capable command, not a per-criterion check); behavior "
|
|
991
|
+
"not precisely verified."
|
|
992
|
+
if coarse else
|
|
993
|
+
"Acceptance criterion proven by a per-criterion compiled check."
|
|
994
|
+
),
|
|
995
|
+
))
|
|
996
|
+
else:
|
|
997
|
+
unproven_acs.append(ac_id)
|
|
998
|
+
# Surface coarse proof as a first-class advisory: these criteria passed only
|
|
999
|
+
# because some acceptance-capable command exited 0, not because a check tied
|
|
1000
|
+
# to the criterion passed. Non-blocking, but no longer invisible.
|
|
1001
|
+
if coarse_proven_acs:
|
|
1002
|
+
gaps.append(Gap(
|
|
1003
|
+
id=self._next_gap_id(task.id, "COARSE"),
|
|
1004
|
+
severity="low",
|
|
1005
|
+
gap_type="coarse_acceptance_proof",
|
|
1006
|
+
task_id=task.id,
|
|
1007
|
+
description=(
|
|
1008
|
+
"Verification mode = COARSE for "
|
|
1009
|
+
f"{', '.join(coarse_proven_acs)}: proven by a passing acceptance-capable "
|
|
1010
|
+
"command, not a per-criterion check. Behavior is not precisely verified."
|
|
1011
|
+
),
|
|
1012
|
+
evidence=[f"coarse-proven: {', '.join(coarse_proven_acs)}"],
|
|
1013
|
+
recommended_fix=(
|
|
1014
|
+
"Add a verification command (or test) that exercises each listed criterion "
|
|
1015
|
+
"specifically, so DevCouncil can compile a per-criterion check instead of "
|
|
1016
|
+
"relying on the coarse fallback."
|
|
1017
|
+
),
|
|
1018
|
+
blocking=False,
|
|
1019
|
+
))
|
|
1020
|
+
if unproven_acs:
|
|
1021
|
+
# Block only on positive evidence of a problem. If verification was
|
|
1022
|
+
# attempted but every failure was unrunnable (missing tooling / tests)
|
|
1023
|
+
# and nothing genuinely failed, that is a verification defect, not a
|
|
1024
|
+
# code defect — surface it as a non-blocking "could not verify".
|
|
1025
|
+
couldnt_verify = had_unrunnable and not genuine_failure and work_present
|
|
1026
|
+
ac_by_id = {ac.id: ac for req in requirements for ac in req.acceptance_criteria}
|
|
1027
|
+
# Methods that can be proven by running code; only these block the gate
|
|
1028
|
+
# when unproven. Inherently-manual criteria (manual/llm_review) and
|
|
1029
|
+
# optional ones are surfaced for human review instead of false-blocking
|
|
1030
|
+
# the autonomous loop — the gate still demands evidence for BEHAVIOR.
|
|
1031
|
+
automatable_methods = {"unit_test", "integration_test", "static_check"}
|
|
1032
|
+
for ac_id in unproven_acs:
|
|
1033
|
+
ac = ac_by_id.get(ac_id)
|
|
1034
|
+
method = ac.verification_method if ac else "unit_test"
|
|
1035
|
+
is_automatable = (ac.required if ac else True) and method in automatable_methods
|
|
1036
|
+
if not is_automatable:
|
|
1037
|
+
blocks = False
|
|
1038
|
+
optional = "" if (ac is None or ac.required) else " optional"
|
|
1039
|
+
fix = (
|
|
1040
|
+
f"This{optional} criterion's verification method is '{method}'; it cannot be "
|
|
1041
|
+
"proven by running code. Review it manually (it does not block the gate)."
|
|
1042
|
+
)
|
|
1043
|
+
suffix = f" (non-blocking: {method})"
|
|
1044
|
+
elif couldnt_verify:
|
|
1045
|
+
blocks = False
|
|
1046
|
+
fix = ("Could not verify this criterion: the verification commands did not run "
|
|
1047
|
+
"(missing tooling or tests). Regenerate them with 'dev repair' to confirm the work.")
|
|
1048
|
+
suffix = " (verification commands could not run)"
|
|
1049
|
+
else:
|
|
1050
|
+
blocks = True
|
|
1051
|
+
fix = "Add or fix a verification command that proves this acceptance criterion."
|
|
1052
|
+
suffix = ""
|
|
1053
|
+
# Concrete, AC-scoped evidence instead of "all command summaries":
|
|
1054
|
+
# * if a compiled check targeted this AC, attach its command(s) and
|
|
1055
|
+
# the specific failing result;
|
|
1056
|
+
# * otherwise an explicit "no check compiled" marker so the agent
|
|
1057
|
+
# knows it must author one, not hunt through unrelated output.
|
|
1058
|
+
ac_compiled = compiled_cmds_by_ac.get(ac_id, [])
|
|
1059
|
+
ac_failures = failing_results_by_ac.get(ac_id, [])
|
|
1060
|
+
ac_evidence: List[str] = []
|
|
1061
|
+
suggested_cmd: Optional[str] = None
|
|
1062
|
+
if ac_compiled:
|
|
1063
|
+
suggested_cmd = ac_compiled[0]
|
|
1064
|
+
ac_evidence.extend(f"compiled check: {c}" for c in ac_compiled)
|
|
1065
|
+
ac_evidence.extend(r.summary[:500] for r in ac_failures)
|
|
1066
|
+
else:
|
|
1067
|
+
ac_evidence.append(
|
|
1068
|
+
f"no DevCouncil check compiled for {ac_id} "
|
|
1069
|
+
f"(expected verification method: {method})"
|
|
1070
|
+
)
|
|
402
1071
|
gaps.append(Gap(
|
|
403
|
-
id=self._next_gap_id(task.id,
|
|
404
|
-
severity="high",
|
|
405
|
-
gap_type="
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
1072
|
+
id=self._next_gap_id(task.id, "AC"),
|
|
1073
|
+
severity="high" if blocks else "medium",
|
|
1074
|
+
gap_type="acceptance_criteria_unproven",
|
|
1075
|
+
requirement_id=self._requirement_id_for_ac(requirements, ac_id),
|
|
1076
|
+
task_id=task.id,
|
|
1077
|
+
description=(
|
|
1078
|
+
f"Acceptance criterion {ac_id} has no passing verification evidence "
|
|
1079
|
+
f"for task {task.id}.{suffix}"
|
|
1080
|
+
),
|
|
1081
|
+
evidence=ac_evidence,
|
|
1082
|
+
recommended_fix=fix,
|
|
1083
|
+
blocking=blocks,
|
|
1084
|
+
acceptance_criterion_id=ac_id,
|
|
1085
|
+
expected_verification_method=method,
|
|
1086
|
+
suggested_command=suggested_cmd,
|
|
1087
|
+
))
|
|
1088
|
+
elif task.requirement_ids:
|
|
1089
|
+
gaps.append(Gap(
|
|
1090
|
+
id=self._next_gap_id(task.id, "NOAC"),
|
|
1091
|
+
severity="high",
|
|
1092
|
+
gap_type="acceptance_criteria_unproven",
|
|
1093
|
+
requirement_id=task.requirement_ids[0],
|
|
1094
|
+
task_id=task.id,
|
|
1095
|
+
description=f"Task {task.id} is linked to requirements but no acceptance criteria.",
|
|
1096
|
+
recommended_fix="Link the task to specific acceptance_criterion_ids before verification.",
|
|
1097
|
+
blocking=True,
|
|
1098
|
+
))
|
|
1099
|
+
|
|
1100
|
+
# 5b. Diff↔coverage gate. A green suite is only acceptance evidence if it
|
|
1101
|
+
# exercised the lines the diff changed. This catches the failure the README
|
|
1102
|
+
# promises to stop: tests "pass" while the new logic is never run (unrelated
|
|
1103
|
+
# suite, code never imported, untouched branch). Measured only when the target
|
|
1104
|
+
# repo has coverage tooling and the diff has measurable Python changes; absent
|
|
1105
|
+
# that, it degrades silently rather than blocking correct work.
|
|
1106
|
+
measure_cov, enforce_cov, min_ratio = self._diff_coverage_settings()
|
|
1107
|
+
any_passing = bool(successful_commands) or any(compiled_pass.values())
|
|
1108
|
+
coverage_measured = False
|
|
1109
|
+
coverage_skipped_reason: Optional[str] = None
|
|
1110
|
+
if not measure_cov:
|
|
1111
|
+
coverage_skipped_reason = "diff coverage disabled in config"
|
|
1112
|
+
elif not diff_content:
|
|
1113
|
+
coverage_skipped_reason = "no diff to measure"
|
|
1114
|
+
elif not task.acceptance_criterion_ids:
|
|
1115
|
+
coverage_skipped_reason = "task has no acceptance criteria"
|
|
1116
|
+
elif not any_passing:
|
|
1117
|
+
coverage_skipped_reason = "no passing verification command to instrument"
|
|
1118
|
+
if measure_cov and diff_content and task.acceptance_criterion_ids and any_passing:
|
|
1119
|
+
cov = self.measure_diff_coverage(task, diff_content)
|
|
1120
|
+
if not cov.measured:
|
|
1121
|
+
coverage_skipped_reason = cov.reason or "diff coverage could not be measured"
|
|
1122
|
+
if cov.measured:
|
|
1123
|
+
coverage_measured = True
|
|
1124
|
+
coverage_skipped_reason = None
|
|
1125
|
+
evidence_to_save.append(DiffCoverageEvidence(
|
|
1126
|
+
task_id=task.id,
|
|
1127
|
+
tool=cov.tool,
|
|
1128
|
+
measured=True,
|
|
1129
|
+
changed_lines=cov.changed_executable_lines,
|
|
1130
|
+
covered_lines=cov.covered_changed_lines,
|
|
1131
|
+
coverage_ratio=cov.ratio,
|
|
1132
|
+
uncovered_by_file=cov.uncovered_by_file,
|
|
1133
|
+
absent_files=cov.absent_files,
|
|
1134
|
+
summary=cov.summary(),
|
|
1135
|
+
))
|
|
1136
|
+
failing = cov.covered_changed_lines == 0 if min_ratio <= 0 else cov.ratio < min_ratio
|
|
1137
|
+
if failing:
|
|
1138
|
+
first_file = next(iter(cov.uncovered_by_file), None)
|
|
1139
|
+
first_lines = cov.uncovered_by_file.get(first_file or "", [])
|
|
1140
|
+
target_cmds = self._coverage_target_commands(task)
|
|
1141
|
+
gaps.append(Gap(
|
|
1142
|
+
id=self._next_gap_id(task.id, "DIFFCOV"),
|
|
1143
|
+
severity="high" if enforce_cov else "medium",
|
|
1144
|
+
gap_type="diff_not_exercised",
|
|
1145
|
+
task_id=task.id,
|
|
1146
|
+
description=(
|
|
1147
|
+
f"Verification commands passed but exercised "
|
|
1148
|
+
f"{cov.covered_changed_lines}/{cov.changed_executable_lines} changed line(s): "
|
|
1149
|
+
f"{cov.summary()}. The acceptance criteria are not proven because the new "
|
|
1150
|
+
"logic was never executed by the tests."
|
|
1151
|
+
),
|
|
1152
|
+
evidence=[cov.summary()] + [
|
|
1153
|
+
f"{path}: lines {lines}" for path, lines in list(cov.uncovered_by_file.items())[:5]
|
|
1154
|
+
],
|
|
1155
|
+
recommended_fix=(
|
|
1156
|
+
"Add or extend a test that executes the changed lines, then re-verify. "
|
|
1157
|
+
"A passing suite that does not run the new code is not acceptance evidence."
|
|
1158
|
+
),
|
|
1159
|
+
# Off by default (signal first); teams opt into blocking via
|
|
1160
|
+
# verification.diff_coverage.enforce.
|
|
1161
|
+
blocking=enforce_cov,
|
|
1162
|
+
file=first_file,
|
|
1163
|
+
line=first_lines[0] if first_lines else None,
|
|
1164
|
+
suggested_command=target_cmds[0] if target_cmds else None,
|
|
1165
|
+
))
|
|
1166
|
+
|
|
1167
|
+
# 6. Secret scan
|
|
1168
|
+
if diff_content:
|
|
1169
|
+
gaps.extend(self.secret_scanner.scan_diff(diff_content, task.id))
|
|
1170
|
+
|
|
1171
|
+
# 7. LLM Implementation Review (ADVISORY ONLY).
|
|
1172
|
+
# DevCouncil's authority is executable evidence, not model confidence — so
|
|
1173
|
+
# an LLM reviewer must never block on its own say-so. Subjective reviewers
|
|
1174
|
+
# over-flag correct code (false negatives that erode trust in "blocked"),
|
|
1175
|
+
# so review findings are surfaced as non-blocking signals. A genuine
|
|
1176
|
+
# requirement gap is caught by the acceptance-criteria evidence checks
|
|
1177
|
+
# above; the review just adds human-facing context.
|
|
1178
|
+
if self.reviewer and diff_content:
|
|
1179
|
+
try:
|
|
1180
|
+
review_result = await self.reviewer.review_changes(task, requirements, diff_content)
|
|
1181
|
+
for finding in review_result.findings:
|
|
1182
|
+
finding.id = self._next_gap_id(task.id, "REVIEW")
|
|
1183
|
+
finding.blocking = False
|
|
1184
|
+
gaps.append(finding)
|
|
1185
|
+
except Exception as e:
|
|
1186
|
+
logger.error("Implementation review failed: %s", e)
|
|
1187
|
+
|
|
1188
|
+
# 8. Open live-review cards
|
|
1189
|
+
for card in unresolved_blocking_cards(self.project_root, task_id=task.id):
|
|
1190
|
+
gaps.append(Gap(
|
|
1191
|
+
id=self._next_gap_id(task.id, "LIVE"),
|
|
1192
|
+
severity="critical",
|
|
1193
|
+
gap_type="architecture_drift",
|
|
1194
|
+
task_id=task.id,
|
|
1195
|
+
description=f"Open critical live-review card remains: {card.summary}",
|
|
1196
|
+
evidence=[card.id, card.message_for_agent],
|
|
1197
|
+
recommended_fix=(
|
|
1198
|
+
f"Address the critique card, then run `dev watch resolve {card.id}` "
|
|
1199
|
+
"or mark it ignored with justification outside the verification gate."
|
|
1200
|
+
),
|
|
1201
|
+
blocking=True,
|
|
1202
|
+
))
|
|
1203
|
+
|
|
1204
|
+
self.last_outcome = VerificationOutcome(
|
|
1205
|
+
mode="compiled" if self.acceptance_compiler else "coarse",
|
|
1206
|
+
compiler_active=compiler_active,
|
|
1207
|
+
diff_empty=diff_empty,
|
|
1208
|
+
coverage_measured=coverage_measured,
|
|
1209
|
+
coverage_skipped_reason=coverage_skipped_reason,
|
|
1210
|
+
)
|
|
1211
|
+
return gaps, evidence_to_save
|
|
1212
|
+
|
|
1213
|
+
def _check_semantic_diff(self, task: Task) -> List[Gap]:
|
|
1214
|
+
gaps: List[Gap] = []
|
|
1215
|
+
semantic_path = self.project_root / ".devcouncil" / "semantic" / task.id
|
|
1216
|
+
after_path = semantic_path / "after.json"
|
|
1217
|
+
if not after_path.exists():
|
|
1218
|
+
return gaps
|
|
1219
|
+
try:
|
|
1220
|
+
from devcouncil.indexing.semantic_index import SemanticIndex
|
|
1221
|
+
|
|
1222
|
+
result = SemanticIndex(self.project_root).diff(task.id)
|
|
1223
|
+
except Exception as e:
|
|
1224
|
+
logger.warning("Semantic diff check failed for %s; skipping semantic gaps: %s", task.id, e)
|
|
1225
|
+
return gaps
|
|
1226
|
+
|
|
1227
|
+
planned_paths = {pf.path for pf in task.planned_files}
|
|
1228
|
+
for item in result.get("classifications", []):
|
|
1229
|
+
change_type = item.get("type", "")
|
|
1230
|
+
path = item.get("path", "")
|
|
1231
|
+
if change_type == "public_api_change" and path not in planned_paths:
|
|
1232
|
+
gaps.append(Gap(
|
|
1233
|
+
id=self._next_gap_id(task.id, "SEM"),
|
|
1234
|
+
severity="high",
|
|
1235
|
+
gap_type="architecture_drift",
|
|
1236
|
+
task_id=task.id,
|
|
1237
|
+
description=f"Unplanned public API change detected in {path}.",
|
|
1238
|
+
evidence=[path],
|
|
1239
|
+
recommended_fix="Add file to planned_files and document acceptance criteria.",
|
|
1240
|
+
blocking=not bool(task.acceptance_criterion_ids),
|
|
1241
|
+
))
|
|
1242
|
+
elif change_type == "import_dependency_change" and path not in planned_paths:
|
|
1243
|
+
gaps.append(Gap(
|
|
1244
|
+
id=self._next_gap_id(task.id, "IMP"),
|
|
1245
|
+
severity="medium",
|
|
1246
|
+
gap_type="dependency_risk",
|
|
1247
|
+
task_id=task.id,
|
|
1248
|
+
description=f"Import dependency change in {path}.",
|
|
1249
|
+
evidence=[path],
|
|
1250
|
+
recommended_fix="Confirm dependency change is intentional.",
|
|
1251
|
+
blocking=False,
|
|
1252
|
+
))
|
|
1253
|
+
elif change_type == "config_schema_dependency_change" and path not in planned_paths:
|
|
1254
|
+
gaps.append(Gap(
|
|
1255
|
+
id=self._next_gap_id(task.id, "CFG"),
|
|
1256
|
+
severity="high",
|
|
1257
|
+
gap_type="dependency_risk",
|
|
1258
|
+
task_id=task.id,
|
|
1259
|
+
description=f"Config/schema change detected in {path}.",
|
|
1260
|
+
evidence=[path],
|
|
1261
|
+
recommended_fix="Plan the config change or revert it.",
|
|
1262
|
+
blocking=True,
|
|
1263
|
+
))
|
|
1264
|
+
return gaps
|
|
1265
|
+
|
|
1266
|
+
# Signatures that mean the verification command itself could not run (or had
|
|
1267
|
+
# nothing to run), so its non-zero exit says nothing about whether the
|
|
1268
|
+
# implementation is correct — a tooling/plan defect, not a code defect.
|
|
1269
|
+
_MALFORMED_COMMAND_SIGNATURES = (
|
|
1270
|
+
"syntaxerror",
|
|
1271
|
+
"invalid syntax",
|
|
1272
|
+
"indentationerror",
|
|
1273
|
+
"no module named", # any tool not installed (pytest, flake8, mypy, ...)
|
|
1274
|
+
"can't open file",
|
|
1275
|
+
"no such file or directory",
|
|
1276
|
+
"file or directory not found", # pytest: target path missing
|
|
1277
|
+
"no tests ran", # pytest -k matched nothing / empty file
|
|
1278
|
+
"no tests collected",
|
|
1279
|
+
"error: not found", # pytest: test node id does not exist
|
|
1280
|
+
"is not recognized as an internal or external command",
|
|
1281
|
+
"command not found",
|
|
1282
|
+
"executable file not found",
|
|
1283
|
+
"failed to run command",
|
|
1284
|
+
"importerror", # the verification harness itself failed to import
|
|
1285
|
+
"modulenotfounderror",
|
|
1286
|
+
)
|
|
1287
|
+
# Compile-/launch-time signatures that mean the code NEVER executed — these are
|
|
1288
|
+
# always authoritative regardless of any ``File "<string>", line N`` marker (a
|
|
1289
|
+
# SyntaxError prints that marker even though nothing ran). They must not be subject
|
|
1290
|
+
# to the "signature must precede a traceback frame" rule that distinguishes a real
|
|
1291
|
+
# in-test traceback from a launcher error.
|
|
1292
|
+
_UNCONDITIONAL_UNRUNNABLE_SIGNATURES = (
|
|
1293
|
+
"syntaxerror",
|
|
1294
|
+
"invalid syntax",
|
|
1295
|
+
"indentationerror",
|
|
1296
|
+
"can't open file",
|
|
1297
|
+
"is not recognized as an internal or external command",
|
|
1298
|
+
"command not found",
|
|
1299
|
+
"executable file not found",
|
|
1300
|
+
"failed to run command",
|
|
1301
|
+
"no tests ran",
|
|
1302
|
+
"no tests collected",
|
|
1303
|
+
"error: not found",
|
|
1304
|
+
)
|
|
1305
|
+
# pytest exit codes that mean "could not run / collect", not "tests failed":
|
|
1306
|
+
# 4 = usage/collection error, 5 = no tests collected.
|
|
1307
|
+
_PYTEST_NONRUN_EXIT_CODES = {4, 5}
|
|
1308
|
+
|
|
1309
|
+
@staticmethod
|
|
1310
|
+
def _is_traceback_frame(line: str) -> bool:
|
|
1311
|
+
"""True for a Python traceback frame line: `` File "...", line N``."""
|
|
1312
|
+
stripped = line.strip()
|
|
1313
|
+
return stripped.startswith('File "') and ", line " in stripped
|
|
1314
|
+
|
|
1315
|
+
def _malformed_signature_precedes_traceback(self, text: str) -> bool:
|
|
1316
|
+
"""Decide whether an unrunnable-launcher signature is authoritative.
|
|
1317
|
+
|
|
1318
|
+
A launcher/collection failure prints its error WITHOUT a Python traceback that
|
|
1319
|
+
executed the code under test (e.g. ``ModuleNotFoundError: No module named
|
|
1320
|
+
pytest`` straight from the interpreter, or pytest's collection error banner).
|
|
1321
|
+
A genuine in-test failure, by contrast, raises from inside a traceback whose
|
|
1322
|
+
frames point at the test/source files; the same signature words can appear
|
|
1323
|
+
there (``ImportError`` re-raised inside a test) but that is a real defect, not
|
|
1324
|
+
an unrunnable command.
|
|
1325
|
+
|
|
1326
|
+
So a signature only proves "unrunnable" when it appears BEFORE the first
|
|
1327
|
+
traceback frame (or there is no traceback frame at all). If a traceback frame
|
|
1328
|
+
appears at or before the signature, the code under test ran and failed — keep
|
|
1329
|
+
it a blocking test failure."""
|
|
1330
|
+
if not text:
|
|
1331
|
+
return False
|
|
1332
|
+
low_all = text.lower()
|
|
1333
|
+
# Compile-/launch-time failures: the code never executed, so a ``File ...``
|
|
1334
|
+
# marker (printed by SyntaxError) is not a real frame. Authoritative outright.
|
|
1335
|
+
if any(sig in low_all for sig in self._UNCONDITIONAL_UNRUNNABLE_SIGNATURES):
|
|
1336
|
+
return True
|
|
1337
|
+
lines = text.splitlines()
|
|
1338
|
+
lowered_lines = [ln.lower() for ln in lines]
|
|
1339
|
+
first_frame_idx: Optional[int] = None
|
|
1340
|
+
for idx, line in enumerate(lines):
|
|
1341
|
+
if self._is_traceback_frame(line):
|
|
1342
|
+
first_frame_idx = idx
|
|
1343
|
+
break
|
|
1344
|
+
for idx, low in enumerate(lowered_lines):
|
|
1345
|
+
if any(sig in low for sig in self._MALFORMED_COMMAND_SIGNATURES):
|
|
1346
|
+
# Signature found; it is only authoritative if no traceback frame
|
|
1347
|
+
# precedes it (i.e. the failure is from the launcher, not from code
|
|
1348
|
+
# that actually executed under a traceback).
|
|
1349
|
+
if first_frame_idx is None or idx < first_frame_idx:
|
|
1350
|
+
return True
|
|
1351
|
+
return False
|
|
1352
|
+
return False
|
|
1353
|
+
|
|
1354
|
+
def _launcher_text(self, result: CommandResult) -> str:
|
|
1355
|
+
"""Captured output for launcher-vs-test analysis, ordered stderr then stdout.
|
|
1356
|
+
|
|
1357
|
+
The traceback-precedence discriminator
|
|
1358
|
+
(:meth:`_malformed_signature_precedes_traceback`) needs to see BOTH streams:
|
|
1359
|
+
an interpreter "cannot run" error lands on stderr (with no traceback frame),
|
|
1360
|
+
while a genuine in-test failure's traceback lands on stdout (frame first, then
|
|
1361
|
+
the exception). We therefore concatenate stderr+stdout so the relative ordering
|
|
1362
|
+
of any signature vs the first traceback frame is preserved.
|
|
1363
|
+
|
|
1364
|
+
Reading the merged ``result.summary`` alone is unsafe: it hoists the salient
|
|
1365
|
+
error line to the FRONT, which would place an in-test ``ImportError`` before its
|
|
1366
|
+
own traceback frame and misclassify a real failure as unrunnable. So prefer the
|
|
1367
|
+
raw logs; only fall back to the summary when no log path is available (e.g. unit
|
|
1368
|
+
tests that stub ``_run_command``). Never raises."""
|
|
1369
|
+
parts: List[str] = []
|
|
1370
|
+
for path in (result.stderr_path, result.stdout_path):
|
|
1371
|
+
if not path:
|
|
1372
|
+
continue
|
|
1373
|
+
try:
|
|
1374
|
+
content = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
1375
|
+
if content.strip():
|
|
1376
|
+
parts.append(content)
|
|
1377
|
+
except Exception:
|
|
1378
|
+
pass
|
|
1379
|
+
if parts:
|
|
1380
|
+
return "\n".join(parts)
|
|
1381
|
+
return result.summary or ""
|
|
1382
|
+
|
|
1383
|
+
# Matches a Python traceback frame: `` File "path/to/x.py", line 42, in foo``.
|
|
1384
|
+
_TRACEBACK_FRAME_RE = re.compile(r'File "(?P<file>[^"]+)", line (?P<line>\d+)')
|
|
1385
|
+
|
|
1386
|
+
def _failure_location(self, result: CommandResult) -> Tuple[Optional[str], Optional[int]]:
|
|
1387
|
+
"""Best-effort (file, line) of a failing command's deepest traceback frame.
|
|
1388
|
+
|
|
1389
|
+
The LAST frame in a Python traceback is the actual raise site, so we scan all
|
|
1390
|
+
frames and keep the last one that points at a real-looking source file (not the
|
|
1391
|
+
``<string>`` of a ``python -c`` snippet). Returns repo-relative posix paths when
|
|
1392
|
+
the frame is inside the project root. Reads the captured logs (stdout has the
|
|
1393
|
+
test traceback; stderr has interpreter errors). Never raises."""
|
|
1394
|
+
sources = []
|
|
1395
|
+
for path in (result.stdout_path, result.stderr_path):
|
|
1396
|
+
if path:
|
|
1397
|
+
try:
|
|
1398
|
+
content = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
1399
|
+
if content.strip():
|
|
1400
|
+
sources.append(content)
|
|
1401
|
+
except Exception:
|
|
1402
|
+
pass
|
|
1403
|
+
sources.append(result.summary or "")
|
|
1404
|
+
best_file: Optional[str] = None
|
|
1405
|
+
best_line: Optional[int] = None
|
|
1406
|
+
for text in sources:
|
|
1407
|
+
for match in self._TRACEBACK_FRAME_RE.finditer(text):
|
|
1408
|
+
raw_file = match.group("file")
|
|
1409
|
+
if not raw_file or raw_file.startswith("<"):
|
|
1410
|
+
continue # e.g. "<string>" from python -c
|
|
1411
|
+
best_file = self._relativize(raw_file)
|
|
1412
|
+
try:
|
|
1413
|
+
best_line = int(match.group("line"))
|
|
1414
|
+
except ValueError:
|
|
1415
|
+
best_line = None
|
|
1416
|
+
if best_file is not None:
|
|
1417
|
+
return best_file, best_line
|
|
1418
|
+
return best_file, best_line
|
|
1419
|
+
|
|
1420
|
+
def _relativize(self, raw_path: str) -> str:
|
|
1421
|
+
"""Normalize a traceback file path to a repo-relative posix path when possible."""
|
|
1422
|
+
normalized = raw_path.replace("\\", "/")
|
|
1423
|
+
try:
|
|
1424
|
+
candidate = Path(raw_path)
|
|
1425
|
+
if candidate.is_absolute():
|
|
1426
|
+
rel = candidate.resolve().relative_to(self.project_root.resolve())
|
|
1427
|
+
return rel.as_posix()
|
|
1428
|
+
except Exception:
|
|
1429
|
+
pass
|
|
1430
|
+
return normalized
|
|
1431
|
+
|
|
1432
|
+
def _command_is_malformed(self, result: CommandResult) -> bool:
|
|
1433
|
+
"""True when a non-zero exit reflects a broken/unrunnable command rather
|
|
1434
|
+
than a genuine assertion or test failure of the code under verification.
|
|
1435
|
+
|
|
1436
|
+
Authoritative signals (in priority order):
|
|
1437
|
+
1. pytest exit 4/5 -> collection/usage error -> unrunnable.
|
|
1438
|
+
2. The launcher error text: an unrunnable signature only counts when it
|
|
1439
|
+
appears BEFORE any Python traceback frame. This stops a genuinely failing
|
|
1440
|
+
test whose traceback contains ``ImportError``/``ModuleNotFoundError`` from
|
|
1441
|
+
being downgraded to a non-blocking "invalid command" (which would let
|
|
1442
|
+
verification falsely PASS)."""
|
|
1443
|
+
is_pytest = "pytest" in (result.command or "")
|
|
1444
|
+
if is_pytest and result.exit_code in self._PYTEST_NONRUN_EXIT_CODES:
|
|
1445
|
+
return True
|
|
1446
|
+
# Otherwise the exit code alone is ambiguous: pytest exit 1 is "tests ran and
|
|
1447
|
+
# FAILED" (a real defect), but a missing pytest module also exits 1 from the
|
|
1448
|
+
# interpreter (``No module named pytest``). The launcher error text is the
|
|
1449
|
+
# authoritative discriminator — a signature only means "unrunnable" when it
|
|
1450
|
+
# appears BEFORE any Python traceback frame. A genuine test failure whose
|
|
1451
|
+
# traceback merely mentions ``ImportError`` keeps a traceback frame first and so
|
|
1452
|
+
# stays a blocking test failure (preventing a false PASS).
|
|
1453
|
+
text = self._launcher_text(result)
|
|
1454
|
+
return self._malformed_signature_precedes_traceback(text)
|
|
1455
|
+
|
|
480
1456
|
def _commands_for_task(self, task: Task) -> Dict[str, List[str]]:
|
|
481
1457
|
if task.expected_tests:
|
|
482
1458
|
return {"test": task.expected_tests}
|
|
@@ -484,6 +1460,65 @@ class Verifier:
|
|
|
484
1460
|
return {"allowed": task.allowed_commands}
|
|
485
1461
|
return self._load_commands()
|
|
486
1462
|
|
|
1463
|
+
def _command_applicable(self, command: str) -> tuple[bool, str]:
|
|
1464
|
+
"""Stack-aware gate for a verification command.
|
|
1465
|
+
|
|
1466
|
+
A planner- or config-supplied command must not BLOCK a task when it targets a
|
|
1467
|
+
language stack the repository does not have (e.g. ``npm test``/``eslint``/
|
|
1468
|
+
``tsc`` on a Python-only repo). Those fail for stack reasons, not real defects —
|
|
1469
|
+
the false-block the benchmark surfaced. Returns ``(applicable, reason)``; an
|
|
1470
|
+
inapplicable command is skipped and recorded as advisory rather than run."""
|
|
1471
|
+
cmd = (command or "").strip()
|
|
1472
|
+
if not cmd:
|
|
1473
|
+
return True, ""
|
|
1474
|
+
try:
|
|
1475
|
+
from devcouncil.repo.ci_scaffold import _command_stack, detect_stacks
|
|
1476
|
+
|
|
1477
|
+
stacks = detect_stacks(self.project_root)
|
|
1478
|
+
stack = _command_stack(cmd)
|
|
1479
|
+
except Exception:
|
|
1480
|
+
return True, ""
|
|
1481
|
+
if stack is not None and stacks and stack not in stacks:
|
|
1482
|
+
detected = ", ".join(sorted(stacks)) or "none"
|
|
1483
|
+
return False, f"command targets the '{stack}' stack not present in this repo (detected: {detected})"
|
|
1484
|
+
return True, ""
|
|
1485
|
+
|
|
1486
|
+
# Linters / formatters / type checkers: a non-zero exit is a style/type OPINION,
|
|
1487
|
+
# not proof of a behavioral defect. Blocking a behaviorally-correct task on these is
|
|
1488
|
+
# the false-block the benchmark surfaced (the planner even spawns dedicated
|
|
1489
|
+
# "add flake8 check" / "run black --check" tasks). Their failures are advisory.
|
|
1490
|
+
_QUALITY_TOOLS = {
|
|
1491
|
+
"black", "flake8", "ruff", "isort", "pylint", "mypy", "pyright", "autopep8",
|
|
1492
|
+
"yapf", "pyflakes", "pycodestyle", "bandit", "eslint", "tsc", "prettier",
|
|
1493
|
+
"stylelint", "standard", "biome",
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
def _is_quality_only_command(self, command: str) -> bool:
|
|
1497
|
+
"""True when the command's executable is purely a linter/formatter/type checker.
|
|
1498
|
+
|
|
1499
|
+
Handles common wrappers (``python -m mypy``, ``npx eslint``, ``poetry run black``,
|
|
1500
|
+
``npm run lint``). A behavioral check like ``pytest`` or ``python -c 'assert ...'``
|
|
1501
|
+
is NOT a quality-only command and still gates."""
|
|
1502
|
+
tokens = command.split()
|
|
1503
|
+
i = 0
|
|
1504
|
+
while i < len(tokens):
|
|
1505
|
+
tok = tokens[i]
|
|
1506
|
+
if tok in {"python", "python3", "py"} and i + 1 < len(tokens) and tokens[i + 1] == "-m":
|
|
1507
|
+
i += 2
|
|
1508
|
+
continue
|
|
1509
|
+
if tok in {"npx", "poetry", "uv", "pdm", "hatch", "rye"}:
|
|
1510
|
+
i += 1
|
|
1511
|
+
if i < len(tokens) and tokens[i] == "run":
|
|
1512
|
+
i += 1
|
|
1513
|
+
continue
|
|
1514
|
+
if tok in {"npm", "pnpm", "yarn"}:
|
|
1515
|
+
return any(word in tokens for word in ("lint", "format", "eslint", "prettier", "stylelint", "biome"))
|
|
1516
|
+
break
|
|
1517
|
+
if i >= len(tokens):
|
|
1518
|
+
return False
|
|
1519
|
+
tool = tokens[i].replace("\\", "/").split("/")[-1].split("==")[0].lower()
|
|
1520
|
+
return tool in self._QUALITY_TOOLS
|
|
1521
|
+
|
|
487
1522
|
def _command_can_prove_acceptance(self, cmd_type: str, command: str) -> bool:
|
|
488
1523
|
if cmd_type == "test":
|
|
489
1524
|
return True
|
|
@@ -507,7 +1542,7 @@ class Verifier:
|
|
|
507
1542
|
return any(keyword in lowered for keyword in evidence_keywords)
|
|
508
1543
|
|
|
509
1544
|
def _requirement_id_for_ac(self, requirements: List[Requirement], ac_id: str) -> Optional[str]:
|
|
510
|
-
for req in requirements:
|
|
511
|
-
if any(ac.id == ac_id for ac in req.acceptance_criteria):
|
|
512
|
-
return req.id
|
|
513
|
-
return None
|
|
1545
|
+
for req in requirements:
|
|
1546
|
+
if any(ac.id == ac_id for ac in req.acceptance_criteria):
|
|
1547
|
+
return req.id
|
|
1548
|
+
return None
|