diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 442548f..d0f4f31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: run: | shellcheck -s bash --severity=warning \ launch.sh dashboard.sh harvest.sh \ - costs.sh progress.sh \ + costs.sh coverage.sh progress.sh \ lib/*.sh lib/drivers/*.sh \ tests/*.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d14799..122c08a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- **Feature: `coverage.sh` post-hoc read coverage.** Parse agent + session logs for `Read` tool calls and simple shell reads + (`sed -n`, `nl -ba | sed -n`, `cat`, `head`, `tail`), merge the + line ranges per file and agent, and report coverage as a table or + JSON. `--targets` lists never-read and partially read target + files, `--prompt-out` turns the gaps into a follow-up prompt, and + `--fail-under` gives CI an exit code. Works on containers or on + saved logs via `--logs`. Closes #65. + - **Rename: `claude-swarm` to `swarm-core`.** Update project links, installation examples, issue-template branding, pre-push stash labels, and test fixtures. Document URL updates for existing clones diff --git a/USAGE.md b/USAGE.md index b3400f5..8501295 100644 --- a/USAGE.md +++ b/USAGE.md @@ -449,6 +449,7 @@ Unit tests (no Docker or API key): ./tests/test_activity_filter.sh # Activity stream parsing. ./tests/test_config.sh # Config parsing. ./tests/test_costs.sh # Cost aggregation. +./tests/test_coverage.sh # Read coverage parsing. ./tests/test_dashboard.sh # Dashboard rendering. ./tests/test_drivers.sh # Agent driver interface. ./tests/test_format.sh # Formatting helpers. @@ -684,6 +685,43 @@ Dashboard columns: - **Tok/s** — output tokens per second of API time. - **Time** — cumulative wall-clock duration. +## Coverage analysis + +Upfront partitioning (roles, lock files, per-group prompts) says +what agents should read; `coverage.sh` reports what they actually +read, from their session logs. + +```bash +./coverage.sh # Table, from containers. +./coverage.sh --json # JSON. +./coverage.sh --targets targets.txt \ + --prompt-out prompts/followup.md # Gap report + prompt. +./coverage.sh --logs ./saved-logs # Offline, no Docker. +``` + +Read ranges come from `Read` tool calls (preferring the result's +`startLine`/`numLines` metadata) and from a deterministic subset of +shell reads: `sed -n 'A,Bp' F`, `nl -ba F | sed -n 'A,Bp'`, +`cat [-n] F`, `head -n N F`, and `tail -n N F`. Other read-like +shell commands (`grep`, `rg`, `awk`, ...) are counted as +unresolved and never guessed, so reported coverage is a lower +bound. + +`--targets` takes one git pathspec per line (for example +`src/**/*.rs`); `#` starts a comment. The report then lists +targets that were never read and the unread line ranges of +partially read ones. `--prompt-out FILE` writes those gaps as a +follow-up prompt for another swarm run or an interactive session, +and `--fail-under PCT` exits `2` when any target is below `PCT` +percent read. Paths are made relative by stripping `/workspace/` +(override with `--strip`), and line counts come from `--root` +(default: the repository root). + +Logs are copied from numbered agent and post-process containers, +so run it before `./launch.sh` cleanup removes them, or save +`agent_logs/` and use `--logs`. Parsing currently targets the +Claude Code `stream-json` format. + ## Drivers Agent drivers decouple the harness from any specific CLI tool. diff --git a/coverage.sh b/coverage.sh new file mode 100755 index 0000000..28bf7e7 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,229 @@ +#!/bin/bash +set -euo pipefail + +# Post-hoc read coverage: which files and lines did agents read? +# Usage: ./coverage.sh [OPTIONS] + +SWARM_DIR="$(cd "$(dirname "$0")" && pwd)" + +usage() { + cat <&2; usage >&2; exit 1 ;; + esac +done + +source "$SWARM_DIR/lib/check-deps.sh" +check_deps git jq awk +# shellcheck source=lib/coverage.sh +source "$SWARM_DIR/lib/coverage.sh" + +if { [ -n "$PROMPT_OUT" ] || [ -n "$FAIL_UNDER" ]; } \ + && [ -z "$TARGETS" ]; then + echo "ERROR: --prompt-out and --fail-under need --targets." >&2 + exit 1 +fi +if [ -n "$TARGETS" ] && [ ! -f "$TARGETS" ]; then + echo "ERROR: targets file not found: $TARGETS" >&2 + exit 1 +fi + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +if [ -z "$ROOT" ]; then + ROOT="$REPO_ROOT" +fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Collect logs from containers when no --logs directory is given. +if [ ${#LOG_DIRS[@]} -eq 0 ]; then + check_deps docker + # shellcheck source=lib/project.sh + source "$SWARM_DIR/lib/project.sh" + PROJECT="$(swarm_project_id "$(basename "$REPO_ROOT")")" + containers=$(docker ps -a --filter "name=${PROJECT}-agent-" \ + --format '{{.Names}}' 2>/dev/null | sort -V || true) + if [ -z "$containers" ]; then + echo "No swarm containers found." >&2 + exit 1 + fi + for cname in $containers; do + mkdir -p "$WORK/logs/$cname" + docker cp "${cname}:/workspace/agent_logs/." \ + "$WORK/logs/$cname/" >/dev/null 2>&1 || true + done + LOG_DIRS=("$WORK/logs") +fi + +log_count=0 +: > "$WORK/rows.tsv" +for dir in "${LOG_DIRS[@]}"; do + while IFS= read -r log; do + base="$(basename "$log")" + agent="${base#agent_}" + agent="${agent%%_*}" + coverage_extract_ranges "$log" "$agent" "$STRIP" \ + >> "$WORK/rows.tsv" || true + log_count=$((log_count + 1)) + done < <(find "$dir" -type f -name 'agent_*.log' | sort) +done + +unresolved=$(awk -F'\t' '$5 == "unresolved"' "$WORK/rows.tsv" \ + | wc -l | tr -d ' ') +reads=$(awk -F'\t' '$5 != "unresolved"' "$WORK/rows.tsv" \ + | wc -l | tr -d ' ') +agents=$(awk -F'\t' '{ print $1 }' "$WORK/rows.tsv" | sort -u \ + | paste -sd, -) + +coverage_resolve_ranges "$ROOT" < "$WORK/rows.tsv" \ + | coverage_merge > "$WORK/files.tsv" + +# Target gap analysis. +: > "$WORK/targets.tsv" +if [ -n "$TARGETS" ]; then + while IFS= read -r t; do + row=$(awk -F'\t' -v p="$t" '$1 == p' "$WORK/files.tsv") + total=$(coverage_file_lines "$ROOT" "$t") + if [ -z "$row" ]; then + printf '%s\t0\t%s\t0.0\t\t\t1-%s\n' "$t" "$total" "$total" \ + >> "$WORK/targets.tsv" + else + ranges=$(printf '%s' "$row" | cut -f6) + gaps=$(coverage_gaps "$ranges" "$total") + printf '%s\t%s\n' "$row" "$gaps" >> "$WORK/targets.tsv" + fi + done < <(coverage_expand_targets "$ROOT" "$TARGETS") +fi + +n_targets=$(wc -l < "$WORK/targets.tsv" | tr -d ' ') +n_full=$(awk -F'\t' '$7 == ""' "$WORK/targets.tsv" \ + | wc -l | tr -d ' ') +n_unread=$(awk -F'\t' '$2 == 0' "$WORK/targets.tsv" \ + | wc -l | tr -d ' ') +n_partial=$((n_targets - n_full - n_unread)) + +if $JSON_MODE; then + jq -n \ + --argjson logs "$log_count" \ + --argjson reads "$reads" \ + --argjson unresolved "$unresolved" \ + --arg agents "$agents" \ + --rawfile files "$WORK/files.tsv" \ + --rawfile targets "$WORK/targets.tsv" \ + --argjson has_targets "$([ -n "$TARGETS" ] && echo true \ + || echo false)" ' + def rows($s): $s | split("\n") | map(select(. != "") + | split("\t")); + def num: if . == "?" then null else tonumber end; + def list: if . == "" then [] else split(",") end; + { + logs: $logs, reads: $reads, unresolved: $unresolved, + agents: ($agents | list), + files: [rows($files)[] | { + path: .[0], covered: (.[1] | tonumber), total: (.[2] | num), + percent: (.[3] | num), agents: (.[4] | list), + ranges: (.[5] | list)}] + } + + (if $has_targets then {targets: [rows($targets)[] | { + path: .[0], covered: (.[1] | tonumber), total: (.[2] | num), + percent: (.[3] | num), agents: (.[4] | list), + unread: (.[6] | list)}]} else {} end)' +else + printf 'Coverage: %s log(s), %s read(s), %s unresolved shell' \ + "$log_count" "$reads" "$unresolved" + printf ' read(s), agents: %s\n\n' "${agents:-none}" + printf '%-50s %13s %7s %s\n' "File" "Lines" "%" "Agents" + printf '%s\n' "$(printf '%.0s-' $(seq 1 79))" + while IFS=$'\t' read -r path cov tot pct ags _ranges; do + printf '%-50s %13s %7s %s\n' "$path" "${cov}/${tot}" \ + "$pct" "$ags" + done < "$WORK/files.tsv" + if [ -n "$TARGETS" ]; then + printf '\nTargets: %s file(s), %s fully read, %s partial,' \ + "$n_targets" "$n_full" "$n_partial" + printf ' %s never read\n' "$n_unread" + if [ "$n_unread" -gt 0 ]; then + echo "Never read:" + awk -F'\t' '$2 == 0 { print " " $1 }' "$WORK/targets.tsv" + fi + fi +fi + +if [ -n "$PROMPT_OUT" ]; then + { + echo "# Coverage follow-up" + echo + echo "A previous swarm run did not read the target code below." + echo "Continue the same task, starting with files never read," + echo "then the unread line ranges of partially read files." + echo + if [ "$n_unread" -gt 0 ]; then + echo "## Never read" + echo + awk -F'\t' '$2 == 0 { print "- `" $1 "`" }' \ + "$WORK/targets.tsv" + echo + fi + if [ "$n_partial" -gt 0 ]; then + echo "## Partially read (unread lines)" + echo + awk -F'\t' '$2 > 0 && $7 != "" { + gsub(",", ", ", $7); print "- `" $1 "`: " $7 }' \ + "$WORK/targets.tsv" + echo + fi + } > "$PROMPT_OUT" +fi + +if [ -n "$FAIL_UNDER" ]; then + below=$(awk -F'\t' -v f="$FAIL_UNDER" \ + '$4 == "?" || $4 + 0 < f + 0' "$WORK/targets.tsv" \ + | wc -l | tr -d ' ') + if [ "$below" -gt 0 ]; then + exit 2 + fi +fi diff --git a/lib/coverage.sh b/lib/coverage.sh new file mode 100644 index 0000000..60d5106 --- /dev/null +++ b/lib/coverage.sh @@ -0,0 +1,241 @@ +#!/bin/bash + +# Post-hoc read coverage for swarm sessions. +# +# Parses agent JSONL session logs (Claude Code `stream-json`), +# extracts the file line ranges each agent actually read, and +# merges them into per-file coverage. Sourced by coverage.sh and +# by tests/test_coverage.sh; no Docker access here. +# +# Range sources: +# - `Read` tool calls. The tool result metadata +# (`tool_use_result.file.startLine/numLines`) is preferred; the +# call's `offset`/`limit` input is the fallback. +# - `Bash` commands in a deterministic subset: +# `sed -n 'A,Bp' F`, `nl -ba F | sed -n 'A,Bp'`, `cat [-n] F`, +# `head -n N F`, `head -N F`, `tail -n N F`. Segments joined by +# `&&` or `;` are parsed independently. Other read-like commands +# are counted as unresolved and never guessed. +# +# Range rows are TSV: agent, path, start, end, source. `end` is +# `EOF` for whole-file reads; `start` is `-N` for `tail -n N`. + +# Emit range rows for one log file. +# Args: +coverage_extract_ranges() { + local logfile="$1" agent="$2" strip="${3:-}" + local q="[\"']?" f="(?

\\S+)" + local re_nl="^nl -ba ${f}\\s*\\|\\s*sed -n ${q}(?[0-9]+)," + re_nl+="(?[0-9]+)p${q}\$" + local re_sed="^sed -n ${q}(?[0-9]+),(?[0-9]+)p${q} ${f}\$" + local re_cat="^cat( -n)? ${f}\$" + local re_head="^head -(n ?)?(?[0-9]+) ${f}\$" + local re_tail="^tail -n ?(?[0-9]+) ${f}\$" + local re_any="^(cat|sed|head|tail|nl|less|more|awk|grep|rg|bat)\\b" + jq -r -s --arg agent "$agent" --arg strip "$strip" \ + --arg re_nl "$re_nl" --arg re_sed "$re_sed" \ + --arg re_cat "$re_cat" --arg re_head "$re_head" \ + --arg re_tail "$re_tail" --arg re_any "$re_any" ' + def strip_p: + if ($strip != "" and startswith($strip)) + then .[($strip | length):] else . end; + def unq: gsub("^[\"\u0027]|[\"\u0027]$"; ""); + def m($re): [capture($re)] | .[0]; + def seg_ranges: + gsub("^\\s+|\\s+$"; "") as $s + | if ($s | m($re_nl)) then ($s | m($re_nl)) + | [(.p | unq), .a, .b, "nl"] + elif ($s | m($re_sed)) then ($s | m($re_sed)) + | [(.p | unq), .a, .b, "sed"] + elif ($s | m($re_cat)) then ($s | m($re_cat)) + | [(.p | unq), "1", "EOF", "cat"] + elif ($s | m($re_head)) then ($s | m($re_head)) + | [(.p | unq), "1", .n, "head"] + elif ($s | m($re_tail)) then ($s | m($re_tail)) + | [(.p | unq), ("-" + .n), "EOF", "tail"] + elif ($s | test($re_any)) then ["-", "0", "0", "unresolved"] + else empty end; + (map(select(.type == "user") + | . as $u + | (.message.content // [])[]? + | select(type == "object" and .type == "tool_result") + | {key: .tool_use_id, + value: {meta: ($u.tool_use_result // {}), + err: (.is_error // false)}}) + | from_entries) as $res + | .[] + | select(.type == "assistant") + | (.message.content // [])[]? + | select(type == "object" and .type == "tool_use") + | . as $t + | ($res[$t.id] // {meta: {}, err: false}) as $r + | if $t.name == "Read" and ($r.err | not) then + (($r.meta | objects | .file) // {}) as $f + | ($f.filePath // $t.input.file_path) as $p + | select($p != null) + | ($f.startLine // $t.input.offset // 1) as $s + | (if ($f.numLines // 0) > 0 then $s + $f.numLines - 1 + elif ($t.input.limit // 0) > 0 + then $s + $t.input.limit - 1 + else "EOF" end) as $e + | [$agent, ($p | strip_p), ($s | tostring), ($e | tostring), + "read"] + elif $t.name == "Bash" and ($r.err | not) then + ($t.input.command // "") + | [splits("&&|;|\n")] + | .[] + | seg_ranges + | [$agent, (.[0] | if . == "-" then . else strip_p end), + .[1], .[2], .[3]] + else empty end + | @tsv + ' "$logfile" +} + +# Print the line count of /, or "?" when unreadable. +# Args: +coverage_file_lines() { + local root="$1" path="$2" full + case "$path" in + /*) full="$path" ;; + *) full="${root%/}/$path" ;; + esac + if [ -f "$full" ] && [ -r "$full" ]; then + awk 'END { print NR }' "$full" + else + echo "?" + fi +} + +# Resolve EOF/tail rows against file sizes and drop unresolved rows. +# Reads range rows on stdin; writes rows with numeric start/end. +# Args: +coverage_resolve_ranges() { + local root="$1" agent path start end src total i n + # Parallel indexed arrays, not `declare -A`: /bin/bash on macOS + # is 3.2, which has no associative arrays. + local -a size_paths=() size_totals=() + while IFS=$'\t' read -r agent path start end src; do + if [ "$src" = "unresolved" ] || [ "$path" = "-" ]; then + continue + fi + total="" + n=${#size_paths[@]} + for (( i = 0; i < n; i++ )); do + if [ "${size_paths[i]}" = "$path" ]; then + total="${size_totals[i]}" + break + fi + done + if [ -z "$total" ]; then + total="$(coverage_file_lines "$root" "$path")" + size_paths[n]="$path" + size_totals[n]="$total" + fi + if [ "$end" = "EOF" ]; then + if [ "$total" = "?" ]; then + continue + fi + end="$total" + fi + if [ "${start:0:1}" = "-" ]; then + start=$(( total - ${start:1} + 1 )) + if [ "$start" -lt 1 ]; then + start=1 + fi + fi + if [ "$total" != "?" ] && [ "$end" -gt "$total" ]; then + end="$total" + fi + if [ "$end" -lt "$start" ]; then + continue + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$agent" "$path" "$start" "$end" "$src" "$total" + done +} + +# Merge resolved rows into per-file coverage. +# Output TSV: path, covered, total, percent, agents, ranges. +# `ranges` is a comma-separated list of merged A-B intervals. +coverage_merge() { + sort -t$'\t' -k2,2 -k3,3n | awk -F'\t' ' + function flush( pct) { + if (cur == "") { return } + if (s != "") { covered += e - s + 1; add_range(s, e) } + pct = (tot == "?" || tot == 0) ? "?" \ + : sprintf("%.1f", 100 * covered / tot) + printf "%s\t%d\t%s\t%s\t%s\t%s\n", \ + cur, covered, tot, pct, agents, ranges + } + function add_range(a, b) { + ranges = ranges (ranges == "" ? "" : ",") a "-" b + } + { + if ($2 != cur) { + flush() + cur = $2; tot = $6; covered = 0; s = ""; e = "" + agents = ""; ranges = ""; delete seen + } + if (!($1 in seen)) { + seen[$1] = 1 + agents = agents (agents == "" ? "" : ",") $1 + } + if (s == "") { s = $3; e = $4 } + else if ($3 <= e + 1) { if ($4 > e) { e = $4 } } + else { covered += e - s + 1; add_range(s, e); s = $3; e = $4 } + } + END { flush() } + ' | while IFS=$'\t' read -r path cov tot pct ags ranges; do + ags="$(printf '%s\n' "${ags//,/$'\n'}" | sort -V | paste -sd, -)" + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$path" "$cov" "$tot" "$pct" "$ags" "$ranges" + done +} + +# Expand target pathspecs (one per line, `#` comments allowed) into +# tracked files under . Non-git roots fall back to shell globs. +# Args: +coverage_expand_targets() { + local root="${1%/}" targets="$2" line f is_git=false + if git -C "$root" rev-parse --git-dir >/dev/null 2>&1; then + is_git=true + fi + while IFS= read -r line || [ -n "$line" ]; do + line="${line%%#*}" + line="$(printf '%s' "$line" | sed 's/[[:space:]]*$//')" + if [ -z "$line" ]; then + continue + fi + if $is_git; then + git -C "$root" ls-files -- "$line" + continue + fi + for f in "$root"/$line; do + if [ -f "$f" ]; then + printf '%s\n' "${f#"$root"/}" + fi + done + done < "$targets" | sort -u +} + +# Complement of merged ranges within [1, total]: "A-B,C-D" or "". +# Args: +coverage_gaps() { + local ranges="$1" total="$2" + awk -v r="$ranges" -v t="$total" 'BEGIN { + n = split(r, parts, ","); next_line = 1; out = "" + for (i = 1; i <= n; i++) { + if (parts[i] == "") { continue } + split(parts[i], ab, "-") + if (ab[1] > next_line) { + out = out (out == "" ? "" : ",") next_line "-" (ab[1] - 1) + } + if (ab[2] + 1 > next_line) { next_line = ab[2] + 1 } + } + if (t != "?" && next_line <= t) { + out = out (out == "" ? "" : ",") next_line "-" t + } + print out + }' +} diff --git a/tests/test_coverage.sh b/tests/test_coverage.sh new file mode 100755 index 0000000..a687508 --- /dev/null +++ b/tests/test_coverage.sh @@ -0,0 +1,179 @@ +#!/bin/bash +set -euo pipefail + +# shellcheck source=_test_env.sh +source "$(dirname "${BASH_SOURCE[0]}")/_test_env.sh" + +# Unit tests for lib/coverage.sh and coverage.sh --logs. +# No Docker or API key required. Log fixtures follow the Claude +# Code stream-json shape: tool_use in assistant messages, and +# tool_result plus top-level tool_use_result in user messages. + +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" +SWARM_DIR="$(cd "$TESTS_DIR/.." && pwd)" +# shellcheck source=../lib/coverage.sh +source "$SWARM_DIR/lib/coverage.sh" + +PASS=0 +FAIL=0 +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo " PASS: ${label}" + PASS=$((PASS + 1)) + else + echo " FAIL: ${label}" + echo " expected: ${expected}" + echo " actual: ${actual}" + FAIL=$((FAIL + 1)) + fi +} + +# Emit an assistant tool_use line. +# Args: +tool_use() { + jq -cn --arg id "$1" --arg name "$2" --argjson input "$3" \ + '{type: "assistant", message: {content: [ + {type: "tool_use", id: $id, name: $name, input: $input}]}}' +} + +# Emit a user tool_result line. +# Args: [is_error] +tool_result() { + jq -cn --arg id "$1" --argjson tur "$2" \ + --argjson err "${3:-false}" \ + '{type: "user", message: {content: [ + {type: "tool_result", tool_use_id: $id, content: "x", + is_error: $err}]}, tool_use_result: $tur}' +} + +ROOT="$TMPDIR/src" +mkdir -p "$ROOT/lib" "$TMPDIR/logs" +seq 1 100 | sed 's/^/line /' > "$ROOT/lib/a.go" +seq 1 10 | sed 's/^/line /' > "$ROOT/lib/b.go" +seq 1 5 | sed 's/^/line /' > "$ROOT/lib/never.go" + +# ============================================================ +echo "=== 1. Read tool: metadata preferred over input ===" + +LOG1="$TMPDIR/logs/agent_1_abc123_1700000000.log" +{ + tool_use r1 Read '{"file_path":"/workspace/lib/a.go","offset":10, + "limit":5}' + tool_result r1 '{"type":"text","file":{"filePath": + "/workspace/lib/a.go","startLine":10,"numLines":11, + "totalLines":100}}' + tool_use r2 Read '{"file_path":"/workspace/lib/b.go"}' + tool_result r2 '{"type":"text"}' + tool_use r3 Read '{"file_path":"/workspace/lib/a.go","offset":90, + "limit":5}' + tool_result r3 '{}' true +} > "$LOG1" + +rows=$(coverage_extract_ranges "$LOG1" 1 /workspace/) +assert_eq "metadata range wins" \ + "1 lib/a.go 10 20 read" "$(sed -n 1p <<< "$rows")" +assert_eq "no metadata or limit reads to EOF" \ + "1 lib/b.go 1 EOF read" "$(sed -n 2p <<< "$rows")" +assert_eq "errored Read is ignored" "2" "$(wc -l <<< "$rows" | tr -d ' ')" + +# ============================================================ +echo "=== 2. Shell reads: deterministic subset ===" + +LOG2="$TMPDIR/logs/agent_2_def456_1700000001.log" +{ + tool_use b1 Bash \ + '{"command":"sed -n '"'"'30,40p'"'"' /workspace/lib/a.go"}' + tool_result b1 '{"stdout":"x"}' + tool_use b2 Bash '{"command":"nl -ba lib/a.go | sed -n '"'"'45,50p'"'"'"}' + tool_result b2 '{"stdout":"x"}' + tool_use b3 Bash '{"command":"head -n 3 lib/b.go && tail -n 2 lib/b.go"}' + tool_result b3 '{"stdout":"x"}' + tool_use b4 Bash '{"command":"cat lib/b.go; grep -n foo lib/a.go"}' + tool_result b4 '{"stdout":"x"}' + tool_use b5 Bash '{"command":"make test"}' + tool_result b5 '{"stdout":"x"}' +} > "$LOG2" + +rows=$(coverage_extract_ranges "$LOG2" 2 /workspace/) +assert_eq "sed -n range with prefix strip" \ + "2 lib/a.go 30 40 sed" "$(sed -n 1p <<< "$rows")" +assert_eq "nl -ba | sed -n range" \ + "2 lib/a.go 45 50 nl" "$(sed -n 2p <<< "$rows")" +assert_eq "head -n in first segment" \ + "2 lib/b.go 1 3 head" "$(sed -n 3p <<< "$rows")" +assert_eq "tail -n in second segment" \ + "2 lib/b.go -2 EOF tail" "$(sed -n 4p <<< "$rows")" +assert_eq "cat whole file" \ + "2 lib/b.go 1 EOF cat" "$(sed -n 5p <<< "$rows")" +assert_eq "grep counted as unresolved" \ + "2 - 0 0 unresolved" "$(sed -n 6p <<< "$rows")" +assert_eq "non-read command ignored" "6" \ + "$(wc -l <<< "$rows" | tr -d ' ')" + +# ============================================================ +echo "=== 3. Resolve and merge ===" + +merged=$( { coverage_extract_ranges "$LOG1" 1 /workspace/ + coverage_extract_ranges "$LOG2" 2 /workspace/; } \ + | coverage_resolve_ranges "$ROOT" | coverage_merge) +assert_eq "a.go merged ranges and agents" \ + "lib/a.go 28 100 28.0 1,2 10-20,30-40,45-50" \ + "$(grep '^lib/a.go' <<< "$merged")" +assert_eq "b.go fully covered by two agents" \ + "lib/b.go 10 10 100.0 1,2 1-10" \ + "$(grep '^lib/b.go' <<< "$merged")" + +# ============================================================ +echo "=== 4. Gaps ===" + +assert_eq "gaps inside and after ranges" "1-9,21-29,41-44,51-100" \ + "$(coverage_gaps "10-20,30-40,45-50" 100)" +assert_eq "no gaps when fully covered" "" "$(coverage_gaps "1-10" 10)" +assert_eq "adjacent ranges leave no gap" "" \ + "$(coverage_gaps "1-4,5-10" 10)" + +# ============================================================ +echo "=== 5. CLI with --logs, --targets, --json, --prompt-out ===" + +printf 'lib/*.go\n# comment\n' > "$TMPDIR/targets.txt" +json=$("$SWARM_DIR/coverage.sh" --logs "$TMPDIR/logs" --root "$ROOT" \ + --targets "$TMPDIR/targets.txt" --json \ + --prompt-out "$TMPDIR/followup.md") +assert_eq "json log count" "2" "$(jq '.logs' <<< "$json")" +assert_eq "json unresolved count" "1" "$(jq '.unresolved' <<< "$json")" +assert_eq "json never-read target" '["1-5"]' \ + "$(jq -c '.targets[] | select(.path == "lib/never.go") | .unread' \ + <<< "$json")" +assert_eq "json partial target gaps" '["1-9","21-29","41-44","51-100"]' \ + "$(jq -c '.targets[] | select(.path == "lib/a.go") | .unread' \ + <<< "$json")" +assert_eq "prompt lists never-read file" "- \`lib/never.go\`" \ + "$(grep 'never.go' "$TMPDIR/followup.md")" +assert_eq "prompt lists partial gaps" \ + "- \`lib/a.go\`: 1-9, 21-29, 41-44, 51-100" \ + "$(grep 'a.go' "$TMPDIR/followup.md")" + +rc=0 +"$SWARM_DIR/coverage.sh" --logs "$TMPDIR/logs" --root "$ROOT" \ + --targets "$TMPDIR/targets.txt" --fail-under 50 >/dev/null || rc=$? +assert_eq "--fail-under exits 2 below threshold" "2" "$rc" + +rc=0 +"$SWARM_DIR/coverage.sh" --logs "$TMPDIR/logs" --root "$ROOT" \ + --prompt-out "$TMPDIR/x.md" >/dev/null 2>&1 || rc=$? +assert_eq "--prompt-out without --targets is rejected" "1" "$rc" + +table=$("$SWARM_DIR/coverage.sh" --logs "$TMPDIR/logs" --root "$ROOT" \ + --targets "$TMPDIR/targets.txt") +assert_eq "table target summary" \ + "Targets: 3 file(s), 1 fully read, 1 partial, 1 never read" \ + "$(grep '^Targets:' <<< "$table")" + +# ============================================================ +echo "" +echo "${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ]