Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a4be0c7
test: pytest evidence line skips the xdist worker header
geuben Aug 28, 2026
a2b7ecb
feat: Verdict.target_evidence — pytest extracts the first E-line of l…
geuben Aug 28, 2026
b464cf7
refactor: pytest evidence is the assertion line, not the xdist worker…
geuben Aug 28, 2026
dad64c6
test: pin empty pytest evidence when no E-line exists
geuben Aug 28, 2026
e04e009
test: xctest evidence line ignores console noise in the test window
geuben Aug 28, 2026
5817c6a
feat: xctest evidence is the first ': error:' line of the test's window
geuben Aug 28, 2026
1f1b40b
refactor: xctest evidence is the error line, not interleaved console …
geuben Aug 28, 2026
3c0c019
test: vitest evidence is the first failureMessage line
geuben Aug 28, 2026
248e550
feat: vitest evidence extracted from failureMessages[0]
geuben Aug 28, 2026
e0a5301
test: gradle evidence is the junit failure message line
geuben Aug 28, 2026
bebee75
feat: gradle evidence extracted from the failure element's message
geuben Aug 28, 2026
1df55d5
test: exec evidence is the last non-empty combined-output line
geuben Aug 28, 2026
78771ff
feat: exec evidence falls back to the last non-empty line
geuben Aug 28, 2026
9012b32
refactor: exec evidence falls back to the last non-empty output line
geuben Aug 28, 2026
53ad973
test: sensitivity check persists evidence_line on its ledger row
geuben Aug 28, 2026
619c0bd
feat: schema v7 — sensitivity_check.evidence_line stored at check time
geuben Aug 28, 2026
739ed8a
refactor: sensitivity check stores the adapter's evidence line in the…
geuben Aug 28, 2026
b2dc16f
test: observed line shows evidence_line, not the raw first line
geuben Aug 28, 2026
ddc6024
feat: friction log prefers evidence_line for the observed snippet
geuben Aug 28, 2026
8e68e09
test: empty evidence renders <no assertion line captured>
geuben Aug 28, 2026
8e03088
feat: render the no-assertion-line sentinel instead of wire noise
geuben Aug 28, 2026
a7004cc
refactor: legacy rows with NULL evidence keep the first-line fallback
geuben Aug 28, 2026
e6d5235
test: an over-long observed line keeps its tail
geuben Aug 28, 2026
146e153
feat: tail-keeping cap on the observed evidence line
geuben Aug 28, 2026
86db050
docs: friction log and PRD update for issue-68-sensitivity-evidence
geuben Aug 29, 2026
e63fee2
Merge branch 'main' into worktree-issue-68-sensitivity-evidence
geuben Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ For the passed-on-arrival case, which occurred in 4 of 8 executed cycles in the
| Command | Behaviour |
|---|---|
| `tdd sensitivity begin` | record `git diff` and the untracked-file set as the reference state |
| `tdd sensitivity check` | run the suite with the agent's mutation in place; require the target to now fail; record the mutation diff and the observed failure |
| `tdd sensitivity check` | run the suite with the agent's mutation in place; require the target to now fail; record the mutation diff and the observed failure; each adapter extracts a one-line `target_evidence` (the assertion line, not runner noise), stored in `sensitivity_check.evidence_line` (schema v7) and rendered as the `observed:` snippet in the friction log |
| `tdd sensitivity end` | `git checkout --` the mutated tracked paths, then assert the resulting `git diff` is byte-identical to the reference; emit `restore_mismatch` on any difference |

- **R8.4** A cycle that passed on arrival cannot reach `CLOSED` without a completed sensitivity
Expand Down Expand Up @@ -802,7 +802,7 @@ depend on any of them being installed.
### 13.1 Storage
- **R13.1** SQLite, single file, append-only for invocations and events.
- **R13.2** Schema versioned and migrated. The schema is the long-lived asset; the transport is not.
Current schema version: **3** (v3 adds the `advance_claim` table, R9.23).
Current schema version: **7** (v7 adds `sensitivity_check.evidence_line TEXT` for per-adapter assertion-line evidence; earlier milestones: v3 adds `advance_claim`, v4–v6 are intermediate columns).

### 13.2 Location
- **R13.3** **One ledger per repository**, in a per-user data directory keyed by the repository's
Expand Down
1 change: 1 addition & 0 deletions src/tddcli/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class Verdict:
target: str | None = None
target_outcome: str = NOT_FOUND
target_failure: str = ""
target_evidence: str = ""
passed: list[str] = field(default_factory=list)
failed: list[str] = field(default_factory=list)
duration_ms: int = 0
Expand Down
3 changes: 3 additions & 0 deletions src/tddcli/adapters/exec_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ def run(self, target: str | None = None) -> Verdict:
if target == qualified:
verdict.target_outcome = FAILED
verdict.target_failure = clip_failure(combined)
verdict.target_evidence = next(
(ln for ln in reversed(combined.splitlines()) if ln.strip()), ""
)

verdict.duration_ms = int((time.monotonic() - started) * 1000)
return verdict
6 changes: 5 additions & 1 deletion src/tddcli/adapters/gradle_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,11 @@ def run(self, target: str | None = None) -> Verdict:
verdict.target_outcome = PASSED
elif target in failed:
verdict.target_outcome = FAILED
verdict.target_failure = failures.get(target, "test failed")
failure_text = failures.get(target, "test failed")
verdict.target_failure = failure_text
verdict.target_evidence = next(
(ln for ln in failure_text.splitlines() if ln.strip()), ""
)
# else NOT_FOUND (default): the suite ran but never produced this id

return verdict
13 changes: 12 additions & 1 deletion src/tddcli/adapters/pytest_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from __future__ import annotations

import json
import re
import shlex
import tempfile
from pathlib import Path
Expand Down Expand Up @@ -148,7 +149,9 @@ def run(self, target: str | None = None) -> Verdict:
if hit is not None:
verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
call = hit.get("call") or hit.get("setup") or {}
verdict.target_failure = clip_failure(str(call.get("longrepr", "")))
longrepr = str(call.get("longrepr", ""))
verdict.target_failure = clip_failure(longrepr)
verdict.target_evidence = self._evidence_line(longrepr)
else:
target_file = native.split("::", 1)[0]
if any(c == target_file or c.startswith(target_file) for c in uncollectable):
Expand All @@ -160,6 +163,14 @@ def run(self, target: str | None = None) -> Verdict:
verdict.target_outcome = NOT_FOUND
return verdict

@staticmethod
def _evidence_line(longrepr: str) -> str:
for line in longrepr.splitlines():
m = re.match(r"^E\s+(.*)", line)
if m:
return m.group(1)
return ""

@staticmethod
def _collector_error(collectors: list[dict], target_file: str) -> str:
for collector in collectors:
Expand Down
7 changes: 6 additions & 1 deletion src/tddcli/adapters/vitest_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,13 @@ def run(self, target: str | None = None) -> Verdict:
self.normalise_id(self._id_for(suite.get("name", ""), t["fullName"]))
== ntarget
):
messages = t.get("failureMessages", [])
verdict.target_failure = "\n".join(
clip_failure(m, 600) for m in t.get("failureMessages", [])[:3]
clip_failure(m, 600) for m in messages[:3]
)
first_msg = messages[0] if messages else ""
verdict.target_evidence = next(
(ln for ln in first_msg.splitlines() if ln.strip()), ""
)
return verdict

Expand Down
11 changes: 10 additions & 1 deletion src/tddcli/adapters/xctest_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,9 @@ def run(self, target: str | None = None) -> Verdict:
verdict.target_outcome = PASSED
elif target in verdict.failed:
verdict.target_outcome = FAILED
verdict.target_failure = self._failure_for(combined, self.strip(target))
window = self._failure_for(combined, self.strip(target))
verdict.target_failure = window
verdict.target_evidence = self._evidence_line(window)
# else NOT_FOUND (default)

return verdict
Expand All @@ -289,6 +291,13 @@ def _build_errors(self, combined: str) -> str:
if ": error:" in line or "** BUILD FAILED **" in line
)

@staticmethod
def _evidence_line(window: str) -> str:
for line in window.splitlines():
if ": error:" in line:
return line
return ""

def _failure_for(self, combined: str, native_id: str) -> str:
"""Capture the assertion lines between 'started' and 'failed' for one test.

Expand Down
4 changes: 3 additions & 1 deletion src/tddcli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1134,16 +1134,18 @@ def cmd_sensitivity(args) -> Envelope:
projects = json.loads(cycle["projects"])

if args.step == "check":
outcomes, _, _, failure_text = engine.run_projects(
outcomes, _, verdicts, failure_text = engine.run_projects(
projects, targets, cycle, "SENSITIVITY", False
)
# A mutation that breaks collection also proves the test depends on the code.
bites = bool(outcomes) and all(o in (FAILED, NOT_COLLECTED) for o in outcomes.values())
evidence = next((v.target_evidence for v in verdicts if v.target_evidence), "")
ledger.update(
"sensitivity_check",
open_check["id"],
mutation_diff=gitutil.diff_text(worktree)[:20000],
observed_failure=failure_text[:4000],
evidence_line=evidence,
)
if not bites:
return Envelope(
Expand Down
5 changes: 4 additions & 1 deletion src/tddcli/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from datetime import datetime, timedelta, timezone
from pathlib import Path

SCHEMA_VERSION = 6
SCHEMA_VERSION = 7


class LedgerVersionError(RuntimeError):
Expand All @@ -37,6 +37,8 @@ class LedgerVersionError(RuntimeError):
4: "ALTER TABLE baseline ADD COLUMN source TEXT NOT NULL DEFAULT 'probed';",
# v5 -> v6 added ancillary_files column to plan_contract; ALTER TABLE covers old ledgers.
5: "ALTER TABLE plan_contract ADD COLUMN ancillary_files TEXT NOT NULL DEFAULT '[]';",
# v6 -> v7 added evidence_line column to sensitivity_check; ALTER TABLE covers old ledgers.
6: "ALTER TABLE sensitivity_check ADD COLUMN evidence_line TEXT;",
}

SCHEMA = """
Expand Down Expand Up @@ -195,6 +197,7 @@ class LedgerVersionError(RuntimeError):
reference_untracked TEXT NOT NULL,
mutation_diff TEXT,
observed_failure TEXT,
evidence_line TEXT,
restored_ok INTEGER,
opened_at TEXT NOT NULL,
closed_at TEXT
Expand Down
11 changes: 9 additions & 2 deletions src/tddcli/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,15 @@ def friction_log(ledger: Ledger, run) -> str:
if sens:
a("- **Sensitivity check:** verified, restore byte-identical")
if sens["observed_failure"]:
snippet = sens["observed_failure"].strip().splitlines()
a(f" - observed: `{snippet[0][:160] if snippet else ''}`")
evidence = sens["evidence_line"]
if evidence:
capped = ("…" + evidence[-160:]) if len(evidence) > 160 else evidence
a(f" - observed: `{capped}`")
elif evidence == "":
a(" - observed: <no assertion line captured>")
else:
snippet = sens["observed_failure"].strip().splitlines()
a(f" - observed: `{snippet[0][:160] if snippet else ''}`")

commits = ledger.all(
"SELECT * FROM commit_record WHERE cycle_id = ? ORDER BY id", (cycle["id"],)
Expand Down
121 changes: 121 additions & 0 deletions tasks/friction-logs/issue-68-sensitivity-evidence-friction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Implementation Friction Log: tasks/issue-68-sensitivity-evidence.md

- Run: 12
- Executor: claude-sonnet-4-6 (source: transcript)
- Plan blob: `34ede538eab3a01b75b9d9cdfde5c86000318fe0` (declared)
- Started: 2026-08-28T16:04:38.652188+00:00 Ended: 2026-08-28T17:21:10.569424+00:00 Outcome: complete
- Baseline failures at start: tddcli=0

## Plan fidelity

- Declared cycles: 11
- Delivered: 11 Skipped: 0
- Never reached: none
- Human interventions: 0

### Cycle 11: a long observed line is capped keeping the tail, not the head _(standard)_
- **Target:** `tddcli::tests/test_sensitivity_evidence.py::test_long_evidence_is_capped_keeping_the_tail`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** failed (as expected)
- **Commits:**
- `e6d5235ad` [red] test: an over-long observed line keeps its tail (1 files)
- `146e15314` [green] feat: tail-keeping cap on the observed evidence line (1 files)

### Cycle 10: legacy rows with NULL evidence keep the first-line fallback _(standard)_
- **Target:** `tddcli::tests/test_sensitivity_evidence.py::test_null_evidence_falls_back_to_first_observed_line`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'SENSITIVITY': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** passed (**passed**)
- **Sensitivity check:** verified, restore byte-identical
- observed: `repo = PosixPath('/private/var/folders/zl/3010c_557g5_2rm9tyqsc03h0000gp/T/pytest-of-headless-coding/pytest-239/test_null_evidence_falls_back_0/workspace')`
- **Commits:**
- `a7004cc9f` [refactor] refactor: legacy rows with NULL evidence keep the first-line fallback (1 files)
- **Event — red_first_violation:** ["tddcli::tests/test_sensitivity_evidence.py::test_null_evidence_falls_back_to_first_observed_line"]

### Cycle 9: empty evidence renders an explicit no-assertion-line sentinel _(standard)_
- **Target:** `tddcli::tests/test_sensitivity_evidence.py::test_empty_evidence_renders_the_sentinel`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** failed (as expected)
- **Commits:**
- `8e68e09f0` [red] test: empty evidence renders <no assertion line captured> (1 files)
- `8e0308820` [green] feat: render the no-assertion-line sentinel instead of wire noise (1 files)

### Cycle 8: friction log observed line renders the stored evidence line _(standard)_
- **Target:** `tddcli::tests/test_sensitivity_evidence.py::test_friction_log_observed_line_is_the_evidence_line`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** failed (as expected)
- **Commits:**
- `b2dc16f52` [red] test: observed line shows evidence_line, not the raw first line (1 files)
- `ddc60246f` [green] feat: friction log prefers evidence_line for the observed snippet (1 files)

### Cycle 7: sensitivity check stores the adapter's evidence line in the ledger _(standard)_
- **Target:** `tddcli::tests/test_sensitivity_evidence.py::test_sensitivity_check_records_the_evidence_line`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2}
- **First run outcome:** failed (as expected)
- **Commits:**
- `53ad9734d` [red] test: sensitivity check persists evidence_line on its ledger row (1 files)
- `619c0bd3d` [green] feat: schema v7 — sensitivity_check.evidence_line stored at check time (2 files)
- `739ed8a0f` [refactor] refactor: sensitivity check stores the adapter's evidence line in the ledger (1 files)

### Cycle 6: exec evidence falls back to the last non-empty output line _(standard)_
- **Target:** `tddcli::tests/test_evidence_extraction.py::test_exec_evidence_is_the_last_nonempty_output_line`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2}
- **First run outcome:** failed (as expected)
- **Commits:**
- `1df55d55d` [red] test: exec evidence is the last non-empty combined-output line (1 files)
- `78771ff01` [green] feat: exec evidence falls back to the last non-empty line (1 files)
- `9012b32fe` [refactor] refactor: exec evidence falls back to the last non-empty output line (1 files)

### Cycle 5: gradle evidence is the first line of the junit failure message _(standard)_
- **Target:** `tddcli::tests/test_evidence_extraction.py::test_gradle_evidence_is_the_first_failure_message_line`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** failed (as expected)
- **Commits:**
- `e0a5301d1` [red] test: gradle evidence is the junit failure message line (1 files)
- `bebee75f9` [green] feat: gradle evidence extracted from the failure element's message (1 files)

### Cycle 4: vitest evidence is the first line of the first failure message _(standard)_
- **Target:** `tddcli::tests/test_evidence_extraction.py::test_vitest_evidence_is_the_first_failure_message_line`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** failed (as expected)
- **Commits:**
- `3c0c01982` [red] test: vitest evidence is the first failureMessage line (1 files)
- `248e55040` [green] feat: vitest evidence extracted from failureMessages[0] (1 files)

### Cycle 3: xctest evidence is the error line, not interleaved console noise _(standard)_
- **Target:** `tddcli::tests/test_evidence_extraction.py::test_xctest_evidence_is_the_error_line_not_console_noise`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2}
- **First run outcome:** failed (as expected)
- **Commits:**
- `e04e0098a` [red] test: xctest evidence line ignores console noise in the test window (1 files)
- `5817c6ab2` [green] feat: xctest evidence is the first ': error:' line of the test's window (1 files)
- `1f1b40b52` [refactor] refactor: xctest evidence is the error line, not interleaved console noise (1 files)

### Cycle 2: pytest evidence is empty when longrepr has no assertion line _(pin)_
- **Target:** `tddcli::tests/test_evidence_extraction.py::test_pytest_evidence_is_empty_when_no_assertion_line_exists`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_PIN': 1, 'SENSITIVITY': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** passed (as expected)
- **Sensitivity check:** verified, restore byte-identical
- observed: `tmp_path = PosixPath('/private/var/folders/zl/3010c_557g5_2rm9tyqsc03h0000gp/T/pytest-of-headless-coding/pytest-162/test_pytest_evidence_is_empty_0')`
- **Commits:**
- `dad64c656` [pin] test: pin empty pytest evidence when no E-line exists (1 files)

### Cycle 1: pytest evidence is the assertion line, not the xdist worker header _(standard)_
- **Target:** `tddcli::tests/test_evidence_extraction.py::test_pytest_evidence_is_the_assertion_line_not_the_xdist_header`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 2, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2}
- **First run outcome:** failed (as expected)
- **Commits:**
- `a4be0c744` [red] test: pytest evidence line skips the xdist worker header (1 files)
- `a2b7ecb47` [green] feat: Verdict.target_evidence — pytest extracts the first E-line of longrepr (2 files)
- `b464cf742` [refactor] refactor: pytest evidence is the assertion line, not the xdist worker header (1 files)

Loading