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.
Files changed (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. 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]