syntaxai/tdd.md · main · content / sama / modeled.md

modeled.md 106 lines · 4652 bytes raw · source

M — Modeled

Rule: tests live next to source. Types and parse-functions live in c31_*. The shape comes before the logic.

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:

  1. Every behaviour has a test file as its sibling — c32_session.ts next to c32_session.test.ts.
  2. Every external input has a parser in a c31_* model — types and parse-functions colocated, not scattered.

The shape of the data and the proof of the behaviour are first-class artefacts, never afterthoughts.

Tests next to source

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.

src/
├── c32_session.ts         ← impl
├── c32_session.test.ts    ← its sibling test
├── c32_judge.ts
└── c32_judge.test.ts

Why this matters for an agent:

  • When you ls src/c32_session.*, you see both the impl and its proof.
  • When you delete the impl, the test goes too — no orphan test files to chase.
  • When you start a TDD red commit, the test file is already next to where the impl will land — no path-juggling.
  • Coverage is a glance, not a tool: any cXX_*.ts without a sibling *.test.ts is unproven.

The tooling agrees: bun test discovers *.test.ts automatically with no path config.

Parsers live in c31

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:

  • Defines the type (the shape).
  • Validates the input.
  • Throws a clear error on bad input.
// src/c31_project_config.ts
export interface ProjectConfig {
  test_runner: TestRunner;
  tracked_branches: string[];
  // ...
}

export const parseProjectConfig = (raw: unknown): ProjectConfig => {
  // explicit shape check, then return a typed value
};

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.

"The shape comes before the logic"

The order in which an agent should approach a feature:

  1. What's the input shape? → c31_* type + parser.
  2. What's the behaviour? → red test in the test file.
  3. What's the impl? → just enough to pass the test.

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.

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.

Examples

Good — type + parser + sibling test:

src/
├── c31_project_config.ts        type + parseProjectConfig
├── c31_project_config.test.ts   parser-edge-case tests
├── c32_judge.ts                 logic that consumes ProjectConfig
└── c32_judge.test.ts            judge-behaviour tests

Bad — type defined inline at point of use:

// src/c21_handlers_projects.ts
const config = await req.json() as { test_runner: string; ... };
//                                  ↑ no parser, no validation, type lies

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.

Common mistakes

  • Tests in a parallel tree. Breaks the "delete the impl, test goes too" property. Move them next to source.
  • as casts at I/O boundaries. A cast is a lie the type system promises not to question. Replace casts with parsers in c31_*.
  • Inline interface in handlers / logic. If two files use the same shape, it belongs in c31_*, not duplicated at each consumer.
  • 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.

What this gives you

  • An agent given the path c32_session.ts can find the contract (sibling test) and the input shape (c31_* type) without searching.
  • The boundary between the typed inside and the untyped outside is one file (c31_*) per concern, not a scatter of as casts.
  • "What does this code do?" is answered by the test next to it, every time.

← A — Architecture · /sama · next: A — Atomic →