syntaxai/tdd.md · commit ecad9eb

SAMA CLI: standalone Bun-bundled verifier with check + verify-repo

The corpus post argued: "every rule worth enforcing should be a
mechanical check, not a polite request." The web /sama/verify proved
the logic works; this commit ships it as an installable binary so it
can run in CI and pre-commit hooks - where the harness doesn't get to
overrule it.

  scripts/sama-cli.ts
    Single-file Bun CLI. Imports the same verifySama() pure logic
    from src/c32_sama_verify.ts that the web verifier uses.
    Subcommands:
      sama check [--json] [--src=<dir>]
      sama verify-repo <owner/name> [--json]
      sama --help, --version
    Exit codes: 0 pass / 1 fail / 2 error.
    ANSI colour when stdout is a TTY, plain text otherwise.

  scripts/p620/deploy-tdd-md.sh
    `bun build scripts/sama-cli.ts --target=bun --outfile=public/sama-cli`
    runs after the test snapshot, before the rsync. Bundle is ~14 KB
    with all imports inlined.

  src/c21_app.ts
    GET /tools/sama-cli serves the bundled file with text/javascript
    content-type and Content-Disposition filename="sama".
    /sama landing gets a "the sama CLI" section with curl install,
    pre-commit hook template, and a GitHub Action workflow.

  .gitignore
    public/sama-cli is regenerated per deploy, never tracked.

Install:
    mkdir -p ~/.local/bin
    curl -fsSL https://tdd.md/tools/sama-cli -o ~/.local/bin/sama
    chmod +x ~/.local/bin/sama
    sama check

Dogfood: running `sama check` from this repo's root reproduces the
same web-tool findings (S+A pass, M flags 5 c32_*, A flags
c21_app.ts > 700 lines).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-09 20:24:21 +01:00
parent
2868f48
commit
ecad9eb4d33f53e546d28bd7d8cde5ec6a9490e3

4 files changed · +307 −1

modified .gitignore +1 −0
@@ -6,3 +6,4 @@ node_modules/
66 .bun-cache/
77 .claude/
88 content/git-history/
9+public/sama-cli
modified scripts/p620/deploy-tdd-md.sh +7 −0
@@ -50,6 +50,13 @@ echo "→ snapshot tests (bun test --reporter=junit) → content/git-history/"
5050 ( cd "$REPO_ROOT" && bun scripts/p620/snapshot-tests.ts ) \
5151 || { echo "✗ snapshot-tests mislukt"; exit 1; }
5252
53+echo "→ bundle sama CLI → public/sama-cli"
54+# Single-file Bun bundle of scripts/sama-cli.ts with all imports
55+# inlined. Served at /tools/sama-cli for `curl | bash`-style install.
56+( cd "$REPO_ROOT" && bun build scripts/sama-cli.ts --target=bun --outfile=public/sama-cli >/dev/null ) \
57+ || { echo "✗ sama-cli bundle mislukt"; exit 1; }
58+chmod +x "$REPO_ROOT/public/sama-cli"
59+
5360 echo "→ source rsync naar $SSH_HOST:~/$REMOTE_SRC_DIR"
5461 ssh "$SSH_HOST" "mkdir -p ~/$REMOTE_SRC_DIR"
5562 # --delete zodat verwijderde files ook weggaan op remote.
added scripts/sama-cli.ts +237 −0
@@ -0,0 +1,237 @@
1+#!/usr/bin/env bun
2+// sama — verify any TypeScript/JavaScript repo against the four SAMA
3+// disciplines (Sorted, Architecture, Modeled, Atomic). Mirrors the
4+// /sama/verify web tool but runs locally with no network for `check`,
5+// and uses the GitHub public API (no token) for `verify-repo`.
6+//
7+// Subcommands:
8+// sama check [--json] [--src=<dir>] verify the current repo's src/
9+// sama verify-repo <owner/name> [--json] verify a public GitHub repo
10+// sama --help usage
11+// sama --version version
12+//
13+// Exit codes: 0 = all checks pass, 1 = at least one check failed,
14+// 2 = error (bad args, network failure, etc.)
15+
16+import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
17+import { resolve, relative } from "node:path";
18+import { verifySama, type SamaReport } from "../src/c32_sama_verify.ts";
19+
20+const VERSION = "0.1.0";
21+
22+const RED = "\x1b[31m";
23+const GREEN = "\x1b[32m";
24+const YELLOW = "\x1b[33m";
25+const DIM = "\x1b[2m";
26+const BOLD = "\x1b[1m";
27+const RESET = "\x1b[0m";
28+
29+const isTty = process.stdout.isTTY;
30+const c = (color: string, s: string): string => (isTty ? `${color}${s}${RESET}` : s);
31+
32+const HELP = `sama ${VERSION} — verify any repo against the four SAMA disciplines.
33+
34+Usage:
35+ sama check [--json] [--src=<dir>]
36+ Verify the current repo's src/ directory.
37+
38+ sama verify-repo <owner/name> [--json]
39+ Verify a public GitHub repo over HTTPS.
40+
41+ sama --help, --version
42+
43+Disciplines checked:
44+ S — Sorted UI must not be a dependency of foundation/data/logic.
45+ A — Architecture Every cXX_ prefix must be in the known set.
46+ M — Modeled c32_*.ts logic files must have a sibling cXX_*.test.ts.
47+ A — Atomic ~700-line split rule, plus zero-expect() placeholder
48+ detection on test files.
49+
50+Exit codes: 0 pass · 1 fail · 2 error.
51+See https://tdd.md/sama for the full discipline pages.
52+`;
53+
54+interface ParsedArgs {
55+ command: "check" | "verify-repo" | "help" | "version" | null;
56+ json: boolean;
57+ src: string;
58+ repoArg?: string;
59+}
60+
61+const parseArgs = (argv: string[]): ParsedArgs => {
62+ const out: ParsedArgs = { command: null, json: false, src: "src" };
63+ let positional = 0;
64+ for (const a of argv) {
65+ if (a === "--help" || a === "-h") return { ...out, command: "help" };
66+ if (a === "--version" || a === "-V") return { ...out, command: "version" };
67+ if (a === "--json") out.json = true;
68+ else if (a.startsWith("--src=")) out.src = a.slice("--src=".length);
69+ else if (a.startsWith("--")) {
70+ console.error(`unknown flag: ${a}`);
71+ out.command = null;
72+ return out;
73+ } else if (positional === 0) {
74+ if (a === "check" || a === "verify-repo") out.command = a;
75+ else {
76+ console.error(`unknown command: ${a}`);
77+ return out;
78+ }
79+ positional++;
80+ } else if (positional === 1 && out.command === "verify-repo") {
81+ out.repoArg = a;
82+ positional++;
83+ } else {
84+ console.error(`unexpected argument: ${a}`);
85+ return out;
86+ }
87+ }
88+ return out;
89+};
90+
91+const walkSrc = (root: string, dir: string, out: string[]): void => {
92+ const entries = readdirSync(dir, { withFileTypes: true });
93+ for (const e of entries) {
94+ const full = `${dir}/${e.name}`;
95+ if (e.isDirectory()) {
96+ // Skip nested build/output dirs that conventionally aren't source.
97+ if (["node_modules", "dist", "build", ".bun", ".next"].includes(e.name)) continue;
98+ walkSrc(root, full, out);
99+ } else if (e.isFile() && e.name.endsWith(".ts")) {
100+ out.push(relative(root, full));
101+ }
102+ }
103+};
104+
105+const runCheck = async (args: ParsedArgs): Promise<number> => {
106+ const srcDir = resolve(process.cwd(), args.src);
107+ if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
108+ console.error(c(RED, `✗ ${args.src}/ not found in ${process.cwd()}`));
109+ return 2;
110+ }
111+ const tsFiles: string[] = [];
112+ walkSrc(srcDir, srcDir, tsFiles);
113+ const contents = new Map<string, string>();
114+ for (const f of tsFiles) {
115+ if (/(?:^|\/)c\d{2}_/.test(f)) {
116+ contents.set(f, readFileSync(`${srcDir}/${f}`, "utf8"));
117+ }
118+ }
119+ const report = verifySama({
120+ repoOwner: "(local)",
121+ repoName: args.src,
122+ defaultBranch: "(working tree)",
123+ srcPaths: tsFiles,
124+ contents,
125+ });
126+ return printReport(report, args.json);
127+};
128+
129+const runVerifyRepo = async (args: ParsedArgs): Promise<number> => {
130+ if (!args.repoArg) {
131+ console.error(c(RED, "✗ verify-repo requires an owner/name argument"));
132+ return 2;
133+ }
134+ const m = /^([^\/\s]+)\/([^\/\s]+)$/.exec(args.repoArg);
135+ if (!m) {
136+ console.error(c(RED, `✗ couldn't parse '${args.repoArg}' — expected owner/name`));
137+ return 2;
138+ }
139+ const [, owner, name] = m;
140+
141+ const repoRes = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner!)}/${encodeURIComponent(name!)}`, {
142+ headers: { Accept: "application/vnd.github+json", "User-Agent": "sama-cli" },
143+ });
144+ if (!repoRes.ok) {
145+ console.error(c(RED, `✗ GitHub repo lookup failed: HTTP ${repoRes.status}`));
146+ if (repoRes.status === 404) console.error(c(DIM, " the repo is private, the name is wrong, or it doesn't exist"));
147+ if (repoRes.status === 403) console.error(c(DIM, " GitHub rate limit hit (60/h anonymous) — wait or set up a token"));
148+ return 2;
149+ }
150+ const repoMeta = (await repoRes.json()) as { default_branch?: string };
151+ const defaultBranch = repoMeta.default_branch ?? "main";
152+
153+ const treeRes = await fetch(
154+ `https://api.github.com/repos/${encodeURIComponent(owner!)}/${encodeURIComponent(name!)}/git/trees/${encodeURIComponent(defaultBranch)}?recursive=1`,
155+ { headers: { Accept: "application/vnd.github+json", "User-Agent": "sama-cli" } },
156+ );
157+ if (!treeRes.ok) {
158+ console.error(c(RED, `✗ GitHub tree fetch failed: HTTP ${treeRes.status}`));
159+ return 2;
160+ }
161+ const treeData = (await treeRes.json()) as {
162+ tree?: Array<{ path: string; type: string }>;
163+ };
164+ const srcEntries = (treeData.tree ?? [])
165+ .filter((e) => e.type === "blob" && e.path.startsWith("src/") && e.path.endsWith(".ts"))
166+ .slice(0, 200);
167+ const srcPaths = srcEntries.map((e) => e.path.slice("src/".length));
168+ const samaPaths = srcPaths.filter((p) => /^c\d{2}_/.test(p));
169+ const contents = new Map<string, string>();
170+ await Promise.all(
171+ samaPaths.map(async (p) => {
172+ const url = `https://raw.githubusercontent.com/${encodeURIComponent(owner!)}/${encodeURIComponent(name!)}/${encodeURIComponent(defaultBranch)}/src/${p.split("/").map(encodeURIComponent).join("/")}`;
173+ const r = await fetch(url, { headers: { "User-Agent": "sama-cli" } });
174+ if (r.ok) contents.set(p, await r.text());
175+ }),
176+ );
177+ const report = verifySama({
178+ repoOwner: owner!,
179+ repoName: name!,
180+ defaultBranch,
181+ srcPaths,
182+ contents,
183+ });
184+ return printReport(report, args.json);
185+};
186+
187+const printReport = (report: SamaReport, json: boolean): number => {
188+ if (json) {
189+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
190+ return report.overallPassed ? 0 : 1;
191+ }
192+ const head = `${c(BOLD, "SAMA verify")} · ${report.repoSlug} · ${c(DIM, report.defaultBranch)}`;
193+ console.log(head);
194+ console.log(c(DIM, ` examined ${report.samaFiles} SAMA files / ${report.testFiles} tests / ${report.totalSrcFiles} src files`));
195+ console.log("");
196+ for (const ch of report.checks) {
197+ const status = ch.passed
198+ ? c(GREEN, "✓ pass")
199+ : c(RED, `✗ ${ch.violations.length} violation${ch.violations.length === 1 ? "" : "s"}`);
200+ console.log(` ${c(BOLD, ch.letter)} — ${ch.property}: ${status} ${c(DIM, `(${ch.examined} files)`)}`);
201+ for (const v of ch.violations.slice(0, 10)) {
202+ console.log(` ${c(DIM, "·")} ${v.file} — ${v.detail}`);
203+ }
204+ if (ch.violations.length > 10) {
205+ console.log(c(DIM, ` · ...and ${ch.violations.length - 10} more`));
206+ }
207+ if (ch.note) console.log(c(YELLOW, ` ${ch.note}`));
208+ }
209+ console.log("");
210+ if (report.overallPassed) {
211+ console.log(c(GREEN, "✓ all four checks passed"));
212+ return 0;
213+ } else {
214+ const failed = report.checks.filter((ch) => !ch.passed).length;
215+ console.log(c(RED, `✗ ${failed} of 4 checks failed`));
216+ return 1;
217+ }
218+};
219+
220+const args = parseArgs(process.argv.slice(2));
221+let exitCode = 0;
222+try {
223+ if (args.command === "help" || args.command === null) {
224+ console.log(HELP);
225+ exitCode = args.command === "help" ? 0 : 2;
226+ } else if (args.command === "version") {
227+ console.log(`sama ${VERSION}`);
228+ } else if (args.command === "check") {
229+ exitCode = await runCheck(args);
230+ } else if (args.command === "verify-repo") {
231+ exitCode = await runVerifyRepo(args);
232+ }
233+} catch (e) {
234+ console.error(c(RED, `✗ ${e instanceof Error ? e.message : String(e)}`));
235+ exitCode = 2;
236+}
237+process.exit(exitCode);
modified src/c21_app.ts +62 −1
@@ -648,6 +648,16 @@ ${rows}
648648 });
649649 },
650650
651+ "/tools/sama-cli": new Response(Bun.file("./public/sama-cli"), {
652+ headers: {
653+ // text/javascript so browsers preview as code; the shebang at
654+ // line 1 makes the file directly executable once chmod +x'd.
655+ "Content-Type": "text/javascript; charset=utf-8",
656+ "Content-Disposition": 'inline; filename="sama"',
657+ "Cache-Control": "public, max-age=300",
658+ },
659+ }),
660+
651661 "/sama/skill": async () => {
652662 const raw = await Bun.file("./content/sama/skill.md").text();
653663 // 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
854864
855865 ## verify any public repo
856866
857-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).
867+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).
868+
869+## the \`sama\` CLI
870+
871+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:
872+
873+\`\`\`bash
874+mkdir -p ~/.local/bin
875+curl -fsSL https://tdd.md/tools/sama-cli -o ~/.local/bin/sama
876+chmod +x ~/.local/bin/sama
877+sama --help
878+\`\`\`
879+
880+Two subcommands:
881+
882+\`\`\`bash
883+sama check # verify the current repo's src/
884+sama check --json # JSON output for piping into CI tooling
885+sama verify-repo owner/name # verify a public GitHub repo (no token)
886+\`\`\`
887+
888+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\`.
889+
890+### pre-commit hook
891+
892+Add to \`.git/hooks/pre-commit\` (or via \`husky\`, \`pre-commit\`, \`lefthook\`):
893+
894+\`\`\`bash
895+#!/usr/bin/env bash
896+# Block commits that violate SAMA layer/atomic/modeled rules.
897+exec sama check
898+\`\`\`
899+
900+### GitHub Action
901+
902+\`\`\`yaml
903+# .github/workflows/sama.yml
904+name: sama
905+on: [push, pull_request]
906+jobs:
907+ verify:
908+ runs-on: ubuntu-latest
909+ steps:
910+ - uses: actions/checkout@v4
911+ - uses: oven-sh/setup-bun@v2
912+ - run: |
913+ curl -fsSL https://tdd.md/tools/sama-cli -o sama
914+ chmod +x sama
915+ ./sama check
916+\`\`\`
917+
918+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.
858919
859920 ## the case behind it
860921