syntaxai/tdd.md · commit f07c9b7

Spec: draft three v2.1 dialects into §6 + parser tolerance for opt-in flags

The dive and ripgrep audits surfaced three places where v2.0 surface
syntax mismatches a target language's idiom: filename-prefix Sorted
doesn't fit Go/Rust, sibling-file Modeled-tests doesn't fit Rust, and
the Atomic 700-LOC cap mis-flags declarative catalogs like ripgrep's
7,779-line flags/defs.rs. The audits proposed three dialects in blog
posts; subsequent audits silently assumed them. Promotes those informal
proposals to drafted §6 extensions in content/sama/v2.md.

- New §6.A umbrella + §6.1/6.2/6.3 subsections, each with the same
  five-part structure (profile syntax / what it relaxes / property
  preserved / preservation mechanism / falsifiable cross-repo
  experiment). All three are opt-in profile flags, default to v2.0
  behaviour, and parsing them does not yet activate dialect semantics
  — activation is a later promotion event per §6.
- ProfileSpec gains three optional fields (layout, tests,
  atomicExemption) with type aliases + allowed-value constants.
- TOML parser reads the three top-level flags, validates against the
  allowed set, throws a clear /sama/v2 §6-referencing error on
  unknown values, leaves them undefined when absent.
- 12 new parser tests (accept each valid value, reject unknown values,
  absent → undefined ≡ v2.0 default, all three can co-occur, dialect
  flags don't interfere with layer parsing). 312/312 pass total.
- This repo's sama.profile.toml does NOT set any dialect flag, so the
  §4 verifier behaviour is bit-identical and /sama/v2/verify continues
  to report 7/7 ✓.
- Five blog posts cross-linked to the new §6 anchor IDs at their
  canonical first-mention sites.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-24 10:29:05 +01:00
parent
c19ffe5
commit
f07c9b7bc320601e92f77df8ad269bb4fdcde148

9 files changed · +248 −12

modified content/blog/sama-v2-go-project-dive-rebuilt.md +2 −0
@@ -8,6 +8,8 @@ This sketch is much smaller than the WordPress one. The starting point is alread
88
99 ## The directory-based dialect this sketch assumes
1010
11+*(Update: this dialect has since been drafted formally into [/sama/v2 §6.1 Directory-layout dialect](/sama/v2#61-directory-layout-dialect) as a v2.1-draft extension. The discussion below is the original informal proposal from this post; the spec section is the canonical reference.)*
12+
1113 v2.0's §4.1 Sorted check is written with TypeScript/PHP filename conventions in mind: *"every file carries a profile-recognized prefix; lexicographic prefix order equals layer order."* The Sorted check is what makes the layer-from-`ls` property real — a reviewer can glance at `ls src/` and read dependency direction off the file ordering.
1214
1315 Go doesn't work like that. Files inside a Go package have descriptive names (`comparer.go`, `diff.go`, `efficiency.go`) and no architectural ordering between them; the layering happens at the **package directory** level. So for Go, the Sorted check translates to: *"the profile declares packages in layer order, and Go's compiler-enforced `internal/` semantics + the absence of upward edges in the import graph confirm that the lex-order of declared package paths matches the actual import direction."*
modified content/blog/sama-v2-go-project-dive.md +1 −1
@@ -67,7 +67,7 @@ Walking the seven §4 checks honestly:
6767
6868 This is the check Go projects cannot pass without changing language idioms. Go organizes by **directory**, not by **filename prefix**. Filenames inside `dive/filetree/` are `comparer.go`, `diff.go`, `efficiency.go`, `file_info.go`, `file_node.go`, `file_tree.go` — they're descriptive, not layer-marking. Lex-sorting them in alphabetical order has no relationship to architectural layer.
6969
70-This isn't `dive` doing something wrong. It's the SAMA v2 spec being written with a language model (TypeScript modules, PHP files) where prefix-sortable filenames are idiomatic. Go's idiom is the opposite. The spec needs a directory-based dialect for Go — *"the package directory's lex position equals the layer's order"* — to be honestly applicable here. I'll come back to this in the conclusion.
70+This isn't `dive` doing something wrong. It's the SAMA v2 spec being written with a language model (TypeScript modules, PHP files) where prefix-sortable filenames are idiomatic. Go's idiom is the opposite. The spec needs a [directory-based dialect](/sama/v2#61-directory-layout-dialect) for Go — *"the package directory's lex position equals the layer's order"* — to be honestly applicable here. I'll come back to this in the conclusion. *(Update: this dialect has since been drafted into [/sama/v2 §6.1](/sama/v2#61-directory-layout-dialect) as a v2.1-draft extension.)*
7171
7272 ### #2 Architecture — would partly pass
7373
modified content/blog/sama-v2-rust-project-ripgrep-parallel-fleet.md +1 −1
@@ -160,7 +160,7 @@ Without those four properties, every multi-agent refactor attempt I've seen run
160160
161161 **The §6 hook** that makes the projection eventually testable: §5 of the v2 spec already says *"compliance proves the rules were followed; the delta is what proves the rules were worth following."* This post identifies *a new delta v2 can take credit for that no other architectural standard can*: parallel-refactor wall-clock. The cost of a refactor under v2-management is a separate, falsifiable empirical property — one that doesn't even exist as a measurable quantity in arbitrary codebases, because in arbitrary codebases parallel refactors don't merge cleanly.
162162
163-If §6 promotes the three v2.1 dialects, a follow-up experiment writes itself:
163+If §6 promotes the three v2.1 dialects (now [drafted formally in /sama/v2 §6.A](/sama/v2#6a-v21-dialects-provisional)), a follow-up experiment writes itself:
164164
165165 1. Fork a v2-conforming open-source repo (this site, eventually ripgrep, eventually dive).
166166 2. Generate a manifest like the one above.
modified content/blog/sama-v2-rust-project-ripgrep-rebuilt.md +5 −3
@@ -8,13 +8,15 @@ The sketch is even smaller than the [`dive` rebuild](/blog/sama-v2-go-project-di
88
99 ## The three v2.1 dialects this sketch assumes
1010
11+*(Update: all three dialects have since been drafted formally into [/sama/v2 §6.A v2.1 dialects](/sama/v2#6a-v21-dialects-provisional) as opt-in profile extensions, each with operational definitions, the property each preserves, and the falsifiable cross-repo experiment that would invalidate it. The bullets below are the original informal proposal.)*
12+
1113 The audit surfaced three places where v2.0 doesn't fit Rust. Each becomes a falsifiable, optionally-applied profile extension in the spirit of §6 evolution policy:
1214
13-1. **`layout = "directory"`** — Sorted-by-crate-directory rather than Sorted-by-filename-prefix. (Same dialect the [Go `dive` rebuild](/blog/sama-v2-go-project-dive-rebuilt) proposes.) Cargo's workspace + `pub use` semantics + the absence of upward edges in the crate graph give the verifier everything it needs to enforce the same property the prefix-lex check enforces in TypeScript/PHP.
15+1. **[`layout = "directory"`](/sama/v2#61-directory-layout-dialect)** — Sorted-by-crate-directory rather than Sorted-by-filename-prefix. (Same dialect the [Go `dive` rebuild](/blog/sama-v2-go-project-dive-rebuilt) proposes.) Cargo's workspace + `pub use` semantics + the absence of upward edges in the crate graph give the verifier everything it needs to enforce the same property the prefix-lex check enforces in TypeScript/PHP.
1416
15-2. **`tests = "inline"`** — Modeled-tests recognises `#[cfg(test)] mod tests { #[test] fn ... }` blocks inside source files instead of requiring a sibling `*_test.rs` file. Rust's convention is fundamentally inline; the v2.0 sibling-file rule was written assuming Jest/PHPUnit-style adjacent test files. The property the rule is *trying* to protect — "every behavioural source unit has a test attached" — is preserved; only the surface syntax of attachment changes.
17+2. **[`tests = "inline"`](/sama/v2#62-inline-tests-dialect)** — Modeled-tests recognises `#[cfg(test)] mod tests { #[test] fn ... }` blocks inside source files instead of requiring a sibling `*_test.rs` file. Rust's convention is fundamentally inline; the v2.0 sibling-file rule was written assuming Jest/PHPUnit-style adjacent test files. The property the rule is *trying* to protect — "every behavioural source unit has a test attached" — is preserved; only the surface syntax of attachment changes.
1618
17-3. **`atomic_exemption = "declarative"`** — the Atomic-700 LOC cap applies to *behavioural* files; files whose body is overwhelmingly declarative (one `pub struct X` + one `impl Trait for X` per item, repeated for many items, with near-zero per-item cyclomatic complexity) are exempt. The verifier detects this heuristically: a file is "declarative" if it crosses the cap *and* its cyclomatic complexity per LOC drops below 0.05, *and* it consists predominantly of `impl X for Y` / `const FOO: T = ...` / `pub struct ...` items. The 7,779-line `defs.rs` flag catalog is the textbook case.
19+3. **[`atomic_exemption = "declarative"`](/sama/v2#63-declarative-exemption-dialect)** — the Atomic-700 LOC cap applies to *behavioural* files; files whose body is overwhelmingly declarative (one `pub struct X` + one `impl Trait for X` per item, repeated for many items, with near-zero per-item cyclomatic complexity) are exempt. The verifier detects this heuristically: a file is "declarative" if it crosses the cap *and* its cyclomatic complexity per LOC drops below 0.05, *and* it consists predominantly of `impl X for Y` / `const FOO: T = ...` / `pub struct ...` items. The 7,779-line `defs.rs` flag catalog is the textbook case.
1820
1921 Each of these is the kind of extension §6 admits provisionally: the property the rule protects stays the same; only the surface that expresses it changes. If subsequent cross-repo §5 metrics show the extension picks up the same architectural drift the original rule did, §6 promotes it to official. If not, it's withdrawn. Today, this rebuild assumes all three.
2022
modified content/blog/sama-v2-rust-project-ripgrep.md +3 −1
@@ -141,7 +141,9 @@ Cargo enforces the absence of cyclic crate dependencies — the workspace litera
141141
142142 Derives from Law on the same edge set.
143143
144-**Tally: 3 of 7 strict-pass (Architecture, Law, Consistency). With the proposed v2.1 dialects (directory mode, inline-tests mode, declarative-Atomic exemption), the score rises to 5-6 of 7.** Without them, ripgrep "fails" v2 mostly because v2 doesn't yet understand Rust.
144+**Tally: 3 of 7 strict-pass (Architecture, Law, Consistency). With the proposed v2.1 dialects ([directory mode](/sama/v2#61-directory-layout-dialect), [inline-tests mode](/sama/v2#62-inline-tests-dialect), [declarative-Atomic exemption](/sama/v2#63-declarative-exemption-dialect)), the score rises to 5-6 of 7.** Without them, ripgrep "fails" v2 mostly because v2 doesn't yet understand Rust.
145+
146+*(Update: all three dialects have since been drafted into [/sama/v2 §6.A](/sama/v2#6a-v21-dialects-provisional) as v2.1-draft extensions, with the same five-part operational structure — what they relax, what property they preserve, and the falsifiable cross-repo experiment that would invalidate each.)*
145147
146148 ## §5 metric estimates
147149
modified content/sama/v2.md +60 −0
@@ -189,6 +189,66 @@ Total: 7 parse-boundary call sites; all 7 fall under prefixes the profile maps t
189189 - **Profiles are the moving edge.** A new profile is a *falsifiable hypothesis*: "this sublayer split lowers context cost for this domain." It is admitted provisionally, measured against §5, and promoted to "official" only if the delta holds across multiple repos.
190190 - **A rule agents structurally violate is a signal — to be triaged, not auto-relaxed.** Either the rule is right and the agent must improve (signal to agent-builders), or the rule is impractical and the *profile* adapts (never the core). The feedback loop tunes profiles; it does not erode the law.
191191
192+### 6.A v2.1 dialects (provisional)
193+
194+Three falsifiable extensions are admitted under §6 as v2.1-draft *dialects*. Each was surfaced by a real-world audit that found the v2.0 surface syntax mismatched a target language's idiom. Each is **opt-in per profile**, defaults to v2.0 behaviour when its flag is absent, and preserves — by different surface syntax — the architectural property the original rule was protecting. Per the bullet above, promotion to "official" requires cross-repo §5 metric data showing the dialect catches the same class of drift the unrelaxed rule did.
195+
196+The conformant verifier MUST parse the three dialect flags (`layout`, `tests`, `atomic_exemption`) as optional top-level profile fields and MUST reject unknown values with a clear error. A verifier MAY refuse to activate a dialect's relaxed semantics (i.e. continue applying the v2.0 rule even when a dialect is declared) — dialect activation is a separate, later promotion event that requires §5 cross-repo evidence. The flags themselves are tolerated today so opt-in profiles for non-TS/PHP languages do not get rejected as malformed.
197+
198+### 6.1 Directory-layout dialect
199+
200+**Profile syntax.** Top-level optional flag:
201+
202+```toml
203+sama_version = "2.1"
204+profile = "..."
205+layout = "directory" # default when absent: "prefix" (v2.0 behaviour)
206+```
207+
208+**What v2.0 rule it relaxes.** §4.1 Sorted — *"every file carries a profile-recognized prefix; lexicographic prefix order equals layer order."*
209+
210+**The architectural property the original rule was protecting.** The dependency direction of the codebase is publicly readable from the file system without running any analysis: a reviewer's `ls src/ | sort` reads top-to-bottom in dependency order, and a layer change is visible in a `git diff` without any tool support.
211+
212+**How the dialect preserves that property.** Under `layout = "directory"`, the Sorted check verifies that the profile declares **packages or crate directories** in layer order, and that the language's compile-time dependency check (Go's `internal/` semantics, Rust's Cargo crate graph, etc.) plus the absence of upward edges in the import graph confirms the lex-order of declared package paths matches actual import direction. The reviewer's analogue of `ls src/ | sort` becomes `cat sama.profile.toml | grep packages =` — still mechanical, still ahead of the build. The property is the same; the surface syntax shifts from per-file prefix to per-directory declaration.
213+
214+**Falsifiable cross-repo experiment.** Run the §5 metrics emitter on a corpus of agent-authored Go and Rust commits, half against `layout = "prefix"` (with a synthetic prefix renaming) and half against `layout = "directory"` (against the natural package layout). The dialect is invalidated if the directory mode systematically reports a different violation set on Sorted than the prefix mode does on the same logical defects. Originally surfaced in the [`dive` audit](/blog/sama-v2-go-project-dive) and [`dive` rebuild sketch](/blog/sama-v2-go-project-dive-rebuilt); confirmed independently by the [`ripgrep` audit](/blog/sama-v2-rust-project-ripgrep).
215+
216+### 6.2 Inline-tests dialect
217+
218+**Profile syntax.** Top-level optional flag:
219+
220+```toml
221+sama_version = "2.1"
222+profile = "..."
223+tests = "inline" # default when absent: "sibling" (v2.0 behaviour)
224+```
225+
226+**What v2.0 rule it relaxes.** §4.3 Modeled (tests) — *"every Layer 1 and Layer 2 behavior file has a sibling test file."* The v2.0 rule was written assuming Jest/PHPUnit-style sibling test files (`foo.ts` + `foo.test.ts`).
227+
228+**The architectural property the original rule was protecting.** Every behavioural source unit has an attached test, mechanically discoverable by the verifier — the test is not centralised in a separate `tests/` tree that may drift out of sync with the source.
229+
230+**How the dialect preserves that property.** Under `tests = "inline"`, the Modeled-tests check scans each Layer 1 / Layer 2 source file for in-file test attachments (`#[cfg(test)] mod tests { #[test] fn ... }` in Rust; equivalent annotations in other languages whose convention is inline tests). A behavioural file with no inline `#[test]` block fails the check exactly as a file with no sibling `*.test.ts` would under v2.0. *Where* the test attaches changes (same file vs sibling file); *that* every behavioural unit has an attached test does not.
231+
232+**Falsifiable cross-repo experiment.** Audit a Rust corpus (e.g. the popular CLI tools `bat`, `fd`, `ripgrep`, `eza`) under both `tests = "sibling"` (which the convention does not produce) and `tests = "inline"` (which it does). The dialect is invalidated if inline-mode systematically classifies files as tested that sibling-mode-on-a-renamed-corpus would not — i.e. if the surface-syntax change quietly admits genuinely untested files. Originally surfaced in the [`ripgrep` audit](/blog/sama-v2-rust-project-ripgrep) and [`ripgrep` rebuild sketch](/blog/sama-v2-rust-project-ripgrep-rebuilt).
233+
234+### 6.3 Declarative-exemption dialect
235+
236+**Profile syntax.** Top-level optional flag:
237+
238+```toml
239+sama_version = "2.1"
240+profile = "..."
241+atomic_exemption = "declarative" # default when absent: "none" (v2.0 behaviour)
242+```
243+
244+**What v2.0 rule it relaxes.** §4.5 Atomic — *"no file exceeds the line cap (default ~700; profile may lower, never raise)."*
245+
246+**The architectural property the original rule was protecting.** *Working-set fit* — every load-bearing source file fits inside the agent's editor context with headroom. A file at 700+ LOC forces the agent to load it incrementally or summarise; either response is a drift surface.
247+
248+**How the dialect preserves that property.** Under `atomic_exemption = "declarative"`, the Atomic check exempts from the LOC cap files whose content is overwhelmingly *declarative* — a file is declarative if it crosses the cap **and** its cyclomatic complexity per LOC drops below 0.05 **and** its body is predominantly `impl X for Y` / `const FOO: T = ...` / `pub struct ...` items (or the language's equivalent). The intuition: a flag-definition catalog or a static type-table is structurally large but does not require holistic loading by an agent — the agent indexes into it by name, not by reading it linearly. The working-set property is preserved for the files that *would* harm an agent's context (behavioural complexity) and selectively waived for files where the cap was a false positive (declarative shape). The 7,779-line `crates/core/flags/defs.rs` in `ripgrep` is the textbook case: 150 flag definitions, each a small struct + small impl, CC/LOC ≈ 0.01.
249+
250+**Falsifiable cross-repo experiment.** Across a multi-language corpus of agent-edit failures (cases where an LLM produced a regression while editing a single file), compute the share that fall in declarative-exempt files vs in over-cap behavioural files. The dialect is invalidated if declarative-exempt files correlate with edit failures at the same or higher rate than over-cap behavioural files do — i.e. if the heuristic exempts files the agent actually struggles with. Originally surfaced in the [`ripgrep` audit](/blog/sama-v2-rust-project-ripgrep) and [`ripgrep` rebuild sketch](/blog/sama-v2-rust-project-ripgrep-rebuilt).
251+
192252 ---
193253
194254 ## Appendix A — Mapping to the four pillars
modified src/a31_sama_v2.ts +23 −0
@@ -25,9 +25,32 @@ export interface LayerSpec {
2525 sublayers: Sublayer[];
2626 }
2727
28+// — v2.1 dialect flags (per /sama/v2 §6.1–6.3) -----------------------
29+//
30+// Three opt-in profile flags admitted under §6 as provisional dialects.
31+// Each defaults to v2.0 behaviour when absent. The current verifier
32+// PARSES these (so opt-in profiles for non-TS/PHP languages don't get
33+// rejected as malformed) but does NOT yet activate their relaxed
34+// semantics — activation is a later promotion event that requires
35+// cross-repo §5 evidence per §6.A.
36+
37+export type ProfileLayout = "prefix" | "directory";
38+export type ProfileTests = "sibling" | "inline";
39+export type ProfileAtomicExemption = "none" | "declarative";
40+
41+export const PROFILE_LAYOUT_VALUES = ["prefix", "directory"] as const;
42+export const PROFILE_TESTS_VALUES = ["sibling", "inline"] as const;
43+export const PROFILE_ATOMIC_EXEMPTION_VALUES = ["none", "declarative"] as const;
44+
2845 export interface ProfileSpec {
2946 samaVersion: string;
3047 profile: string; // profile name, e.g. "tdd-md"
48+ // Optional v2.1 dialect flags. Absent ≡ v2.0 default behaviour
49+ // (prefix / sibling / none). No §4 check currently inspects these —
50+ // see comment on the type aliases above.
51+ layout?: ProfileLayout;
52+ tests?: ProfileTests;
53+ atomicExemption?: ProfileAtomicExemption;
3154 layers: {
3255 0: LayerSpec;
3356 1: LayerSpec;
modified src/c14_sama_profile.test.ts +98 −0
@@ -114,6 +114,104 @@ prefixes = []
114114 `)).toThrow(/layers\.3/);
115115 });
116116
117+ describe("v2.1 dialect flags (§6.1–6.3)", () => {
118+ const minimalProfile = (extra: string = ""): string => `
119+sama_version = "2.1"
120+profile = "x"
121+${extra}
122+
123+[layers.0]
124+prefixes = ["a_"]
125+
126+[layers.1]
127+prefixes = ["b_"]
128+
129+[layers.2]
130+prefixes = ["c_"]
131+
132+[layers.3]
133+prefixes = ["d_"]
134+`;
135+
136+ test("absent dialect flags → ProfileSpec leaves them undefined (≡ v2.0 defaults)", () => {
137+ const p = parseProfileToml(minimalProfile());
138+ expect(p.layout).toBeUndefined();
139+ expect(p.tests).toBeUndefined();
140+ expect(p.atomicExemption).toBeUndefined();
141+ });
142+
143+ test("layout = \"prefix\" parses to ProfileSpec.layout = \"prefix\"", () => {
144+ const p = parseProfileToml(minimalProfile(`layout = "prefix"`));
145+ expect(p.layout).toBe("prefix");
146+ });
147+
148+ test("layout = \"directory\" parses to ProfileSpec.layout = \"directory\"", () => {
149+ const p = parseProfileToml(minimalProfile(`layout = "directory"`));
150+ expect(p.layout).toBe("directory");
151+ });
152+
153+ test("layout with an unknown value throws a clear error referencing §6 and the allowed set", () => {
154+ expect(() => parseProfileToml(minimalProfile(`layout = "nonsense"`))).toThrow(
155+ /layout.*nonsense.*prefix.*directory.*§6/s,
156+ );
157+ });
158+
159+ test("tests = \"sibling\" parses to ProfileSpec.tests = \"sibling\"", () => {
160+ const p = parseProfileToml(minimalProfile(`tests = "sibling"`));
161+ expect(p.tests).toBe("sibling");
162+ });
163+
164+ test("tests = \"inline\" parses to ProfileSpec.tests = \"inline\"", () => {
165+ const p = parseProfileToml(minimalProfile(`tests = "inline"`));
166+ expect(p.tests).toBe("inline");
167+ });
168+
169+ test("tests with an unknown value throws a clear error", () => {
170+ expect(() => parseProfileToml(minimalProfile(`tests = "elsewhere"`))).toThrow(
171+ /tests.*elsewhere.*sibling.*inline/s,
172+ );
173+ });
174+
175+ test("atomic_exemption = \"none\" parses to ProfileSpec.atomicExemption = \"none\"", () => {
176+ const p = parseProfileToml(minimalProfile(`atomic_exemption = "none"`));
177+ expect(p.atomicExemption).toBe("none");
178+ });
179+
180+ test("atomic_exemption = \"declarative\" parses to ProfileSpec.atomicExemption = \"declarative\"", () => {
181+ const p = parseProfileToml(minimalProfile(`atomic_exemption = "declarative"`));
182+ expect(p.atomicExemption).toBe("declarative");
183+ });
184+
185+ test("atomic_exemption with an unknown value throws a clear error", () => {
186+ expect(() => parseProfileToml(minimalProfile(`atomic_exemption = "behavioural"`))).toThrow(
187+ /atomic_exemption.*behavioural.*none.*declarative/s,
188+ );
189+ });
190+
191+ test("all three dialect flags can co-occur (the ripgrep rebuild profile shape)", () => {
192+ const p = parseProfileToml(minimalProfile(`
193+layout = "directory"
194+tests = "inline"
195+atomic_exemption = "declarative"
196+`));
197+ expect(p.layout).toBe("directory");
198+ expect(p.tests).toBe("inline");
199+ expect(p.atomicExemption).toBe("declarative");
200+ });
201+
202+ test("dialect flags do not interfere with layer parsing", () => {
203+ const p = parseProfileToml(minimalProfile(`
204+layout = "directory"
205+tests = "inline"
206+atomic_exemption = "declarative"
207+`));
208+ expect(p.layers[0].sublayers.map((s) => s.prefix)).toEqual(["a_"]);
209+ expect(p.layers[1].sublayers.map((s) => s.prefix)).toEqual(["b_"]);
210+ expect(p.layers[2].sublayers.map((s) => s.prefix)).toEqual(["c_"]);
211+ expect(p.layers[3].sublayers.map((s) => s.prefix)).toEqual(["d_"]);
212+ });
213+ });
214+
117215 test("parses the actual repo profile file", () => {
118216 // Inline copy of the real-repo profile to keep this test
119217 // hermetic — no filesystem read. If sama.profile.toml's shape
modified src/c14_sama_profile.ts +55 −6
@@ -13,12 +13,18 @@
1313
1414 import { readdirSync, readFileSync } from "node:fs";
1515 import { resolve } from "node:path";
16-import type {
17- LayerNumber,
18- LayerSpec,
19- ProfileSpec,
20- SamaV2Input,
21- Sublayer,
16+import {
17+ PROFILE_ATOMIC_EXEMPTION_VALUES,
18+ PROFILE_LAYOUT_VALUES,
19+ PROFILE_TESTS_VALUES,
20+ type LayerNumber,
21+ type LayerSpec,
22+ type ProfileAtomicExemption,
23+ type ProfileLayout,
24+ type ProfileSpec,
25+ type ProfileTests,
26+ type SamaV2Input,
27+ type Sublayer,
2228 } from "./a31_sama_v2.ts";
2329
2430 // — TOML subset parser ----------------------------------------------
@@ -159,6 +165,46 @@ export const parseProfileToml = (text: string): ProfileSpec => {
159165 throw new Error("profile must declare `sama_version` and `profile` at the top level");
160166 }
161167
168+ // v2.1 optional dialect flags — see /sama/v2 §6.1–6.3 and the
169+ // ProfileSpec comment in a31_sama_v2.ts. Absent ≡ v2.0 defaults.
170+ const validateEnum = <T extends string>(
171+ fieldName: string,
172+ raw: unknown,
173+ allowed: readonly T[],
174+ ): T | undefined => {
175+ if (raw === undefined) return undefined;
176+ if (typeof raw !== "string") {
177+ throw new Error(
178+ `profile field \`${fieldName}\` must be a string, got: ${typeof raw}`,
179+ );
180+ }
181+ if (!(allowed as readonly string[]).includes(raw)) {
182+ const allowedQuoted = allowed.map((v) => JSON.stringify(v)).join(", ");
183+ throw new Error(
184+ `profile field \`${fieldName}\` has invalid value ${JSON.stringify(raw)} ` +
185+ `(expected one of: ${allowedQuoted}). ` +
186+ `See /sama/v2 §6 for the v2.1 dialect set.`,
187+ );
188+ }
189+ return raw as T;
190+ };
191+
192+ const layout = validateEnum<ProfileLayout>(
193+ "layout",
194+ top.get("layout"),
195+ PROFILE_LAYOUT_VALUES,
196+ );
197+ const tests = validateEnum<ProfileTests>(
198+ "tests",
199+ top.get("tests"),
200+ PROFILE_TESTS_VALUES,
201+ );
202+ const atomicExemption = validateEnum<ProfileAtomicExemption>(
203+ "atomic_exemption",
204+ top.get("atomic_exemption"),
205+ PROFILE_ATOMIC_EXEMPTION_VALUES,
206+ );
207+
162208 const buildLayer = (k: LayerNumber): LayerSpec => {
163209 const sec = state.sections.get(`layers.${k}`);
164210 if (!sec) {
@@ -188,6 +234,9 @@ export const parseProfileToml = (text: string): ProfileSpec => {
188234 return {
189235 samaVersion,
190236 profile,
237+ ...(layout !== undefined ? { layout } : {}),
238+ ...(tests !== undefined ? { tests } : {}),
239+ ...(atomicExemption !== undefined ? { atomicExemption } : {}),
191240 layers: {
192241 0: buildLayer(0),
193242 1: buildLayer(1),