ecad9eb4d33f53e546d28bd7d8cde5ec6a9490e3
diff --git a/.gitignore b/.gitignore
index 21ebc0e33dccd09efd9ac049039865612c70a43a..93c844ec551d99af80b9ff450bb625a0402e1e42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ node_modules/
.bun-cache/
.claude/
content/git-history/
+public/sama-cli
diff --git a/scripts/p620/deploy-tdd-md.sh b/scripts/p620/deploy-tdd-md.sh
index f973b4f1a045e09af3a0375e23c97bf67522a0c7..dfb854a6b54e7fc1f650b0b7cf3334c231671014 100755
--- a/scripts/p620/deploy-tdd-md.sh
+++ b/scripts/p620/deploy-tdd-md.sh
@@ -50,6 +50,13 @@ echo "→ snapshot tests (bun test --reporter=junit) → content/git-history/"
( cd "$REPO_ROOT" && bun scripts/p620/snapshot-tests.ts ) \
|| { echo "✗ snapshot-tests mislukt"; exit 1; }
+echo "→ bundle sama CLI → public/sama-cli"
+# Single-file Bun bundle of scripts/sama-cli.ts with all imports
+# inlined. Served at /tools/sama-cli for `curl | bash`-style install.
+( cd "$REPO_ROOT" && bun build scripts/sama-cli.ts --target=bun --outfile=public/sama-cli >/dev/null ) \
+ || { echo "✗ sama-cli bundle mislukt"; exit 1; }
+chmod +x "$REPO_ROOT/public/sama-cli"
+
echo "→ source rsync naar $SSH_HOST:~/$REMOTE_SRC_DIR"
ssh "$SSH_HOST" "mkdir -p ~/$REMOTE_SRC_DIR"
# --delete zodat verwijderde files ook weggaan op remote.
diff --git a/scripts/sama-cli.ts b/scripts/sama-cli.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a9a6cfc78101f9bd77250585c89ac65f66f9a944
--- /dev/null
+++ b/scripts/sama-cli.ts
@@ -0,0 +1,237 @@
+#!/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=
] verify the current repo's src/
+// sama verify-repo [--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/c32_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=]
+ Verify the current repo's src/ directory.
+
+ sama verify-repo [--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 => {
+ 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();
+ 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 => {
+ 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();
+ 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);
diff --git a/src/c21_app.ts b/src/c21_app.ts
index beed1c594500c287da12f173b62c65ea1c51bdce..f589718d3406ef72c993d7fc69dbcd4ad1123143 100644
--- a/src/c21_app.ts
+++ b/src/c21_app.ts
@@ -648,6 +648,16 @@ ${rows}
});
},
+ "/tools/sama-cli": new Response(Bun.file("./public/sama-cli"), {
+ headers: {
+ // text/javascript so browsers preview as code; the shebang at
+ // line 1 makes the file directly executable once chmod +x'd.
+ "Content-Type": "text/javascript; charset=utf-8",
+ "Content-Disposition": 'inline; filename="sama"',
+ "Cache-Control": "public, max-age=300",
+ },
+ }),
+
"/sama/skill": async () => {
const raw = await Bun.file("./content/sama/skill.md").text();
// Strip the YAML frontmatter for the HTML render — the .md raw
@@ -854,7 +864,58 @@ The skill is the same content as the four pages here, written in obra/superpower
## verify any public repo
-Want to know whether a repo follows SAMA without reading its source? Paste the \`owner/name\` and tdd.md will run all four checks against the default branch — *Sorted* (the import-direction grep), *Architecture* (known layer prefixes), *Modeled* (sibling tests), *Atomic* (700-line + placeholder-test detection). Pass/fail per discipline, with violation lists. **[verify a repo →](/sama/verify)** · or try it on this site: [\`syntaxai/tdd.md\`](/sama/verify?repo=syntaxai/tdd.md).
+Want to know whether a repo follows SAMA without reading its source? Paste the \`owner/name\` and tdd.md will run all four checks against the default branch — *Sorted* (the import-direction grep), *Architecture* (known layer prefixes), *Modeled* (sibling tests), *Atomic* (700-line + placeholder-test detection). Pass/fail per discipline, with violation lists. **[verify a repo on the web →](/sama/verify)** · or try it on this site: [\`syntaxai/tdd.md\`](/sama/verify?repo=syntaxai/tdd.md).
+
+## the \`sama\` CLI
+
+The web verifier is good for ad-hoc checks. For CI and pre-commit, install the standalone CLI — same checks, no network needed for local repos:
+
+\`\`\`bash
+mkdir -p ~/.local/bin
+curl -fsSL https://tdd.md/tools/sama-cli -o ~/.local/bin/sama
+chmod +x ~/.local/bin/sama
+sama --help
+\`\`\`
+
+Two subcommands:
+
+\`\`\`bash
+sama check # verify the current repo's src/
+sama check --json # JSON output for piping into CI tooling
+sama verify-repo owner/name # verify a public GitHub repo (no token)
+\`\`\`
+
+Exit codes: \`0\` on pass, \`1\` if any check fails, \`2\` on error. The CLI is a single Bun bundle (~14 KB). [Bun](https://bun.sh) needs to be on \`PATH\`.
+
+### pre-commit hook
+
+Add to \`.git/hooks/pre-commit\` (or via \`husky\`, \`pre-commit\`, \`lefthook\`):
+
+\`\`\`bash
+#!/usr/bin/env bash
+# Block commits that violate SAMA layer/atomic/modeled rules.
+exec sama check
+\`\`\`
+
+### GitHub Action
+
+\`\`\`yaml
+# .github/workflows/sama.yml
+name: sama
+on: [push, pull_request]
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ - run: |
+ curl -fsSL https://tdd.md/tools/sama-cli -o sama
+ chmod +x sama
+ ./sama check
+\`\`\`
+
+If the rule lives in a hook or an action that fails the build, the harness can't talk the agent out of it. That is the whole point of the [corpus post](/blog/agentic-coding-corpus-three-patterns) and the next step from the [from-rules-to-checks](/blog/from-rules-to-checks) wrap-up.
## the case behind it