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