Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions .github/workflows/python-coverage-pilot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ on:
- "backend/research_markup.py"
- "tests/test_text_identity.py"
- "tests/fixtures/text_identity_vectors.json"
- "scripts/changed_line_coverage.py"
- "tests/test_changed_line_coverage.py"
workflow_dispatch:

permissions:
Expand All @@ -28,6 +30,8 @@ jobs:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# PR merge first parent is the baseline for changed executable lines.
fetch-depth: 2
persist-credentials: false

- name: Set up Python
Expand Down Expand Up @@ -83,3 +87,24 @@ jobs:
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as out:
out.write(summary)
PY

- name: Report newly changed executable lines (informational)
shell: bash
run: |
set -euo pipefail
python -m unittest tests.test_changed_line_coverage
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
echo "No changed-line comparison on manual coverage runs." >> "$GITHUB_STEP_SUMMARY"
elif git rev-parse --verify 'HEAD^1^{commit}' >/dev/null 2>&1; then
patch="$RUNNER_TEMP/prks-python-changed.diff"
git diff --no-ext-diff --unified=0 HEAD^1 HEAD -- \
backend/domain/text_identity.py > "$patch"
python scripts/changed_line_coverage.py \
--format coveragepy \
--report "$RUNNER_TEMP/prks-coverage.json" \
--diff "$patch" \
--source backend/domain/text_identity.py \
--summary-file "$GITHUB_STEP_SUMMARY"
else
echo "Changed-line comparison unavailable (missing PR merge parent); no ratio inferred." >> "$GITHUB_STEP_SUMMARY"
fi
43 changes: 43 additions & 0 deletions .github/workflows/vitest-coverage-pilot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ on:
- "frontend-app/vite.config.ts"
- "frontend-app/package.json"
- "frontend-app/package-lock.json"
- "scripts/changed_line_coverage.py"
- "tests/test_changed_line_coverage.py"
workflow_dispatch:

permissions:
Expand All @@ -40,6 +42,8 @@ jobs:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# PR merge first parent is the changed-line comparison baseline.
fetch-depth: 2
persist-credentials: false

- name: Set up Node.js
Expand Down Expand Up @@ -68,6 +72,7 @@ jobs:
--coverage.include='src/{lifecycle/tab-leave,lifecycle/owner-resource,query/retry,workspace/transitions,workspace/invariants,workspace/tree,workspace/derive,workspace/commands,workspace/snapshot}.ts' \
--coverage.reporter=text \
--coverage.reporter=json-summary \
--coverage.reporter=json \
--coverage.reportsDirectory="$RUNNER_TEMP/prks-vitest-coverage"
node --input-type=module <<'NODE'
import { readFileSync, appendFileSync } from 'node:fs'
Expand Down Expand Up @@ -128,3 +133,41 @@ jobs:
console.log(summary)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary, 'utf8')
NODE

- name: Report newly changed executable lines (informational)
working-directory: frontend-app
shell: bash
run: |
set -euo pipefail
python3 -m unittest discover -s ../tests -p test_changed_line_coverage.py
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
echo "No changed-line comparison on manual coverage runs." >> "$GITHUB_STEP_SUMMARY"
elif git -C .. rev-parse --verify 'HEAD^1^{commit}' >/dev/null 2>&1; then
patch="$RUNNER_TEMP/prks-vitest-changed.diff"
git -C .. diff --no-ext-diff --unified=0 HEAD^1 HEAD -- \
frontend-app/src/lifecycle/tab-leave.ts \
frontend-app/src/lifecycle/owner-resource.ts \
frontend-app/src/query/retry.ts \
frontend-app/src/workspace/transitions.ts \
frontend-app/src/workspace/invariants.ts \
frontend-app/src/workspace/tree.ts \
frontend-app/src/workspace/derive.ts \
frontend-app/src/workspace/commands.ts \
frontend-app/src/workspace/snapshot.ts > "$patch"
python3 ../scripts/changed_line_coverage.py \
--format istanbul \
--report "$RUNNER_TEMP/prks-vitest-coverage/coverage-final.json" \
--diff "$patch" \
--source frontend-app/src/lifecycle/tab-leave.ts \
--source frontend-app/src/lifecycle/owner-resource.ts \
--source frontend-app/src/query/retry.ts \
--source frontend-app/src/workspace/transitions.ts \
--source frontend-app/src/workspace/invariants.ts \
--source frontend-app/src/workspace/tree.ts \
--source frontend-app/src/workspace/derive.ts \
--source frontend-app/src/workspace/commands.ts \
--source frontend-app/src/workspace/snapshot.ts \
--summary-file "$GITHUB_STEP_SUMMARY"
else
echo "Changed-line comparison unavailable (missing PR merge parent); no ratio inferred." >> "$GITHUB_STEP_SUMMARY"
fi
146 changes: 146 additions & 0 deletions scripts/changed_line_coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Informational changed-executable-line coverage for the two #651 B1 pilots.

This is deliberately *not* total/PR-wide coverage: only lines in the explicitly
instrumented files count. Non-executable diff lines are excluded and unavailable
data is reported as unavailable, never as a fabricated 0% or 100%.
"""

from __future__ import annotations

import argparse
import json
import re
from pathlib import Path

HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")


def added_lines(patch: str) -> dict[str, set[int]]:
"""Return new-side added line numbers from Git's zero-context diff."""
result: dict[str, set[int]] = {}
current: str | None = None
for line in patch.splitlines():
if line.startswith("+++ "):
name = line[4:]
current = name[2:] if name.startswith("b/") else None
elif current is not None:
match = HUNK.match(line)
if match:
start = int(match.group(1))
count = int(match.group(2)) if match.group(2) is not None else 1
if count:
result.setdefault(current, set()).update(range(start, start + count))
return result


def _find_entry(report: dict, source: str, kind: str) -> dict | None:
files = report["files"] if kind == "coveragepy" else {
key: value for key, value in report.items() if key != "total"
}
matches = [
value for key, value in files.items()
if key.replace("\\", "/") == source
or key.replace("\\", "/").endswith("/" + source)
]
if len(matches) > 1:
raise ValueError(f"Ambiguous coverage source: {source}")
return matches[0] if matches else None


def executable_lines(entry: dict, kind: str) -> tuple[set[int], set[int]] | None:
"""Return (executable, covered) lines, or None if detail is unavailable."""
if kind == "coveragepy":
if "executed_lines" not in entry or "missing_lines" not in entry:
return None
covered = set(map(int, entry["executed_lines"]))
missing = set(map(int, entry["missing_lines"]))
return covered | missing, covered
# Vitest's V8 provider uses Istanbul's JSON reporter. Unlike older
# Istanbul formats, coverage-final.json generally has statementMap/s
# but no precomputed line map. Combine statement hits on their start
# lines, using max so an uncovered statement cannot erase a covered one.
Comment thread
Fooftilly marked this conversation as resolved.
statements, hits = entry.get("statementMap"), entry.get("s")
if isinstance(statements, dict) and isinstance(hits, dict) and statements:
lines: dict[int, int] = {}
for statement_id, location in statements.items():
if statement_id not in hits:
return None
line = int(location["start"]["line"])
lines[line] = max(lines.get(line, 0), int(hits[statement_id]))
return set(lines), {line for line, count in lines.items() if count > 0}
# Older Istanbul JSON representations may include line hits directly.
lines = entry.get("l")
if not isinstance(lines, dict) or not lines:
return None
Comment thread
Fooftilly marked this conversation as resolved.
return {int(n) for n in lines}, {int(n) for n, count in lines.items() if count > 0}


def changed_line_report(
patch: str, report: dict, kind: str, sources: list[str]
) -> str:
"""Write a bounded Markdown summary without treating comments as misses."""
touched = added_lines(patch)
rows = [
"### Changed executable lines (instrumented pilot modules only)",
"",
"| Module | Added executable lines covered |",
"| --- | ---: |",
]
covered_total = measured_total = 0
unavailable = False
for source in sources:
added = touched.get(source, set())
if not added:
rows.append(f"| `{source}` | No added lines in this pilot |")
continue
entry = _find_entry(report, source, kind)
detail = executable_lines(entry, kind) if entry is not None else None
if detail is None:
unavailable = True
rows.append(f"| `{source}` | Unavailable (no line-level coverage data) |")
continue
executable, covered = detail
eligible = added & executable
hit = len(eligible & covered)
covered_total += hit
measured_total += len(eligible)
if eligible:
rows.append(f"| `{source}` | {hit}/{len(eligible)} ({hit / len(eligible):.0%}) |")
else:
rows.append(f"| `{source}` | No added executable lines |")
overall = (
"Unavailable for some changed pilot files"
if unavailable else
f"{covered_total}/{measured_total} ({covered_total / measured_total:.0%})"
if measured_total else "No added executable lines in pilot scope"
)
rows.extend([
f"| **Total (measurable pilot lines only)** | **{overall}** |",
"",
"Informational; no threshold. Counts only newly added executable lines in",
"the selected pilot modules, not total PR coverage, and excludes comments,",
"blank lines, deleted lines, uninstrumented modules and branch outcomes.",
"An unavailable source is never silently counted as covered.",
"",
])
return "\n".join(rows)


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--format", choices=("coveragepy", "istanbul"), required=True)
parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--diff", type=Path, required=True)
parser.add_argument("--source", action="append", required=True)
parser.add_argument("--summary-file", type=Path, required=True)
args = parser.parse_args()
patch = args.diff.read_text(encoding="utf-8")
report = json.loads(args.report.read_text(encoding="utf-8"))
summary = changed_line_report(patch, report, args.format, args.source)
print(summary)
with args.summary_file.open("a", encoding="utf-8") as output:
output.write(summary)


if __name__ == "__main__":
main()
125 changes: 125 additions & 0 deletions tests/test_changed_line_coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Synthetic, deterministic regression tests for focused changed-line coverage."""

from __future__ import annotations

import sys
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

from scripts.changed_line_coverage import added_lines, changed_line_report, executable_lines

PYTHON_SOURCE = "backend/domain/text_identity.py"
TS_SOURCE = "frontend-app/src/workspace/tree.ts"


def _diff(path: str, first: int, count: int) -> str:
return (
f"diff --git a/{path} b/{path}\n"
f"--- a/{path}\n+++ b/{path}\n"
f"@@ -1,0 +{first},{count} @@\n"
+ "".join("+line\n" for _ in range(count))
)


class ChangedLineCoverageTests(unittest.TestCase):
def test_zero_context_hunks_multiple_files_and_deletions(self):
patch = (
_diff(PYTHON_SOURCE, 8, 3)
+ f"@@ -16 +19 @@\n+replacement\n"

Check warning on line 32 in tests/test_changed_line_coverage.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_changed_line_coverage.py#L32

f-string is missing placeholders (F541)
+ f"diff --git a/docs/readme.md b/docs/readme.md\n"

Check warning on line 33 in tests/test_changed_line_coverage.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_changed_line_coverage.py#L33

f-string is missing placeholders (F541)
+ "--- a/docs/readme.md\n+++ b/docs/readme.md\n"
+ "@@ -10 +10,0 @@\n-deleted\n"
+ _diff(TS_SOURCE, 30, 1)
)
self.assertEqual(
added_lines(patch),
{PYTHON_SOURCE: {8, 9, 10, 19}, TS_SOURCE: {30}},
)

def test_python_ignores_nonexecutables_and_reports_partial_coverage(self):
patch = _diff(PYTHON_SOURCE, 8, 4)
report = {
"files": {
PYTHON_SOURCE: {
"executed_lines": [8, 14],
"missing_lines": [10, 15],
}
}
}
result = changed_line_report(patch, report, "coveragepy", [PYTHON_SOURCE])
self.assertIn("1/2 (50%)", result)
self.assertIn("excludes comments", result)

def test_istanbul_report_uses_line_hits_and_path_suffix(self):
patch = _diff(TS_SOURCE, 30, 4)
report = {
"/runner/work/prks/" + TS_SOURCE: {
"l": {"30": 5, "31": 0, "33": 2, "40": 0},
},
"total": {"lines": {}},
}
result = changed_line_report(patch, report, "istanbul", [TS_SOURCE])
self.assertIn("2/3 (67%)", result)

def test_vitest_actual_coverage_final_statement_map_without_line_hits(self):
# Istanbul's JSON reporter usually supplies statementMap + s, not l.
patch = _diff(TS_SOURCE, 30, 4)
report = {
"/runner/prks/" + TS_SOURCE: {
"statementMap": {
"0": {"start": {"line": 30, "column": 0}, "end": {"line": 30, "column": 8}},
"1": {"start": {"line": 31, "column": 0}, "end": {"line": 31, "column": 8}},
"2": {"start": {"line": 31, "column": 9}, "end": {"line": 31, "column": 20}},
"3": {"start": {"line": 33, "column": 0}, "end": {"line": 33, "column": 8}},
},
"s": {"0": 4, "1": 0, "2": 3, "3": 0},
}
}
result = changed_line_report(patch, report, "istanbul", [TS_SOURCE])
self.assertIn("2/3 (67%)", result)

def test_missing_istanbul_statement_hits_are_unavailable(self):
incomplete = {
"statementMap": {
"0": {"start": {"line": 30, "column": 0}, "end": {"line": 30, "column": 3}},
},
"s": {},
}
self.assertIsNone(executable_lines(incomplete, "istanbul"))

def test_unchanged_pilot_sources_do_not_claim_zero_coverage(self):
report = {"files": {}}
result = changed_line_report(_diff("docs/note.md", 1, 1), report, "coveragepy", [PYTHON_SOURCE])
self.assertIn("No added executable lines in pilot scope", result)
self.assertNotIn("(100%)", result)

def test_missing_changed_file_coverage_is_explicitly_unavailable(self):
result = changed_line_report(
_diff(PYTHON_SOURCE, 2, 1), {"files": {}}, "coveragepy", [PYTHON_SOURCE]
)
self.assertIn("Unavailable for some changed pilot files", result)
self.assertNotIn("0/1", result)

def test_coverage_without_line_level_detail_is_unavailable(self):
self.assertIsNone(executable_lines({"covered_lines": 1}, "coveragepy"))
self.assertIsNone(executable_lines({"lines": {"pct": 100}}, "istanbul"))

def test_ambiguous_coverage_paths_raise(self):
report = {
"files": {
"/runner/one/" + PYTHON_SOURCE: {"executed_lines": [], "missing_lines": [2]},
"/runner/two/" + PYTHON_SOURCE: {"executed_lines": [], "missing_lines": [2]},
}
}
with self.assertRaisesRegex(ValueError, "Ambiguous"):
changed_line_report(
_diff(PYTHON_SOURCE, 2, 1), report, "coveragepy", [PYTHON_SOURCE]
)


if __name__ == "__main__":
unittest.main()
Loading