syntaxai/tdd.md · main · src / a31_project_config.ts
// 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<string, unknown>;
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";
}