iter design-md-rolesplit.1 (DONE 9/9): DESIGN.md -> design/ ledger role-split

The 3020-line docs/DESIGN.md is replaced by the design/ ledger:
design/INDEX.md (sole addressable spine, typed Contracts+Models tables,
polymorphic links — prose file OR authoritative source //!), 14
design/contracts/*.md test-linked invariants + 3 source-link-only
contracts (mangling/env-construction/qualified-xref, no prose file —
code is SoT), 5 design/models/*.md whitepapers, and
docs/journals/2026-05-19-design-decision-records.md (the
relitigation-guard archive — every why/rejected/does-not-do/rollback/
empirical ### moved out at ###-granularity). Clean cut: git rm
docs/DESIGN.md, no stub.

RED-first crates/ailang-core/tests/design_index_pin.rs — the 4-clause
anti-regrowth spine (DESIGN.md-gone / every-INDEX-link-resolves /
every-contract-names-a-resolvable-ratifier /
contracts-carry-no-decision-record-prose) — demonstrably RED before,
GREEN after. Build-atomic by task ordering: design_schema_drift.rs's
include_str! (the only compile-time consumer) retargeted to
design/contracts/data-model.md BEFORE the deletion; its
## Data model/## Pipeline slicer dropped (a simplification the split
enables). 2 NoInstance diagnostics + 2 lockstep E2Es retargeted to
design/contracts/{float-semantics,typeclasses}.md. ~12 agent reading
lists + 5 SKILL bodies + CLAUDE.md + skills/README.md + ~25
code/C/.ail/spec comment xrefs retargeted; OQ7 dangling 'Iter 13b'
cite deleted (no forward target — a pointer would be fiction).
honesty-rule.md rewritten so the rule names the new home
(rationale->journals), resolving the recon-found internal
contradiction; the two docs_honesty_pin.rs:70,72 pinned phrases kept
verbatim+contiguous.

Boss-verified independently: cargo test --workspace 646 passed /
0 failed; design_index_pin 4/4; acceptance grep CLEAN of live
DESIGN.md refs (residuals = only the spec-mandated clause-4
deletion-enforcer). 2 DONE_WITH_CONCERNS routed to the mandatory
milestone-close audit: (a) str-abi.md:23 '(iter str-concat,
2026-05-13)' provenance stamp trips advisory architect_sweeps Sweep-1
— Boss-confirmed byte-identical to DESIGN.md@deeffb1:2062-2065, a
faithfully-migrated PRE-EXISTING anchor (regexes verbatim, only path
retargeted), NOT split-introduced — RATIFY-or-tidy at audit; (b) a
now stale-direction intra-prose 'see Str ABI below' cross-ref in
float-semantics.md — audit-adjudication candidate. Plan defect noted:
Task 9 Step 4's verbatim acceptance grep used a ^./ anchor not
matching the system's grep -rIn output; substance re-verified CLEAN.

Spec grounding-check PASS x2. Journals INDEX + decision-records
pointer appended (Boss-only).
This commit is contained in:
2026-05-19 13:04:22 +02:00
parent deeffb1872
commit 176821c2e7
76 changed files with 3707 additions and 3340 deletions
+3 -2
View File
@@ -1390,7 +1390,8 @@ fn load_workspace_human(
///
/// Cross-module references (`<prefix>.<def>`) are always kept — a local
/// binding can never shadow a dotted name (the typechecker rejects defs
/// with a dot, see DESIGN.md "qualified cross-module references").
/// with a dot; qualified cross-module references are code-SoT — see
/// the `qualified-xref` row in design/INDEX.md).
fn collect_refs(def: &ailang_core::Def) -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
let builtins: std::collections::HashSet<&'static str> =
@@ -1569,7 +1570,7 @@ fn walk_term(
/// Adds the variable bindings introduced by a pattern to `scope` and
/// returns the names that were freshly inserted (so the caller can roll
/// them back). MVP-restricted: nested ctor patterns only contain
/// `Var`/`Wild` — see DESIGN.md.
/// `Var`/`Wild` — see design/contracts/data-model.md.
fn bind_pattern(
p: &ailang_core::ast::Pattern,
scope: &mut std::collections::HashSet<String>,
@@ -1,6 +1,6 @@
//! Pin for the iter-24.3 codegen `import_map`-fallback path
//! (DESIGN.md §"Cross-module references in synthesised bodies"
//! invariant 2).
//! (design/contracts/typeclasses.md, "Cross-module references in
//! synthesised bodies" invariant 2).
//!
//! Property protected: post-mono synthesised body cross-module
//! references resolve at codegen via the fallback to
+3 -3
View File
@@ -2840,9 +2840,9 @@ fn str_clone_drop_balances_rc_stats() {
/// static-Str input (literal `"abc"`). Both clones print their
/// input bytes; RC stats account for three heap-Str slabs (one
/// from int_to_str, two from str_clone) and three matching frees.
/// Pins the "uniform consumer ABI" claim from DESIGN.md §"Str
/// ABI" — str_clone only reads len + bytes + NUL, never the
/// rc_header.
/// Pins the "uniform consumer ABI" claim from
/// design/contracts/str-abi.md — str_clone only reads len + bytes +
/// NUL, never the rc_header.
#[test]
fn str_clone_cross_realisation_uniform_abi() {
let (stdout, allocs, frees, live) =
+2 -2
View File
@@ -1,6 +1,6 @@
/* Embedding-ABI M3 record round-trip C host (own + borrow via a
* compile-time -DBORROW switch). Frozen value layout (DESIGN.md
* §"Embedding ABI" > "Frozen value layout"):
* compile-time -DBORROW switch). Frozen value layout
* (design/contracts/frozen-value-layout.md):
* p - 8 .. p uint64_t refcount header (set to 1 by ailang_rc_alloc)
* p + 0 int64_t constructor tag (single ctor → 0)
* p + 8 IEEE-754 double field 0 = Float acc
+1 -1
View File
@@ -3,7 +3,7 @@
* only ever push a *scalar* per-tick sample; this host pushes a
* single-ctor all-scalar *record* `Tick` as the per-call payload —
* the actual minimal data-server binding shape. Frozen value layout
* (DESIGN.md §"Embedding ABI" > "Frozen value layout"):
* (design/contracts/frozen-value-layout.md):
*
* State (24-byte payload):
* p - 8 .. p uint64_t refcount header (set to 1 by ailang_rc_alloc)
+6 -6
View File
@@ -2,8 +2,8 @@
//!
//! Property protected: a polymorphic Eq/Ord helper invoked at Float
//! fires `no-instance` (since Float has neither Eq nor Ord instance
//! per DESIGN.md §"Float semantics") AND the diagnostic message
//! cross-references the canonical Float-semantics section so the
//! per design/contracts/float-semantics.md) AND the diagnostic message
//! cross-references the canonical float-semantics contract so the
//! LLM author immediately learns the partial-Float story rather
//! than seeing a bare "no instance" message.
@@ -35,11 +35,11 @@ fn eq_at_float_fires_float_aware_noinstance() {
no_inst.message
);
// Cross-ref to DESIGN.md §Float semantics — the LLM author should be
// pointed at the canonical explanation of partial Float orderability.
// Cross-ref to the float-semantics contract — the LLM author should
// be pointed at the canonical explanation of partial Float orderability.
assert!(
no_inst.message.contains("Float semantics") || no_inst.message.contains("DESIGN"),
"expected NoInstance message to cross-reference DESIGN.md §Float semantics, got: {:?}",
no_inst.message.contains("design/contracts/float-semantics.md"),
"expected NoInstance message to cross-reference the float-semantics contract, got: {:?}",
no_inst.message
);
}
+7 -4
View File
@@ -86,8 +86,10 @@ fn eq_ord_user_adt_eq_intbox_hash_stable() {
let mono = monomorphise_workspace(&ws).expect("mono");
// The mono symbol for `eq` at a user-defined ADT uses the
// `<method>__<8-hex-prefix>` mangling per DESIGN.md
// §"Mangling scheme" (primitives use the bare type name; compound
// `<method>__<8-hex-prefix>` mangling (mangling is code-SoT —
// see the `mangling` row in design/INDEX.md / the
// crates/ailang-codegen/src/lib.rs //! header) (primitives use
// the bare type name; compound
// / user-defined types use the 8-hex BLAKE3 prefix of the
// canonical type bytes). We look up the single `eq__` symbol
// synthesised at `IntBox` — there is only one user instance,
@@ -107,8 +109,9 @@ fn eq_ord_user_adt_eq_intbox_hash_stable() {
.expect("eq__<IntBox-hash> not synthesised");
// The 8-hex-prefix in the mono-symbol name is the BLAKE3 prefix
// of the canonical IntBox type bytes (DESIGN.md §"Mangling
// scheme"). Drift here means either the canonical-form encoding
// of the canonical IntBox type bytes (mangling is code-SoT — see
// the `mangling` row in design/INDEX.md). Drift here means either
// the canonical-form encoding
// changed or the type's canonical bytes changed — both warrant
// investigation before re-pinning.
assert_eq!(
@@ -1,6 +1,6 @@
//! Pin for the iter-24.3 FreeFnCall constraint-residual-push
//! invariant (DESIGN.md §"Cross-module references in synthesised
//! bodies" invariant 3).
//! invariant (design/contracts/typeclasses.md, "Cross-module
//! references in synthesised bodies" invariant 3).
//!
//! Property protected: bare-name references to polymorphic free fns
//! that resolve through the auto-injected-prelude path AT THE
+5 -6
View File
@@ -2,7 +2,7 @@
//!
//! Property protected: calling `print` on a function type fires the
//! `no-instance` diagnostic with a Show-aware message that
//! cross-references DESIGN.md §"Prelude (built-in) classes", so the
//! cross-references design/contracts/typeclasses.md, so the
//! LLM author immediately learns which types ship with built-in Show
//! and how to declare their own instance for a user type.
@@ -40,12 +40,11 @@ fn print_on_fn_type_fires_show_aware_no_instance() {
no_inst.message
);
// Must cross-reference DESIGN.md §"Prelude (built-in) classes"
// verbatim — that's the canonical anchor naming which types
// ship with Show.
// Must cross-reference design/contracts/typeclasses.md — that's
// the canonical contract naming which types ship with Show.
assert!(
no_inst.message.contains("Prelude (built-in) classes"),
"expected DESIGN.md §Prelude (built-in) classes cross-reference, got message: {:?}",
no_inst.message.contains("design/contracts/typeclasses.md"),
"expected design/contracts/typeclasses.md cross-reference, got message: {:?}",
no_inst.message
);
}
+11 -11
View File
@@ -446,7 +446,7 @@ pub enum CheckError {
/// An `(export …)` fn has a parameter or return type that is not
/// a permitted embedding-ABI type. The embedding ABI is frozen as
/// of M3 (DESIGN.md §"Embedding ABI" > "Frozen value layout"):
/// of M3 (design/contracts/embedding-abi.md):
/// `Int`/`Float` or a single-constructor record of those; every
/// other shape (multi-ctor sums, `Str`/`List`/nested-record
/// fields) is rejected at typecheck (the feature-acceptance
@@ -858,7 +858,7 @@ impl CheckError {
}
// Iter 23.5: Float-aware addendum on `NoInstance`. When the
// unresolved class is `Eq` or `Ord` AND the type is `Float`,
// append a cross-reference to DESIGN.md §"Float semantics" so
// append a cross-reference to design/contracts/float-semantics.md so
// the LLM author learns the partial-Float story immediately
// rather than seeing a bare "no instance" message. Float has
// neither Eq nor Ord by design (partial orderability per
@@ -871,7 +871,7 @@ impl CheckError {
if (class == "prelude.Eq" || class == "prelude.Ord") && at_type == "Float" {
d.message = format!(
"{} — Float has no Eq/Ord instance by design (partial \
orderability per IEEE-754); see DESIGN.md §\"Float semantics\".",
orderability per IEEE-754); see design/contracts/float-semantics.md.",
d.message
);
} else if class == "prelude.Show" {
@@ -879,14 +879,14 @@ impl CheckError {
// for any type lacking a Show instance — most commonly
// a function type (`print f` where f is bare-fn-typed)
// or a user type the LLM-author forgot to give an
// instance for. Cross-references DESIGN.md §"Prelude
// (built-in) classes" so the author learns which types
// ship with built-in Show immediately.
// instance for. Cross-references
// design/contracts/typeclasses.md so the author learns
// which types ship with built-in Show immediately.
d.message = format!(
"{} — `print` and `show` require a Show instance. \
Built-in Show ships for Int, Bool, Str, Float in \
the prelude; see DESIGN.md §\"Prelude (built-in) \
classes\". User types declare their own \
the prelude; see design/contracts/typeclasses.md. \
User types declare their own \
`instance prelude.Show <T>` in the type's defining \
module per Decision 11 coherence.",
d.message
@@ -2644,7 +2644,7 @@ fn constraint_type_matches(declared: &Type, residual: &Type) -> bool {
/// Decision 8.
///
/// `is_tail` is the tail-context flag for the term currently being
/// visited. The propagation rules (from DESIGN.md Decision 8):
/// visited. The propagation rules (from design/contracts/tail-calls.md):
///
/// - The body of a fn / Lam is visited with `is_tail = true`.
/// - `Match { scrutinee, arms }`: the scrutinee is **not** in tail
@@ -6333,8 +6333,8 @@ mod tests {
/// Floats milestone post-fieldtest B1 (RED): pattern-matching on a
/// `Literal::Float` literal must be hard-rejected at typecheck via
/// `CheckError::FloatPatternNotAllowed` per DESIGN.md §"Float
/// semantics" — even when the match arm reaches `check` through
/// `CheckError::FloatPatternNotAllowed` per
/// design/contracts/float-semantics.md — even when the match arm reaches `check` through
/// the *full* check pipeline (which runs `desugar_module` first).
///
/// The existing `reject_float_pattern_in_match` test in
@@ -1,8 +1,11 @@
//! Pin for `CheckError::DuplicateCtor` — same-named ctors in two
//! different ADTs within one module. Ratifies the load-bearing
//! consumer of the per-module `env.ctor_index` rebuild in
//! `check_in_workspace` that DESIGN.md §"Env construction" rests
//! on as the asymmetry-with-mono-side argument. If the
//! `check_in_workspace`. Env construction is code-SoT (the
//! `env-construction` ledger row in design/INDEX.md is source-link
//! only); the why-two-overlays rationale this test rests on as the
//! asymmetry-with-mono-side argument lives in
//! docs/journals/2026-05-19-design-decision-records.md. If the
//! per-module rebuild is ever removed without an equivalent
//! replacement path, this test goes red.
+2 -2
View File
@@ -85,7 +85,7 @@ impl<'a> Emitter<'a> {
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
// FROZEN ABI (embedding boundary) — see DESIGN.md §"Embedding ABI".
// FROZEN ABI (embedding boundary) — see design/contracts/embedding-abi.md.
out.push_str(" %tag = load i64, ptr %p, align 8\n");
let n_ctors = td.ctors.len();
@@ -111,7 +111,7 @@ impl<'a> Emitter<'a> {
if lty != "ptr" {
continue;
}
// FROZEN ABI (embedding boundary) — see DESIGN.md §"Embedding ABI".
// FROZEN ABI (embedding boundary) — see design/contracts/embedding-abi.md.
let off = 8 + (j as i64) * 8;
let addr_id = local;
local += 1;
+8 -7
View File
@@ -99,9 +99,10 @@ pub enum CodegenError {
/// `llvm_type` was asked to lower an AILang [`Type`] it does not
/// know how to represent. In the MVP this fires for an unresolved
/// rigid `Type::Var` reaching codegen (a substitution bug; see
/// Iter 13b notes in `DESIGN.md`) or for any non-`Con`/`Fn`/`Var`
/// shape that has not yet been wired through.
/// rigid `Type::Var` reaching codegen (a substitution bug: a
/// parameterised-ADT type argument that monomorphisation should
/// have substituted to a concrete type before codegen) or for any
/// non-`Con`/`Fn`/`Var` shape that has not yet been wired through.
#[error("unsupported type: {0}")]
UnsupportedType(String),
@@ -175,8 +176,8 @@ pub enum Target {
/// a mandatory leading `ptr %ctx` (per-thread embedding context)
/// published via `@__ail_tls_ctx` around the unchanged internal
/// call. The ctx-threaded C signature is the M2 shape; the
/// value/record layout is frozen as of M3 (DESIGN.md §"Embedding
/// ABI" > "Frozen value layout").
/// value/record layout is frozen as of M3
/// (design/contracts/frozen-value-layout.md).
StaticLib,
}
@@ -682,8 +683,8 @@ fn llvm_scalar(t: &Type) -> &'static str {
Type::Con { name, .. } if name == "Int" => "i64",
// M3: any other Type::Con reaching an (export) signature is,
// by the Task-2 export gate, a single-ctor scalar record;
// it crosses the C ABI as a bare `ptr` (DESIGN.md §"Embedding
// ABI" frozen layout — payload pointer).
// it crosses the C ABI as a bare `ptr`
// (design/contracts/frozen-value-layout.md — payload pointer).
Type::Con { .. } => "ptr",
_ => "i64",
}
+2 -2
View File
@@ -104,8 +104,8 @@ impl<'a> Emitter<'a> {
}
compiled.push((v, vty));
}
// FROZEN ABI (embedding boundary) — DESIGN.md §"Embedding ABI"
// > "Frozen value layout". size = 8 + n*8, tag@0, fields@8+i*8.
// FROZEN ABI (embedding boundary) —
// design/contracts/frozen-value-layout.md. size = 8 + n*8, tag@0, fields@8+i*8.
let size_bytes = 8 + (compiled.len() * 8) as i64;
let p = self.fresh_ssa();
// Iter 17a: pick allocator based on escape analysis. `alloca`
@@ -1,4 +1,4 @@
//! FROZEN ABI byte-pin — see DESIGN.md §"Embedding ABI" frozen layout.
//! FROZEN ABI byte-pin — see design/contracts/frozen-value-layout.md.
//! Pins the heap (@ailang_rc_alloc) box layout a boundary-crossing
//! single-ctor scalar record takes: size = 8 + n*8, tag i64 @ offset
//! 0, fields i64-strided from offset 8. Goes RED if codegen moves an
@@ -44,8 +44,8 @@ fn staticlib_emits_forwarder_no_main() {
}
/// M3: a single-ctor scalar record param/ret crosses the embedding
/// C boundary as a bare `ptr` (the frozen payload pointer — DESIGN.md
/// §"Embedding ABI" > "Frozen value layout"). The M2 forwarder body
/// C boundary as a bare `ptr` (the frozen payload pointer —
/// design/contracts/frozen-value-layout.md). The M2 forwarder body
/// is byte-unchanged: leading `ptr %ctx`, TLS save/store/restore, the
/// internal `@ail_<module>_<fn>` call carrying no ctx arg and no
/// aggregate calling convention (no `sret`/`byval` — a record is a
+1 -1
View File
@@ -86,7 +86,7 @@ externally-callable entrypoint (Embedding ABI M1). The symbol is
author-chosen and decoupled from the internal `ail_<module>_<def>`
mangling. M1 requires the fn's parameter and return types to be
scalar (`Int`/`Float`) and its effect set to be empty — see
DESIGN.md §"Embedding ABI".
design/contracts/embedding-abi.md.
`suppress` clauses silence advisory diagnostics for this def. The
`code` MUST be one of the registered codes (today: `over-strict-mode`).
+3 -3
View File
@@ -1,6 +1,6 @@
//! AST nodes for the AILang language.
//!
//! **The canonical schema lives in `docs/DESIGN.md` §"Data model"**;
//! **The canonical schema lives in `design/contracts/data-model.md`**;
//! this module is the Rust-side projection of it. When the two drift,
//! `crates/ailang-core/tests/design_schema_drift.rs` fires.
//!
@@ -208,8 +208,8 @@ pub struct FnDef {
/// Iter embedding-abi-m1: when `Some(sym)`, this fn is the
/// embedding boundary. Codegen's `Target::StaticLib` mode
/// additionally emits an externally-visible C entrypoint
/// `@<sym>` (signature frozen as of M3 — DESIGN.md §"Embedding
/// ABI" > "Frozen value layout") forwarding
/// `@<sym>` (signature frozen as of M3 —
/// design/contracts/embedding-abi.md) forwarding
/// to `@ail_<module>_<fn>`. The symbol is author-chosen and
/// decoupled from the `ail_<module>_<def>` mangling so the
/// M3-frozen ABI survives module/fn refactors.
@@ -0,0 +1,157 @@
//! Structural pin for the design/ ledger. Sibling of
//! docs_honesty_pin.rs. Fails RED the instant the design/ split
//! re-conflates contract + rationale + narrative, an INDEX row
//! dangles, a contract loses its ratifying test, or docs/DESIGN.md
//! is resurrected. Spec: docs/specs/2026-05-19-design-md-rolesplit.md.
use std::fs;
use std::path::PathBuf;
fn root() -> PathBuf {
PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../"))
}
fn read(rel: &str) -> String {
fs::read_to_string(root().join(rel))
.unwrap_or_else(|e| panic!("read {rel}: {e}"))
}
fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Parse the two pipe-tables out of design/INDEX.md.
/// Returns (contracts, models) as rows of trimmed cells.
fn index_tables() -> (Vec<Vec<String>>, Vec<Vec<String>>) {
let idx = read("design/INDEX.md");
let mut contracts = Vec::new();
let mut models = Vec::new();
let mut section = "";
for line in idx.lines() {
let t = line.trim();
if t.starts_with("## Contracts") {
section = "c";
continue;
}
if t.starts_with("## Models") {
section = "m";
continue;
}
if !t.starts_with('|') {
continue;
}
let cells: Vec<String> = t
.trim_matches('|')
.split('|')
.map(|c| c.trim().to_string())
.collect();
// skip header + separator rows
if cells.iter().any(|c| c.starts_with("---")) {
continue;
}
if cells.first().map(|c| c.as_str()) == Some("id") {
continue;
}
match section {
"c" => contracts.push(cells),
"m" => models.push(cells),
_ => {}
}
}
(contracts, models)
}
/// A link cell may be a design/ path, a source path, a dual-link
/// "A + B", or carry a trailing "(in-source ...)" / "§..." note.
/// Resolve to the first concrete path token and check it exists.
fn link_target_exists(cell: &str) -> bool {
let first = cell.split(" + ").next().unwrap_or(cell).trim();
// strip a trailing parenthetical or §-note
let path = first
.split(" (")
.next()
.unwrap_or(first)
.split(" §")
.next()
.unwrap_or(first)
.trim()
.trim_end_matches("//!")
.trim();
!path.is_empty() && root().join(path).exists()
}
#[test]
fn design_md_is_gone() {
// clause 4 — clean-cut pin
assert!(
!root().join("docs/DESIGN.md").exists(),
"docs/DESIGN.md was resurrected; the split is clean-cut"
);
}
#[test]
fn every_index_link_resolves() {
// clause 1
let (contracts, models) = index_tables();
assert!(contracts.len() >= 15, "expected >=15 contract rows, got {}", contracts.len());
assert!(models.len() >= 5, "expected >=5 model rows, got {}", models.len());
for row in contracts.iter().chain(models.iter()) {
let link = row.last().expect("row has a link cell");
assert!(
link_target_exists(link),
"INDEX link does not resolve: {:?} (row {:?})",
link,
row
);
}
}
#[test]
fn every_contract_names_a_resolvable_ratifying_test() {
// clause 2 — ratifying-test token resolves to a real file under
// crates/**/tests, crates/**/src (in-source #[cfg(test)] mod
// tests are first-class ratifiers — spec OQ1/OQ2), bench/, or
// skills/**/SKILL.md.
let (contracts, _) = index_tables();
for row in &contracts {
// columns: id | consumer/lifetime | ratifying-test | link
let rt = &row[2];
// take the path token (before any " (" note)
let path = rt.split(" (").next().unwrap_or(rt).trim();
assert!(
root().join(path).exists(),
"ratifying-test does not resolve to a real file: {:?} (contract {:?})",
path,
row[0]
);
}
}
#[test]
fn contracts_carry_no_decision_record_prose() {
// clause 3 — the conflation tripwire.
let dir = root().join("design/contracts");
let markers = [
"we rejected",
"an earlier draft",
"Why not other",
"was retired in iter",
"rollback plan",
"previously all",
];
for entry in fs::read_dir(&dir).expect("design/contracts/ exists") {
let p = entry.unwrap().path();
if p.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let body = norm(&fs::read_to_string(&p).unwrap());
for m in &markers {
assert!(
!body.contains(m),
"decision-record marker {:?} found in contract {:?} — re-conflation",
m,
p.file_name().unwrap()
);
}
}
}
+33 -74
View File
@@ -1,43 +1,28 @@
//! Drift detection between ast.rs and `docs/DESIGN.md` §"Data model".
//! Drift detection between ast.rs and `design/contracts/data-model.md`.
//!
//! DESIGN.md is the canonical schema source-of-truth. Every AST enum
//! (`Term`, `Pattern`, `Type`, `Def`, `Literal`, `ParamMode`) must have a
//! JSON-schema anchor (e.g. `"t": "lit"`, `"k": "fn"`) present in that
//! document. These tests enforce the property.
//! `design/contracts/data-model.md` is the canonical schema
//! source-of-truth. Every AST enum (`Term`, `Pattern`, `Type`, `Def`,
//! `Literal`, `ParamMode`) must have a JSON-schema anchor (e.g.
//! `"t": "lit"`, `"k": "fn"`) present in that document. These tests
//! enforce the property.
//!
//! The exhaustive `match` per enum is the load-bearing mechanism: adding a
//! new variant without a matching arm fails compilation before the test runs.
//! Once the variant is matched, the test asserts the anchor is present in
//! §"Data model" specifically — not anywhere in DESIGN.md. The narrow scope
//! is load-bearing: before this tightening, the test scanned the whole
//! document, so an anchor that appeared only in §"Decision 11" (or any
//! other discussion section) was treated as "present" even though
//! §"Data model" — the canonical schema reference — was missing it.
//! `design/contracts/data-model.md`. The whole file is the data-model
//! contract, so the file boundary now bounds the section — the former
//! `## Data model` … `## Pipeline` slicer is no longer needed (the split
//! gave the section its own file).
use ailang_core::ast::{
ClassDef, ClassMethod, Constraint, ConstDef, Ctor, Def, FnDef, InstanceDef,
InstanceMethod, Literal, Pattern, ParamMode, Suppress, Term, Type, TypeDef,
};
const DESIGN_MD: &str = include_str!("../../../docs/DESIGN.md");
/// Slice of `DESIGN_MD` covering only §"Data model": from the `## Data model`
/// header to the next top-level `## ` header. Returned as a `&'static str`
/// because `DESIGN_MD` is itself static. Panics if §"Data model" is missing
/// — that itself would be drift.
fn data_model_section() -> &'static str {
let start = DESIGN_MD
.find("## Data model")
.expect("DESIGN.md must contain `## Data model` header");
let from_start = &DESIGN_MD[start..];
match from_start.find("\n## ") {
Some(end) => &from_start[..end],
None => from_start,
}
}
const DATA_MODEL: &str = include_str!("../../../design/contracts/data-model.md");
/// Every `Term` variant must have its JSON-schema anchor present in
/// DESIGN.md §"Data model". An LLM author cannot produce a term variant
/// design/contracts/data-model.md. An LLM author cannot produce a term variant
/// whose `"t"` tag is absent from the canonical schema document.
#[test]
fn design_md_anchors_every_term_variant() {
@@ -171,15 +156,15 @@ fn design_md_anchors_every_term_variant() {
Term::Recur { .. } => "recur",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a Term variant — \
add it to docs/DESIGN.md"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a Term variant — \
add it to design/contracts/data-model.md"
);
}
}
/// Every `Pattern` variant must have its JSON-schema anchor present in
/// DESIGN.md §"Data model". Missing anchors mean an LLM cannot produce
/// design/contracts/data-model.md. Missing anchors mean an LLM cannot produce
/// the corresponding pattern form.
#[test]
fn design_md_anchors_every_pattern_variant() {
@@ -201,14 +186,14 @@ fn design_md_anchors_every_pattern_variant() {
Pattern::Ctor { .. } => "ctor",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a Pattern variant"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a Pattern variant"
);
}
}
/// Every `Type` variant must have its JSON-schema anchor present in
/// DESIGN.md §"Data model". The `"k"` discriminator is load-bearing for
/// design/contracts/data-model.md. The `"k"` discriminator is load-bearing for
/// the codegen and typechecker; an LLM must know all four forms.
#[test]
fn design_md_anchors_every_type_variant() {
@@ -234,14 +219,14 @@ fn design_md_anchors_every_type_variant() {
Type::Forall { .. } => "forall",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a Type variant"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a Type variant"
);
}
}
/// Every `Literal` variant must have its JSON-schema anchor present in
/// DESIGN.md §"Data model". The `"kind"` discriminator identifies the
/// design/contracts/data-model.md. The `"kind"` discriminator identifies the
/// literal type; an LLM cannot produce a literal it hasn't seen in the
/// schema.
#[test]
@@ -263,14 +248,14 @@ fn design_md_anchors_every_literal_variant() {
Literal::Float { .. } => "float",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a Literal variant"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a Literal variant"
);
}
}
/// Every `Def` kind must have its JSON-schema anchor present in
/// DESIGN.md §"Data model". All five kinds (`fn`, `const`, `type`,
/// design/contracts/data-model.md. All five kinds (`fn`, `const`, `type`,
/// `class`, `instance`) must be documented so an LLM can write
/// any kind of top-level definition.
#[test]
@@ -335,14 +320,14 @@ fn design_md_anchors_every_def_kind() {
Def::Instance(_) => "instance",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a Def kind"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a Def kind"
);
}
}
/// Every `ParamMode` variant must have its serialized string form present
/// in DESIGN.md §"Data model". The mode annotations are load-bearing for
/// in design/contracts/data-model.md. The mode annotations are load-bearing for
/// ownership checking; an LLM author must know all three forms.
#[test]
fn design_md_anchors_every_parammode_variant() {
@@ -359,13 +344,13 @@ fn design_md_anchors_every_parammode_variant() {
ParamMode::Borrow => "borrow",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a ParamMode variant"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a ParamMode variant"
);
}
}
/// Nested struct key anchors must be present in DESIGN.md §"Data model".
/// Nested struct key anchors must be present in design/contracts/data-model.md.
/// These keys appear inside `Suppress`, `ClassMethod`, `InstanceMethod`,
/// and `Type::Forall` — they are not discriminators but they ARE part
/// of the canonical JSON schema and must remain documented.
@@ -396,36 +381,10 @@ fn design_md_anchors_nested_struct_keys() {
for anchor in anchors {
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing nested-struct-key anchor `{anchor}`"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing nested-struct-key anchor `{anchor}`"
);
}
}
/// Pin the §"Data model" section extractor itself: it must return a
/// non-empty slice starting with `## Data model`, and it must NOT bleed
/// into the next top-level section (`## Pipeline` today). A bug in the
/// extractor would otherwise silently widen every drift test's scope
/// back toward full-document scanning — the failure mode this file's
/// tightening exists to prevent.
#[test]
fn data_model_section_is_bounded() {
let section = data_model_section();
assert!(
section.starts_with("## Data model"),
"section must start at the §Data model header; got first 40 bytes: {:?}",
&section[..40.min(section.len())]
);
assert!(
!section.contains("\n## Pipeline"),
"section must stop before the next top-level header (\\n## Pipeline); \
extractor regressed to whole-document scanning"
);
assert!(
section.len() > 1000,
"§Data model is the canonical schema reference; a slice shorter than \
1 KB suggests the extractor truncated; got {} bytes",
section.len()
);
}
+80 -43
View File
@@ -1,6 +1,6 @@
//! Regression pin: the canonical docs must stay present-tense-honest.
//!
//! `docs/DESIGN.md` (and `docs/PROSE_ROUNDTRIP.md`) must not assert
//! The `design/` ledger (and `docs/PROSE_ROUNDTRIP.md`) must not assert
//! something exists/works/changed that does not. This pin fails if any
//! swept-and-corrected Wunschdenken / post-mortem string reappears, or
//! if any present-tense corrected anchor (incl. the protected
@@ -25,70 +25,107 @@ fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// The `design/` prose set the absent-pins span after the role-split
/// (these pins formerly scanned the single canonical design doc): the
/// Boehm/Decision-9 narrative
/// (`models/rc-uniqueness.md`), float semantics
/// (`contracts/float-semantics.md`), the typeclass contract incl.
/// prelude classes & "does NOT commit to"
/// (`contracts/typeclasses.md`), the effects model
/// (`models/effects.md`), and the authoring-surface notation model
/// (`models/authoring-surface.md`). A swept Wunschdenken /
/// doc-archaeology string must be absent from **all** of them, so the
/// reads are joined and a single absence assertion covers the set.
fn design_corpus() -> String {
[
read("design/models/rc-uniqueness.md"),
read("design/contracts/float-semantics.md"),
read("design/contracts/typeclasses.md"),
read("design/models/effects.md"),
read("design/models/authoring-surface.md"),
]
.join("\n")
}
#[test]
fn design_md_has_no_wunschdenken() {
let d = norm(&read("docs/DESIGN.md"));
let d = norm(&design_corpus());
assert!(!d.contains("is on the path to retirement"),
"DESIGN.md: Boehm retirement is forward intent — it lives in docs/roadmap.md, not DESIGN.md");
"design/: Boehm retirement is forward intent — it lives in docs/roadmap.md, not the ledger");
assert!(!d.contains("Will back `Show Float`"),
"DESIGN.md: 'Will back Show Float' asserts a future capability as fact");
"design/: 'Will back Show Float' asserts a future capability as fact");
assert!(!d.contains("codegen ships in a future iter"),
"DESIGN.md: float_to_str codegen is reserved/not-yet-shipped, not a promised future ship");
"design/: float_to_str codegen is reserved/not-yet-shipped, not a promised future ship");
assert!(!d.contains("may land in a future iteration"),
"DESIGN.md: auto-derivation 'may land in a future iteration' is Wunschdenken");
"design/: auto-derivation 'may land in a future iteration' is Wunschdenken");
assert!(!d.contains("If/when concurrency arrives"),
"DESIGN.md: 'if/when concurrency arrives … will be' is forward conditional");
"design/: 'if/when concurrency arrives … will be' is forward conditional");
assert!(!d.contains("was deferred — all three layer additively"),
"DESIGN.md: deferred future integration paths are roadmap intent, not DESIGN.md");
"design/: deferred future integration paths are roadmap intent, not the ledger");
assert!(!d.contains("was deferred from milestone 22 entirely"),
"DESIGN.md: 'was deferred from milestone 22 entirely … A future iter ships' is history+Wunschdenken (Task 4 Step 14 catch-all)");
"design/: 'was deferred from milestone 22 entirely … A future iter ships' is history+Wunschdenken");
assert!(!d.contains("A future iter ships the full prose projection"),
"DESIGN.md: Form-B class/instance prose 'a future iter ships' is forward intent → roadmap");
"design/: Form-B class/instance prose 'a future iter ships' is forward intent → roadmap");
}
#[test]
fn design_md_has_no_doc_archaeology() {
let d = norm(&read("docs/DESIGN.md"));
let d = norm(&design_corpus());
assert!(!d.contains("An earlier draft of this Decision said"),
"DESIGN.md: doc-archaeology ('an earlier draft said … a follow-up replaced it') lives in docs/journals/");
"design/: doc-archaeology ('an earlier draft said … a follow-up replaced it') lives in docs/journals/");
assert!(!d.contains("previously all diagnostics were `Error`"),
"DESIGN.md: 'previously all diagnostics were Error' is project history → docs/journals/");
"design/: 'previously all diagnostics were Error' is project history → docs/journals/");
assert!(!d.contains("What did change in 2026-05-13 (this milestone) is"),
"DESIGN.md: 'what did change in <date> (this milestone)' is post-mortem narrative");
"design/: 'what did change in <date> (this milestone)' is post-mortem narrative");
assert!(!d.contains("primitives were retired 2026-05-14 in iter rpe.1"),
"DESIGN.md: 'X were retired <date> in iter Y' is history → docs/journals/");
"design/: 'X were retired <date> in iter Y' is history → docs/journals/");
assert!(!d.contains("were retired in iter rpe.1 (2026-05-14)"),
"DESIGN.md: the second per-type-print-op retirement-narrative sentence is history");
"design/: the second per-type-print-op retirement-narrative sentence is history");
assert!(!d.contains("originally listed here as the eighth fixture, was retired"),
"DESIGN.md: 'originally listed … was retired by milestone X' is doc-archaeology");
"design/: 'originally listed … was retired by milestone X' is doc-archaeology");
}
#[test]
fn design_md_present_tense_anchors_present() {
let d = norm(&read("docs/DESIGN.md"));
// the discriminator meta-subsection (Task 3)
assert!(d.contains("the honesty rule it holds itself to"),
"DESIGN.md must carry the discriminator meta-subsection heading");
assert!(d.contains("whether the document asserts something exists, works, or changed that does not"),
"DESIGN.md discriminator must state the operational test");
// Each anchor is read from its post-split home (spec Appendix
// relocation map). Three honest-reserved rationale anchors
// ("Regions were considered and rejected", the "does not infer
// everything" bullet, the Form-B prose-placeholder state) sat in
// `### What this Decision deliberately does not do` / `### What
// this decision does NOT commit to`, which the Appendix routes to
// the decision-record journal — so the journal is part of the
// canonical anchor corpus, not a contract surface.
let honesty = norm(&read("design/contracts/honesty-rule.md"));
let scope = norm(&read("design/contracts/scope-boundaries.md"));
let memory = norm(&read("design/contracts/memory-model.md"));
let pipeline = norm(&read("design/models/pipeline.md"));
let str_abi = norm(&read("design/contracts/str-abi.md"));
let typeclasses = norm(&read("design/contracts/typeclasses.md"));
let records = norm(&read("docs/journals/2026-05-19-design-decision-records.md"));
// the discriminator meta-subsection -> contracts/honesty-rule.md
assert!(honesty.contains("the honesty rule it holds itself to"),
"honesty-rule.md must carry the discriminator meta-subsection heading");
assert!(honesty.contains("whether the document asserts something exists, works, or changed that does not"),
"honesty-rule.md discriminator must state the operational test");
// protected honest-reserved exceptions — over-correction guard
assert!(d.contains("`Diverge` is a reserved effect name with no op and no codegen"),
"the gold-form honest-reserved anchor must remain (do not over-strip)");
assert!(d.contains("Regions were considered and rejected"),
"the present-tense design-rationale exclusion must remain (do not over-strip)");
assert!(d.contains("a tiebreaker, not a rationale"),
"the self-labelled tiebreaker is honest and stays (do not over-strip)");
// corrected present-tense anchors (Task 4)
assert!(d.contains("`--alloc=gc` selects the transitional Boehm backend"),
"DESIGN.md must describe Boehm present-tense, not as 'on the path to retirement'");
assert!(d.contains("type-installed; codegen is reserved and not yet shipped"),
"float_to_str must be present-tense honest-reserved");
assert!(d.contains("AILang demands annotations *because* the LLM author can produce them effortlessly"),
"the 'does not infer everything' bullet must be present-tense (no doc-archaeology)");
assert!(d.contains("`io/print_str` is the only built-in direct-output effect-op"),
"the print-op set must be stated present-tense, not as a retirement narrative");
assert!(d.contains("the render in `crates/ailang-prose/src/lib.rs` is a placeholder and informational only"),
"the Form-B class/instance prose-projection state must be present-tense (Task 4 Step 14 catch-all)");
assert!(scope.contains("`Diverge` is a reserved effect name with no op and no codegen"),
"the gold-form honest-reserved anchor must remain in scope-boundaries.md (do not over-strip)");
assert!(records.contains("Regions were considered and rejected"),
"the present-tense design-rationale exclusion must remain in the decision-records (do not over-strip)");
assert!(memory.contains("a tiebreaker, not a rationale"),
"the self-labelled tiebreaker is honest and stays in memory-model.md (do not over-strip)");
// corrected present-tense anchors
assert!(pipeline.contains("`--alloc=gc` selects the transitional Boehm backend"),
"models/pipeline.md must describe Boehm present-tense, not as 'on the path to retirement'");
assert!(str_abi.contains("type-installed; codegen is reserved and not yet shipped"),
"float_to_str must be present-tense honest-reserved in str-abi.md");
assert!(records.contains("AILang demands annotations *because* the LLM author can produce them effortlessly"),
"the 'does not infer everything' bullet must be present-tense in the decision-records (no doc-archaeology)");
assert!(typeclasses.contains("`io/print_str` is the only built-in direct-output effect-op"),
"the print-op set must be stated present-tense in typeclasses.md, not as a retirement narrative");
assert!(records.contains("the render in `crates/ailang-prose/src/lib.rs` is a placeholder and informational only"),
"the Form-B class/instance prose-projection state must be present-tense in the decision-records");
}
#[test]
@@ -109,7 +146,7 @@ fn form_a_scalar_param_carveout_present_and_old_rule_absent() {
// (planner Step-5 item 6: line-wrap is structurally discharged,
// the substrings need not be contiguous in the .md source).
let f = norm(FORM_A_SPEC);
let d = norm(&read("docs/DESIGN.md"));
let d = norm(&read("design/contracts/embedding-abi.md"));
// --- ABSENT: the four old unconditional phrasings must be gone ---
assert!(!f.contains("Mode annotations on every `(fn ...)` parameter."),
@@ -131,7 +168,7 @@ fn form_a_scalar_param_carveout_present_and_old_rule_absent() {
assert!(f.contains("Putting a mode on a scalar parameter."),
"form_a.md Pitfalls must carry the inverse pitfall — over-wrapping a scalar (site 4)");
// --- PRESENT: the DESIGN.md §\"Embedding ABI\" mirror ---
// --- PRESENT: the design/contracts/embedding-abi.md mirror ---
assert!(d.contains("Export parameters are written **bare**: a scalar type carries no `own`/`borrow` mode"),
"DESIGN.md §Embedding ABI must mirror the canonical bare-scalar export-param rule");
"design/contracts/embedding-abi.md must mirror the canonical bare-scalar export-param rule");
}
@@ -16,21 +16,32 @@ fn read(rel: &str) -> String {
.unwrap_or_else(|e| panic!("read {rel}: {e}"))
}
/// Collapse every whitespace run to a single space so a pinned phrase
/// split across the design/ files' hard line-wrap (or across the
/// join seam) still matches a single-line assertion substring.
fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn design_md_effect_prose_is_true() {
let d = read("docs/DESIGN.md");
// Decision 3 effect prose -> design/models/effects.md; the
// "the built-in IO and Diverge ops" absent-pin's home
// `## What is not (yet) supported` -> design/contracts/scope-boundaries.md.
let d = norm(&[read("design/models/effects.md"),
read("design/contracts/scope-boundaries.md")].join("\n"));
// fiction absent
assert!(!d.contains("row-polymorphic"),
"DESIGN.md: the effect row is NOT row-polymorphic (no EffectRow / row var exists)");
"design/: the effect row is NOT row-polymorphic (no EffectRow / row var exists)");
assert!(!d.contains("`IO` and `Diverge` (for infinite"),
"DESIGN.md: `Diverge` is not a wired-up MVP effect");
"design/: `Diverge` is not a wired-up MVP effect");
assert!(!d.contains("the built-in IO and Diverge ops"),
"DESIGN.md §What-is-not-supported: there is no built-in Diverge op");
"design/contracts/scope-boundaries.md: there is no built-in Diverge op");
// corrected anchors present
assert!(d.contains("flat, unordered, closed set of effect names"),
"DESIGN.md Decision 3 must describe the real (flat set-equality) effect model");
"design/models/effects.md Decision 3 must describe the real (flat set-equality) effect model");
assert!(d.contains("`Diverge` (for non-termination) is reserved"),
"DESIGN.md Decision 3 must state Diverge is reserved/unimplemented");
"design/models/effects.md Decision 3 must state Diverge is reserved/unimplemented");
}
#[test]
+1 -1
View File
@@ -4,7 +4,7 @@
//! [`ailang_core::ast::Module`] values. The JSON-AST in `ailang-core`
//! remains the canonical, hashable, content-addressed source of truth;
//! form (A) is the AI-authoring projection optimised for token-efficient
//! production by LLMs (see `docs/DESIGN.md` Decision 6).
//! production by LLMs (see `design/contracts/authoring-surface.md`).
//!
//! ## Modules
//!
+4 -2
View File
@@ -75,10 +75,12 @@
//! lit-form ::= integer | "true" | "false" | string
//! ```
//!
//! Notes on the form (deviations from the spec in DESIGN.md Decision 6):
//! Notes on the form (the binding constraints are in
//! design/contracts/authoring-surface.md; the form-refinement
//! rationale is in docs/journals/2026-05-19-design-decision-records.md):
//!
//! - The `lam` form carries `paramTypes`, a `ret` type, and an
//! optional `effects` clause. The original DESIGN.md sketch left
//! optional `effects` clause. The early form sketch left
//! these out; the AST stores them per-lambda and so the form must
//! round-trip them.
//! - The `import` form admits an optional `as` alias to round-trip
+1 -1
View File
@@ -125,7 +125,7 @@ fn write_type_def(out: &mut String, td: &TypeDef, level: usize) {
}
// Iter 18e: `(drop-iterative)` annotation. Printed last (after
// every ctor) so the canonical form lands consistent with the
// DESIGN.md example. Omitted when `drop_iterative == false`.
// design/contracts/data-model.md example. Omitted when `drop_iterative == false`.
if td.drop_iterative {
out.push('\n');
indent(out, level + 1);
+2 -2
View File
@@ -7,7 +7,7 @@
//!
//! 1. `parse_is_deterministic_over_every_ail_fixture` — pins
//! **parse-determinism** (property 1 of the post-form-a Roundtrip
//! Invariant, DESIGN.md §"Roundtrip Invariant"). For every
//! Invariant, design/contracts/roundtrip-invariant.md). For every
//! well-formed `.ail` text `t`, `parse(t)` produces the same
//! canonical bytes every invocation. The parser is a pure
//! function of input — no randomness, no time dependence, no
@@ -69,7 +69,7 @@ fn list_ail_fixtures() -> Vec<PathBuf> {
}
/// Pins **idempotency-under-print** (property 2 of the post-form-a
/// Roundtrip Invariant, DESIGN.md §"Roundtrip Invariant"): for every
/// Roundtrip Invariant, design/contracts/roundtrip-invariant.md): for every
/// well-formed `.ail` text `t`, the composition `parse → print → parse`
/// is idempotent on the AST.
///