SAMA per-domain split: c51_render_<domain> + c21_handlers_<domain>
Applies the updated SAMA convention from snowplaza-info's CLAUDE.md
(rule 6 — split layer files past ~700 lines per UI/data domain, same
prefix, no barrel re-exports).
c51 — render split:
c51_render_layout.ts chrome + escape, htmlResponse, errorPage,
phaseSpan, relativeTime, Section, PageOptions
c51_render_projects.ts /projects body builders
c51_render_reports.ts /reports body builders + sparkline/tile/bars
c21 — dispatcher + per-cluster handlers:
c21_app.ts routes literal + createApp + appFetch + appError
(now 657 lines, was 1176)
c21_handlers_agents.ts renderAgentsIndex + renderAgentDetail
c21_handlers_auth.ts startGithubOauth + handleGithubCallback
(welcome body builder)
c21_handlers_leaderboard.ts renderLeaderboard
c21_handlers_repo_view.ts bare /:owner/:repo render via Forgejo
Largest source file is now c21_app.ts at 657 lines; every other layer
file sits well under the threshold. .gitignore now excludes .claude/.
All 21 probed routes return their previous status (200/302/404).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
10 files changed · +1143 −1064
.gitignore
+1
−0
| @@ -4,3 +4,4 @@ node_modules/ | ||
| 4 | 4 | .env |
| 5 | 5 | .env.local |
| 6 | 6 | .bun-cache/ |
| 7 | +.claude/ | |
src/c21_app.ts
+17
−536
| @@ -6,58 +6,49 @@ import { | ||
| 6 | 6 | renderPage, |
| 7 | 7 | renderNotFound, |
| 8 | 8 | htmlResponse, |
| 9 | - errorPage, | |
| 10 | - phaseSpan, | |
| 11 | - relativeTime, | |
| 9 | +} from "./c51_render_layout.ts"; | |
| 10 | +import { | |
| 11 | + projectsLandingMd, | |
| 12 | + projectRegisterMd, | |
| 13 | + projectDetailMd, | |
| 14 | +} from "./c51_render_projects.ts"; | |
| 15 | +import { | |
| 12 | 16 | reportsLandingMd, |
| 13 | 17 | execSummaryMd, |
| 14 | 18 | agentDrilldownMd, |
| 15 | 19 | testsOverviewMd, |
| 16 | - projectsLandingMd, | |
| 17 | - projectRegisterMd, | |
| 18 | - projectDetailMd, | |
| 19 | -} from "./c51_render.ts"; | |
| 20 | -import * as github from "./c14_github.ts"; | |
| 21 | -import * as forgejo from "./c14_forgejo.ts"; | |
| 20 | +} from "./c51_render_reports.ts"; | |
| 22 | 21 | import { |
| 23 | 22 | FORGEJO_URL, |
| 24 | 23 | adminApiHeaders, |
| 25 | - getUserVisibility, | |
| 26 | 24 | proxyToForgejo, |
| 27 | - type ForgejoUserSummary, | |
| 28 | 25 | } from "./c14_forgejo.ts"; |
| 29 | -import { parseCommit, computeProgress } from "./c31_commits.ts"; | |
| 30 | -import { loadGame, listGames } from "./c31_games.ts"; | |
| 26 | +import { fetchProjectConfig } from "./c14_github.ts"; | |
| 27 | +import { listGames, loadGame } from "./c31_games.ts"; | |
| 31 | 28 | import { ALL_POSTS } from "./c31_blog.ts"; |
| 32 | 29 | import { ALL_GUIDES } from "./c31_guides.ts"; |
| 33 | 30 | import { DEMO_REPORTS } from "./c31_reports_demo.ts"; |
| 34 | 31 | import { parseRepoIdentifier } from "./c31_project_config.ts"; |
| 35 | -import { fetchProjectConfig } from "./c14_github.ts"; | |
| 36 | 32 | import { judge } from "./c32_judge.ts"; |
| 37 | 33 | import { |
| 38 | - SESSION_TTL_SEC, | |
| 39 | 34 | getViewer, |
| 40 | - randomHex, | |
| 41 | - parseCookies, | |
| 42 | - signSession, | |
| 43 | 35 | sessionCookieHeader, |
| 44 | 36 | timingSafeEqual, |
| 45 | 37 | hmacSha256Hex, |
| 46 | 38 | } from "./c32_session.ts"; |
| 47 | 39 | import { |
| 48 | - latestRun, | |
| 49 | - allLatestRuns, | |
| 50 | 40 | listActiveProjects, |
| 51 | 41 | getProject, |
| 52 | 42 | upsertProject, |
| 53 | 43 | } from "./c13_database.ts"; |
| 44 | +import { renderRepoView } from "./c21_handlers_repo_view.ts"; | |
| 45 | +import { renderAgentsIndex, renderAgentDetail } from "./c21_handlers_agents.ts"; | |
| 46 | +import { renderLeaderboard } from "./c21_handlers_leaderboard.ts"; | |
| 47 | +import { startGithubOauth, handleGithubCallback } from "./c21_handlers_auth.ts"; | |
| 54 | 48 | |
| 55 | 49 | const HOME_MD = "./content/home.md"; |
| 56 | 50 | const GAME_DIR = "./content/games"; |
| 57 | 51 | |
| 58 | -const BASE_URL = process.env.BASE_URL ?? "https://tdd.md"; | |
| 59 | -const CALLBACK_URL = `${BASE_URL}/auth/github/callback`; | |
| 60 | - | |
| 61 | 52 | const HOME_DESCRIPTION = |
| 62 | 53 | "Test-driven development for agentic coding. Your AI agent practices on scored katas; the judge replays its commits against hidden tests and posts a public verdict on the discipline."; |
| 63 | 54 | |
| @@ -123,129 +114,6 @@ const renderKata = async (kata: string): Promise<Response | null> => { | ||
| 123 | 114 | return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); |
| 124 | 115 | }; |
| 125 | 116 | |
| 126 | -const renderAgentsIndex = async (): Promise<Response> => { | |
| 127 | - let users: ForgejoUserSummary[] = []; | |
| 128 | - const adminToken = process.env.FORGEJO_ADMIN_TOKEN; | |
| 129 | - if (adminToken) { | |
| 130 | - const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, { | |
| 131 | - headers: adminApiHeaders(), | |
| 132 | - }); | |
| 133 | - if (r.ok) users = (await r.json()) as ForgejoUserSummary[]; | |
| 134 | - } | |
| 135 | - // Drop the admin (id 1) and anyone whose visibility isn't "public" — | |
| 136 | - // private and limited agents stay invisible on the public index. | |
| 137 | - const agents = users.filter( | |
| 138 | - (u) => u.id !== 1 && !u.is_admin && (u.visibility ?? "public") === "public", | |
| 139 | - ); | |
| 140 | - | |
| 141 | - // Per-agent score totals from the latest run per repo. | |
| 142 | - const allRuns = allLatestRuns(); | |
| 143 | - const totalsByOwner = new Map<string, { score: number; runs: number }>(); | |
| 144 | - for (const r of allRuns) { | |
| 145 | - const t = totalsByOwner.get(r.owner) ?? { score: 0, runs: 0 }; | |
| 146 | - t.score += r.verdict.totalScore; | |
| 147 | - t.runs += 1; | |
| 148 | - totalsByOwner.set(r.owner, t); | |
| 149 | - } | |
| 150 | - | |
| 151 | - let body: string; | |
| 152 | - if (agents.length === 0) { | |
| 153 | - body = `# agents | |
| 154 | - | |
| 155 | -> No agents registered yet. Be the first. | |
| 156 | - | |
| 157 | -[ Register your agent → ](/agents/register) | |
| 158 | -`; | |
| 159 | - } else { | |
| 160 | - const rows = agents | |
| 161 | - .map((u) => { | |
| 162 | - const t = totalsByOwner.get(u.login) ?? { score: 0, runs: 0 }; | |
| 163 | - const sign = t.score >= 0 ? "+" : ""; | |
| 164 | - return `| [${u.login}](/agents/${u.login}) | ${t.runs} | ${sign}${t.score} |`; | |
| 165 | - }) | |
| 166 | - .join("\n"); | |
| 167 | - body = `# agents | |
| 168 | - | |
| 169 | -| agent | attempts | total score | | |
| 170 | -|---|---|---| | |
| 171 | -${rows} | |
| 172 | - | |
| 173 | -[ Register your agent → ](/agents/register) | |
| 174 | -`; | |
| 175 | - } | |
| 176 | - | |
| 177 | - const description = | |
| 178 | - agents.length === 0 | |
| 179 | - ? "AI agents doing test-driven development on tdd.md — registration is open, sign in with GitHub to play." | |
| 180 | - : `${agents.length} AI ${agents.length === 1 ? "agent" : "agents"} doing test-driven development on tdd.md, scored on red→green discipline against hidden tests for agentic coding.`; | |
| 181 | - | |
| 182 | - const html = await renderPage({ | |
| 183 | - title: "AI agents on tdd.md", | |
| 184 | - description, | |
| 185 | - bodyMarkdown: body, | |
| 186 | - ogPath: "https://tdd.md/agents", | |
| 187 | - active: "agents", | |
| 188 | - }); | |
| 189 | - return htmlResponse(html); | |
| 190 | -}; | |
| 191 | - | |
| 192 | -const renderLeaderboard = async (): Promise<Response> => { | |
| 193 | - // Only show runs whose owner is public. Fetch the user list once | |
| 194 | - // and build a Set so we can filter without N+1 lookups. | |
| 195 | - const adminToken = process.env.FORGEJO_ADMIN_TOKEN; | |
| 196 | - const publicOwners = new Set<string>(); | |
| 197 | - if (adminToken) { | |
| 198 | - const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, { | |
| 199 | - headers: adminApiHeaders(), | |
| 200 | - }); | |
| 201 | - if (r.ok) { | |
| 202 | - const users = (await r.json()) as ForgejoUserSummary[]; | |
| 203 | - for (const u of users) { | |
| 204 | - if ((u.visibility ?? "public") === "public") publicOwners.add(u.login); | |
| 205 | - } | |
| 206 | - } | |
| 207 | - } | |
| 208 | - const runs = allLatestRuns() | |
| 209 | - .filter((r) => publicOwners.size === 0 || publicOwners.has(r.owner)) | |
| 210 | - .sort((a, b) => b.verdict.totalScore - a.verdict.totalScore); | |
| 211 | - let body: string; | |
| 212 | - if (runs.length === 0) { | |
| 213 | - body = `# leaderboard | |
| 214 | - | |
| 215 | -> No verdicts yet. The first agent to push a red→green pair lands here. | |
| 216 | - | |
| 217 | -[ Register your agent → ](/agents/register) | |
| 218 | -`; | |
| 219 | - } else { | |
| 220 | - const rows = runs | |
| 221 | - .map((r, i) => { | |
| 222 | - const sign = r.verdict.totalScore >= 0 ? "+" : ""; | |
| 223 | - const verified = r.verdict.steps.filter((s) => s.status === "verified").length; | |
| 224 | - return `| ${i + 1} | [${r.owner}](/agents/${r.owner}) | [${r.repo}](/${r.owner}/${r.repo}) | ${sign}${r.verdict.totalScore} | ${verified} |`; | |
| 225 | - }) | |
| 226 | - .join("\n"); | |
| 227 | - body = `# leaderboard | |
| 228 | - | |
| 229 | -| rank | agent | kata | score | verified steps | | |
| 230 | -|---|---|---|---|---| | |
| 231 | -${rows} | |
| 232 | -`; | |
| 233 | - } | |
| 234 | - const description = | |
| 235 | - runs.length === 0 | |
| 236 | - ? "TDD leaderboard for AI agents on tdd.md — be the first verdict." | |
| 237 | - : `Top AI agents by TDD score on tdd.md — ${runs.length} ranked ${runs.length === 1 ? "submission" : "submissions"} graded on red→green discipline and hidden test pass rate.`; | |
| 238 | - | |
| 239 | - const html = await renderPage({ | |
| 240 | - title: "TDD leaderboard — tdd.md", | |
| 241 | - description, | |
| 242 | - bodyMarkdown: body, | |
| 243 | - ogPath: "https://tdd.md/leaderboard", | |
| 244 | - active: "leaderboard", | |
| 245 | - }); | |
| 246 | - return htmlResponse(html); | |
| 247 | -}; | |
| 248 | - | |
| 249 | 117 | const REGISTER_BODY = `# register |
| 250 | 118 | |
| 251 | 119 | > Sign in with GitHub to create your tdd.md agent. |
| @@ -274,191 +142,6 @@ const REGISTER_HTML = await renderPage({ | ||
| 274 | 142 | noindex: true, |
| 275 | 143 | }); |
| 276 | 144 | |
| 277 | -interface ForgejoRepoSummary { | |
| 278 | - description: string; | |
| 279 | - clone_url: string; | |
| 280 | - empty: boolean; | |
| 281 | - private: boolean; | |
| 282 | -} | |
| 283 | - | |
| 284 | -interface ForgejoCommit { | |
| 285 | - sha: string; | |
| 286 | - commit: { message: string; author: { name: string; date: string } }; | |
| 287 | -} | |
| 288 | - | |
| 289 | -const renderRepoView = async ( | |
| 290 | - owner: string, | |
| 291 | - repo: string, | |
| 292 | - viewer: string | null, | |
| 293 | -): Promise<Response> => { | |
| 294 | - // Private/limited owners get a 404 to anonymous visitors — but the | |
| 295 | - // owner themselves (verified via session cookie) can always see | |
| 296 | - // their own pages. | |
| 297 | - const ownerVisibility = await getUserVisibility(owner); | |
| 298 | - if (ownerVisibility !== null && ownerVisibility !== "public" && viewer !== owner) { | |
| 299 | - const html = await renderNotFound(`/${owner}/${repo}`); | |
| 300 | - return htmlResponse(html, 404); | |
| 301 | - } | |
| 302 | - | |
| 303 | - const repoApi = `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; | |
| 304 | - const repoRes = await fetch(repoApi, { headers: adminApiHeaders() }); | |
| 305 | - if (repoRes.status === 404) { | |
| 306 | - const html = await renderNotFound(`/${owner}/${repo}`); | |
| 307 | - return htmlResponse(html, 404); | |
| 308 | - } | |
| 309 | - if (!repoRes.ok) { | |
| 310 | - const html = await renderPage({ | |
| 311 | - title: `${owner}/${repo} — tdd.md`, | |
| 312 | - bodyMarkdown: `# ${owner}/${repo}\n\n> repository unavailable`, | |
| 313 | - }); | |
| 314 | - return htmlResponse(html, 502); | |
| 315 | - } | |
| 316 | - const info = (await repoRes.json()) as ForgejoRepoSummary; | |
| 317 | - const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`; | |
| 318 | - const isPrivate = info.private === true; | |
| 319 | - | |
| 320 | - // The repo name is by convention the kata id. If the kata exists, the | |
| 321 | - // header link is meaningful and we know the total step count. | |
| 322 | - let totalSteps: number | null = null; | |
| 323 | - let kataExists = false; | |
| 324 | - try { | |
| 325 | - const game = await loadGame(repo); | |
| 326 | - totalSteps = game.steps.length; | |
| 327 | - kataExists = true; | |
| 328 | - } catch { | |
| 329 | - // Repo isn't a known kata — still render, just without step totals. | |
| 330 | - } | |
| 331 | - | |
| 332 | - let commits: ForgejoCommit[] = []; | |
| 333 | - if (!info.empty) { | |
| 334 | - const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`, { | |
| 335 | - headers: adminApiHeaders(), | |
| 336 | - }); | |
| 337 | - if (commitsRes.ok) commits = (await commitsRes.json()) as ForgejoCommit[]; | |
| 338 | - } | |
| 339 | - const progress = computeProgress(commits); | |
| 340 | - const verified = progress.verifiedSteps.size; | |
| 341 | - | |
| 342 | - let status: string; | |
| 343 | - if (commits.length === 0) { | |
| 344 | - status = "awaiting first push"; | |
| 345 | - } else if (totalSteps !== null && verified >= totalSteps) { | |
| 346 | - status = "kata complete"; | |
| 347 | - } else if (verified > 0) { | |
| 348 | - status = "in progress"; | |
| 349 | - } else { | |
| 350 | - status = "no verified steps yet"; | |
| 351 | - } | |
| 352 | - const stepCounter = totalSteps !== null ? `${verified} / ${totalSteps}` : `${verified} / ?`; | |
| 353 | - | |
| 354 | - let phaseLog: string; | |
| 355 | - if (commits.length === 0) { | |
| 356 | - phaseLog = "_No commits yet — push your first `red:` commit to start the cycle._"; | |
| 357 | - } else { | |
| 358 | - const rows = commits.map((c) => { | |
| 359 | - const sha = c.sha.slice(0, 7); | |
| 360 | - const p = parseCommit(c.commit.message); | |
| 361 | - const subject = (p.subject || c.commit.message.split("\n")[0] || "").replace(/\|/g, "\\|"); | |
| 362 | - const stepCell = p.step ? `\`${p.step}\`` : "—"; | |
| 363 | - return `| \`${sha}\` | ${phaseSpan(p.phase)} | ${stepCell} | ${subject} | ${relativeTime(c.commit.author.date)} |`; | |
| 364 | - }); | |
| 365 | - phaseLog = `| sha | phase | step | message | when |\n|---|---|---|---|---|\n${rows.join("\n")}`; | |
| 366 | - } | |
| 367 | - | |
| 368 | - const kataLink = kataExists | |
| 369 | - ? `[\`${repo}\` →](/games/${repo})` | |
| 370 | - : `\`${repo}\``; | |
| 371 | - const privateBadge = isPrivate ? ` <span class="muted">[private]</span>` : ""; | |
| 372 | - | |
| 373 | - const verdict = latestRun(owner, repo); | |
| 374 | - const headSha = commits[0]?.sha ?? null; | |
| 375 | - const verdictStale = verdict !== null && headSha !== null && verdict.headSha !== headSha; | |
| 376 | - | |
| 377 | - let scoreSection: string; | |
| 378 | - if (verdict === null) { | |
| 379 | - scoreSection = `> Not yet judged. The next push triggers a judge run, or [run the judge now](/api/judge/${owner}/${repo}) (POST).\n\nPhase tally: <span class="red">red ${progress.redCount}</span> · <span class="green">green ${progress.greenCount}</span> · <span class="blue">refactor ${progress.refactorCount}</span>${progress.untaggedCount > 0 ? ` · <span class="muted">untagged ${progress.untaggedCount}</span>` : ""}.`; | |
| 380 | - } else { | |
| 381 | - const stale = verdictStale ? ` · <span class="muted">stale — newer commits not yet judged</span>` : ""; | |
| 382 | - const sign = verdict.totalScore >= 0 ? "+" : ""; | |
| 383 | - const statusClass = (status: string): string => { | |
| 384 | - if (status === "verified") return "green"; | |
| 385 | - if (status === "discipline-only") return "blue"; | |
| 386 | - if (status === "no-green") return "muted"; | |
| 387 | - return "red"; | |
| 388 | - }; | |
| 389 | - const modeLabel = (m: string): string => { | |
| 390 | - const cls = m === "strict" ? "red" : m === "pragmatic" ? "blue" : "green"; | |
| 391 | - return `<span class="${cls}">${m}</span>`; | |
| 392 | - }; | |
| 393 | - const rows = verdict.steps.length === 0 | |
| 394 | - ? "_No red→green pairs found yet._" | |
| 395 | - : `| step | red | green | hidden | status | points | explanation |\n|---|---|---|---|---|---|---|\n` + | |
| 396 | - verdict.steps.map((s) => { | |
| 397 | - const cls = statusClass(s.status); | |
| 398 | - const sign = s.scoreDelta >= 0 ? "+" : ""; | |
| 399 | - const hiddenCell = | |
| 400 | - s.hiddenPassed === true ? `<span class="green">pass</span>` : | |
| 401 | - s.hiddenPassed === false ? `<span class="red">fail</span>` : | |
| 402 | - `<span class="muted">—</span>`; | |
| 403 | - const explanation = (s.explanation ?? "").replace(/\|/g, "\\|"); | |
| 404 | - return `| \`${s.stepId}\` | \`${s.redSha?.slice(0, 7) ?? "—"}\` | \`${s.greenSha?.slice(0, 7) ?? "—"}\` | ${hiddenCell} | <span class="${cls}">${s.status}</span> | ${sign}${s.scoreDelta} | ${explanation} |`; | |
| 405 | - }).join("\n"); | |
| 406 | - const refactorRows = (verdict.refactors ?? []).length === 0 | |
| 407 | - ? "" | |
| 408 | - : `\n\n### refactors\n\n| sha | step | tests | points | explanation |\n|---|---|---|---|---|\n` + | |
| 409 | - verdict.refactors.map((r) => { | |
| 410 | - const sign = r.scoreDelta >= 0 ? "+" : ""; | |
| 411 | - const cls = r.testsPassed ? "green" : "red"; | |
| 412 | - const verb = r.testsPassed ? "green" : "broke tests"; | |
| 413 | - const explanation = (r.explanation ?? "").replace(/\|/g, "\\|"); | |
| 414 | - return `| \`${r.sha.slice(0, 7)}\` | ${r.stepId ? `\`${r.stepId}\`` : "—"} | <span class="${cls}">${verb}</span> | ${sign}${r.scoreDelta} | ${explanation} |`; | |
| 415 | - }).join("\n"); | |
| 416 | - const modeLine = verdict.mode ? `**mode: ${modeLabel(verdict.mode)}** · ` : ""; | |
| 417 | - scoreSection = `${modeLine}**total: ${sign}${verdict.totalScore}** · judged ${relativeTime(new Date(verdict.judgedAt).toISOString())}${stale}\n\n${rows}${refactorRows}`; | |
| 418 | - } | |
| 419 | - | |
| 420 | - const body = `# ${owner} · playing ${kataLink}${privateBadge} | |
| 421 | - | |
| 422 | -> ${status} | |
| 423 | -> **${stepCounter}** steps verified | |
| 424 | - | |
| 425 | -## phase log | |
| 426 | - | |
| 427 | -${phaseLog} | |
| 428 | - | |
| 429 | -## score | |
| 430 | - | |
| 431 | -${scoreSection} | |
| 432 | - | |
| 433 | -## clone | |
| 434 | - | |
| 435 | -\`\`\` | |
| 436 | -git clone ${cloneUrl} | |
| 437 | -\`\`\` | |
| 438 | - | |
| 439 | -[← /agents/${owner}](/agents/${owner})${kataExists ? ` · [kata spec →](/games/${repo})` : ""} | |
| 440 | -`; | |
| 441 | - | |
| 442 | - // Dynamic description tailored to this attempt — gives every agent | |
| 443 | - // run a unique snippet for search results and social previews instead | |
| 444 | - // of falling back to the site default. | |
| 445 | - const totalSnippet = | |
| 446 | - verdict !== null | |
| 447 | - ? `, score ${verdict.totalScore >= 0 ? "+" : ""}${verdict.totalScore}` | |
| 448 | - : ""; | |
| 449 | - const description = kataExists | |
| 450 | - ? `${owner}'s ${repo} TDD kata attempt on tdd.md — ${verified}${totalSteps !== null ? `/${totalSteps}` : ""} steps verified${totalSnippet}.` | |
| 451 | - : `${owner}/${repo} on tdd.md — ${commits.length} ${commits.length === 1 ? "commit" : "commits"} in the phase log${totalSnippet}.`; | |
| 452 | - | |
| 453 | - const html = await renderPage({ | |
| 454 | - title: `${owner} · ${repo}${kataExists ? " TDD kata" : ""} — tdd.md`, | |
| 455 | - description, | |
| 456 | - bodyMarkdown: body, | |
| 457 | - ogPath: `https://tdd.md/${owner}/${repo}`, | |
| 458 | - active: "agents", | |
| 459 | - }); | |
| 460 | - return htmlResponse(html); | |
| 461 | -}; | |
| 462 | 145 | |
| 463 | 146 | const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => { |
| 464 | 147 | if (pathname.includes(".git/") || pathname.endsWith(".git")) return true; |
| @@ -832,93 +515,8 @@ ${rows} | ||
| 832 | 515 | "/agents": () => renderAgentsIndex(), |
| 833 | 516 | "/agents/register": htmlResponse(REGISTER_HTML), |
| 834 | 517 | "/agents/:name": async (req) => { |
| 835 | - const name = req.params.name; | |
| 836 | 518 | const viewer = await getViewer(req); |
| 837 | - const userRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}`, { | |
| 838 | - headers: adminApiHeaders(), | |
| 839 | - }); | |
| 840 | - // Treat private/limited users as if they don't exist publicly — | |
| 841 | - // unless the logged-in viewer IS the owner. Owner can always see | |
| 842 | - // their own dashboard, public or not. | |
| 843 | - if (userRes.ok) { | |
| 844 | - const u = (await userRes.clone().json()) as ForgejoUserSummary; | |
| 845 | - const ownVisibility = u.visibility ?? "public"; | |
| 846 | - if (ownVisibility !== "public" && viewer !== name) { | |
| 847 | - const html = await renderNotFound(`/agents/${name}`); | |
| 848 | - return htmlResponse(html, 404); | |
| 849 | - } | |
| 850 | - } | |
| 851 | - if (userRes.status === 404) { | |
| 852 | - const html = await renderPage({ | |
| 853 | - title: `${name} — agents — tdd.md`, | |
| 854 | - bodyMarkdown: `# agents / ${name}\n\n> No agent registered with this name.\n\n[← all agents](/agents) · [register your own →](/agents/register)`, | |
| 855 | - ogPath: `https://tdd.md/agents/${name}`, | |
| 856 | - active: "agents", | |
| 857 | - }); | |
| 858 | - return htmlResponse(html, 404); | |
| 859 | - } | |
| 860 | - const reposRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}/repos?limit=50`, { | |
| 861 | - headers: adminApiHeaders(), | |
| 862 | - }); | |
| 863 | - const repos = reposRes.ok ? ((await reposRes.json()) as { name: string; description: string }[]) : []; | |
| 864 | - | |
| 865 | - const progressByRepo = await Promise.all( | |
| 866 | - repos.map(async (r) => { | |
| 867 | - const cRes = await fetch( | |
| 868 | - `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(name)}/${encodeURIComponent(r.name)}/commits?limit=50&stat=false`, | |
| 869 | - { headers: adminApiHeaders() }, | |
| 870 | - ); | |
| 871 | - const commits = cRes.ok ? ((await cRes.json()) as { commit: { message: string } }[]) : []; | |
| 872 | - return { repo: r, progress: computeProgress(commits) }; | |
| 873 | - }), | |
| 874 | - ); | |
| 875 | - | |
| 876 | - const totals: Record<string, number> = {}; | |
| 877 | - for (const r of repos) { | |
| 878 | - try { | |
| 879 | - const game = await loadGame(r.name); | |
| 880 | - totals[r.name] = game.steps.length; | |
| 881 | - } catch { | |
| 882 | - // unknown kata, no total | |
| 883 | - } | |
| 884 | - } | |
| 885 | - | |
| 886 | - const isSelf = viewer === name; | |
| 887 | - let body = `# agents / ${name}\n\n`; | |
| 888 | - if (isSelf) { | |
| 889 | - body += `> Welcome back, ${name}. This is your dashboard — only you and admins see it when your profile is private.\n\n`; | |
| 890 | - } | |
| 891 | - if (repos.length === 0) { | |
| 892 | - body += "> Registered, but no kata attempts yet.\n\n[← all agents](/agents)"; | |
| 893 | - } else { | |
| 894 | - body += "## attempts\n\n"; | |
| 895 | - body += "| kata | verified | phases |\n|---|---|---|\n"; | |
| 896 | - for (const { repo: r, progress } of progressByRepo) { | |
| 897 | - const total = totals[r.name]; | |
| 898 | - const verified = progress.verifiedSteps.size; | |
| 899 | - const counter = total !== undefined ? `${verified} / ${total}` : `${verified} / ?`; | |
| 900 | - const phases = `<span class="red">red ${progress.redCount}</span> · <span class="green">green ${progress.greenCount}</span> · <span class="blue">refactor ${progress.refactorCount}</span>`; | |
| 901 | - body += `| [${r.name}](/${name}/${r.name}) | ${counter} | ${phases} |\n`; | |
| 902 | - } | |
| 903 | - } | |
| 904 | - | |
| 905 | - if (isSelf) { | |
| 906 | - body += `\n\n---\n\n[sign out](/auth/logout) · [toggle visibility](#) <span class="muted">(POST /api/agents/${name}/visibility with your push token)</span>`; | |
| 907 | - } | |
| 908 | - | |
| 909 | - const verifiedSteps = progressByRepo.reduce((acc, p) => acc + p.progress.verifiedSteps.size, 0); | |
| 910 | - const description = | |
| 911 | - repos.length === 0 | |
| 912 | - ? `${name} just registered on tdd.md — no kata attempts yet.` | |
| 913 | - : `${name}'s TDD attempts on tdd.md: ${repos.length} ${repos.length === 1 ? "kata" : "katas"} pushed, ${verifiedSteps} verified red→green ${verifiedSteps === 1 ? "step" : "steps"}.`; | |
| 914 | - const html = await renderPage({ | |
| 915 | - title: `${name} · TDD attempts — tdd.md`, | |
| 916 | - description, | |
| 917 | - bodyMarkdown: body, | |
| 918 | - ogPath: `https://tdd.md/agents/${name}`, | |
| 919 | - active: "agents", | |
| 920 | - }); | |
| 921 | - return htmlResponse(html); | |
| 519 | + return renderAgentDetail(req.params.name, viewer); | |
| 922 | 520 | }, |
| 923 | 521 | // Redirect the legacy URL to the canonical /:owner/:repo path — |
| 924 | 522 | // /agents/:name/:kata used to render a placeholder before the |
| @@ -1051,126 +649,9 @@ ${rows} | ||
| 1051 | 649 | }); |
| 1052 | 650 | }, |
| 1053 | 651 | |
| 1054 | - "/auth/github/start": (_req) => { | |
| 1055 | - if (!github.isConfigured() || !forgejo.isConfigured()) { | |
| 1056 | - return errorPage("registration is not configured on this server", 503); | |
| 1057 | - } | |
| 1058 | - const nonce = randomHex(16); | |
| 1059 | - return new Response(null, { | |
| 1060 | - status: 302, | |
| 1061 | - headers: { | |
| 1062 | - Location: github.authorizeUrl(nonce, CALLBACK_URL), | |
| 1063 | - "Set-Cookie": `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, | |
| 1064 | - }, | |
| 1065 | - }); | |
| 1066 | - }, | |
| 1067 | - | |
| 1068 | - "/auth/github/callback": async (req) => { | |
| 1069 | - const url = new URL(req.url); | |
| 1070 | - const code = url.searchParams.get("code"); | |
| 1071 | - const state = url.searchParams.get("state"); | |
| 1072 | - if (!code || !state) return errorPage("missing code or state"); | |
| 1073 | - | |
| 1074 | - const cookies = parseCookies(req.headers.get("cookie")); | |
| 1075 | - const cookieState = cookies.tdd_oauth_state; | |
| 1076 | - if (!cookieState || !timingSafeEqual(cookieState, state)) { | |
| 1077 | - return errorPage("state mismatch — open the registration page again and retry"); | |
| 1078 | - } | |
| 1079 | - | |
| 1080 | - let username: string; | |
| 1081 | - let email: string; | |
| 1082 | - let fullName: string | null; | |
| 1083 | - try { | |
| 1084 | - const accessToken = await github.exchangeCode(code, CALLBACK_URL); | |
| 1085 | - const user = await github.fetchUser(accessToken); | |
| 1086 | - username = user.login; | |
| 1087 | - fullName = user.name; | |
| 1088 | - // GitHub's noreply email format: unique per account, never collides | |
| 1089 | - // with another Forgejo user. We don't need a deliverable address — | |
| 1090 | - // agents authenticate by token, not by email reset flow. | |
| 1091 | - email = `${user.id}+${user.login}@users.noreply.github.com`; | |
| 1092 | - } catch (err) { | |
| 1093 | - return errorPage(`github oauth failed: ${(err as Error).message}`, 400); | |
| 1094 | - } | |
| 1095 | - | |
| 1096 | - // Login vs register: if the user already exists in Forgejo, this | |
| 1097 | - // is a returning visitor — set the session cookie, redirect to | |
| 1098 | - // their dashboard, don't rotate their token. | |
| 1099 | - const isExisting = await forgejo.userExists(username); | |
| 1100 | - const sessionToken = await signSession(username); | |
| 1101 | - const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC); | |
| 1102 | - const clearOauthState = | |
| 1103 | - "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0"; | |
| 1104 | - | |
| 1105 | - if (isExisting) { | |
| 1106 | - return new Response(null, { | |
| 1107 | - status: 302, | |
| 1108 | - headers: new Headers([ | |
| 1109 | - ["Location", `/agents/${username}`], | |
| 1110 | - ["Set-Cookie", sessionCookie], | |
| 1111 | - ["Set-Cookie", clearOauthState], | |
| 1112 | - ]), | |
| 1113 | - }); | |
| 1114 | - } | |
| 1115 | - | |
| 1116 | - let reg: forgejo.AgentRegistration; | |
| 1117 | - try { | |
| 1118 | - reg = await forgejo.registerAgent({ | |
| 1119 | - username, | |
| 1120 | - email, | |
| 1121 | - fullName: fullName ?? undefined, | |
| 1122 | - }); | |
| 1123 | - } catch (err) { | |
| 1124 | - return errorPage(`failed to create your agent: ${(err as Error).message}`, 422); | |
| 1125 | - } | |
| 1126 | - | |
| 1127 | - const verb = reg.isNew ? "created" : "rotated"; | |
| 1128 | - const body = `# welcome, ${reg.username} | |
| 1129 | - | |
| 1130 | -> Your tdd.md agent has been ${verb}. **Save the token below — this page is the only time you'll see it.** If you lose it, [register again](/agents/register) to issue a fresh one (the old one will stop working). | |
| 1131 | - | |
| 1132 | -## push token | |
| 1133 | - | |
| 1134 | -\`\`\` | |
| 1135 | -${reg.pushToken} | |
| 1136 | -\`\`\` | |
| 1137 | - | |
| 1138 | -## kata: string-calc | |
| 1139 | - | |
| 1140 | -Your repo is at [\`git.tdd.md/${reg.username}/string-calc\`](https://git.tdd.md/${reg.username}/string-calc), already initialized with a default branch \`main\`. | |
| 1141 | - | |
| 1142 | -\`\`\` | |
| 1143 | -git clone ${reg.repoCloneUrl} | |
| 1144 | -cd string-calc | |
| 1145 | - | |
| 1146 | -# play the kata, commit per phase | |
| 1147 | -# red: commit a failing test | |
| 1148 | -# green: commit the impl that makes it pass | |
| 1149 | -# refactor: commit a structural change with tests staying green | |
| 652 | + "/auth/github/start": (_req) => startGithubOauth(), | |
| 1150 | 653 | |
| 1151 | -git push | |
| 1152 | -# username: ${reg.username} | |
| 1153 | -# password: <paste the token above> | |
| 1154 | -\`\`\` | |
| 654 | + "/auth/github/callback": async (req) => handleGithubCallback(req), | |
| 1155 | 655 | |
| 1156 | -When you push, the judge replays your commits and posts the verdict at [/agents/${reg.username}/string-calc](/agents/${reg.username}/string-calc). | |
| 1157 | - | |
| 1158 | -[← spec](/games/string-calc) · [all agents](/agents) | |
| 1159 | -`; | |
| 1160 | - | |
| 1161 | - const html = await renderPage({ | |
| 1162 | - title: `welcome ${reg.username} — tdd.md`, | |
| 1163 | - bodyMarkdown: body, | |
| 1164 | - active: "agents", | |
| 1165 | - noindex: true, | |
| 1166 | - }); | |
| 1167 | - return new Response(html, { | |
| 1168 | - headers: new Headers([ | |
| 1169 | - ["Content-Type", "text/html; charset=utf-8"], | |
| 1170 | - ["Set-Cookie", sessionCookie], | |
| 1171 | - ["Set-Cookie", clearOauthState], | |
| 1172 | - ]), | |
| 1173 | - }); | |
| 1174 | - }, | |
| 1175 | 656 | }, |
| 1176 | 657 | }); |
src/c21_handlers_agents.ts
+175
−0
| @@ -0,0 +1,175 @@ | ||
| 1 | +// c21 (agents) — handlers for /agents (index) and /agents/:name (detail). | |
| 2 | +// Both compose Forgejo admin lookups (c14) with kata progress (c31) and | |
| 3 | +// the verdict store (c13). The route table in c21_app.ts forwards the | |
| 4 | +// matching path here. | |
| 5 | + | |
| 6 | +import { | |
| 7 | + FORGEJO_URL, | |
| 8 | + adminApiHeaders, | |
| 9 | + type ForgejoUserSummary, | |
| 10 | +} from "./c14_forgejo.ts"; | |
| 11 | +import { computeProgress } from "./c31_commits.ts"; | |
| 12 | +import { loadGame } from "./c31_games.ts"; | |
| 13 | +import { allLatestRuns } from "./c13_database.ts"; | |
| 14 | +import { | |
| 15 | + renderPage, | |
| 16 | + renderNotFound, | |
| 17 | + htmlResponse, | |
| 18 | +} from "./c51_render_layout.ts"; | |
| 19 | + | |
| 20 | +export const renderAgentsIndex = async (): Promise<Response> => { | |
| 21 | + let users: ForgejoUserSummary[] = []; | |
| 22 | + const adminToken = process.env.FORGEJO_ADMIN_TOKEN; | |
| 23 | + if (adminToken) { | |
| 24 | + const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, { | |
| 25 | + headers: adminApiHeaders(), | |
| 26 | + }); | |
| 27 | + if (r.ok) users = (await r.json()) as ForgejoUserSummary[]; | |
| 28 | + } | |
| 29 | + // Drop the admin (id 1) and anyone whose visibility isn't "public" — | |
| 30 | + // private and limited agents stay invisible on the public index. | |
| 31 | + const agents = users.filter( | |
| 32 | + (u) => u.id !== 1 && !u.is_admin && (u.visibility ?? "public") === "public", | |
| 33 | + ); | |
| 34 | + | |
| 35 | + // Per-agent score totals from the latest run per repo. | |
| 36 | + const allRuns = allLatestRuns(); | |
| 37 | + const totalsByOwner = new Map<string, { score: number; runs: number }>(); | |
| 38 | + for (const r of allRuns) { | |
| 39 | + const t = totalsByOwner.get(r.owner) ?? { score: 0, runs: 0 }; | |
| 40 | + t.score += r.verdict.totalScore; | |
| 41 | + t.runs += 1; | |
| 42 | + totalsByOwner.set(r.owner, t); | |
| 43 | + } | |
| 44 | + | |
| 45 | + let body: string; | |
| 46 | + if (agents.length === 0) { | |
| 47 | + body = `# agents | |
| 48 | + | |
| 49 | +> No agents registered yet. Be the first. | |
| 50 | + | |
| 51 | +[ Register your agent → ](/agents/register) | |
| 52 | +`; | |
| 53 | + } else { | |
| 54 | + const rows = agents | |
| 55 | + .map((u) => { | |
| 56 | + const t = totalsByOwner.get(u.login) ?? { score: 0, runs: 0 }; | |
| 57 | + const sign = t.score >= 0 ? "+" : ""; | |
| 58 | + return `| [${u.login}](/agents/${u.login}) | ${t.runs} | ${sign}${t.score} |`; | |
| 59 | + }) | |
| 60 | + .join("\n"); | |
| 61 | + body = `# agents | |
| 62 | + | |
| 63 | +| agent | attempts | total score | | |
| 64 | +|---|---|---| | |
| 65 | +${rows} | |
| 66 | + | |
| 67 | +[ Register your agent → ](/agents/register) | |
| 68 | +`; | |
| 69 | + } | |
| 70 | + | |
| 71 | + const description = | |
| 72 | + agents.length === 0 | |
| 73 | + ? "AI agents doing test-driven development on tdd.md — registration is open, sign in with GitHub to play." | |
| 74 | + : `${agents.length} AI ${agents.length === 1 ? "agent" : "agents"} doing test-driven development on tdd.md, scored on red→green discipline against hidden tests for agentic coding.`; | |
| 75 | + | |
| 76 | + const html = await renderPage({ | |
| 77 | + title: "AI agents on tdd.md", | |
| 78 | + description, | |
| 79 | + bodyMarkdown: body, | |
| 80 | + ogPath: "https://tdd.md/agents", | |
| 81 | + active: "agents", | |
| 82 | + }); | |
| 83 | + return htmlResponse(html); | |
| 84 | +}; | |
| 85 | + | |
| 86 | +export const renderAgentDetail = async ( | |
| 87 | + name: string, | |
| 88 | + viewer: string | null, | |
| 89 | +): Promise<Response> => { | |
| 90 | + const userRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}`, { | |
| 91 | + headers: adminApiHeaders(), | |
| 92 | + }); | |
| 93 | + // Treat private/limited users as if they don't exist publicly — | |
| 94 | + // unless the logged-in viewer IS the owner. Owner can always see | |
| 95 | + // their own dashboard, public or not. | |
| 96 | + if (userRes.ok) { | |
| 97 | + const u = (await userRes.clone().json()) as ForgejoUserSummary; | |
| 98 | + const ownVisibility = u.visibility ?? "public"; | |
| 99 | + if (ownVisibility !== "public" && viewer !== name) { | |
| 100 | + const html = await renderNotFound(`/agents/${name}`); | |
| 101 | + return htmlResponse(html, 404); | |
| 102 | + } | |
| 103 | + } | |
| 104 | + if (userRes.status === 404) { | |
| 105 | + const html = await renderPage({ | |
| 106 | + title: `${name} — agents — tdd.md`, | |
| 107 | + bodyMarkdown: `# agents / ${name}\n\n> No agent registered with this name.\n\n[← all agents](/agents) · [register your own →](/agents/register)`, | |
| 108 | + ogPath: `https://tdd.md/agents/${name}`, | |
| 109 | + active: "agents", | |
| 110 | + }); | |
| 111 | + return htmlResponse(html, 404); | |
| 112 | + } | |
| 113 | + const reposRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}/repos?limit=50`, { | |
| 114 | + headers: adminApiHeaders(), | |
| 115 | + }); | |
| 116 | + const repos = reposRes.ok ? ((await reposRes.json()) as { name: string; description: string }[]) : []; | |
| 117 | + | |
| 118 | + const progressByRepo = await Promise.all( | |
| 119 | + repos.map(async (r) => { | |
| 120 | + const cRes = await fetch( | |
| 121 | + `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(name)}/${encodeURIComponent(r.name)}/commits?limit=50&stat=false`, | |
| 122 | + { headers: adminApiHeaders() }, | |
| 123 | + ); | |
| 124 | + const commits = cRes.ok ? ((await cRes.json()) as { commit: { message: string } }[]) : []; | |
| 125 | + return { repo: r, progress: computeProgress(commits) }; | |
| 126 | + }), | |
| 127 | + ); | |
| 128 | + | |
| 129 | + const totals: Record<string, number> = {}; | |
| 130 | + for (const r of repos) { | |
| 131 | + try { | |
| 132 | + const game = await loadGame(r.name); | |
| 133 | + totals[r.name] = game.steps.length; | |
| 134 | + } catch { | |
| 135 | + // unknown kata, no total | |
| 136 | + } | |
| 137 | + } | |
| 138 | + | |
| 139 | + const isSelf = viewer === name; | |
| 140 | + let body = `# agents / ${name}\n\n`; | |
| 141 | + if (isSelf) { | |
| 142 | + body += `> Welcome back, ${name}. This is your dashboard — only you and admins see it when your profile is private.\n\n`; | |
| 143 | + } | |
| 144 | + if (repos.length === 0) { | |
| 145 | + body += "> Registered, but no kata attempts yet.\n\n[← all agents](/agents)"; | |
| 146 | + } else { | |
| 147 | + body += "## attempts\n\n"; | |
| 148 | + body += "| kata | verified | phases |\n|---|---|---|\n"; | |
| 149 | + for (const { repo: r, progress } of progressByRepo) { | |
| 150 | + const total = totals[r.name]; | |
| 151 | + const verified = progress.verifiedSteps.size; | |
| 152 | + const counter = total !== undefined ? `${verified} / ${total}` : `${verified} / ?`; | |
| 153 | + const phases = `<span class="red">red ${progress.redCount}</span> · <span class="green">green ${progress.greenCount}</span> · <span class="blue">refactor ${progress.refactorCount}</span>`; | |
| 154 | + body += `| [${r.name}](/${name}/${r.name}) | ${counter} | ${phases} |\n`; | |
| 155 | + } | |
| 156 | + } | |
| 157 | + | |
| 158 | + if (isSelf) { | |
| 159 | + body += `\n\n---\n\n[sign out](/auth/logout) · [toggle visibility](#) <span class="muted">(POST /api/agents/${name}/visibility with your push token)</span>`; | |
| 160 | + } | |
| 161 | + | |
| 162 | + const verifiedSteps = progressByRepo.reduce((acc, p) => acc + p.progress.verifiedSteps.size, 0); | |
| 163 | + const description = | |
| 164 | + repos.length === 0 | |
| 165 | + ? `${name} just registered on tdd.md — no kata attempts yet.` | |
| 166 | + : `${name}'s TDD attempts on tdd.md: ${repos.length} ${repos.length === 1 ? "kata" : "katas"} pushed, ${verifiedSteps} verified red→green ${verifiedSteps === 1 ? "step" : "steps"}.`; | |
| 167 | + const html = await renderPage({ | |
| 168 | + title: `${name} · TDD attempts — tdd.md`, | |
| 169 | + description, | |
| 170 | + bodyMarkdown: body, | |
| 171 | + ogPath: `https://tdd.md/agents/${name}`, | |
| 172 | + active: "agents", | |
| 173 | + }); | |
| 174 | + return htmlResponse(html); | |
| 175 | +}; | |
src/c21_handlers_auth.ts
+145
−0
| @@ -0,0 +1,145 @@ | ||
| 1 | +// c21 (auth) — GitHub OAuth start + callback handlers. Composes | |
| 2 | +// c14_github (token exchange + user fetch), c14_forgejo (existence check | |
| 3 | +// + agent registration), c32_session (sign + cookie), c51 layout for | |
| 4 | +// the welcome page rendered after first-time registration. | |
| 5 | + | |
| 6 | +import * as github from "./c14_github.ts"; | |
| 7 | +import * as forgejo from "./c14_forgejo.ts"; | |
| 8 | +import { | |
| 9 | + SESSION_TTL_SEC, | |
| 10 | + parseCookies, | |
| 11 | + signSession, | |
| 12 | + sessionCookieHeader, | |
| 13 | + timingSafeEqual, | |
| 14 | + randomHex, | |
| 15 | +} from "./c32_session.ts"; | |
| 16 | +import { renderPage, errorPage } from "./c51_render_layout.ts"; | |
| 17 | + | |
| 18 | +const BASE_URL = process.env.BASE_URL ?? "https://tdd.md"; | |
| 19 | +const CALLBACK_URL = `${BASE_URL}/auth/github/callback`; | |
| 20 | + | |
| 21 | +const CLEAR_OAUTH_STATE = | |
| 22 | + "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0"; | |
| 23 | + | |
| 24 | +export const startGithubOauth = (): Response => { | |
| 25 | + if (!github.isConfigured() || !forgejo.isConfigured()) { | |
| 26 | + // errorPage is async; we wrap below. | |
| 27 | + return new Response("registration is not configured on this server", { status: 503 }); | |
| 28 | + } | |
| 29 | + const nonce = randomHex(16); | |
| 30 | + return new Response(null, { | |
| 31 | + status: 302, | |
| 32 | + headers: { | |
| 33 | + Location: github.authorizeUrl(nonce, CALLBACK_URL), | |
| 34 | + "Set-Cookie": `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, | |
| 35 | + }, | |
| 36 | + }); | |
| 37 | +}; | |
| 38 | + | |
| 39 | +const welcomeBody = (reg: forgejo.AgentRegistration): string => { | |
| 40 | + const verb = reg.isNew ? "created" : "rotated"; | |
| 41 | + return `# welcome, ${reg.username} | |
| 42 | + | |
| 43 | +> Your tdd.md agent has been ${verb}. **Save the token below — this page is the only time you'll see it.** If you lose it, [register again](/agents/register) to issue a fresh one (the old one will stop working). | |
| 44 | + | |
| 45 | +## push token | |
| 46 | + | |
| 47 | +\`\`\` | |
| 48 | +${reg.pushToken} | |
| 49 | +\`\`\` | |
| 50 | + | |
| 51 | +## kata: string-calc | |
| 52 | + | |
| 53 | +Your repo is at [\`git.tdd.md/${reg.username}/string-calc\`](https://git.tdd.md/${reg.username}/string-calc), already initialized with a default branch \`main\`. | |
| 54 | + | |
| 55 | +\`\`\` | |
| 56 | +git clone ${reg.repoCloneUrl} | |
| 57 | +cd string-calc | |
| 58 | + | |
| 59 | +# play the kata, commit per phase | |
| 60 | +# red: commit a failing test | |
| 61 | +# green: commit the impl that makes it pass | |
| 62 | +# refactor: commit a structural change with tests staying green | |
| 63 | + | |
| 64 | +git push | |
| 65 | +# username: ${reg.username} | |
| 66 | +# password: <paste the token above> | |
| 67 | +\`\`\` | |
| 68 | + | |
| 69 | +When you push, the judge replays your commits and posts the verdict at [/agents/${reg.username}/string-calc](/agents/${reg.username}/string-calc). | |
| 70 | + | |
| 71 | +[← spec](/games/string-calc) · [all agents](/agents) | |
| 72 | +`; | |
| 73 | +}; | |
| 74 | + | |
| 75 | +export const handleGithubCallback = async (req: Request): Promise<Response> => { | |
| 76 | + const url = new URL(req.url); | |
| 77 | + const code = url.searchParams.get("code"); | |
| 78 | + const state = url.searchParams.get("state"); | |
| 79 | + if (!code || !state) return errorPage("missing code or state"); | |
| 80 | + | |
| 81 | + const cookies = parseCookies(req.headers.get("cookie")); | |
| 82 | + const cookieState = cookies.tdd_oauth_state; | |
| 83 | + if (!cookieState || !timingSafeEqual(cookieState, state)) { | |
| 84 | + return errorPage("state mismatch — open the registration page again and retry"); | |
| 85 | + } | |
| 86 | + | |
| 87 | + let username: string; | |
| 88 | + let email: string; | |
| 89 | + let fullName: string | null; | |
| 90 | + try { | |
| 91 | + const accessToken = await github.exchangeCode(code, CALLBACK_URL); | |
| 92 | + const user = await github.fetchUser(accessToken); | |
| 93 | + username = user.login; | |
| 94 | + fullName = user.name; | |
| 95 | + // GitHub's noreply email format: unique per account, never collides | |
| 96 | + // with another Forgejo user. We don't need a deliverable address — | |
| 97 | + // agents authenticate by token, not by email reset flow. | |
| 98 | + email = `${user.id}+${user.login}@users.noreply.github.com`; | |
| 99 | + } catch (err) { | |
| 100 | + return errorPage(`github oauth failed: ${(err as Error).message}`, 400); | |
| 101 | + } | |
| 102 | + | |
| 103 | + // Login vs register: if the user already exists in Forgejo, this | |
| 104 | + // is a returning visitor — set the session cookie, redirect to | |
| 105 | + // their dashboard, don't rotate their token. | |
| 106 | + const isExisting = await forgejo.userExists(username); | |
| 107 | + const sessionToken = await signSession(username); | |
| 108 | + const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC); | |
| 109 | + | |
| 110 | + if (isExisting) { | |
| 111 | + return new Response(null, { | |
| 112 | + status: 302, | |
| 113 | + headers: new Headers([ | |
| 114 | + ["Location", `/agents/${username}`], | |
| 115 | + ["Set-Cookie", sessionCookie], | |
| 116 | + ["Set-Cookie", CLEAR_OAUTH_STATE], | |
| 117 | + ]), | |
| 118 | + }); | |
| 119 | + } | |
| 120 | + | |
| 121 | + let reg: forgejo.AgentRegistration; | |
| 122 | + try { | |
| 123 | + reg = await forgejo.registerAgent({ | |
| 124 | + username, | |
| 125 | + email, | |
| 126 | + fullName: fullName ?? undefined, | |
| 127 | + }); | |
| 128 | + } catch (err) { | |
| 129 | + return errorPage(`failed to create your agent: ${(err as Error).message}`, 422); | |
| 130 | + } | |
| 131 | + | |
| 132 | + const html = await renderPage({ | |
| 133 | + title: `welcome ${reg.username} — tdd.md`, | |
| 134 | + bodyMarkdown: welcomeBody(reg), | |
| 135 | + active: "agents", | |
| 136 | + noindex: true, | |
| 137 | + }); | |
| 138 | + return new Response(html, { | |
| 139 | + headers: new Headers([ | |
| 140 | + ["Content-Type", "text/html; charset=utf-8"], | |
| 141 | + ["Set-Cookie", sessionCookie], | |
| 142 | + ["Set-Cookie", CLEAR_OAUTH_STATE], | |
| 143 | + ]), | |
| 144 | + }); | |
| 145 | +}; | |
src/c21_handlers_leaderboard.ts
+71
−0
| @@ -0,0 +1,71 @@ | ||
| 1 | +// c21 (leaderboard) — handler that ranks tracked agents by their kata | |
| 2 | +// verdict totals. Forgejo admin lookup gives us the public/limited | |
| 3 | +// filter; c13 supplies the per-repo verdicts. | |
| 4 | + | |
| 5 | +import { | |
| 6 | + FORGEJO_URL, | |
| 7 | + adminApiHeaders, | |
| 8 | + type ForgejoUserSummary, | |
| 9 | +} from "./c14_forgejo.ts"; | |
| 10 | +import { allLatestRuns } from "./c13_database.ts"; | |
| 11 | +import { | |
| 12 | + renderPage, | |
| 13 | + htmlResponse, | |
| 14 | +} from "./c51_render_layout.ts"; | |
| 15 | + | |
| 16 | +export const renderLeaderboard = async (): Promise<Response> => { | |
| 17 | + // Only show runs whose owner is public. Fetch the user list once | |
| 18 | + // and build a Set so we can filter without N+1 lookups. | |
| 19 | + const adminToken = process.env.FORGEJO_ADMIN_TOKEN; | |
| 20 | + const publicOwners = new Set<string>(); | |
| 21 | + if (adminToken) { | |
| 22 | + const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, { | |
| 23 | + headers: adminApiHeaders(), | |
| 24 | + }); | |
| 25 | + if (r.ok) { | |
| 26 | + const users = (await r.json()) as ForgejoUserSummary[]; | |
| 27 | + for (const u of users) { | |
| 28 | + if ((u.visibility ?? "public") === "public") publicOwners.add(u.login); | |
| 29 | + } | |
| 30 | + } | |
| 31 | + } | |
| 32 | + const runs = allLatestRuns() | |
| 33 | + .filter((r) => publicOwners.size === 0 || publicOwners.has(r.owner)) | |
| 34 | + .sort((a, b) => b.verdict.totalScore - a.verdict.totalScore); | |
| 35 | + let body: string; | |
| 36 | + if (runs.length === 0) { | |
| 37 | + body = `# leaderboard | |
| 38 | + | |
| 39 | +> No verdicts yet. The first agent to push a red→green pair lands here. | |
| 40 | + | |
| 41 | +[ Register your agent → ](/agents/register) | |
| 42 | +`; | |
| 43 | + } else { | |
| 44 | + const rows = runs | |
| 45 | + .map((r, i) => { | |
| 46 | + const sign = r.verdict.totalScore >= 0 ? "+" : ""; | |
| 47 | + const verified = r.verdict.steps.filter((s) => s.status === "verified").length; | |
| 48 | + return `| ${i + 1} | [${r.owner}](/agents/${r.owner}) | [${r.repo}](/${r.owner}/${r.repo}) | ${sign}${r.verdict.totalScore} | ${verified} |`; | |
| 49 | + }) | |
| 50 | + .join("\n"); | |
| 51 | + body = `# leaderboard | |
| 52 | + | |
| 53 | +| rank | agent | kata | score | verified steps | | |
| 54 | +|---|---|---|---|---| | |
| 55 | +${rows} | |
| 56 | +`; | |
| 57 | + } | |
| 58 | + const description = | |
| 59 | + runs.length === 0 | |
| 60 | + ? "TDD leaderboard for AI agents on tdd.md — be the first verdict." | |
| 61 | + : `Top AI agents by TDD score on tdd.md — ${runs.length} ranked ${runs.length === 1 ? "submission" : "submissions"} graded on red→green discipline and hidden test pass rate.`; | |
| 62 | + | |
| 63 | + const html = await renderPage({ | |
| 64 | + title: "TDD leaderboard — tdd.md", | |
| 65 | + description, | |
| 66 | + bodyMarkdown: body, | |
| 67 | + ogPath: "https://tdd.md/leaderboard", | |
| 68 | + active: "leaderboard", | |
| 69 | + }); | |
| 70 | + return htmlResponse(html); | |
| 71 | +}; | |
src/c21_handlers_repo_view.ts
+207
−0
| @@ -0,0 +1,207 @@ | ||
| 1 | +// c21 (repo-view) — handler that renders the bare /:owner/:repo page. | |
| 2 | +// Composes c14_forgejo (repo + commits via admin API), c31 commits + | |
| 3 | +// games (parsing, kata lookup), c13 verdict store, c51 layout helpers. | |
| 4 | +// Exposed via the c21_app.ts fallback fetch — reserved top-level routes | |
| 5 | +// are matched first, this is the catch-all for /<owner>/<repo>. | |
| 6 | + | |
| 7 | +import { | |
| 8 | + FORGEJO_URL, | |
| 9 | + adminApiHeaders, | |
| 10 | + getUserVisibility, | |
| 11 | +} from "./c14_forgejo.ts"; | |
| 12 | +import { parseCommit, computeProgress } from "./c31_commits.ts"; | |
| 13 | +import { loadGame } from "./c31_games.ts"; | |
| 14 | +import { latestRun } from "./c13_database.ts"; | |
| 15 | +import { | |
| 16 | + renderPage, | |
| 17 | + renderNotFound, | |
| 18 | + htmlResponse, | |
| 19 | + phaseSpan, | |
| 20 | + relativeTime, | |
| 21 | +} from "./c51_render_layout.ts"; | |
| 22 | + | |
| 23 | +interface ForgejoRepoSummary { | |
| 24 | + description: string; | |
| 25 | + clone_url: string; | |
| 26 | + empty: boolean; | |
| 27 | + private: boolean; | |
| 28 | +} | |
| 29 | + | |
| 30 | +interface ForgejoCommit { | |
| 31 | + sha: string; | |
| 32 | + commit: { message: string; author: { name: string; date: string } }; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export const renderRepoView = async ( | |
| 36 | + owner: string, | |
| 37 | + repo: string, | |
| 38 | + viewer: string | null, | |
| 39 | +): Promise<Response> => { | |
| 40 | + // Private/limited owners get a 404 to anonymous visitors — but the | |
| 41 | + // owner themselves (verified via session cookie) can always see | |
| 42 | + // their own pages. | |
| 43 | + const ownerVisibility = await getUserVisibility(owner); | |
| 44 | + if (ownerVisibility !== null && ownerVisibility !== "public" && viewer !== owner) { | |
| 45 | + const html = await renderNotFound(`/${owner}/${repo}`); | |
| 46 | + return htmlResponse(html, 404); | |
| 47 | + } | |
| 48 | + | |
| 49 | + const repoApi = `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; | |
| 50 | + const repoRes = await fetch(repoApi, { headers: adminApiHeaders() }); | |
| 51 | + if (repoRes.status === 404) { | |
| 52 | + const html = await renderNotFound(`/${owner}/${repo}`); | |
| 53 | + return htmlResponse(html, 404); | |
| 54 | + } | |
| 55 | + if (!repoRes.ok) { | |
| 56 | + const html = await renderPage({ | |
| 57 | + title: `${owner}/${repo} — tdd.md`, | |
| 58 | + bodyMarkdown: `# ${owner}/${repo}\n\n> repository unavailable`, | |
| 59 | + }); | |
| 60 | + return htmlResponse(html, 502); | |
| 61 | + } | |
| 62 | + const info = (await repoRes.json()) as ForgejoRepoSummary; | |
| 63 | + const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`; | |
| 64 | + const isPrivate = info.private === true; | |
| 65 | + | |
| 66 | + // The repo name is by convention the kata id. If the kata exists, the | |
| 67 | + // header link is meaningful and we know the total step count. | |
| 68 | + let totalSteps: number | null = null; | |
| 69 | + let kataExists = false; | |
| 70 | + try { | |
| 71 | + const game = await loadGame(repo); | |
| 72 | + totalSteps = game.steps.length; | |
| 73 | + kataExists = true; | |
| 74 | + } catch { | |
| 75 | + // Repo isn't a known kata — still render, just without step totals. | |
| 76 | + } | |
| 77 | + | |
| 78 | + let commits: ForgejoCommit[] = []; | |
| 79 | + if (!info.empty) { | |
| 80 | + const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`, { | |
| 81 | + headers: adminApiHeaders(), | |
| 82 | + }); | |
| 83 | + if (commitsRes.ok) commits = (await commitsRes.json()) as ForgejoCommit[]; | |
| 84 | + } | |
| 85 | + const progress = computeProgress(commits); | |
| 86 | + const verified = progress.verifiedSteps.size; | |
| 87 | + | |
| 88 | + let status: string; | |
| 89 | + if (commits.length === 0) { | |
| 90 | + status = "awaiting first push"; | |
| 91 | + } else if (totalSteps !== null && verified >= totalSteps) { | |
| 92 | + status = "kata complete"; | |
| 93 | + } else if (verified > 0) { | |
| 94 | + status = "in progress"; | |
| 95 | + } else { | |
| 96 | + status = "no verified steps yet"; | |
| 97 | + } | |
| 98 | + const stepCounter = totalSteps !== null ? `${verified} / ${totalSteps}` : `${verified} / ?`; | |
| 99 | + | |
| 100 | + let phaseLog: string; | |
| 101 | + if (commits.length === 0) { | |
| 102 | + phaseLog = "_No commits yet — push your first `red:` commit to start the cycle._"; | |
| 103 | + } else { | |
| 104 | + const rows = commits.map((c) => { | |
| 105 | + const sha = c.sha.slice(0, 7); | |
| 106 | + const p = parseCommit(c.commit.message); | |
| 107 | + const subject = (p.subject || c.commit.message.split("\n")[0] || "").replace(/\|/g, "\\|"); | |
| 108 | + const stepCell = p.step ? `\`${p.step}\`` : "—"; | |
| 109 | + return `| \`${sha}\` | ${phaseSpan(p.phase)} | ${stepCell} | ${subject} | ${relativeTime(c.commit.author.date)} |`; | |
| 110 | + }); | |
| 111 | + phaseLog = `| sha | phase | step | message | when |\n|---|---|---|---|---|\n${rows.join("\n")}`; | |
| 112 | + } | |
| 113 | + | |
| 114 | + const kataLink = kataExists | |
| 115 | + ? `[\`${repo}\` →](/games/${repo})` | |
| 116 | + : `\`${repo}\``; | |
| 117 | + const privateBadge = isPrivate ? ` <span class="muted">[private]</span>` : ""; | |
| 118 | + | |
| 119 | + const verdict = latestRun(owner, repo); | |
| 120 | + const headSha = commits[0]?.sha ?? null; | |
| 121 | + const verdictStale = verdict !== null && headSha !== null && verdict.headSha !== headSha; | |
| 122 | + | |
| 123 | + let scoreSection: string; | |
| 124 | + if (verdict === null) { | |
| 125 | + scoreSection = `> Not yet judged. The next push triggers a judge run, or [run the judge now](/api/judge/${owner}/${repo}) (POST).\n\nPhase tally: <span class="red">red ${progress.redCount}</span> · <span class="green">green ${progress.greenCount}</span> · <span class="blue">refactor ${progress.refactorCount}</span>${progress.untaggedCount > 0 ? ` · <span class="muted">untagged ${progress.untaggedCount}</span>` : ""}.`; | |
| 126 | + } else { | |
| 127 | + const stale = verdictStale ? ` · <span class="muted">stale — newer commits not yet judged</span>` : ""; | |
| 128 | + const sign = verdict.totalScore >= 0 ? "+" : ""; | |
| 129 | + const statusClass = (status: string): string => { | |
| 130 | + if (status === "verified") return "green"; | |
| 131 | + if (status === "discipline-only") return "blue"; | |
| 132 | + if (status === "no-green") return "muted"; | |
| 133 | + return "red"; | |
| 134 | + }; | |
| 135 | + const modeLabel = (m: string): string => { | |
| 136 | + const cls = m === "strict" ? "red" : m === "pragmatic" ? "blue" : "green"; | |
| 137 | + return `<span class="${cls}">${m}</span>`; | |
| 138 | + }; | |
| 139 | + const rows = verdict.steps.length === 0 | |
| 140 | + ? "_No red→green pairs found yet._" | |
| 141 | + : `| step | red | green | hidden | status | points | explanation |\n|---|---|---|---|---|---|---|\n` + | |
| 142 | + verdict.steps.map((s) => { | |
| 143 | + const cls = statusClass(s.status); | |
| 144 | + const sign = s.scoreDelta >= 0 ? "+" : ""; | |
| 145 | + const hiddenCell = | |
| 146 | + s.hiddenPassed === true ? `<span class="green">pass</span>` : | |
| 147 | + s.hiddenPassed === false ? `<span class="red">fail</span>` : | |
| 148 | + `<span class="muted">—</span>`; | |
| 149 | + const explanation = (s.explanation ?? "").replace(/\|/g, "\\|"); | |
| 150 | + return `| \`${s.stepId}\` | \`${s.redSha?.slice(0, 7) ?? "—"}\` | \`${s.greenSha?.slice(0, 7) ?? "—"}\` | ${hiddenCell} | <span class="${cls}">${s.status}</span> | ${sign}${s.scoreDelta} | ${explanation} |`; | |
| 151 | + }).join("\n"); | |
| 152 | + const refactorRows = (verdict.refactors ?? []).length === 0 | |
| 153 | + ? "" | |
| 154 | + : `\n\n### refactors\n\n| sha | step | tests | points | explanation |\n|---|---|---|---|---|\n` + | |
| 155 | + verdict.refactors.map((r) => { | |
| 156 | + const sign = r.scoreDelta >= 0 ? "+" : ""; | |
| 157 | + const cls = r.testsPassed ? "green" : "red"; | |
| 158 | + const verb = r.testsPassed ? "green" : "broke tests"; | |
| 159 | + const explanation = (r.explanation ?? "").replace(/\|/g, "\\|"); | |
| 160 | + return `| \`${r.sha.slice(0, 7)}\` | ${r.stepId ? `\`${r.stepId}\`` : "—"} | <span class="${cls}">${verb}</span> | ${sign}${r.scoreDelta} | ${explanation} |`; | |
| 161 | + }).join("\n"); | |
| 162 | + const modeLine = verdict.mode ? `**mode: ${modeLabel(verdict.mode)}** · ` : ""; | |
| 163 | + scoreSection = `${modeLine}**total: ${sign}${verdict.totalScore}** · judged ${relativeTime(new Date(verdict.judgedAt).toISOString())}${stale}\n\n${rows}${refactorRows}`; | |
| 164 | + } | |
| 165 | + | |
| 166 | + const body = `# ${owner} · playing ${kataLink}${privateBadge} | |
| 167 | + | |
| 168 | +> ${status} | |
| 169 | +> **${stepCounter}** steps verified | |
| 170 | + | |
| 171 | +## phase log | |
| 172 | + | |
| 173 | +${phaseLog} | |
| 174 | + | |
| 175 | +## score | |
| 176 | + | |
| 177 | +${scoreSection} | |
| 178 | + | |
| 179 | +## clone | |
| 180 | + | |
| 181 | +\`\`\` | |
| 182 | +git clone ${cloneUrl} | |
| 183 | +\`\`\` | |
| 184 | + | |
| 185 | +[← /agents/${owner}](/agents/${owner})${kataExists ? ` · [kata spec →](/games/${repo})` : ""} | |
| 186 | +`; | |
| 187 | + | |
| 188 | + // Dynamic description tailored to this attempt — gives every agent | |
| 189 | + // run a unique snippet for search results and social previews instead | |
| 190 | + // of falling back to the site default. | |
| 191 | + const totalSnippet = | |
| 192 | + verdict !== null | |
| 193 | + ? `, score ${verdict.totalScore >= 0 ? "+" : ""}${verdict.totalScore}` | |
| 194 | + : ""; | |
| 195 | + const description = kataExists | |
| 196 | + ? `${owner}'s ${repo} TDD kata attempt on tdd.md — ${verified}${totalSteps !== null ? `/${totalSteps}` : ""} steps verified${totalSnippet}.` | |
| 197 | + : `${owner}/${repo} on tdd.md — ${commits.length} ${commits.length === 1 ? "commit" : "commits"} in the phase log${totalSnippet}.`; | |
| 198 | + | |
| 199 | + const html = await renderPage({ | |
| 200 | + title: `${owner} · ${repo}${kataExists ? " TDD kata" : ""} — tdd.md`, | |
| 201 | + description, | |
| 202 | + bodyMarkdown: body, | |
| 203 | + ogPath: `https://tdd.md/${owner}/${repo}`, | |
| 204 | + active: "agents", | |
| 205 | + }); | |
| 206 | + return htmlResponse(html); | |
| 207 | +}; | |
src/c51_render.ts
+0
−528
| @@ -1,528 +0,0 @@ | ||
| 1 | -// c51 — UI: HTML rendering. Page chrome (renderPage / renderNotFound) | |
| 2 | -// plus all per-page body builders. Imports types from c13/c31; never | |
| 3 | -// from c11 or c21 (lower-numbered layers can be imported, higher ones | |
| 4 | -// cannot). | |
| 5 | - | |
| 6 | -import { marked } from "marked"; | |
| 7 | -import type { ProjectRow } from "./c13_database.ts"; | |
| 8 | -import { PROJECT_CONFIG_PATH } from "./c31_project_config.ts"; | |
| 9 | -import type { Phase } from "./c31_commits.ts"; | |
| 10 | -import { | |
| 11 | - DEMO_PERIOD, | |
| 12 | - DEMO_ORG, | |
| 13 | - DEMO_REPOS, | |
| 14 | - DEMO_REPORTS, | |
| 15 | - DEMO_SNAPSHOTS, | |
| 16 | - DEMO_STABILITY, | |
| 17 | - type AgentReport, | |
| 18 | - type FailureSlice, | |
| 19 | - type TestSnapshot, | |
| 20 | - type TestStability, | |
| 21 | -} from "./c31_reports_demo.ts"; | |
| 22 | - | |
| 23 | -const STYLE_CSS = "./public/style.css"; | |
| 24 | -const css = await Bun.file(STYLE_CSS).text(); | |
| 25 | - | |
| 26 | -export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard"; | |
| 27 | - | |
| 28 | -export interface PageOptions { | |
| 29 | - title: string; | |
| 30 | - bodyMarkdown: string; | |
| 31 | - description?: string; | |
| 32 | - ogPath?: string; | |
| 33 | - active?: Section; | |
| 34 | - noindex?: boolean; | |
| 35 | - jsonLd?: Record<string, unknown>; | |
| 36 | -} | |
| 37 | - | |
| 38 | -const SITE_DESCRIPTION = "Test-driven development for agentic coding. Scored katas, public verdicts."; | |
| 39 | - | |
| 40 | -const escape = (s: string): string => | |
| 41 | - s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); | |
| 42 | - | |
| 43 | -const navLink = (href: string, label: string, active: boolean): string => { | |
| 44 | - const cls = active ? ' class="nav-active"' : ""; | |
| 45 | - return `<a href="${href}"${cls}>${label}</a>`; | |
| 46 | -}; | |
| 47 | - | |
| 48 | -const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`; | |
| 49 | - | |
| 50 | -export const renderPage = async (opts: PageOptions): Promise<string> => { | |
| 51 | - const body = await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false }); | |
| 52 | - const description = opts.description ?? SITE_DESCRIPTION; | |
| 53 | - const ogPath = opts.ogPath ?? "https://tdd.md"; | |
| 54 | - const robots = opts.noindex ? `<meta name="robots" content="noindex,nofollow">\n` : ""; | |
| 55 | - const jsonLd = opts.jsonLd | |
| 56 | - ? `<script type="application/ld+json">${JSON.stringify(opts.jsonLd)}</script>\n` | |
| 57 | - : ""; | |
| 58 | - return `<!doctype html> | |
| 59 | -<html lang="en"> | |
| 60 | -<head> | |
| 61 | -<meta charset="utf-8"> | |
| 62 | -<meta name="viewport" content="width=device-width,initial-scale=1"> | |
| 63 | -<meta name="color-scheme" content="dark light"> | |
| 64 | -<meta name="description" content="${escape(description)}"> | |
| 65 | -${robots}<link rel="canonical" href="${escape(ogPath)}"> | |
| 66 | -<meta property="og:title" content="${escape(opts.title)}"> | |
| 67 | -<meta property="og:description" content="${escape(description)}"> | |
| 68 | -<meta property="og:type" content="website"> | |
| 69 | -<meta property="og:url" content="${escape(ogPath)}"> | |
| 70 | -<meta property="og:image" content="https://tdd.md/og.svg"> | |
| 71 | -<meta property="og:image:type" content="image/svg+xml"> | |
| 72 | -<meta property="og:image:width" content="1200"> | |
| 73 | -<meta property="og:image:height" content="630"> | |
| 74 | -<meta property="og:site_name" content="tdd.md"> | |
| 75 | -<meta name="twitter:card" content="summary_large_image"> | |
| 76 | -<meta name="twitter:title" content="${escape(opts.title)}"> | |
| 77 | -<meta name="twitter:description" content="${escape(description)}"> | |
| 78 | -<meta name="twitter:image" content="https://tdd.md/og.svg"> | |
| 79 | -<title>${escape(opts.title)}</title> | |
| 80 | -${jsonLd}<style>${css}</style> | |
| 81 | -</head> | |
| 82 | -<body> | |
| 83 | -${nav(opts.active)} | |
| 84 | -<main class="md"> | |
| 85 | -${body} | |
| 86 | -</main> | |
| 87 | -</body> | |
| 88 | -</html>`; | |
| 89 | -}; | |
| 90 | - | |
| 91 | -export const renderNotFound = async (path: string): Promise<string> => | |
| 92 | - renderPage({ | |
| 93 | - title: "404 — tdd.md", | |
| 94 | - bodyMarkdown: `# 404\n\n> No such path: \`${path}\`\n\nTry [home](/), [games](/games), [agents](/agents), or [leaderboard](/leaderboard).`, | |
| 95 | - noindex: true, | |
| 96 | - }); | |
| 97 | - | |
| 98 | -// --------------------------------------------------------------------- | |
| 99 | -// Small response/formatting helpers used by c21 handlers. | |
| 100 | -// --------------------------------------------------------------------- | |
| 101 | - | |
| 102 | -export const htmlResponse = (html: string, status = 200): Response => | |
| 103 | - new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); | |
| 104 | - | |
| 105 | -export const errorPage = async (message: string, status = 400): Promise<Response> => { | |
| 106 | - const html = await renderPage({ | |
| 107 | - title: "error — tdd.md", | |
| 108 | - bodyMarkdown: `# error\n\n> ${message}\n\n[← back](/agents/register)`, | |
| 109 | - active: "agents", | |
| 110 | - }); | |
| 111 | - return htmlResponse(html, status); | |
| 112 | -}; | |
| 113 | - | |
| 114 | -export const phaseSpan = (p: Phase): string => { | |
| 115 | - const cls = p === "red" ? "red" : p === "green" ? "green" : p === "refactor" ? "blue" : "muted"; | |
| 116 | - return `<span class="${cls}">${p}</span>`; | |
| 117 | -}; | |
| 118 | - | |
| 119 | -export const relativeTime = (iso: string): string => { | |
| 120 | - const ms = Date.now() - new Date(iso).getTime(); | |
| 121 | - if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s ago`; | |
| 122 | - if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`; | |
| 123 | - if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`; | |
| 124 | - return `${Math.floor(ms / 86_400_000)}d ago`; | |
| 125 | -}; | |
| 126 | - | |
| 127 | -// --------------------------------------------------------------------- | |
| 128 | -// Body builders for /projects. | |
| 129 | -// --------------------------------------------------------------------- | |
| 130 | - | |
| 131 | -const projectListRow = (p: ProjectRow): string => { | |
| 132 | - const slug = `${p.repoOwner}/${p.repoName}`; | |
| 133 | - const display = p.displayName ?? slug; | |
| 134 | - const team = p.team ? ` <span class="muted">· ${escape(p.team)}</span>` : ""; | |
| 135 | - const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", "); | |
| 136 | - const runner = p.testRunner === "none" ? "trace-only" : p.testRunner; | |
| 137 | - return `| [${escape(display)}](/projects/${p.repoOwner}/${p.repoName}) ${team} | ${branches} | ${runner} |`; | |
| 138 | -}; | |
| 139 | - | |
| 140 | -export const projectsLandingMd = (projects: ProjectRow[]): string => { | |
| 141 | - const rows = projects.length === 0 | |
| 142 | - ? `| _no projects yet — [register one](/projects/new)_ | | |` | |
| 143 | - : projects.map(projectListRow).join("\n"); | |
| 144 | - return `# projects | |
| 145 | - | |
| 146 | -> Real repos that opted in to tdd.md scoring. Each project drops \`${PROJECT_CONFIG_PATH}\` at its root, registers here, and from then on its commits on tracked branches get judged structurally — red-fails, green-passes, no test-deletion, no regression. The aggregated scores feed [the reports](/reports). | |
| 147 | - | |
| 148 | -## tracked | |
| 149 | - | |
| 150 | -| project | branches | runner | | |
| 151 | -|---|---|---| | |
| 152 | -${rows} | |
| 153 | - | |
| 154 | -## register a repo | |
| 155 | - | |
| 156 | -[Register a project →](/projects/new) — paste a public GitHub URL; tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from the default branch and onboards it. | |
| 157 | - | |
| 158 | -## the config file | |
| 159 | - | |
| 160 | -Drop \`${PROJECT_CONFIG_PATH}\` at the root of your repo's default branch: | |
| 161 | - | |
| 162 | -\`\`\`json | |
| 163 | -{ | |
| 164 | - "version": 1, | |
| 165 | - "test_runner": "none", | |
| 166 | - "tracked_branches": ["main"], | |
| 167 | - "display_name": "API Gateway", | |
| 168 | - "team": "platform" | |
| 169 | -} | |
| 170 | -\`\`\` | |
| 171 | - | |
| 172 | -- **\`test_runner\`** — \`"none"\` for trace-mode (commit-discipline only, language-agnostic). \`"bun"\` will run the test suite once the sandbox-runner ships. | |
| 173 | -- **\`tracked_branches\`** — pushes to these branches get scored. Defaults to \`["main"]\`. | |
| 174 | -- **\`display_name\`** / **\`team\`** — optional, only used in the reporting UI. | |
| 175 | - | |
| 176 | -## what comes next | |
| 177 | - | |
| 178 | -Registration just stores the project. Per-commit judging (the part that produces score data for the reports) lands in the next sliver — until then the [report pages](/reports) keep showing the demo dataset. | |
| 179 | - | |
| 180 | -[← back to tdd.md](/) · [the reports](/reports) | |
| 181 | -`; | |
| 182 | -}; | |
| 183 | - | |
| 184 | -export const projectRegisterMd = ( | |
| 185 | - viewer: string | null, | |
| 186 | - prefilled?: string, | |
| 187 | - errorMessage?: string, | |
| 188 | -): string => { | |
| 189 | - if (!viewer) { | |
| 190 | - return `# register a project | |
| 191 | - | |
| 192 | -> You need to sign in before registering a project. We use your GitHub identity to record who onboarded the repo. | |
| 193 | - | |
| 194 | -[ sign in with github → ](/auth/github/start) | |
| 195 | - | |
| 196 | -[← all projects](/projects) | |
| 197 | -`; | |
| 198 | - } | |
| 199 | - const error = errorMessage | |
| 200 | - ? `<div class="project-form-error"><strong>Couldn't register that repo:</strong><br>${escape(errorMessage)}</div>` | |
| 201 | - : ""; | |
| 202 | - const value = prefilled ? ` value="${escape(prefilled)}"` : ""; | |
| 203 | - return `# register a project | |
| 204 | - | |
| 205 | -> Paste a public GitHub URL. tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from its default branch, validates it, and onboards the repo. Re-register the same repo to refresh the config. | |
| 206 | - | |
| 207 | -${error} | |
| 208 | - | |
| 209 | -<form method="post" action="/projects/new" class="project-form"> | |
| 210 | - <label for="repo-url">Repository URL or <code>owner/name</code></label> | |
| 211 | - <input id="repo-url" name="repo" type="text" required | |
| 212 | - placeholder="https://github.com/owner/name" | |
| 213 | - autocomplete="off" autocapitalize="off" autocorrect="off"${value} /> | |
| 214 | - <button type="submit">Register</button> | |
| 215 | -</form> | |
| 216 | - | |
| 217 | -> Signed in as <code>${escape(viewer)}</code>. Don't have \`${PROJECT_CONFIG_PATH}\` yet? [See the format on /projects](/projects#the-config-file). | |
| 218 | - | |
| 219 | -[← all projects](/projects) | |
| 220 | -`; | |
| 221 | -}; | |
| 222 | - | |
| 223 | -// --------------------------------------------------------------------- | |
| 224 | -// Body builders for /reports. | |
| 225 | -// --------------------------------------------------------------------- | |
| 226 | - | |
| 227 | -const trendArrow = (delta: number): { glyph: string; cls: string } => | |
| 228 | - delta > 0 ? { glyph: "↑", cls: "up" } : delta < 0 ? { glyph: "↓", cls: "down" } : { glyph: "→", cls: "flat" }; | |
| 229 | - | |
| 230 | -const sparkline = (values: number[], height = 60, width = 320): string => { | |
| 231 | - if (values.length === 0) return ""; | |
| 232 | - const min = Math.min(...values); | |
| 233 | - const max = Math.max(...values); | |
| 234 | - const range = Math.max(1, max - min); | |
| 235 | - const stepX = width / Math.max(1, values.length - 1); | |
| 236 | - const pad = 6; | |
| 237 | - const innerH = height - pad * 2; | |
| 238 | - const points = values | |
| 239 | - .map((v, i) => { | |
| 240 | - const x = (i * stepX).toFixed(1); | |
| 241 | - const y = (pad + innerH - ((v - min) / range) * innerH).toFixed(1); | |
| 242 | - return `${x},${y}`; | |
| 243 | - }) | |
| 244 | - .join(" "); | |
| 245 | - return `<svg class="report-sparkline" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true"> | |
| 246 | - <polyline fill="none" stroke="currentColor" stroke-width="1.5" points="${points}" /> | |
| 247 | -</svg>`; | |
| 248 | -}; | |
| 249 | - | |
| 250 | -const tile = (a: AgentReport): string => { | |
| 251 | - const arr = trendArrow(a.delta); | |
| 252 | - const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`; | |
| 253 | - return `<div class="report-tile"> | |
| 254 | - <p class="report-tile-name"><a href="/reports/demo/agents/${a.slug}">${escape(a.name)}</a></p> | |
| 255 | - <p class="report-tile-score">${a.score}<span class="report-tile-score-suffix"> / 100</span></p> | |
| 256 | - <p class="report-tile-trend ${arr.cls}">${arr.glyph} ${escape(deltaStr)}</p> | |
| 257 | - <p class="report-tile-volume">${a.commits.toLocaleString()} commits</p> | |
| 258 | - <div class="report-tile-issue">top issue: <strong>${escape(a.topIssueLabel)}</strong> (${a.topIssuePct}%)</div> | |
| 259 | -</div>`; | |
| 260 | -}; | |
| 261 | - | |
| 262 | -const bars = (mix: FailureSlice[]): string => { | |
| 263 | - const rows = mix | |
| 264 | - .map( | |
| 265 | - (s) => `<div class="report-bar-row"> | |
| 266 | - <span class="report-bar-label">${escape(s.label)}</span> | |
| 267 | - <span class="report-bar-track"><span class="report-bar-fill ${s.tone}" style="width: ${s.pct}%"></span></span> | |
| 268 | - <span class="report-bar-pct">${s.pct}%</span> | |
| 269 | -</div>`, | |
| 270 | - ) | |
| 271 | - .join("\n"); | |
| 272 | - return `<div class="report-bars">${rows}</div>`; | |
| 273 | -}; | |
| 274 | - | |
| 275 | -const streakBox = (a: AgentReport): string => { | |
| 276 | - const cls = a.streakBroken ? "broken" : a.streak >= 30 ? "long" : ""; | |
| 277 | - const label = a.streakBroken ? "recent break" : "consecutive clean cycles"; | |
| 278 | - return `<span class="report-streak ${cls}"><span class="report-streak-num">${a.streak}</span> ${label}</span>`; | |
| 279 | -}; | |
| 280 | - | |
| 281 | -const mockBanner = `<div class="report-mockup-banner">demo data — real reporting wires up when the project-tracking pipeline ships. <a href="/blog/tweag-handbook-tdd">why tdd.md needs this</a> · <a href="/reports">about reporting</a></div>`; | |
| 282 | - | |
| 283 | -const snapshotBlock = (s: TestSnapshot): string => { | |
| 284 | - const failuresHtml = s.failures.length === 0 | |
| 285 | - ? `<li class="test-list-pass">all ${s.passing} tests groen</li>` | |
| 286 | - : s.failures | |
| 287 | - .map( | |
| 288 | - (f) => | |
| 289 | - `<li class="test-list-fail">${escape(f.test)} <span class="test-list-meta">${f.flaky ? "intermittent · " : ""}sinds ${f.since}</span></li>`, | |
| 290 | - ) | |
| 291 | - .concat([`<li class="test-list-collapsed">+ ${s.passing.toLocaleString()} passing tests</li>`]) | |
| 292 | - .join("\n"); | |
| 293 | - const statusCls = s.failing === 0 ? "ok" : "bad"; | |
| 294 | - return `<div class="test-snapshot ${statusCls}"> | |
| 295 | - <p class="test-snapshot-head"><strong>${escape(s.repo)}</strong> <span class="test-snapshot-branch">@ ${escape(s.branch)}</span></p> | |
| 296 | - <p class="test-snapshot-stats">${s.total.toLocaleString()} tests · <span class="green">${s.passing.toLocaleString()} passing</span>${s.failing > 0 ? ` · <span class="red">${s.failing.toLocaleString()} failing</span>` : ""}</p> | |
| 297 | - <ul class="test-list"> | |
| 298 | -${failuresHtml} | |
| 299 | - </ul> | |
| 300 | -</div>`; | |
| 301 | -}; | |
| 302 | - | |
| 303 | -const agentTagHtml = (slug: AgentReport["slug"]): string => { | |
| 304 | - const name = DEMO_REPORTS.find((r) => r.slug === slug)?.name ?? slug; | |
| 305 | - return `<a class="agent-tag" href="/reports/demo/agents/${slug}">${escape(name)}</a>`; | |
| 306 | -}; | |
| 307 | - | |
| 308 | -const stabilityRow = (s: TestStability): string => { | |
| 309 | - const cls = s.flagged ? "test-stab-row flagged" : "test-stab-row"; | |
| 310 | - const warn = s.flagged ? ` <span class="test-stab-warn" title="test-deletion of weakening dit kwartaal">⚠</span>` : ""; | |
| 311 | - return `<tr class="${cls}"> | |
| 312 | - <td class="test-stab-name">${escape(s.test)}<div class="test-stab-repo">${escape(s.repo)}</div></td> | |
| 313 | - <td class="test-stab-num green">${s.pass}</td> | |
| 314 | - <td class="test-stab-num ${s.fail >= 8 ? "red" : ""}">${s.fail}</td> | |
| 315 | - <td class="test-stab-num ${s.deleted > 0 ? "red" : ""}">${s.deleted}</td> | |
| 316 | - <td class="test-stab-by">${agentTagHtml(s.lastBrokenBy)}${warn}</td> | |
| 317 | -</tr>`; | |
| 318 | -}; | |
| 319 | - | |
| 320 | -export const reportsLandingMd = (): string => `# reports | |
| 321 | - | |
| 322 | -> Per-agent TDD-discipline reporting over real project repos. The judge replays each commit on tracked branches and scores it structurally — red-fails, green-passes, no test-deletion, no regression. The scores roll up per agent over time, with trend, failure-mode breakdown, and an exec summary fit for a quarterly readout. | |
| 323 | - | |
| 324 | -This is a design preview. The pipeline that ingests real repos isn't wired yet; what you can navigate today is a mockup with synthetic data: | |
| 325 | - | |
| 326 | -- [exec summary mockup →](/reports/demo) — single page, 1 quarter, 3 agents | |
| 327 | -- [per-agent drill-down →](/reports/demo/agents/cursor) — trend, failure mix, recent flagged commits | |
| 328 | -- [tests overzicht →](/reports/demo/tests) — huidige stand per repo + test-stabiliteit per test-naam | |
| 329 | - | |
| 330 | -Want a real repo on this layer? [Register a project →](/projects) — drops \`.tdd-md.json\` at the repo root, onboards in seconds. Per-commit judging follows in the next sliver; until then registered projects show up under [/projects](/projects) but don't yet feed the report numbers. | |
| 331 | - | |
| 332 | -## what gets measured | |
| 333 | - | |
| 334 | -This layer measures **discipline**, not code-quality. Without hidden tests (those only exist on katas), tdd.md can't catch tautologies or weakened assertions on real repos. It *can* catch: | |
| 335 | - | |
| 336 | -| failure mode | what triggers it | what it costs | | |
| 337 | -|---|---|---| | |
| 338 | -| \`red-did-not-fail\` | commit tagged \`red:\` but tests pass | -5 / commit | | |
| 339 | -| \`test-deleted\` | test count drops between commits | -20 / commit | | |
| 340 | -| \`broken refactor\` | tests fail at a \`refactor:\` commit | -5 / commit | | |
| 341 | -| \`no phase tag\` | tracked-branch commit missing \`red\\|green\\|refactor:\` | counts against phase-coverage % | | |
| 342 | - | |
| 343 | -The metric pair that anchors the report is **discipline-score** (0-100) + **phase-coverage %**. An agent with 0% phase-coverage doesn't *do* TDD — its score is N/A, not 0. Don't let a low-volume non-attempt look like a high-volume slip. | |
| 344 | - | |
| 345 | -## reading the data | |
| 346 | - | |
| 347 | -For management: | |
| 348 | -- the [exec summary](/reports/demo) gives one number per agent + a narrative paragraph. Prints to one page. | |
| 349 | - | |
| 350 | -For team-leads: | |
| 351 | -- the [drill-down](/reports/demo/agents/cursor) shows trend, failure-mix, streak, and the most recent flagged commits with one-click coaching links to the [Claude Code](/blog/claude-code-tdd) / [Cursor](/blog/cursor-tdd) / [Aider](/blog/aider-tdd) posts. | |
| 352 | - | |
| 353 | -[← back to tdd.md](/) · [the blog](/blog) · [the katas](/games) | |
| 354 | -`; | |
| 355 | - | |
| 356 | -export const execSummaryMd = (): string => { | |
| 357 | - const totalCommits = DEMO_REPORTS.reduce((s, a) => s + a.commits, 0); | |
| 358 | - const tiles = DEMO_REPORTS.map(tile).join("\n"); | |
| 359 | - return `# tdd-discipline rapport · q1 2026 | |
| 360 | - | |
| 361 | -${mockBanner} | |
| 362 | - | |
| 363 | -> **Periode** ${DEMO_PERIOD} · **Scope** ${DEMO_REPOS} repos · ${totalCommits.toLocaleString()} AI-toegeschreven commits in ${escape(DEMO_ORG)}. | |
| 364 | - | |
| 365 | -<div class="report-tiles"> | |
| 366 | -${tiles} | |
| 367 | -</div> | |
| 368 | - | |
| 369 | -## wat veranderde dit kwartaal | |
| 370 | - | |
| 371 | -Cursor's score zakte 15 punten nadat agent-mode in maart default werd; test-deletion-incidenten stegen van 2% naar 14% van refactor-commits, geconcentreerd in de \`api-gateway\` repo. Claude Code's score steeg na invoering van phase-getagde commit-prefix in CLAUDE.md aan het einde van januari. Aider blijft stabiel hoog — auto-commit-per-edit voorkomt het meeste cross-phase bedrog vanzelf. | |
| 372 | - | |
| 373 | -## wat we doen | |
| 374 | - | |
| 375 | -- **Cursor in \`api-gateway\`**: agent-mode gedeactiveerd voor refactor-prompts, CONVENTIONS-regel "never delete a test in a refactor commit" gepind ([details →](/reports/demo/agents/cursor)). | |
| 376 | -- **Claude Code uitrollen**: het CLAUDE.md-template dat in \`billing-service\` werkte naar de andere drie repos kopiëren. | |
| 377 | -- **Volgende meting**: 2026-04-30, mid-Q2, om te zien of de Cursor-fix vasthoudt. | |
| 378 | - | |
| 379 | -## wat dit getal *niet* meet | |
| 380 | - | |
| 381 | -Discipline, niet code-kwaliteit. Hidden tests (zoals op de katas) bestaan niet voor productie-repos, dus *tautologische* tests en *zwak-geformuleerde* asserties blijven onzichtbaar voor de judge. Dit cijfer zegt: "de agent volgt de TDD-cyclus eerlijk". Het zegt niets over of de tests die hij schrijft het juiste beweren. Voor dat tweede signaal blijft kata-performance ([leaderboard](/leaderboard)) de proxy. | |
| 382 | - | |
| 383 | ---- | |
| 384 | - | |
| 385 | -[per-agent drill-down: Claude Code](/reports/demo/agents/claude-code) · [Cursor](/reports/demo/agents/cursor) · [Aider](/reports/demo/agents/aider) · [tests overzicht](/reports/demo/tests) · [back to /reports](/reports) | |
| 386 | -`; | |
| 387 | -}; | |
| 388 | - | |
| 389 | -export const agentDrilldownMd = (slug: AgentReport["slug"]): string | null => { | |
| 390 | - const a = DEMO_REPORTS.find((r) => r.slug === slug); | |
| 391 | - if (!a) return null; | |
| 392 | - const arr = trendArrow(a.delta); | |
| 393 | - const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`; | |
| 394 | - const recentRows = a.recent | |
| 395 | - .map( | |
| 396 | - (r) => | |
| 397 | - `| ${r.date} | \`${r.repo}\` | \`${r.sha}\` | ${r.phase} | ${r.failure} | ${r.pts} |`, | |
| 398 | - ) | |
| 399 | - .join("\n"); | |
| 400 | - return `# ${a.name} · drill-down | |
| 401 | - | |
| 402 | -${mockBanner} | |
| 403 | - | |
| 404 | -> Discipline-score **${a.score} / 100** <span class="report-tile-trend ${arr.cls}">${arr.glyph} ${deltaStr}</span> over ${DEMO_PERIOD}. ${a.commits.toLocaleString()} commits geanalyseerd, phase-coverage **${a.phaseCoveragePct}%**. | |
| 405 | - | |
| 406 | -## trend (30 dagen) | |
| 407 | - | |
| 408 | -<div class="${arr.cls === "down" ? "red" : arr.cls === "up" ? "green" : "muted"}"> | |
| 409 | -${sparkline(a.trend)} | |
| 410 | -</div> | |
| 411 | - | |
| 412 | -${streakBox(a)} | |
| 413 | - | |
| 414 | -## failure-mode breakdown | |
| 415 | - | |
| 416 | -${bars(a.failureMix)} | |
| 417 | - | |
| 418 | -Top issue dit kwartaal: **${escape(a.topIssueLabel)}** (${a.topIssuePct}% van commits). | |
| 419 | - | |
| 420 | -## recent flagged | |
| 421 | - | |
| 422 | -| date | repo | sha | phase | failure | pts | | |
| 423 | -|---|---|---|---|---|---| | |
| 424 | -${recentRows} | |
| 425 | - | |
| 426 | -## coaching | |
| 427 | - | |
| 428 | -- ${a.slug === "claude-code" ? `[Claude Code does not do TDD by default](/blog/claude-code-tdd) — CLAUDE.md rules + fresh-context boundaries that prevent \`red-did-not-fail\`.` : a.slug === "cursor" ? `[Cursor knows how to do TDD; users skip the parts that matter](/blog/cursor-tdd) — Plan Mode, fresh chats, \`.cursor/rules\` to stop test-deletion.` : `[Aider is the closest agent to TDD on rails — until \`--auto-test\`](/blog/aider-tdd) — keep auto-test off for green commits, on for refactor.`} | |
| 429 | -- [Tweag's TDD handbook needs a judge](/blog/tweag-handbook-tdd) — why local green isn't enough. | |
| 430 | - | |
| 431 | ---- | |
| 432 | - | |
| 433 | -[← exec summary](/reports/demo) · [back to /reports](/reports) | |
| 434 | -`; | |
| 435 | -}; | |
| 436 | - | |
| 437 | -export const testsOverviewMd = (): string => { | |
| 438 | - const total = DEMO_SNAPSHOTS.reduce((s, r) => s + r.total, 0); | |
| 439 | - const passing = DEMO_SNAPSHOTS.reduce((s, r) => s + r.passing, 0); | |
| 440 | - const failing = DEMO_SNAPSHOTS.reduce((s, r) => s + r.failing, 0); | |
| 441 | - const snapshots = DEMO_SNAPSHOTS.map(snapshotBlock).join("\n"); | |
| 442 | - const stabRows = DEMO_STABILITY.map(stabilityRow).join("\n"); | |
| 443 | - return `# tests overzicht | |
| 444 | - | |
| 445 | -${mockBanner} | |
| 446 | - | |
| 447 | -> Snapshot van de huidige test-stand per repo + stabiliteit van individuele tests over ${DEMO_PERIOD}. Een hoge fail-count zonder deletion betekent dat de test echte regressies vangt; hoge fail+deletion is het signaal dat een test onder druk komt te staan — vaak het spoor van een agent die het makkelijker maakt zichzelf te laten "winnen". | |
| 448 | - | |
| 449 | -## huidige stand · per repo | |
| 450 | - | |
| 451 | -<div class="test-snapshots"> | |
| 452 | -${snapshots} | |
| 453 | -</div> | |
| 454 | - | |
| 455 | -**Totaal**: ${total.toLocaleString()} tests · <span class="green">${passing.toLocaleString()} passing</span> · <span class="${failing > 0 ? "red" : "muted"}">${failing.toLocaleString()} failing</span>. | |
| 456 | - | |
| 457 | -## test-stabiliteit · q1 2026 | |
| 458 | - | |
| 459 | -Top 12 meest-flappende tests dit kwartaal, met aantal pass/fail/deleted-events en de agent die de test het laatst heeft gebroken. | |
| 460 | - | |
| 461 | -<table class="test-stability"> | |
| 462 | -<thead> | |
| 463 | - <tr> | |
| 464 | - <th>test</th> | |
| 465 | - <th class="num">pass</th> | |
| 466 | - <th class="num">fail</th> | |
| 467 | - <th class="num">del</th> | |
| 468 | - <th>laatst gebroken door</th> | |
| 469 | - </tr> | |
| 470 | -</thead> | |
| 471 | -<tbody> | |
| 472 | -${stabRows} | |
| 473 | -</tbody> | |
| 474 | -</table> | |
| 475 | - | |
| 476 | -> ⚠ markeert tests waarbij dit kwartaal een test-deletion of weakening-event is gedetecteerd. In een echte setup linkt klik op een test-naam door naar de commit-historie van die specifieke test. | |
| 477 | - | |
| 478 | -## hoe lees je dit | |
| 479 | - | |
| 480 | -- **Veel pass, weinig fail, 0 del**: gezond. Test doet wat hij moet, niemand sloopt 'm. | |
| 481 | -- **Veel fail, 0 del**: test vangt actief regressies. Goed nieuws — discipline werkt. | |
| 482 | -- **Fail én del > 0**: test wordt onder druk gezet. Coach de agent die 'm gebroken heeft (klik op het tag-icoon). | |
| 483 | -- **Snapshot rood + stabiliteit hoog**: bekende, langlopende kapotte test. Apart onderwerp, niet per se een agent-probleem. | |
| 484 | - | |
| 485 | ---- | |
| 486 | - | |
| 487 | -[← exec summary](/reports/demo) · [back to /reports](/reports) | |
| 488 | -`; | |
| 489 | -}; | |
| 490 | - | |
| 491 | -// --------------------------------------------------------------------- | |
| 492 | -// Body builder for /projects/:owner/:repo. | |
| 493 | -// --------------------------------------------------------------------- | |
| 494 | - | |
| 495 | -export const projectDetailMd = (p: ProjectRow): string => { | |
| 496 | - const display = p.displayName ?? `${p.repoOwner}/${p.repoName}`; | |
| 497 | - const registeredAt = new Date(p.registeredAt).toISOString().slice(0, 10); | |
| 498 | - const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", "); | |
| 499 | - const runnerNote = p.testRunner === "none" | |
| 500 | - ? "Trace-mode — judging looks at commit phase tags, test-count drift, and refactor stability. No test execution." | |
| 501 | - : "Bun runner — test suite executes in a sandbox at every tracked-branch commit. (Sandbox-runner ships in the next sliver; meanwhile this falls back to trace-mode.)"; | |
| 502 | - return `# ${escape(display)} | |
| 503 | - | |
| 504 | -> [${escape(p.repoOwner)}/${escape(p.repoName)}](https://github.com/${p.repoOwner}/${p.repoName}) · registered by [${escape(p.registeredBy)}](/agents/${p.registeredBy}) on ${registeredAt}. | |
| 505 | - | |
| 506 | -## config | |
| 507 | - | |
| 508 | -| key | value | | |
| 509 | -|---|---| | |
| 510 | -| test_runner | \`${p.testRunner}\` | | |
| 511 | -| tracked_branches | ${branches} | | |
| 512 | -| display_name | ${p.displayName ? `\`${escape(p.displayName)}\`` : "_(none)_"} | | |
| 513 | -| team | ${p.team ? `\`${escape(p.team)}\`` : "_(none)_"} | | |
| 514 | -| status | \`${p.status}\` | | |
| 515 | - | |
| 516 | -${runnerNote} | |
| 517 | - | |
| 518 | -## scored commits | |
| 519 | - | |
| 520 | -> _No commits judged yet._ The webhook ingest + judging pipeline lands in the next sliver — once it does, scored commits for tracked branches will appear here grouped by agent. | |
| 521 | - | |
| 522 | -## refresh | |
| 523 | - | |
| 524 | -Push an updated \`${PROJECT_CONFIG_PATH}\` to your default branch and [re-register](/projects/new?repo=${encodeURIComponent(`${p.repoOwner}/${p.repoName}`)}) to pick up the new config. | |
| 525 | - | |
| 526 | -[← all projects](/projects) | |
| 527 | -`; | |
| 528 | -}; | |
src/c51_render_layout.ts
+113
−0
| @@ -0,0 +1,113 @@ | ||
| 1 | +// c51 (layout) — UI: page chrome + small response/format helpers shared | |
| 2 | +// across every domain. Bigger per-domain body builders live next to this | |
| 3 | +// file as `c51_render_<domain>.ts` (projects, reports). Layout exports | |
| 4 | +// `escape`, `renderPage`, `renderNotFound`, `htmlResponse`, `errorPage`, | |
| 5 | +// `phaseSpan`, `relativeTime`, plus the `Section` + `PageOptions` types. | |
| 6 | +// Per the SAMA convention, lower layers don't import from this one. | |
| 7 | + | |
| 8 | +import { marked } from "marked"; | |
| 9 | +import type { Phase } from "./c31_commits.ts"; | |
| 10 | + | |
| 11 | +const STYLE_CSS = "./public/style.css"; | |
| 12 | +const css = await Bun.file(STYLE_CSS).text(); | |
| 13 | + | |
| 14 | +export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard"; | |
| 15 | + | |
| 16 | +export interface PageOptions { | |
| 17 | + title: string; | |
| 18 | + bodyMarkdown: string; | |
| 19 | + description?: string; | |
| 20 | + ogPath?: string; | |
| 21 | + active?: Section; | |
| 22 | + noindex?: boolean; | |
| 23 | + jsonLd?: Record<string, unknown>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const SITE_DESCRIPTION = "Test-driven development for agentic coding. Scored katas, public verdicts."; | |
| 27 | + | |
| 28 | +export const escape = (s: string): string => | |
| 29 | + s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); | |
| 30 | + | |
| 31 | +const navLink = (href: string, label: string, active: boolean): string => { | |
| 32 | + const cls = active ? ' class="nav-active"' : ""; | |
| 33 | + return `<a href="${href}"${cls}>${label}</a>`; | |
| 34 | +}; | |
| 35 | + | |
| 36 | +const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`; | |
| 37 | + | |
| 38 | +export const renderPage = async (opts: PageOptions): Promise<string> => { | |
| 39 | + const body = await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false }); | |
| 40 | + const description = opts.description ?? SITE_DESCRIPTION; | |
| 41 | + const ogPath = opts.ogPath ?? "https://tdd.md"; | |
| 42 | + const robots = opts.noindex ? `<meta name="robots" content="noindex,nofollow">\n` : ""; | |
| 43 | + const jsonLd = opts.jsonLd | |
| 44 | + ? `<script type="application/ld+json">${JSON.stringify(opts.jsonLd)}</script>\n` | |
| 45 | + : ""; | |
| 46 | + return `<!doctype html> | |
| 47 | +<html lang="en"> | |
| 48 | +<head> | |
| 49 | +<meta charset="utf-8"> | |
| 50 | +<meta name="viewport" content="width=device-width,initial-scale=1"> | |
| 51 | +<meta name="color-scheme" content="dark light"> | |
| 52 | +<meta name="description" content="${escape(description)}"> | |
| 53 | +${robots}<link rel="canonical" href="${escape(ogPath)}"> | |
| 54 | +<meta property="og:title" content="${escape(opts.title)}"> | |
| 55 | +<meta property="og:description" content="${escape(description)}"> | |
| 56 | +<meta property="og:type" content="website"> | |
| 57 | +<meta property="og:url" content="${escape(ogPath)}"> | |
| 58 | +<meta property="og:image" content="https://tdd.md/og.svg"> | |
| 59 | +<meta property="og:image:type" content="image/svg+xml"> | |
| 60 | +<meta property="og:image:width" content="1200"> | |
| 61 | +<meta property="og:image:height" content="630"> | |
| 62 | +<meta property="og:site_name" content="tdd.md"> | |
| 63 | +<meta name="twitter:card" content="summary_large_image"> | |
| 64 | +<meta name="twitter:title" content="${escape(opts.title)}"> | |
| 65 | +<meta name="twitter:description" content="${escape(description)}"> | |
| 66 | +<meta name="twitter:image" content="https://tdd.md/og.svg"> | |
| 67 | +<title>${escape(opts.title)}</title> | |
| 68 | +${jsonLd}<style>${css}</style> | |
| 69 | +</head> | |
| 70 | +<body> | |
| 71 | +${nav(opts.active)} | |
| 72 | +<main class="md"> | |
| 73 | +${body} | |
| 74 | +</main> | |
| 75 | +</body> | |
| 76 | +</html>`; | |
| 77 | +}; | |
| 78 | + | |
| 79 | +export const renderNotFound = async (path: string): Promise<string> => | |
| 80 | + renderPage({ | |
| 81 | + title: "404 — tdd.md", | |
| 82 | + bodyMarkdown: `# 404\n\n> No such path: \`${path}\`\n\nTry [home](/), [games](/games), [agents](/agents), or [leaderboard](/leaderboard).`, | |
| 83 | + noindex: true, | |
| 84 | + }); | |
| 85 | + | |
| 86 | +// --------------------------------------------------------------------- | |
| 87 | +// Small response/formatting helpers used by c21 handlers + domain renders. | |
| 88 | +// --------------------------------------------------------------------- | |
| 89 | + | |
| 90 | +export const htmlResponse = (html: string, status = 200): Response => | |
| 91 | + new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); | |
| 92 | + | |
| 93 | +export const errorPage = async (message: string, status = 400): Promise<Response> => { | |
| 94 | + const html = await renderPage({ | |
| 95 | + title: "error — tdd.md", | |
| 96 | + bodyMarkdown: `# error\n\n> ${message}\n\n[← back](/agents/register)`, | |
| 97 | + active: "agents", | |
| 98 | + }); | |
| 99 | + return htmlResponse(html, status); | |
| 100 | +}; | |
| 101 | + | |
| 102 | +export const phaseSpan = (p: Phase): string => { | |
| 103 | + const cls = p === "red" ? "red" : p === "green" ? "green" : p === "refactor" ? "blue" : "muted"; | |
| 104 | + return `<span class="${cls}">${p}</span>`; | |
| 105 | +}; | |
| 106 | + | |
| 107 | +export const relativeTime = (iso: string): string => { | |
| 108 | + const ms = Date.now() - new Date(iso).getTime(); | |
| 109 | + if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s ago`; | |
| 110 | + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`; | |
| 111 | + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`; | |
| 112 | + return `${Math.floor(ms / 86_400_000)}d ago`; | |
| 113 | +}; | |
src/c51_render_projects.ts
+133
−0
| @@ -0,0 +1,133 @@ | ||
| 1 | +// c51 (projects) — body builders for /projects, /projects/new, | |
| 2 | +// /projects/:owner/:repo. Imports chrome helpers from c51_render_layout. | |
| 3 | + | |
| 4 | +import type { ProjectRow } from "./c13_database.ts"; | |
| 5 | +import { PROJECT_CONFIG_PATH } from "./c31_project_config.ts"; | |
| 6 | +import { escape } from "./c51_render_layout.ts"; | |
| 7 | + | |
| 8 | +const projectListRow = (p: ProjectRow): string => { | |
| 9 | + const slug = `${p.repoOwner}/${p.repoName}`; | |
| 10 | + const display = p.displayName ?? slug; | |
| 11 | + const team = p.team ? ` <span class="muted">· ${escape(p.team)}</span>` : ""; | |
| 12 | + const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", "); | |
| 13 | + const runner = p.testRunner === "none" ? "trace-only" : p.testRunner; | |
| 14 | + return `| [${escape(display)}](/projects/${p.repoOwner}/${p.repoName}) ${team} | ${branches} | ${runner} |`; | |
| 15 | +}; | |
| 16 | + | |
| 17 | +export const projectsLandingMd = (projects: ProjectRow[]): string => { | |
| 18 | + const rows = projects.length === 0 | |
| 19 | + ? `| _no projects yet — [register one](/projects/new)_ | | |` | |
| 20 | + : projects.map(projectListRow).join("\n"); | |
| 21 | + return `# projects | |
| 22 | + | |
| 23 | +> Real repos that opted in to tdd.md scoring. Each project drops \`${PROJECT_CONFIG_PATH}\` at its root, registers here, and from then on its commits on tracked branches get judged structurally — red-fails, green-passes, no test-deletion, no regression. The aggregated scores feed [the reports](/reports). | |
| 24 | + | |
| 25 | +## tracked | |
| 26 | + | |
| 27 | +| project | branches | runner | | |
| 28 | +|---|---|---| | |
| 29 | +${rows} | |
| 30 | + | |
| 31 | +## register a repo | |
| 32 | + | |
| 33 | +[Register a project →](/projects/new) — paste a public GitHub URL; tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from the default branch and onboards it. | |
| 34 | + | |
| 35 | +## the config file | |
| 36 | + | |
| 37 | +Drop \`${PROJECT_CONFIG_PATH}\` at the root of your repo's default branch: | |
| 38 | + | |
| 39 | +\`\`\`json | |
| 40 | +{ | |
| 41 | + "version": 1, | |
| 42 | + "test_runner": "none", | |
| 43 | + "tracked_branches": ["main"], | |
| 44 | + "display_name": "API Gateway", | |
| 45 | + "team": "platform" | |
| 46 | +} | |
| 47 | +\`\`\` | |
| 48 | + | |
| 49 | +- **\`test_runner\`** — \`"none"\` for trace-mode (commit-discipline only, language-agnostic). \`"bun"\` will run the test suite once the sandbox-runner ships. | |
| 50 | +- **\`tracked_branches\`** — pushes to these branches get scored. Defaults to \`["main"]\`. | |
| 51 | +- **\`display_name\`** / **\`team\`** — optional, only used in the reporting UI. | |
| 52 | + | |
| 53 | +## what comes next | |
| 54 | + | |
| 55 | +Registration just stores the project. Per-commit judging (the part that produces score data for the reports) lands in the next sliver — until then the [report pages](/reports) keep showing the demo dataset. | |
| 56 | + | |
| 57 | +[← back to tdd.md](/) · [the reports](/reports) | |
| 58 | +`; | |
| 59 | +}; | |
| 60 | + | |
| 61 | +export const projectRegisterMd = ( | |
| 62 | + viewer: string | null, | |
| 63 | + prefilled?: string, | |
| 64 | + errorMessage?: string, | |
| 65 | +): string => { | |
| 66 | + if (!viewer) { | |
| 67 | + return `# register a project | |
| 68 | + | |
| 69 | +> You need to sign in before registering a project. We use your GitHub identity to record who onboarded the repo. | |
| 70 | + | |
| 71 | +[ sign in with github → ](/auth/github/start) | |
| 72 | + | |
| 73 | +[← all projects](/projects) | |
| 74 | +`; | |
| 75 | + } | |
| 76 | + const error = errorMessage | |
| 77 | + ? `<div class="project-form-error"><strong>Couldn't register that repo:</strong><br>${escape(errorMessage)}</div>` | |
| 78 | + : ""; | |
| 79 | + const value = prefilled ? ` value="${escape(prefilled)}"` : ""; | |
| 80 | + return `# register a project | |
| 81 | + | |
| 82 | +> Paste a public GitHub URL. tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from its default branch, validates it, and onboards the repo. Re-register the same repo to refresh the config. | |
| 83 | + | |
| 84 | +${error} | |
| 85 | + | |
| 86 | +<form method="post" action="/projects/new" class="project-form"> | |
| 87 | + <label for="repo-url">Repository URL or <code>owner/name</code></label> | |
| 88 | + <input id="repo-url" name="repo" type="text" required | |
| 89 | + placeholder="https://github.com/owner/name" | |
| 90 | + autocomplete="off" autocapitalize="off" autocorrect="off"${value} /> | |
| 91 | + <button type="submit">Register</button> | |
| 92 | +</form> | |
| 93 | + | |
| 94 | +> Signed in as <code>${escape(viewer)}</code>. Don't have \`${PROJECT_CONFIG_PATH}\` yet? [See the format on /projects](/projects#the-config-file). | |
| 95 | + | |
| 96 | +[← all projects](/projects) | |
| 97 | +`; | |
| 98 | +}; | |
| 99 | + | |
| 100 | +export const projectDetailMd = (p: ProjectRow): string => { | |
| 101 | + const display = p.displayName ?? `${p.repoOwner}/${p.repoName}`; | |
| 102 | + const registeredAt = new Date(p.registeredAt).toISOString().slice(0, 10); | |
| 103 | + const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", "); | |
| 104 | + const runnerNote = p.testRunner === "none" | |
| 105 | + ? "Trace-mode — judging looks at commit phase tags, test-count drift, and refactor stability. No test execution." | |
| 106 | + : "Bun runner — test suite executes in a sandbox at every tracked-branch commit. (Sandbox-runner ships in the next sliver; meanwhile this falls back to trace-mode.)"; | |
| 107 | + return `# ${escape(display)} | |
| 108 | + | |
| 109 | +> [${escape(p.repoOwner)}/${escape(p.repoName)}](https://github.com/${p.repoOwner}/${p.repoName}) · registered by [${escape(p.registeredBy)}](/agents/${p.registeredBy}) on ${registeredAt}. | |
| 110 | + | |
| 111 | +## config | |
| 112 | + | |
| 113 | +| key | value | | |
| 114 | +|---|---| | |
| 115 | +| test_runner | \`${p.testRunner}\` | | |
| 116 | +| tracked_branches | ${branches} | | |
| 117 | +| display_name | ${p.displayName ? `\`${escape(p.displayName)}\`` : "_(none)_"} | | |
| 118 | +| team | ${p.team ? `\`${escape(p.team)}\`` : "_(none)_"} | | |
| 119 | +| status | \`${p.status}\` | | |
| 120 | + | |
| 121 | +${runnerNote} | |
| 122 | + | |
| 123 | +## scored commits | |
| 124 | + | |
| 125 | +> _No commits judged yet._ The webhook ingest + judging pipeline lands in the next sliver — once it does, scored commits for tracked branches will appear here grouped by agent. | |
| 126 | + | |
| 127 | +## refresh | |
| 128 | + | |
| 129 | +Push an updated \`${PROJECT_CONFIG_PATH}\` to your default branch and [re-register](/projects/new?repo=${encodeURIComponent(`${p.repoOwner}/${p.repoName}`)}) to pick up the new config. | |
| 130 | + | |
| 131 | +[← all projects](/projects) | |
| 132 | +`; | |
| 133 | +}; | |
src/c51_render_reports.ts
+281
−0
| @@ -0,0 +1,281 @@ | ||
| 1 | +// c51 (reports) — body builders for /reports, /reports/demo, | |
| 2 | +// /reports/demo/agents/:slug, /reports/demo/tests. All synthetic data | |
| 3 | +// comes from c31_reports_demo; chrome helpers come from c51_render_layout. | |
| 4 | + | |
| 5 | +import { | |
| 6 | + DEMO_PERIOD, | |
| 7 | + DEMO_ORG, | |
| 8 | + DEMO_REPOS, | |
| 9 | + DEMO_REPORTS, | |
| 10 | + DEMO_SNAPSHOTS, | |
| 11 | + DEMO_STABILITY, | |
| 12 | + type AgentReport, | |
| 13 | + type FailureSlice, | |
| 14 | + type TestSnapshot, | |
| 15 | + type TestStability, | |
| 16 | +} from "./c31_reports_demo.ts"; | |
| 17 | +import { escape } from "./c51_render_layout.ts"; | |
| 18 | + | |
| 19 | +const trendArrow = (delta: number): { glyph: string; cls: string } => | |
| 20 | + delta > 0 ? { glyph: "↑", cls: "up" } : delta < 0 ? { glyph: "↓", cls: "down" } : { glyph: "→", cls: "flat" }; | |
| 21 | + | |
| 22 | +const sparkline = (values: number[], height = 60, width = 320): string => { | |
| 23 | + if (values.length === 0) return ""; | |
| 24 | + const min = Math.min(...values); | |
| 25 | + const max = Math.max(...values); | |
| 26 | + const range = Math.max(1, max - min); | |
| 27 | + const stepX = width / Math.max(1, values.length - 1); | |
| 28 | + const pad = 6; | |
| 29 | + const innerH = height - pad * 2; | |
| 30 | + const points = values | |
| 31 | + .map((v, i) => { | |
| 32 | + const x = (i * stepX).toFixed(1); | |
| 33 | + const y = (pad + innerH - ((v - min) / range) * innerH).toFixed(1); | |
| 34 | + return `${x},${y}`; | |
| 35 | + }) | |
| 36 | + .join(" "); | |
| 37 | + return `<svg class="report-sparkline" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true"> | |
| 38 | + <polyline fill="none" stroke="currentColor" stroke-width="1.5" points="${points}" /> | |
| 39 | +</svg>`; | |
| 40 | +}; | |
| 41 | + | |
| 42 | +const tile = (a: AgentReport): string => { | |
| 43 | + const arr = trendArrow(a.delta); | |
| 44 | + const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`; | |
| 45 | + return `<div class="report-tile"> | |
| 46 | + <p class="report-tile-name"><a href="/reports/demo/agents/${a.slug}">${escape(a.name)}</a></p> | |
| 47 | + <p class="report-tile-score">${a.score}<span class="report-tile-score-suffix"> / 100</span></p> | |
| 48 | + <p class="report-tile-trend ${arr.cls}">${arr.glyph} ${escape(deltaStr)}</p> | |
| 49 | + <p class="report-tile-volume">${a.commits.toLocaleString()} commits</p> | |
| 50 | + <div class="report-tile-issue">top issue: <strong>${escape(a.topIssueLabel)}</strong> (${a.topIssuePct}%)</div> | |
| 51 | +</div>`; | |
| 52 | +}; | |
| 53 | + | |
| 54 | +const bars = (mix: FailureSlice[]): string => { | |
| 55 | + const rows = mix | |
| 56 | + .map( | |
| 57 | + (s) => `<div class="report-bar-row"> | |
| 58 | + <span class="report-bar-label">${escape(s.label)}</span> | |
| 59 | + <span class="report-bar-track"><span class="report-bar-fill ${s.tone}" style="width: ${s.pct}%"></span></span> | |
| 60 | + <span class="report-bar-pct">${s.pct}%</span> | |
| 61 | +</div>`, | |
| 62 | + ) | |
| 63 | + .join("\n"); | |
| 64 | + return `<div class="report-bars">${rows}</div>`; | |
| 65 | +}; | |
| 66 | + | |
| 67 | +const streakBox = (a: AgentReport): string => { | |
| 68 | + const cls = a.streakBroken ? "broken" : a.streak >= 30 ? "long" : ""; | |
| 69 | + const label = a.streakBroken ? "recent break" : "consecutive clean cycles"; | |
| 70 | + return `<span class="report-streak ${cls}"><span class="report-streak-num">${a.streak}</span> ${label}</span>`; | |
| 71 | +}; | |
| 72 | + | |
| 73 | +const mockBanner = `<div class="report-mockup-banner">demo data — real reporting wires up when the project-tracking pipeline ships. <a href="/blog/tweag-handbook-tdd">why tdd.md needs this</a> · <a href="/reports">about reporting</a></div>`; | |
| 74 | + | |
| 75 | +const snapshotBlock = (s: TestSnapshot): string => { | |
| 76 | + const failuresHtml = s.failures.length === 0 | |
| 77 | + ? `<li class="test-list-pass">all ${s.passing} tests groen</li>` | |
| 78 | + : s.failures | |
| 79 | + .map( | |
| 80 | + (f) => | |
| 81 | + `<li class="test-list-fail">${escape(f.test)} <span class="test-list-meta">${f.flaky ? "intermittent · " : ""}sinds ${f.since}</span></li>`, | |
| 82 | + ) | |
| 83 | + .concat([`<li class="test-list-collapsed">+ ${s.passing.toLocaleString()} passing tests</li>`]) | |
| 84 | + .join("\n"); | |
| 85 | + const statusCls = s.failing === 0 ? "ok" : "bad"; | |
| 86 | + return `<div class="test-snapshot ${statusCls}"> | |
| 87 | + <p class="test-snapshot-head"><strong>${escape(s.repo)}</strong> <span class="test-snapshot-branch">@ ${escape(s.branch)}</span></p> | |
| 88 | + <p class="test-snapshot-stats">${s.total.toLocaleString()} tests · <span class="green">${s.passing.toLocaleString()} passing</span>${s.failing > 0 ? ` · <span class="red">${s.failing.toLocaleString()} failing</span>` : ""}</p> | |
| 89 | + <ul class="test-list"> | |
| 90 | +${failuresHtml} | |
| 91 | + </ul> | |
| 92 | +</div>`; | |
| 93 | +}; | |
| 94 | + | |
| 95 | +const agentTagHtml = (slug: AgentReport["slug"]): string => { | |
| 96 | + const name = DEMO_REPORTS.find((r) => r.slug === slug)?.name ?? slug; | |
| 97 | + return `<a class="agent-tag" href="/reports/demo/agents/${slug}">${escape(name)}</a>`; | |
| 98 | +}; | |
| 99 | + | |
| 100 | +const stabilityRow = (s: TestStability): string => { | |
| 101 | + const cls = s.flagged ? "test-stab-row flagged" : "test-stab-row"; | |
| 102 | + const warn = s.flagged ? ` <span class="test-stab-warn" title="test-deletion of weakening dit kwartaal">⚠</span>` : ""; | |
| 103 | + return `<tr class="${cls}"> | |
| 104 | + <td class="test-stab-name">${escape(s.test)}<div class="test-stab-repo">${escape(s.repo)}</div></td> | |
| 105 | + <td class="test-stab-num green">${s.pass}</td> | |
| 106 | + <td class="test-stab-num ${s.fail >= 8 ? "red" : ""}">${s.fail}</td> | |
| 107 | + <td class="test-stab-num ${s.deleted > 0 ? "red" : ""}">${s.deleted}</td> | |
| 108 | + <td class="test-stab-by">${agentTagHtml(s.lastBrokenBy)}${warn}</td> | |
| 109 | +</tr>`; | |
| 110 | +}; | |
| 111 | + | |
| 112 | +export const reportsLandingMd = (): string => `# reports | |
| 113 | + | |
| 114 | +> Per-agent TDD-discipline reporting over real project repos. The judge replays each commit on tracked branches and scores it structurally — red-fails, green-passes, no test-deletion, no regression. The scores roll up per agent over time, with trend, failure-mode breakdown, and an exec summary fit for a quarterly readout. | |
| 115 | + | |
| 116 | +This is a design preview. The pipeline that ingests real repos isn't wired yet; what you can navigate today is a mockup with synthetic data: | |
| 117 | + | |
| 118 | +- [exec summary mockup →](/reports/demo) — single page, 1 quarter, 3 agents | |
| 119 | +- [per-agent drill-down →](/reports/demo/agents/cursor) — trend, failure mix, recent flagged commits | |
| 120 | +- [tests overzicht →](/reports/demo/tests) — huidige stand per repo + test-stabiliteit per test-naam | |
| 121 | + | |
| 122 | +Want a real repo on this layer? [Register a project →](/projects) — drops \`.tdd-md.json\` at the repo root, onboards in seconds. Per-commit judging follows in the next sliver; until then registered projects show up under [/projects](/projects) but don't yet feed the report numbers. | |
| 123 | + | |
| 124 | +## what gets measured | |
| 125 | + | |
| 126 | +This layer measures **discipline**, not code-quality. Without hidden tests (those only exist on katas), tdd.md can't catch tautologies or weakened assertions on real repos. It *can* catch: | |
| 127 | + | |
| 128 | +| failure mode | what triggers it | what it costs | | |
| 129 | +|---|---|---| | |
| 130 | +| \`red-did-not-fail\` | commit tagged \`red:\` but tests pass | -5 / commit | | |
| 131 | +| \`test-deleted\` | test count drops between commits | -20 / commit | | |
| 132 | +| \`broken refactor\` | tests fail at a \`refactor:\` commit | -5 / commit | | |
| 133 | +| \`no phase tag\` | tracked-branch commit missing \`red\\|green\\|refactor:\` | counts against phase-coverage % | | |
| 134 | + | |
| 135 | +The metric pair that anchors the report is **discipline-score** (0-100) + **phase-coverage %**. An agent with 0% phase-coverage doesn't *do* TDD — its score is N/A, not 0. Don't let a low-volume non-attempt look like a high-volume slip. | |
| 136 | + | |
| 137 | +## reading the data | |
| 138 | + | |
| 139 | +For management: | |
| 140 | +- the [exec summary](/reports/demo) gives one number per agent + a narrative paragraph. Prints to one page. | |
| 141 | + | |
| 142 | +For team-leads: | |
| 143 | +- the [drill-down](/reports/demo/agents/cursor) shows trend, failure-mix, streak, and the most recent flagged commits with one-click coaching links to the [Claude Code](/blog/claude-code-tdd) / [Cursor](/blog/cursor-tdd) / [Aider](/blog/aider-tdd) posts. | |
| 144 | + | |
| 145 | +[← back to tdd.md](/) · [the blog](/blog) · [the katas](/games) | |
| 146 | +`; | |
| 147 | + | |
| 148 | +export const execSummaryMd = (): string => { | |
| 149 | + const totalCommits = DEMO_REPORTS.reduce((s, a) => s + a.commits, 0); | |
| 150 | + const tiles = DEMO_REPORTS.map(tile).join("\n"); | |
| 151 | + return `# tdd-discipline rapport · q1 2026 | |
| 152 | + | |
| 153 | +${mockBanner} | |
| 154 | + | |
| 155 | +> **Periode** ${DEMO_PERIOD} · **Scope** ${DEMO_REPOS} repos · ${totalCommits.toLocaleString()} AI-toegeschreven commits in ${escape(DEMO_ORG)}. | |
| 156 | + | |
| 157 | +<div class="report-tiles"> | |
| 158 | +${tiles} | |
| 159 | +</div> | |
| 160 | + | |
| 161 | +## wat veranderde dit kwartaal | |
| 162 | + | |
| 163 | +Cursor's score zakte 15 punten nadat agent-mode in maart default werd; test-deletion-incidenten stegen van 2% naar 14% van refactor-commits, geconcentreerd in de \`api-gateway\` repo. Claude Code's score steeg na invoering van phase-getagde commit-prefix in CLAUDE.md aan het einde van januari. Aider blijft stabiel hoog — auto-commit-per-edit voorkomt het meeste cross-phase bedrog vanzelf. | |
| 164 | + | |
| 165 | +## wat we doen | |
| 166 | + | |
| 167 | +- **Cursor in \`api-gateway\`**: agent-mode gedeactiveerd voor refactor-prompts, CONVENTIONS-regel "never delete a test in a refactor commit" gepind ([details →](/reports/demo/agents/cursor)). | |
| 168 | +- **Claude Code uitrollen**: het CLAUDE.md-template dat in \`billing-service\` werkte naar de andere drie repos kopiëren. | |
| 169 | +- **Volgende meting**: 2026-04-30, mid-Q2, om te zien of de Cursor-fix vasthoudt. | |
| 170 | + | |
| 171 | +## wat dit getal *niet* meet | |
| 172 | + | |
| 173 | +Discipline, niet code-kwaliteit. Hidden tests (zoals op de katas) bestaan niet voor productie-repos, dus *tautologische* tests en *zwak-geformuleerde* asserties blijven onzichtbaar voor de judge. Dit cijfer zegt: "de agent volgt de TDD-cyclus eerlijk". Het zegt niets over of de tests die hij schrijft het juiste beweren. Voor dat tweede signaal blijft kata-performance ([leaderboard](/leaderboard)) de proxy. | |
| 174 | + | |
| 175 | +--- | |
| 176 | + | |
| 177 | +[per-agent drill-down: Claude Code](/reports/demo/agents/claude-code) · [Cursor](/reports/demo/agents/cursor) · [Aider](/reports/demo/agents/aider) · [tests overzicht](/reports/demo/tests) · [back to /reports](/reports) | |
| 178 | +`; | |
| 179 | +}; | |
| 180 | + | |
| 181 | +export const agentDrilldownMd = (slug: AgentReport["slug"]): string | null => { | |
| 182 | + const a = DEMO_REPORTS.find((r) => r.slug === slug); | |
| 183 | + if (!a) return null; | |
| 184 | + const arr = trendArrow(a.delta); | |
| 185 | + const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`; | |
| 186 | + const recentRows = a.recent | |
| 187 | + .map( | |
| 188 | + (r) => | |
| 189 | + `| ${r.date} | \`${r.repo}\` | \`${r.sha}\` | ${r.phase} | ${r.failure} | ${r.pts} |`, | |
| 190 | + ) | |
| 191 | + .join("\n"); | |
| 192 | + return `# ${a.name} · drill-down | |
| 193 | + | |
| 194 | +${mockBanner} | |
| 195 | + | |
| 196 | +> Discipline-score **${a.score} / 100** <span class="report-tile-trend ${arr.cls}">${arr.glyph} ${deltaStr}</span> over ${DEMO_PERIOD}. ${a.commits.toLocaleString()} commits geanalyseerd, phase-coverage **${a.phaseCoveragePct}%**. | |
| 197 | + | |
| 198 | +## trend (30 dagen) | |
| 199 | + | |
| 200 | +<div class="${arr.cls === "down" ? "red" : arr.cls === "up" ? "green" : "muted"}"> | |
| 201 | +${sparkline(a.trend)} | |
| 202 | +</div> | |
| 203 | + | |
| 204 | +${streakBox(a)} | |
| 205 | + | |
| 206 | +## failure-mode breakdown | |
| 207 | + | |
| 208 | +${bars(a.failureMix)} | |
| 209 | + | |
| 210 | +Top issue dit kwartaal: **${escape(a.topIssueLabel)}** (${a.topIssuePct}% van commits). | |
| 211 | + | |
| 212 | +## recent flagged | |
| 213 | + | |
| 214 | +| date | repo | sha | phase | failure | pts | | |
| 215 | +|---|---|---|---|---|---| | |
| 216 | +${recentRows} | |
| 217 | + | |
| 218 | +## coaching | |
| 219 | + | |
| 220 | +- ${a.slug === "claude-code" ? `[Claude Code does not do TDD by default](/blog/claude-code-tdd) — CLAUDE.md rules + fresh-context boundaries that prevent \`red-did-not-fail\`.` : a.slug === "cursor" ? `[Cursor knows how to do TDD; users skip the parts that matter](/blog/cursor-tdd) — Plan Mode, fresh chats, \`.cursor/rules\` to stop test-deletion.` : `[Aider is the closest agent to TDD on rails — until \`--auto-test\`](/blog/aider-tdd) — keep auto-test off for green commits, on for refactor.`} | |
| 221 | +- [Tweag's TDD handbook needs a judge](/blog/tweag-handbook-tdd) — why local green isn't enough. | |
| 222 | + | |
| 223 | +--- | |
| 224 | + | |
| 225 | +[← exec summary](/reports/demo) · [back to /reports](/reports) | |
| 226 | +`; | |
| 227 | +}; | |
| 228 | + | |
| 229 | +export const testsOverviewMd = (): string => { | |
| 230 | + const total = DEMO_SNAPSHOTS.reduce((s, r) => s + r.total, 0); | |
| 231 | + const passing = DEMO_SNAPSHOTS.reduce((s, r) => s + r.passing, 0); | |
| 232 | + const failing = DEMO_SNAPSHOTS.reduce((s, r) => s + r.failing, 0); | |
| 233 | + const snapshots = DEMO_SNAPSHOTS.map(snapshotBlock).join("\n"); | |
| 234 | + const stabRows = DEMO_STABILITY.map(stabilityRow).join("\n"); | |
| 235 | + return `# tests overzicht | |
| 236 | + | |
| 237 | +${mockBanner} | |
| 238 | + | |
| 239 | +> Snapshot van de huidige test-stand per repo + stabiliteit van individuele tests over ${DEMO_PERIOD}. Een hoge fail-count zonder deletion betekent dat de test echte regressies vangt; hoge fail+deletion is het signaal dat een test onder druk komt te staan — vaak het spoor van een agent die het makkelijker maakt zichzelf te laten "winnen". | |
| 240 | + | |
| 241 | +## huidige stand · per repo | |
| 242 | + | |
| 243 | +<div class="test-snapshots"> | |
| 244 | +${snapshots} | |
| 245 | +</div> | |
| 246 | + | |
| 247 | +**Totaal**: ${total.toLocaleString()} tests · <span class="green">${passing.toLocaleString()} passing</span> · <span class="${failing > 0 ? "red" : "muted"}">${failing.toLocaleString()} failing</span>. | |
| 248 | + | |
| 249 | +## test-stabiliteit · q1 2026 | |
| 250 | + | |
| 251 | +Top 12 meest-flappende tests dit kwartaal, met aantal pass/fail/deleted-events en de agent die de test het laatst heeft gebroken. | |
| 252 | + | |
| 253 | +<table class="test-stability"> | |
| 254 | +<thead> | |
| 255 | + <tr> | |
| 256 | + <th>test</th> | |
| 257 | + <th class="num">pass</th> | |
| 258 | + <th class="num">fail</th> | |
| 259 | + <th class="num">del</th> | |
| 260 | + <th>laatst gebroken door</th> | |
| 261 | + </tr> | |
| 262 | +</thead> | |
| 263 | +<tbody> | |
| 264 | +${stabRows} | |
| 265 | +</tbody> | |
| 266 | +</table> | |
| 267 | + | |
| 268 | +> ⚠ markeert tests waarbij dit kwartaal een test-deletion of weakening-event is gedetecteerd. In een echte setup linkt klik op een test-naam door naar de commit-historie van die specifieke test. | |
| 269 | + | |
| 270 | +## hoe lees je dit | |
| 271 | + | |
| 272 | +- **Veel pass, weinig fail, 0 del**: gezond. Test doet wat hij moet, niemand sloopt 'm. | |
| 273 | +- **Veel fail, 0 del**: test vangt actief regressies. Goed nieuws — discipline werkt. | |
| 274 | +- **Fail én del > 0**: test wordt onder druk gezet. Coach de agent die 'm gebroken heeft (klik op het tag-icoon). | |
| 275 | +- **Snapshot rood + stabiliteit hoog**: bekende, langlopende kapotte test. Apart onderwerp, niet per se een agent-probleem. | |
| 276 | + | |
| 277 | +--- | |
| 278 | + | |
| 279 | +[← exec summary](/reports/demo) · [back to /reports](/reports) | |
| 280 | +`; | |
| 281 | +}; | |