syntaxai/tdd.md · main · scripts / sama-cli.ts

sama-cli.ts 238 lines · 8749 bytes raw
#!/usr/bin/env bun
// sama — verify any TypeScript/JavaScript repo against the four SAMA
// disciplines (Sorted, Architecture, Modeled, Atomic). Mirrors the
// /sama/verify web tool but runs locally with no network for `check`,
// and uses the GitHub public API (no token) for `verify-repo`.
//
// Subcommands:
//   sama check [--json] [--src=<dir>]      verify the current repo's src/
//   sama verify-repo <owner/name> [--json]  verify a public GitHub repo
//   sama --help                            usage
//   sama --version                         version
//
// Exit codes: 0 = all checks pass, 1 = at least one check failed,
//             2 = error (bad args, network failure, etc.)

import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
import { resolve, relative } from "node:path";
import { verifySama, type SamaReport } from "../src/b32_sama_verify.ts";

const VERSION = "0.1.0";

const RED = "\x1b[31m";
const GREEN = "\x1b[32m";
const YELLOW = "\x1b[33m";
const DIM = "\x1b[2m";
const BOLD = "\x1b[1m";
const RESET = "\x1b[0m";

const isTty = process.stdout.isTTY;
const c = (color: string, s: string): string => (isTty ? `${color}${s}${RESET}` : s);

const HELP = `sama ${VERSION} — verify any repo against the four SAMA disciplines.

Usage:
  sama check [--json] [--src=<dir>]
      Verify the current repo's src/ directory.

  sama verify-repo <owner/name> [--json]
      Verify a public GitHub repo over HTTPS.

  sama --help, --version

Disciplines checked:
  S — Sorted        UI must not be a dependency of foundation/data/logic.
  A — Architecture  Every cXX_ prefix must be in the known set.
  M — Modeled       c32_*.ts logic files must have a sibling cXX_*.test.ts.
  A — Atomic        ~700-line split rule, plus zero-expect() placeholder
                    detection on test files.

Exit codes: 0 pass · 1 fail · 2 error.
See https://tdd.md/sama for the full discipline pages.
`;

interface ParsedArgs {
  command: "check" | "verify-repo" | "help" | "version" | null;
  json: boolean;
  src: string;
  repoArg?: string;
}

const parseArgs = (argv: string[]): ParsedArgs => {
  const out: ParsedArgs = { command: null, json: false, src: "src" };
  let positional = 0;
  for (const a of argv) {
    if (a === "--help" || a === "-h") return { ...out, command: "help" };
    if (a === "--version" || a === "-V") return { ...out, command: "version" };
    if (a === "--json") out.json = true;
    else if (a.startsWith("--src=")) out.src = a.slice("--src=".length);
    else if (a.startsWith("--")) {
      console.error(`unknown flag: ${a}`);
      out.command = null;
      return out;
    } else if (positional === 0) {
      if (a === "check" || a === "verify-repo") out.command = a;
      else {
        console.error(`unknown command: ${a}`);
        return out;
      }
      positional++;
    } else if (positional === 1 && out.command === "verify-repo") {
      out.repoArg = a;
      positional++;
    } else {
      console.error(`unexpected argument: ${a}`);
      return out;
    }
  }
  return out;
};

const walkSrc = (root: string, dir: string, out: string[]): void => {
  const entries = readdirSync(dir, { withFileTypes: true });
  for (const e of entries) {
    const full = `${dir}/${e.name}`;
    if (e.isDirectory()) {
      // Skip nested build/output dirs that conventionally aren't source.
      if (["node_modules", "dist", "build", ".bun", ".next"].includes(e.name)) continue;
      walkSrc(root, full, out);
    } else if (e.isFile() && e.name.endsWith(".ts")) {
      out.push(relative(root, full));
    }
  }
};

const runCheck = async (args: ParsedArgs): Promise<number> => {
  const srcDir = resolve(process.cwd(), args.src);
  if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
    console.error(c(RED, `✗ ${args.src}/ not found in ${process.cwd()}`));
    return 2;
  }
  const tsFiles: string[] = [];
  walkSrc(srcDir, srcDir, tsFiles);
  const contents = new Map<string, string>();
  for (const f of tsFiles) {
    if (/(?:^|\/)c\d{2}_/.test(f)) {
      contents.set(f, readFileSync(`${srcDir}/${f}`, "utf8"));
    }
  }
  const report = verifySama({
    repoOwner: "(local)",
    repoName: args.src,
    defaultBranch: "(working tree)",
    srcPaths: tsFiles,
    contents,
  });
  return printReport(report, args.json);
};

const runVerifyRepo = async (args: ParsedArgs): Promise<number> => {
  if (!args.repoArg) {
    console.error(c(RED, "✗ verify-repo requires an owner/name argument"));
    return 2;
  }
  const m = /^([^\/\s]+)\/([^\/\s]+)$/.exec(args.repoArg);
  if (!m) {
    console.error(c(RED, `✗ couldn't parse '${args.repoArg}' — expected owner/name`));
    return 2;
  }
  const [, owner, name] = m;

  const repoRes = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner!)}/${encodeURIComponent(name!)}`, {
    headers: { Accept: "application/vnd.github+json", "User-Agent": "sama-cli" },
  });
  if (!repoRes.ok) {
    console.error(c(RED, `✗ GitHub repo lookup failed: HTTP ${repoRes.status}`));
    if (repoRes.status === 404) console.error(c(DIM, "  the repo is private, the name is wrong, or it doesn't exist"));
    if (repoRes.status === 403) console.error(c(DIM, "  GitHub rate limit hit (60/h anonymous) — wait or set up a token"));
    return 2;
  }
  const repoMeta = (await repoRes.json()) as { default_branch?: string };
  const defaultBranch = repoMeta.default_branch ?? "main";

  const treeRes = await fetch(
    `https://api.github.com/repos/${encodeURIComponent(owner!)}/${encodeURIComponent(name!)}/git/trees/${encodeURIComponent(defaultBranch)}?recursive=1`,
    { headers: { Accept: "application/vnd.github+json", "User-Agent": "sama-cli" } },
  );
  if (!treeRes.ok) {
    console.error(c(RED, `✗ GitHub tree fetch failed: HTTP ${treeRes.status}`));
    return 2;
  }
  const treeData = (await treeRes.json()) as {
    tree?: Array<{ path: string; type: string }>;
  };
  const srcEntries = (treeData.tree ?? [])
    .filter((e) => e.type === "blob" && e.path.startsWith("src/") && e.path.endsWith(".ts"))
    .slice(0, 200);
  const srcPaths = srcEntries.map((e) => e.path.slice("src/".length));
  const samaPaths = srcPaths.filter((p) => /^c\d{2}_/.test(p));
  const contents = new Map<string, string>();
  await Promise.all(
    samaPaths.map(async (p) => {
      const url = `https://raw.githubusercontent.com/${encodeURIComponent(owner!)}/${encodeURIComponent(name!)}/${encodeURIComponent(defaultBranch)}/src/${p.split("/").map(encodeURIComponent).join("/")}`;
      const r = await fetch(url, { headers: { "User-Agent": "sama-cli" } });
      if (r.ok) contents.set(p, await r.text());
    }),
  );
  const report = verifySama({
    repoOwner: owner!,
    repoName: name!,
    defaultBranch,
    srcPaths,
    contents,
  });
  return printReport(report, args.json);
};

const printReport = (report: SamaReport, json: boolean): number => {
  if (json) {
    process.stdout.write(JSON.stringify(report, null, 2) + "\n");
    return report.overallPassed ? 0 : 1;
  }
  const head = `${c(BOLD, "SAMA verify")} · ${report.repoSlug} · ${c(DIM, report.defaultBranch)}`;
  console.log(head);
  console.log(c(DIM, `  examined ${report.samaFiles} SAMA files / ${report.testFiles} tests / ${report.totalSrcFiles} src files`));
  console.log("");
  for (const ch of report.checks) {
    const status = ch.passed
      ? c(GREEN, "✓ pass")
      : c(RED, `✗ ${ch.violations.length} violation${ch.violations.length === 1 ? "" : "s"}`);
    console.log(`  ${c(BOLD, ch.letter)} — ${ch.property}: ${status} ${c(DIM, `(${ch.examined} files)`)}`);
    for (const v of ch.violations.slice(0, 10)) {
      console.log(`     ${c(DIM, "·")} ${v.file} — ${v.detail}`);
    }
    if (ch.violations.length > 10) {
      console.log(c(DIM, `     · ...and ${ch.violations.length - 10} more`));
    }
    if (ch.note) console.log(c(YELLOW, `     ${ch.note}`));
  }
  console.log("");
  if (report.overallPassed) {
    console.log(c(GREEN, "✓ all four checks passed"));
    return 0;
  } else {
    const failed = report.checks.filter((ch) => !ch.passed).length;
    console.log(c(RED, `✗ ${failed} of 4 checks failed`));
    return 1;
  }
};

const args = parseArgs(process.argv.slice(2));
let exitCode = 0;
try {
  if (args.command === "help" || args.command === null) {
    console.log(HELP);
    exitCode = args.command === "help" ? 0 : 2;
  } else if (args.command === "version") {
    console.log(`sama ${VERSION}`);
  } else if (args.command === "check") {
    exitCode = await runCheck(args);
  } else if (args.command === "verify-repo") {
    exitCode = await runVerifyRepo(args);
  }
} catch (e) {
  console.error(c(RED, `✗ ${e instanceof Error ? e.message : String(e)}`));
  exitCode = 2;
}
process.exit(exitCode);