doesitfire 0.1.2__py3-none-any.whl

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.
@@ -0,0 +1,24 @@
1
+ # Agent sandbox image for doesitfire. CLI versions are pinned on purpose:
2
+ # their JSON event formats change between releases.
3
+ FROM node:26.13.0-bookworm-slim
4
+
5
+ ARG CLAUDE_CODE_VERSION=2.1.317
6
+ ARG CODEX_VERSION=0.166.0
7
+ ARG OPENCODE_VERSION=1.19.5
8
+
9
+ RUN npm install -g --no-audit --no-fund \
10
+ @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
11
+ @openai/codex@${CODEX_VERSION} \
12
+ opencode-ai@${OPENCODE_VERSION} \
13
+ && npm cache clean --force \
14
+ && apt-get update && apt-get install -y --no-install-recommends ca-certificates git ripgrep \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ COPY egress-proxy.mjs /opt/doesitfire/egress-proxy.mjs
18
+ COPY entrypoint.sh /opt/doesitfire/entrypoint.sh
19
+ RUN chmod 0755 /opt/doesitfire/entrypoint.sh
20
+
21
+ RUN mkdir -p /home/node/work && chown -R node:node /home/node
22
+ USER node
23
+ WORKDIR /home/node
24
+ ENTRYPOINT ["/opt/doesitfire/entrypoint.sh"]
@@ -0,0 +1,113 @@
1
+ // Minimal egress proxy: the only way out of the internal Docker network.
2
+ // Allows HTTP CONNECT (HTTPS) and plain HTTP forwarding to an allowlist of
3
+ // host:port pairs given in DOESITFIRE_ALLOW (comma separated). Everything
4
+ // else gets 403. No logging of request bodies.
5
+ import http from "node:http";
6
+ import net from "node:net";
7
+
8
+ const allow = new Set(
9
+ (process.env.DOESITFIRE_ALLOW || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean),
10
+ );
11
+ const port = Number(process.env.DOESITFIRE_PROXY_PORT || 8888);
12
+ // Ollama's OpenAI Responses endpoint drops input messages with role "developer", which is
13
+ // where Codex puts its skill list. For local Ollama only (plain HTTP), rewrite them to
14
+ // "system", which Ollama keeps. HTTPS traffic to paid APIs is tunnelled and never touched.
15
+ const developerAsSystem = process.env.DOESITFIRE_DEVELOPER_AS_SYSTEM === "1";
16
+
17
+ function rewriteDeveloper(buf) {
18
+ try {
19
+ const body = JSON.parse(buf.toString("utf8"));
20
+ if (!Array.isArray(body.input)) return buf;
21
+ let changed = false;
22
+ for (const item of body.input) {
23
+ if (item && item.role === "developer") {
24
+ item.role = "system";
25
+ changed = true;
26
+ }
27
+ }
28
+ return changed ? Buffer.from(JSON.stringify(body), "utf8") : buf;
29
+ } catch {
30
+ return buf;
31
+ }
32
+ }
33
+
34
+ function allowed(host, p) {
35
+ return allow.has(`${String(host).toLowerCase()}:${p}`);
36
+ }
37
+
38
+ const server = http.createServer((req, res) => {
39
+ req.on("error", () => {});
40
+ res.on("error", () => {});
41
+ let url;
42
+ try {
43
+ url = new URL(req.url);
44
+ } catch {
45
+ res.writeHead(400).end("absolute URL required\n");
46
+ return;
47
+ }
48
+ const p = url.port || (url.protocol === "https:" ? 443 : 80);
49
+ if (url.protocol !== "http:" || !allowed(url.hostname, p)) {
50
+ console.error(`deny ${req.method} ${url.hostname}:${p}`);
51
+ res.writeHead(403).end("blocked by doesitfire egress proxy\n");
52
+ return;
53
+ }
54
+ console.error(`allow ${req.method} ${url.hostname}:${p}${url.pathname}`);
55
+ const headers = { ...req.headers };
56
+ delete headers["proxy-connection"];
57
+ delete headers["proxy-authorization"];
58
+ const forward = (body) => {
59
+ if (body) {
60
+ headers["content-length"] = String(body.length);
61
+ delete headers["transfer-encoding"];
62
+ }
63
+ const upstream = http.request(
64
+ { host: url.hostname, port: p, method: req.method, path: url.pathname + url.search, headers },
65
+ (up) => {
66
+ res.writeHead(up.statusCode || 502, up.headers);
67
+ up.pipe(res);
68
+ },
69
+ );
70
+ upstream.on("error", () => {
71
+ if (!res.headersSent) res.writeHead(502);
72
+ res.end();
73
+ });
74
+ res.on("close", () => upstream.destroy());
75
+ if (body) upstream.end(body);
76
+ else req.pipe(upstream);
77
+ };
78
+ if (developerAsSystem && req.method === "POST" && url.pathname.endsWith("/responses")) {
79
+ const chunks = [];
80
+ req.on("data", (c) => chunks.push(c));
81
+ req.on("end", () => forward(rewriteDeveloper(Buffer.concat(chunks))));
82
+ } else {
83
+ forward(null);
84
+ }
85
+ });
86
+
87
+ server.on("connect", (req, socket, head) => {
88
+ socket.on("error", () => {});
89
+ const [host, p] = String(req.url).split(":");
90
+ if (!allowed(host, p || 443)) {
91
+ console.error(`deny CONNECT ${req.url}`);
92
+ socket.end("HTTP/1.1 403 Forbidden\r\n\r\n");
93
+ return;
94
+ }
95
+ console.error(`allow CONNECT ${req.url}`);
96
+ const upstream = net.connect(Number(p || 443), host, () => {
97
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
98
+ upstream.write(head);
99
+ upstream.pipe(socket);
100
+ socket.pipe(upstream);
101
+ });
102
+ upstream.on("error", () => socket.destroy());
103
+ socket.on("error", () => upstream.destroy());
104
+ socket.on("close", () => upstream.destroy());
105
+ });
106
+
107
+ server.on("clientError", (err, socket) => socket.destroy());
108
+ // A proxy must never die because one client hung up mid-stream.
109
+ process.on("uncaughtException", (err) => console.error(`proxy error: ${err.code || err.message}`));
110
+
111
+ server.listen(port, "0.0.0.0", () => {
112
+ console.error(`egress proxy on :${port}, allow=${[...allow].join(",") || "(nothing)"}`);
113
+ });
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ # Copies the skills mounted read-only at /skills into the discovery directory
3
+ # of the agent under test (DOESITFIRE_SKILLS_DIR, relative to a fresh
4
+ # workspace), writes optional agent config, then runs the agent command.
5
+ set -eu
6
+ WORK=/home/node/work
7
+ mkdir -p "$WORK"
8
+ cd "$WORK"
9
+ git init -q . >/dev/null 2>&1 || true
10
+ if [ -d /skills ] && [ -n "${DOESITFIRE_SKILLS_DIR:-}" ]; then
11
+ mkdir -p "$WORK/$DOESITFIRE_SKILLS_DIR"
12
+ cp -R /skills/. "$WORK/$DOESITFIRE_SKILLS_DIR/"
13
+ fi
14
+ exec "$@"
doesitfire/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """User-facing errors. The CLI prints these without a traceback."""
2
+
3
+
4
+ class DoesItFireError(Exception):
5
+ """An error with a message that tells the user what to do next."""
6
+
7
+ exit_code = 2
8
+
9
+
10
+ class BudgetExceeded(DoesItFireError):
11
+ """Raised when --max-cost would be exceeded; carries the partial results."""
12
+
13
+ exit_code = 3
14
+
15
+ def __init__(self, message: str, results: dict | None = None):
16
+ super().__init__(message)
17
+ self.results = results
doesitfire/html.py ADDED
@@ -0,0 +1,26 @@
1
+ """Static, self-contained HTML report (one file, no external JS)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from importlib import resources
7
+ from pathlib import Path
8
+
9
+ from .metrics import overall, summarize
10
+
11
+
12
+ def render_html(results: dict) -> str:
13
+ summary = summarize(results)
14
+ payload = {
15
+ "meta": {k: v for k, v in results.items() if k != "trials"},
16
+ "summary": summary,
17
+ "overall": overall(summary),
18
+ "runs": len(results.get("trials", [])),
19
+ }
20
+ data = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
21
+ template = (resources.files("doesitfire") / "templates" / "report.html").read_text(encoding="utf-8")
22
+ return template.replace("__DOESITFIRE_DATA__", data)
23
+
24
+
25
+ def write_html(results: dict, path: Path) -> None:
26
+ path.write_text(render_html(results), encoding="utf-8")
doesitfire/metrics.py ADDED
@@ -0,0 +1,117 @@
1
+ """Turn raw trials into the activation matrix.
2
+
3
+ Per agent x skill:
4
+ - recall = fired runs / valid runs, over should-fire prompts
5
+ - false-fire rate = fired runs / valid runs, over should-not-fire prompts
6
+ - precision = true fires / (true fires + false fires)
7
+ Runs that errored (timeout, API error) are excluded from every denominator and counted apart.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import asdict, dataclass, field
13
+
14
+
15
+ @dataclass
16
+ class Cell:
17
+ agent: str
18
+ skill: str
19
+ fire_runs: int = 0 # valid runs on should-fire prompts
20
+ true_fires: int = 0
21
+ nofire_runs: int = 0 # valid runs on should-not-fire prompts
22
+ false_fires: int = 0
23
+ errors: int = 0
24
+ prompts: list[dict] = field(default_factory=list)
25
+
26
+ @property
27
+ def recall(self) -> float | None:
28
+ return self.true_fires / self.fire_runs if self.fire_runs else None
29
+
30
+ @property
31
+ def false_fire_rate(self) -> float | None:
32
+ return self.false_fires / self.nofire_runs if self.nofire_runs else None
33
+
34
+ @property
35
+ def precision(self) -> float | None:
36
+ fired = self.true_fires + self.false_fires
37
+ return self.true_fires / fired if fired else None
38
+
39
+ def to_dict(self) -> dict:
40
+ d = asdict(self)
41
+ d.update(recall=self.recall, false_fire_rate=self.false_fire_rate, precision=self.precision)
42
+ return d
43
+
44
+
45
+ def _add(cell: Cell, t: dict) -> None:
46
+ if t.get("error"):
47
+ cell.errors += 1
48
+ return
49
+ if t["expect"] == "fire":
50
+ cell.fire_runs += 1
51
+ cell.true_fires += bool(t["fired"])
52
+ else:
53
+ cell.nofire_runs += 1
54
+ cell.false_fires += bool(t["fired"])
55
+
56
+
57
+ def summarize(results: dict) -> dict:
58
+ agents = [a["name"] for a in results["agents"]]
59
+ skills = [s["name"] for s in results["skills"]]
60
+ cells = {(a, s): Cell(a, s) for a in agents for s in skills}
61
+ totals = {a: Cell(a, "*") for a in agents}
62
+ per_prompt: dict[tuple, dict] = {}
63
+ for t in results["trials"]:
64
+ key = (t["agent"], t["skill"])
65
+ if key not in cells:
66
+ continue
67
+ _add(cells[key], t)
68
+ _add(totals[t["agent"]], t)
69
+ pk = (t["agent"], t["skill"], t["expect"], t["prompt"])
70
+ row = per_prompt.setdefault(
71
+ pk, {"prompt": t["prompt"], "expect": t["expect"], "fired": 0, "runs": 0, "errors": 0, "other": []}
72
+ )
73
+ if t.get("error"):
74
+ row["errors"] += 1
75
+ else:
76
+ row["runs"] += 1
77
+ row["fired"] += bool(t["fired"])
78
+ for other in t.get("activated") or []:
79
+ if other != t["skill"] and other not in row["other"]:
80
+ row["other"].append(other)
81
+ for (a, s, _e, _p), row in per_prompt.items():
82
+ cells[(a, s)].prompts.append(row)
83
+ return {
84
+ "agents": agents,
85
+ "skills": skills,
86
+ "cells": {f"{a}|{s}": c.to_dict() for (a, s), c in cells.items()},
87
+ "totals": {a: c.to_dict() for a, c in totals.items()},
88
+ }
89
+
90
+
91
+ def overall(summary: dict) -> dict:
92
+ """Pooled figures across agents and skills (used by the badge)."""
93
+ tf = sum(c["true_fires"] for c in summary["totals"].values())
94
+ fr = sum(c["fire_runs"] for c in summary["totals"].values())
95
+ ff = sum(c["false_fires"] for c in summary["totals"].values())
96
+ nr = sum(c["nofire_runs"] for c in summary["totals"].values())
97
+ return {
98
+ "recall": tf / fr if fr else None,
99
+ "false_fire_rate": ff / nr if nr else None,
100
+ "precision": tf / (tf + ff) if (tf + ff) else None,
101
+ }
102
+
103
+
104
+ def pct(value: float | None) -> str:
105
+ return "n/a" if value is None else f"{round(value * 100):d}%"
106
+
107
+
108
+ def check_thresholds(summary: dict, min_recall: float | None, max_false_fire: float | None) -> list[str]:
109
+ """Return human-readable failures for cells below the thresholds."""
110
+ failures = []
111
+ for key, c in summary["cells"].items():
112
+ agent, skill = key.split("|", 1)
113
+ if min_recall is not None and c["recall"] is not None and c["recall"] < min_recall:
114
+ failures.append(f"{skill} on {agent}: recall {pct(c['recall'])} < {pct(min_recall)}")
115
+ if max_false_fire is not None and c["false_fire_rate"] is not None and c["false_fire_rate"] > max_false_fire:
116
+ failures.append(f"{skill} on {agent}: false-fire rate {pct(c['false_fire_rate'])} > {pct(max_false_fire)}")
117
+ return failures
@@ -0,0 +1,121 @@
1
+ """Model provider presets and which agent can talk to which provider.
2
+
3
+ Keys are read from environment variables only and are never written to disk,
4
+ printed, or passed on a command line: Docker receives the variable *name* and
5
+ copies the value from this process's environment.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+ from urllib.parse import urlparse
13
+
14
+ from .errors import DoesItFireError
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Provider:
19
+ name: str
20
+ openai_base: str # OpenAI-compatible base URL (".../v1")
21
+ key_env: str | None # env var holding the API key; None for local models
22
+ anthropic_base: str | None = None # Anthropic Messages-compatible base, if any
23
+ responses_api: bool = False # serves the OpenAI Responses API (needed by Codex)
24
+ local: bool = False
25
+
26
+ @property
27
+ def paid(self) -> bool:
28
+ return not self.local
29
+
30
+
31
+ def _ollama_root() -> str:
32
+ host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").strip().rstrip("/")
33
+ if "://" not in host:
34
+ host = "http://" + host
35
+ parsed = urlparse(host)
36
+ if parsed.hostname in ("0.0.0.0", "", None):
37
+ host = f"{parsed.scheme}://localhost:{parsed.port or 11434}"
38
+ return host
39
+
40
+
41
+ def get_provider(name: str) -> Provider:
42
+ if name == "ollama":
43
+ root = _ollama_root()
44
+ return Provider("ollama", root + "/v1", None, anthropic_base=root, responses_api=True, local=True)
45
+ if name == "openrouter":
46
+ return Provider(
47
+ "openrouter",
48
+ "https://openrouter.ai/api/v1",
49
+ "OPENROUTER_API_KEY",
50
+ anthropic_base="https://openrouter.ai/api",
51
+ responses_api=True,
52
+ )
53
+ if name == "openai":
54
+ return Provider("openai", "https://api.openai.com/v1", "OPENAI_API_KEY", responses_api=True)
55
+ if name == "mistral":
56
+ return Provider("mistral", "https://api.mistral.ai/v1", "MISTRAL_API_KEY")
57
+ if name == "anthropic":
58
+ return Provider(
59
+ "anthropic",
60
+ "https://api.anthropic.com/v1",
61
+ "ANTHROPIC_API_KEY",
62
+ anthropic_base="https://api.anthropic.com",
63
+ )
64
+ raise DoesItFireError(f"Unknown provider '{name}'. Use one of: {', '.join(PROVIDER_NAMES)}.")
65
+
66
+
67
+ PROVIDER_NAMES = ("ollama", "openrouter", "openai", "mistral", "anthropic")
68
+
69
+ # Which providers each agent CLI can use, and through which API.
70
+ SUPPORTED = {
71
+ "claude-code": ("anthropic", "ollama", "openrouter"), # Anthropic Messages API
72
+ "codex": ("ollama", "openai", "openrouter"), # OpenAI Responses API
73
+ "opencode": ("ollama", "openrouter", "openai", "mistral", "anthropic"),
74
+ }
75
+
76
+
77
+ def check_supported(agent: str, provider: str) -> None:
78
+ if provider not in SUPPORTED[agent]:
79
+ why = {
80
+ "claude-code": "Claude Code speaks the Anthropic Messages API",
81
+ "codex": "Codex needs the OpenAI Responses API",
82
+ }.get(agent, "")
83
+ raise DoesItFireError(
84
+ f"Agent '{agent}' cannot use provider '{provider}'"
85
+ + (f" ({why})" if why else "")
86
+ + f". Supported: {', '.join(SUPPORTED[agent])}."
87
+ )
88
+
89
+
90
+ def resolve_key(agent: str, provider: Provider) -> tuple[str, str] | None:
91
+ """Return (env var name, value) for the key this agent needs, or None for local models."""
92
+ if provider.key_env is None:
93
+ return None
94
+ candidates = [provider.key_env]
95
+ if agent == "claude-code" and provider.name == "anthropic":
96
+ # A Claude subscription token from `claude setup-token` works too.
97
+ candidates.append("CLAUDE_CODE_OAUTH_TOKEN")
98
+ for var in candidates:
99
+ value = os.environ.get(var, "").strip()
100
+ if value:
101
+ return var, value
102
+ raise DoesItFireError(
103
+ f"Missing API key for provider '{provider.name}' (agent '{agent}'). "
104
+ f"Set the {' or '.join(candidates)} environment variable, or switch the agent to "
105
+ "provider 'ollama' in doesitfire.yaml."
106
+ )
107
+
108
+
109
+ def container_url(url: str) -> str:
110
+ """Rewrite localhost URLs so they work from inside a container."""
111
+ parsed = urlparse(url)
112
+ if parsed.hostname in ("localhost", "127.0.0.1", "::1"):
113
+ netloc = "host.docker.internal" + (f":{parsed.port}" if parsed.port else "")
114
+ return parsed._replace(netloc=netloc).geturl()
115
+ return url
116
+
117
+
118
+ def host_port(url: str) -> str:
119
+ parsed = urlparse(url)
120
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
121
+ return f"{parsed.hostname}:{port}"
doesitfire/report.py ADDED
@@ -0,0 +1,175 @@
1
+ """Terminal matrix, JUnit XML, badge SVG and shields.io endpoint JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from xml.sax.saxutils import escape, quoteattr
8
+
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from .metrics import overall, pct, summarize
14
+
15
+ OK, WARN, ERR = "#11813c", "#a45e00", "#c22826"
16
+ ACCENT = "#a1319b"
17
+
18
+
19
+ def recall_color(v: float | None) -> str:
20
+ if v is None:
21
+ return "dim"
22
+ return "green" if v >= 0.8 else "yellow" if v >= 0.5 else "red"
23
+
24
+
25
+ def false_color(v: float | None) -> str:
26
+ if v is None:
27
+ return "dim"
28
+ return "green" if v <= 0.1 else "yellow" if v <= 0.3 else "red"
29
+
30
+
31
+ def recorded_banner(results: dict) -> str:
32
+ models = ", ".join(sorted({f"{a['provider']}/{a['model']}" for a in results["agents"]}))
33
+ host = results.get("host") or {}
34
+ when = (results.get("started_at") or "")[:10]
35
+ parts = [f"{when}", f"doesitfire {results.get('version')}", models]
36
+ if host.get("hardware"):
37
+ parts.append(host["hardware"])
38
+ return " | ".join(p for p in parts if p)
39
+
40
+
41
+ def print_matrix(results: dict, console: Console | None = None) -> dict:
42
+ console = console or Console()
43
+ summary = summarize(results)
44
+ if results.get("recorded"):
45
+ console.print(Text(f"RECORDED RESULT, not a live run: {recorded_banner(results)}", style="bold magenta"))
46
+ table = Table(title="Skill activation (fired runs / valid runs)", title_justify="left", expand=False)
47
+ table.add_column("skill", style="bold")
48
+ for agent in results["agents"]:
49
+ label = agent["name"] + (" (experimental)" if agent.get("experimental") else "")
50
+ table.add_column(f"{label}\n[dim]{agent['model']}[/dim]", justify="left")
51
+ for skill in summary["skills"]:
52
+ row: list[Text | str] = [skill]
53
+ for agent in summary["agents"]:
54
+ c = summary["cells"][f"{agent}|{skill}"]
55
+ t = Text()
56
+ t.append(f"fires {c['true_fires']}/{c['fire_runs']}", style=recall_color(c["recall"]))
57
+ t.append("\n")
58
+ t.append(f"false {c['false_fires']}/{c['nofire_runs']}", style=false_color(c["false_fire_rate"]))
59
+ if c["errors"]:
60
+ t.append(f"\nerrors {c['errors']}", style="red")
61
+ row.append(t)
62
+ table.add_row(*row)
63
+ table.add_section()
64
+ for label, key, colorer in (
65
+ ("recall", "recall", recall_color),
66
+ ("false-fire rate", "false_fire_rate", false_color),
67
+ ("precision", "precision", recall_color),
68
+ ):
69
+ row = [Text(label, style="dim")]
70
+ for agent in summary["agents"]:
71
+ v = summary["totals"][agent][key]
72
+ row.append(Text(pct(v), style=colorer(v)))
73
+ table.add_row(*row)
74
+ console.print(table)
75
+ cost = results.get("cost_usd") or 0.0
76
+ reps = {a.get("repeats", results.get("repeats")) for a in results["agents"]}
77
+ rep_text = (
78
+ f"{reps.pop()} repeat(s) per prompt"
79
+ if len(reps) == 1
80
+ else "repeats " + ", ".join(f"{a['name']} {a.get('repeats')}" for a in results["agents"])
81
+ )
82
+ console.print(
83
+ f"[dim]{len(results['trials'])} runs, {rep_text}, "
84
+ f"{results.get('duration_s') or 0:.0f}s, cost {cost:.4f} USD[/dim]"
85
+ )
86
+ if any(a.get("experimental") for a in results["agents"]):
87
+ console.print(
88
+ "[dim]Codex is experimental: it reports no activation event, so a run counts as fired "
89
+ "when Codex reads the skill's SKILL.md.[/dim]"
90
+ )
91
+ return summary
92
+
93
+
94
+ def write_junit(results: dict, path: Path) -> None:
95
+ summary = summarize(results)
96
+ suites, total = [], {"tests": 0, "failures": 0, "errors": 0}
97
+ for agent in summary["agents"]:
98
+ cases, counts = [], {"tests": 0, "failures": 0, "errors": 0}
99
+ for skill in summary["skills"]:
100
+ for row in summary["cells"][f"{agent}|{skill}"]["prompts"]:
101
+ counts["tests"] += 1
102
+ label = "should fire" if row["expect"] == "fire" else "should not fire"
103
+ name = f"[{label}] {row['prompt']}"
104
+ body = ""
105
+ result = f"fired {row['fired']}/{row['runs']} valid runs"
106
+ if row["runs"] == 0:
107
+ counts["errors"] += 1
108
+ errored = f"all {row['errors']} runs errored"
109
+ body = f"<error message={quoteattr(errored)}/>"
110
+ else:
111
+ majority = row["fired"] * 2 > row["runs"]
112
+ ok = majority if row["expect"] == "fire" else not majority
113
+ if not ok:
114
+ counts["failures"] += 1
115
+ body = f"<failure message={quoteattr(result)}/>"
116
+ cases.append(
117
+ f" <testcase classname={quoteattr(f'{agent}.{skill}')} name={quoteattr(name)}>"
118
+ f"{body}<system-out>{escape(result)}</system-out></testcase>"
119
+ )
120
+ for k in total:
121
+ total[k] += counts[k]
122
+ suites.append(
123
+ f' <testsuite name={quoteattr(agent)} tests="{counts["tests"]}" failures="{counts["failures"]}" '
124
+ f'errors="{counts["errors"]}">\n' + "\n".join(cases) + "\n </testsuite>"
125
+ )
126
+ xml = (
127
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
128
+ f'<testsuites name="doesitfire" tests="{total["tests"]}" failures="{total["failures"]}" '
129
+ f'errors="{total["errors"]}">\n' + "\n".join(suites) + "\n</testsuites>\n"
130
+ )
131
+ path.write_text(xml, encoding="utf-8")
132
+
133
+
134
+ def _badge_parts(results: dict) -> tuple[str, str]:
135
+ o = overall(summarize(results))
136
+ message = f"fires {pct(o['recall'])} · false {pct(o['false_fire_rate'])}"
137
+ r = o["recall"]
138
+ color = OK if r is not None and r >= 0.8 else WARN if r is not None and r >= 0.5 else ERR
139
+ return message, color
140
+
141
+
142
+ def _text_width(s: str) -> int:
143
+ # Approximate Verdana 11px advance widths, enough for a badge.
144
+ narrow, wide = set("fijlrt ·:|!.,'"), set("mwMW%")
145
+ return int(sum(4 if c in narrow else 10 if c in wide else 7 for c in s)) + 10
146
+
147
+
148
+ def badge_svg(results: dict, label: str = "doesitfire") -> str:
149
+ message, color = _badge_parts(results)
150
+ lw, mw = _text_width(label), _text_width(message)
151
+ w = lw + mw
152
+ aria = f"{escape(label)}: {escape(message)}"
153
+ return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" aria-label="{aria}">
154
+ <title>{escape(label)}: {escape(message)}</title>
155
+ <clipPath id="r"><rect width="{w}" height="20" rx="3" fill="#fff"/></clipPath>
156
+ <g clip-path="url(#r)">
157
+ <rect width="{lw}" height="20" fill="{ACCENT}"/>
158
+ <rect x="{lw}" width="{mw}" height="20" fill="{color}"/>
159
+ </g>
160
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
161
+ <text x="{lw / 2:.1f}" y="14">{escape(label)}</text>
162
+ <text x="{lw + mw / 2:.1f}" y="14">{escape(message)}</text>
163
+ </g>
164
+ </svg>
165
+ """
166
+
167
+
168
+ def badge_endpoint(results: dict, label: str = "doesitfire") -> dict:
169
+ message, color = _badge_parts(results)
170
+ return {"schemaVersion": 1, "label": label, "message": message, "color": color.lstrip("#")}
171
+
172
+
173
+ def write_badges(results: dict, out: Path) -> None:
174
+ (out / "badge.svg").write_text(badge_svg(results), encoding="utf-8")
175
+ (out / "badge.json").write_text(json.dumps(badge_endpoint(results), indent=2) + "\n", encoding="utf-8")