// c31 — model: types + parser for `.tdd-md.json`, the per-repo opt-in // config used by the project-tracking pipeline. Pure data, no I/O. // Fetching the file lives in c14_github; persistence lives in c13_database; // page rendering lives in c51_render. export const PROJECT_CONFIG_PATH = ".tdd-md.json"; export const PROJECT_CONFIG_VERSION = 1; export type TestRunner = "none" | "bun"; export type AgentSlug = "claude-code" | "cursor" | "aider" | "unknown"; export interface ProjectConfig { version: number; // "none" → trace-mode judging only (commit discipline, no test execution). // "bun" → full sandbox-runner judging (later sliver — registration accepts // the value but judging stays trace-only until the runner ships). test_runner: TestRunner; // Branches whose pushes get scored. Defaults to ["main"]. tracked_branches: string[]; // Optional reporting metadata. display_name?: string; team?: string; } export const DEFAULT_CONFIG: ProjectConfig = { version: PROJECT_CONFIG_VERSION, test_runner: "none", tracked_branches: ["main"], }; // Validates and normalises a parsed JSON blob into a ProjectConfig. // Throws with a human-readable message on failure — those messages are // surfaced verbatim to the registering user, so they need to be useful. export const parseProjectConfig = (raw: unknown): ProjectConfig => { if (!raw || typeof raw !== "object") { throw new Error(".tdd-md.json must be a JSON object"); } const obj = raw as Record; const version = obj.version; if (typeof version !== "number" || version !== PROJECT_CONFIG_VERSION) { throw new Error( `.tdd-md.json has version ${JSON.stringify(version)}; expected ${PROJECT_CONFIG_VERSION}`, ); } let testRunner: TestRunner = "none"; if (obj.test_runner !== undefined) { if (obj.test_runner !== "none" && obj.test_runner !== "bun") { throw new Error( `.tdd-md.json: test_runner must be "none" or "bun" (got ${JSON.stringify(obj.test_runner)})`, ); } testRunner = obj.test_runner; } let trackedBranches: string[] = ["main"]; if (obj.tracked_branches !== undefined) { if (!Array.isArray(obj.tracked_branches) || obj.tracked_branches.some((b) => typeof b !== "string" || !b)) { throw new Error(".tdd-md.json: tracked_branches must be a non-empty array of branch names"); } trackedBranches = obj.tracked_branches as string[]; } const config: ProjectConfig = { version, test_runner: testRunner, tracked_branches: trackedBranches, }; if (typeof obj.display_name === "string" && obj.display_name) { config.display_name = obj.display_name; } if (typeof obj.team === "string" && obj.team) { config.team = obj.team; } return config; }; // Parse a GitHub repo URL or owner/repo shorthand. Accepts: // https://github.com/syntaxai/tdd.md // https://github.com/syntaxai/tdd.md.git // github.com/syntaxai/tdd.md // syntaxai/tdd.md // Returns the owner + repo or throws with a precise message. export const parseRepoIdentifier = (raw: string): { owner: string; repo: string } => { const trimmed = raw.trim(); if (!trimmed) throw new Error("Repository URL is required."); let path = trimmed; const httpsMatch = path.match(/^https?:\/\/(?:www\.)?github\.com\/(.+)$/i); if (httpsMatch?.[1]) path = httpsMatch[1]; const bareMatch = path.match(/^github\.com\/(.+)$/i); if (bareMatch?.[1]) path = bareMatch[1]; path = path.replace(/\.git$/i, "").replace(/\/+$/, ""); const parts = path.split("/").filter(Boolean); const owner = parts[0]; const repo = parts[1]; if (parts.length !== 2 || !owner || !repo) { throw new Error( `Couldn't parse "${raw}" as a GitHub repo. Use a URL like https://github.com/owner/name or the shorthand owner/name.`, ); } if (!/^[A-Za-z0-9._-]+$/.test(owner) || !/^[A-Za-z0-9._-]+$/.test(repo)) { throw new Error(`"${raw}" contains characters that aren't valid for a GitHub owner/repo.`); } return { owner, repo }; }; // Row-shape returned by c13_database for project records. Defined here // in Layer 0 (Pure) per SAMA v2 §1.1 so c51 render code can reference // the type without importing from Layer 2 (Adapter). export interface ProjectRow { id: number; registeredBy: string; repoOwner: string; repoName: string; testRunner: TestRunner; trackedBranches: string[]; displayName: string | null; team: string | null; registeredAt: number; status: "active" | "paused"; }