syntaxai/tdd.md · main · src / a31_project_config.ts

a31_project_config.ts 119 lines · 4495 bytes raw
// 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";
}