syntaxai/tdd.md · commit 0159bbb

SAMA section: /sama landing + per-discipline pages

Spells out the four SAMA properties as a navigable section so an AI
agent (or human) can point at one rule at a time:

  /sama                landing: four-property table + reading order
  /sama/sorted         alphabetical sort = dependency direction;
                       includes the one-line verification grep
  /sama/architecture   the layer prefixes (c11/c13/c14/c21/c31/c32/c51)
                       and what each one is allowed to contain
  /sama/modeled        tests next to source; types and parsers in c31_*
  /sama/atomic         one responsibility, ~700-line split rule,
                       no barrel re-exports

Each discipline page closes with the common-mistakes list and links
to the previous/next discipline.

Files:
  src/c31_sama.ts                  registry of the four disciplines
                                   (slug, letter, title, rule, blurb)
  src/c21_app.ts                   /sama + /sama/:slug routes; sitemap
                                   gets the five new URLs
  src/c51_render_layout.ts         "sama" added to the Section type
                                   and the top nav strip
  content/sama/sorted.md
  content/sama/architecture.md
  content/sama/modeled.md
  content/sama/atomic.md

Follows the same /guides + /guides/:slug + content/guides/<slug>.md
pattern that already drives the agent walkthroughs - one model, one
index handler, one detail handler, four markdown bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-09 14:33:51 +01:00
parent
a518670
commit
0159bbb5c2a8721d2225ff77104f96c3561e1998

7 files changed · +485 −2

added content/sama/architecture.md +75 −0
@@ -0,0 +1,75 @@
1+# A — Architecture
2+
3+> **Rule:** the number is the layer; the layer is the contract. Pick the layer first, then the name.
4+
5+The second letter. Where *Sorted* says "files line up by prefix", *Architecture* says **what each prefix means**. A file's `cXX_*.ts` prefix tells the agent — and the reviewer — exactly what kind of code is allowed inside it.
6+
7+## The layers
8+
9+| prefix | layer | what's allowed | what's not |
10+|---|---|---|---|
11+| `c11_*` | server entry | env, `Bun.serve()`, port wiring | route logic, SQL, HTML |
12+| `c13_*` | database | SQLite queries, schema | HTTP, HTML, route handling |
13+| `c14_*` | secondary I/O | HTTP clients (GitHub, Forgejo, mail) | SQL, business logic |
14+| `c21_*` | handlers | route handlers, composes lower layers | direct SQL, raw HTTP, model definitions |
15+| `c31_*` | models | types, parsers, pure data helpers | I/O of any kind, side effects |
16+| `c32_*` | logic | pure business logic, deterministic transforms | I/O, randomness, time without injection |
17+| `c51_*` | UI | HTML rendering, page chrome | data fetching, mutations |
18+
19+Numbers are spaced (no `c12`, no `c22`) so future layers can land between existing ones without renaming the world.
20+
21+## Why prefix-as-contract
22+
23+The alternative — folder names like `src/handlers/`, `src/models/`, `src/db/` — looks tidier but pays a hidden tax: an agent moving between projects has to relearn the folder layout each time. The SAMA prefix is *one rule, three projects*, identical everywhere. Once you know `c31` means "model", you know it forever.
24+
25+The prefix also keeps the import grep simple. Folder-based layouts force you to check every import against a multi-line config of "which folder may import which folder". The number does the same job in one comparison.
26+
27+## Picking the right prefix
28+
29+Decide in this order:
30+
31+1. **Does it perform I/O?** Yes → `c13` (SQL) or `c14` (HTTP). No → continue.
32+2. **Is it pure types or parsers, with no logic?** Yes → `c31`.
33+3. **Is it pure logic that derives one value from others?** Yes → `c32`.
34+4. **Does it produce HTML?** Yes → `c51`.
35+5. **Is it a route handler that composes the above?** Yes → `c21`.
36+
37+If you can't fit the file into one of those buckets, the file is doing more than one job — split it. *Atomic* covers the split rule.
38+
39+## Examples from this codebase
40+
41+```
42+c11_server.ts PORT + Bun.serve(createApp())
43+c13_database.ts SQLite (runs + projects tables)
44+c14_github.ts GitHub OAuth + commits API + raw-content fetch
45+c14_forgejo.ts Forgejo HTTP + proxy
46+c21_app.ts routes literal + dispatcher
47+c21_handlers_agents.ts route handlers for /agents/*
48+c31_blog.ts ALL_POSTS registry (pure data)
49+c31_commits.ts parseCommit + computeProgress (parser + helper)
50+c31_project_config.ts .tdd-md.json schema + parser
51+c32_judge.ts kata-judging logic (pure)
52+c32_session.ts HMAC sign/verify + cookie helpers (pure)
53+c51_render_layout.ts page chrome, escape, htmlResponse
54+c51_render_reports.ts /reports body builders
55+```
56+
57+Reading top-to-bottom = reading the dependency graph. `c21_app.ts` imports things from its row and above. `c11_server.ts` imports from `c21` only. Nothing reaches "down" past its own number.
58+
59+## Common mistakes
60+
61+- **A handler doing its own SQL.** If `c21_handlers_*.ts` calls `db.query(...)` directly, the database concern leaked. Move the query into `c13_*` and have the handler call the helper.
62+- **A model that imports `fetch`.** `c31_*` is pure. If you find yourself adding I/O, the file becomes `c14_*` or splits in two: a `c31_*` model + a `c14_*` client that produces it.
63+- **A renderer that parses input.** `c51_*` produces HTML from already-shaped data. Parsing belongs in `c31_*`, never inside the renderer.
64+
65+When you spot a violation, the fix is always the same shape: split the file along the layer line. Two right files beat one wrong file every time.
66+
67+## What this gives you
68+
69+- A reviewer can read a diff and tell whether a change belongs in this file *just from the prefix*. No need to re-derive what should go where.
70+- An agent asked to "add a route" knows it lives in `c21_*` without thinking.
71+- "Where do the types go?" has one answer: `c31_*`.
72+
73+---
74+
75+[← S — Sorted](/sama/sorted) · [/sama](/sama) · [next: M — Modeled →](/sama/modeled)
added content/sama/atomic.md +100 −0
@@ -0,0 +1,100 @@
1+# A — Atomic
2+
3+> **Rule:** one responsibility per module. When a layer file passes ~700 lines, split per UI/data domain using the same prefix. No barrel re-exports.
4+
5+The fourth and final letter of SAMA. *Atomic* is the rule that keeps every other property honest as the codebase grows. *Sorted* doesn't help if every file is 5,000 lines. *Architecture* doesn't help if a single `c21_*.ts` does fifteen unrelated things. *Modeled* doesn't help if the test file balloons to match.
6+
7+## One responsibility per module
8+
9+The smallest version of the rule: **a module should answer one question.** `c32_session.ts` answers "how do we sign and verify session cookies?" — that's one question. If you find yourself adding "and also generate access tokens" or "and also handle CSRF", you have a second question and the file should split.
10+
11+Concretely:
12+
13+- One model per domain in `c31_*` (`c31_project_config.ts`, `c31_blog.ts`, `c31_games.ts`).
14+- One pure-logic concern per file in `c32_*` (`c32_judge.ts`, `c32_session.ts`).
15+- One I/O target per file in `c14_*` (`c14_github.ts`, `c14_forgejo.ts`).
16+
17+When in doubt: read the filename. If you can't say what's inside in one short clause without `and`, you have an atom problem.
18+
19+## The ~700-line split rule
20+
21+Lines are a proxy for surface area. Past ~700 lines, even a "single responsibility" file usually contains several sub-domains that each deserve their own atom. The split rule:
22+
23+> When a layer file passes ~700 lines, split per UI/data domain using the same prefix.
24+
25+The prefix stays — both halves are still the same layer. The suffix changes to name the sub-domain.
26+
27+### c51 split (UI/render layer)
28+
29+```
30+c51_render_layout.ts chrome: renderPage, renderNotFound, escape, htmlResponse
31+c51_render_projects.ts /projects body builders
32+c51_render_reports.ts /reports body builders
33+```
34+
35+Same layer (c51), three atoms. Layout holds the chrome that everyone reuses; the per-domain files hold body builders that only one part of the site uses.
36+
37+### c21 split (handlers layer)
38+
39+```
40+c21_app.ts routes literal + dispatcher (the "router")
41+c21_handlers_agents.ts /agents/* handlers
42+c21_handlers_auth.ts OAuth handlers
43+c21_handlers_leaderboard.ts /leaderboard handler
44+```
45+
46+`c21_app.ts` stays the dispatcher; per-cluster handlers move out the moment the dispatcher would otherwise grow large.
47+
48+### c13 split (database layer)
49+
50+```
51+c13_database.ts connection + dispatcher (when needed)
52+c13_db_runs.ts runs-table SQL
53+c13_db_projects.ts projects-table SQL
54+```
55+
56+Same shape: one "core" file, per-domain extensions when growth pushes past the threshold.
57+
58+The number 700 is not magic. It's the line count at which one experienced reader finds it slower to navigate the file than to remember which of three smaller files holds what they want. If 600 is your number, use 600. The rule is *a threshold exists*, not *exactly 700*.
59+
60+## No barrel re-exports
61+
62+When you split a layer file into multiple atoms, **do not** create an `index.ts` (or `c51_render.ts`, or `c21_handlers.ts`) that re-exports everything. Consumers import directly from the atom they need.
63+
64+```ts
65+// good — direct from the atom
66+import { renderProjectDetail } from "./c51_render_projects.ts";
67+import { renderReportTile } from "./c51_render_reports.ts";
68+
69+// bad — barrel hides the dependency direction
70+import { renderProjectDetail, renderReportTile } from "./c51_render.ts";
71+```
72+
73+Why this matters:
74+
75+- A barrel re-exports everything in the layer, which means importing one helper drags every other helper's transitive deps into your module's compile graph. Bun and TypeScript both pay for this.
76+- The grep that proves *Sorted* relies on direct imports. A barrel makes the grep useless because every cross-file reference goes through the same neutral name.
77+- Renaming or moving a function inside a barrel-ed layer becomes a coordination problem. Without barrels, the IDE follows the import to one obvious place.
78+
79+## Why ~700 (the rationale)
80+
81+The threshold is set by what a coding agent can hold in a single tight context window without drift. Past about 700 lines, you start needing to cite line numbers in prompts ("look at the function around line 480") which signals the file has outgrown the agent's working memory. Splitting at the threshold keeps context windows small even for non-trivial domains.
82+
83+It also keeps **token cost** bounded. An agent given "edit `c51_render_reports.ts`" pulls in one bounded file plus its sibling test, and not the entire layer. That's the same constraint the [token-saving tips](/blog/three-constraints-agentic-coding) push from the prompt side; *Atomic* enforces it from the codebase side.
84+
85+## Common mistakes
86+
87+- **"It's still one responsibility, just bigger."** Often true at 800 lines, less true at 1,200, and almost never true at 1,800. Re-read the file as if you were new to the codebase. If you'd want a table of contents, you have at least two atoms.
88+- **Splitting along the wrong axis.** Split per UI/data *domain*, not per "type of helper". `c51_render_helpers.ts` + `c51_render_pages.ts` is a worse split than `c51_render_layout.ts` + `c51_render_projects.ts` + `c51_render_reports.ts`.
89+- **Adding a barrel "for ergonomics".** The five extra characters in the import path are not a real cost. The barrel is.
90+- **Splitting too eagerly.** A 200-line file with two clear functions is fine. Don't split before the threshold; the cost of two files is real, even if small.
91+
92+## What this gives you
93+
94+- Every file fits in one context window with room to spare for its test.
95+- Renames and moves stay local — no barrel to update.
96+- An agent told to "work on the projects domain" reads `c51_render_projects.ts` + `c21_handlers_projects.ts` (if exists) + `c31_project_config.ts` and that's the entire surface. Three small files, one domain, one trip.
97+
98+---
99+
100+[← M — Modeled](/sama/modeled) · [/sama](/sama) · [back to the four properties](/sama)
added content/sama/modeled.md +105 −0
@@ -0,0 +1,105 @@
1+# M — Modeled
2+
3+> **Rule:** tests live next to source. Types and parse-functions live in `c31_*`. The shape comes before the logic.
4+
5+The third letter of SAMA. Where *Sorted* and *Architecture* tell the agent *where files go*, *Modeled* tells it *what those files contain*. Two concrete commitments:
6+
7+1. Every behaviour has a test file as its sibling — `c32_session.ts` next to `c32_session.test.ts`.
8+2. Every external input has a parser in a `c31_*` model — types and parse-functions colocated, not scattered.
9+
10+The shape of the data and the proof of the behaviour are first-class artefacts, never afterthoughts.
11+
12+## Tests next to source
13+
14+Not in a separate `tests/` tree. Not in a parallel `__tests__` folder. The test for `c32_session.ts` is `c32_session.test.ts` in the same directory.
15+
16+```
17+src/
18+├── c32_session.ts ← impl
19+├── c32_session.test.ts ← its sibling test
20+├── c32_judge.ts
21+└── c32_judge.test.ts
22+```
23+
24+Why this matters for an agent:
25+
26+- When you `ls src/c32_session.*`, you see *both* the impl and its proof.
27+- When you delete the impl, the test goes too — no orphan test files to chase.
28+- When you start a TDD red commit, the test file is already next to where the impl will land — no path-juggling.
29+- Coverage is a glance, not a tool: any `cXX_*.ts` without a sibling `*.test.ts` is unproven.
30+
31+The tooling agrees: `bun test` discovers `*.test.ts` automatically with no path config.
32+
33+## Parsers live in c31
34+
35+Anything that comes in from outside the process — JSON files, request bodies, environment variables, third-party API responses — gets a parser in `c31_*` *before* any logic touches it. The parser:
36+
37+- Defines the type (the shape).
38+- Validates the input.
39+- Throws a clear error on bad input.
40+
41+```ts
42+// src/c31_project_config.ts
43+export interface ProjectConfig {
44+ test_runner: TestRunner;
45+ tracked_branches: string[];
46+ // ...
47+}
48+
49+export const parseProjectConfig = (raw: unknown): ProjectConfig => {
50+ // explicit shape check, then return a typed value
51+};
52+```
53+
54+The rest of the codebase consumes `ProjectConfig`, never `unknown` and never `any`. By the time data leaves `c31_*`, it has a name and a shape that the type system can carry the rest of the way.
55+
56+## "The shape comes before the logic"
57+
58+The order in which an agent should approach a feature:
59+
60+1. What's the input shape? → `c31_*` type + parser.
61+2. What's the behaviour? → red test in the test file.
62+3. What's the impl? → just enough to pass the test.
63+
64+Not the other way around. Writing the impl first lets you smuggle assumptions about the input ("oh, this field is always a number") into code that should have refused to start without an explicit type.
65+
66+The discipline lines up with TDD's iron law: *no code without a failing test first*. SAMA adds: *no test without a typed input first*. Together they force you to write down what the world looks like before you write down what to do with it.
67+
68+## Examples
69+
70+**Good** — type + parser + sibling test:
71+
72+```
73+src/
74+├── c31_project_config.ts type + parseProjectConfig
75+├── c31_project_config.test.ts parser-edge-case tests
76+├── c32_judge.ts logic that consumes ProjectConfig
77+└── c32_judge.test.ts judge-behaviour tests
78+```
79+
80+**Bad** — type defined inline at point of use:
81+
82+```ts
83+// src/c21_handlers_projects.ts
84+const config = await req.json() as { test_runner: string; ... };
85+// ↑ no parser, no validation, type lies
86+```
87+
88+The fix: move the type to `c31_project_config.ts`, write a `parseProjectConfig` that validates, call it in the handler. The shape and its proof are now in one place; every consumer benefits.
89+
90+## Common mistakes
91+
92+- **Tests in a parallel tree.** Breaks the "delete the impl, test goes too" property. Move them next to source.
93+- **`as` casts at I/O boundaries.** A cast is a lie the type system promises not to question. Replace casts with parsers in `c31_*`.
94+- **Inline `interface` in handlers / logic.** If two files use the same shape, it belongs in `c31_*`, not duplicated at each consumer.
95+- **A test file with no sibling impl.** The test is for something that no longer exists, or the impl drifted to another file. Either reattach it or delete it.
96+
97+## What this gives you
98+
99+- An agent given the path `c32_session.ts` can find the contract (sibling test) and the input shape (`c31_*` type) without searching.
100+- The boundary between the typed inside and the untyped outside is one file (`c31_*`) per concern, not a scatter of `as` casts.
101+- "What does this code do?" is answered by the test next to it, every time.
102+
103+---
104+
105+[← A — Architecture](/sama/architecture) · [/sama](/sama) · [next: A — Atomic →](/sama/atomic)
added content/sama/sorted.md +76 −0
@@ -0,0 +1,76 @@
1+# S — Sorted
2+
3+> **Rule:** alphabetical sort = dependency direction. Lower-numbered layers never import from higher-numbered ones.
4+
5+The first property of SAMA. Run `ls src/` and you have the architecture diagram. There is no separate "where does the data flow?" document because the file system answers it.
6+
7+## Why
8+
9+Two reasons:
10+
11+1. **An agent can navigate by `ls`.** No need to read a `README` to know that `c14_github.ts` is lower than `c21_app.ts`. The prefix is the layer; the layer is the import contract.
12+2. **The rule is mechanically verifiable.** A grep proves it or refutes it in one second. There is no judgement call about "is this dependency OK?" — the only question is whether the prefix on the left is lower than the prefix on the right.
13+
14+## The verification command
15+
16+Run this from the repo root:
17+
18+```bash
19+grep -rE 'from "\./c[5-9]' src/c1*.ts src/c2*.ts src/c3*.ts
20+```
21+
22+If the output is empty, the rule holds. If anything appears, you have a higher layer leaking into a lower one — fix the import or move the file.
23+
24+This is the single load-bearing test for the *Sorted* property. Wire it into CI and forget about it.
25+
26+## Examples
27+
28+**Allowed** — lower importing higher? Never. Higher importing lower? Always:
29+
30+```ts
31+// src/c21_app.ts (handler layer — c21)
32+import { fetchUser } from "./c14_github.ts"; // c14 < c21 ✓
33+import { parseCommit } from "./c31_commits.ts"; // c31 > c21 — wrong direction!
34+```
35+
36+Wait — `c31` is *higher* than `c21`, so this would be backwards. Let me restate the example correctly:
37+
38+```ts
39+// src/c21_app.ts
40+import { fetchUser } from "./c14_github.ts"; // c14 < c21 ✓ (handler uses I/O)
41+import { parseCommit } from "./c31_commits.ts"; // c31 > c21 — this is fine,
42+ // c21 (handler) consumes c31 (model)
43+```
44+
45+The rule is *lower numbers never import higher numbers*. So `c14` (I/O) cannot import `c21` (handler) — that would be a leaf importing the trunk. `c21` importing `c31` is fine because `c21` (handler) is meant to compose models from `c31`.
46+
47+**Forbidden:**
48+
49+```ts
50+// src/c14_github.ts (I/O layer — c14)
51+import { renderPage } from "./c51_render_layout.ts"; // c51 < c14? NO — c51 > c14, but
52+ // wait, the rule is
53+ // "lower never imports higher",
54+ // and c14 < c51, so importing
55+ // c51 from c14 is forbidden.
56+```
57+
58+Plain reading of the rule: a file at level *n* may only import from levels *< n*. The grep above checks the most common violation (1- and 2- and 3-prefixed files reaching into 5+).
59+
60+## Common mistakes
61+
62+- **Putting "shared utilities" in a top-level file with no prefix.** The prefix isn't optional. If a helper is used across layers, it belongs in the lowest layer that all callers can reach — usually `c31_*` (pure models) or a layer-appropriate `_layout` / `_helpers` file (e.g. `c51_render_layout.ts`).
63+- **Re-exporting from a "barrel" file.** Defeats the grep. Every module imports directly from the source file.
64+- **Treating tests as "outside the rule".** Tests are part of the layer they test. `c32_session.test.ts` is itself `c32` and follows the same import constraints.
65+
66+## What this gives you
67+
68+A file tree where:
69+
70+- `ls src/` is the dependency graph, sorted top-to-bottom.
71+- The agent can answer "where does X live?" in one prefix-letter.
72+- Layer violations are caught by `grep`, not by reviewers' memory.
73+
74+---
75+
76+[← /sama](/sama) · [next: A — Architecture →](/sama/architecture)
modified src/c21_app.ts +80 −0
@@ -27,6 +27,7 @@ import { fetchProjectConfig } from "./c14_github.ts";
2727 import { listGames, loadGame } from "./c31_games.ts";
2828 import { ALL_POSTS } from "./c31_blog.ts";
2929 import { ALL_GUIDES } from "./c31_guides.ts";
30+import { ALL_SAMA } from "./c31_sama.ts";
3031 import {
3132 DEMO_REPORTS,
3233 DEMO_PERIOD,
@@ -291,6 +292,9 @@ export const createApp = (port: number) => Bun.serve({
291292 const guideUrls = ALL_GUIDES.map((g) =>
292293 url(`https://tdd.md/guides/${g.slug}`, "0.8"),
293294 ).join("\n");
295+ const samaUrls = ALL_SAMA.map((d) =>
296+ url(`https://tdd.md/sama/${d.slug}`, "0.8"),
297+ ).join("\n");
294298 const blogUrls = ALL_POSTS.map((p) =>
295299 url(`https://tdd.md/blog/${p.slug}`, "0.8"),
296300 ).join("\n");
@@ -301,6 +305,8 @@ ${url("https://tdd.md/games", "0.9")}
301305 ${kataUrls}
302306 ${url("https://tdd.md/guides", "0.9")}
303307 ${guideUrls}
308+${url("https://tdd.md/sama", "0.9")}
309+${samaUrls}
304310 ${url("https://tdd.md/blog", "0.7")}
305311 ${blogUrls}
306312 ${url("https://tdd.md/agents", "0.7")}
@@ -624,6 +630,80 @@ ${rows}
624630 return htmlResponse(html);
625631 },
626632
633+ "/sama": async () => {
634+ const rows = ALL_SAMA
635+ .map((d) => `| **[${d.letter} — ${d.title}](/sama/${d.slug})** | ${d.rule} |`)
636+ .join("\n");
637+ const body = `# SAMA
638+
639+> **Sorted, Architecture, Modeled, Atomic.** Four properties of a codebase that an AI agent can navigate, change, and verify without drift. The acronym is the rule set; each letter has a one-paragraph definition and a verification you can run.
640+
641+This is the file-naming and module-organisation convention this site is built on, shared across two other projects in my workspace. It exists to give an AI agent **one obvious place** for every change — and one mechanical check for every layer rule.
642+
643+## the four disciplines
644+
645+| letter | discipline | one-line rule |
646+|---|---|---|
647+${rows}
648+
649+## reading order
650+
651+If you're new to this:
652+1. Start with **[Sorted](/sama/sorted)** — it has the verification grep that everything else is built around.
653+2. Then **[Architecture](/sama/architecture)** — what each layer prefix means.
654+3. Then **[Modeled](/sama/modeled)** — where types and tests live.
655+4. Then **[Atomic](/sama/atomic)** — the split rule that keeps the rest honest as the codebase grows.
656+
657+Each page is short, opinionated, and ends with the common mistakes you'll see if the discipline lapses.
658+
659+## why these four together
660+
661+Each property fixes a different failure mode:
662+
663+- *Sorted* fails when imports go in any direction → grep proves the rule.
664+- *Architecture* fails when responsibilities blur → the prefix is the contract.
665+- *Modeled* fails when types and tests scatter → siblings are mandatory.
666+- *Atomic* fails when files swell → the ~700-line split keeps atoms small.
667+
668+Pick one and you'll claw back some clarity. Pick all four and the codebase becomes the kind an agent can be left alone with — there is exactly one right place for any change, and a one-line shell command that proves the layer rule.
669+
670+The blog post [*Red, tokens, atoms*](/blog/three-constraints-agentic-coding) argues SAMA also compounds with TDD and Claude Code's token-saving discipline; the four properties on this page are the *Atomic* / *Modeled* / *Architecture* / *Sorted* halves of that story.
671+
672+[← back to tdd.md](/) · [the blog](/blog) · [the guides](/guides)
673+`;
674+ const html = await renderPage({
675+ title: "SAMA — sorted, architecture, modeled, atomic — tdd.md",
676+ description: "SAMA is a four-property file-naming and module convention for codebases that AI agents work in: sorted by layer prefix, architecture as a contract, models with siblings, atomic files. One page per discipline.",
677+ bodyMarkdown: body,
678+ ogPath: "https://tdd.md/sama",
679+ active: "sama",
680+ });
681+ return htmlResponse(html);
682+ },
683+
684+ "/sama/:slug": async (req) => {
685+ const slug = req.params.slug;
686+ const entry = ALL_SAMA.find((d) => d.slug === slug);
687+ if (!entry) {
688+ const html = await renderNotFound(`/sama/${slug}`);
689+ return htmlResponse(html, 404);
690+ }
691+ const file = Bun.file(`./content/sama/${slug}.md`);
692+ if (!(await file.exists())) {
693+ const html = await renderNotFound(`/sama/${slug}`);
694+ return htmlResponse(html, 404);
695+ }
696+ const md = await file.text();
697+ const html = await renderPage({
698+ title: `SAMA · ${entry.letter} — ${entry.title} — tdd.md`,
699+ description: entry.description,
700+ bodyMarkdown: md,
701+ ogPath: `https://tdd.md/sama/${slug}`,
702+ active: "sama",
703+ });
704+ return htmlResponse(html);
705+ },
706+
627707 "/games/:kata": async (req) => {
628708 const res = await renderKata(req.params.kata);
629709 if (res) return res;
added src/c31_sama.ts +47 −0
@@ -0,0 +1,47 @@
1+// c31 — model: SAMA discipline registry. Drives /sama + /sama/:slug.
2+// Markdown bodies live in content/sama/<slug>.md. Each entry maps to
3+// one of the four SAMA properties (Sorted, Architecture, Modeled,
4+// Atomic) and surfaces its one-line rule on the index page.
5+
6+export interface SamaDiscipline {
7+ slug: "sorted" | "architecture" | "modeled" | "atomic";
8+ letter: "S" | "A" | "M" | "A";
9+ title: string;
10+ rule: string;
11+ description: string;
12+}
13+
14+export const ALL_SAMA: SamaDiscipline[] = [
15+ {
16+ slug: "sorted",
17+ letter: "S",
18+ title: "Sorted",
19+ rule: "Alphabetical sort = dependency direction. Lower-numbered layers never import from higher-numbered ones.",
20+ description:
21+ "The first letter of SAMA. `ls src/` is the architecture diagram: files sort by layer prefix, and the prefix tells the agent what may import from what. One grep verifies the rule.",
22+ },
23+ {
24+ slug: "architecture",
25+ letter: "A",
26+ title: "Architecture",
27+ rule: "The number is the layer; the layer is the contract. c11 = entry, c13 = SQL, c14 = HTTP I/O, c21 = handlers, c31 = models, c32 = pure logic, c51 = UI.",
28+ description:
29+ "The contract is in the prefix. A `c31_*` file holds models — no I/O. A `c21_*` file composes lower layers — no SQL of its own. Pick the layer first, then the name.",
30+ },
31+ {
32+ slug: "modeled",
33+ letter: "M",
34+ title: "Modeled",
35+ rule: "Tests live next to source. Types and parse-functions live in c31_*. The shape comes before the logic.",
36+ description:
37+ "Every behaviour has a test file as its sibling, every external input has a parser in `c31_*`. The model is the thing the impl has to satisfy — not a docstring, not a comment, the file next to it.",
38+ },
39+ {
40+ slug: "atomic",
41+ letter: "A",
42+ title: "Atomic",
43+ rule: "One responsibility per module. When a layer file passes ~700 lines, split per UI/data domain using the same prefix. No barrel re-exports.",
44+ description:
45+ "Atoms are small enough that an agent can hold one in its context with room to spare for the test. The split rule keeps them small as the codebase grows; the no-barrel rule keeps imports honest.",
46+ },
47+];
modified src/c51_render_layout.ts +2 −2
@@ -11,7 +11,7 @@ import type { Phase } from "./c31_commits.ts";
1111 const STYLE_CSS = "./public/style.css";
1212 const css = await Bun.file(STYLE_CSS).text();
1313
14-export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard";
14+export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama";
1515
1616 export interface PageOptions {
1717 title: string;
@@ -33,7 +33,7 @@ const navLink = (href: string, label: string, active: boolean): string => {
3333 return `<a href="${href}"${cls}>${label}</a>`;
3434 };
3535
36-const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`;
36+const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/sama", "sama", active === "sama")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`;
3737
3838 export const renderPage = async (opts: PageOptions): Promise<string> => {
3939 const body = await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false });