Theme
Architecture
Topology
text
Browser / CLI / MCP client
│ React dashboard (localStorage: settings + keys)
│ edge-spec CLI or local stdio MCP server (keys from env)
│ fetch + SSE, credentials as headers
▼
Cloudflare Worker (Hono)
├─ /api/* JSON + SSE
├─ /webhook GitHub webhook receiver
├─ ASSETS binding built React app (assets-first routing)
├─ AI binding Workers AI
└─ TEST_GENERATION_WORKFLOW
│
▼
TestGenerationWorkflow (durable, deployment-owned credentials)
Outbound from the Worker:
api.github.com tree, files, branches, commits, PRs
provider endpoint OpenAI-compatible / Anthropic / Gemini
user sandbox runner (optional) executes `bun test`The Worker holds no session and no user configuration. A request carries everything needed to serve it. Run history, backlog, policies, memory, schedules, workspace settings and audit are an optional D1 binding (RUNS_DB); the raw validation bundles and drift summaries live in R2 (ARTIFACTS). Without the bindings the related routes answer enabled: false and nothing is stored. Generated test source, prompts and keys are never written to either store.
Frontend bundle
The dashboard is a Vite SPA served from dist/web by the ASSETS binding. The build splits it so a deploy does not invalidate the heavy parts:
| Chunk | What | When it loads |
|---|---|---|
index | the dashboard code | always |
react | react and react-dom | always |
radix | the Radix primitives | always |
vendor | the rest | always |
icons | lucide | always |
trees | @pierre/trees | when a file view opens |
kirari | the code highlighter | when a generated test is shown |
The two heavy ones are behind React.lazy (apps/web/src/components/path-tree.tsx and apps/web/src/components/code-view.tsx), so the file tree and the syntax highlighter are only parsed and executed when a reader actually needs them. apps/web/vite.config.ts names the vendor chunks with manualChunks, so a change in the app code does not invalidate the cached vendor files.
Modules
| Module | Responsibility | Rules |
|---|---|---|
packages/core/src/types.ts | Gap, Generation, Validation, RunEvent, HttpError | No HTTP or provider knowledge |
packages/core/src/catalog.ts | Provider/model catalog, resolveAi, parseRepo, parseRef, pipelineConfig | Single source of truth for validation and defaults |
packages/core/src/github.ts | REST client: tree, file, branch, commit, PR, PR list | fetch, no SDK; path segments encoded |
packages/core/src/scanner.ts | classifyTree, ancestor test discovery, OpenAPI parsing, repository overview | Regex-based, no TypeScript parser |
packages/core/src/coverage.ts | LCOV parsing and coverageGaps | Facts from a runner; no report means "not measured" |
packages/core/src/openapi.ts | Deterministic contract tests, apiGaps, JUnit parsing | One test named METHOD /path per operation; no AI |
packages/core/src/ci.ts | Reads the repository's checks, formatter and style rules | Pure functions over the tree, the manifest and workflows |
packages/core/src/ai.ts | Prompt, code extraction, provider dispatch | Key only goes into upstream request headers |
packages/core/src/validate.ts | Static checks + optional sandbox execution | verified is true only after a runner ran |
packages/core/src/sandbox.ts | v1/v2 request contract, config parsing, runner health | v1 {files, command} stays the default body |
packages/core/src/api-surface.ts | Infers OpenAPI from route calls when a spec is missing | Explicitly inferred; regex, not an AST |
packages/core/src/pipeline.ts | Async generator for scan → generate → validate → correct → PR | Transport-agnostic; bounded pool + channel |
apps/worker/src/index.ts | Hono routes, header parsing, SSE framing | Translates HTTP to core calls |
apps/worker/src/workflow.ts | Durable webhook automation | Deployment credentials only |
apps/web/src/ | Dashboard | App.tsx owns run state; components are presentational |
packages/ui/src/ | React primitives and brand tokens | Dashboard imports components; docs imports theme.css |
packages/client/src/ | Worker API client, SSE parsing, env defaults | No pipeline logic; CLI and MCP use it |
packages/cli/src/ | scan, generate, run, catalog commands | Secrets from env, never flags |
packages/mcp/src/ | Stdio MCP tools over the shared client | PR tool requires a one-time approval token |
Interactive run (POST /api/run)
text
POST /api/run
parse repo + ref 400 on invalid input
resolve AI choice 400 if provider/key/model/base URL unusable
open SSE stream 200 text/event-stream
runPipeline(...)
step scan GitHub tree → classifyTree (+ OpenAPI specs in api mode)
gaps event missing + coverage + api + unsupported, with totals
pool up to `concurrency` files at once
step generate fetch source → prompt chosen model → extract code
generation full test file, with sourcePath
step validate static checks, then sandbox if configured
validation {ok, verified, output, durationMs}, with sourcePath
loop up to maxRetries corrections while !ok
api mode deterministic contract tests → sandbox → JUnit → api event
step PR ready to open (the reviewer opens it)
result {ok, ready?, reason?}
done frameConcurrency is bounded (1–6, default 3) and also capped by the sandbox's own concurrency. Events carry sourcePath, so the dashboard keys generations and validations per file; ready is assembled in scan order, not completion order. One file that exhausts its retries fails alone and the rest of the run continues.
- Static checks always run. Failure stops the run before any sandbox call.
- Sandbox runs only when the request supplies a runner URL. The result is
verified: true. - No sandbox returns
ok: trueonly when static checks pass, withverified: falseand an explicit message. The UI labels this static check only.
A failed validation is fed back to the model with the previous code and runner output. The loop is bounded by maxRetries (clamped to 0–5). If it still fails, no PR is opened and the result is ok: false.
Only the first gap is attempted per run. Scanning is cheap; generating is not. The dashboard lets the user generate another gap individually via POST /api/generate.
Webhook automation (TestGenerationWorkflow)
text
GitHub ── POST /webhook ──▶ verify HMAC (if GH_WEBHOOK_SECRET is set)
parse repository.full_name + ref + installation.id
push: binding.create({ repo, ref, event:"push" })
pull_request: binding.create({ repo, ref:headSha,
pullRequest, event:"pull_request" })
issue_comment: authorize association, parse /edgespec,
rate limit, then binding.create({
pullRequest, event:"pr_command", command })
→ 202 { workflowId }
Workflow · push
scan-repository tree, first gap, source (durable step)
generate-test Workers AI (2 retries, exponential)
validate-test static + optional sandbox
correct-test-N bounded by maxRetries
create-pull-request branch + file + PR
Workflow · pull_request (advisory)
changed-files first page of PR files
scan-changed changed sources without tests
impact-graph test import closure (≤200 files) → dependents + reasons
run-affected-tests selected tests, only with a runner
publish-check upsert one check run + one marked comment
Workflow · pr_command (authorized /edgespec comment)
resolve-pull head commit
changed-files first page of PR files
scan-changed changed sources and their tests
retest|coverage|explain
publish-command one reply + the same EdgeSpec check upsertedWorkflow parameters are persisted by Cloudflare. Therefore the workflow:
- never accepts a visitor's provider key,
- uses the deployment's
AIbinding, - mints a repo-scoped GitHub App installation token when the App secrets are configured, cached only until shortly before expiry;
GH_TOKENis the fallback for deployments without an App, - rejects non-Workers-AI providers with a clear error.
The dashboard remains the place for bring-your-own-key runs.
Test-debt backlog
A full dashboard scan and the scheduled drift scan both call the same backlog sync. It upserts the current gaps, preserves accepted/rejected/ignored decisions, closes paths that disappeared, and reopens a closed path that returns. Ownership is the first CODEOWNERS owner for the path (last matching rule wins). The dashboard reads and updates the same D1 rows through /api/backlog.
Scheduled drift (scheduled handler)
text
cron (daily) ──▶ due schedules from D1
tree + OpenAPI operations
compare with drift_snapshots
changed? → webhook summary + optional R2 artifact
update snapshot + last_run_atThe first scan stores a baseline. Only a repository whose untested paths or contract operations changed produces one notification. The handler never generates tests or opens a pull request.
GitHub client
GitHub wraps the operations used by the pipeline:
tree(repo, ref)— recursive tree, blobs only for scanning.file(repo, path, ref)— decodes Base64 content.branches(repo),defaultBranch(repo)— feed the header branch dropdown.headSha,createBranch,putFile,openPull— one branch and one commit.pulls(repo)— 20 newest, all states.
Authentication is optional per request: a token enables private repositories, avoids anonymous rate limits, and is required to push or open PRs. Public reads work without one.
Workers detail:
fetchis captured as a local before invocation. Calling it asthis.fetchImpl(...)throwsIllegal invocationin the Workers runtime.
Scanning rules
A path is a source file when it matches .ts .tsx .js .jsx .mjs .cjs and is not:
- a test (
*.test.*,*.spec.*), - a declaration (
*.d.ts,*.d.mts,*.d.cts), - under generated/vendor directories (
dist,build,out,coverage,node_modules,vendor,generated,__generated__,migrations), - under a test directory (
test,tests,__tests__,__mocks__,e2e,fixtures), - a tool config (
vite,vitest,jest,playwright,tailwind,postcss,eslint,wrangler,bun,tsconfig,setup,webpack,rollup).
A gap is a source file whose sibling <name>.test.<ext> (or .spec) is absent. Exports are extracted with regexes (best effort, capped at 12). Risk is a path heuristic: auth/token/payment/webhook keywords → high; util/helper/theme/UI keywords → low; otherwise medium, or high above 20 exports.
Security boundaries
| Boundary | Control |
|---|---|
| Repository input | parseRepoInput accepts a folder start path in the header; the API gets owner/name only, via parseRepo, with no traversal |
| Branch selector | Values come from GitHub's branch list; parseRef still rejects .., leading /, unsafe characters |
| Provider selection | Catalog allowlist; unknown providers and models are rejected |
| Base URL | Only the custom provider (private id) accepts one; known providers cannot be redirected, preventing key exfiltration |
| Workflow params | Never contain user keys |
| CLI/MCP credentials | Environment only; no key flags or tool arguments |
| Webhook | HMAC SHA-256 verification when a secret is configured |
| Code execution | Generated code is never executed on the Worker; only an external sandbox may run it |
| Generated content | Always treated as a draft; PR body says so |
Keys are held in browser localStorage only; the Worker forwards them and does not log or persist them. This is still a bearer-token model: a compromised browser or deployment can read them. See Configuration.
Failure behaviour
- Validation errors before streaming → JSON
{error, status}with 4xx/5xx. - Errors after streaming starts → an
errorevent, thenresult(ok: false), thendone; the HTTP status stays 200 because headers are already sent. - Provider 401/403 → 400 with a short message (so the UI can say "check your key"); other provider failures → 502.
- Sandbox unreachable → validation
ok: false,verified: false. - GitHub failures include a readable message; 404 usually means private repo or a bad ref.
Why these choices
- No SDKs for GitHub or AI: the surface used here is small, and
fetchkeeps the Worker bundle lean. - No server state: removes storage, GDPR and cache-invalidation questions for a tool that runs on demand.
- Async generator pipeline: the interactive path is one implementation over SSE. The durable Workflow calls the same core scan/generate/validate functions inside its steps, so behaviour stays close without persisting user keys.
- CLI and MCP are API clients: they reuse the Worker's scan/generate/run/PR routes instead of duplicating the pipeline, so the dashboard, terminal, and agents all see the same validation and sandbox behavior.
- Radix primitives (shadcn-style): accessible dialog, select, tabs, switch and slider without hand-rolling focus management or keyboard behaviour.