devcouncil 0.1.1 → 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/README.md +190 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +167 -5
- package/src/devcouncil/artifacts/graph.py +23 -3
- 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 +6 -3
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/doctor.py +221 -21
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +452 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +124 -15
- package/src/devcouncil/cli/commands/init.py +154 -18
- package/src/devcouncil/cli/commands/integrate.py +894 -105
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/plan.py +212 -51
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +40 -23
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +69 -49
- 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 +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +25 -1
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +138 -3
- package/src/devcouncil/cli/commands/watch.py +9 -9
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +56 -7
- package/src/devcouncil/domain/evidence.py +22 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +59 -10
- package/src/devcouncil/execution/permissions.py +17 -24
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +633 -21
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +6 -2
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +663 -39
- package/src/devcouncil/executors/native/agent.py +121 -20
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
- package/src/devcouncil/gating/policy.py +158 -10
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +1 -1
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1256 -9
- 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_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +35 -0
- package/src/devcouncil/integrations/mcp/server.py +1552 -29
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/live/cards.py +161 -19
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/transcripts.py +9 -6
- package/src/devcouncil/llm/cache.py +10 -6
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +515 -34
- package/src/devcouncil/llm/router.py +231 -46
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +7 -2
- package/src/devcouncil/planning/plan_service.py +17 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
- package/src/devcouncil/planning/spec_service.py +27 -1
- 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/json_report.py +11 -1
- package/src/devcouncil/reporting/markdown_report.py +15 -0
- 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 +83 -2
- package/src/devcouncil/storage/models.py +121 -0
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +137 -75
- package/src/devcouncil/telemetry/cost.py +123 -17
- 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 +12 -9
- package/src/devcouncil/ui/dashboard.py +324 -23
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- 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/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 +1065 -47
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Compile natural-language acceptance criteria into self-contained executable
|
|
2
|
+
checks that DevCouncil owns and runs.
|
|
3
|
+
|
|
4
|
+
This is the difference between trusting the planner/agent's word and gathering
|
|
5
|
+
real evidence: instead of running planner-authored ``expected_tests`` (which the
|
|
6
|
+
benchmark showed often reference tools or test files that do not exist), DevCouncil
|
|
7
|
+
derives one runnable check per acceptance criterion directly from the criterion
|
|
8
|
+
text and the code under review, then maps each check 1:1 to its criterion.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Dict, List
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from devcouncil.domain.requirement import Requirement
|
|
19
|
+
from devcouncil.domain.task import Task
|
|
20
|
+
from devcouncil.llm.router import ModelRouter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CompiledCheck(BaseModel):
|
|
24
|
+
acceptance_criterion_id: str
|
|
25
|
+
command: str # a single shell command that exits 0 iff the criterion holds
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CompiledChecks(BaseModel):
|
|
29
|
+
checks: List[CompiledCheck]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AcceptanceTestCompiler:
|
|
33
|
+
def __init__(self, router: ModelRouter, role: str = "implementation_reviewer"):
|
|
34
|
+
self.router = router
|
|
35
|
+
self.role = role
|
|
36
|
+
|
|
37
|
+
async def compile(
|
|
38
|
+
self,
|
|
39
|
+
task: Task,
|
|
40
|
+
requirements: List[Requirement],
|
|
41
|
+
code_context: str,
|
|
42
|
+
) -> Dict[str, List[str]]:
|
|
43
|
+
"""Return {acceptance_criterion_id: [self-contained check command(s)]}.
|
|
44
|
+
|
|
45
|
+
Best-effort: returns {} if the model cannot produce usable checks, so the
|
|
46
|
+
caller can fall back to the task's declared expected_tests.
|
|
47
|
+
"""
|
|
48
|
+
ac_by_id = {ac.id: ac for req in requirements for ac in req.acceptance_criteria}
|
|
49
|
+
target = [ac_by_id[i] for i in task.acceptance_criterion_ids if i in ac_by_id]
|
|
50
|
+
if not target:
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
acs_json = json.dumps(
|
|
54
|
+
[{"id": ac.id, "description": ac.description, "method": ac.verification_method} for ac in target],
|
|
55
|
+
indent=2,
|
|
56
|
+
)
|
|
57
|
+
prompt = f"""
|
|
58
|
+
You are DevCouncil's acceptance-test compiler. Convert each acceptance criterion
|
|
59
|
+
below into exactly ONE shell command that EXITS 0 if and only if the BEHAVIOR
|
|
60
|
+
described by the criterion holds for the code shown.
|
|
61
|
+
|
|
62
|
+
Acceptance criteria:
|
|
63
|
+
{acs_json}
|
|
64
|
+
|
|
65
|
+
Code under review (the diff / current files):
|
|
66
|
+
{code_context}
|
|
67
|
+
|
|
68
|
+
What a check must verify — BEHAVIOR ONLY:
|
|
69
|
+
- A check exists to confirm the code DOES what the criterion describes when its
|
|
70
|
+
public API is exercised: import the module/symbol and call its function(s), or
|
|
71
|
+
run its CLI/entrypoint, and assert on the observable result (return value,
|
|
72
|
+
raised exception, stdout, exit code).
|
|
73
|
+
- DevCouncil already enforces scope, file ownership, and append-only/orphan-diff
|
|
74
|
+
constraints with its OWN gates. Acceptance checks must therefore NEVER re-assert
|
|
75
|
+
repository or filesystem STATE — that is not their job and it produces false
|
|
76
|
+
BLOCKED results because `dev` itself adds workspace files (AGENTS.md, CLAUDE.md,
|
|
77
|
+
.gitignore, .devcouncil/config.yaml, etc.).
|
|
78
|
+
|
|
79
|
+
Rules — the commands are executed verbatim by the verifier:
|
|
80
|
+
- One command per acceptance_criterion_id (reference the id exactly).
|
|
81
|
+
- Each command MUST be a single, SELF-CONTAINED, immediately-runnable command:
|
|
82
|
+
import the real module/symbol from the code and assert the behavior directly.
|
|
83
|
+
Do NOT depend on test files, fixtures, or any external setup.
|
|
84
|
+
- Prefer: python -c "import <module>; assert <expr>". For an expected exception,
|
|
85
|
+
use a one-line guard, e.g.
|
|
86
|
+
python -c "import m; \\ntry: m.f([])\\nexcept ValueError: pass\\nelse: raise SystemExit(1)"
|
|
87
|
+
(real newlines are fine; never put try/if/for after a ';').
|
|
88
|
+
- Use the actual module name implied by the code (e.g. file 'stats.py' -> import stats).
|
|
89
|
+
|
|
90
|
+
HARD PROHIBITIONS — a command that does any of these is INVALID; omit the
|
|
91
|
+
criterion instead of emitting such a command:
|
|
92
|
+
- NEVER assert exact git or filesystem state. Forbidden: `git status`,
|
|
93
|
+
`git status --porcelain`, `git diff`, `git diff --name-only`, `git show`,
|
|
94
|
+
`git ls-files`, `ls`/`find`/`os.listdir` equality checks, asserting a precise
|
|
95
|
+
set or count of changed/created files, or asserting a file does/does not exist
|
|
96
|
+
as the criterion's pass condition.
|
|
97
|
+
- NEVER do append-only or byte-level file/content comparisons (e.g.
|
|
98
|
+
`git show HEAD:file`, diffing bytes, asserting only N bytes/lines were added).
|
|
99
|
+
Assert the resulting BEHAVIOR instead, not how the file changed.
|
|
100
|
+
- NEVER invoke linters, type checkers, formatters, or build/package tools that
|
|
101
|
+
may be absent: flake8, mypy, ruff, pylint, black, isort, eslint, tsc, prettier,
|
|
102
|
+
npm, npx, yarn, pnpm, cargo, go vet, etc. Only use such a tool if the code
|
|
103
|
+
context clearly shows it is configured for this repo (e.g. a matching config
|
|
104
|
+
section/file is present in the context) AND it is essential to the criterion.
|
|
105
|
+
- If a criterion cannot be checked by a behavioral command (e.g. pure 'manual'
|
|
106
|
+
review, or it only describes repo/tooling state), OMIT it rather than inventing
|
|
107
|
+
a state-based or bogus command.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
result = await self.router.complete_structured(
|
|
111
|
+
role=self.role,
|
|
112
|
+
messages=[{"role": "user", "content": prompt}],
|
|
113
|
+
schema=CompiledChecks,
|
|
114
|
+
fallback=CompiledChecks(checks=[]),
|
|
115
|
+
)
|
|
116
|
+
except Exception:
|
|
117
|
+
return {}
|
|
118
|
+
|
|
119
|
+
out: Dict[str, List[str]] = {}
|
|
120
|
+
valid_ids = {ac.id for ac in target}
|
|
121
|
+
for check in result.checks:
|
|
122
|
+
cmd = (check.command or "").strip()
|
|
123
|
+
if check.acceptance_criterion_id in valid_ids and cmd:
|
|
124
|
+
out.setdefault(check.acceptance_criterion_id, []).append(cmd)
|
|
125
|
+
return out
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Verify an ad-hoc working-tree diff against an inline requirement — no planning, no keys.
|
|
2
|
+
|
|
3
|
+
This powers ``dev check``'s evidence-gate mode (the lite entry point): wrap whatever is
|
|
4
|
+
in the working tree as a synthetic Requirement→Task, run the *same* deterministic
|
|
5
|
+
:class:`~devcouncil.verification.verifier.Verifier` the full workflow uses — orphan-diff,
|
|
6
|
+
secret scan, acceptance evidence, and the diff↔coverage gate — and return the verdict
|
|
7
|
+
plus the typed next-actions contract. ``router=None`` keeps it provider-key-free so a
|
|
8
|
+
newcomer can taste the evidence gate before committing to the full council flow.
|
|
9
|
+
|
|
10
|
+
The logic lives here (not in the CLI command) so it is unit-testable without Typer and
|
|
11
|
+
resilient to churn in the command module.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import List, Optional
|
|
20
|
+
|
|
21
|
+
from devcouncil.domain.evidence import DiffCoverageEvidence
|
|
22
|
+
from devcouncil.domain.gap import Gap
|
|
23
|
+
from devcouncil.domain.requirement import AcceptanceCriterion, Requirement
|
|
24
|
+
from devcouncil.domain.task import PlannedFile, Task
|
|
25
|
+
from devcouncil.llm.router import ModelRouter
|
|
26
|
+
from devcouncil.verification.next_actions import NextAction, build_next_actions
|
|
27
|
+
from devcouncil.verification.verifier import Verifier
|
|
28
|
+
|
|
29
|
+
_REQ_ID = "REQ-CHECK"
|
|
30
|
+
_AC_ID = "AC-CHECK"
|
|
31
|
+
_TASK_ID = "CHECK"
|
|
32
|
+
|
|
33
|
+
_DEFAULT_CRITERION = "The working-tree changes are correct and exercised by tests."
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class AdHocCheckResult:
|
|
38
|
+
requirement: str
|
|
39
|
+
changed_files: List[str] = field(default_factory=list)
|
|
40
|
+
gaps: List[Gap] = field(default_factory=list)
|
|
41
|
+
next_actions: List[NextAction] = field(default_factory=list)
|
|
42
|
+
diff_coverage: Optional[DiffCoverageEvidence] = None
|
|
43
|
+
passed: bool = True
|
|
44
|
+
reason: str = ""
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return {
|
|
48
|
+
"ok": True,
|
|
49
|
+
"verified": self.passed,
|
|
50
|
+
"requirement": self.requirement,
|
|
51
|
+
"changed_files": self.changed_files,
|
|
52
|
+
"reason": self.reason,
|
|
53
|
+
"gap_count": len(self.gaps),
|
|
54
|
+
"blocking_gap_count": len([g for g in self.gaps if g.blocking]),
|
|
55
|
+
"gaps": [g.model_dump() for g in self.gaps],
|
|
56
|
+
"next_actions": [a.model_dump() for a in self.next_actions],
|
|
57
|
+
"diff_coverage": self.diff_coverage.model_dump() if self.diff_coverage else None,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_working_tree_check(
|
|
62
|
+
project_root: Path,
|
|
63
|
+
requirement: Optional[str] = None,
|
|
64
|
+
*,
|
|
65
|
+
test_commands: Optional[List[str]] = None,
|
|
66
|
+
enforce_coverage: bool = False,
|
|
67
|
+
min_ratio: float = 0.0,
|
|
68
|
+
router: Optional[ModelRouter] = None,
|
|
69
|
+
verifier: Optional[Verifier] = None,
|
|
70
|
+
) -> AdHocCheckResult:
|
|
71
|
+
"""Verify the current working-tree diff against a one-line requirement.
|
|
72
|
+
|
|
73
|
+
Builds a synthetic task whose planned files are exactly the changed files (so the
|
|
74
|
+
result is about evidence, not scope noise) and whose expected tests are
|
|
75
|
+
``test_commands``. Diff coverage is always measured; pass ``enforce_coverage`` (or a
|
|
76
|
+
positive ``min_ratio``) to make an unexercised diff blocking.
|
|
77
|
+
"""
|
|
78
|
+
verifier = verifier or Verifier(project_root, router=router)
|
|
79
|
+
|
|
80
|
+
diff = verifier.get_diff()
|
|
81
|
+
changed_files = verifier.get_changed_files()
|
|
82
|
+
if not diff or not changed_files:
|
|
83
|
+
return AdHocCheckResult(requirement="", passed=True, reason="no_changes")
|
|
84
|
+
|
|
85
|
+
criterion = requirement or _DEFAULT_CRITERION
|
|
86
|
+
req = Requirement(
|
|
87
|
+
id=_REQ_ID,
|
|
88
|
+
title=(requirement or "Working-tree change")[:80],
|
|
89
|
+
description=criterion,
|
|
90
|
+
priority="high",
|
|
91
|
+
source="user",
|
|
92
|
+
acceptance_criteria=[
|
|
93
|
+
AcceptanceCriterion(id=_AC_ID, description=criterion, verification_method="unit_test"),
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
untracked = set(verifier._get_untracked_files())
|
|
97
|
+
task = Task(
|
|
98
|
+
id=_TASK_ID,
|
|
99
|
+
title="Ad-hoc working-tree check",
|
|
100
|
+
description=criterion,
|
|
101
|
+
requirement_ids=[_REQ_ID],
|
|
102
|
+
acceptance_criterion_ids=[_AC_ID],
|
|
103
|
+
planned_files=[
|
|
104
|
+
PlannedFile(
|
|
105
|
+
path=path,
|
|
106
|
+
reason="working-tree change",
|
|
107
|
+
allowed_change="create" if path in untracked else "modify",
|
|
108
|
+
)
|
|
109
|
+
for path in changed_files
|
|
110
|
+
],
|
|
111
|
+
expected_tests=list(test_commands or []),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Always measure diff coverage in lite mode; block on it only when asked. A positive
|
|
115
|
+
# --min-coverage implies enforcement so the flag is never silently inert.
|
|
116
|
+
enforce = enforce_coverage or min_ratio > 0
|
|
117
|
+
verifier._diff_coverage_override = (True, enforce, float(min_ratio))
|
|
118
|
+
|
|
119
|
+
gaps, evidence = asyncio.run(verifier.verify_task(task, [req]))
|
|
120
|
+
coverage = next((ev for ev in evidence if isinstance(ev, DiffCoverageEvidence)), None)
|
|
121
|
+
blocking = [g for g in gaps if g.blocking]
|
|
122
|
+
return AdHocCheckResult(
|
|
123
|
+
requirement=criterion,
|
|
124
|
+
changed_files=changed_files,
|
|
125
|
+
gaps=gaps,
|
|
126
|
+
next_actions=build_next_actions(gaps),
|
|
127
|
+
diff_coverage=coverage,
|
|
128
|
+
passed=not blocking,
|
|
129
|
+
)
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Diff↔coverage intersection — proof that the *changed lines* were exercised.
|
|
2
|
+
|
|
3
|
+
DevCouncil's headline promise is that a passing test must prove the **new logic
|
|
4
|
+
was exercised**, not merely that *some* suite exited 0. An agent can make a green
|
|
5
|
+
suite pass while the changed code is never imported, never called, or shadowed by
|
|
6
|
+
an unrelated passing test. This module closes that gap: it runs a task's test
|
|
7
|
+
command under coverage instrumentation, then intersects the lines the tests
|
|
8
|
+
actually executed with the lines the diff changed.
|
|
9
|
+
|
|
10
|
+
Two failure shapes are caught:
|
|
11
|
+
|
|
12
|
+
1. **Touched-but-not-exercised** — the changed file *is* in the coverage report,
|
|
13
|
+
but the changed executable lines were never executed (e.g. a passing test that
|
|
14
|
+
exercises a different branch).
|
|
15
|
+
2. **Never-imported** — the changed source file is *absent* from the coverage
|
|
16
|
+
report entirely, meaning the tests never loaded it.
|
|
17
|
+
|
|
18
|
+
False-positive discipline (mirrors :class:`~devcouncil.verification.verifier.Verifier`):
|
|
19
|
+
this analysis only ever produces a *signal* when it has reliable data — a
|
|
20
|
+
parseable diff with real hunks, a detected coverage tool, and changed *executable*
|
|
21
|
+
lines to measure. When any of those is missing it returns
|
|
22
|
+
``DiffCoverageResult(measured=False, ...)`` and the verifier degrades to its prior
|
|
23
|
+
behaviour rather than blocking correct work. Coverage is read from the **target
|
|
24
|
+
repository's** own tooling (coverage.py for Python); DevCouncil never forces its
|
|
25
|
+
own coverage dependency into the project under verification.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Dict, List, Optional, Set
|
|
34
|
+
|
|
35
|
+
# A unified-diff hunk header: ``@@ -<old> +<newStart>[,<newLen>] @@``. We only need
|
|
36
|
+
# the new-file starting line to walk added/context lines into new-file numbers.
|
|
37
|
+
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
38
|
+
|
|
39
|
+
# Path fragments that mark a file as test code. Test files are excluded from the
|
|
40
|
+
# "must be exercised" denominator: a test exercising itself is not the new logic
|
|
41
|
+
# whose behaviour an acceptance criterion is about.
|
|
42
|
+
_TEST_MARKERS = (
|
|
43
|
+
"/tests/",
|
|
44
|
+
"tests/",
|
|
45
|
+
"/test_",
|
|
46
|
+
"test_",
|
|
47
|
+
"_test.",
|
|
48
|
+
".test.",
|
|
49
|
+
".spec.",
|
|
50
|
+
"_spec.",
|
|
51
|
+
"/spec/",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _strip_diff_prefix(path: str) -> str:
|
|
56
|
+
"""Strip a leading ``a/`` or ``b/`` and normalise to forward slashes."""
|
|
57
|
+
path = path.strip().strip('"')
|
|
58
|
+
if path.startswith(("a/", "b/")):
|
|
59
|
+
path = path[2:]
|
|
60
|
+
return path.replace("\\", "/")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_test_path(path: str) -> bool:
|
|
64
|
+
lowered = path.replace("\\", "/").lower()
|
|
65
|
+
return any(marker in lowered for marker in _TEST_MARKERS)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_code_like(line: str) -> bool:
|
|
69
|
+
"""Conservative heuristic: a non-blank line that is not a pure comment.
|
|
70
|
+
|
|
71
|
+
Used only for changed files that are *absent* from the coverage report, where
|
|
72
|
+
no authoritative executable-line set exists. Imports, ``def``/``class``,
|
|
73
|
+
decorators and statements all count; blank lines and ``#`` comments do not.
|
|
74
|
+
Kept deliberately conservative so it never inflates the denominator.
|
|
75
|
+
"""
|
|
76
|
+
stripped = line.strip()
|
|
77
|
+
return bool(stripped) and not stripped.startswith("#")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_changed_lines(diff: str) -> Dict[str, Dict[int, str]]:
|
|
81
|
+
"""Parse a unified diff into ``{file_path: {new_line_number: added_text}}``.
|
|
82
|
+
|
|
83
|
+
Only *added* lines (``+`` in the new file) are recorded, keyed by their line
|
|
84
|
+
number in the post-change file so they can be intersected with coverage data.
|
|
85
|
+
Deletions and context lines advance the counter but are not themselves
|
|
86
|
+
"changed lines" we require coverage for. Paths are normalised relative to the
|
|
87
|
+
repo (``a/``/``b/`` prefixes stripped, forward slashes).
|
|
88
|
+
"""
|
|
89
|
+
changed: Dict[str, Dict[int, str]] = {}
|
|
90
|
+
current_file: Optional[str] = None
|
|
91
|
+
new_line = 0
|
|
92
|
+
in_hunk = False
|
|
93
|
+
|
|
94
|
+
for raw in diff.splitlines():
|
|
95
|
+
if raw.startswith("diff --git") or raw.startswith("--- "):
|
|
96
|
+
in_hunk = False
|
|
97
|
+
continue
|
|
98
|
+
if raw.startswith("+++ "):
|
|
99
|
+
target = raw[4:].strip()
|
|
100
|
+
if target == "/dev/null":
|
|
101
|
+
current_file = None
|
|
102
|
+
else:
|
|
103
|
+
current_file = _strip_diff_prefix(target)
|
|
104
|
+
changed.setdefault(current_file, {})
|
|
105
|
+
in_hunk = False
|
|
106
|
+
continue
|
|
107
|
+
if raw.startswith("@@"):
|
|
108
|
+
match = _HUNK_RE.match(raw)
|
|
109
|
+
if match:
|
|
110
|
+
new_line = int(match.group(1))
|
|
111
|
+
in_hunk = True
|
|
112
|
+
else:
|
|
113
|
+
# A header we can't number (e.g. a combined-merge ``@@@``). Stay out
|
|
114
|
+
# of hunk mode rather than mis-attribute added lines to line 0.
|
|
115
|
+
in_hunk = False
|
|
116
|
+
continue
|
|
117
|
+
if not in_hunk or current_file is None:
|
|
118
|
+
continue
|
|
119
|
+
if raw.startswith("\\"): # ""
|
|
120
|
+
continue
|
|
121
|
+
if raw.startswith("+"):
|
|
122
|
+
changed[current_file][new_line] = raw[1:]
|
|
123
|
+
new_line += 1
|
|
124
|
+
elif raw.startswith("-"):
|
|
125
|
+
continue # old-file only; does not advance the new-file counter
|
|
126
|
+
else:
|
|
127
|
+
new_line += 1 # context line
|
|
128
|
+
|
|
129
|
+
return {path: lines for path, lines in changed.items() if lines}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_coverage_json(data: dict, root: Path) -> "CoverageData":
|
|
133
|
+
"""Extract executed and executable lines per file from ``coverage json`` output.
|
|
134
|
+
|
|
135
|
+
``coverage.py`` reports ``executed_lines`` and ``missing_lines`` per file; their
|
|
136
|
+
union is the set of statements coverage knows are executable. Paths are
|
|
137
|
+
normalised to repo-relative POSIX so they intersect with diff paths.
|
|
138
|
+
"""
|
|
139
|
+
executed: Dict[str, Set[int]] = {}
|
|
140
|
+
executable: Dict[str, Set[int]] = {}
|
|
141
|
+
files = data.get("files", {}) if isinstance(data, dict) else {}
|
|
142
|
+
for raw_path, payload in files.items():
|
|
143
|
+
if not isinstance(payload, dict):
|
|
144
|
+
continue
|
|
145
|
+
rel = _relativize(raw_path, root)
|
|
146
|
+
if rel is None:
|
|
147
|
+
continue
|
|
148
|
+
run = {int(n) for n in payload.get("executed_lines", []) or []}
|
|
149
|
+
miss = {int(n) for n in payload.get("missing_lines", []) or []}
|
|
150
|
+
executed[rel] = run
|
|
151
|
+
executable[rel] = run | miss
|
|
152
|
+
return CoverageData(executed=executed, executable=executable)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _relativize(raw_path: str, root: Path) -> Optional[str]:
|
|
156
|
+
candidate = Path(raw_path)
|
|
157
|
+
try:
|
|
158
|
+
if candidate.is_absolute():
|
|
159
|
+
rel = candidate.resolve().relative_to(root.resolve())
|
|
160
|
+
else:
|
|
161
|
+
rel = candidate
|
|
162
|
+
except ValueError:
|
|
163
|
+
# Outside the repo (site-packages, stdlib) — not a changed-file candidate.
|
|
164
|
+
return None
|
|
165
|
+
return rel.as_posix()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class CoverageData:
|
|
170
|
+
executed: Dict[str, Set[int]] = field(default_factory=dict)
|
|
171
|
+
executable: Dict[str, Set[int]] = field(default_factory=dict)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class DiffCoverageResult:
|
|
176
|
+
"""Outcome of intersecting changed lines with executed lines.
|
|
177
|
+
|
|
178
|
+
``measured`` is True only when there was a meaningful signal to compute — at
|
|
179
|
+
least one changed *executable* line. When False, callers must NOT treat the
|
|
180
|
+
result as evidence of a problem (false-positive discipline).
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
measured: bool
|
|
184
|
+
tool: str = ""
|
|
185
|
+
reason: str = ""
|
|
186
|
+
changed_executable_lines: int = 0
|
|
187
|
+
covered_changed_lines: int = 0
|
|
188
|
+
uncovered_by_file: Dict[str, List[int]] = field(default_factory=dict)
|
|
189
|
+
absent_files: List[str] = field(default_factory=list)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def ratio(self) -> float:
|
|
193
|
+
if self.changed_executable_lines == 0:
|
|
194
|
+
return 1.0
|
|
195
|
+
return self.covered_changed_lines / self.changed_executable_lines
|
|
196
|
+
|
|
197
|
+
def summary(self) -> str:
|
|
198
|
+
if not self.measured:
|
|
199
|
+
return f"diff coverage not measured ({self.reason})" if self.reason else "diff coverage not measured"
|
|
200
|
+
pct = round(self.ratio * 100)
|
|
201
|
+
base = (
|
|
202
|
+
f"{self.covered_changed_lines}/{self.changed_executable_lines} changed lines exercised "
|
|
203
|
+
f"({pct}%) via {self.tool or 'coverage'}"
|
|
204
|
+
)
|
|
205
|
+
if self.absent_files:
|
|
206
|
+
base += f"; not imported by tests: {', '.join(self.absent_files)}"
|
|
207
|
+
return base
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def intersect(
|
|
211
|
+
changed: Dict[str, Dict[int, str]],
|
|
212
|
+
coverage: CoverageData,
|
|
213
|
+
*,
|
|
214
|
+
tool: str = "coverage.py",
|
|
215
|
+
) -> DiffCoverageResult:
|
|
216
|
+
"""Intersect changed lines with executed lines to measure diff coverage.
|
|
217
|
+
|
|
218
|
+
``changed`` should already be filtered to measurable source files (e.g. ``.py``
|
|
219
|
+
non-test files). For each file present in the coverage report we use coverage's
|
|
220
|
+
authoritative executable-line set; for changed source files *absent* from the
|
|
221
|
+
report (never imported) we fall back to the conservative ``is_code_like``
|
|
222
|
+
heuristic and count those added lines as executable-but-uncovered.
|
|
223
|
+
"""
|
|
224
|
+
total_executable = 0
|
|
225
|
+
total_covered = 0
|
|
226
|
+
uncovered_by_file: Dict[str, List[int]] = {}
|
|
227
|
+
absent_files: List[str] = []
|
|
228
|
+
|
|
229
|
+
for path, line_map in changed.items():
|
|
230
|
+
changed_nums = set(line_map.keys())
|
|
231
|
+
if path in coverage.executable:
|
|
232
|
+
file_executable = changed_nums & coverage.executable[path]
|
|
233
|
+
file_covered = changed_nums & coverage.executed.get(path, set())
|
|
234
|
+
total_executable += len(file_executable)
|
|
235
|
+
total_covered += len(file_covered)
|
|
236
|
+
missing = sorted(file_executable - file_covered)
|
|
237
|
+
if missing:
|
|
238
|
+
uncovered_by_file[path] = missing
|
|
239
|
+
else:
|
|
240
|
+
# Absent from the coverage report -> the tests never loaded this file.
|
|
241
|
+
code_like = sorted(num for num, text in line_map.items() if is_code_like(text))
|
|
242
|
+
if code_like:
|
|
243
|
+
total_executable += len(code_like)
|
|
244
|
+
uncovered_by_file[path] = code_like
|
|
245
|
+
absent_files.append(path)
|
|
246
|
+
|
|
247
|
+
if total_executable == 0:
|
|
248
|
+
return DiffCoverageResult(
|
|
249
|
+
measured=False,
|
|
250
|
+
tool=tool,
|
|
251
|
+
reason="no changed executable lines to measure",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return DiffCoverageResult(
|
|
255
|
+
measured=True,
|
|
256
|
+
tool=tool,
|
|
257
|
+
changed_executable_lines=total_executable,
|
|
258
|
+
covered_changed_lines=total_covered,
|
|
259
|
+
uncovered_by_file=uncovered_by_file,
|
|
260
|
+
absent_files=absent_files,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def measurable_python_changes(changed: Dict[str, Dict[int, str]]) -> Dict[str, Dict[int, str]]:
|
|
265
|
+
"""Filter parsed diff lines to Python source files (non-test) coverage.py can measure."""
|
|
266
|
+
return {
|
|
267
|
+
path: lines
|
|
268
|
+
for path, lines in changed.items()
|
|
269
|
+
if path.endswith(".py") and not is_test_path(path)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def coverage_run_argv(
|
|
274
|
+
command_argv: List[str],
|
|
275
|
+
python: str,
|
|
276
|
+
*,
|
|
277
|
+
append: bool,
|
|
278
|
+
data_file: str,
|
|
279
|
+
source: str = ".",
|
|
280
|
+
) -> Optional[List[str]]:
|
|
281
|
+
"""Transform a test command's argv into a ``coverage run`` invocation.
|
|
282
|
+
|
|
283
|
+
Supports the common Python entry points. Returns ``None`` for commands that
|
|
284
|
+
cannot be instrumented (the caller then leaves diff coverage unmeasured rather
|
|
285
|
+
than guessing). ``python -c "<code>"`` is handled separately by the caller
|
|
286
|
+
because it must materialise a temp script first.
|
|
287
|
+
"""
|
|
288
|
+
if not command_argv:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
prefix = [python, "-m", "coverage", "run", f"--source={source}", f"--data-file={data_file}"]
|
|
292
|
+
if append:
|
|
293
|
+
prefix.append("-a")
|
|
294
|
+
|
|
295
|
+
head = Path(command_argv[0]).name.lower()
|
|
296
|
+
if head.endswith(".exe"): # Windows: python.exe / pytest.exe
|
|
297
|
+
head = head[:-4]
|
|
298
|
+
rest = command_argv[1:]
|
|
299
|
+
|
|
300
|
+
# python -m pytest / python -m unittest -> reuse the same module entry point.
|
|
301
|
+
if head in {"python", "python3", "py"} and len(rest) >= 2 and rest[0] == "-m":
|
|
302
|
+
module = rest[1]
|
|
303
|
+
if module in {"pytest", "unittest"}:
|
|
304
|
+
return [*prefix, "-m", module, *rest[2:]]
|
|
305
|
+
return None
|
|
306
|
+
# bare pytest -> run via the module entry point under coverage.
|
|
307
|
+
if head in {"pytest", "py.test"}:
|
|
308
|
+
return [*prefix, "-m", "pytest", *rest]
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def inline_python_code(command_argv: List[str]) -> Optional[str]:
|
|
313
|
+
"""Return the ``CODE`` of a ``python -c "CODE"`` command, else None.
|
|
314
|
+
|
|
315
|
+
DevCouncil's acceptance compiler and many planner ``expected_tests`` are inline
|
|
316
|
+
assertions (``python -c "import m; assert m.f()==1"``). These are exactly the
|
|
317
|
+
checks whose diff coverage matters, so they are instrumented via a temp script
|
|
318
|
+
(see :func:`coverage_run_script_argv`) rather than left unmeasured.
|
|
319
|
+
"""
|
|
320
|
+
if len(command_argv) < 3:
|
|
321
|
+
return None
|
|
322
|
+
head = Path(command_argv[0]).name.lower()
|
|
323
|
+
if head.endswith(".exe"):
|
|
324
|
+
head = head[:-4]
|
|
325
|
+
if head in {"python", "python3", "py"} and command_argv[1] == "-c":
|
|
326
|
+
return command_argv[2]
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def inline_script_content(code: str, root: Path) -> str:
|
|
331
|
+
"""Wrap inline ``-c`` code as a script that imports like ``python -c`` would.
|
|
332
|
+
|
|
333
|
+
``python -c`` puts the current working directory on ``sys.path``; a plain script
|
|
334
|
+
instead puts the script's own directory there. Since the temp script lives under
|
|
335
|
+
``.devcouncil/tmp`` we re-insert the repo root so ``import <module>`` resolves the
|
|
336
|
+
same way the original inline check did.
|
|
337
|
+
"""
|
|
338
|
+
return f"import sys\nsys.path.insert(0, {str(Path(root))!r})\n{code}\n"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def coverage_run_script_argv(
|
|
342
|
+
script_path: str,
|
|
343
|
+
python: str,
|
|
344
|
+
*,
|
|
345
|
+
append: bool,
|
|
346
|
+
data_file: str,
|
|
347
|
+
source: str = ".",
|
|
348
|
+
) -> List[str]:
|
|
349
|
+
"""A ``coverage run`` invocation for a materialised script (used for inline checks)."""
|
|
350
|
+
prefix = [python, "-m", "coverage", "run", f"--source={source}", f"--data-file={data_file}"]
|
|
351
|
+
if append:
|
|
352
|
+
prefix.append("-a")
|
|
353
|
+
return [*prefix, script_path]
|