← Все решения

Types — record, sum-type, protocol, generic, поля

Решения этой группы задают систему типов Nova: четыре формы объявления данных, структурные контракты-протоколы, семантику передачи параметров и мутабельность полей, делегацию через use. Синтаксические детали (методы через @, generic-применение [T], литералы) — в 03-syntax.md.

#РешениеStatus
D17Объявление типов: единый синтаксис без |revised → D52
D52Объявление типов revised: newtype, alias, sum через leading |revised → D406
D406Sum-type синтаксис: enum маркер вместо leading |; inline enum в type-позицииactive
D53Унификация: protocol под type, protocol как kind-токенactive
D55Literal coercion в позиции с явным типом: sum-конструкторы и record-литералыactive
D42protocol keyword для структурных интерфейсовrevised → D53
D15Структурные интерфейсыrevised → D42D53
D39Embed и delegation: use name Type (alias обязателен)active
D32Семантика передачи параметровrevised для полей → D36
D36Поля типа: дефолт mutable у mut-binding’а, readonly для never-mutactive
D175readonly field — полный freeze, транзитивность (амендмент D36)active
D176readonly T — тип-модификатор, coercion rules, zero overheadactive
D66Self universal: ссылка на обобщающий тип в методах, effects, protocolsactive
D72Generic bounds через [T Protocol] — protocol как типactive
D110Ghost state — spec-only bindingsactive
D122Hybrid dispatch для bound-K methodsactive
D123Tuple monomorphizationactive
D215Named tuple fields + value/reference allocation contractactive
D119Method-level type parameters в generic methodsactive
D372Canonical .new() constructors (convention)active
D181Array methods — -> @ fluent mut chain + slice syntaxactive
D182Self в return-type static methods — required form для parametric typesactive
D183Canonical comparison protocols + default method bodies (Plan 91.8a)active
flip-scan-draftPointer mutability — running-current flip-scan (Plan 147; RETRACTED 2026-06-12D246)retracted
D246Три оси мутабельности (L1 binding / L2 view / L3 pointee); restores *T ≡ *ro T universally; E_REDUNDANT_POINTER_RO (Plan 147)active
D281Module-level field privacy type X priv { … } — bare priv = module-private (Plan 160, D281)active
D355Blanket protocol-receiver methods (ex-D282, renumber 2026-07-03) fn[I Next[T]] I @m — typevar-ресивер + bound-dispatch (Plan 161, G-F)active
D284EnumerateIter[I, T] — zero-cost enumerate adapter; per-type @zenumerate() dispatch; tuple parametric return (Plan 162)active
D290Iterator value-records: VecIter[T] value (GC-pointer fields covered by fiber arena) + Range/RangeIter/StepRangeIter/ReverseRangeIter value (int-only, pure stack) — zero malloc in adapter chain (Plan 165)active
D307File-private visibility priv(file) fn/type/const — лесенка priv(file) ⊂ module ⊂ export; E_FILE_PRIV_LEAK; file-discriminated codegen; dedup одноимённых в peer-файлах (Plan 170)active
D315ResolvedType — единый канонический носитель типа; проверки/совместимость/конверсии/перевод-в-C выводятся из него; type_ref_to_c ретайрится; сахар нормализуется; ABI выводится, не хранится (Plan 172.1, D315)active
D310Type-set bounds: type Name set T1 | T2 | … — именованное множество конкретных типов как generic-bound; SignedInt/UnsignedInt; Go-style type-constraint (Plan 172.3)active

D17. Объявление типов: единый синтаксис без |

⚠️ REVISED. Заменено D52. Старый синтаксис (type X = Y для alias, type X = A, B для sum) — запрещён. Новый: type X Y (newtype), type X alias Y (alias), type X | A | B (sum). Текст ниже — для исторической справки.

Что

Все формы объявления типа — record, позиционная структура, unit, alias, sum-type — используют один разделитель списка (запятая) и синхронизированы по =: = ставится только когда справа выражение типа (alias или sum-type), не когда форма данных ({...} или (...)).

Правило

Полный синтаксис:

// alias
type UserId = u64

// record (именованные поля)
type User { id u64, name str }

// позиционная структура
type Point(f64, f64)

// unit-тип (без полей)
type Empty

// sum-type
type Color = Red, Green, Blue

type Shape =
    Circle { radius f64 },
    Square { side f64 },
    Triangle { a f64, b f64, c f64 }

type Result[T, E] = Ok(T), Err(E)

Парсер однозначен по первому токену после имени типа:

После type X идётЧто это
{ ... }record-структура
( ... )позиционная структура
ничегоunit-тип
= потом типalias
= потом список вариантов через запятуюsum-type

type X { ... } — это record с полями. Методы внутри {...} запрещены: набор методов = поведение, для него используется protocol (D42). Эффекты — это protocol, использованный в позиции эффекта между ) и -> (04-effects.md → D18).

Создание значений и pattern matching — обычные:

ro p = Point(1.0, 2.0)
ro u = User { id: 1, name: "alice" }
ro c = Circle { radius: 5.0 }

match shape {
    Circle { radius }    => 3.14159 * radius * radius
    Square { side }      => side * side
    Triangle { a, b, c } => heron(a, b, c)
}

Field punning для record-литералов: если имя поля совпадает с именем переменной в скоупе, можно писать имя один раз:

ro key = "alice"
ro value = 42

ro entry = Entry { key, value }                    // shorthand
ro entry = Entry { key, value, extra: "data" }     // можно смешивать

Парсер однозначен: name: → полная форма, name, или name} → shorthand. Если переменной нет в scope — compile error.

Partial pattern matching — две эквивалентные формы:

// явная — с маркером ..
match @buckets[idx] {
    Occupied { value, .. } => Some(value)
    _                      => None
}

// неявная — без маркера, остальные поля игнорируются
match @buckets[idx] {
    Occupied { value } => Some(value)
    _                  => None
}

Явная форма — visual cue «здесь ещё поля». Неявная — краткость.

Переименование при деструктуризации остаётся явным: Occupied { key: k, value }.

Construction всегда требует все обязательные поля — частичное заполнение типа Rust ..default отдельным синтаксисом не зафиксировано.

Почему

  1. Один разделитель списка на весь язык — запятая. Параметры, элементы массивов, поля записи, варианты sum-type — везде ,. Меньше правил, меньше ошибок LLM.
  2. = означает «справа выражение типа». Когда справа форма данных — = лишний.
  3. Парсер по первому токену — никакого backtracking, чистые сообщения об ошибках.

Что отвергнуто

  • ML-style | Variant (OCaml/Haskell/F#/Rust). Два разделителя подряд (= |), чужд языкам не из ML-семейства, дублирует роль запятой.
  • type Point = | Point(f64, f64) для одно-вариантного sum-type — дубль. Sum-type с одним вариантом и структура — это одно и то же.
  • type User = { id u64, name str } для record. = лишний, когда справа форма данных.

Связь

  • 03-syntax.md → D27 — массивы ([]T, [N]T) как отдельные конструкции типов, не варианты type.
  • 03-syntax.md → D38 — generic-применение Имя[T] для параметризованных типов.
  • 02-types.md → D42 — почему protocol отдельный keyword, а не type X = { методы }.
  • 02-types.md → D36 — префиксы полей (readonly, mut) и group-syntax внутри record.

D52. Объявление типов revised: newtype, alias, sum через leading |

⚠️ REVISED. Синтаксис sum-type заменён D406: enum Variant | ... вместо leading | Variant | .... Остальные формы (newtype, alias, record, tuple, unit) без изменений.

Что

Полная пересборка D17. Один keyword type для всех data-форм, никаких = в декларациях, форма различается первым токеном после имени. Шесть форм:

  • newtypetype X Y (X — новый тип, типизированно отличный от Y, Go-style)
  • aliastype X alias Y (X и Y совместимы, для длинных дженериков)
  • recordtype X { поля }heap-allocated reference type (GC-managed)
  • tupletype X(типы)stack-allocated value type (позиционные поля .0/.1)
  • named tupletype X(name1 T1, name2 T2)stack-allocated value type (именованные поля .name) (D215, Plan 120)
  • unittype X (ничего после имени)
  • sumtype X enum A | B | C (enum маркер обязателен; D406)

Allocation contract (D215, Plan 120): скобки кодируют семантику размещения: () = stack-allocated value type, copy-семантика при передаче; {} = heap-allocated reference type, GC-tracked. Выбор формы явно документирует производительность и lifetime ожидания.

Sum-варианты могут иметь числовые discriminants с auto-increment. protocol остаётся отдельным keyword’ом для поведения (D42).

Правило

Полный синтаксис

// 1. Newtype — type X Y, без =
type UserId u64
type Email str
type Score f64

// 2. Alias — type X alias Y, для сокращения длинных дженериков
type StringMap[V] alias HashMap[str, V]
type Cache[K, V] alias HashMap[K, (V, Time)]

// 3. Record — type X { поля }
type User { id u64, name str }
type Point3D { x, y, z f64 }                    // group-syntax (D36)
type Account {
    ro id u64
    balance money
    mut last_access time
}

// 4a. Positional tuple — type X(типы)
type Point(f32, f32)          // .0 / .1 access
type Pair[A, B](A, B)

// 4b. Named tuple — type X(name type, ...) (D215, Plan 120)
type Vec3(x f64, y f64, z f64)       // .x / .y / .z access; stack-allocated
type Color(r u8, g u8, b u8, a u8)
type Generic[T](value T, count int)

// 5. Unit — type X
type Empty
type Sentinel

// 6. Sum — type X enum A | B (enum маркер обязателен; D406)
type Color enum Red | Green | Blue
type Direction enum North | East | South | West

// Sum многострочный — если варианты на новых строках, | обязателен у каждого
type Result[T, E] enum
    | Ok(T)
    | Err(E)

type Shape enum
    | Circle { radius f64 }
    | Square { side f64 }
    | Triangle { a f64, b f64, c f64 }

Парсер однозначен по первому токену после имени (с учётом дженериков)

После type X (или type X[params]) идётФорма
enumsum (D406)
protocolprotocol-тип (D53)
effecteffect-тип (D53)
settype-set bound (D310)
value {value-record — stack-allocated (D228/D277/D290)
( + ident + bare-typenamed tuple (D215) — (name1 T1, name2 T2)
( + bare-typepositional tuple — (T1, T2)
{record — heap-allocated (GC-managed)
aliasalias
<base-type> enumsum с явным базовым типом для discriminants
идентификатор/тип, конец строкиnewtype
конец строки сразуunit

Парсер видит первый токен — сразу знает форму. Для ( — один дополнительный lookahead: если (IDENT type → named tuple, иначе → positional tuple. Никакого backtracking.

Модификаторы type-declaration

Помимо kind-токена, type-declaration может нести модификаторы — они не меняют «форму» типа, а добавляют квалификаторы:

МодификаторСемантикаD-block
exportвиден снаружи модуляD47
valuestack-allocated (по значению, не в GC-куче)D228/D290
privполя module-private по умолчаниюD281
priv(type)поля type-private по умолчаниюD281
priv(file)символ виден только в этом файлеD307
consumemust-be-consumed affine typeD133

priv(file) — двойная позиция: prefix перед type (priv(file) type X { … }) или modifier после имени (type X priv(file) { … }). Обе формы эквивалентны. Для fn и const только prefix-форма: priv(file) fn f(), priv(file) const K.

Грамматика type-declaration: [export] type Name[T] [value] [priv|priv(type)|priv(file)] [consume] { … } эквивалентно: [export|priv(file)] type Name[T] [value] [priv|priv(type)] [consume] { … }

Модификаторы комбинируются: export type Job value priv consume { … }.

Поля внутри {…} могут иметь field-level модификаторы: ro (D175), mut (D36), priv / priv(type) / priv(file) (D281/D307).

Sum-варианты с числовыми discriminants

// Auto-increment без явных значений (от 0)
type ExitStatus enum Ok | Failure | Critical                  // 0, 1, 2

// Auto-increment от заданного
type FileMode enum Read = 1 | Write | Execute                 // 1, 2, 3

// Все явные
type ErrorCode enum
    | NotFound       = 404
    | Unauthorized   = 401
    | InternalError  = 500

// С отрицательными
type Sign enum Negative = -1 | Zero = 0 | Positive = 1

// Decreasing/non-monotonic — разрешено
type Code enum A = 10 | B = 5 | C                            // A=10, B=5, C=6

// Явный базовый тип
type Bit u8 enum Off = 0 | On = 1
type HttpCode i32 enum Ok = 200 | NotFound = 404

Явный базовый тип пока не реализован (parser drift, 2026-05-27). Формы с u8/i32/etc. между именем и enum парсер отвергает. Работает только дефолтная форма (без базового типа, implicit int). См. Plan 105.

Правила discriminants:

  1. Базовый тип — дефолт int. Опционально явный (type X i32 enum, type X u8 enum).
  2. Auto-increment от первого варианта:
    • Первый без значения → 0.
    • Каждый следующий без значения → предыдущий + 1.
  3. Отрицательные значения — разрешены.
  4. Decreasing/non-monotonic последовательности — разрешены.
  5. Конфликт значений (два варианта с одинаковым discriminant) — запрещён компилятором.
  6. Mixed (некоторые с полями, некоторые без, у всех discriminants) — разрешено:
    type Event enum
        | Click(x int, y int)              = 1
        | KeyPress(key str)                 = 2
        | Idle                              = 3
        | Data { payload []u8, crc u32 } = 10
    

Cast между sum-типом и числом

Sum → int — безопасный, всегда работает:

ro c = Red                 // Color
ro n = c as int            // 0 (если auto-increment)

ro e = NotFound            // ErrorCode
ro n = e as i32            // 404

int → Sum — через pattern match obligation:

ro n = read_from_db()
ro c = match n {
    0 => Red
    1 => Green
    2 => Blue
    _ => throw InvalidColor
}

Никакого n as Color — программист сам обрабатывает «нет такого варианта». Это согласовано с эффектом Fail[E].

stdlib может предоставлять Color.from_int(n) для удобства:

fn Color.from_int(n int) Fail[InvalidVariant] -> Color =>
    match n {
        0 => Ok(Red)
        1 => Ok(Green)
        2 => Ok(Blue)
        _ => Err(InvalidVariant)
    }

Параметризованные sum

type Option[T] enum Some(T) | None
type Result[T, E] enum Ok(T) | Err(E)
type Tree[T] enum
    | Leaf
    | Node { value T, left Tree[T], right Tree[T] }

Параметры в [...] после имени работают везде, как и раньше.

Сравнение alias и newtype

type AliasUserId alias u64
type NewUserId u64

ro a AliasUserId = 42        // ok
ro b u64 = a                  // ok — alias совместим с u64
ro c u64 = 42
ro d AliasUserId = c          // ok — обратное тоже работает

ro n NewUserId = 42           // ok (литерал подгоняется под целевой тип)
ro e u64 = n                  // ОШИБКА: NewUserId не u64
ro f u64 = n as u64           // ok через cast

Альтернативу newtype через record-обёртку (type X { value u64 }) никто не запрещает, но type X u64 — компактнее и привычнее программистам с фоном Go.

Field punning — расширено и обязательно

D52 расширяет field punning из D17 двумя правилами:

1. Shorthand для @field-доступов (новое в D52):

type RangeIter { end int, inclusive bool, mut cur int }

fn Range @iter() -> RangeIter =>
    { @end, @inclusive, cur: @start }
//    ↑    ↑           ↑
//    @end shorthand   полная форма (имя поля cur ≠ start)

{ @end } означает «поле end, значение @end (то есть self.end)». По симметрии с D17 ({ name } для переменной name в scope) — теперь { @field } для self-доступа.

2. Shorthand обязателен, когда имя поля совпадает с источником:

// Переменная в scope:
ro key = "alice"
ro value = 42
ro entry = Entry { key, value }                  // ✓ обязательная форма
ro entry = Entry { key: key, value: value }      // ✗ ОШИБКА: избыточная форма

// @field-доступ:
ro r = { @end, @inclusive, cur: @start }         // ✓
ro r = { end: @end, inclusive: @inclusive, ... } // ✗ ОШИБКА: избыточная

// Явная форма обязательна, когда имя источника отличается:
ro entry = Entry { name: user_name }             // ✓ имя поля ≠ переменной
ro r = { cur: @start }                            // ✓ имя поля cur ≠ start
ro r = { end: other.end }                         // ✓ источник — выражение, не @field

Парсер: { name/{ @name/{ name,/{ name } — shorthand; { name: expr — полная форма. После : ожидается выражение, но если выражение — это ровно тот же identifier или @+identifier, что и имя поля → ошибка компиляции «избыточная форма, используйте shorthand».

Status: ✅ enforced (2026-05-17, commit 34666922c35). Реализация в compiler-codegen/src/types/mod.rs RecordLit walker. AST flag RecordLitField.at_shorthand различает parser-generated @field shorthand от explicit { field: @field } (одинаковая AST форма). Test guards: nova_tests/negative_capability/d52_redundant_field_literal_rejected.nv

  • d52_redundant_self_field_rejected.nv.

Mixed разрешён:

{ @end, @inclusive, cur: @start, kind: "iter" }     // shorthand + полные

Когда расширение работает:

Имя поляИсточникПравило
nameпеременная name в scopeshorthand { name } обязателен
name@name (self-поле)shorthand { @name } обязателен
nameпеременная other (другое имя)полная форма { name: other }
name@other или выражениеполная форма { name: @other }
nameobj.fieldполная форма { name: obj.field }
nameлитерал, вызов, любое выражениеполная форма

Pattern matching и construction

match @buckets[idx] {
    Occupied { value, .. } => Some(value)            // partial с ..
    Occupied { value }     => Some(value)            // partial без ..
    _                      => None
}

Construction всегда требует все обязательные поля. Частичное заполнение типа Rust ..default отдельным синтаксисом не зафиксировано.

Что запрещено

  • type X = Y для alias — старый D17 синтаксис, заменён на type X alias Y.
  • type X = A, B для sum — заменён на type X enum A | B.
  • type X = { ... } для record — синтаксис никогда не был активным (D17 уже отвергал), = в этой позиции запрещён.
  • , для разделения вариантов sum — заменено на |.
  • Sum без enum маркера — запрещён (type X Red | Green ✗, type X enum Red | Green ✓). См. D406.
  • Single-variant sum — запрещён (как в D17), используйте record.
  • Конфликт discriminants — запрещён.
  • Избыточная форма { name: name } — обязателен shorthand { name }. Аналогично { field: @field } — обязателен { @field }. Если имя источника совпадает с именем поля, программист обязан использовать shorthand. См. «Field punning» выше.

Почему

  1. Системность. В D17 правило «= для выражений типа, без = для форм данных» работало для alias, но спотыкалось на sum-type: type Color = Red, Green, Blue — справа не «выражение типа» в обычном смысле, а список конструкторов. С D52 sum обрабатывается как именованная форма (через |), как и record/tuple/unit.
  2. Никаких = в декларациях типов — устраняется напряжение «иногда есть, иногда нет». = остаётся за binding’ом значений (let x = ...) и parameter defaults (если будут).
  3. Newtype как first-class. Domain-modeling (type Email str, type Score f64) даёт реальную защиту типов без шумной record-обёртки. Прецедент Go (type UserId int64).
  4. Discriminants для wire-протоколов. HTTP-коды, syscall-коды, serialization tags — программист может задать стабильные значения, как в C/TS/Swift enum.
  5. Парсер однозначен по первому токену — никакого lookahead глубже одного-двух токенов. AI-friendly: LLM с одного взгляда понимает форму.
  6. Leading | для sum — visual symmetry: все варианты выровнены, прецедент OCaml/F#/Scala 3.
  7. Согласованность с D1 «protocols + data, без классов»type только для данных, protocol отдельно для поведения.
  8. Field punning расширен и обязателен. Один способ записать «поле = источник с тем же именем» — shorthand. Запрет избыточной формы { name: name } устраняет «два пути к одному результату», что AI-unfriendly (LLM генерирует случайно). Также покрывает { @field } для self-доступов — частый паттерн в record-литералах методов-конструкторов. Прецедент: TS/Rust имеют shorthand, но не делают его обязательным; Nova идёт строже ради единого стиля (D40/D43-стилевая последовательность).

Что отвергнуто

  • Сохранить type X = Y для alias. Создаёт асимметрию: alias и sum с =, record/tuple/newtype без — нет единого правила.
  • Kind-токен enum с фигурными скобками (type X enum { A, B }). Заменено на type X enum A | B без скобок (D406).
  • Литералы как sum-варианты (type State | "open" | "closed", TS-style literal types). Полезно, но это отдельная фича (subtyping, runtime representation), отложена на следующую версию языка.
  • Итерация по вариантам (for c in Color). Связано с reflection и stdlib, отложено до Q9.
  • type X protocol { ... } под единым type. Семантически protocol — поведение, не данные; отдельный keyword чище.
  • type X newtype Y с явным kind-токеном. type X Y без токена короче и согласовано с Go.
  • Implicit cast int → Sum. Type-небезопасно (число может не попасть в варианты). Только через pattern match.

Цена

  1. Большой breaking change. Все существующие декларации в spec/, decisions/, examples/ переписать. Кода пока мало, миграция разовая.
  2. alias становится keyword’ом. Раньше был обычным идентификатором.
  3. Программистам с фоном Rust/TypeScript: type X = Y больше не alias, а ошибка. Адаптация через документацию.
  4. Парсинг type X Y (newtype) vs type X (unit) — различие по следующему токену (тип vs конец строки). Просто, но требует внимательности.
  5. | имеет двойную роль — разделитель в sum и @or в операторах (D46). Парсер различает по контексту.

Связь

  • D17 — старая версия, помечена revised → D52.
  • D42protocol остаётся отдельным keyword’ом для поведения.
  • D36 — префиксы полей (readonly, mut) и group-syntax внутри record.
  • D39 — delegation через use Type. Newtype с embed (type X { use Y }) — альтернатива alias для случаев, когда нужна обёртка с дополнительными полями.
  • 03-syntax.md → D44 — числовые литералы (0xFF, 1_000, негативные) — используются для discriminants.
  • 03-syntax.md → D46| в operator overloading (@or) — разрешается компилятором по контексту. Полная семантика overloading — D84.

Открытые вопросы

  • Литералы как sum-варианты (TS-style | "open" | "closed") — отложено до следующей версии.
  • Итерация по вариантам (for c in Color, Color.values()) — связано с reflection, откладывается до Q9 (stdlib).
  • Implicit cast литерала в newtype. Сейчас let u UserId = 42 — допустим (литерал подгоняется), но let n u64 = 42; let u UserId = n — требует явного cast. Точную семантику зафиксировать в Q (литералы vs binding’и).

Эволюция

D17 был первой итерацией, основанной на правиле «= для выражений типа». Со временем выяснилось, что:

  1. Sum-type с = — натяжка («справа выражение типа» не точно описывает список вариантов).
  2. Newtype отсутствовал как явная фича — программистам приходилось делать record-обёртки type X { value u64 }, что шумно.
  3. Discriminants на sum-вариантах не были специфицированы — но реальные wire-протоколы их требуют.

D52 решает все три, ценой breaking change по syntax-site всех type-объявлений. Подробно — history/evolution.md.


D406. Sum-type синтаксис: enum маркер (2026-07-01)

Revises D52 §«Sum». Остальные формы D52 (newtype, alias, record, tuple, unit) без изменений.

Что

Sum-type теперь объявляется с обязательным ключевым словом enum вместо leading |. Keyword enum — маркер в грамматике типов, поэтому он работает везде где допустим тип: в named-type declaration, в позиции параметра, возврата, поля, binding’а.

Правило

Синтаксис

// Named sum inline — без | перед первым вариантом
type Color enum Red | Green | Blue
type Direction enum North | East | South | West
type Option[T] enum Some(T) | None

// Многострочный — | обязателен у каждого варианта (включая первый)
type Result[T, E] enum
    | Ok(T)
    | Err(E)

type Shape enum
    | Circle { radius f64 }
    | Square { side f64 }
    | Triangle { a f64, b f64, c f64 }

// С discriminants
type ExitCode enum Ok = 0 | Failure = 1 | Critical = 2

// С явным базовым типом (пока не реализован, Plan 105)
type Bit u8 enum Off = 0 | On = 1

Inline enum в type-позиции

enum Variant1 | Variant2 — это тип-выражение, валидное в любой позиции где допустим тип:

// Параметр функции
fn job(a enum A | B) { ... }

// Возвратный тип
fn parse() -> enum Ok(int) | Err(str) { ... }

// Поле записи
type Response {
    status enum Ok | NotFound | InternalError
}

// Let-binding
ro x: enum Some(int) | None = Some(42)

type Foo enum A | B — это объявление имени для типа-выражения enum A | B. Named и inline — одна грамматика.

Минимум один вариант.

| в inline и многострочной формах

  • Inline| разделяет варианты, перед первым не нужен: type Color enum Red | Green | Blue
  • Многострочный — если варианты на новых строках, | обязателен у каждого варианта (включая первый), аналогично type-set bounds:
type Color enum Red | Green | Blue       // inline — без | перед первым

type Result[T, E] enum                   // многострочный — | обязателен
    | Ok(T)
    | Err(E)

Парсер

После type X (или type X[params])Форма
enumsum
<base-type> enumsum с явным базовым типом (Plan 105)

В type-expression position enum — prefix, парсер строит EnumTypeExpr.

Почему

  1. Симметрия с alias. type X alias Y и type X enum A | B — одна структура: keyword даёт форму, далее описание. Единый паттерн.
  2. Явный grep-маркер. enum в любой type-позиции мгновенно идентифицирует sum-type — в IDE, grep, LLM-prompt.
  3. Устраняет неоднозначность |. В D52 leading | конфликтовало с оператором @or и вызывало удивление. enum — незвуковой маркер без операторных коннотаций.
  4. Inline enum. Анонимные sum-типы в позициях параметра/возврата/поля становятся возможными — естественный extension грамматики.
  5. Named = type + inline. type Foo enum A | B тавтологично читается: «тип Foo это enum A или B». Интуитивно.

Что отвергнуто

  • Сохранить leading | (D52). Конфликт с оператором; не grep-абелен; inline-позиция невозможна.
  • enum { A, B } со скобками (Go/C стиль). Нарушает Nova-правило «{ → record»; , как разделитель заменено на | ещё в D52.
  • sum A | B (другой keyword). enum общепринятый термин в PL; sum слишком математичен, непривычен программистам.
  • type X = enum A | B с =. Убрано ещё в D52; D406 следует тому же принципу «никаких = в type-declaration».

Связь

  • D52 — заменяемый синтаксис
  • D53protocol/effect kind-токены в той же системе
  • D310set kind-токен в той же системе; | в type-set тоже разделитель, не оператор
  • D55 — literal coercion в позиции sum-type (inline enum тоже)
  • 03-syntax.md → D46| как @or оператор — разрешается по контексту (keyword enum/set или expr-контекст)
  • Plan 105 — явный базовый тип discriminants

D53. Унификация: protocol под type, protocol как kind-токен

Что

protocol перестаёт быть отдельным keyword’ом. Становится kind- токеном в системе D52, наряду с alias. Все объявления типов (включая структурные контракты-protocol’ы) идут через единый keyword type. Анонимный protocol-тип в позиции параметра пишется через protocol { ... } (с явным маркером, симметрично []T, (A, B), fn() -> T).

any — пустой именованный protocol-тип в prelude:

type any protocol { }

Правило

Объявление через type X protocol { ... }

// Раньше (D42): отдельный keyword
protocol Hash {
    hash() -> u64
    eq(other Self) -> bool
}

// Теперь (D53): kind-токен в системе D52
type Hash protocol {
    hash() -> u64
    eq(other Self) -> bool
}

type Logger effect {
    log(msg str) -> ()
}

type Iterator[T] protocol {
    next() -> Option[T]
}

type Db effect {
    query(q Sql) Fail[DbError] -> []DbRow
    exec(q Sql)  Fail[DbError] -> int
}

Парсер: protocol как kind-токен после имени

Расширение таблицы D52:

После type X (или type X[params]) идётФорма
protocolprotocol-тип
effecteffect-тип
enumsum (D406)
settype-set bound (D310)
value {value-record — stack-allocated (D228/D277/D290)
(tuple
{record — heap-allocated
aliasalias
<base-type> enumsum с явным базовым типом (Plan 105)
идентификатор/тип, конец строкиnewtype
конец строки сразуunit

protocol, effect, enum, set, alias, value — kind-токены после имени. Парсер однозначен по первому токену после имени (или generic-параметров).

Анонимный protocol-тип в позиции параметра

protocol { ... } в позиции типа — анонимный protocol-литерал, симметрично []T, (A, B), fn() -> T:

fn log_one(x protocol { show() -> str }) Log -> () =>
    Log.info(x.show())

fn closer_call(c protocol { close() -> () }) Io -> () =>
    c.close()

fn process(x any) -> () =>      // any — именованный пустой protocol
    ...

fn process2(x protocol { }) -> () =>   // эквивалент через анонимный
    ...

Маркер protocol обязателен — { ... } без префикса в позиции типа запрещено. Это убирает двусмысленность с record-литералами и выражениями-блоками.

any в prelude

// В prelude:
type any protocol { }

Любой тип удовлетворяет пустому контракту (структурная типизация), поэтому any — top-type. Использование:

type Logger effect {
    log_event(level int, fields []any) -> ()
    //                          ^^^^^ массив значений любого типа
}

fn dump(x any) Io -> () =>
    println(x)

Имя any lowercase — исключение в D30 naming convention, по аналогии с примитивами (int, str, bool, f64, ()). Top-type концептуально близок к примитивам — встроенный универсальный тип.

Эффекты — без изменений

Эффект — это protocol-тип, использованный в позиции эффекта (между ) и ->). Меняется только синтаксис объявления, не использования:

type Db effect {
    query(q Sql) Fail[DbError] -> []DbRow
    exec(q Sql)  Fail[DbError] -> int
}

fn list_users() Db -> []User =>      // Db в позиции эффекта — как раньше
    Db.query(sql`SELECT * FROM users`)

Generic-параметры — без изменений

D42-уточнение про две модели (на protocol-уровне и на методе) сохраняется. Меняется только синтаксис объявления:

// Модель A — generic на protocol
type Container[T] protocol {
    add(item T) -> ()
    get(idx int) -> T
}

// Модель B — generic на методе
type Tracer effect {
    span[T](body fn() -> T) -> T
    measure[U](body fn() -> U) -> Duration
}

Структурная совместимость — без изменений

Любой тип со структурно совпадающими методами автоматически удовлетворяет protocol’у:

type User { id u64, name str }

type Display protocol {
    show() -> str
}

fn User @show() -> str => "User(${@name})"

fn log_one(x Display) Log -> () =>
    Log.info(x.show())

log_one(my_user)                // ok, User совместим со Display

Self внутри protocol { ... } блока — это «late-bound» тип, определяется при удовлетворении (см. также D66Self universal во всех type-контекстах).

Почему

  1. Унификация под одним keyword. Все типы (data + behavior) идут через type. Один keyword для объявления, kind-токен различает форму. Согласовано с D52, который вводит alias как kind-токен — protocol встаёт в тот же ряд.
  2. Снимается асимметрия. До D53: protocol Foo — отдельный keyword, но Foo использовался как тип (в позиции параметра). Программист спрашивал «если protocol — тип, почему не объявляется через type?». D53 отвечает: теперь объявляется.
  3. Анонимные protocol-типы становятся явными. Раньше fn f(x { ... }) без префикса — двусмысленно (record-литерал? record-тип? protocol-тип?). С protocol { ... } — намерение явно.
  4. any — пустой именованный protocol. Простое и согласованное решение для top-type, через ту же систему. Прецедент Go (type any = interface{}), Swift (protocol AnyObject { }).
  5. Прецедент Go. Go объявляет type X struct { } и type X interface { } через единый type с kind-токеном. D53 повторяет эту схему точно (только interfaceprotocol).
  6. AI-friendly. Один keyword type в начале — LLM сразу видит «это объявление типа», kind показывает форму. Меньше keyword’ов для запоминания.

Что отвергнуто

  • Сохранить protocol Foo { ... } как отдельный keyword (текущий D42). Создаёт асимметрию: data объявляется через type, behavior — через protocol, оба используются как типы — два пути к одной концепции «тип». D53 устраняет.
  • type any alias protocol { } как форма для any. Для protocol’ов alias-форма семантически тождественна newtype-форме (структурная типизация делает имена незначимыми). Дополнительный синтаксис без выигрыша. Прямая type any protocol { } короче и яснее.
  • Any (PascalCase). Согласовано с D30 строже, но any lowercase привычнее (Go, TS) и согласовано с примитивами.
  • Анонимный protocol без префикса { ... }. Двусмысленно с record-литералами и блок-выражениями. protocol { ... } всегда явно.
  • Литеральные protocol’ы со значениями полей (как interface{} в Go допускает методы и встраивание других interface’ов через composition). Composition protocol’ов (Foo : Bar) — открытый вопрос (см. D42 раздел «Открытые вопросы»), не входит в D53.

Цена

  1. Большой breaking change. Все protocol Foo { ... } в spec/, decisions/, examples/ переписать в type Foo protocol { ... }. Это — повторение масштаба D52 миграции.
  2. На одно слово длиннее. type Hash protocol { ... } против protocol Hash { ... } — лишний type (5 символов).
  3. protocol теперь kind-токен, не keyword. Грамматически разные роли (kind-token ≠ leading keyword), хотя пишется одинаково.
  4. Анонимные protocol-типы в позиции параметра — новая форма, старая (без префикса) запрещена. Все fn f(x { method() })fn f(x protocol { method() }).
  5. Q22 закрывается этим решением — больше не открытый вопрос.

Связь

  • D17 — старая система объявлений, revised → D52.
  • D52D53 расширяет: protocol встаёт в ряд kind-токенов рядом с alias.
  • D42D53 заменяет protocol keyword на kind-токен. Семантика структурной типизации и generic-параметров сохраняется.
  • 04-effects.md → D18 — эффект как использование protocol-типа в позиции эффекта. Меняется только объявление.
  • 08-runtime.md → D26any добавлен в prelude.
  • 03-syntax.md → D30 — naming: any lowercase как исключение, по аналогии с примитивами.

Открытые вопросы

  • Type-pattern-match для значений any. Извлечение конкретного типа из any-значения (match x { int(n) => ..., str(s) => ... }) требует runtime-tag и новой формы match. Не входит в D53.
  • Composition protocol’ов (Foo : Bar или Foo extends Bar) — не входит, см. Q21 «proliferation эффектов» как родственный вопрос.

Эволюция

D42 ввёл protocol как отдельный keyword. После D52 (kind-токены alias) выявилась асимметрия: protocol используется как тип, но объявляется не через type. D53 снимает асимметрию — protocol становится kind-токеном в системе D52, унифицируя объявление всех типов под единым keyword’ом.

Q22 («унификация type/protocol») — закрыт принятием D53.

Method-prefix в protocol-блоке (Plan 17 Ф.1)

В protocol-объявлении instance-методы можно писать в обеих формах — и с префиксом @, и без. Они эквивалентны:

type Hash protocol {
    hash() -> u64                    // ✅ голое имя
    eq(other Self) -> bool
}

type Hash protocol {
    @hash() -> u64                   // ✅ с @, симметрия с реализацией
    @eq(other Self) -> bool
}

@ факультативен потому что в protocol-блоке метод всегда instance — без receiver-выражения, контекст однозначный. С @ форма читается как «копия декларации из реализации» (точно как fn User @hash() -> u64); без @ — короче. Структурная совместимость работает одинаково.

Когда писать что:

  • @method() — для визуальной симметрии с реализацией; для объявлений где соседние static-методы (если они появятся через Q-static-method-protocol) пишутся через .method().
  • method() — для краткости в простых protocol’ах.

Mut-методыmut @method() обязательно с @ (mut-modifier требует receiver-маркера; голое mut method() отвергнуто как двусмысленное с mut-binding’ом):

type Iter[T] protocol {
    mut @next() -> Option[T]         // ✅
    mut next() -> Option[T]          // ✅ (текущая prelude-форма, D26)
}

В bootstrap’е (2026-05-08) обе формы парсятся; std/testing/property.nv и std/collections/* используют голую форму.

См. также Q-protocol-method-prefix (closed этой секцией).

Реализация в bootstrap (2026-05-09)

Plan 15 D53 strict-mode (Plan 15 Ф.5) ввёл различие protocol/effect на уровне AST. Раньше оба keyword’а маршрутизировались в один TypeDeclKind::Effect(Vec<EffectMethod>), что нарушало D72: любой method-bag тип permissively принимался как generic-bound.

Текущее состояние:

  • TypeDeclKind::Protocol(Vec<EffectMethod>) — для type X protocol {…}.
  • TypeDeclKind::Effect(Vec<EffectMethod>) — для type X effect {…}.
  • Парсер маршрутизирует по ключевому слову (отдельные match-arm).
  • Codegen эмитит vtable только для Effect-kind. Protocol — compile-time-only; type_ref_to_c для protocol-методов не вызывается. Это попутно зафиксировало pre-existing bug: Self в protocol-методе раньше ломал codegen (искал несуществующий Nova_Self*).
  • Type-checker (D72 enforcement) регистрирует только Protocol-kind в protocol_specs. Попытка использовать Effect как bound — compile error c hint’ом «X is an effect, not a protocol — declare as type X protocol {…}».
  • Анонимные protocol-литералы в позиции типа (fn close(c protocol { close() -> () }), §628 этой секции) — ✅ реализованы в Plan 97 Ф.2 через новый TypeRef::Protocol(ProtocolSig) variant.
  • Protocol-литералы в expression-position (let l = protocol Name { ops }) с runtime vtable + dispatch — ✅ реализованы в Plan 97.1 (codegen vtable struct + emit_protocol_lit + Plan 56 D122 box-pattern). См. также D142.

D55. Literal coercion в позиции с явным типом: sum-конструкторы и record-литералы

Что

В позиции, где компилятор явно знает целевой тип T (let с аннотацией, аргумент функции, return-выражение), литерал автоматически подгоняется под T. Четыре случая:

  1. Sum-coercion. Значение типа S оборачивается в единственный unary-конструктор C(S) sum-типа T.
  2. Record-coercion. Анонимный record-литерал { field: value, ... } получает тип T без необходимости писать имя типа перед {}.
  3. Map-coercion. Анонимный record-литерал { name: value, ... } в позиции, ожидающей str-keyed map (тип помечен атрибутом #from_fields, как HashMap[str, V]), превращается в map: имена полей становятся строковыми ключами. Это не record-coercion (поля литерала ≠ поля struct’а HashMap) — отдельное правило, см. ниже.
  4. Numeric literal coercion. Целочисленный литерал в позиции numeric-типа (u8, u16, u32, u64, i8, i16, i32, i64, int) принимается без явного as-cast если значение влезает в диапазон целевого типа. Это распространяется на аргументы функций (включая generic после конкретизации: Vec[u8].push(1)), let с аннотацией (ro a u8 = 42), поля record-литерала. Выход за диапазон — compile error E_LIT_OUT_OF_RANGE: ro a u8 = 300300 > u8.MAX (255), ro b u8 = -1-1 < u8.MIN (0).

Без runtime-cost, без subtyping. После coercion тип значения — сам T.

// Sum-coercion
type StrOrInt enum S(str) | I(int)

ro a StrOrInt = "test"          // компилятор: a = S("test")
ro b StrOrInt = 25               // компилятор: b = I(25)

fn process(x StrOrInt) -> str => ...
process("alice")                   // компилятор: process(S("alice"))
process(42)                        // компилятор: process(I(42))

// Record-coercion
type User { id u64, name str }

ro u User = { id: 2, name: "Bob" }    // компилятор: u = User { id: 2, name: "Bob" }

fn create_user() -> User =>
    { id: 3, name: "Carol" }            // компилятор подставляет User

fn save(u User) -> () => ...
save({ id: 4, name: "Dave" })           // компилятор: save(User { ... })

Правило

Позиции с «явно ожидаемым типом»

Coercion (и sum-, и record-вариант) применяется только там, где компилятор точно знает целевой тип:

ПозицияCoercion применяется?Реализовано (bootstrap)?
let x T = value (явная аннотация)да✅ record (Plan 51 Ф.1)
const X T = valueда✅ record
fn f() -> T => value (return-выражение)да✅ record
fn f(x T) — на caller-стороне (f(value))да✅ sum/record/map (Plan 52 Ф.3a)
Generic-параметр после конкретизации — numeric (Vec[u8].push(1))да✅ numeric literals
Generic-параметр после конкретизации — record/sumда⛔ ещё нет
Match-arm result (когда тип ветки фиксирован)да⛔ ещё нет
Литерал коллекции с явным типом ([]T)да для каждого элемента⛔ ещё нет
let x = value (без аннотации)нет — выводится тип значения

В позициях без явного типа никакая coercion не применяется — литерал имеет «свой» тип ({ id: 2 } — анонимный record, 42 — int, и т.д.).

Статус реализации (2026-05-15). В bootstrap-компиляторе sum-/record-/map-coercion для безымянного литерала реально работает в позициях, помеченных ✅ (включая аргумент-позицию после Plan 52 Ф.3a — f({...}), f([k:v]), named-args). Для ⛔-позиций безымянный { ... } пока даёт codegen-ошибку — там пиши T { ... }. Полная реализация D55 во всех позициях — отдельная задача (investigation в Plan 51 показал, что «~900 избыточных мест» — переоценка; основная масса — это перенос имени, а не устранение).

⚠️ Пример save_all([{id:1,name:"a"}, ...]) ниже некорректен для bootstrap’а. Элемент-позиция литерала коллекции ([]T) помечена ⛔ — coercion на элементах массива пока не работает. Пример станет валиден после расширения Ф.3a на element-positions (за scope Plan 52). Пока там нужен [User{...}, ...] с явным именем типа на каждом элементе.

Запрет дублирования имени типа (Plan 51)

Там, где компилятор знает целевой тип, имя типа в record-литерале избыточно и запрещено — тип объявляется ровно один раз. Enforce’ится в двух позициях:

ФормаВердикт
fn f() -> T => { ... }✅ каноничная
fn f() -> T => T { ... }⛔ тип дважды
fn f() => T { ... }⛔ нет return-типа — тип «спрятан» в литерале
let x T = { ... }✅ каноничная
let x = T { ... }✅ (тип один раз — в литерале)
let x T = T { ... }⛔ тип дважды

-> Self резолвится к типу receiver’а (-> Self => Counter { ... } в методе Counter — тоже избыточно). Правило не срабатывает, когда тип литерала ≠ целевой тип — это sum-coercion (fn f() -> Result[U,E] => U { ... }, fn g() -> Shape => Circle { ... }): имя варианта обязательно. Применяется к fn, @-методам и closure-full с =>-телом.

Sum-coercion

В позиции с явным ожидаемым типом T (sum-тип) значение типа S оборачивается, если:

  1. У T ровно один unary-конструктор C(S), принимающий тип S.
  2. Значение точного типа T уже не подходит (нет exact match).

Стандартные prelude-типы:

ro m Maybe[int] = 42                        // Just(42)
ro r Result[User, str] = User { ... }       // Ok(User { ... })
ro opt Option[str] = "alice"                // Some("alice")

Коллекции:

type SqlValue enum I(i64) | F(f64) | S(str) | B(bool) | Bytes([]u8) | Null

ro args []SqlValue = [42, "alice", true]    // [I(42), S("alice"), B(true)]

// В sql`...` тэге интерполяции тоже coerce'ятся: i64 → I, str → S, bool → B
ro q = sql`SELECT * FROM users WHERE id = ${42}`   // args = [I(42)]

Генерики:

type Wrapper[T] enum W(T) | Empty

ro w Wrapper[int] = 42                      // W(42)
ro w Wrapper[str] = "test"                   // W("test")

Record-coercion

В позиции с явным ожидаемым record-типом T анонимный record-литерал { field: value, ... } подгоняется под T. Имя типа перед {} писать не нужно — компилятор подставляет.

type User { id u64, name str }

ro u User = { id: 2, name: "Bob" }
// эквивалент:
ro u User = User { id: 2, name: "Bob" }

fn save(u User) -> () => ...
save({ id: 4, name: "Dave" })             // эквивалент save(User { ... })

fn create() -> User =>
    { id: 5, name: "Eve" }                 // эквивалент User { id: 5, name: "Eve" }

fn make_default() -> Account =>
    { id: 1, balance: 0, closed: false }   // в return-позиции с типом Account

Правила:

  1. Все обязательные поля должны присутствовать в литерале — как и для именованного record-литерала (D17 construction всегда требует все поля).
  2. Имена и типы полей должны точно соответствовать T. Лишнее поле или несовпадение типа — ошибка компиляции.
  3. Field punning (D17) работает: let u User = { id, name } если id и name — переменные в скоупе.
  4. Без явного целевого типа литерал { id: 2, name: "Bob" } остаётся анонимным record-значением. Тип параметра функции или аннотации let активирует coercion.

Композиция с sum-coercion:

ro r Result[User, str] = { id: 2, name: "Bob" }
// шаг 1 (record-coercion): { id: 2, name: "Bob" } → User { id: 2, name: "Bob" }
// шаг 2 (sum-coercion): User → Ok(User { ... })

Записывается как одно действие компилятора в позиции с явным типом Result[User, str]. Один-единственный record-литерал → User → Ok.

Симметрия с массивами:

То же type-driven поведение работает для массивов и других литералов в позиции аргумента — это та же модель, которой Nova уже пользуется для пустых массивов:

fn first[T](xs []T) -> Option[T] => ...
ro r = first([])                   // [] : []T, T выводится из контекста

fn save(u User) -> () => ...
save({ id: 2, name: "Bob" })        // { ... } : User, тип параметра известен

fn save_all(us []User) -> () => ...
save_all([{ id: 1, name: "a" }, { id: 2, name: "b" }])
// каждый { ... } получает тип User из контекста []User

Аннотация типа параметра — единственный «локальный контекст», который читается, и он рядом с вызовом.

Sum-варианты с record-формой не получают анонимной формы — программист пишет конструктор:

type Shape enum Circle { radius f64 } | Square { side f64 }

ro s Shape = Circle { radius: 5.0 }   // явный конструктор обязателен
ro s Shape = { radius: 5.0 }           // ОШИБКА: по полям невозможно
                                        // выбрать между Circle и Square
                                        // (даже если у них разные поля,
                                        // программист пишет имя варианта)

Это сознательное ограничение: sum-варианты с record-формой требуют имени конструктора всегда. Иначе at parse-time нужно матчить по структуре полей — type-driven parsing, антипаттерн.

Map-coercion

В позиции с явным ожидаемым типом HashMap[str, V] анонимный record-литерал { name: value, ... } превращается в str-keyed map: имена полей литерала становятся строковыми ключами, значения — значениями map.

ro h HashMap[str, bool] = { debug: true, verbose: false }
// эквивалент: HashMap[str, bool] с ключами "debug", "verbose"

fn configure(opts HashMap[str, int]) -> () => ...
configure({ width: 80, height: 25 })          // ключи "width", "height"

Почему отдельное правило, а не record-coercion. HashMap[K, V] — это struct (type HashMap[K, V] { buckets, count, ... }). Обычная record-coercion матчила бы { debug: ... } против полей struct’а HashMap (buckets, count) и падала бы. Map-coercion трактует имена полей литерала как ключи, а не как поля struct’а. Чтобы компилятор знал, какое из двух правил применить, целевой тип несёт #from_fields атрибут (TypeAttr, ставится перед type):

  • Это не opt-in ради эргономики (которое D55 отвергает для sum/record) — marker здесь load-bearing для дисамбигуации: «трактовать {...} как поля этого struct’а» vs «как строковые ключи». Без него правило неоднозначно.
  • Gating: HashMap[str, V] несёт marker; случайный struct — нет, и не начнёт принимать произвольные record-литералы.
  • Bootstrap: marker захардкожен для HashMap. Протокол FromFields[V] как точка расширения (OrderedMap, BTreeMap[str, V]) — позже.
  • Протокол #from_fields: тип обязан иметь:
    • static with_capacity(n int) -> Self — предаллоцировать под n записей;
    • mut @insert_new(key str, val V) -> () — вставить новую запись (без возврата Option; дублей нет по construction). Отсутствие любого из методов → CC-FAIL при десугаринге.

Правила:

  1. Ключи — только str (имена полей литерала). Нестроковые ключи, не-идентификаторные строки, вычисляемые ключи — это map-литерал [k: v] (03-syntax.md → D108), не {...}.
  2. Значения гомогенны — все поля одного типа V (после возможной sum-coercion на каждом значении).
  3. Композиция с sum-coercion:
    ro j HashMap[str, JsonValue] = { name: "alice", age: 30.0 }
    // "alice" → Str("alice"), 30.0 → Num(30.0); оба → JsonValue
    
  4. Десугаринг — без промежуточных объектов: block-expression с with_capacity + @insert_new, никакой промежуточный record не материализуется (литерал — только синтаксис):
    { mut _m0 = HashMap[str, V].with_capacity(n)
      _m0.insert_new("debug", true)
      _m0.insert_new("verbose", false)
      _m0 }
    
    (@insert_new вместо @insert — мапа только что создана, дублей нет, нет нужды в Option[V]-возврате.)
  5. Пустой {} — это НЕ пустая мапа. {} всегда парсится как пустой block-expression с типом unit — даже в позиции, ожидающей HashMap[str, V]. Пустая мапа записывается как [] + ожидаемый тип (03-syntax.md → D108):
    ro h HashMap[str, bool] = []     // ✅ пустая мапа (тип из контекста)
    ro h HashMap[str, bool] = {}     // ⛔ {} — пустой блок, тип unit ≠ HashMap
    

    Ревизия (Plan 52 Ф.0). Прежняя формулировка §5 ошибочно допускала {} в map-позиции → HashMap[str, V].new(). Это требовало type-directed parsing блока — Nova этого не делает (D43). Правило удалено; пустая мапа — только [].

  6. Дубликаты ключей невозможны — имена полей record-литерала уникальны by construction.

Граница с map-литералом [k: v]: {...} — когда ключи это статические имена-идентификаторы; [...] — когда ключи это выражения (см. D108).

Когда coercion НЕ применяется

Ambiguity — несколько конструкторов с тем же типом (sum-coercion):

type Ambiguous enum A(int) | B(int)

ro x Ambiguous = 42         // ОШИБКА: ambiguous, A(42) или B(42)?
ro x = A(42)                 // явный конструктор — ok

Несоответствие — ни один конструктор не принимает тип значения:

type Color enum Red | Green | Blue

ro c Color = "red"           // ОШИБКА: ни один конструктор не принимает str
ro c = Red                    // unit-конструктор

Без аннотации — coercion отключён:

type StrOrInt enum S(str) | I(int)

ro a = "test"                // a : str (не StrOrInt, аннотации нет)
ro b StrOrInt = "test"        // b : StrOrInt = S("test") (аннотация есть)

ro r = { id: 2, name: "Bob" }   // r : анонимный record { id int, name str }
ro u User = { id: 2, name: "Bob" }   // u : User (через record-coercion)

Newtype через D52 — coercion следует типу значения, не возможным кастам:

type UserId u64
type Wrapper enum W(UserId) | N(int)

ro w Wrapper = 42            // 42 : int → N(42) (тип значения int)
ro w Wrapper = 42 as UserId  // → W(42 as UserId) — явный as, потом coercion
ro w Wrapper = UserId(42)    // явный конструктор UserId

Несовпадение полей record:

type User { id u64, name str }

ro u User = { id: 2 }                    // ОШИБКА: missing field `name`
ro u User = { id: 2, name: "Bob", age: 30 }   // ОШИБКА: unknown field `age`
ro u User = { id: "two", name: "Bob" }   // ОШИБКА: id expects u64, got str

Coercion не строит цепочку конверсий — только одна обёртка вокруг exact-type значения.

Multi-parameter и tuple-варианты

Multi-parameter конструкторы — coercion не применяется в MVP:

type Event enum Click(int, int) | KeyPress(str)

ro e Event = "enter"         // ok — KeyPress("enter"), unary с str
ro e Event = (5, 10)          // ОШИБКА в MVP: tuple-coercion не вводится
ro e = Click(5, 10)           // явный конструктор

Tuple-coercion (5, 10) → Click(5, 10) — отложено. Усложняет правила (как различать «tuple как значение» vs «tuple-coercion в multi-param»), не критично для use-case’ов.

Unit-конструкторы — coercion бессмыслен

Unit-варианты не принимают значение, coercion не нужен — программист пишет конструктор напрямую:

type State enum Open | Closed
ro s State = Open              // unit, coercion не применяется

Почему

  1. Огромный win в эргономике для prelude-типов. Option[T] и Result[T, E] — самые частые sum’ы языка. Без coercion программист пишет Some(42), Ok(user) каждый раз. С coercion — 42, user. Убирает значительную часть boilerplate.
  2. Без subtyping. Тип значения после coercion — сам sum или сам record, не подтип. На уровне типов всё чисто: pattern match exhaustive, variance не возникает. Anonymous unions (TS-style string | number) не вводятся — coercion не делает того же эффекта семантически.
  3. Без runtime-cost. Sum-обёртка — обычный конструктор, runtime-tag уже есть в representation sum’а (D52). Record-coercion — это просто подстановка имени типа, никакого runtime-преобразования.
  4. Закрывает use-case’ы any (sum) и убирает шум именования (record). sql\…${value}`теперь type-safe —valuecoerce'ится вSqlValueбез[]anyи безis-extract. let u User = { id: 2, name: “Bob” }` — без повтора имени типа.
  5. AI-friendly. LLM пишет [42, "alice"] для SQL-аргументов естественно, без думания о конструкторах. { id: 2, name: "Bob" } в позиции с явным типом — естественный способ создать record. Имя типа из аннотации — единственный «локальный контекст», который нужно прочитать, и он уже рядом.
  6. Прецеденты:
    • Swift ExpressibleByStringLiteral/ExpressibleByIntegerLiteral — opt-in protocol’ы для coercion. Nova делает это автоматически для unary-конструкторов sum’ов (без opt-in).
    • Scala 3 Conversion[A, B] — opt-in given-конверсии.
    • TypeScript — через subtyping для anonymous union, через structural typing для record (const u: User = { id, name } работает). Nova даёт похожую эргономику без subtyping.
    • Rust struct expressions требуют имени (User { id, name }) — прецедент против record-coercion. Nova выбирает TS-эргономику для record в позиции с явным типом, но только в этой позиции.

Что отвергнуто

  • Subtyping (int <: StrOrInt) — TS-style anonymous unions. Серьёзное расширение системы типов (variance, type inference, exhaustiveness), runtime-cost (boxing на каждой границе). Coercion даёт то же удобство без subtyping. Записан как Q-anonymous-union для возможного пересмотра.
  • Anonymous record-coercion вне позиций с явным типом. let x = { id: 2, name: "Bob" } остаётся анонимным record-типом, не превращается в User. Только явный целевой тип активирует coercion. AI-locality сохраняется.
  • Record-coercion для sum-вариантов с record-формой (type Shape | Circle { radius f64 } | Square { side f64 }, let s Shape = { radius: 5.0 }). Программист обязан писать имя варианта (Circle { radius: 5.0 }), даже если поля уникальны для одного варианта. Альтернатива — type-driven parsing по совпадению полей, антипаттерн в Nova.
  • Tuple-coercion в MVP. Двусмысленность с tuple-литералами как значениями. Отложено до v1.0+.
  • Coercion на цепочках конверсий (int → UserId → Wrapper). Только одна обёртка. Иначе правила усложняются, и легко получить неожиданный результат.
  • Coercion без явной аннотации типа (let x = "test" → выводить StrOrInt?). Type inference не должен «угадывать» sum или record. Только явный target type активирует coercion.
  • Opt-in coercion через protocol (Swift-style ExpressibleBy*Literal). Программист объявляет sum/record, поведение работает автоматически без дополнительного opt-in. Это менее гибко, но проще.
  • Coercion для multi-parameter конструкторов через tuple ((5, 10) → Click(5, 10)). Отложено как tuple-coercion в MVP.

Цена

  1. Implicit conversion — первая в Nova. До D55 язык избегал неявного. Это философский сдвиг, обоснованный эргономикой prelude-типов и анонимных record. AI-friendly: LLM не должна угадывать конструктор или имя типа.
  2. Type-checker сложнее. В позиции с явным типом нужно проверить exact match, потом coercion (sum или record). Стандартное расширение, но code path не нулевой.
  3. IDE-подсказки усложняются. «Ожидается StrOrInt, передан str → coerce в S», «Ожидается User, передан анонимный record → подгонка под User» — IDE должна это показывать.
  4. Migration sum’а опасна: добавление нового unary-конструктора с тем же типом параметра ломает существующий код (был exact match через coercion в S(str), стал ambiguous из-за S(str) | S2(str)). Это breaking change для sum’а — программист должен учитывать.
  5. Migration record’а тоже: добавление обязательного поля в record ломает все анонимные литералы без него. Это известная проблема record-типов вообще, не специфическая для D55.
  6. Закрывает большую часть use-case’ов any — это плюс, но требует пересмотра примеров (args []anyargs []SqlValue).
  7. Парсер — без type-driven decisions. Coercion работает в позициях, где целевой тип уже известен type-checker’у — парсер по-прежнему чисто синтаксический. {...} парсится как record-литерал/block-выражение по обычным правилам D17/D49, а тип ему присваивает type-checker по аннотации.

Связь

  • D52 — sum-типы и unary-конструкторы, на которых coercion работает.
  • D53any остаётся для подлинно открытых случаев (plugins, reflection), D55 закрывает большую часть use-case’ов через closed sum’ы.
  • 03-syntax.md → D44 — numeric literal coercion (100 подгоняется под u8/u32 в позиции типа) — D55 расширяет эту идею на sum’ы и record’ы.
  • 03-syntax.md → D54as/is остаются явными для конвертации/проверки. D55 не вводит implicit cast между обычными типами, только для sum-обёрток и record-литералов.
  • 08-runtime.md → D26Option[T], Result[T, E] в prelude получают эргономичный синтаксис через D55.
  • #d17-объявление-типов-единый-синтаксис-без- (revised → D52) — record-литерал User { id: 1, name: "alice" } с именем типа — обязательный, когда тип не выводится из контекста. D55 разрешает опускать имя в позиции с явным целевым типом.
  • 03-syntax.md → D108 — map-литерал [k: v]; комплементарен map-coercion ({...} — ключи-имена, [...] — ключи-выражения). Реализация обоих — Plan 52.

Открытые вопросы

  • Tuple-coercion для multi-parameter конструкторов. Отложено.
  • Anonymous unions (type StrOrInt | type str | type int) — TS-style без обёрток. Записан как Q-anonymous-union (требует subtyping, серьёзное расширение системы типов). См. open-questions.md.
  • Стандартные closed sum’ы в prelude (SqlValue, JsonValue) — что именно положить, формат и набор операций. См. Q9 (stdlib).
  • Cross-type numeric coercion в D55 (42f64 для Number(f64)). Сейчас строгий exact match. См. Q-numeric-coercion.

Style-guide: когда coerce, когда писать тип явно (Plan 17 Ф.1)

D55 разрешает обе формы — coerce и явный конструктор. Чтобы кодовая база не превращалась в смесь стилей, ниже рекомендации для nova fmt/линтера и code review (это не правило компилятора, оба варианта остаются валидными).

Coerce (короче, тип в аннотации) — предпочитать когда:

// 1. let с явной аннотацией — тип сразу слева, имя справа лишнее
ro u User = { id: 1, name: "alice" }                ✅
ro m Maybe[int] = 42                                 ✅

// 2. return-position в expression-body, есть -> T
fn make_default() -> Account => { id: 0, balance: 0 } ✅

// 3. call-site с явным типом параметра — coercion даёт чистый литерал
serve({ ...SERVER_DEFAULTS, port: 9000 })             ✅

// 4. коллекции с разнородными элементами в позиции []SqlValue
ro args []SqlValue = [42, "alice", true]             ✅
//                    [I(42), S("alice"), B(true)]    ❌ шумно

Явный конструктор — предпочитать когда:

// 1. let без аннотации — coercion не работает, имя обязательно
ro r = if cond { Some(value) } else { None }         ✅
ro r = if cond { value } else { None }               ❌ — нет аннотации

// 2. match-arms где хотя бы одна ветка — unit-вариант (None / Empty)
//    — для визуальной симметрии писать ВСЕ ветки с конструкторами
match @cache.get(key) {
    Some(v) => Some(v)            ✅ симметрично с None
    None    => fallback()
}
match @cache.get(key) {
    Some(v) => v                  ❌ value слева, None справа —
    None    => fallback()         //    асимметрично, читать сложнее
}

// 3. nested record-литерал внутри блока — { {...} } визуально шумно
fn compute() -> Money =>
    if special { Money { amount: 100, currency: usd } }   ✅
    else       { Money { amount: a + b, currency: c } }
fn compute() -> Money =>
    if special { { amount: 100, currency: usd } }          ❌ шум
    else       { { amount: a + b, currency: c } }

// 4. ambiguous unary-конструкторы (compile-error без явного имени)
type Mixed enum A(int) | B(int)
ro x Mixed = 42                  ❌ ambiguous — обязателен A(42) / B(42)

Сводка:

КонтекстРекомендация
let x T = ... (есть аннотация)coerce
let x = ... (нет аннотации)явный конструктор
fn f() -> T => ... (есть -> T)coerce
fn f(x T) call-site f(...)coerce
match с unit-веткойявный (симметрия)
nested { ... } в блоке после if/elseявный (избежать { {...} })
ambiguous unary-конструкторыявный (обязательно)

Аргумент. nova fmt не должен переписывать одну форму в другую — выбор стилистический. Линтер может в будущем выдавать подсказку для самых тяжёлых случаев (например, { {...} } в block-context), но без флага --strict-style — это рекомендация, не ошибка.

См. также Q-style-coercion (закрыт этой секцией).

Эволюция

До D55 sum-варианты требовали явный конструктор на каждом значении (Some(42), Ok(user), S("test")), а record-литералы — имя типа перед {} (User { id: 1, name: "alice" }).

После D55 в позиции с явным целевым типом:

  • sum-значение оборачивается автоматически (42 в позиции Maybe[int]Just(42)),
  • анонимный record-литерал получает имя из аннотации ({ id: 1, name: "alice" } в позиции UserUser { id: 1, name: "alice" }).

Это эргономический сдвиг уровня D52, без слома типовой модели.

Альтернатива (anonymous unions через subtyping) рассмотрена и отвергнута — слишком серьёзное расширение системы типов для эргономического выигрыша. D55 даёт похожее удобство более узким и контролируемым механизмом.


D42. protocol keyword для структурных интерфейсов

⚠️ REVISED. Заменено D53. protocol — теперь не отдельный keyword, а kind-токен в системе D52: type Foo protocol { ... }. Семантика структурной типизации, generic-параметров и эффектов сохраняется. Текст ниже — для исторической справки.

Что

Структурные интерфейсы объявляются отдельным keyword protocol. type — для данных (record, sum-type, alias), protocol — для поведения (набор методов как контракт). Любой тип со структурно совпадающими сигнатурами автоматически удовлетворяет protocol’у — без явных impl-блоков.

Эффекты — это тоже protocol, использованный в позиции эффекта (между ) и ->). Один и тот же protocol может играть роль эффекта или роль структурного контракта-параметра — различение по контексту использования (04-effects.md → D18). type без полей с одними методами не допускается — нужен protocol.

Правило

type Hash protocol {        // D52/D53: kind-токен `protocol` под `type`
    hash() -> u64
    eq(other Self) -> bool
}

type Iterator[T] protocol {
    next() -> Option[T]
}

type Login {                    // record (данные) — голый type
    username str
    password str
}

Self внутри protocol-блока — late-bound. См. D66 для других контекстов где Self тоже валиден (static/instance методы, effects).

Структурная совместимость — автоматическая. Метод определяется у типа через @-синтаксис (03-syntax.md → D35) и без дополнительных деклараций удовлетворяет protocol’у:

type User { id u64, name str }

type Display protocol {
    show() -> str
}

fn User @show() -> str => "User(${@name})"

fn log_one(x Display) Log -> () =>
    Log.info(x.show())

log_one(my_user)                // ok, User автоматически совместим

Параметр функции может декларировать требования прямо в типе, без именованного protocol’а:

fn log_one(x { show() -> str }) Log -> () =>
    Log.info(x.show())

В protocol fn-префикс не нужен — там по определению все «члены» это методы. В record-типе поле-функция объявляется явно с fn:

type Button {
    text str
    on_click fn() Io -> ()      // поле-функция в record, не protocol
}

Generic-параметры: на protocol-уровне vs на методе

В Nova есть две явных модели generic-параметров для protocol’а. Программист выбирает по семантике.

Модель A — generic на protocol (protocol P[T] { ... }). T фиксирован для всего protocol’а: один handler = один T. Все методы видят один и тот же T. Разные T = разные сущности (Iterator[Int] и Iterator[String] несовместимы).

type Iterator[T] protocol {
    next() -> Option[T]
    peek() -> Option[T]
}

type Container[T] protocol {
    add(item T) -> ()
    get(idx int) -> T
    size() -> int                    // методы без T тоже допустимы
}

type Channel[T] effect {            // effect — нужен with-substitution
    send(value T) -> ()
    recv() -> T
}

type Cache[K, V] effect {
    get(key K) -> Option[V]
    set(key K, value V) -> ()
}

Когда применять: когда T — фундаментальная характеристика protocol’а, все или большинство методов работают с этим T, и разные T = разные handler’ы имеют смысл.

Модель B — generic на методе (method[T](...)). T живёт только в скоупе одного метода. Один и тот же handler protocol’а вызывает метод с разными T для каждого вызова.

type Tracer effect {
    span[T](body fn() -> T) -> T          // T живёт только здесь
    measure[U](body fn() -> U) -> Duration  // U независим от T
    set_attr(key str, value Json) -> ()    // методы без generic тоже
}

type Db effect {
    query(q Sql) Fail[DbError] -> []DbRow
    in_transaction[T](body fn() Db Fail -> T) Fail -> T
    // ↑ один Db handler оборачивает любой T
}

Когда применять: когда метод принимает/возвращает любой тип, а сам protocol не привязан к этому типу — один handler работает с любым T для каждого вызова.

Различие в семантике handler’а:

Модель AМодель B
Объявление Tprotocol P[T]method[T] в сигнатуре
Scope Tвесь protocolодин метод
Один handler работает содним Tлюбым T (per-call)
Использованиеwith P[Int] = ...with P = ...; P.method[Int](...)
Реализациямономорфизация по Trank-2 polymorphism в handler’е

В одном protocol’е можно комбинировать оба механизма:

type Stream[T] protocol {
    next() -> Option[T]                       // T на protocol-уровне
    fold[Acc](init Acc, f fn(Acc, T) -> Acc) -> Acc   // Acc на методе
}

T фиксирован для stream (Stream[int]), Acc независим — fold может собирать в разные accumulator-типы из одного и того же stream’а.

Почему

  1. Намерение должно быть явным. Старая форма type X = { методы } визуально совпадала с record-формой type X { поля }, различаясь только знаком =. LLM и человек различали намерение по единственному символу — хрупко. Отдельный keyword делает намерение явным с первого токена.
  2. Прецедент. protocol как keyword для интерфейсов используется в Swift, Objective-C, Clojure, Elixir, Python (typing.Protocol). Семантически Nova ближе всего к Python typing.Protocol — чисто структурный subtyping.
  3. Эффекты в сигнатурах методов делают protocol строже Go interface — реализация не может привнести эффект сверх объявленного. Это уникальное свойство Nova.

Что отвергнуто

  • type X = { методы } — слишком похоже на record, отличается одним знаком =. См. «Почему» выше.
  • contract — занято под pre/post-условия (09-tooling.md → D24).
  • promise — массовая ассоциация с async (JS Promise).
  • interface — слишком сильный nominal-bias (Java/C#).
  • trait — обещает Rust-фичи (default impl, supertraits, blanket impl), которых в Nova нет.
  • shape — короче, но менее знакомо как keyword.
  • ability — образно, но без знакомства; навязывает -able суффикс именам.
  • Implicit shared scope для generic-параметров (T в нескольких методах одного protocol’а автоматически означает один и тот же тип). Снижает локальность: чтобы понять [T] в одном методе, нужно прочитать весь protocol-блок и проверить остальные методы. Невозможно выразить «независимый T в разных методах» без смены convention (использования других букв). Прецедентов нет — Rust/Swift/Scala/Haskell все используют либо явный protocol-уровень, либо явный method-уровень. Альтернатива (protocol P[T]) уже даёт ту же семантику явно.

Связь

  • 02-types.md → D15D15 ввёл структурные интерфейсы; D42 уточняет грамматику отдельным keyword.
  • 02-types.md → D39use Type для делегации между record-типами; protocol не embed’ится.
  • 03-syntax.md → D35 — методы через @ как способ удовлетворить protocol.
  • 01-philosophy.md → D1protocols + data как фундамент парадигмы.

Открытые вопросы

  • Bounds на дженерикиHashMap[K: Hash, V] требует отдельного решения. Сейчас параметр без bound, компилятор полагается на структурное соответствие при использовании.
  • Default-методы в protocol — пока запрещены.
  • Inheritance protocol’овprotocol A : B пока запрещено; эквивалент достигается явным включением методов B в A.

Эволюция

Изначально структурные интерфейсы описывались через type X = { методы } (см. D15). D42 заменил эту форму на отдельный keyword protocol. Детали — в history/evolution.md.


D15. Структурные интерфейсы

Status: revised. Роль перешла к protocol keyword (D42).

Что

Изначальный механизм структурных «интерфейсов» в Nova: отдельной концепции interface или trait нет; контракт — это набор сигнатур, любой тип со совпадающими методами автоматически совместим. Сейчас этот механизм обогащён keyword protocol (D42), который делает объявление контракта синтаксически явным.

Правило

Структурная совместимость — автоматическая. Имя контракту даёт protocol:

type Display protocol {
    show() -> str
}

type User { id u64, name str }

fn User @show() -> str => "User(${@name})"

fn log_one(x Display) Log -> () => Log.info(x.show())

log_one(my_user)                // ok, User автоматически совместим

Анонимный структурный тип прямо в сигнатуре параметра — без отдельного имени:

fn log_one(x { show() -> str }) Log -> () =>
    Log.info(x.show())

Что сохранено:

  • Эффекты в полях-функциях — часть сигнатуры, проверяются как обычно. Реализация не может привнести эффект сверх объявленного. Это ключевое отличие Nova от Go: контракт жёстче, потому что эффекты — часть сигнатуры.
  • Структурная совместимость автоматическая, как в Go.
  • Дженерики без bound’ов — требования описываются типом параметра.

Почему

  1. Следует из принципа «не добавлять фичи без оправдания центральной идеей или AI-first». Rust-style traits ни тому, ни другому не служат.
  2. Унификация: одна концепция «структурный тип» вместо двух («record»
    • «interface»). Меньше синтаксиса — проще для LLM.
  3. Эффекты в сигнатурах методов делают структурный тип строже, чем Go interface — это уникальное свойство Nova, которое нельзя получить простым заимствованием Go.

Что отвергнуто

  • trait / interface как отдельный keyword с nominal-семантикой (Java/C#/Rust).
  • impl Trait for Type блоки.
  • [T: Trait] bounds в дженериках.
  • dyn Trait vs impl Trait разделение.
  • Ассоциированные типы.
  • Дефолтные методы.
  • Trait-наследование, specialization, HKT.

Цена

  • Нет имени для контракта иначе как через protocol. В IDE нельзя «найти всех, кто реализует X» так же легко, как в Rust/Java — поиск идёт по совпадению методов.
  • Нет номинальности. Если очень нужна — через newtype-обёртку (паттерн, не фича).

Связь

Эволюция

Ранние черновики описывали контракт через type X = { методы } — визуально неотличимо от record. D42 ввёл отдельный keyword protocol, сохранив структурную семантику D15. Подробно — в history/evolution.md.


D39. Embed и delegation: use name Type (alias обязателен)

Что

Композиция типов через use name Type внутри record-декларации. Имя поля всегда явное — программист пишет alias в snake_case по D30. Default-имя по типу (Go-style use Type → поле Type) не вводится — нарушает D30 (поля snake_case, типы PascalCase).

Это delegation, не наследование: обёртка не является подтипом встроенного.

Правило

Базовое использование

type AuditedAccount {
    use account Account              // имя поля = "account" (snake_case)
    audit_log []AuditEntry
}

ro acc AuditedAccount = ...

// Auto-proxy: прямой доступ к полям и методам Account
println(acc.balance)                 // = acc.account.balance
println(acc.owner)                   // = acc.account.owner
acc.is_solvent()                     // = acc.account.is_solvent()

// Доступ к встроенному объекту целиком — через имя поля
ro just_account = acc.account

use Account без имени — ошибка компиляции: имя поля обязательно.

type AuditedAccount {
    use Account                      // ОШИБКА: имя поля обязательно
    audit_log []AuditEntry
}

Auto-generated прокси-методы

При use name Type компилятор генерирует прокси для каждого метода Type:

type Account { balance money }
fn Account @balance_pct(of money) -> f64 => @balance / of * 100.0

type AuditedAccount {
    use account Account
    audit_log []AuditEntry
}

// Компилятор генерирует:
// fn AuditedAccount @balance_pct(of money) -> f64 =>
//     @account.balance_pct(of)

ro aa AuditedAccount = ...
aa.balance_pct(1000.0)               // через auto-proxy

Zero-cost — компилятор инлайнит вызов, никакой vtable.

Грамматика согласована с record-полями

use name Type использует тот же порядок «имя тип», что и обычные поля, параметры функций, let-bindings, for-loop:

type Wrapper {
    item       str                   // обычное поле: имя тип
    use iter   HashMapIter[K, V]     // embed: use + имя тип
    extra      int
}

fn deposit(mut acc Account) -> () => ...   // параметр: имя тип
ro user User = ...                           // ro: имя тип
for id u64 in ids { ... }                     // for: имя тип

Везде имя слева, тип справа — одно правило для всего языка.

use — keyword, не имя поля

use — зарезервированное слово (D29 для импортов

  • embed-конструкция здесь). Имя поля use запрещено.

В декларации {use name Type} use — keyword embed-формы; имя поля — alias после use:

type Set[T] {
    use map HashMap[T, ()]           // имя поля — "map"
}

// record-литерал — имя поля
ro s Set[int] = { map: HashMap[int, ()].new() }      // ✓
ro s Set[int] = { use: HashMap[int, ()].new() }       // ✗ use — keyword

// доступ — имя поля
fn Set[T] @len() => @map.len()                          // ✓
fn Set[T] @len() => @use.len()                          // ✗ use — keyword

Override метода

Если тип-обёртка определяет метод с тем же именем — он затмевает делегированный:

type AuditedAccount {
    use account Account
    audit_log []AuditEntry
}

fn AuditedAccount mut @deposit(amount money) {
    @account.deposit(amount)         // явный вызов «родителя» через имя поля
    @audit_log.push(AuditEntry.deposit(amount))
}

mut acc AuditedAccount = ...
acc.deposit(100)                     // вызовет AuditedAccount.deposit

Без @account. в теле — бесконечная рекурсия. Программист обязан явно обращаться к встроенному через имя поля.

Конфликт имён — разные alias-имена

Если два use вводят одинаковые имена методов — программист даёт разные alias-имена и явно решает, через какой:

type Logger effect { log(msg str) -> () }
type Auditor { log(msg str) -> () }

type Combined {
    use console Logger
    use audit Auditor
}

ro c = Combined { ... }
c.log("...")                         // ОШИБКА: ambiguous (оба имеют log)

Решение — явный вызов через имя поля:

fn Combined @log_all(msg str) {
    @console.log(msg)
    @audit.log(msg)
}

ro c = Combined { ... }
c.console.log("...")
c.audit.log("...")

Anonymous embed: use _ Type (без alias-имени)

Альтернатива явному alias — anonymous embed через _:

type Set[T] {
    use _ HashMap[T, ()]
}

ro s = Set[int].new()
s.insert(item, ())          // ✓ через auto-proxy на HashMap.insert
s.contains(item)            // ✓ через auto-proxy
s.len()                     // ✓ через auto-proxy (D117 method-only)

_ — это wildcard: программист сознательно отказывается от имени поля, потому что не нуждается в прямом доступе к встроенному.

Когда использовать

use _ подходит для simple wrappers где:

  • Нет необходимости в прямом доступе к встроенному (@base.method()).
  • Wrapper-методы не вызывают delegated в своём теле.

Set[T] — типичный case: вся семантика приходит из HashMap через auto-proxy + override на одно поведение (insert возвращает bool вместо Option).

Override через own-methods — работает

Программист может определить wrapper-метод того же имени что у embedded:

type Set[T] {
    use _ HashMap[T, ()]
}

// Override @insert — заменяем семантику
fn Set[T] mut @insert(item T) -> bool {
    // Здесь нельзя обратиться к HashMap.insert напрямую — нет имени
    // поля для @<base>.insert(...). Override полностью заменяет
    // логику.
    Log.info("inserting...")
    // ... custom impl, не делегируя к HashMap
}

Resolution через call-site overload resolution (D84) с override-precedence: own-method (определённый напрямую на receiver) wins over delegated (через use).

ro s Set[int] = ...
s.insert(42)
// → resolve_overload("insert", "Set[int]", [int])
// → 2 candidates: Set.@insert (own), HashMap.@insert (delegated)
// → override-precedence: own wins → Set.@insert
// → no ambiguity error
Когда не использовать

Если wrapper-метод нуждается в @base.method() для делегирования — нужен named alias:

// ✓ named alias — есть `@account` для явного call
type AuditedAccount {
    use account Account
    audit_log []AuditEntry
}

fn AuditedAccount mut @deposit(amount money) {
    @account.deposit(amount)        // explicit base call
    @audit_log.push(AuditEntry.deposit(amount))
}

// ✗ anonymous embed не подходит — нет имени для base call
type AuditedAccount {
    use _ Account
    audit_log []AuditEntry
}

fn AuditedAccount mut @deposit(amount money) {
    ???                             // как вызвать Account.deposit?
                                    // НИКАК — anonymous embed не даёт имени
}

Compile error в этом случае возникает естественно на call-site: программист пишет @deposit(amount) (без имени поля), это рекурсивный вызов Self — бесконечная рекурсия, которая, скорее всего, не то что хотел программист.

Lint-warning (не error) предложит: «possible infinite recursion in anonymous embed override; use named alias for base-call».

Что запрещено

Два anonymous embed одного типа — недопустимо:

// ✗ COMPILE ERROR
type Wallet {
    use _ Account
    use _ Account               // ambiguous — два anonymous Account
}

При вызове w.balance resolution даёт два candidates с одинаковым priority — ambiguity unresolvable, потому что нет имени поля для disambig’а. Compile error при declaration.

Решение — named alias:

type Wallet {
    use primary Account
    use backup Account
}
Резолвинг — общий механизм overload

Anonymous embed не вводит специальных правил в компилятор. Resolution использует тот же resolve_overload (D84) с двумя расширениями:

  1. Анонимные embed-методы регистрируются в overload registry с kind = MethodKind::Delegated(via_use_anonymous) — флагом «delegated».
  2. Override-precedence: own-methods (без флага) wins over delegated, при прочих равных (тот же receiver, та же arity, те же arg-types).

Это даёт желаемое поведение «own override затмевает delegated» без отдельной declaration-time проверки collision’а.

Сводка use _ Type vs use name Type
Аспектuse name Typeuse _ Type
Имя поляявное (name)нет
Auto-proxyдада
Override через own-methodдада
Доступ к base через @<name>.method()данет
Multiple embed одного типада (разные имена)нет (compile error)
Construction через literalT { name: ..., ... }через factory T.new(...)
Pattern destructureвозможен через имяunsupported

use для встроенных типов ([]T, tuples)

use поддерживает не только именованные record-типы, но и встроенные конструкции — массивы ([]T), tuples ((A, B)), и т.п. Имя поля обязательно (как и для именованных типов):

// VecBuf через embed []T — все методы массива доступны
type VecBuf[T] {
    use data []T
    extra str
}

ro v = VecBuf[int] { data: [1, 2, 3], extra: "info" }
ro n = v.len            // прокси-метод к data.len ([]T API)
v.push(42)               // прокси-метод к data.push
ro x = v.get(0)         // прокси к data.get

Этим механизмом строятся «именованные обёртки над массивами» с дополнительными полями/методами без переписывания базового API.

API расширяется обычными методами на типе (D35):

fn VecBuf[T] @first_or_default(def T) -> T =>
    @data.get(0).unwrap_or(def)

API самих встроенных типов ([]T.len, []T.push, etc.) — открытый вопрос Q-array-api в open-questions.md, формализуется в Q9 stdlib.

Что это НЕ

Не наследование. AuditedAccount не является Account:

fn process(a Account) -> () => ...

ro aa AuditedAccount = ...
process(aa)                         // ОШИБКА
process(aa.account)                 // ок: извлекли Account-часть через имя поля

Если нужен полиморфизм — структурный protocol:

type HasBalance protocol {
    balance() -> money
}

fn process(a HasBalance) -> () => ...
process(aa)                         // ок: AuditedAccount имеет balance()
                                    //  через delegation auto-proxy

Не множественное наследование. Можно use несколько типов, но конфликты решаются alias’ом или явным обращением. Diamond-problem не возникает — нет иерархии.

Почему

  1. Замена наследования (D1) — embed решает 80% задач композиции без сложности subtyping.
  2. Согласованность с D30 naming. Поля Nova — snake_case (D30). Default-имя по типу (Go-style) дало бы PascalCase-поле — нарушение D30. Явный alias обязывает программиста выбрать snake_case, всё единообразно.
  3. Согласованность с language-wide порядком. use name Type — тот же порядок «имя тип», что параметры, поля, let-bindings, for-loop. Одно правило для всего языка.
  4. AI-friendly. Никакой magic-conversion (HashMaphashmap/ hash_map?), программист явно выбирает имя поля. LLM не догадывается.

Что отвергнуто

  • Default-имя поля по типу (use Account → поле Account, Go-style). Создаёт исключение в D30 (поля PascalCase в одном record-блоке с snake_case полями). Auto-conversion PascalCase → snake_case (HashMaphash_map?) — magic, не очевидное правило.
  • use Type as name (Rust import-style). as зафиксировано для cast в выражениях (D54) и импортов (07-modules.md → D29). В embed — «объявление поля», порядок «имя тип» согласован с остальным языком.
  • Subtyping — противоречит D1; полиморфизм через protocol.
  • Множественное наследование — известный антипаттерн (diamond, fragile base).

Связь

Эволюция

Первая редакция D39 разрешала default-имя = имя типа: use Account → поле Account (PascalCase, Go-style). Это создавало нарушение D30 (поля должны быть snake_case) — в одном record- блоке audit_log (snake) и Account (Pascal) выглядели несогласованно.

Что стало: alias обязателен. use Account без имени — ошибка компиляции, программист пишет use account Account. Default-имя отменено, никакой magic-conversion HashMaphash_map.

Также поменялся синтаксис конфликтов: раньше предлагался «явный вызов через имя типа» (c.Logger.log(...)), теперь только через alias- имя поля (c.console.log(...)). Это согласовано с тем, что все поля имеют alias-имя, и в коде используется оно.

Q-embed-syntax в open-questions всё ещё открыт — это отдельный вопрос про keyword (use vs embed vs голый тип), а не про обязательность имени.

Anonymous embed (2026-05-08): добавлена форма use _ Type для simple wrappers где явное имя поля бессмысленно (use _ HashMap[T, ()] в Set[T]). Программист не выбирает alias из bikeshedding map/inner/ s/value_ явно говорит «безымянный embed, прямой доступ не нужен».

Resolution для anonymous через lazy mechanism — общий call-site overload-resolution (D84) с override-precedence (own-method wins over delegated). Никаких declaration-time проверок collision’ов. Это упрощает компилятор — один путь для named и anonymous.

Trade-off anonymous vs named: anonymous теряет @<name>.method() (прямой base-call) и pattern-destructure через имя поля. Эти возможности трактуются как «escape hatches» — для них программист пишет use name Type явно.

Прецеденты:

  • Go embedded interface{} — anonymous, прямой доступ через имя типа (s.Account). Nova не следует — D30 запрещает PascalCase поля.
  • D alias this — anonymous embed с implicit conversion. Nova не следует — нет subtyping (D1).
  • Rust composition — нет anonymous embed; программист пишет field + manual delegation. Nova use _ экономит boilerplate.

Bootstrap status (2026-05-08)

Реализовано в bootstrap-codegen (Plan 11 Ф.9):

  • ✅ Parser: use name Type (named embed) и use _ Type (anonymous). Anonymous имя поля — синтетическое __embed_<TypeName>.
  • ✅ AST: RecordField.is_embed: bool, RecordField.embed_anonymous: bool.
  • ✅ Codegen auto-proxy generation: embed_fields registry per record-type; для каждого Own-метода embedded-типа эмитится Delegated MethodSig + C-функция, которая делегирует через nova_self->field.
  • ✅ Override-precedence (Own > Delegated) в emit_call и infer paths (Plan 11 Ф.9.3). Strict-match candidates сначала, затем фильтр Own.
  • ✅ Multi-anonymous detection: declaration-time error если ≥2 anonymous embeds одного типа в одном record’е (Plan 11 Ф.9.4).
  • ✅ Lint warning possible infinite recursion: при detect own-method override на anonymous embed — stderr-warning о невозможности base-call’а (Plan 11 Ф.9.5).

Bootstrap-ограничения:

  • C-name mangling по param-types: для overloaded delegated proxy имена с suffix’ом __<types>, как для own overload.
  • Generic embed (use map HashMap[K, V] в generic wrapper) — работает для конкретных type-параметров; full generic monomorphization — открытый вопрос.

D32. Семантика передачи параметров

Status: revised для полей. D36 переписал семантику mut на поле типа. Семантика mut на параметре (этот D32) — без изменений.

⚠️ AMENDED by Plan 118 (D216)&value operator restored для создания typed pointer (*T / *mut T). Это НЕ Rust borrow (нет lifetime checker, нет XOR aliasing); safety обеспечивается через escape analysis + auto-promote (Go-style) + unsafe gating. D32 spirit «no borrow» preserved — *T это explicit unsafe-gated raw pointer с safety net через GC, не lifetime-checked reference. See Plan 118 §«&value operator + escape analysis с auto-promote» и D216 §4.

⚠️ D32 align (Plan 138.5; amend Plan 147 D246) — мутируемость в указательном типе — это L3 pointee (target), из ТИПА позиционно-независимо: *mut T = writable target, *T = ro-pointee (*T ≡ *ro T УНИВЕРСАЛЬНО; pointee-mut НЕ наследуется от binding — flip-scan-draft отклонён). Перепривязываемость самого указателя — это L1 binding (ro/mut), как у любого параметра/переменной по D32/D36, НЕ часть типа и НЕ влияет на pointee. Prefix-модификатор перед * (mut * T) запрещён (D216 §1 E_POINTER_PREFIX_MODIFIER). Семантика передачи параметров (этот D32) не меняется: mut p *T = mutable binding указателя (p reassignable), доступ к target — по pointee-модификатору типа.

Amended Plan 114 D184 (2026-05-31): default immutable binding теперь выражается через ro X = … (immutable) и mut X = … (mutable); let retracted. Семантика default-immutable не меняется — только keyword. См. D184.

Что

Параметры функций передаются by reference в managed heap (как Java/C# для объектов, Go для maps/slices). Без mut — immutable view, с mut — мутации видны вызывающему. Примитивы (int, bool, f64, …) — by value в регистре. Borrow &T отсутствует как концепция.

Правило

Базовое поведение.

type Account { balance money }

// без mut — функция только читает
fn show(acc Account) Io -> () =>
    println("balance: ${acc.balance}")

// с mut — функция меняет, изменения видны вызывающему
fn deposit(mut acc Account, amount money) {
    acc.balance += amount
}

mut my_acc = Account { balance: 100 }
deposit(my_acc, 50)
// my_acc.balance == 150 — мутация видна

Примитивы — by value. Числа, bool, char, u8, () — всегда копия в регистре. С mut x int это локальная переменная функции, изменения не видны вызывающему:

fn weird(mut x int) {
    x = 999                         // меняет локально
}

ro n = 5
weird(n)
// n == 5 — примитив всегда by value

Явная таксономия value vs reference типов (D215 amend, Plan 120; Receiver mut-ABI column added Plan 128 Ф.5, 2026-06-05):

КатегорияПримерыРазмещениеПередачаReceiver mut-ABI (fn T mut @...)
Примитивыint, bool, f64, char, u8, ()register/stackby value (копия)forbiddenE_PRIMITIVE_MUT_METHOD (Plan 128 Ф.3)
Tuples (positional или named)type X(T1, T2), type Vec3(x f64, ...)stackby value (копия)NovaTuple_<X>* pointer (Plan 128 Ф.2) — &v/hoist+&temp call-site
Value recordstype X value { ... }stackby value (копия)NovaValue_<X>* pointer (D228) — &v/hoist+&temp call-site
Recordstype X { ... }managed heapby reference (указатель)Nova_<X>* pointer (unchanged — already by-reference)
Sum typestype X | A | Bmanaged heapby referenceNova_<X>* pointer
Arrays[]Tmanaged heap (handle inline)by referenceNova_<X>* pointer
str (Plan 139)strstack (16-байт value {ptr,len}; буфер на heap/rodata)by value (копия)nova_str value — handle-copy

str reclassified (Plan 139, 2026-06-11): ранее str стоял в одной строке с []T как «managed heap / by reference». Теперь strvalue type, несущий heap-backed буфер: само значение — 16-байт stack-value type str value priv { ptr *u8, len int } с copy-семантикой (как примитив/tuple/value-record), а UTF-8 байты живут в heap (RawMem, GC-tracked) либо rodata (литералы). Поэтому передача str — by-value копия 16-байт handle’а (НЕ pointer-to-heap-object), а buffer разделяется immutably через *u8 (ro-pointee, ≡ *ro u8; D246). См. D26 MAJOR AMEND

  • D228 «str — канонический reference-field value-record».

Bracket choice явно кодирует size/lifetime semantics: () = stack, {} = heap. Tuple value types (D123): zero GC pressure, predictable lifetime — ideal для hot-path math types, FFI returns, iterator state.

Receiver mut-ABI rationale (Plan 128): value categories (tuples, value-records) — by-value normally, но mut @ receiver требует pointer чтобы мутации были видны caller’у. Reference categories (records, sum-types, arrays, strings) уже passed by pointer — no extra ABI flip. Primitives никогда mut-method (Nova-first idiom: int.add returns new value, не mutates self) — Plan 128 Ф.3 E_PRIMITIVE_MUT_METHOD diagnostic enforces. Threading: MethodCallInfo::recv.mutable flag консолидирует решение, propagated через emit_c.rs::prepare_method_recv (Plan 128 Ф.1).

Объекты (record / sum-type / массивы) — managed reference. Указатель в managed heap, отслеживаемый GC. В синтаксисе программист пишет просто o Order — никакого & или *:

type Order { items []Item, total money }

fn add_item(mut order Order, item Item) {
    order.items.push(item)
    order.total += item.price
}

mut my_order = Order { items: [], total: 0 }
add_item(my_order, item1)
// my_order содержит item1 и обновлённый total

&T (borrow в Rust-стиле) не существует в Nova. Escape analysis закрывает большинство perf-кейсов автоматически; для real-time — region { ... } (05-memory.md → D6).

Иммутабельный binding. Без mut параметр нельзя мутировать ни одно поле (кроме помеченных mut per-field — см. D36):

type Account { balance money }

fn read_only(acc Account) {
    acc.balance += 50               // ОШИБКА: acc immutable
    println(acc.balance)            // ок, чтение
}

Семантика mut на параметре и mut на поле взаимодействуют через правила D36 — для записи нужно соответствие на обоих уровнях.

Производительность. Когда нужна максимальная производительность без GC overhead — escape analysis (автоматически) или region { ... } (05-memory.md → D6):

fn process_audio(samples []f32) Realtime -> []f32 =>
    region {
        ro buf = []f32.with_capacity(1024)
        // обработка, без GC pauses
        buf.to_owned()
    }

Никаких &T borrow, никаких lifetime-аннотаций в обычном коде.

Сводка

Форма параметраПередачаМутация видна снаружи
x int (примитив)by valueнет (примитив всегда копия)
mut x intby valueнет (локальная копия)
o Order (объект)managed referenceнет (immutable view)
mut o Ordermanaged referenceда

Почему

  1. Согласовано с managed heap (05-memory.md → D6) — объекты уже в куче, передача указателя дешёвая, копировать бессмысленно.
  2. AI-first видимость в типах (01-philosophy.md → D10) — сигнатура fn deposit(mut acc Account, …) против fn show(acc Account) сразу показывает контракт. Java/C#: всё mutable references по умолчанию, программист помнит наизусть.
  3. mut — единый префикс для разных случаев (let, поле, параметр). Везде «mut = разрешена мутация» — одно понятие, не разные. Согласовано с D36 и 03-syntax.md → D33.

Что отвергнуто

  • By-value для всех типов (Go-стиль). Копирование больших structs дорого, несовместимо с managed heap, программист удивляется «изменил поле — не сохранилось».
  • By-reference с обязательным &mut (Rust-стиль). Слишком много синтаксиса для прикладного кода; в Nova mut уже работает для let и полей.
  • Move-семантика (Rust для не-Copy). Сложна для прикладного программиста, не нужна с GC.
  • Borrow &T. Скопирован в раннем дизайне рефлекторно. Borrow существует в Rust, потому что нет GC; в Nova с GC передача = указатель. Escape analysis + region закрывают остальное. Lifetime checker — research-уровень, цена реализации высокая. Go показывает: без borrow инфраструктура интернета работает.

Связь

  • 02-types.md → D36 — пересмотр семантики mut для полей типа. Параметры — без изменений.
  • 05-memory.md → D6 — managed heap делает by-reference дешёвым; region для real-time.
  • 04-effects.md → D62Mut[T] как generic эффект удалён; мутация через mut поля/параметры (локально) или специализированные state-эффекты (Counter/Cache/IdGen).
  • 01-philosophy.md → D10 — AI-first видимость мутации в типе.
  • 03-syntax.md → D35fn Type mut @method использует тот же mut для self-binding’а.

Эволюция

В D32 поле типа mut field мутировалось только у mut-binding’а. Для аккумуляторов (все поля mutable) приходилось писать mut 18 раз — шум без пользы. D36 переписал это: дефолт mutable у mut-binding’а, readonly для never-mut, mut per-field — только для cache/lazy. Семантика параметров не менялась.


D36. Поля типа: дефолт mutable у mut binding’а, ro для never-mut

Amended Plan 114 D184 (2026-05-31): readonlyro keyword rename в полях. Sample обновлён. Error code E_READONLY_FIELD сохранён как stable API. Семантика per-field freeze не меняется.

Что

Поле без префикса мутируется, если binding mutable. readonly запрещает мутацию даже у mutable binding’а (для id, foreign keys, invariants). mut per-field разрешает мутацию даже у immutable binding’а (для cache, lazy init, atomic counters — аналог C++ mutable). Group-syntax: несколько полей одного типа через запятую.

Правило

Базовое использование.

// Аккумулятор — все поля мутируемые, никаких префиксов не нужно
type RunAcc {
    att_wins int, def_wins int, draws int
    total_rounds int
    total_moon_chance f64
    atk_lost_m int, atk_lost_s int, atk_lost_h int
}

mut acc = RunAcc { att_wins: 0, def_wins: 0, ... }
acc.att_wins += 1                   // ок — binding mut, поле без ro

// Структура с invariant'ами — readonly для read-only полей
type Account {
    ro id u64                 // никогда не меняется
    ro owner str              // тоже
    balance money                    // мутируется у mut binding'а
    closed bool
}

ro acc = Account.new("alice")
acc.balance = 100                   // ОШИБКА: binding не mut

mut acc2 = Account.new("alice")
acc2.balance = 100                  // ок
acc2.id = 999                       // ОШИБКА: id объявлено ro

// Cache/lazy — mut для полей, мутируемых через immutable binding
type LazyConfig {
    path str
    mut cached_value Option[str]    // обновляется при первом read
}

fn LazyConfig @get() -> str {
    if Some(v) = @cached_value { return v }
    ro v = read_file(@path)
    @cached_value = Some(v)         // мутация через @-метод даже у ro-binding
    v
}

Group-syntax. Несколько полей одного типа — через запятую:

type Point { x, y, z f64 }                          // три f64
type Color { r, g, b u8 }                           // три u8
type RunAcc {
    att_wins, def_wins, draws int
    atk_lost_m, atk_lost_s, atk_lost_h int
    atk_lost_pts, def_lost_pts f64
}

С префиксами:

type Account {
    ro id, owner_id u64       // два immutable
    balance money                    // дефолт (mutable у mut-binding)
    mut last_access_time time        // mutable всегда
}

Сводная таблица

Объявление поляMutable у let accMutable у let mut accUse case
field T (без префикса)нетдабольшинство полей
readonly field Tникогданикогдаid, immutable invariants
mut field Tдадаcache, lazy init, atomic counters

Почему

  1. Меньше шума для типичного случая. Аккумулятор с 18 mutable полями писать без префиксов — все поля «обычные», никаких акцентов. Раньше 18 раз mut — визуальный мусор.
  2. Сигнатура показывает только важное. Префикс ставится только на исключения (readonly для invariants, mut для cache). LLM, читая тип, видит: readonly id — «не трогай», обычное поле — «можно мутировать с mut-binding’ом».
  3. Прецедент Rust/Go/C++ — поля без префикса мутируются у mut-binding’а; readonly для never-mut близко к C++ const member.

Что отвергнуто

  • Старая семантика D32 (поле mut мутируется только у mut-binding). Заставляет писать mut перед каждым полем аккумулятора; если все поля mut — выделение теряет смысл.
  • Rust-полное (поле всегда mutable у mut-binding, нет never-mut). Невозможно зафиксировать read-only invariant без приватного поля + getter.
  • type X mut { … } (mut на тип). Один маркер вместо 18 — короче, но при 90% mut + 10% read-only нужен опт-аут per field. Усложнение. Конфликт с современным паттерном «struct + immutable defaults + явная мутация» из Swift/Rust.
  • final (Java-стиль) для never-mut полей. Короче, прецедент Java/Dart/Kotlin, но семантически перегружен (final method, final class, final var). readonly прямо говорит «только для чтения».
  • let для never-mut полей. Короче (3 символа), прецедент Swift, но let уже значит «binding имени со значением» (03-syntax.md → D33). На поле без = необычно, не самообъясняемо. readonly прямо говорит цель.
  • const (C++-стиль). Конфликт с 03-syntax.md → D33 — там const = compile-time константа. Здесь — runtime-immutable. Перегрузка термина, AI-first против — невозможно.

Связь

  • 02-types.md → D32 — пересмотр семантики mut для полей. Передача параметров (fn f(mut o Order)) остаётся: mut на параметре = mutable binding, внутри — мутации полей по правилам D36.
  • 02-types.md → D17 — group-syntax для полей одного типа внутри record.
  • 03-syntax.md → D33let это immutable binding; на поле — аналогия в роли readonly.
  • 03-syntax.md → D35fn Type mut @method даёт mutable-binding self, поля затем по правилам D36.

Эволюция

До D36 поле помечалось mut field T, мутируемое только у mut-binding’а (D32). Для аккумуляторов это требовало 18 раз повторить mut — шум без пользы. D36 инвертировал дефолт: «обычное поле — мутируется у mut-binding’а», readonly — для исключений. Семантика параметров (D32) не менялась. Подробно — в history/evolution.md.

Enforcement (Plan 108.2, 2026-05-30)

Плановое поведение D36let без mut — immutable») de-facto существовало с самого начала, но компилятор не enforce’ил его строго: на binding’е без mut можно было вызывать mut-методы (.push, .append, .insert и т.п.) и присваивать поля (b.field = ...). Plan 108.2 закрывает этот gap:

ro b = Box.new(1)
b.value = 99                  // ✗ E_LOCAL_NOT_MUT
b.push(2)                     // ✗ E_LOCAL_NOT_MUT

mut b2 = Box.new(1)
b2.value = 99                 // ✓
b2.push(2)                    // ✓

Правила (Plan 108.2):

Операцияlet x = ...let mut x = ...consume x = ...
read field
non-mut method
x.field = ...E_LOCAL_NOT_MUT
x.mut_method()E_LOCAL_NOT_MUT
x[i] = ...E_LOCAL_NOT_MUT
rebind x = newval✗ existing E_REBINDn/a (move)

consume X = ... неявно подразумевает mut (как consume param в D176 amend Plan 108.1) — ownership transfer → владелец может мутировать.

Symmetry с D176 (Plan 108.1):

КонтекстDefault = readonly?Opt-in mut
Param✓ (Plan 108.1)mut name T
Local binding✓ (Plan 108.2)let mut x = ...
Loop variable✓ (Plan 108.3)for mut x in iter
Pattern element✓ (Plan 108.3)let (mut a, b) = pair (per-name)
Field✓ (D36 default = mutable у mut-binding)n/a

Loop-var и pattern-binding (Plan 108.3, 2026-05-30)

Loop-var mutability: в for-цикле переменная итерации по умолчанию read-only. Opt-in mut через for mut x in iter:

for x in arrs { x.push(1) }           // ✗ E_LOCAL_NOT_MUT — x immutable
for mut x in arrs { x.push(1) }       // ✓ — x mutable

Pattern-binding per-name mut: при destructure (tuple, record) mut ставится на каждое имя отдельно, parallel Rust pattern semantics:

ro (a, b) = pair                     // оба immutable
ro (mut a, b) = pair                 // a mutable, b immutable
ro (a, mut b) = pair                 // a immutable, b mutable
ro (mut a, mut b) = pair             // оба mutable

Запрет group-mut: let mut (a, b) = ... отвергается parser-level (E_PATTERN_GROUP_MUT) — keyword mut относится к одному имени, не к pattern целиком (consistent с Rust):

mut (a, b) = pair                 // ✗ E_PATTERN_GROUP_MUT

Использование mut внутри pattern — единственно правильная форма.

Указатели: перепривязываемость = binding (D216 cross-ref, Plan 138.5)

Перепривязываемость указательной переменной (можно ли p = other_ptr) регулируется её binding’ом (ro = фиксирован, mut = reassignable) — ровно как у любой другой переменной по правилам D36 выше. Это НЕ часть типа указателя: указательный тип несёт только мутируемость pointee (target) постфиксом (*mut T / *ro T). Никакого type-level «outer pointer-mut» нет.

ro p *mut int = &x         // p фиксирован (binding ro); target writable (pointee mut)
mut q *ro int = &y         // q reassignable (binding mut); target read-only
q = &z                     // ✓ q — mut binding
p = &w                     // ✗ existing E_REBIND — p ro binding

Две роли чисто разделены: ведущий ro/mut перед именем = binding; *mut/*ro после * = pointee. Prefix-модификатор перед * (mut * T) запрещён (D216 §1 E_POINTER_PREFIX_MODIFIER).

Связь


D175. ro field — полный freeze (амендмент D36)

📌 Полная модель мутабельности — D246 (3 оси: L1 binding / L2 content-view / L3 pointee). Этот D-блок описывает только L2: freeze поля через ro field. Для общей картины, дефолтов и error-кодов читай D246.

⚠️ См. D216 V3 §V3.1 (Plan 118.5 V3, 2026-06-04/05) для storage-class-aware rules о ro + mut adjacency: type-form ro mut T запрещён на value-T (E_MUTABILITY_CONFLICT_VALUE_TYPE), binding-form ro x mut T allowed regardless of T storage class (Ф.6 relaxation).

Status: active (Plan 108, 2026-05-28); amended Plan 114 D184 (2026-05-31): readonlyro keyword rename. Error code E_READONLY_FIELD сохранён как stable API. Семантика freeze + транзитивность не меняется.

Что

Уточнение D36: ro field T запрещает и переприсвоение поля, и мутацию содержимого — транзитивно.

ОбъявлениеПереприсвоитьМутировать содержимоеUse case
field Tу mut bindingу mut bindingбольшинство полей
ro field T❌ никогда❌ никогдаid, invariants, frozen state
field ro Tу mut binding❌ никогдаmutable ref, immutable content
mut field T✅ всегдау mut bindingcache, lazy init
mut field ro T✅ всегда❌ никогдаswappable readonly view

Транзитивность: если поле объявлено ro, доступ через него также запрещает мутацию вложенных полей и вызов mut-методов:

type Tags { mut items []str }
type Account {
    ro id u64
    ro tags Tags              // нельзя acc.tags.items.push("x")
}
mut acc = ...
acc.id = 999                  // E_READONLY_FIELD
acc.tags = Tags{}             // E_READONLY_FIELD
acc.tags.items.push("x")      // E_READONLY_FIELD (транзитивно)

Связь

  • D36 — расширяется
  • D176ro как тип-позиция
  • D184 — keyword refresh (readonly → ro rename)

D176. ro T — тип-модификатор

📌 Полная модель мутабельности — D246 (3 оси: L1 binding / L2 content-view / L3 pointee). Этот D-блок описывает только L2: ro T как type-modifier + параметры ro по умолчанию. Для общей картины, дефолтов и error-кодов читай D246.

⚠️ См. D216 V3 §V3.1 (Plan 118.5 V3, 2026-06-04/05) для storage-class-aware rules о ro + mut adjacency: type-form ro mut T запрещён на value-T (E_MUTABILITY_CONFLICT_VALUE_TYPE), binding-form ro x mut T allowed regardless of T storage class (Ф.6 relaxation).

Status: active (Plan 108, 2026-05-28); amended (Plan 108.1, 2026-05-30); amended Plan 114 D184 (2026-05-31): readonlyro keyword rename; return-type defaults + @-inheritance section added. Error codes E_READONLY_CONTENT / E_READONLY_COERCE / E_PARAM_NOT_MUT сохранены как stable API.

Что

ro как prefix-модификатор типа в любой позиции:

fn str @as_bytes() -> ro []u8                 // возвращаемый тип
fn process(data ro []u8) { ... }              // параметр
type Wrapper { field ro []u8 }                // поле
ro view ro []u8 = s.as_bytes()                // binding с ro-content

Двойное ro в последней строке — не tautology: первое ro — binding mutability (нельзя view = …), второе — type-modifier (нельзя view[0] = …). См. D184 для полного дизайна.

Семантика

  • Запрещает вызов mut-методов на значении типа ro T
  • Запрещает запись через индекс: view[i] = xE_READONLY_CONTENT
  • Tro T coercion разрешён автоматически (сужение прав)
  • ro TT запрещён: E_READONLY_COERCE
ro arr []u8 = [1, 2, 3]
ro view ro []u8 = arr                 // ✅ []u8 → ro []u8
mut back []u8 = view                  // ❌ E_READONLY_COERCE
view[0] = 99                          // ❌ E_READONLY_CONTENT
take_ro(arr)                          // ✅ auto-coerce при вызове

Return-type defaults + @-inheritance (Plan 114 D184)

Асимметрия с параметрами — намеренная. Plan 108.1 сделал параметры default ro (callee не может мутировать без opt-in). Для возвращаемых значений правило противоположное: default = mutable (caller получает значение, делает с ним что хочет).

fn make_buf(n int) -> []u8                  // -> mutable []u8 by default
fn read_view(s str) -> ro []u8              // explicit ro в возврате

Обоснование. Param ro default — defensive (callee не имеет права). Return mut default — permissive (caller владеет результатом). Это совпадает с Rust/Swift/Kotlin: fn foo() -> Vec<T> отдаёт owned mutable; чтобы вернуть read-only view — explicit -> ro T.

Особый случай: pointer-returns (D216 amend, Plan 138.5; D246). Pointee-mut возвращаемого указателя — это L3, из ТИПА (*T=ro / *mut T=mut), а НЕ return-mut-default: дефолтная mut-семантика возврата относится только к L1 binding результата у caller’а (reassign), не к pointee. У указательного типа нет «outer pointer-mut» (retracted, D216 §V2.6). -> *ro TE_REDUNDANT_POINTER_RO (используй -> *T).

Return typePointee (L3, из типа)L1 binding результата
-> *T (≡ -> *ro T)read-onlybind-site (ro p/mut p)
-> *mut Twritablebind-site (ro p/mut p)
-> *unsafe Tpossibly-uninit (FFI)bind-site (ro p/mut p)
fn alloc_cell() -> *mut int                 // writable target
fn peek_head(buf []u8) -> *u8               // *u8 = ro target (D246; *ro u8 → E_REDUNDANT_POINTER_RO)
ro p *mut int = alloc_cell()                // binding ro: p фиксирован; target writable
mut q *u8 = peek_head(buf)                  // binding mut: q reassignable; target ro

Реассайнабельность результата (p = other_ptr) задаётся bind-site (ro/mut, D36), не возвращаемым типом. Это устраняет прежнюю двусмысленность «двух mut» в return-позиции (pointee-mut в типе vs reassignability указателя).

Ревизия (Plan 184, 2026-07-06). Наследование мутируемости ниже — исходная D326-R7/R8 формулировка через оракул/decay. По ревизии D326 (Plan 184, Р7) -> @ имеет конкретный тип: -> ref Self у стекового (value) типа, -> Self у кучевого — см. Ревизия D326 (Plan 184). Таблица ниже сохранена как исходная семантическая модель (поведение цепочек эквивалентно).

Особый случай: -> @ (self-return для fluent chains, D181). Возвращаемая @ наследует мутируемость от receiver:

ReceiverReturn -> @Пример
fn T @method() -> @ (implicit/ro receiver)ro @ (read-only self-view)ro r = obj.method()
fn T mut @method() -> @mut @ (mutable self-view)obj.push(1).push(2) — fluent mut chain
fn T consume @method() -> @parse error E_CONSUME_RECEIVER_RETURNS_ATconsume already moves ownership; return @ создал бы dangling-view

Почему такое правило для @. @ это тот же экземпляр что receiver — его access-mutability не может быть строже, чем у receiver’а:

  • ro @ receiver → @ уже view; return view’а — view; consistent.
  • mut @ receiver → @ mutable handle; return mutable handle; consistent — именно так работают fluent chains xs.push(1).push(2).
  • consume @ receiver → ownership уже перемещён внутрь method’а; вернуть @ = alias на consumed value = use-after-move; запрещено. Если нужно fluent после consume — возвращайте новый owned (fn T consume @transform() -> T), не @.

Что НЕ меняется в return-семантике:

  • Любой явный return type (-> T, -> []u8, -> ro T, -> mut T, -> *mut T, -> *ro T) — берётся как написан (для указателей модификатор всегда относится к pointee — postfix; prefix перед * = E_POINTER_PREFIX_MODIFIER, D216 §1).
  • -> Self (статический Self-тип, D182) — owned-by-caller; не наследует receiver-мут.
  • -> @ без receiver-method context (free fn) → E_AT_RETURN_OUTSIDE_METHOD.

Escape hatch

Снять readonly в Nova-коде нельзя. Кому нужен mutable доступ — явно копирует: let copy []u8 = view.to_owned(). Если необходим обход через FFI, это делается в external fn на C-стороне.

Рантайм

Zero overhead — readonly только compile-time проверка, не влияет на codegen. ABI readonly []u8 = NovaArray_uint8_t* (идентично []u8).

Применение

str.as_bytes() -> readonly []u8 — zero-copy view в UTF-8 буфер строки без memcpy. UTF-8 invariant защищён: записать в буфер нельзя.

Параметры функций (Plan 108.1)

Default = read-only. Параметр без явного модификатора эквивалентен readonly param T — callee может только читать, не вызывать mut-методы, не присваивать через индекс.

fn f(b []int) { b.push(1) }       // ✗ E_PARAM_NOT_MUT — нет `mut`
fn f(mut b []int) { b.push(1) }   // ✓ explicit mut
fn f(ro b []int) { ... }    // ✓ synonym default (для документации)
fn f(consume b []int) { ... }     // ✓ owned move — mut по умолчанию

Правила сочетания модификаторов:

СочетаниеРезультат
param Treadonly (default)
mut param Tmutable view
readonly param Treadonly (явно) — synonym default
consume param Towned move, mut by default
mut consume param T✗ parser-level E_PARAM_MOD_CONFLICT
consume mut param T✗ parser-level E_PARAM_MOD_CONFLICT
mut readonly param T✗ parser-level E_PARAM_MOD_CONFLICT
readonly mut param T✗ parser-level E_PARAM_MOD_CONFLICT

Coercion (передача аргумента в параметр).

После Plan 108.1 T в позиции параметра уже readonly по умолчанию. Поэтому readonly T → T (param) — это readonly → readonly (тождество), а единственное реальное нарушение это readonly → mut:

caller-type → callee-param-typeOK?
T → T (param default readonly)✓ (caller-T → callee-readonly = сужение)
T → readonly T (param explicit readonly)✓ (synonym default)
T → mut T (param explicit mut)✓ (caller разрешает mut доступ)
readonly T → T (param default readonly)✓ — оба readonly
readonly T → readonly T
readonly T → mut T (param explicit mut)E_READONLY_COERCE — единственное нарушение
mut T → T (param default readonly)✓ (сужение, mutable можно показать как readonly)
mut T → mut T

Closure-параметры — аналогично функциональным.

Закрытые маркеры (Plan 108.1)

  • [M-108-readonly-mut-method-check] — вызов mut-метода на параметре без mut теперь даёт E_PARAM_NOT_MUT.
  • [M-108-readonly-coerce-on-param] — closed дефакто: старая формулировка маркера предполагала, что param T mutable; после Plan 108.1 param T уже readonly, поэтому coerce readonly T → T (param) — это readonly → readonly (no violation). Единственный остаточный case — readonly T → mut T (param explicit) — отдельный followup [M-108.1-readonly-to-explicit-mut-coerce] (узкий нишевый сценарий, не блокирует).

Связь

  • D36readonly field предшественник
  • D175 — readonly field enforcement
  • D144 — слайсы arr[a..b]
  • D157 — view-borrow для consume-типов (Plan 108.1 распространяет принцип на не-consume)
  • Plan 108 — реализация D175/D176
  • Plan 108.1 — params readonly by default + закрытие 2 markers

D66. Self universal — ссылка на обобщающий тип в методах, effects, protocols

Что

Self — keyword-ссылка на «тот тип, к которому принадлежит метод», валиден в любом контексте, ассоциированном с конкретным типом:

  • Внутри protocol { ... }Self = тип, удовлетворяющий контракту (как сейчас по D42 (REVISED)/D53).
  • Внутри effect { ... }Self = тип эффекта (Db, Net, …).
  • В static-методе fn T.name(...)SelfT.
  • В instance-методе fn T @method(...) / fn T mut @method(...)SelfT.
  • Для generic-типа T[A, B]SelfT[A, B] (с теми же параметрами).

Правило

type Box[T] {
    value T
}

// static method — Self вместо повтора Box[T]
fn Box[T].of(v T) -> Self =>
    Self { value: v }

// instance method — Self в return type для builder pattern
fn Box[T] @with_value(v T) -> Self =>
    Self { value: v }

// protocol — для type-safe equality
type Hash protocol {
    hash() -> u64
    eq(other Self) -> bool       // Self = тот тип, что реализует
}

// effect — для transactional/recursive handler-операций
type Db effect {
    query(q Sql) -> []DbRow
    nested(body fn() Self -> ()) -> ()  // Self = Db
}

// sum-type method
type Tree enum Leaf | Node(int, Tree, Tree)
fn Tree @clone() -> Self => match @ {
    Leaf          => Leaf
    Node(v, l, r) => Node(v, l.clone(), r.clone())
}

Семантика

  • Self подставляется в момент использования метода/протокола, не в момент объявления.
  • Для concrete-типа T (record, sum, newtype) SelfT.
  • Для generic T[A, B] SelfT[A, B] (наследует ту же специализацию).
  • Внутри protocol-объявления Self остаётся «late-bound» — конкретный тип определяется при удовлетворении.

Static-методы знают свой тип через Self

Static-метод в Nova связан с типом на уровне компилятора — не «просто функция в namespace» (как Go), а полноценный метод типа с доступом к Self. Это влияет на три use-case’а:

1. Self в return type (DRY-форма)

type Box[T] {
    value T
}

fn Box[T].of(v T) -> Self =>            // Self ≡ Box[T]
    Self { value: v }                    // generic-параметры наследуются

// Эквивалент без Self (verbose):
fn Box[T].of(v T) -> Box[T] =>
    Box[T] { value: v }

Без Self программист пишет Box[T] дважды; с Self — один раз (в receiver). Compiler знает что Self ≡ Box[T] потому что метод объявлен на Box[T].

2. Self в expression position — вызов другого статического

type Account { balance money }

fn Account.new() -> Self =>
    Self.with_initial(0)                 // другой static-метод того же типа

fn Account.with_initial(amount money) -> Self =>
    Self { balance: amount }              // Self { ... } literal

Self.with_initial(0) резолвится compiler’ом в Account.with_initial(0). То же для Self { ... } — это Account { ... } literal.

Это canonical pattern для default-конструктор → parameterized-конструктор:

fn HashMap[K, V].new() -> Self =>
    Self.with_capacity(16)              // default делегирует к parameterized

fn HashMap[K, V].with_capacity(n int) -> Self =>
    Self { buckets: new_buckets(n), count: 0, ... }

Refactoring-safe: переименование HashMap → Map меняет только заголовки методов, не тела. Все Self авто-резолвятся.

3. Self в полиморфных контекстах (через protocol bound)

type FromStr protocol {
    from_str(s str) -> Self              // late-bound
}

fn parse[T FromStr](s str) -> T => T.from_str(s)
//                                  ^^^^^^^^^^^^
// На каждой инстанциации parse[int](...) / parse[Money](...)
// T резолвится в конкретный тип. Compiler через monomorphization
// знает Self ≡ T для каждого вызова.

Это post-monomorphization — для каждого parse[X] генерится свой код где X.from_str(s) это конкретный static-метод X. Static-метод знает что он на X в каждом инстанциации.

Что это не значит

  • Нет runtime-рефлексии. Static-метод не имеет cls-параметра (как Python @classmethod), не может узнать своё имя как строку, не может сравнить два типа в runtime. Знание чисто compile-time.
  • Self в expression — синтаксическая подстановка. Compiler заменяет Self на имя receiver-типа в момент codegen’а; runtime никаких type-id не передаёт.
  • Нет inheritance / virtual dispatch. Self ≠ виртуальный reference на subclass. У Nova нет наследования (D1) — только generic-bound через protocol.

Прецеденты

  • Rust: impl Foo { fn make() -> Self { Self::new(2) } } — активно используется. Self доступен везде в impl-блоке.
  • Swift: static func make() -> Self, Self.method(), Self() initializer.
  • Kotlin: companion object с methods, доступ к this::class.
  • C#: static метод имеет доступ к containing type.

Не следуем:

  • Go: static-методов нет, только receiver-функции. Static в Nova = named function в namespace типа.
  • Python @staticmethod: не получает cls, не знает свой тип. @classmethod получает cls runtime — мы делаем то же на compile-time через Self.

Где запрещено

  • На top-level (вне типа/protocol/effect) — compile error «Self не в type-контексте».
  • Внутри лямбды, объявленной не в method-теле — compile error.
  • В сигнатуре свободной (top-level) функции fn name(...) — compile error.

Почему

  1. DRY. До D66 в каждом методе fn Box[T].of(v T) -> Box[T] имя типа повторялось 2-3 раза. Refactoring (BoxContainer) ломал копипастой. Self устраняет повтор.
  2. Generic-параметры наследуются автоматически. fn Box[T].of с Self корректно подставит Box[T], не Box без параметров — программисту не нужно указывать generics в методе.
  3. AI-friendly. LLM генерирует Self для return type без знания точного имени — снижает количество ошибок при автогенерации builder-методов.
  4. Унификация. До D66 Self работало только в protocol — это создавало впечатление, что для других контекстов нужен другой механизм. На самом деле семантика одинаковая — «текущий тип». Один keyword для всех контекстов = D40 «один способ».
  5. Прецеденты. Swift, Rust используют Self универсально (везде где есть impl T { ... } блок). Nova следует тому же паттерну.

Что отвергнуто

  • @type — конструкция вида @type для ссылки на свой тип в методе. Отвергнуто: @ уже занят под self-field, добавление второго смысла создаёт двусмысленность.
  • Имя типа повторять везде. Отвергнуто: см. п.1 «DRY».
  • Self только в generic-методах (как в Java <T extends Self>). Отвергнуто: семантика остаётся та же, ограничение лишнее.

Связь

Эволюция

В D42 Self был валиден только внутри protocol { ... } блока — это ограничение унаследовано от первой редакции, где Self вводился именно для type-safe equality (Hash.eq(other Self)). На practice’е Self оказался полезен также в:

  • static-методах для DRY возврата того же типа,
  • instance-методах для builder pattern’а,
  • effect-методах для self-referential операций (transactions),
  • sum-вариантах для @clone/@with_* методов.

D66 убирает ограничение: Self валиден везде, где есть type-контекст.

AMEND (Plan «self-nested-generic», 2026-06-15) — Self как вложенный generic type-arg

Правило (расширение «generic-параметры наследуются», п.2 «Почему»). Self валиден не только как самостоятельный return/param-тип, но и как type-argument внутри другого Named generic — в любой глубине вложения, в return- и в param-позиции:

type MapIter[I, T, U] value { src I, f fn(T) -> U }

// Self как вложенный type-arg — receiver-mono наследуется в позицию I
fn MapIter[I, T, U] @zmap(g fn(U) -> V) -> MapIter[Self, U, V] => ...
//                                          ^^^^^^^^^^^^^^^^^^^^ Self ≡ MapIter[I,T,U]
fn MapIter[I, T, U] @zfilter(p fn(U) -> bool) -> FilterIter[Self, U] => ...

// и в param-позиции (симметрично)
fn FilterIter[I, T] @combine(other FilterIter[Self, T]) -> ... => ...

Семантически MapIter[Self, U, V]MapIter[MapIter[I,T,U], U, V]Self подставляется на тот же mono-инстанс receiver’а, что и в return--> Self. Это устраняет повтор имени receiver-типа в adapter-on-adapter цепочках (zero-cost ленивые итераторы, std/collections/vec_iter_zc.nv).

Где было сломано (codegen, до фикса). Call-site return-inference биндил Self value-aware (без trailing-*), но эмиссия метода (register_mono_method_instance fwd-decl + emit_monomorphized_method body) строила current_type_subst только из receiver-generics — без записи для Self. Вложенный Self промахивался мимо early-lookup current_type_subst["Self"] и падал в общий "Self"-арм type_ref_to_c, который даёт POINTER-форму (Nova_X*) → лишний trailing *_p в mangle → C-имя mono на call-site ≠ имя в fwd-decl/body → forward-decl≠def. Это тот же класс рассинхрона, что D182 закрыл для голого -> Self.

Фикс. В обоих emit-местах после установки current_receiver_type биндить Selfvalue_aware_generic_c_type("Nova_{recv}*") в current_type_subst (через .entry().or_insert() — no-clobber), guard recv_type.contains("____") (только mono-инстансы). value_aware_* оставляет heap-generic / non-value формы без изменений, поэтому top-level heap-generic -> Self (где Self — owned-by-caller heap-ref, не value-record) не затронут. Подробности маркера — docs/plans/backlog-followups.md[M-138.2-self-in-param].

Известное ограничение (НЕ покрыто фиксом). Self, равный single-param generic-ресиверу (VecIter[T]), использованный как type-arg внутри multi-param адаптера (MapIter[Self, T, U]), по-прежнему мис-резолвит receiver (chain-ENTRY методы на VecIter[T]). Фикс покрывает Self, где ресивер — тот же adapter-family, который ре-вкладывается. См. ОСТАЁТСЯ в маркере.


D72. Generic bounds через [T Protocol] — protocol как тип

Что

Параметр-тип в generic-списке может иметь bound — protocol-тип, которому должны удовлетворять конкретизации параметра. Синтаксис — единое правило «name type» без двоеточия:

[T Hash]
[K Hash, V]
[K, T From[K]]

Без bound — [T] — параметр без ограничений (структурное соответствие проверяется при использовании, как было до D72).

Bound — это protocol-тип (D53) ИЛИ type-set (D310, Plan 172.3: именованное множество конкретных типов, [T SignedInt]). Тот же Hash стоит и в позиции типа значения (fn f(x Hash) — existential), и в позиции bound’а (fn f[T Hash](x T) — universal). Одна сущность — тип со структурным контрактом — в трёх позициях:

  1. Тип значения: fn f(x Hash) -> u64
  2. Bound: fn f[T Hash](x T) -> u64
  3. Эффект (между ) и ->): fn f(...) Db -> () (D18)

Различение по позиции, не по keyword’у. Закрывает Q-bounds.

Правило

Синтаксис

generic-params = '[' generic-param { ',' generic-param } ']'
generic-param  = identifier [ type ]

generic-param следует общему правилу Nova «name type», как параметры функции (x int), поля record (id u64), let-bindings (let x int = 5), for-loops (for x int in xs), embed (use w HashMapIter[K, V]).

fn sort[T](xs []T, less fn(T, T) -> bool) -> []T
//      ^ без bound — структурное соответствие при использовании

fn dedup[T Hash](xs []T) -> []T
//       ^^^^^^^^^^^ T должен реализовывать Hash

type HashMap[K Hash, V] {
//          ^^^^^^^^^^^ K — Hash, V — без bound
    ...
}

fn fold[T, Acc](xs Iter[T], init Acc, f fn(Acc, T) -> Acc) -> Acc
//      ^^^^^^ ни T, ни Acc bound'а не имеют

fn[T] ReceiverType @method префикс (Plan 101.1 partial, 2026-05-24)

Generic-параметры также декларируются через fn[T] префикс — для receiver’ов без carrier-brackets ([]T, bare T, tuple). Параллель D145. Bound syntax из D72 применим в этой позиции — fn[T Hash] []T @method.

fn[T] []T @map[U](f fn(T) -> U) -> []U          // T через fn[T] (нет carrier)
fn[T Hash] []T @dedup() -> []T              // bound в fn[T] (D72 + Plan 101.2)

Plan 101.1 status (2026-05-24): parser + базовый codegen работают для []int element type. Codegen mono-per-T для других element-types ([]str, []User) — marker [M-fn-prefix-int-only-mono] ✅ RESOLVED (Plan 101 Group I, vec_map_int_str fix).

Порядок объявления параметров

Generic-параметры читаются слева направо. Имя в bound’е должно быть уже объявлено — либо ранее в том же списке [...], либо в type-контексте (top-level type, окружающий тип для метода).

fn func[K, T From[K]](v K) -> T => T.from(v)
//      ^                          ^
//      объявлен раньше            используется в bound

fn func[T From[K], K](v K) -> T          // ОШИБКА: K используется до объявления
fn func[T Test[K]](v K) -> T             // ОШИБКА: K не объявлен вообще

Это согласовано с правилом параметров функции: fn f(x int, y T) — имена читаются слева направо, ранее объявленные доступны позже. Forward-references запрещены ради простоты type-checker’а и читаемости (LLM не нужно держать «отложенный контекст»).

Bound — это protocol-тип

Hash, From[T], Into[T] и т.д. — обычные protocol-типы (D53):

type Hash protocol {
    hash() -> u64
    eq(other Self) -> bool
}

// Bound в generic-объявлении:
fn map[K Hash, V](m HashMap[K, V]) -> ...

// Тот же Hash в позиции типа значения (existential):
fn dump_one(x Hash) -> u64 => x.hash()

Existential vs universal — различение по позиции:

ФормаСемантикаDispatchАналог Rust
fn f(x Hash)existential («какое-то значение типа Hash»)dynamic (vtable)fn f(x: &dyn Hash)
fn f[T Hash](x T)universal («для любого T : Hash»)static (mono)fn f<T: Hash>(x: T)

В обоих случаях Hashтип. Различие только в позиции: внутри [...] — generic-параметр и его bound; в обычной позиции — тип значения. Прецедент — Go (interface { M() } используется и как тип, и как constraint).

Multiple bounds — анонимный protocol

Если параметру нужно несколько bounds, объединяются в анонимный protocol-тип через protocol { ... } (D53):

fn min[T protocol { @lt(other Self) -> bool, @eq(other Self) -> bool }](xs []T) -> T

Долго, но без специального синтаксиса для intersection bound’ов. Если паттерн повторяется — выносится в именованный protocol:

type Ord protocol {
    @lt(other Self) -> bool
    @eq(other Self) -> bool
}

fn min[T Ord](xs []T) -> T => ...

Сокращённая форма [T A & B] — открытый вопрос (Q-multi-bound).

Self в bounds

Self (D66) валиден внутри protocol/method-контекста. В bound’е generic-параметра свободной функции — запрещён:

fn merge[T Eq](a T, b T) -> T => ...           // ok
fn merge[T Eq Self](a T, b T) -> T => ...      // ОШИБКА: Self вне type-контекста

В method-контексте (fn Box[T] @method[U Self]) — открытый вопрос, пока запрещено.

Bound как effect — запрещено

Bound — это protocol-тип. Effect — тоже protocol, но используется в позиции эффекта (между ) и ->). Использовать Db как bound запрещено — это ошибка категории (D62: effectprotocol для generic-bound):

fn run[T Db](handler T) -> ()         // ОШИБКА: Db — effect, не bound-protocol

Если нужно «принимает Effect[Db]» — пишется явно: fn run(h Effect[Db]).

Bound на типах (не функциях)

Тот же синтаксис в declaration типов:

type HashMap[K Hash, V] {
    ro buckets []Slot[K, V]
}

type Set[T Hash] {
    ro inner HashMap[T, ()]
}

type Sorted[T Ord] | Empty | Node(T, Sorted[T], Sorted[T])

Bound применяется при инстанциировании: HashMap[User, int] требует чтобы User реализовывал Hash.

Проверка bound’а — структурная (D53)

Bound удовлетворён, если у конкретного типа есть методы из protocol’а (структурно). Никаких явных impl/declaration не нужно:

type User { id u64 }

fn User @hash() -> u64 => @id
fn User @eq(other Self) -> bool => @id == other.id

// User автоматически удовлетворяет Hash, потому что есть @hash и @eq
ro m HashMap[User, str] = HashMap.new()       // ok

Если методов нет — compile error на месте использования (HashMap[User, str] с инстанциированием), не на declaration type User.

Почему

  1. Закрывает Q-bounds. Generic-инфраструктура (HashMap, From/Into, collect, FromIter) требует bound’ов. Без них либо безопасности нет, либо ошибки откладываются до места использования с непонятным сообщением.

  2. Согласовано с правилом «name type». Параметр функции x int, поле id u64, generic-параметр T Hash — единая грамматика. Двоеточие в Nova зарезервировано под key-value, использовать его для bound — нарушение D17.

  3. Protocol = тип (D53). Hash уже тип в Nova. Использовать его как bound — естественное расширение, не новый механизм. Existential (x Hash) и universal ([T Hash]) различаются позицией.

  4. Прецедент Go. Go 1.18+: interface { M() } используется и как тип значения, и как constraint в generics. Один синтаксис, два контекста, проверено в большом продакшне.

  5. Структурная проверка вместо impl. Nova не имеет orphan rule (D42/D53) — нет impl Trait for Type блоков. Bound удовлетворяется автоматически, как и existential. Это последовательно.

  6. AI-friendly. LLM пишет [T Hash] без специальных keyword’ов (where, impl, :). Грамматика читается как естественный язык: «параметр T типа Hash».

Что отвергнуто

  • [T: Hash] (Rust/Scala/Kotlin/Swift). Конфликтует с D17 — двоеточие в Nova только для key-value (record-литералы, dict). Делать исключение для generic-list — нарушение единства.
  • [T is Hash]. is уже занят под runtime type-check (D54). Третий смысл (compile-time bound) перегружает keyword.
  • where-clauses после сигнатуры (C# / Haskell-style). Многословно, раздваивает информацию между списком параметров и where-блоком. Bound у параметра — единое место.
  • [T impl Hash] (Swift some-style). Нестандартно, impl не используется в Nova ни для чего ещё.
  • Bounds через контракты (requires implements(T, Hash)). Контракты (D24) проверяются SMT на значениях, bound — type-checker’ом на типах. Разные уровни.
  • Sealed/closed bound’ы («только эти типы»). Открытый вопрос, не входит в D72.

Цена

  1. Type-checker сложнее. Проверка structural-bound при мономорфизации — дополнительная работа.
  2. Сообщения об ошибках. «User не реализует Hash: missing method @hash» — нужно генерировать понятные диагностики.
  3. Множественные bounds через анонимный protocol — многословно для частых пар (Hash + Eq). Сокращённая форма откладывается.

Связь

Открытые вопросы

  • Множественные bounds: сокращённая форма ([T Hash & Eq], [T (Hash, Eq)]) — Q-multi-bound.
  • Bound на эффект-параметре: можно ли [E SomeProtocolOnEffects] — связано с Q-effect-params.
  • Self в bound в method-контексте — отложено.
  • Conditional methods через where-clause (fn Vec[T] @sort() where T Ord) — отложено вместе с conditional impls.

Эволюция

В MVP bounds были отвергнуты (D42 «Открытые вопросы», history/rejected.md: «[T: Bound] отвергнут в MVP»). Пользовались структурным соответствием при использовании — ошибка вылезала на месте вызова, не объявления. С ростом stdlib (HashMap, From/Into, collect) стало ясно что без bound’ов нельзя: generic-функции не могут опираться на методы T без явного контракта.

Q-bounds зафиксировал синтаксис заранее ([T Bound] без двоеточия). D72 принимает это как формальное решение, расширяет до полной семантики (structural check, existential-vs-universal через позицию, multiple bounds через анонимный protocol).

⚠️ D72 AMENDED by Plan 108.4 (2026-06-09) — When a type participates as bound [T Iterable[U]] or any protocol bound, the type-checker now verifies receiver_mut consistency for all protocol methods at the use-site. A type that declares @method() (ro) does not satisfy a protocol requiring mut @method(). Errors: E_PROTO_IMPL_RO_FOR_MUT, E_PROTO_IMPL_MUT_FOR_RO, E_PROTO_IMPL_MUT_FOR_CONSUME, E_PROTO_IMPL_CONSUME_FOR_MUT. See D209.


D110. Ghost state — spec-only bindings

Статус: Принято (Plan 33.3 Ф.10, реализовано в AST и type-checker)

Решение

ghost let / ghost var объявляют spec-only переменные — они видимы в requires/ensures/invariant и других ghost-statements, но никогда не эмитируются в C-код (ни в debug, ни в release).

fn fill(xs mut []int) -> ()
    ensures forall i in 0..xs.len() : xs[i] == 0
{
    ghost ro n = xs.len()      // spec-only: виден в invariant
    for i in 0..xs.len()
        invariant forall j in 0..i : xs[j] == 0
    {
        xs[i] = 0
    }
}

Правила видимости ghost:

  • Ghost-binding виден: в других ghost-stmts; в requires/ensures/invariant; в теле #pure функций.
  • Использование ghost-binding в non-ghost emit-code → compile error.
  • Codegen: ghost-stmts и ghost-bindings полностью стираются (паритет с Dafny).

Следствие: invariants, использующие ghost-данные, в debug не проверяются runtime — только через SMT. Это задокументированное design-решение.

Обоснование

Ghost state позволяет писать контракты в терминах вспомогательных концепций (счётчики, логические флаги, промежуточные значения), не засоряя runtime-код. Паритет с Dafny ghost var, F* Ghost.

Реализация

  • compiler-codegen/src/ast/mod.rs — поле is_ghost: bool в LetDecl; enum-вариант Stmt::Ghost для ghost-блоков (Ф.10 scope).
  • compiler-codegen/src/types/mod.rs — type-check: reject ghost-ref в non-ghost context.
  • compiler-codegen/src/codegen/emit_c.rs — ghost-stmts стираются (пустой emit).
  • compiler-codegen/src/verify/encode.rs — ghost-vars участвуют в SMT-encoding как обычные fresh-vars.

D122. Hybrid dispatch для bound-K methods

Status: active (spec). Реализация — Plan 56.

Что

Generic-bound method call’ы dispatch’аются по hybrid strategy:

  1. Mono path — для concrete K на call-site (e.g. HashMap[str, int]): compiler instantiates generic method с substituted K, V. Bound methods (key.hash(), key.eq()) resolve в direct call к concrete K methods (nova_str_hash(key)). Zero-cost — паритет Rust impl<T: Hash>.

  2. Erased path — для generic body emit (когда compiler не может / не должен mono’d, e.g. recursive generic call на Self type внутри generic method body): generic body эмитится как stub (call’еры полагаются на mono path для concrete instances). Bootstrap не использует vtable — простая stub-fallback стратегия.

  3. Vtable path (future, Plan 56 Ф.2 full): для truly erased contexts (cross-crate generic, dyn Trait-like), bound methods dispatch’аются через vtable structure. Vtable runtime defined в compiler-codegen/nova_rt/vtables.h (Plan 56 Ф.1).

Bootstrap status (2026-05-16)

  • ✅ Mono path для bound methods works (HashMap.clone() пример).
  • ✅ Vtable runtime infrastructure готова (NovaVtable_Hash, NovaVtable_Compare, NovaVtable_Display + 4 primitive K vtables: int/bool/u8/f64/str).
  • ✅ Erased emit для bound-method-using generic methods stub’ится (emit_generic_method_erased — wider stub condition включает Array fields с generic inner type).
  • ⏸️ Vtable codegen integration (truly erased dispatch) — deferred до cross-crate compilation (Plan 03).

Acceptance criteria для bound methods

Type-checker (Plan 15 / D72) enforces:

  • Bound должны быть protocol-типами (D53).
  • Concrete K на call-site должен implement все bound methods (D72 enforcement).

Codegen (Plan 56 Ф.1 + Ф.2):

  • Protocol-методы могут иметь эффекты (Fail / Io / Db) — напр. type TryFrom[T, E] protocol { try_from(t T) Fail[E] -> Self }. Под mono-dispatch (текущий bootstrap) эффект protocol-метода пробрасывается как у обычной effectful-функции — без спец-кейса. (D122 amended 2026-05-20: снят запрет Plan 56 Ф.2.7 на pure-only bound methods.) Ограничение: true-vtable dispatch (Plan 03) не пробрасывает effect-handlers через vtable-ABI — в truly-erased контексте effectful-protocol bounds обязаны mono-dispatch’иться; чистая vtable-диспетчеризация effectful-метода — будущая работа Plan 03.
  • Self type в bound method signature substitutes runtime receiver type.

Связь

  • D72 — generic bounds enforcement (type-checker side).
  • D53 — protocol-типы.
  • D24 — vtable lookups compatible с proven-contracts skip (no-op).

D123. Tuple monomorphization

Status: active (spec, 2026-05-17 EOD+2 — Phase 7 production polish applied). Реализация — Plan 59 (6 phases + Phase 7).

Plan 59.1 amend (2026-06-01): general generic anonymous tuple monomorphization — fn[T] f() -> (A[T], B[T]) — закрывает gap в Plan 59 Ф.7.5. Schema _NovaTuple_<arity>_<L1>_<T1>_..._<LN>_<TN> (length-prefixed) теперь применяется не только к Result, но к любому generic anonymous tuple в return position. См. D354 для full spec.

Plan 148 Ф.4 amend (2026-06-12, [M-codegen-unify-tuple-repr]): typed representation унифицирован, legacy all-int путь сжат до on-demand. Три изменения:

  1. Blanket pre-decl retired. Раньше каждый C-файл получал typedef … _NovaTuple1; … _NovaTuple8; (8 all-nova_int структур) в преамбуле — вне зависимости от использования. Теперь legacy _NovaTupleN эмитится on demand только для арностей, которые erased-generic fallback реально запрашивает (на практике — только arity 2, от erased HashMap[K,V]/Set (K, V) пар). Concrete tuples всегда используют typed mono’d путь. Регистрируется через register_legacy_tuple(n), splice в /*__LEGACY_TUPLE_TYPEDEFS__*/.
  2. Self-describing field decode. Field access (t.0, t.0.1) и type inference больше не зависят исключительно от per-Ident side-table tuple_element_types — элемент-тип декодируется напрямую из имени mono’d структуры в obj_ty через parse_mono_tuple_elements. Это чинит field-read на fn-параметрах, call-result кортежах и вложенных t.0.1 цепочках (раньше collapse’или в nova_int fallback → дроп второго .0 / неверный тип). Закрыло 5 pre-existing CC-FAIL в Plan 59 (f2/f10/f13/f15/f16).
  3. Arity diagnostic code. Destructure-arity-mismatch diagnostic (3 codegen-сайта: let / for-pattern / match-variant inner Tuple) несёт код [E_TUPLE_DESTRUCTURE_ARITY] (раньше — bare message).

Что

Tuple типы (T1, T2, ..., TN) monomorphized — для каждой concrete комбинации element types compiler generate’ит отдельную struct с real field types (не nova_int slot erasure).

Mangle scheme (Plan 59 Phase 5, length-prefixed)

Itanium ABI / Rust v0 mangle analog — unambiguous для любой глубины nesting:

_NovaTuple_<arity>_<L1>_<T1>_<L2>_<T2>_..._<LN>_<TN>

где <Ln> — десятичная byte length sanitized name <Tn>. Parser читает length, берёт точно столько chars, переходит к следующему. Самоописательный, никаких ambiguity даже для tuple-of-tuples.

Примеры:

  • (int, int)_NovaTuple_2_8_nova_int_8_nova_int
  • (str, int)_NovaTuple_2_8_nova_str_8_nova_int
  • ((int, int), int) outer → _NovaTuple_2_34__NovaTuple_2_8_nova_int_8_nova_int_8_nova_int (L1=34 — точно столько chars как T1)

Distinguishable от legacy _NovaTupleN (e.g. _NovaTuple2) по _ после NovaTuple.

Правило

ro p (str, int) = ("a", 1)
//                   ^^^^^^^ generates _NovaTuple_2_8_nova_str_8_nova_int
//                   { nova_str f0; nova_int f1; }

for (k, v) in hashmap {
//   ^^^^^^^^^^^^^^^^ implicit Iter (D58) + tuple destructure через
//                    mono'd struct (k: nova_str, v: nova_int direct
//                    field access)
}

match some_kv {
    Some((k, v)) => ...
//       ^^^^^^^ Plan 59 Phase 6 — variant payload mono'd tuple,
//               heterogeneous types работают (str + int)
}

Параллель: Rust (T1, T2) mono’d per concrete instantiation, zero-cost. C++ std::tuple<T1, T2> template — то же. Nova bootstrap паритет (vs предыдущий int-slot erasure breaking struct elements).

Decision tree

При codegen tuple type:

  1. All elements concrete (resolved via current_type_subst, no type-param placeholders) → use mono’d _NovaTuple_<arity>_<L1>_<T1>... struct. Zero erasure cost.
  2. Erased context (one or more element types unresolved) → fallback legacy _NovaTupleN (nova_int slot) с runtime cast. Bootstrap-compat для truly generic contexts. Plan 148 Ф.4: этот typedef эмитится on demand (per requested arity, idempotent-guarded #ifndef) — не blanket _NovaTuple1..8. На практике достигается только arity 2 (erased HashMap/Set (K, V) пары).

Constraints

  • Tuple field access (p.0, p.1) — direct C field access (.f0, .f1) на mono’d struct.
  • Tuple destructure (let (a, b) = ...) — direct binding, no cast.
  • Nested tuples (((int, str), bool)) — recursive mono’d (inner tuple registered first; length-prefix encoding handles нестинг любой глубины — validated 5-level tests).
  • Tuple в variant payload (Option[(K, V)], Result[(K, V), E]) — match destructure Some((k, v)) / Ok((k, v)) propagate mono’d element types через registry (Phase 6 + Plan 63 Fix F+).
  • Tuple in collections (HashMap[K, V] returns Option[(K, V)] from iter().next()) — mono’d через template + subst at iter mono pass.

Diagnostics (Plan 59 Phase 7.1)

  • Arity mismatch ([E_TUPLE_DESTRUCTURE_ARITY], code added Plan 148 Ф.4) — destructure pattern имеющий разное число элементов чем actual tuple, reject’ится Nova-level clear error (file:line + hint) до C-emit’а. Покрывает 3 sites: let-destructure, for-pattern, match-variant inner Tuple. Раньше упирался в нечитаемый “no member named ‘fN’” C error.

Lint warnings (Plan 59 Phase 7.3)

  • Large tuple warning — mono’d tuple с >5 элементов OR >128 bytes estimated size emit’ит W-warning suggesting record type (clarity + stable ABI). Estimate sums known element sizes: pointers=8, nova_str=16, scalars per type. Threshold выбран эмпирически — typical cache line 64 bytes, 2× giving safe margin.

Stdlib idiom (Plan 59 Phase 7.2)

После Plan 63 Fix E (mono’d tuple iter в generic method body работает) — stdlib коллекции используют идиоматичный for (k, v) in self / for (k, v) in @iter() вместо direct-field workaround’ов. HashMap.@clone/@merge_from/@filter все idiomatic.

Record literal для tuple struct полей ({ end, idx: 0 } для {end int, idx int} где end — variable в scope) — shorthand обязателен при совпадении имени поля с источником ({ end: end } запрещено, см. D52 §2).

Почему

  1. Correctness — struct value types (nova_str, user records) не fit’ят в nova_int slot. Без mono (str, int) was broken.
  2. Zero-cost — direct field access, no intptr_t cast, no heap alloc для tuple value.
  3. Параллель Rust/C++ — индустриальный standard для tuples.
  4. Diagnostics quality — Plan 36 R7 bar (file:line + hint).
  5. Self-describing mangle — length-prefix encoding debug’абельно, ABI-tools (debuggers) могут decode.

Что отвергнуто (deferred с rationale)

  • Universal tuple type (all elements any) — type-erased, runtime type-tag overhead, breaks AOT zero-cost goal.
  • Named tuple fields ((x: T1, y: T2)) — ОТКЛОНЕНО окончательно (Plan 59 Ф.7.4, 2026-05-21). Именованные поля кортежа почти идентичны record’у; заводить два почти одинаковых синтаксиса для одной семантики в Nova нет причин. Нужен агрегат с именованными полями — это record (type T { x int, y int }). Tuple остаётся позиционным (.0/.1). ✅ REOPENED (Plan 120, 2026-05-31). Отклонение основывалось на неполном reasoning: tuple и record имеют fundamentally different allocation semantics (D32: stack vs heap). Named tuple fields не эквивалентны record — они value types с именованным доступом, zero GC overhead. See D215.
  • Tuple subtyping ((int, str) <: (any, any)) — ОТКЛОНЕНО окончательно (Plan 59 Ф.7.6, 2026-05-21). Реализация дорогая (требует variance-системы covariance/contravariance в type-checker, которой в Nova нет — язык не использует structural typing); под фичу не нашлось ни одной реальной задачи. Не реализуется.
  • Full mono’d Result (NovaRes_<T>_<E> typedefs analogous Option) — Plan 63 Fix F+ targeted boxed-pointer tracking покрывает все observable cases без full sum-type mono refactor. Defer до Plan 65. ✅ РЕАЛИЗОВАНО (Plan 59 Ф.7.5 increment 2, 2026-05-21): Result полностью мономорфизирован — per-(T,E) C-тип NovaRes_<ok>_<err>* (аналог NovaOpt_<T>). Legacy единый Nova_Result устранён; targeted Fix F+ boxed-tracking больше не нужен — Ok/Err payload типизируется реальным T/E inline.

Связь

  • D27 — tuple литерал синтаксис.
  • D52 §2 — field shorthand mandatory.
  • [D58 Iter protocol] — for (k, v) in coll использует mono’d tuple через implicit .iter().
  • Plan 48 — monomorphization infrastructure (mono pass).
  • Plan 63 — Fix E (mono’d iter в generic method body) + Fix F/F+ (Result Ok payload tuple unboxing).

D215. Named tuple fields + value/reference allocation contract

Status: active (spec, 2026-05-31). Реализация — Plan 120. Extends D52 tuple form; amends D32 с explicit value/reference taxonomy; amends D123 с named field codegen. Withdraws Plan 59 Ф.7.4 rejection (corrected reasoning).

Что

Extension D52 tuple form: поля кортежа могут быть именованными (parallel с positional). Named tuple — stack-allocated value type с именованным доступом (.x, .y), identical performance к positional tuple (D123).

Allocation contract — bracket choice кодирует semantics:

СинтаксисТипРазмещениеСемантика
type X(T1, T2)positional tuplestackvalue (копия при передаче)
type X(name1 T1, name2 T2)named tuplestackvalue (копия при передаче)
type X { name T }recordheap (GC)reference (pointer при передаче)

Синтаксис

// Named tuple declarations
type Point(x f64, y f64)
type Vec3(x f64, y f64, z f64)
type Color(r u8, g u8, b u8, a u8)
type Generic[T](value T, count int)

// Construction — named args
ro v = Vec3(x: 1.0, y: 2.0, z: 3.0)
ro c = Color(r: 255, g: 0, b: 128, a: 255)

// Field access — by name
v.x     // 1.0
v.y     // 2.0

// Methods — identical to records
fn Vec3 @add(other Vec3) -> Vec3 =>
    Vec3(x: @x + other.x, y: @y + other.y, z: @z + other.z)

Грамматика (extends D52)

tuple_fields  ::= positional_list | named_list
positional_list ::= type ("," type)*
named_list      ::= named_field ("," named_field)*
named_field     ::= IDENT type

// Mixed positional+named в одном декларации — forbidden (E_TUPLE_MIXED_FIELDS)

Parser disambiguation: если после ( стоит IDENT type-start → named tuple; иначе → positional. Один lookahead, никакого backtracking.

Type errors

СитуацияОшибка
.0 на named tupleE_TUPLE_POSITIONAL_ACCESS_ON_NAMED
.name на positional tuple typeE_TUPLE_NAMED_ACCESS_ON_POSITIONAL
mixed named+positional в declarationE_TUPLE_MIXED_FIELDS

Codegen (extends D123)

Named tuple → C named struct (not anonymous):

typedef struct NovaTuple_Vec3 NovaTuple_Vec3;
struct NovaTuple_Vec3 {
    double x;
    double y;
    double z;
};

Symbol prefix NovaTuple_<Name> distinguishes от positional _NovaTuple_<arity>_... и от records Nova_<Name>*. Named tuple = value type (no pointer in C signature); всегда stack-allocated.

Method receiver passing (Plan 128 Ф.2, 2026-06-05)

Named tuple @-методы получают receiver через ABI-conditional форму:

Receiver modeParameter typeCall-site form
fn NamedTuple @method(...) (ro receiver)NovaTuple_<Name> (by value, copy)f(v) — copy semantics
fn NamedTuple mut @method(...) (mut receiver)NovaTuple_<Name>* (pointer)f(&v) для identifier; для rvalue — hoist в temp + &temp

Mutation visibility: mut @method мутирует caller’s slot через pointer; copies стека не делается. Это symmetric с D228 value-record mut @method (NovaValue_X*).

Call-site emission (emit_c.rs::prepare_method_recv):

  • Identifier receiver: emit &local_var directly. Var must be mut binding (D33 + D215 amend «binding-level mutability»); ro binding + mut @method = E_BINDING_NOT_MUT (caught в type-checker).
  • Lvalue projection receivers: b.v.method(), arr[i].method(), @field.method(), multi-level a.b.c.method() — when each base в projection chain is an lvalue (Ident/SelfAccess/Member-of-lvalue/ Index-of-lvalue), emit &(b->v) / &(arr->data[i]) / &(nova_self->field) directly. Mutation flows к original slot, no temp hoist. Plan 128.1 Ф.1 implementation.
  • Rvalue receiver: hoist в NovaTuple_<Name> __tmp_recv_<id> = expr; и pass &__tmp_recv_<id>. Мутации в temp видны только внутри expression chain — corresponds к D32 «mutate-by-copy для rvalue» spirit.
  • Chained .method() на trailing receiver — recurse same rule.

Symmetric правило с records (D32): records передаются Nova_<Name>* unconditionally; named tuples — by-value кроме mut @ receiver path, который промоутится к NovaTuple_<Name>*. Это codifies «no pointer in C signature кроме mut receiver» refinement над D215 original wording.

Wired через recv.mutable flag (MethodCallInfo::recv) — Plan 128 Ф.1 thread’нул flag через emit_c.rs helpers; Ф.2 consume’нул для NamedTuple codegen branch. См. также §D228 Ф.4 «Method receiver passing» — параллельный pointer pattern для value-records.

ПаттернТипПочему
Hot-path math (Vec3, Matrix, Quaternion)named tuplezero GC, predictable
Pixel formats (Color, Pixel)named tuplesmall, copy-cheap
FFI multi-value returnsnamed tuplestack return, fit в registers
Iterator statenamed tuplelocal-lifetime, no heap
Domain entities (User, Order, Account)recordidentity, sharing
Large aggregatesrecordcopy expensive

Почему: Plan 59 Ф.7.4 rejection был неполным

Plan 59 rejection (2026-05-21) argued: «named tuples ≈ records, нет причин иметь два похожих синтаксиса». Reasoning flaw: tuple и record имеют fundamentally different allocation semantics:

  • Tuple → stack, zero GC pressure, copy semantics
  • Record → heap (D32, D123), GC-tracked, reference semantics

Разные allocation characteristics = разные performance + lifetime characteristics = different syntactic forms justified. Plan 120 (2026-05-31) reopens с corrected reasoning.

Out of scope (followups)

  • [M-120-positional-fallback]: allow .0/.1 на named tuples (Rust-style fallback). V1 = Option B: forbid (Q120 decision).
  • [M-120-named-positional-mix]: mixed positional+named в одном decl.
  • [M-120-stack-arrays]: stack-allocated fixed-size arrays [3]Vec3.

Связь

  • D32 — value vs reference taxonomy (amended)
  • D52 — tuple syntax (amended + named form)
  • D123 — positional tuple codegen (named form extends)
  • Plan 120 — реализация

D354. Generic anonymous tuple monomorphization

Renumber 2026-07-03: блок был D216 — номер коллидировал с D216 Typed pointer family; anon-tuple-mono перенумерован в D354 (решение владельца; приёмник из резерва Plan 174 §6, прецедент D109/D110/D111). Status: active (spec, 2026-06-01). Реализация — Plan 59.1. Extends D123 с generic-aware substitution path. Closes gap в Plan 59 Ф.7.5 (Result mono landed; general generic anonymous tuple оставался под V1 erasure fallback до 2026-06-01).

Что

Generic anonymous tuple в return position функции с type-параметрами — fn[T] f() -> (A[T], B[T]) или fn[T, U] g() -> (T, U) — мономорфизируется per instantiation. Element types конкретизируются через current_type_subst, получают C-name через type_ref_to_c, регистрируются через register_mono_tuple, и emit’ятся как unique typedef’ы per element combination.

fn[T] dup(v T) -> (T, T) => (v, v)

test {
    ro (a, b) = dup[int](42)           // → _NovaTuple_2_8_nova_int_8_nova_int
    ro (s, t) = dup[str]("hi")          // → _NovaTuple_2_8_nova_str_8_nova_str
    // Два разных typedef'а в одной compilation unit, каждый с real types.
}

fn[T, U] pair(a T, b U) -> (T, U) => (a, b)
ro (i, s) = pair[int, str](7, "x")      // → _NovaTuple_2_8_nova_int_8_nova_str

Правило

Mangling schema

Length-prefixed mangling: _NovaTuple_<arity>_<L1>_<T1>_<L2>_<T2>...

  • <arity> — количество элементов
  • <Li> — длина sanitized C-name i-го элемента
  • <Ti> — sanitized C-name (точки/звёздочки заменены на _, pointer suffix retained как _p)

Примеры:

  • (int, int)_NovaTuple_2_8_nova_int_8_nova_int
  • (str, bool)_NovaTuple_2_8_nova_str_9_nova_bool
  • (ChanWriter[T], ChanReader[T]) после mono[T=int] → _NovaTuple_2_18_Nova_ChanWriter_p_18_Nova_ChanReader_p

Length prefix обязателен — без него parsing неоднозначен для nested tuples (tuple of tuples) и user types с underscores в имени.

Per-instantiation deduplication

mono_tuple_instances (HashSet) хранит set element-type vectors. register_mono_tuple([elem1, elem2, ...]) идемпотентен — повторные вызовы с same elements не emit’ят дубликаты typedef’а.

Finalize emit (typedef ordering)

В module finalize все registered tuples emit’ятся с topological sort’ом (внутренний tuple раньше outer’а):

  • Tuple A depends on tuple B если B’s mangled name появляется как element type в A → emit B first.
  • Cycle detection: impossible для value-tuple struct’ов; если обнаружен — emit anyway без depth-check (no hang).

Codegen в emit_call

  1. Call-site f[T1, T2, ...](args) lookups mono_fn_decls[f.name].
  2. resolve_mono_type_args строит type_subst из turbofish + arg-inference.
  3. compute_mono_name(base, subst) → unique mono fn name.
  4. register_mono_instance enqueue в worklist.
  5. Args emit без erasure boxing (concrete types).
  6. Variable type at call site = mono’d tuple via type_ref_to_c(return_type) с активным current_type_subst.

Body emission (emit_monomorphized_fn)

current_type_subst устанавливается перед body emit; type_ref_to_c(TypeRef::Tuple) возвращает mono’d name; tuple-литералы emit’ятся как value-struct compound literals (no heap-box).

Destructure

emit_tuple_destructure использует actual mono’d return type для temp variable (получает через infer_expr_c_type). Element types парсятся через parse_mono_tuple_elements (length-prefixed inverse). Arity mismatch → Nova-level diagnostic с pattern/scrutinee arity (Plan 59 Ф.7.1).

Value semantics, no heap-box

Mono’d tuple — value type (C struct), passed by value, returned by value. No heap allocation для anonymous tuple wrapper’а (Result mono Ф.7.5 parity). Element pointers (если elements — pointer types) остаются heap-allocated независимо.

Edge cases (covered V1)

  • Multi-instantiation: same fn → разные T → unique typedef’ы per instantiation.
  • Multi-param tuple: fn[T, U] pair(a T, b U) -> (T, U).
  • Nested generic tuple: fn[T] nest() -> (T, (T, T)) — recursive subst через register_tuples_in_typeref.
  • Tuple-in-Option: fn[T] f() -> Option[(T, T)] — Option mono + inner tuple mono.
  • Tuple-in-Result: уже работает (Plan 59 Ф.7.5).
  • Non-generic tuple: fn make() -> (int, str) — без T, substitution тривиален, мономорфизация single instance.
  • Arity 3+: fn[T] triple() -> (T, T, T) — generic mangling параметризован по arity.
  • Positional field access: pair.0 / pair.1 после mono.

Edge cases (V1 limitations — followups)

  • 🟡 [M-59.1-array-of-mono-tuple]: fn[T] f() -> []((T, T)) — array-of-mono-tuple. Body falls back на NovaArray_nova_int* (boxed pointer storage, как records/sums в bootstrap), call-site infer выдаёт NovaArray_<mono_tuple>* (typedef которого не существует). Mismatch → CC-FAIL. Fix: align infer с body fallback ИЛИ packed NovaArray_<mono_tuple> typedef + element retrieval cast. Низкий приоритет — workaround через explicit Nova_ record type.

    Update 2026-06-08 (Plan 91 Ф.1, [M-91.1-composite-array-storage]): Для pointer-элементов (record/sum Nova_<Name>*) этот класс РЕШЁН выбранным здесь подходом «align infer с body fallback» + завершением side-channel array_element_types. Контракт хранения composite-массивов: элементы record/sum — boxed-pointer в nova_int слоте (NovaArray_nova_int*), а реальный elem C-тип проносится через array_element_types (var→Nova_<X>*) и проставляется на результат generic map/filter (register_array_result_elem), так что [i], for-in и .get() кастят слот назад к указателю. Tuple-by-value ([]((T,T)) — value-struct >8 байт) НЕ покрыт erasure-подходом и остаётся открытым как [M-91.1-value-struct-array-elem] (тот же класс, что и []Option[T]). Подробности — plan-91 Ф.1 closure.

  • 🟡 [M-59.1-tuple-field-oob-nova-diag]: pair.5 на arity-2 tuple leaks к C-level error «no member named ‘f5’». Should be Nova-level diagnostic в type-checker. Cosmetic — error caught, но not optimal UX.

  • 🟡 [M-59.1-channel-new-cleanup]: Channel.new продолжает использовать 3 ad-hoc special-case branches в emit_c.rs:18435/20159/22694 + Nova_ChannelPair runtime struct. После Plan 59.1 generic mono path способен обработать Channel.new если добавить Nova-side declaration fn[T] Channel[T].new(cap int) -> (ChanWriter[T], ChanReader[T]) через external fn (Plan 115 Pattern B). Cleanup deferred to отдельный план (runtime + std API surgery). Spec D91 signature остаётся буквальной реальностью после cleanup’а; до того — implementation detail, aspirational notation.

Backward compatibility

  • Все existing non-generic anonymous tuple usages ((int, str) returns, destructures) — продолжают работать unchanged. Plan 59 Ф.7.5 mono’d path был активен только для Result; теперь активен для всех anonymous tuples.
  • Plan 59 Ф.7 legacy _NovaTuple<arity> schema (без underscore — nova_int placeholders) технически остаётся как fallback в type_ref_to_c для cases где type_subst не доступен (degenerate case — non-generic context с unresolved tuple). На практике не наблюдается после fix.

Cross-refs

  • D52 — anonymous tuple type syntax.
  • D123 — positional tuple codegen baseline (Plan 59 Ф.7.5).
  • D215 — named tuple types (Plan 120 D215, ortho к D354).
  • D91 — Channel.new signature now буквально implementable; cleanup ad-hoc paths — [M-59.1-channel-new-cleanup].
  • D141 — bulk slice-операции (orthogonal к tuple mono).
  • Plan 59.1 — implementation plan.
  • Plan 59 Ф.7.5 — Result mono prior art.

D119. Method-level type parameters в generic methods

Status: active (spec, 2026-05-17). Реализация — Plan 48 Ф.9. Закрывает частично Q-generic-receiver-method (для user-defined generic типов; built-in []T остаётся V2).

Что

Generic methods могут иметь собственные type-параметры, независимые от type-параметров receiver’а. Метод Wrapper[T] @map[U](f fn(T) -> U) -> Wrapper[U] имеет два уровня generics: receiver-level T и method-level U. Compiler через monomorphization создаёт отдельную mono-instance для каждой комбинации (T, U).

Правило

export type Wrapper[T] { inner T }

// Receiver-level T, method-level U.
export fn Wrapper[T] @map[U](f fn(T) -> U) -> Wrapper[U] {
    Wrapper[U].of(f(@inner))
}

// Call-site:
ro w = Wrapper[int].of(5)
ro a = w.map(|x| x * 2)              // (T=int, U=int) instance
ro s = w.map(|x| str.from(x))        // (T=int, U=str) instance
ro s2 = s.map(|x| x + "!")           // (T=str, U=str) instance

Compiler emits 3 distinct mono’d methods:

  • Wrapper____nova_int_method_map____nova_int
  • Wrapper____nova_int_method_map____nova_str
  • Wrapper____nova_str_method_map____nova_str

Параллель: Rust impl<T> Wrapper<T> { fn map<U>(self, f: impl Fn(T) -> U) -> Wrapper<U> } — то же monomorphization per (T, U). C++ template<T> class Wrapper { template<U> Wrapper<U> map(...) } — то же. Nova bootstrap теперь паритет.

Decision tree

При codegen call’а obj.method[U](args):

  1. Receiver T — резолвится из obj C-type (Nova_Wrapper____<T>* → T = <T>). Существующая infrastructure (D72 + Plan 48 Ф.0).
  2. Method-level U — резолвится через bidirectional inference из call args:
    • Non-closure args: infer_expr_c_type(arg) → bind U через infer_type_param_binding.
    • Closure-typed args (|x| body): pre-populate closure-param types с T-substituted C-types, recurse в body для return type → bind U.
  3. Method C-name включает обa уровней: <TypeBase>____<T>_method_<m>____<U>.

Constraints

  • Method-level generics declared в @method[U] — synтаксис как у free-function generics (fn name[U](...)); receiver [T] parsed отдельно.

  • Closure args drive inference — без explicit turbofish (obj.map::<int>(...)), U inferенtsя из closure return type. Если нет args или U не появляется в parameter types, compiler emit’ит clean diagnostic:

    cannot infer method-level type argument `U` for generic method
    `<TypeBase>____<T>.<method>` (only in return type — provide arg
    whose type binds it); provide a closure/arg whose type fixes `U`
    

    (См. реализацию в compiler-codegen/src/codegen/emit_c.rs path 5b.) Раньше unresolved method-level params silently dropped → Nova_U_p placeholder leak в emitted C → undefined-struct CC-FAIL.

  • Per-(T, U) instances — каждая уникальная пара получает свою mono’d function. Worklist enrollment предотвращает дубликаты.

  • Return type substitutionWrapper[U] в return type корректно resolves в Nova_Wrapper____<U>* (не Nova_U_p placeholder).

Почему

  1. Параллель Rust/C++ — индустриальный standard для generic methods.
  2. Zero-cost — каждая mono-instance это direct call, инлайнится, no void* boxing/cast.
  3. Composabilityw.map(f).map(g).filter(p) typical functional chain работает без erasure penalty.
  4. Был CC-FAIL — без method-param mono let m = w.map(|x| str.from(x)) эмиттил Nova_Wrapper____Nova_U_p* m = ... (undefined struct, C-compile fail).

Что отвергнуто

  • Method-level type-erasure (void* U) — для bootstrap проще, но ломает первый-class closures + breaks struct-typed U (record-value не fit’ит в void* без heap-box). Equivalent проблема к Plan 48 receiver-level erasure отвергнутой в V1.
  • Explicit-only U (obj.map::<U>(...) обязателен) — verbose, не matches industry standard. Inference из args — first-class.

Связь

  • D72 — generic bounds на type params; method-level U могут иметь bounds.
  • D122 — hybrid dispatch для protocol-bound type params; orthogonal к method-level vs receiver-level.
  • D123 — tuple mono пользуется тем же worklist infrastructure.
  • Plan 48 Ф.9 — реализация (emit_call path 5b + infer_mono_method_ret_with_args).
  • Plan 63 Fix C — remaining edge case Plan 63, закрытый этим D119.
  • Q-generic-receiver-method

D367. Удаление byte: каноническое имя — u8

Решение: Тип byte удалён из языка. Единственное каноническое имя для 8-битного беззнакового целого — u8. Срез байт пишется []u8.

Мотивация. Наличие двух равнозначных имён (byte и u8) порождает неоднозначность в коде, документации и стандартной библиотеке: один и тот же тип можно было написать двумя способами, что усложняло чтение и тулинг.

Миграция. Все вхождения byte как типа заменяются на u8:

  • []byte[]u8
  • параметры/поля типа byteu8
  • в примитивном перечислении: byte убирается из списка

Исключения (не меняются):

  • Тег шаблонных строк bytes`...` (D48) — это имя функции, не тип.
  • Слово «byte» в английском/русском тексте комментариев (единицы памяти).

Реализовано: Plan 69 — 2026-05-22. byte удалён из builtin-типов компилятора (lexer/parser/type-checker/ codegen); все вхождения в spec/ / std/ / nova_tests/ мигрированы на u8. C-typedef nova_byte (= uint8_t) сохранён как внутреннее имя codegen — не пользовательская поверхность.


D368. Strict type propagation в codegen — no silent nova_int fallback

Решение. Codegen pass (compiler-codegen/src/codegen/) обязан производить deterministic, явный C-type для каждого Nova expression и type reference. Silent fallback к nova_int при failure type resolution — запрещён. Любой site где type_ref_to_c(...) возвращает Err без strict-error должен производить compile-time diagnostic [E7001] и failing build, а не подставлять placeholder type.

Мотивация. До Plan 70 паттерн type_ref_to_c(&ty).unwrap_or_else(|_| "nova_int".into()) встречался в codegen в 117 местах (audit 2026-05-18). Семантика: «если type translation failed → silently emit nova_int (long long) и продолжай». Результат — silent miscompilation:

  • pointer cast to int → garbage address как число
  • bool/char печатается как code-point (Plan 67 закрыл частный случай)
  • record/sum-type memcpy с неправильным sizeof
  • float → int truncation

Программа «работает», но возвращает мусор. Debug невозможен — компилятор ничего не сигналит.

Industry baseline. Rust / Swift / Go (post-1.18) — все производят compile error на любом unresolved type в codegen. Nova до Plan 70 был хуже всех baseline (silent default). D368 закрывает регрессию.

Категории erasure (Cat A/B/C/D). Audit разделил 154 fallback sites на четыре категории:

CatPatternСемантикаДействие
A1type_ref_to_c(...).unwrap_or_else(|_| "nova_int")Silent fallback при resolution failureStrict error
A2_ => "nova_int" wildcard без комментарияWildcard fallback unknown typeStrict error или Cat D classification
B_ => "nova_int", // erased T (commented)Pre-mono generic body emit — type-param ещё unresolvedDocumented intentional erasure
CWithResultCategory::IntLike => "nova_int"Categorical mapping для int-family aliasesLegit, keep
DDispatch wildcard на известный receiverKnown type, unknown method (type-checker уже rejected)Legit, keep

Только Cat A даёт silent miscompilation. После Plan 70 closure все Cat A sites мигрированы к strict error path. Cat B/C/D documented в docs/codegen-erasure-sites.md.

Strict-error architecture. Две helper-функции в emit_c.rs:

  1. err_no_int_fallback(context, cause) → String — для functions возвращающих Result<_, String>. Используется с ? propagation:

    let ty = self.type_ref_to_c(&p.ty).map_err(|e|
        self.err_no_int_fallback("parameter `x`", &e)
    )?;
    
  2. record_strict_error(context, cause) → "nova_int" — для cascade-blocked sites (functions whose signature нельзя менять без massive caller-chain refactor: infer_expr_c_type (135 callers), register_mono_instance, etc). Pushes E7001 в strict_errors: RefCell<Vec<String>> field; finalization gate в emit_module проверяет non-empty и failit codegen pass с aggregated error message.

Оба helper’а используют unified diagnostic format [E7001] (range E7001-E7099 reserved для Plan 70 family). Plan 36 R7 structured diagnostic compatibility.

Production-grade default. Strict mode — always on, без opt-in env var. ANY silent fallback = build failure (Rust/Swift baseline). Это breaking change для user code который полагался на silent int default (R20 в Plan 70). Bootstrap convention: clean break с machine-applicable migration suggestions.

Diagnostic format (E7001).

[E7001] cannot infer C type for parameter `x`: <cause>. Silent
fallback к `nova_int` produced wrong runtime output для non-int
types (record/string/float/bool). Add explicit type annotation,
ensure generic is monomorphized, или register type в external_registry.
См. Plan 70 ([M-no-silent-nova-int-fallback]).

Internal lint guard (CI). scripts/lint-no-silent-int-fallback.sh greps compiler-codegen/src/ против baseline counts из docs/codegen-erasure-sites.md. Bumping baseline требует:

  1. Inline comment с rationale «почему erasure безопасна»
  2. Entry в docs/codegen-erasure-sites.md со file:line + причина
  3. PR review

CI gate fails если added counts превышают baseline без updates.

Acceptance criteria (Plan 70 closure).

  • Helper infra err_no_int_fallback + record_strict_error (Ф.1 / Ф.B0)
  • Cat A1/A2 migration: 90 → 8 (only Cat B holdovers remain)
  • Cat B documentation: 10 sites listed в codegen-erasure-sites.md
  • Internal lint guard scripts/lint-no-silent-int-fallback.sh
  • Spec D368 (этот блок)
  • 796+ PASS / 0 FAIL nova test (0 regressions vs baseline 761)

Реализовано: Plan 70 — sessions 1+2 (2026-05-18); 90+ Cat A1 sites migrated, infrastructure complete, lint guard active.

Связь:

  • D118 — typed Fail[E] codegen (similar precision-by-construction pattern)
  • Plan 67 — println overload fix (sibling: один из видимых частных случаев)
  • Plan 48 — monomorphization (упрощает Cat B → меньше erasure)
  • Plan 36 — diagnostic infra (R7 structured format)
  • docs/codegen-erasure-sites.md — Cat B/D inventory

D128. char distinct from int в codegen mono’d generics

AMEND (Plan 152.8, 2026-06-16). nova_char переведён с int64_t на uint32_t. Codepoints fit in 21 bits (U+0000..U+10FFFF); uint32_t — естественный unsigned type (как Rust char ABI). ABI cost минимален: nova_char-поля в structs layout’ятся как 4-byte, Box-pointer смещается с 8 на 8 (4-byte char + 4-byte padding → align 8). GC layout обновлён: char_size = (4, 4) (было (8, 8)). Char-literal суффикс: U вместо LL; is_typed_int_c_ty / emit_typed_int_literal включают nova_char.

Решение (исходное, Plan 70.3). Тип char имеет собственный C-typedef nova_char (alias над int64_tuint32_t D128 AMEND), distinct C identifier). Generic mono mangling использует nova_char separately от nova_int, поэтому Option[char] и Option[int] производят разные C-типы NovaOpt_nova_char vs NovaOpt_nova_int — структурно неотличимы становятся различимы.

Мотивация. До Plan 70.3 оба char и int map’ились в один C-тип nova_int. Результат — silent type collapse в generic mono:

  • Option[char] и Option[int] mangle в идентичный NovaOpt_nova_int
  • []char и []int обе → NovaArray_nova_int*
  • Map[char, V] и Map[int, V] → одинаковая mangled name

Concrete observed bug (триггер плана): str @char_at(idx int) -> Option[int] declared, returned Option[char] де-факто. Type-checker не ловил поскольку C-level structural compatibility. ~50 callers использовали char literals (Some('/'), unwrap_or('.')) в slot expecting Option[int] — silent collapse через NovaOpt_nova_int. User pre-fix 2026-05-19 corrected signature, Plan 70.3 — архитектурное предотвращение.

Industry baseline. Rust/Swift char is distinct primitive (char vs u32); Go has rune distinct from int32. Nova до Plan 70.3 был unusual в C-level collapse. D128 закрывает регрессию.

Implementation (Plan 70.3 Ф.1-Ф.2).

  1. Typedef: typedef int64_t nova_char;typedef uint32_t nova_char; (D128 AMEND Plan 152.8) в compiler-codegen/nova_rt/nova_rt.h.
  2. Codegen mapping: type_ref_to_c "char" => "nova_char" (was "nova_int") в emit_c.rs и external_registry.rs (двойная sync).
  3. Array element: []char → NovaArray_nova_char* (separate instantiation parallel NovaArray_nova_int*).
  4. Option element: NovaOpt_nova_char typedef + constructors + nova_opt_eq_nova_char helper.
  5. CharLit emission: 'x' → ((nova_char)<codepoint>U) (was LL; D128 AMEND Plan 152.8 — uint32_t requires U-suffix).
  6. infer_expr_c_type: CharLit => "nova_char" (was "nova_int").
  7. Runtime fn signatures: nova_str_char_at updated return NovaOpt_nova_char (was NovaOpt_nova_int).

GC layout (D128 AMEND Plan 152.8). char_size = (4, 4) (was (8, 8)). In a struct with a char field followed by a Box pointer: char occupies 4 bytes + 4-byte pad → Box at offset 8. gc_layout.rs::prim_emit("char") => Some((4, 4)).

Backward compat. В emit_binary_op special-case для Nova_StringBuilder* + char accepts обе nova_char AND nova_int для backward-compat — pre-fix existing code emitted char as nova_int, existing test binaries reference legacy form. After full migration of existing generated C (regen test fixtures), nova_int branch может быть удалён.

ABI cost. Minimal. nova_char is typedef uint32_t — 4 bytes vs 8 bytes. Struct layout changes where char is followed by a pointer (padding shrinks from 0 to 4 bytes). GC scanner updated. C type identifier remains distinct from nova_int.

Acceptance criteria.

  • Ф.1 codegen mapping switch (emit_c.rs + external_registry.rs)
  • Ф.2 runtime helpers parallel (NovaArray_DECL(nova_char), NovaOpt_nova_char constructors + eq helper)
  • Ф.3 audit + fixtures (2 PASS в nova_tests/plan70_3/)
  • Ф.4 type-checker tightening (reject let x Option[int] = Some('a'))
  • Ф.5 spec D128 (этот блок)
  • 0 regressions в nova test (801 PASS sustained)

Реализовано: Plan 70.3 — Ф.0-Ф.5 closed 2026-05-19. D128 AMEND (Plan 152.8) — nova_char int64_t→uint32_t, 2026-06-16.

Связь:

  • D26 — Q-string-indexing (char = codepoint convention)
  • D54as-cast narrowing (explicit char↔int conversion)
  • Plan 70 — parent family (silent type bugs от Nova↔C collapse)
  • Plan 70.4 — sibling proposal (f32/f64 generic-container distinct mangling)

D129. int как alias i64 в bootstrap Nova

⚠️ AMEND (Plan 133) — ЧИТАЙ ПЕРВЫМ: int = intptr_t, НЕ int64_t

int = nova_int = intptr_t — ЗНАКОВОЕ ADDRESS-SIZED ЦЕЛОЕ (модель Go C-эры intgo), а НЕ int64_t.

  • intnova_int (typedef intptr_t) — ширина = ширине указателя платформы (64 бита на x86_64/ARM64, 32 бита на 32-bit/WASM). См. nova_rt.h: typedef intptr_t nova_int; /* int — signed address-sized (Go C-era intgo, Plan 133) */.
  • i64int64_tВСЕГДА ровно 64 бита, независимо от платформы.
  • На bootstrap-таргете (x86_64, 64-битный указатель) int и i64 СОВПАДАЮТ по ширине и значению — отсюда историческое слово «alias» в заголовке/теле ниже — НО это РАЗНЫЕ C-типы: primitive_name_to_c даёт int → nova_int и i64 → int64_t, поэтому их mangle-имена РАЗЛИЧАЮТСЯ (NovaOpt_nova_intNovaOpt_int64_t; Map[int,V]Map[i64,V] по C-имени).
  • «int ≡ i64» — совпадение ШИРИНЫ на 64-бит, НЕ тождество типов. Не считать int равным i64/int64: аналогия — Go intint64, Rust isizei64 (platform-pointer-width). На 32-bit/WASM int станет 32-битным, i64 останется 64-битным.
  • Следствие для §0/named-priority: числовая константа/выражение типа i64 НЕ должна схлопываться в nova_int (и наоборот) — это разные типы (см. Plan 172.1 P67 ФАЗА 2 STEP 1, де-коллапс i64.MAX→int64_t). То же char (codepoint, nova_char) ≠ int.

Текст «Решение / Мотивация / Codegen» НИЖЕ — ИСТОРИЧЕСКИЙ (Plan 70.4, до Plan 133): его утверждения «оба → nova_int (typedef int64_t)» и «mangle идентичен» УСТАРЕЛИ — читать как «совпадают по ширине на 64-bit», а C-тип nova_int теперь intptr_t, не int64_t.

Решение. Тип int в Nova bootstrap является alias для i64 (64-bit signed integer) ПО ШИРИНЕ/ЗНАЧЕНИЮ на 64-bit таргете (см. ⚠️ AMEND выше — C-типы РАЗНЫЕ: intnova_int=intptr_t, i64int64_t). Оба маппируются в C-тип nova_int (typedef int64_t) [УСТАРЕЛО Plan 133]. Отсутствие distinction в codegen намеренно относится к bootstrap-x86_64 (где ширины совпадают), но int и i64различимые типы (разный C-typedef, разный mangle): это не collapse-баг, а address-sized vs fixed-width архитектурное различие (Plan 133).

Мотивация. Audit Plan 70.4 выявил, что int и i64 используют один C-тип. Mangle для Map[int, V] и Map[i64, V] идентичен. В отличие от других collapse-паттернов Ф.1/Ф.2 плана 70.4 (ABI-real silent miscompilation) или Plan 70.3 char/int (semantically distinct types), inti64 является семантическим инвариантом — оба означают 64-bit signed integer без разницы в значении или поведении. Nova bootstrap targets x86_64 only (fixed 64-bit pointer width).

Industry baseline.

  • Rust: isize distinct от i64 (platform-pointer width varies на 32-bit)
  • Go: int distinct от int64 (platform-pointer width)
  • C#: int = alias System.Int32 (semantically identical)
  • Python/Java: нет fixed-width integer aliases
  • Nova: int = alias i64 — правильная аналогия C# для fixed-width platform

Future evolution path. Если Nova добавит multi-arch targets (32-bit, WASM), int может стать platform-pointer-width type аналогично Rust’s isize. На этот момент потребуется breaking change в codegen mangling — Map[int, V] и Map[i64, V] станут distinct. D129 explicitly documents текущее bootstrap decision как alias-based, чтобы будущий architect не принял отсутствие distinction за bug. Migration path: introduce nova_iptr (platform-width) typedef, make int resolve to it, maintain nova_int = int64_t for i64.

Codegen. Без изменений. type_ref_to_c "int" => "nova_int" и "i64" => "nova_int" — оба корректны и эквивалентны по спецификации. Distinct mangling не вводится, т.к. это создало бы необходимость явно выбирать int vs i64 для каждого generic instantiation — user-hostile и ортогонально семантической разнице (которой нет).

Acceptance criteria.

  • Ф.3 spec D129 (этот блок) — формализует alias decision
  • Нет codegen изменений — intentional collapse документирован
  • Future: multi-arch migration path зафиксирован (Migration note выше)

Реализовано: Plan 70.4 — Ф.3 closed 2026-05-19.

Связь:

  • D54as-cast narrowing semantics
  • D128 — Plan 70.3 char/int distinction (contrast: там distinction нужна)
  • Plan 70.4 — parent plan (этот блок = Plan 70.4 Ф.3)
  • Plan 70 — parent family (silent type bugs)

D130. uint — unsigned 64-bit alias в bootstrap Nova

AMEND (Plan 133). C-тип uintnova_uint (typedef uintptr_t, address-sized unsigned), а не сырой uint64_t. На x86_64-bootstrap (фикс. 64-бит указатель) nova_uintuint64_t по ширине/знаку; u64 остаётся фиксированным uint64_t. Канонический словарь — primitive_name_to_c (uint → nova_uint).

AMEND (Plan 172.1-K1, 2026-06-28). uint (и вся РАЗЛИЧНАЯ int-семья) лоуэрится в свой точный C-тип во ВСЕХ позициях — включая method-receiver. Ранее receiver_c_type схлопывал примитивный ресивер (uint/u8..u64/i8..i32) в nova_int (Plan 70.5 «64-бит слот») — нарушение §0/§10/D368 (второе окно правды) и soundness-баг: Nova_uint_method_compare(nova_int, nova_int) давал знаковое сравнение беззнаковых (неверный порядок для uint с установленным старшим битом). Теперь scalar-arm receiver_c_type делегирует в единый primitive_name_to_c (тот же лист, что resolved_type_to_c): uint-ресивер = nova_uint@-операции беззнаковые ПО ПОСТРОЕНИЮ. inti64nova_int сохраняется (D129 — намеренный alias, НЕ схлопывание различных типов). Acceptance: spec_tests/conformance/d130_uint_method_compare.nv. receiver_c_type как отдельная функция ретайрится в receiver-aware resolved_type_to_c (U.4.5/FIN).

Решение. Тип uint является alias для u64 (64-bit unsigned integer) в Nova bootstrap. Маппируется в C-тип uint64_t (AMEND Plan 133: → nova_uint). Отличие от int/i64 (alias pair, signed) — uint/u64 является симметричным unsigned pair. int as uint cast saturates (negative → 0); int as u64 — direct bit-cast (существующее поведение сохранено).

Дизайн (Q1-Q4, подтверждены 2026-05-19).

ВопросРешениеОбоснование
Q1: alias или distinct?Alias u64 (= uint64_t)Mirror int = i64 alias pattern; нет multi-arch story в bootstrap
Q2: int→uint castas uint saturates (neg → 0)D54 precedent (float→int); Rust bit-cast hostile; Swift trap verbose
Q3: IndexingKeep int (no change)Breaking change для 100+ APIs; Swift/Go/Kotlin используют signed indexing
Q4: Literal defaultint (keep current)Backward compat; 42 as uint или let x uint = 42 для opt-in

Saturation semantics (int as uint).

 -1000 as uint → 0
    -1 as uint → 0
     0 as uint → 0
     1 as uint → 1

Реализован через nova_int_to_uint(int64_t x) helper в nova_rt/cast.h. u64 as uint — direct cast (no-op; uint64_t → uint64_t).

Codegen mapping.

  • type_ref_to_c "uint" => "uint64_t" (scalar)
  • []uint → NovaArray_uint64_t* (parallel с u64)
  • Option[uint] → NovaOpt_uint64_t (parallel с u64)
  • uint.MAXне поддержан parser’ом (parser не распознаёт uint как type-path prefix; используй u64.MAX = эквивалент).

Будущая эволюция. Аналогично D129 (int/i64): если Nova добавит multi-arch, uint может стать platform-pointer-width unsigned (как Rust’s usize). Bootstrap-grade alias.

Acceptance criteria.

  • let x uint = 42 as uint компилируется
  • int as uint saturates (neg → 0) — nova_int_to_uint helper
  • int as u64 остаётся bit-cast (no saturation)
  • []uintNovaArray_uint64_t*
  • Option[uint]NovaOpt_uint64_t
  • 3 fixtures nova_tests/plan70_5/ PASS
  • 0 regressions
  • uint.MAX — defer (parser keyword support)

Реализовано: Plan 70.5 — Ф.1-Ф.3 closed 2026-05-19.

Связь:

  • D54as-cast saturation precedent
  • D129 — int/i64 alias (signed symmetric pair)
  • Plan 07 — original float→int saturation
  • Plan 70.5 — parent plan (этот блок)
  • Plan 70.4 — sibling (codegen type distinction family)

D133. type X consume — обязательная consume-семантика (must-be-consumed)

Plan 100.1. Принято 2026-05-23 (proposed; implementation pending). Extends D131 affine consume qualifier.

Что

Квалификатор consume на type-decl. Помечает, что инстансы такого типа обязаны быть потреблены до выхода из scope’а на каждом code- path’е. Compile error если live consume-переменная остаётся на exit- point’е.

type Transaction consume { id int }
type File consume { fd i32 }
type Lock consume { mutex *Mutex }

Расширяет D131 с противоположной стороны:

СвойствоD131 affine consume (Plan 73)D133 type-level consume (Plan 100.1)
Потребить ≤1 раз✅ enforce✅ enforce (наследуется)
Потребить ≥1 раз (обязательно)❌ забыть OK✅ enforce — must-be-consumed
Помечается наreceiver / param методаtype-decl + поле + binding

Канонический use-case — Transaction.commit() / .rollback(), File.close(), lock-guard .release().

Синтаксис

consume стоит после имени типа, перед {:

type Transaction consume {                    // type-decl marker
    id int,
}

fn Transaction consume @commit() -> ()         // consume-method (D131)
fn Transaction consume @rollback() -> ()

consume на type-decl + хотя бы один consume-метод (D131) — обязательное сочетание (compile error: «consume-type требует ≥1 consume-method»).

Правило — must-consume на каждом exit-path’е

Compiler проводит flow-sensitive анализ (расширение Plan 73 D131 check_consume pass’а). Для каждой переменной consume-типа отслеживается VarState:

  • Live — значение доступно, обязательство активно.
  • Consumed — значение потреблено (через consume-метод / consume- параметр / return).
  • MaybeConsumed — потреблено лишь на части путей (branch join).

На каждой точке выхода scope’а проход по active consume-переменным:

  • Live или MaybeConsumedcompile error E (D133-not-consumed) с указанием консьюм-методов.
  • Consumed → OK.

Точки выхода:

  • конец function body (последний statement);
  • return expr — все live consume-vars (кроме возвращаемой) → error;
  • panic / expr!! / expr? / unwinding-paths;
  • loop break;
  • branch join if/matchLive ⊔ Consumed = MaybeConsumed.

defer / errdefer могут покрывать обязательство (см. D158+ Plan 100.4 family).

Что считается consume

ДействиеЭффект на VarState
tx.commit() — вызов consume-методаtxConsumed
f(tx) где f(consume tx Tx) — consume-paramtxConsumed
f(make_tx()) где f(consume t Tx) — rvalue → consume-paramrvalue ownership передаётся напрямую (без binding) ✅
return tx (тип consume)txReturned (передача caller’у)
record.field = tx где field declared consumetxMoved (в record)
consume new_owner = tx (transfer alias)txConsumed, new_ownerLive
f(tx) где f(tx Tx) — view-param (no qualifier)tx остаётся Live (callee — view-borrow)
f(make_tx()) где f(t Tx) — rvalue → view-param❌ E (D133-consume-rvalue-in-view)
f(tx) где f(mut tx Tx) — mut-view-paramtx остаётся Live (callee — mut-borrow)
f(make_tx()) где f(mut t Tx) — rvalue → mut-view-param❌ E (D133-consume-rvalue-in-mut-view)
let alias = tx — view-aliasоба в alias-class (Plan 73); consume любого инвалидирует
let mut alias = tx — mut-view-aliasто же + mut-методы через alias
let _ = tx (silent drop)❌ compile error D133-suppress-not-allowed

Заразность через поля + explicit double-marker

Record/sum, имеющий поле consume-типа, обязан быть объявлен consume:

type TxState consume {                         // ← ОБЯЗАТЕЛЬНО
    consume tx Transaction,                    // ← ОБЯЗАТЕЛЬНО (тип = consume)
    writes []Write,                            // обычное поле
}

Compiler enforces consistency:

  • consume-поле без consume-маркера → error E (D133-field-marker-missing);
  • consume-маркер на field без consume на type-decl → error E (D133-type-marker-missing);
  • consume f int (тип поля не consume) → error E (D133-marker-on- non-consume) — keyword использован но не нужен.

consume-type БЕЗ consume-полей разрешён — каноничный паттерн для opaque-resource типов (StringBuilder consume с runtime backing через external type; consume-method @into() потребляет; никаких consume-полей в декларации). Достаточно хотя бы одного declared consume-метода.

Field-aware flow внутри методов record’а

@field отслеживается как независимый VarState slot. На exit’е метода:

Тип методаconsume-поля должны быть
fn X consume @method(...)Consumed (record closes)
fn X mut @method(...)Live (invariant preserved)
fn X @method(...) (regular)Live (invariant preserved)

Это позволяет реальные паттерны (rotate / reopen / replace):

type Service consume {
    consume file File,
}

fn Service mut @reopen() -> Result[(), OpenErr] {
    consume new_file = File.open()?            // сначала добываем замену
    @file.close()                               // только теперь закрываем старое
    @file = new_file                            // rebind — @file опять Live;
                                                //  new_file → Consumed (transfer в @file)
}                                               // mut exit: @file Live ✅

Compiler ловит реальные баги:

  • забытый rebind на ветке → exit MaybeConsumed → error.
  • early return без rebind → error.
  • наивный close-then-open с error-path (@file.close(); @file = open()?) → error если open Err (@file Consumed, не rebinded).

Assign в Live consume-поле / locals — запрещено

Прямое присваивание @field = expr разрешено только когда @field уже Consumed (для simple-typed consume-поля) либо все consume-sub- fields внутри @field уже Consumed (для nested-consume-record-поля). Иначе compile error E (D133-assign-live-field).

fn Service mut @overwrite_naive() {
    @file = File.open()?                       // ❌ @file Live, silent overwrite
}

fn Service mut @overwrite_correct() {
    @file.close()                              // @file → Consumed
    consume new = File.open()?
    @file = new                                // ✅ @file Consumed → assign OK
}

Nested case@inner содержит consume tx; assign в @inner разрешён когда внутренний @inner.tx уже Consumed (recursively для deep nesting):

fn Outer mut @reset() {
    @inner.tx.commit()                         // @inner.tx → Consumed;
                                               //  @inner effectively «empty container»
    consume new = Inner.new()
    @inner = new                               // ✅ all consume-sub-fields Consumed
                                               //  → @inner replace OK
}

То же для локальных consume-var: повторный consume tx = ... без consume старой — error.

Nested field paths

Multi-level field tracking — ConsumeCtx хранит state по произвольно глубокому пути @f1.f2.f3:

type Inner consume { consume tx Transaction }
type Outer consume { consume inner Inner }

fn Outer mut @commit_inner() {
    @inner.tx.commit()                         // deep path consume; @inner.tx → Consumed
                                               //  @inner — «empty container» (consume-sub-field Consumed)
    consume new = Inner.new()
    @inner = new                               // rebind inner — assign OK
                                               //  (внутренний tx был Consumed)
}

Реализация — ConsumeCtx::states: HashMap<FieldPath, VarState> где FieldPath = Vec<String>.

Заразность через generic-args

type_is_consume(TypeRef) — рекурсивная функция (общая, не Option- специфичная):

  • тип в LinearityRegistry (объявлен consume)?
  • record/sum с ≥1 consume-полем?
  • generic-wrap G[T1, ..., Tn] — хотя бы один Ti consume?
  • generic-param T (без bound) — false (bootstrap silent-ignore; закрывается D156 Plan 100.2 через [T consume] bound).

Option[Transaction] / Result[Transaction, E] / Box[Transaction] / user Wrapper[Transaction] — все автоматически consume через wrap. Никакого Option-специфичного хардкода — общее правило для любого generic-wrapper’а.

Три mode’а binding-position: view / mut-view / consume

Единое правило везде (param / for / match / if-let / let-binding): consume keyword маркирует ownership. Без него — view (read- only borrow). mut — view + mutation.

fn read(tx Transaction) -> int                 // view (default; callee читает)
fn modify(mut tx Transaction)                  // mut-view (+ mut методы)
fn close(consume tx Transaction)               // consume (transfer; tx → Consumed)

View (default — без qualifier’а)

ДействиеOK?
tx.field (read)
tx.regular_method()
t.mut_method()❌ (нужен mut tx)
t.consume_method()❌ E (D133-consume-via-view)
передача в view-param другой fn
передача в consume-param❌ E (D133-move-via-view)
передача в mut-param❌ (нужен mut tx)
return tx (escape)❌ E (D133-view-escape-return)
store в record-field❌ E (D133-view-escape-store)
capture в closure, returned❌ E (D133-view-escape-closure)
let alias = tx (alias)✅ view-alias (Plan 73)

Mut-view (mut tx qualifier)

То же что view, но mut-методы разрешены. Не consume, не escape.

Consume (consume tx qualifier)

Полный ownership-transfer. Callee/binding обязан consumed до scope- exit’а через один из 5 механизмов (см. §«Когда consume binding считается удовлетворённым»).

Consume-rvalue в arg-position (без binding)

Прямой call f(make_tx()), где make_tx() -> Tx consume возвращает fresh consume-owner, без сохранения через consume name = … — правила по qualifier’у callee-param:

Callee paramOK?
f(consume t Tx) — consume-param✅ ownership передаётся напрямую; callee обязан consumed внутри
f(t Tx) — view-param (default)❌ E (D133-consume-rvalue-in-view)
f(mut t Tx) — mut-view-param❌ E (D133-consume-rvalue-in-mut-view)

Почему запрет на view / mut-view: view/mut-view-param не consume’нят callee-стороной. После возврата из f rvalue остаётся не consumed и не bound к локальной переменной → flow-checker не имеет slot’а в ConsumeCtx для tracking’а → must-consume gate его не увидит → ресурс утечёт молча. Запрет — единственное безопасное правило: consume-value требует именованного owner’а либо немедленной передачи ownership через consume-param.

Hint в diagnostic: «привяжи через consume name = make_tx(), затем f(name); после consume-method/consume-param/return name будет Consumed». Альтернатива — заменить sig f на consume-param, если callee действительно должен потребить.

Цепочки (g(f(make_tx()))) — рекурсивно: rvalue-результат f анализируется по тому же правилу для соответствующего param’а g. Если f возвращает consume-value, а g-param это view → error на внешнем вызове.

Глубокий peek без consume

match @file {                                  // view-match (default)
    Some(f) => f.fd,                           // f: view File, read-only
    None => 0,
}
// @file остаётся Live ✅

См. D157 (Plan 100.3) — match-pattern в view-mode + closure capture analysis.

consume + -> @ несовместимы

fn Tx consume @prepare() -> @ { ... }parse error. Противоречие между «забираю целиком» и «возвращаю тот же объект» (D132 fluent- return).

Binding: consume keyword обязателен для ownership

Для consume-типов consume keyword обязателен в LHS, когда binding становится Live-linear-owner:

ro tx = begin()                               // ❌ ERROR D133-consume-needs-keyword:
                                               //    consume-type требует `consume` keyword

consume tx = begin()                           // ✅ initial binding — owns

ro alias = tx                                 // ✅ view-alias (no ownership; Plan 73)
mut alias = tx                             // ✅ mut-view-alias
consume new_owner = tx                         // ✅ transfer: tx → Consumed

Без consume keyword’а LHS = view-alias (alias-class Plan 73, read-only borrow). Это симметрично param/for/match — везде «no qualifier = view, consume = transfer».

Когда consume binding считается удовлетворённым

Live consume-binding обязан к scope-exit’у оказаться в одном из 5 состояний:

  1. Closed locallytx.commit() (consume-метод).
  2. Returnedreturn tx.
  3. Transferredf(tx) где f(consume tx T).
  4. Stored in record-field, который сам уходит наверх:
    consume tx = begin()
    return Wrapper { tx: tx }                  // tx → record-field, record returns
    
  5. Covered by defer/errdefer/okdefer (D158-D162 Plan 100.4 family).

Иначе error E (D133-not-consumed).

AI-first explicit-ness — почему mandatory

consume keyword обязателен специально — для loud visibility:

  • 🟢 Каждое появление ownership видно с первого взгляда.
  • 🟢 Refactor-safety — добавил consume к типу → compiler ловит все существующие let x = T.new() sites, force review.
  • 🟢 Единое правило симметрии с param / for / match.

Verbose-ness bounded — только для consume-типов (rare; resource- management).

Runtime mental model (Option-projection, не ABI)

Концептуально consume-тип проецируется в Option[T]-space:

  • LiveSome(t).
  • ConsumedNone.
  • MaybeConsumed ≡ branch-зависимо.

Это mental model для spec/docs. Реализация остаётся pragmatic (D131-style):

  • pointer-based consume: NULL = None (zero overhead);
  • value consume: zero-out fields после consume;
  • compile-time check_consume — основной механизм; runtime null-deref panic — defense-in-depth.

User-facing pattern-match match tx { Some(t) => ... } для runtime- проверки не вводится — ослабит compile-time гарантии.

Что отвергнуто

  • Universal affine/linear для всех let — отвергнуто в D75 §«Compile-time token-scope enforcement»: «это Rust borrow checker ради одной фичи, несоразмерно для GC-языка». D133 — opt-in per-type, не default.
  • Suppress-механизм let _ = v — anti-Rust #[must_use] gateway. Единственный канал — consume-метод. Если «иногда хочу забыть» — знак, что тип неправильно помечен consume.
  • Drop-method auto-cleanup (Rust-style RAII) — размывает выбор commit/rollback. D133 требует явный consume-метод.
  • Pattern-match destructure consume-record (let { tx } = state) — ломает encapsulation (consume-поле уходит в независимый linear- binding). Вынос через явный consume-метод record’а: fn TxState consume @into_parts() -> (Transaction, []Write) => (@tx, @writes).
  • Strict-mode binding-form (let tx = «обязан передать наверх» vs consume tx = «обязан закрыть здесь») — отвергнуто (overspec, refactor friction). Финальная модель: consume keyword mandatory для ownership; let для consume-types = error либо view-alias (в alias-position).
  • view T keyword как explicit qualifier — отвергнуто (default- view достаточно). view mode = absence of consume/mut qualifier (см. D157 Plan 100.3).
  • Implicit _ = tx discard — суррогат suppress; force compile- error.

Сравнение с другими языками

СвойствоRustTS (ES2024)KotlinGoNova D133
Compile-time enforcement⚠️ #[must_use] warning, suppressable❌ runtime via dispose❌ runtime via use{}error
Suppress escape hatchmem::forget(v) / let _ = vn/an/an/aby design
Distinct cleanup methods (commit/rollback)⚠️ enum-в-Drop, awkward⚠️ single dispose⚠️ use{} block⚠️ conventionnative (consume-методы)
Lifetime / borrow-checker cost❌ естьn/an/an/a✅ нет (поверх GC)

D133 строже Rust на suppress (нет mem::forget), expressive Rust на distinct cleanup methods. Не требует lifetime’ов / move-семантики.

Связь

  • D131 — affine consume foundation. D133 — extension on type-decl level.
  • D132-> @ fluent-return; sound builder-chain alias через -> @ нужен для consume-checker’а builder API.
  • D75 — почему universal consume отвергнут.
  • D90defer / errdefer foundation; интеграция через Plan 100.4 family (D158-D162).
  • D85 — kinded throws, cancel-routing; взаимодействие через D162 Plan 100.4.5.
  • D156 Plan 100.2 — generic [T consume] strict-mode bound.
  • D157 Plan 100.3 — view T read-only borrow для deep peek.
  • D158-D162 Plan 100.4.1-5 — defer/errdefer integration для cleanup- on-failure.
  • D163 Plan 100.5 — FFI external consume fn.
  • D164 Plan 100.6 — cross-module consume visibility + mangling.
  • D165 Plan 100.7 — stdlib migration playbook.
  • D166 Plan 100.8 — performance + IDE tooling.

D156. Generic [T consume] bound + collection-aware iteration

Plan 100.2. Принято 2026-05-23 (proposed; implementation pending). Extends D133 на generic-код. Closes silent-leak hole для consume-T в generic-функциях.

Что

Bound [T consume] на generic-параметр — opt-in strict mode: внутри generic-body параметр T трактуется как possibly-consume; silent-forget T-значения → compile error. Backward-compat: generic-функции без bound сохраняют silent-ignore behavior (Plan 100.1 default), чтобы existing stdlib generic-код продолжал работать.

// Strict mode — compiler enforces strict consume handling внутри:
fn box[T consume](consume x T) -> Box[T] => Box { val: x }

// Без bound — silent-ignore:
fn drop[T](x T) -> ()                          // silent forget если T consume

Плюс — collection-aware iteration с 3 mode’ами (unified с D133): for tx in vec (view default) / for mut tx in vec (mut-view) / for consume tx in vec (consume, vec → Consumed).

Зачем

Без D156 generic-код имеет дыру:

type Transaction consume { id int }
fn Transaction consume @commit() -> ()

fn first[T](pair (T, T)) -> T => pair.0       // silent leak pair.1 если T=consume

consume tx1 = Transaction { id: 1 }
consume tx2 = Transaction { id: 2 }
consume chosen = first((tx1, tx2))             // tx2 уехала в first и потерялась
chosen.commit()
// tx2 LEAK — compiler молчит.

Это самый серьёзный hole D133 bootstrap’а — именно generic-helpers есть в каждой stdlib. Rust решает через Move trait + ownership; D156 решает через [T consume] bound + collection-aware iteration.

Синтаксис bound

fn box[T consume](consume x T) -> Box[T]
fn map[T consume, U consume](items []T, f fn(consume T) -> U) -> []U
fn id[T consume](consume x T) -> T => x

consume — bound в generic-position, мирится с другими bounds ([T Iter[U]] из D72) — но bootstrap не поддерживает комбинации ([T consume + Clone] — parse error; будущее расширение).

Strict mode внутри [T consume] body

Внутри функции с [T consume] bound параметр T трактуется как possibly-consume; compiler обращается строго:

Действие с T-значениемБез boundС [T consume]
let _ = x (silent drop)✅ OK❌ error E (D156-strict-forget)
передача в non-consume fn⚠️ silently❌ error
destructure tuple, discard part⚠️ silently❌ error
return x✅ (передача наверх)
передача в consume fn-param✅ (consume)

Force’ит honest API. Чтобы legitimately drop элемент — нужен явный consume-параметр для drop:

fn first[T consume](consume a T, consume drop_b T) -> T => a
//                              ^^^^^^^^^^^^^^^^^^ — caller обязан передать
//                                                   drop_b как consume; внутри
//                                                   first drop_b силен забыть
//                                                   (это локальный binding).

Backward-compat и migration policy

  • Default = silent-ignore для generic-functions без bound (Plan 100.1 behavior preserved). Иначе сломается весь stdlib generic-код.
  • Opt-in [T consume] для функций, которые хотят strict mode.
  • Migration: stdlib generic-functions (Plan 17/26/30/52/57 collection API) — постепенно аннотируются [T consume] через nova consume-migrate CLI (Plan 100.7).

Collection-aware iteration — 3 mode’а

Симметрично D133 param/match mode’ам:

consume tx1 = begin()
consume tx2 = begin()
consume txs = [tx1, tx2]                       // []Transaction — generic-заразность (D133 D6)
                                               // txs владеет (consume keyword обязателен)

// View (default) — read-only, vec stays Live:
for tx in txs {
    println(tx.id)                             // ✅ read field
    // tx.commit()                             // ❌ view → не consume-метод
}
// txs Live после for; нужно consume другим способом.

// Mut-view — vec stays Live, элементы mutated in-place:
for mut tx in txs {
    tx.update()                                // ✅ mut method
}
// txs Live, элементы updated.

// Consume — consume каждое, vec → Consumed:
for consume tx in txs {
    tx.commit()                                // ✅ consume-метод
}
// txs → Consumed после for ✅

Loop-handling pragmatic: for consume tx in vec помечает vec Consumed после loop (даже если break early — D161 multi-defer LIFO error accumulation gracefully handles partial-consumed state).

Каждый tx в arm-теле проверяется стандартным check_consume правилом для соответствующего mode’а (view / mut-view / consume).

Alternative consume-methods для collection

Чтобы consume collection без iteration:

  • vec.pop() -> Option[T] — single-element consume (Option auto- consume через D133 D6 generic-заразность).
  • vec.drain() -> Iter[T] — consume через iterator.
  • vec.into_first() -> T consume-method record’а возвращает один элемент (consume rest internally).

stdlib audit (Plan 100.7) аннотирует эти методы с [T consume] bound.

Generic propagation для HOF (map/filter/fold)

Closure-параметры HOF используют те же 3 mode’а через qualifier:

fn map[T consume, U consume](consume items []T, f fn(consume T) -> U) -> []U
fn filter[T consume](consume items []T, f fn(t T) -> bool) -> []T
//                                          ^^^ — view (default; read-only)
fn for_each[T consume](consume items []T, f fn(consume T) -> ())
fn modify[T consume](mut items []T, f fn(mut T) -> ())
//                                       ^^^^ — mut-view (in-place modify)

filter использует view-closure (default) — predicate читает T без consume. map consume’ит каждое T → producer’ит U. modify mut-view для in-place.

Compiler enforces consume-handling в closure-body через generic-bound propagation + view-default rules.

HashMap / user-generic propagation

type_is_consume рекурсивно (D133 D6): wrapper’ы с consume-arg сами становятся consume:

consume tx_map = HashMap[str, Transaction].new()
                                               // ↑ Transaction consume → HashMap consume
                                               //   через generic-заразность
                                               //   consume keyword обязателен (D133)
tx_map.insert("a", consume begin())            // insert требует consume value (transfer)
// На scope-exit tx_map должен быть Consumed (через consume-метод HashMap).
for consume (_, tx) in tx_map.drain() {        // consume через drain-iteration
    tx.commit()
}

HashMap (и другие collection API) — должны аннотировать [V consume] на методах, манипулирующих consume-values (insert(k K, consume v V), remove() -> Option[V], drain() -> Iter[(K, V)], etc.). Migration audit — часть Plan 100.7.

Runtime cost

Zero. Все проверки compile-time. Runtime-представление generic’ов не меняется. Bound [T consume] — type-level only, не влияет на codegen mono’d functions.

Сравнение

CapabilityGoRustTSKotlinNova D156
Generic linear boundn/aT: Move (default)n/an/a[T consume] opt-in
Detection «generic drops linear arg»n/a✅ compile-errorn/an/a
Backward-compat: generic без boundn/an/an/an/asilent-ignore остаётся
Vec<T> ownership iterationn/an/an/afor tx in vec

Nova превосходит Rust на одной оси — backward-compat: generic без bound сохраняет existing behavior; opt-in strict — choice.

Что отвергнуто

  • [T consume + Clone] combined bound — bootstrap parse-error; будущее расширение (комбинация с другими D72 bounds).
  • [T !consume] anti-bound — не вводится; нет use-case в bootstrap.
  • Variance linear-typed wrappers — отдельный план (общая variance system).

Связь

  • D133 — foundation type-level consume; D156 — generic-уровень.
  • D72 — generic bounds [T Protocol]; D156 идиоматически близок.
  • D157view T (Plan 100.3); filter-style HOF использует view для read-only inspection.
  • D158-D162 (Plan 100.4 family) — defer/errdefer integration; orthogonal.

D163. FFI consume integration — type-driven, без отдельного keyword’а

🔴 RETRACTED 2026-05-30 (Plan 91.10). needs <Cap> syntax удалён. Capability tracking via отдельный mechanism — redundant с effect system (Plan 33). Structurally needs Cap ≡ effect-без-операций: same propagation, same static tracking, different syntax. Если в будущем понадобится capability gating — вводить как formal effect declarations (type Fs effect { ... }) с handler’ами. Конкретный pain: consume (ownership/linearity) vs capability (authority) — orthogonal concerns, D163 их жёстко связал. См. [docs/plans/91.10-d163-retract-capability-syntax.md].

Что осталось от D163:

  • external type X consume в любом module — продолжает работать (D126 + опаковая FFI-семантика).
  • consume keyword на параметрах external fn — продолжает работать (D131 ownership). Но external fn остаётся stdlib-only (D82) — user-module external fn через D163 capability path больше не валидны.

Plan 100.5 historical original record: Принято 2026-05-23. Ред. 2 (2026-05-24): drop external consume fn keyword. Ред. 3 (2026-05-27): РЕАЛИЗОВАНО — parser needs clause, type-checker D163-missing-cap, C codegen стабы. Extends D82 external fn + D126 external type + D63 capability.

Удалено (Plan 91.10):

  • Parser needs clause (hard error w/ migration hint).
  • check_external_fn_needs_caps (D163-missing-cap diagnostic).
  • emit_d163_external_stub (C codegen стаб generator).
  • FnDecl.needs_caps AST field — удалён (Plan 91.15 Ф.5, [M-91.10-remove-needs-caps-field] ✅).
  • Test fixtures nova_tests/plan100_5/external_* (6 files) и nova_tests/plan100_7/{file_open_read_close,mutex_lock_release, socket_listen_accept}.nv (3 files).

Текст ниже — historical reference для контекста.

Что

Никакого нового keyword’а для external fn — унифицировано с regular fn: return-type carrying consume-ness (через D133 type-decl consume) автоматически передаёт ownership caller’у. consume keyword используется только на параметрах/receiver’ах (D131 semantic).

// Opaque consume-type (D126 + D133):
external type File consume
external type Mutex consume
external type Socket consume

// Return consume-type → caller получает ownership (через type, не keyword):
external fn nova_file_open(path str) -> File
    needs Fs                                    // capability required (D63)

// Param-side consume — D131 semantic, тот же keyword `consume` на param:
external fn nova_file_close(consume f File)
    needs Fs

// Result wraps consume — generic-заразность из D133 D6:
external fn nova_open(path str) -> Result[File, IoErr]
    needs Fs
// Caller обязан consume Result через match-Ok-arm.

Зачем drop keyword

Параллель с regular fn:

fn factory() -> Transaction => Transaction.new()
//              ^^^^^^^^^^^ — return type carries consume-ness. NO `consume`
//                            keyword on fn declaration.

fn finish(consume tx Transaction) -> () { ... }
//        ^^^^^^^ — consume on PARAM (D131).

Применяем то же к external — symmetry без нового keyword’а.

Capability requirement (D63)

external fn касающийся OS resource обязан declare capability — это независимо от consume-семантики (общее правило D63):

external fn nova_file_open(path str) -> File
    needs Fs                                    // OS access → cap required

external fn nova_socket_accept(consume srv ServerSocket) -> ClientSocket
    needs Net

Capability и consume — две ortogонные concern. Capability для OS privilege; consume для ownership. Combined через type-decl + needs-clause.

C runtime defensive helpers

C-side nova_file_close(consume f File) обязан:

  • nv_consume_validate(f) — assert f != NULL на entry.
  • После работы — memset поля File* в zero / NULL (defense-in-depth per D131 Plan 73 pattern).

Это даёт двойную защиту: compile-time (D133 check_consume) + runtime (NULL-deref panic на use-after-consume).

Generic-заразность через FFI — uniform

external fn nova_open() -> Result[File, IoErr] needs Fs
//                         ^^^^^^^^^^^^^^^^^^^ — Result consume через generic-arg
// Caller обязан consume Result (через match Ok-arm с consume File).

Никакого FFI-специфичного правила — общее D133 D6 generic-заразность.

Cross-fiber FFI safety

FFI-call может суспендиться (libuv async I/O). Plan 47/22/49 fiber infra preserves consume-state через migration; D163 verify через runtime tests (Plan 100.5 Ф.6).

Сравнение

CapabilityRustKotlin/JNIGo cgoTS Node N-APINova D163
Ownership через FFIunsafe fn + manual contract⚠️ manual⚠️ manual⚠️ manualtype-driven, без extra keyword
Auto-close на panic при FFI handle✅ через Drop wrapper⚠️ try-finally⚠️ defer⚠️ try-finallyчерез D162
Capability tracking⚠️ unsafe fn⚠️ manual⚠️ manualn/aD63 needs-clause
unsafe keyword нужен✅ даn/an/an/aнет (D6)
Уникальный FFI-syntax⚠️ unsafe fn⚠️ JNI prefix⚠️ cgo annotation⚠️ napi macroунифицировано с regular fn

Nova превосходит Rust — (a) нет unsafe keyword (D6 + D63 capability); (b) уни­фи­цировано с regular fn (одна mental model для FFI и Nova-side functions).

Что отвергнуто

  • external consume fn keyword (Ред. 1) — избыточный, return-type уже carries consume-ness. Drop в Ред. 2.
  • Vacuous-marker warning (Ред. 1 W D163-vacuous-consume) — отпадает вместе с keyword.

Связь

  • D82external fn foundation; D163 расширяет.
  • D126external type opaque; combine’ится с consume.
  • D63, D64 — capability enforcement.
  • D131, D133 — consume foundation.
  • Plan 18 — основной consumer (File/Mutex/Socket migration).

D164. Cross-module consume — visibility + mangling + package contracts

Plan 100.6. Принято 2026-05-23 (proposed). Extends D26 visibility + D134 mangling v0 + Plan 03 package ecosystem.

Что

consume-маркер (D133) — part of exported type signature. Visibility (D26, D47 Plan 35 R26) propagates marker. Symbol mangling (extends D134 Plan 81) включает consume-bit — ловит cross-version ABI break. Plan 03 nova audit verifies cross-package consume-contracts.

Cross-package visibility

// package A, module a/types.nv
export type Transaction consume {
    id int,
}
// package B, module b/main.nv
import a.types.Transaction

fn main() {
    consume tx = Transaction { id: 1 }          // ✅ consume-marker visible
    tx.commit()
}

consume propagates через export + import. Plan 35 R26 (visibility enforcement) — без special-case’ов; consume — обычный type-attribute.

Mangling extension (D134 amend)

Plan 81 D134 определил symbol-mangling v0:

nova_fn_<pkg>_<mod>_<name>_<param-types>_<return-type>

D164 amend:

nova_fn_<pkg>_<mod>_<name>_<consume-bit>_<param-types>_<return-type>
                          ^^^^^^^^^^^^^^^
                          `c` если consume-маркер на type-decl, `_` иначе

Это ловит ABI mismatch — package A v1.0 имеет Transaction consume, v2.0 убрал marker; linker ловит cross-version mismatch на load.

Re-export через export import (Plan 42.09)

// package B re-exports A.Transaction
export import a.types.{Transaction}

Re-export preserves consume-marker. Plan 42.09 уже работает; D164 verifies.

Folder-modules (Plan 42) + relative imports (Plan 84)

consume-types работают идентично в folder-modules + relative imports: не вводятся special-case rules. Plan 42 / Plan 84 уже работают; D164 verifies.

Package version contracts (Plan 03)

nova.toml consume-contracts:

[package]
name = "my_lib"
version = "1.0.0"

[exports.consume_types]
Transaction = "1.0"                             // consume contract v1
File = "1.0"

Cross-version compat:

  • v1.0 → v1.x — consume-status unchanged.
  • v1.x → v2.0 — consume-status может change (major-bump required).

nova audit (Plan 03.4) verifies — ловит «v1 → v1.1 breaking change» unauthorized.

Cross-module diagnostic

error: consume value `tx` (type a::Transaction) not consumed
  note: type defined in package 'a' v1.0 at a/types.nv:5
  note: consume via .commit() or .rollback() (declared in 'a')

Includes package origin, version, consume-method hint.

Private consume не leak

type InternalCache consume { ... }              // no `export`
// usable только в этом package; cross-package — invisible

Plan 35 R26 — без special-case’ов.

Сравнение

CapabilityRustKotlin/JavaGoTSNova D164
Pub visibility consume-маркера✅ pub Drop visible⚠️ AutoCloseable interface⚠️ exported method⚠️ TS typesD164 propagation
ABI mangling включает ownership-info✅ через type⚠️ via signaturen/aconsume-bit
Cross-package consume contracts✅ Cargo + Rust types⚠️ Maven coordinates⚠️ go modules⚠️ npm typesnova.toml
Re-export preserves marker✅ через pub usen/an/an/a✅ Plan 42.09

Nova matches Rust на всех осях; превосходит на consume-bit-in- mangling (ловит silent ABI mismatch которого Rust не видит через type-id alone).

Связь

  • D26, D47, Plan 35 R26 — visibility foundation.
  • D134 — mangling v0 (Plan 81); D164 extends.
  • D29 — modules + folder-modules.
  • D126 — opaque types; cross-package consume может быть opaque.
  • D131, D133 — consume foundation.
  • Plan 03 / Plan 03.4 — package ecosystem, nova audit.
  • Plan 42, Plan 42.09, Plan 84 — folder-modules, re-export, relative imports.

D135. Type-checker completeness — «no silent fallback» на уровне типов

Статус: принято, реализовано (Plan 79).

Контекст. D368 закрыл silent-fallback в кодогене («no silent nova_int»). Но bootstrap type-checker (types/mod.rs) проверял имена, структуру, эффекты, контракты — и не базовую совместимость типов. Эмпирическая перепроверка 2026-05-21 показала: ряд элементарных ошибок типов компилировался молча (silent miscompilation) либо ловился только C-компилятором (CC-FAIL, поздняя нечитаемая диагностика):

СлучайДо Plan 79Severity
let x int = trueкомпилируется И выполняется неверно🔴 silent
want_bool(42) (int в bool-параметр)то же🔴 silent
fn g() -> Result[int] (1 type-arg вместо 2)компилируется тихо🔴 silent
let c = Foo (имя типа как значение)CC-FAIL🟡 поздняя
f.nonexistent (нет поля)CC-FAIL🟡 поздняя

Go / Rust / TS ловят все пять на compile-time. По базовой проверке типов Nova была позади всех трёх.

Решение. Type-checker обязан ловить базовые ошибки типов на этапе компиляции собственной диагностикой (серия E73xx), а не молча и не перекладывая на C-компилятор. Отдельный проход TypeCheckCtx (паттерн NameResCtx / MapLitCtx):

  • E7310 — арность type-аргументов. Использование generic-типа с явно указанным, но неверным числом аргументов (Result[int], Result[A,B,C], Foo[int] для не-generic Foo). Опущенные аргументы (fn f() -> Result { Ok(1) }) — легальны (выводятся из контекста), это не arity-ошибка.
  • E7301 — assignability. let-аннотация ↔ RHS и аргумент ↔ параметр. Целочисленный литерал полиморфен (D44): let x u8 = 200 валиден; let x int = true, want_bool(42) — нет. Сравнение по категориям типов; structural-конформность протоколов — забота D72, не этой проверки.
  • E7320 — существование поля / метода. obj.name, где obj — concrete record: name обязан быть полем либо методом (into/ try_into синтезируются из D73/D77).
  • E7330 — type-vs-value. Имя непустого record/sum-типа в value-позиции (let c = Foo, Foo + 1) — ошибка: тип не значение.

Принцип «no any-hole» (строже TS). Ни один путь проверки не присваивает выражению результат «молча неверно». Там, где тип выражения не выводится (bootstrap type-checker по дизайну не типизирует каждое выражение — вывод завершается в кодогене), проверка пропускается локально — это не silent miscompilation: программа не становится неверной, недостающая проверка либо ловится дальше по пайплайну, либо случай корректен. any — только из явной аннотации ([]any), он не «заражает» и не отключает проверку соседних выражений. Полная типизация каждого выражения на уровне type-checker’а — задача пост-bootstrap full inference engine, вне scope Plan 79.

Сравнение. Go/Rust/TS ловят все пять случаев на compile-time; Plan 79 выводит Nova на их уровень для перечисленных проверок. Строже TS: у TS any молча гасит ошибки — в Nova такого пути нет.

Связь:

  • D368 — sibling: «no silent fallback» для кодогена (Plan 70).
  • D44 — полиморфизм числовых литералов.
  • D72 — structural bounds (конформность протоколов — там).
  • D73 / D77into/try_into синтез.
  • Plan 79 — родительский план (этот блок).
  • Plan 37 — newtype/alias as-cast строгость (смежная, отдельная).

D142. protocol/effect declaration ↔ literal symmetry

Plan 97. Принято 2026-05-23. Объединяет Q-keyword-symmetry (open-questions.md) с Q-static-method-protocol (D58).

Что

Декларация и литерал и для протоколов, и для эффектовсимметричны по ключевым словам:

// Declaration:
type Cron effect   { run() -> () }
type Fan  protocol { run() -> () }

// Literal (значение, реализующее контракт):
ro h = effect   Cron { run() => spawn_cron() }   // value of type Effect[Cron]
ro p = protocol Fan  { run() => spin_blades() }  // value реализующее Fan

Раньше литерал эффекта писался ключевым словом handler, а литерала протокола не было. Теперь:

  • литерал эффекта — effect X { ... } (тот же keyword, что в declaration);
  • литерал протокола — protocol X { ... } (тот же keyword, что в declaration);
  • встроенный тип Handler[E, IRT]Effect[E, IRT] (Effect[E]Effect[E, Never] через D88 default).

Clean break — старое ключевое слово handler (литерал) удалено без deprecated-алиаса; парсер при встрече выдаёт diagnostic «handler keyword removed; use effect (D142)».

Правило

Декларация (без изменений)

type Db   effect   { query(q str) -> [str] }
type Hash protocol { hash() -> u64 }

Литерал — symmetry

// effect-литерал (value)
ro h = effect Db {
    query(q) => mock_rows()
}
with Db = h { ... }

// protocol-литерал (value реализующий контракт) — instance-only
ro l = protocol Locker { lock() => state.lock() }

Анонимный protocol в type-position (D53 §628)

fn close_all(items []protocol { close() -> () }) {
    for it in items { it.close() }
}

fn min[T protocol { @lt(other Self) -> bool }](xs []T) -> Option[T] => ...

Body анонимного protocol — тот же синтаксис, что у named: bare-имена = instance; leading-точка .method = static (D143).

protocol-литерал: instance-only

Static-методы — это методы типа (Type.method, D35); у литерала нет «своего типа» (анонимная impl). Попытка реализовать static в protocol-литерале → diagnostic «static methods cannot be implemented in protocol-literal; they belong to a type (D35) — use a named type».

Capture-rules

Закрытие над окружающим scope’ом — как обычное closure (D22 / D6 managed heap). Никаких особых правил поверх closure не вводится.

Почему

  • Симметрия снижает когнитивный налог. Один keyword из declaration работает и в literal — нет «двух жаргонов» (handler vs protocol vs effect).
  • Анонимный protocol-литерал разблокирует pattern «capability-split factory» — Lock.new() -> (Locker, Unlocker) без двух named-обёрток. Кандидаты в stdlib Plan 18: Process.spawn, HttpServer.bind, Db.transaction.
  • Symmetry побеждает локальную точность. let h = effect X { ... } читается чуть точнее как «handler», но protocol X { ... }-литерал всё равно нужен — приходится либо ввести ещё keyword, либо унифицировать. Унификация чище.
  • Clean break без deprecated — текущая база .nv маленькая (~30 файлов); миграция атомарным sweep’ом дешевле двух-keyword’ового периода + последующей чистки.

Что отвергнуто

  • Protocol[P] first-class тип — отвергнут как избыточный. Для эффектов Effect[E, IRT] нужен, потому что значение эффекта передаётся в with X = h (нужна типизация значения). У протоколов «значение, реализующее контракт» — это тип реализации; обёртка не нужна. Тривиальный alias решит, если когда-нибудь понадобится (Q-protocol-type-wrapping).
  • deprecated handler alias — отвергнут (clean break, ~30 файлов миграции).
  • Static в protocol-литерале — отвергнут (нет «своего типа»; см. D35).
  • Изменение семантики handler’ов — нет, только rename keyword’ов.

Связь

  • D53 — protocol declaration; D53 §628 (анон-protocol в type-position) ✅ реализовано (Plan 97 Ф.2).
  • Protocol-литерал codegen — value protocol Name { ops } с runtime vtable + dispatch — ✅ реализовано в подплане Plan 97.1 (emit_protocol_lit + расширенный Plan 56 D122 box-pattern). Capability-split factory pattern работает end-to-end.
  • D61 — handler-литерал; rename keyword handlereffect (Plan 97 Ф.3).
  • D87Effect[E, IRT]; rename в Effect[E, IRT] (Plan 97 Ф.3).
  • D88 — default generics (Effect[E]Effect[E, Never]).
  • D143.method-префикс для static в protocol-body (закрывает Q-static-method-protocol).
  • D35 — static vs instance методы.
  • D22 — closure capture-rules.
  • Q-keyword-symmetry — закрывается этим D-блоком.
  • Plan 97 — имплементация parser + AST + type-checker.
  • Plan 97.1 — runtime codegen (vtable + dispatch) + followup-hardening (Nova-side enforcement, capture-mode by-value snapshot для factory, shadowing fix, scan_fwd recurse, GC stress, multi-method, nested).
  • Ориентиры: Java/Kotlin (anonymous interface), TS (object-literal structurally), Koka/Eff (handler-literal).

Canonical example — capability-split factory pattern

Use-case D142, разблокированный Plan 97.1 codegen’ом:

type Reader protocol { read() -> int }
type Writer protocol { write(v int) -> () }

type Cell { mut value int }

fn Cell.new(initial int) -> (Reader, Writer) {
    ro state = Cell { value: initial }
    ro r = protocol Reader { read() => state.value }
    ro w = protocol Writer { write(v) { state.value = v } }
    (r, w)
}

// caller:
ro (r, w) = Cell.new(10)
ro initial = r.read()    // 10
w.write(99)
ro after = r.read()      // 99 — shared state через protocol-литералы

Реализация (Plan 97.1 emit_protocol_lit, Approach A):

  1. Литерал protocol Reader { read() => state.value } создаёт synthetic struct Nova_ProtoLit_<N> с capture-field state.
  2. Free fn Nova_ProtoLit_<N>_method_read(self, ...) использует self->state->value.
  3. Allocate NovaVtable_Reader* + ctx; patch vt->read = impl_fn.
  4. Возврат NovaBox_Reader { .data = ctx, .vtable = vt } (fat-pointer pattern Plan 56 D122).

Method dispatch r.read()r.vtable->read(r.data) — стандартный vtable indirect call.

Capture-rules:

  • Heap obj / let mut → by-pointer (alias, mutation visible).
  • Immutable scalar / fn-param → by-value snapshot (factory-safe, survives fn exit).

D144. Sub-slice views для []T и strarr[a..b] / s[a..b]

Amended (Plan 138 D238+D239, 2026-06-10): arr[i] для user-типов (Vec[T], HashMap[K,V] и т.д.) теперь через @index protocol (D238). []T = Vec[T] (D239); typed-storage gap закрыт — []Option[int], []Record и другие exotic-element типы получают правильное typed хранение. Range-slicing v[2..5] для Vec[T] — через @index(Range) overload. Предложение «future language version» из D232 Migration path снято: D239 фиксирует []TVec[T] как текущую спецификацию.

Источник: Plan 96 (2026-05-23). Закрывает Q-array-slicing, Q-array-api.5, D27 §1663 drift («Слайсинг отложен»), D27 §1632 drift (raw arr[i] без bounds-check). Зависит от D6 non-moving GC; D58 Range; D27 []T API; Plan 90 / D141 bulk-ops.

Семантика — sub-slice view

arr[range] где range : Range возвращает view — новый 24-байтовый header NovaArray_T* с data = orig->data + from, len = cap = to - from. Без копии данных backing’а (O(1) creation).

str[range] возвращает codepoint-indexed view (двухпроходный walk UTF-8 → byte offsets; structurally идентично nova_str_slice, но с panic при OOB вместо clamp).

5 форм Range (Rust RangeBounds parity)

ФормаСемантикаOpen-ended?
arr[a..b]exclusive: [a, b)нет
arr[a..=b]inclusive: [a, b]нет
arr[a..]от a до концада (end = len)
arr[..b]от начала до bда (start = 0)
arr[..]весь массивда

Open-ended формы — только в slice-context (arr[range]). В materialize / for-loop / quantifier / parallel-for они отвергаются с compile-time diagnostic «open-ended Range without bound (Plan 96)».

Single-type design

[]Tодин тип для owner и view. Нет Slice[T] (Rust-модель раздельных типов). View передаётся в функцию ждущую []T без дополнительной конверсии.

cap == len invariant

View имеет cap == len == to - from. Push на view → realloc (как обычно при exhausted cap) → view silent detach от parent. Parent backing никогда не молча перезаписывается — это устраняет Go-append-footgun без borrow checker’а.

mut parent = [1, 2, 3, 4, 5]
mut view = parent[1..4]   \ view: [2, 3, 4]
view.push(99)                  \ realloc; view detached
\ parent == [1, 2, 3, 4, 5]   — НЕ затронут
\ view == [2, 3, 4, 99]

Mut-семантика

mut-view только от mut-источника. Через mut-view write идёт в shared backing — изменения видны parent. Несколько mut-view одного backing’а разрешены (как в Go); caller responsibility, никакого borrow checker’а.

Iterator invalidation

for x in viewlen берётся snapshot’ом в начале цикла (Go-style). Push на parent во время итерации view’а не виден view’у: parent реаллоцирует, view продолжает указывать на старый backing через interior-pointer.

GC requirement — interior pointers stable

Необходимое условие: runtime гарантирует stable interior pointers (non-moving GC, D6). View хранит data = backing->data + from — это указатель внутрь backing’а; Boehm (GC_set_all_interior_pointers(1)) держит backing alive по interior-ptr.

Любая будущая замена GC-backend на moving GC требует одновременной замены slice-представления (separate header struct + ptr-update on move). Это закрепляется здесь как нормативный invariant.

Bounds-check

  • from < 0 → panic
  • to < from → panic
  • to > len → panic (для str — to > total_codepoints)
  • Empty slice (arr[a..a]) → валиден
  • Отрицательные индексы → panic, не Python-style wrap

Сообщение panic’а: "array: slice [N..M] out of bounds for length L" (паритет с Go/Rust).

Также: raw arr[i] bounds-check (D27 §1632 drift)

D144 одновременно фиксирует pre-existing drift: codegen arr[i] теперь эмитит runtime bounds-check (раньше эмитил голый (arr)->data[i] — controlled buffer overflow на запись, UB на чтение). Сообщение: "array: index N out of bounds for length L".

Concurrency / M:N

Slice-view = shared mut backing между fiber’ами в M:N runtime = формально UB по D79. В D71 single-threaded bootstrap — OK по факту. Передача view через Channel[]T] или spawn-capture в M:N — inherits D79 disclaimer.

Header layout

24 байта (ptr + len + cap) — тот же что у owner. Не оптимизировано до 16 байт (которое требовало бы отдельного типа Slice[T] — отвергнуто single-type-design’ом).

str[a..b] — bracket syntax для строк

Bracket-форма унифицирует idiom: arr[a..b]str[a..b]. Codepoint-indexed (как существующий nova_str_slice метод). Panic при OOB (consistent с arr[a..b]).

Старый s.slice(a, b) метод — сохраняется с clamp-семантикой для backwards-compat; align на panic откладывается в Plan 94 (см. [P-str-slice-clamp-vs-panic] в docs/simplifications.md).

Verified против

  • Go s[a:b] — паритет, без append-footgun.
  • Rust &[T] — близко, без borrow checker (caller responsibility для multi-mut).
  • TypeScript TypedArray.subarray — паритет.
  • Swift ArraySlice<T>без CoW-disconnect (view сразу видит mut).
  • Python memoryview — паритет.

Связь

  • D6 — non-moving GC; interior-ptr invariant амендится здесь.
  • D27[]T API; §1632 bounds-check (D144 чинит drift); §1663 «Слайсинг отложен» (D144 закрывает).
  • D58 — Range-литералы; D144 расширяет до 5 форм (open-ended).
  • D79 — shared mut между fiber’ами = UB в M:N; slice inherits.
  • D141 — Plan 90 bulk-ops; работают на view автоматически.

D145. fn[T] префикс — receiver-generic decl + bounds (Plan 101)

Status: MOSTLY CLOSED (2026-05-25, ред. 6 — Plan 101.1/2/3/4 ✅, 101.5 partial). Plan 101.1 codegen для non-int mono-dispatch — marker [M-fn-prefix-int-only-mono] ✅ RESOLVED (Plan 101 Group I, vec_map_int_str fix).

AMEND (2026-06-14, Plan 153.5): вложенные generic-ресиверы произвольной глубины (fn[T] [][]T @m / fn[T] Vec[Vec[T]] @m — structural typevar-bind в самый внутренний элемент, depth-agnostic) — см. секцию «AMEND … вложенные generic-ресиверы» ниже. Разблокировало @flatten (D263); закрыло [M-153.5-flatten-nested-receiver].

Реализовано (Plan 101.1–101.4 + 101.2):

  • 101.1 ✅ — Parser fn[T] ReceiverType @method + 5 disambiguation error codes (E_UNDECLARED_TYPEVAR_IN_RECEIVER, E_BARE_TYPEVAR_NEEDS_PREFIX, E_DUPLICATE_GENERIC_DECL, E_PREFIX_SHADOWS_NAMED_TYPE, E_UNUSED_PREFIX_TYPEVAR). Codegen mono []int element + bare-T + non-int element (через Plan 95 array-ext infrastructure). vec.nv migration: 7 методов.
  • 101.2 ✅ — Bound integration: method-call bound enforcement (check_method_call_bounds в types/mod.rs); receiver-generic fn[T Bound] []T @m ловит violation на call-site xs.m().
  • 101.3 ✅ — Multi-bound [T A + B]: GenericParam.bound → bounds Vec, parser + Type chain, type-check iterate all bounds (conjunction), strict check_generic_bound_declarations (E_BOUND_UNKNOWN / E_BOUND_NOT_PROTOCOL).
  • 101.4 ✅ — Protocol composition use TypeName в protocol body: AST TypeDeclKind::Protocol { methods, embeds }, parser parse_protocol_body, type-check flatten DFS + 5 диагностик (E_PROTOCOL_EMBED_{UNKNOWN, NOT_PROTOCOL, CYCLE, DUPLICATE, AFTER_METHOD, NOT_NAMED}).
  • 101.5 partial — stdlib audit: только vec.nv использует fn[T] prefix (7 методов работают; non-int — deferred). HashMap/PQ/Lru используют carrier-brackets (Plan 15 D72 path, unchanged).

Deferred (followup):

  • vec_map_int_str — T=int U=str cross-type case ✅ RESOLVED (Plan 101 Group I, M-fn-prefix-int-only-mono).
  • LSP quick-fixes (Plan 101.5 V2).

Ред. 3 (2026-05-24): complete rewrite после critical review. Ред. 1 описывала narrow fn[T] only. Ред. 2 ошибочно ввела implicit-T (моя misinterpretation D35). Ред. 3 — finalized design: никакого implicit T, fn[T] префикс обязателен везде где receiver не имеет carrier-brackets, + bounds через existing D72,

  • multi-bound +, + protocol composition use Foo.

Ред. 5 (2026-05-25): Plan 101.3 (multi-bound [T A + B]) и Plan 101.4 (protocol composition use TypeName — pivot от earlier discussion A1 use A, B к более читаемому line-per-use) финализированы и реализованы.

Ред. 3 (2026-05-24): complete rewrite после critical review. Ред. 1 описывала narrow fn[T] only. Ред. 2 ошибочно ввела implicit-T (моя misinterpretation D35). Ред. 3 — finalized design: никакого implicit T, fn[T] префикс обязателен везде где receiver не имеет carrier-brackets, + bounds через existing D72,

  • multi-bound +, + protocol composition use Foo.

Что

Generic-параметры функции в receiver-position декларируются по одному из двух механизмов, в зависимости от формы receiver’а:

  1. Carrier-brackets на named generic-типе — existing D119:
    • fn Option[T] @map[U] — T в Option[T] декларирует T.
    • fn HashMap[K, V] @keys() — K, V в HashMap[K, V].
    • fn Result[T, E] @ok() — T, E.
    • С bound (D72): fn HashMap[K Hash, V] @from_pairs(...).
  2. fn[T] префикс (новое, D145) — для receiver’ов без carrier brackets: bare T, []T, tuple (T, U), composite без carrier:
    • fn[T] T @identity() -> T => @ — bare typevar.
    • fn[T] []T @map[U](f fn(T) -> U) -> []U => ... — array.
    • fn[T, U] (T, U) @swap() -> (U, T) => (@1, @0) — tuple.
    • fn[T Hash] []T @dedup() -> []T => ... — bounds через D72.
    • fn[T A + B] []T @method() => ... — multi-bound через + (Plan 101.3).

Правило

Когда fn[T] обязателен

fn[T1, ..., Tn] префикс обязателен для каждого typevar в receiver-position, который не декларируется через carrier-brackets именованного generic-типа. Конкретно:

Receiver-shapeCarrier?fn[T] нужен?
Option[T], HashMap[K, V]да named-bracketsнет
[]Tнет — [] not bracket-declда fn[T] []T
T bareнетда fn[T] T
(T, U) tupleнет — tuple-parens not bracket-declда fn[T, U] (T, U)
(T, Option[U]) mixT нет, U через Optionда fn[T] (T, Option[U])
[]Option[T] compositeT через Option[T]нет

Запрет дублирования

fn[T] запрещён для typevar, который ТАКЖЕ декларируется через carrier-brackets:

fn[K Hash, V] HashMap[K, V] @method   // ERROR E_DUPLICATE_GENERIC_DECL
// K, V уже декларированы через HashMap[K, V]; используй
// fn HashMap[K Hash, V] @method

Disambiguation: bare T vs named type

fn-prefixReceivertype T в scope?Result
TдаOK — метод на named T (D35 status quo)
Tнетerror E_BARE_TYPEVAR_NEEDS_PREFIX
[T]TнетOK — generic, T = typevar
[T]Tдаerror E_PREFIX_SHADOWS_NAMED_TYPE
[]Tда или нетparse OK — но если есть named T, T = named (silent miscompile risk; см. ниже)
[T][]Tда или нетOK — explicit prefix wins, T = fn-generic

Critical: fn []T @method без fn[T] префикса и без type T в scopetype-check error: «T не объявлен ни через carrier-brackets, ни через fn[T] префикс, ни как named type». Закрывает silent-miscompile gap (vec.nv pre-Plan-101 поведение).

Bound syntax (через D72)

fn[T Hash] []T @dedup() -> []T => ...
fn[T A + B] []T @method() => ...                    // multi-bound (Plan 101.3)
fn[K Hash, V] (K, V) @key_value() -> (K, V) => @
fn[T From[K], K] T @construct_from(v K) -> T => T.from(v)   // parametric protocol

Bound = protocol-тип (D72) ИЛИ type-set (D310, Plan 172.3). Type-set — именованное множество конкретных типов (type SignedInt set i8 | i16 | …), используемое как bound: fn[T SignedInt] T.parse(...). Композиция type-set ∧ protocol — через тот же + ([T SignedInt + Hash]): T ∈ set И реализует protocol, проверки независимы per-member; не более одного type-set в одном bound-листе (E_MULTIPLE_TYPE_SETS). Произвольные representation/underlying bounds (~int, structural) — по-прежнему open question Q-representation-bound, Plan 102 (future); D310 закрывает только explicit-member-set, не representation.

Protocol composition (Plan 101.4 — закрывает D53 open question)

Protocols composed через use A, B keyword внутри protocol body. Параллель D39 record-embed (same keyword, разная семантика). Composition валиден в type-decl и anonymous type-position. Literal-position — composition ОТВЕРГНУТА (см. ниже).

type Reader protocol { read(buf []u8) -> int }
type Writer protocol { write(buf []u8) -> int }

// 1. Multi-composition в type-decl:
type ReadWriter protocol {
    use Reader, Writer       // embed
    close() -> ()            // own method
}

// 2. Single-composition (естественно, без ambiguity):
type ReadExt protocol {
    use Reader
    job() -> ()
}

// 3. Pure composition без own methods:
type Streamable protocol {
    use Reader, Writer, Closeable
}

// 4. Mix anywhere в block — order independent:
type Complex protocol {
    init() -> ()
    use Reader
    helper() -> int
    use Writer
}

// 5. Anonymous-composition в type-position (extension D53):
fn process(rw protocol { use Reader, Writer }) { ... }

// 6. Использование как bound — composed protocol работает как named:
fn[T ReadWriter] []T @process() => ...
// эквивалентно fn[T Reader + Writer] []T @process() (101.3 multi-bound)

Семантика:

  • use A, B, C — flatten method-signatures из A, B, C в этот protocol.
  • Resulting method-set = union(A, B, C, own_methods).
  • Multiple use-statements аккумулируются: use A, B; use Cuse A, B, C.
  • T satisfies composed-protocol ⟺ T has все methods из union.

Реализация ред. 5 (2026-05-25, Plan 101.4):

  • Парсер поддерживает обе формы: use A, B (comma-list, как в spec) и use A\n use B (line-per-use, более читаемо в большом protocol’е).
  • Все use-items должны идти В НАЧАЛЕ protocol body — interleaving с методами запрещён (E_PROTOCOL_EMBED_AFTER_METHOD). Это упрощает чтение: сначала видишь “состав”, потом “новое”.
  • Type-check ловит:
    • E_PROTOCOL_EMBED_UNKNOWN — embed target не объявлен.
    • E_PROTOCOL_EMBED_NOT_PROTOCOL — target существует, но не protocol.
    • E_PROTOCOL_EMBED_CYCLE — A use BB use A (или self-embed).
    • E_PROTOCOL_EMBED_DUPLICATE — после flatten’а ≥2 method из разных embed-источников с тем же (name, arity). Override-механизм отложен.
    • E_PROTOCOL_EMBED_NOT_NAMED — use <complex type> запрещено.

Literal-composition — отвергнута:

// ❌ ОТВЕРГНУТО:
ro v = protocol Foo {
    use Reader               // error: E_LITERAL_COMPOSITION_NOT_ALLOWED
    read(buf) => impl1
    close() => impl2
}

// Workflow: extract в named type:
type MyRW protocol { use Reader, Writer }
ro v = protocol MyRW {
    read(buf)  => impl1
    write(buf) => impl2
}

Почему literal-composition отвергнута: literal — value-construction (impls), composition — type-level operation. Смешивать слои когнитивно нагружено. Industry-aligned — Rust/Go/Java/Kotlin/Scala не разрешают anonymous-composition в literals.

Asymmetry с multi-bound (101.3) [T A + B] оправдана: разные contexts — multi-bound = use-site intersection при satisfaction-check; protocol composition = decl-time method-set union. Разные scopes, разные операторы.

Differences vs D39 (record-embed):

  • D39 record use name Type (field-form, runtime delegation+field).
  • D53+ protocol use Type[, Type]* (нет field, compile-time method-set union).
  • Same keyword use — same intuition «include this stuff». Parser распознаёт по контексту (record-body vs protocol-body).

Многократное использование одного имени

Одно имя — один generic во всей сигнатуре (existing D119 / D72 convention):

fn[T] (T, T) @duplicate(a T) -> (T, T) => (a, a)   // T дважды → один T
fn[T] [][]T @flatten() -> []T => ...                // T в receiver и return — один T

AMEND (2026-06-14, Plan 153.5 commit 1c323d0e): вложенные generic-ресиверы произвольной глубины

fn[T]-typevar в receiver-position теперь связывается структурной унификацией на ЛЮБОЙ глубине вложенности — не только на верхнем уровне элемента. Это закрывает дыру, из-за которой [][]T @flatten() (= carrier-форма Vec[Vec[T]] @flatten() под D239) не работал: тело должно назвать внутренний T, а компилятор биндил его в непосредственный элемент.

Корень (обе формы записи теряли вложенность до фикса):

  • Carrier Vec[Vec[T]] — ПАРСЕР отвергал вложенный тип в carrier-слоте (parse_generic_decl_params ждал parse_ident на каждый слот → «expected ], got identifier»).
  • Slice [][]T — ПАРСИЛСЯ, но монорфизатор биндил receiver-typevar T в непосредственный элемент (Vec[int]), не во внутренний (int) — вложенность [][]T схлопывалась в один "[]T"-ресивер → тело строило out []T == Vec[Vec[int]], возвращало неверный тип (verified probe RUN-FAIL, mono’d out = Nova_Vec____Nova_Vec____nova_int_p).

Правило (после AMEND):

  • Обе формы записи приняты и эквивалентны под D239: fn[T] Vec[Vec[T]] @mfn[T] [][]T @m. Парсер несёт полный структурированный тип ресивера в Receiver.receiver_ty (type_name его flatten’ит в "[][]T" и теряет глубину — поэтому нужен отдельный структурный слот).
  • Receiver-typevar биндится в самый внутренний элемент, рекурсивно (depth-agnostic): для Vec[Vec[T]]/[][]T T = element-of-element; для Vec[Vec[Vec[T]]]/[][][]T T = element третьего уровня; и так далее. Унификация — структурная (по форме типа), не one-level-hardcoded.
  • Свободные typevar’ы collect’ятся рекурсивно из вложенного carrier-слота для проверки E_UNUSED_PREFIX_TYPEVAR (typevar объявлен в fn[T], но не упомянут в ресивере → ошибка) — собираются из receiver_ty, а не из flatten’енного имени.
  • E_UNDECLARED_TYPEVAR_IN_RECEIVER сохраняется для fn []T @m / fn [][]T @m без fn[T]-префикса (scope-typevar НЕ сидится из receiver_ty — это намеренно, иначе ошибка маскировалась бы; см. checker-заметку).
fn[T] [][]T @flatten() -> []T => ...                // T = innermost (depth 2) — РАБОТАЕТ
fn[T] Vec[Vec[T]] @flatten() -> Vec[T] => ...       // carrier-форма, ≡ выше под D239
fn[T] [][][]T @deep_count() -> int => ...           // depth 3 — T = innermost
fn[T] [][]T @first_row() -> []T => ...              // вложенный-типизированный return

Реализация (depth-agnostic, без one-level-hardcoding):

  • ASTReceiver.receiver_ty: Option<TypeRef> несёт полный структурированный тип вложенного ресивера (единственное место, где глубина переживает — type_name flatten’ит в "[][]T").
  • Parser — slice [][]T: счёт глубины Array + спуск до внутреннего Named → строит Array(Array(Named T)). Carrier Vec[Vec[T]]: новый разбор принимает ВЛОЖЕННЫЙ parse_type в слоте (детект Ident[) + рекурсивный сбор free-typevars; структурные слоты сворачиваются в receiver_ty. Free-fn [T Bound=D]-разбор не тронут.
  • Mono — переиспользован существующий рекурсивный infer_type_param_binding для структурного бинда receiver-typevar (Array-арм также снимает mono-форму Vec____, восстанавливая элемент через generic_type_instance_info); override применён на ВСЕХ путях, биндящих receiver-typevar (emit-dispatch carrier + []T-sentinel slice + call-site return-inference). Depth-aware sentinel-ключи "[]"*N+"T" заменили hardcoded "[]T". Flat []T (depth 1) остался byte-identical (legacy NovaArray_-путь); override гейтится receiver_ty_is_nested — только для реально вложенных ресиверов.
  • Checker — вложенные typevar’ы из receiver_ty собираются в referenced-множество для E_UNUSED_PREFIX_TYPEVAR; scope gs НЕ сидится из receiver_ty (сохраняет E_UNDECLARED_TYPEVAR_IN_RECEIVER — verified, что seed был бы регрессией).

Cross-cutting заметка. Это путь, через который идут ВСЕ []T-методы stdlib (slice- dispatch). Изменение специально гейтнуто на genuinely-nested ресиверы → flat-случай неизменен. См. D263 AMEND (@flatten использует этот фундамент).

Известное ортогональное ограничение (pre-existing, вне scope): slice-форма fn[T] [][]T -> []T, чьё тело строит свежий Vec[T].new(), упирается в pre-existing erased-base-body лимит, который ЛОМАЕТ и flat fn[T] []T с Vec[T].new() на baseline (expected struct 'Vec____Nova_T_p'). Production-flatten — CARRIER-форма Vec[Vec[T]] @flatten (как все stdlib), работает полностью; slice-form nested-receiver binding доказан отдельно (@count_all/@first_row).

Backward-compat

  • 100% преserve для existing fn Option[T] @map[U], fn HashMap[K, V] @keys, fn Result[T, E] @ok, fn HashMap[K Hash, V] @methodD145 строго аддитивно.
  • std/collections/vec.nv содержит 7 методов pattern fn []T @method[U] (написан как-если-бы T дженерик). Это bug — T silently трактуется как named type, codegen падает. Plan 101.1 включает migration vec.nv → fn[T] []T @method[U].

Параллель индустрии — таблица

LangSynтакс для array-methodBound syntax
Rustimpl<T> Vec<T> { fn map<U> }<T: A + B>
Gofunc (v Vec[T]) Map[U][T A | B] (union, не intersection!)
TypeScriptfunction map<T, U>(arr: T[], f)T extends A & B
Kotlinfun <T, U> Array<T>.map(f)<T : A> + where T : B
Scala 3extension [T](arr: Array[T]) def map[U]T <: A & B
Java<T, U> U[] map(T[] arr, ...)<T extends A & B>
Nova D145fn[T] []T @map[U][T A + B] (Rust-style +)

Nova edge:

  1. Cleanest receiver syntaxfn[T] []T @map короче Rust impl<T> Vec<T> { fn map<U> } (2 nested blocks → 1 line).
  2. Bound syntax без двоеточия[T Hash] (D72) — параллель Nova name type convention (params, fields, let).
  3. Multi-bound + familiar — Rust audience узнаёт.
  4. Protocol composition через use — параллель D39 record-embed, единое правило.
  5. Loud disambiguationE_BARE_TYPEVAR_NEEDS_PREFIX / E_PREFIX_SHADOWS_NAMED_TYPE явные, не silent miscompile.
  6. Future-proofQ-representation-bound открыт для extension на concrete-type bounds (Plan 102).

Lineage

  • Plan 48 / D119 — method-level + receiver-via-carrier generics.
  • Plan 72 / D72 — bound syntax [T Bound] (free fn + type-decl). D145 переиспользует в новой позиции (fn[T Bound] prefix).
  • Plan 88 — static-method-on-typevar.
  • Plan 99 — Option/Result closure-applying на Nova-body (paritет).
  • D39use Type embed для records. D145 переиспользует pattern для protocol composition (Plan 101.4).
  • D53type X protocol { ... }. D145 закрывает open question «Composition protocol’ов» через 101.4.

См. также

  • D72 — bound syntax.
  • D119.
  • D39use для embed.
  • D53 — protocol decl.
  • Plan 101 master
    • 5 sub-plan’ов:
    • 101.1 — core fn[T] grammar + codegen + vec.nv migration (P1, blocker Plan 91).
    • 101.2 — bound integration fn[T Hash].
    • 101.3 — multi-bound [T A + B], closes Q-multi-bound.
    • 101.4 — protocol embedding use Foo, closes D53 open question.
    • 101.5 — stdlib audit + LSP + close.
  • Q-representation-bound — concrete-type bounds (newtype/embed-aware), Plan 102 future.

D372. Canonical .new() constructors (convention)

Статус: convention (stdlib provides, compiler does NOT auto-generate).

stdlib предоставляет .new() для типов с единственным очевидным default-значением:

Тип.new() возвращаетФайл декларации
int, u8u64, i8i640std/runtime/defaults.nv
f32, f640.0std/runtime/defaults.nv
boolfalsestd/runtime/defaults.nv
str""std/runtime/string.nv
[]T (для любого T)[] (empty array)builtin (emit_c.rs)

Также []T.with_capacity(n int) -> Self — empty с pre-allocated capacity (builtin). Retracted — см. амендмент ниже (2026-07-06): with_capacity удалён, ёмкость теперь свойство cap.

Для своих типов разработчик пишет .new() явно. Компилятор НЕ автогенерирует для user records / sum types / consume types. Это design discipline:

  1. Явный конструктор виден в nova doc и IDE.
  2. Имена кодируют намерение (User.new(name, email) vs User.guest()).
  3. Валидация инвариантов в момент создания.
  4. Эволюция типа: добавление поля заставляет обновить конструктор — good failure (компилятор поймает breaking change).

НЕ имеют canonical .new() (convention — не использовать; enforcement diagnostic — followup [M-91.7-default-new-enforcement]):

  • char ('\0' сомнителен как «default»)
  • Result[T, E] (Ok или Err? ambiguous)
  • Option[T] — каноничен, но codegen ограничение для generic builtin sum static methods откладывает Nova-side декларацию (followup [M-91.7-option-new-static]). До закрытия — использовать None напрямую.
  • tuples ((int, str) etc.)
  • user-defined records / sum / consume types — по конвенции этого блока
  • protocols, fn types, external/opaque

Пример

// stdlib provides:
ro x = int.new()      // 0
ro s = str.new()      // ""
ro a = []int.new()    // []
ro buf = []u8.with_capacity(1024)

// User type — explicit:
type User { name str, email str, is_admin bool }
fn User.new(name str, email str) -> Self => { name, email, is_admin: false }
fn User.guest() -> Self => { name: "guest", email: "", is_admin: false }

Связь

  • D26 — prelude auto-availability.
  • D66Self в return type.
  • D131 — consume / fluent.
  • D182Self requirement.
  • Plan 91.7.

Амендмент (vec-sweep, 2026-07-06): with_capacity удалён, ёмкость — свойство cap. with_capacity(n) -> Self как отдельный static-конструктор УДАЛЁН для Vec[T]/[]T, HashMap[K,V], Set[T], StringBuilder, WriteBuffer, Queue[T] (везде, где он существовал). Ёмкость теперь read/write СВОЙСТВО по D117 (arity-overload): X.cap() -> int (getter) / X.mut cap(n int) -> @ (setter). Конструкция с pre-allocated capacity — X.new().cap(n) вместо бывшего X.with_capacity(n).

Semantics по типу:

  • Vec[T]/[]T: cap(n) — ТОЧНАЯ ёмкость (requires n >= @len(), без округления), полностью взаимозаменяема с бывшим with_capacity(n).
  • HashMap[K,V]/Set[T]: cap(n) гарантирует МИНИМУМ n вставок без rehash (entry-count → bucket-count, как раньше), но только РАСТЁТ на непустой карте; на СВЕЖЕЙ (@_count == 0) карте допускает и уменьшение до точного целевого bucket-count — так new().cap(n) воспроизводит старое with_capacity(n) побайтово.
  • WriteBuffer/StringBuilder: cap(n) делегирует к внутреннему []u8’s собственному точному cap(n).
  • Queue[T]: cap(n) резервирует ёмкость НА ОБОИХ backing-массивах (_inbox/_outbox) одновременно.

from_raw_parts → перегрузка new (амендмент, vec-sweep 2026-07-06). Vec[T].from_raw_parts(ptr, len, cap) -> Self переименован в Vec[T].new(ptr *T, len int, cap int) -> Self — арность-перегрузка статического new (0-арг = пустой Vec, 3-арг = raw components). Контракт unsafe-обязательства на call site — БЕЗ ИЗМЕНЕНИЙ (см. текст на месте декларации, std/collections/vec/core.nv). @into_raw (обратная операция) не переименован.

Известные компиляторные гэпы, обнаруженные при миграции:

  • [M-vec-spelling-array-value-position-cap-collision] — цепочка []T.new().cap(n) В ОДНОМ выражении может мис-дispatch’иться по NAME+ARITY на «cap»-метод НЕСВЯЗАННОГO ко-компилируемого типа (не только erased-array случай D239-амендмента выше — воспроизведено и для произвольных типов). Обход: биндинг в explicitly-typed локаль ПЕРЕД вызовом .cap(n) отдельной инструкцией.
  • [M-vec-spelling-consume-chain-cap-collision]consume x = T.new().M(...) (ЛЮБОЙ 2-звенный chain, забинженный через consume, для T consume-типа) ломает D133 consume-tracking для ВСЕХ ОСТАЛЬНЫХ consume ... = T.new() site’ов в той же compile unit (воспроизведено вне зависимости от имени второго метода — не специфично для cap). Обход: consume x = T.new() (один вызов) + x.M(...) отдельной инструкцией.
  • [M-vec-spelling-maplit-desugar-cap-ice] — попытка добавить .cap(n) pre-sizing statement в desugar [k:v]-map-литерала (после HashMap.new(), до insert_new-цикла) роняла компилятор (ICE: “method call .insert_new return type unknown”) при компиляции нескольких файлов вместе в одной CU. Обход (принят): pre-sizing убран из desugar’а полностью — map-литералы строятся через голый .new() + amortized growth в insert_new-цикле (perf-only регрессия, corretness не затронута).

D181. Array methods — -> @ fluent mut chain + slice syntax

Статус: active (Plan 91.7, 2026-05-28).

Амендмент-упрощение (Plan 184, 2026-07-06). Тип -> @ больше не выводится эвристикой «heap-алиас / value-копия-с-распадом» (исходные D326-R7/R8: RETURN-оракул D246 + escape-decay D228). Теперь -> @ имеет конкретный тип по категории Self (таблица Р7 ревизии D326-Plan184): у стекового (value) типа -> @ = -> ref Self; у кучевого (heap) — -> Self. Эвристики bind-site заменены типами; поведение fluent-цепочек (a.push(1).push(2)) не меняется.

-> @ для всех mut-методов []T

Все мутирующие методы массива возвращают @ (receiver pointer) для fluent chain (D131):

МетодСигнатура
@push(v T)-> @
@reserve(extra int)-> @
@truncate(n int)-> @
@fill(v T)-> @
@copy_from(src readonly []T)-> @
@extend_from(src readonly []T)-> @
@insert_from(i int, src readonly []T)-> @
@copy_within(src_from, dst_from, len)-> @
@sort() (Nova-side)-> @
@sort_by(cmp)-> @

Non-mut методы (@get(i), @pop()) возвращают Option[T] — unchanged.

Пример

mut a = []int.new()
a.push(1).push(2).push(3).reserve(10)
a.sort()                       // direct call
ro r = a.sort_by(|x,y| ...)   // can also return into binding

Slice — только bracket syntax (Plan 96)

Метод @slice(from, to) -> []T удалён. Используйте arr[a..b] (zero-copy view, см. Plan 96 / D-str-slice). Один очевидный путь.

Известные ограничения

  • Mixed Nova-method + builtin chain: a.sort().push(99) — codegen пока эмитит a->sort() (struct field access) вместо function call. Followup [M-91.7-mixed-method-chain]. Workaround: разнесите вызовы.
  • Generic sort/min/max для [T Ord] — followup [M-91.7-sort-generic]. Текущий MVP — concrete []int @sort() (Plan 91.3).

Связь

  • D131 — fluent API семантика -> @.
  • D177 — Nova-body dispatch механизм.
  • Plan 90.1 — extend-family (extend_from, insert_from, reserve).
  • Plan 96arr[a..b] slice syntax.

D182. Self в return-type static methods — required form для parametric types

Статус: active (Plan 91.7, 2026-05-28).

Правило

Для static-методов на параметризованных типах (fn Option[T].new(), fn HashMap[K, V].new(), etc.) return-type должен использовать Self, а не explicit-form -> Option[T] / -> HashMap[K, V].

Rationale:

  1. Explicit-form дублирует тип-параметры — redundant.
  2. Self устойчив к переименованию типа (rename-safe).
  3. Self явно говорит «возврат того же receiver-типа» — semantic clarity.
  4. Single canonical form — D9 «один очевидный путь».

Примеры

// ✅ Correct (canonical):
export fn Option[T].new() -> Self => None
export fn HashMap[K, V].new() -> Self => { ... }
export fn StringBuilder.new() -> Self => { ... }

// ❌ Wrong (explicit redundant form):
export fn Option[T].new() -> Option[T] => None
export fn HashMap[K, V].new() -> HashMap[K, V] => { ... }

Для primitive receiver types

Self тоже рекомендуется для consistency:

export fn int.new() -> Self => 0          // канонично
export fn int.new() -> int => 0           // допустимо, но не canonical

Codegen requirement

Self в return-type корректно resolved через current_receiver_type ⇒ правильный C type:

  • primitive receiver → primitive value type (nova_int, nova_bool, …)
  • Option/Result → sum repr (NovaOpt_<T>, NovaRes_<ok>_<err>*)
  • user record → Nova_<TypeName>*

См. emit_c.rs::type_ref_to_c "Self" case — делегирует в receiver_c_type.

Enforcement

Validation rule — followup [M-91.7-self-required-parametric]. Текущий compiler принимает обе формы; canonical форма документирована здесь.

Связь

  • D66Self универсальный.
  • D372.new() convention.
  • Plan 91.7.

D183. Canonical comparison protocols + default method bodies (Plan 91.8a)

Статус: active (Plan 91.8a, 2026-05-29).

Канонические протоколы (renames)

БылоСталоФайл
Iter[T]Iterable[T]Next[T] + Iter[I] (Plan 138 D241+D242)std/prelude/collections.nv
DisplayDisplaystd/prelude/protocols.nv
Equal.eq(other Self) -> boolEqual.equals(other Self) -> boolstd/prelude/protocols.nv
Compare.cmp(other Self) -> OrderingCompare.compare(other Self) -> intstd/prelude/protocols.nv
Hash.hash() -> u64unchangedstd/prelude/protocols.nv

Rationale renames:

  • -able suffix convention — unified naming (Iterable/Equal/Compare/Hash/Display).
  • Compare.compare -> int — единый стиль с str.compare() (D178) и C memcmp/strcmp. Ordering sum-type удалён.
  • Equal.equals — явнее чем eq (Java convention).
  • DisplayDisplay — действие через -able, не имя-noun.

Compare embeds Equal

export type Equal protocol {
    equals(other Self) -> bool
}

export type Compare protocol {
    use Equal
    compare(other Self) -> int
    equals(other Self) -> bool => @compare(other) == 0    // default body
}

use Equal (D39 embed) делает каждый Compare также Equal. Локальная декларация equals в Compare с default body overrides embedded default — implementer пишет только @compare, @equal auto-synthesized из default body как @compare(other) == 0.

Default method bodies в protocols

Правило (новое в D183):

Метод в protocol-декларации может иметь тело (=> expr или { ... }). Тело используется как default-реализация: если тип-implementer не задаёт свой @method, компилятор использует body из протокола, подставляя Self = receiver type. Если implementer задал @method явно — explicit version используется (override).

Семантика:

  • Метод без тела = abstract — implementer ОБЯЗАН реализовать.
  • Метод с телом = default — implementer МОЖЕТ override.

Пример:

type Compare protocol {
    use Equal
    compare(other Self) -> int                              // abstract
    equals(other Self) -> bool => @compare(other) == 0      // default
}

type MyDate { y int, m int, d int }
fn MyDate @compare(other MyDate) -> int { ... }
// @equal НЕ объявлен — используется default из Compare.

// Override для perf:
type FastHashed { hash_cache u64, ... }
fn FastHashed @compare(other FastHashed) -> int { ... }
fn FastHashed @equal(other FastHashed) -> bool {
    @hash_cache == other.hash_cache && @compare(other) == 0
}

Cleanup

  • Ordering sum-type удалён из std/prelude/core.nv.
  • Less / Equal / Greater exports удалены из std/prelude.nv.
  • std/sort.nv sort_by(cmp fn(int, int) -> int) — memcmp-style convention.
  • PRELUDE_VERSION bumped 12 → 13.

Memcmp-compatible int return

compare(other) -> int returns:

  • negative if @ < other
  • zero if @ == other
  • positive if @ > other

Caller должен использовать только sign (< 0, == 0, > 0), НЕ magnitude. Совместимо с C memcmp/strcmp convention. Implementer для primitive numerics рекомендуется использовать safe signum form:

fn int @compare(other int) -> int =>
    if @ < other { -1 } else if @ > other { 1 } else { 0 }

Не использовать => @ - other — overflow risk для больших int.

Реализация (части)

  • Парсер (compiler-codegen/src/parser/mod.rs::parse_effect_methods): добавлен parser default body после return_type/contracts. Body = => expr или { ... }. Поле EffectMethod.default_body: Option<Block> в AST.
  • check_protocol_embeds (compiler-codegen/src/types/mod.rs): local override embedded methods разрешён — locally declared метод в protocol с тем же именем что embedded не считается duplicate. Используется для Compare.equals overrides embedded Equal.equals default.
  • Codegen synthesis для defaults: followup [M-91.8a.2-default-codegen]. Сейчас implementer пишет default-method explicitly для compatibility (как boilerplate equals(o) => @compare(o) == 0).

Известные ограничения / followups

  • Codegen synthesis ([M-91.8a.2-default-codegen]): type T который имеет @compare но не @equal пока компилируется только если @equal объявлен явно. Eager synthesis из default body — отдельный codegen pass.
  • Operator dispatch (D363, Plan 91.8b): == всё ещё dispatches к @eq (D46). Renaming @eq@equal в operator dispatch — задача Plan 91.8b. До 91.8b implementer пишет оба: @equal (protocol) + @eq (operator).
  • Structural == для mono’d generic-sum + Result ✅ (Plan 153.3, commit 1cc82de5): дефолтное структурное == (tag + payload, без user @equal/@compare) теперь покрывает мономорфизированные generic-sum (Foo[int].A(1) == A(1)) и Result (NovaRes_*). Раньше оба тихо деградировали в pointer-identity: legacy sum_schemas keyed generic-именем (mono’d-ключ отсутствовал → emit_field_eq промахивался мимо schema), а Result-NovaRes_* (спец-ABI с typed-error-полями) не матчил Nova_-sum-тест. Фикс: reconstruct_mono_sum_schema (substituted-схема вариантов из generic-шаблона + recorded type-args; tag-префикс = полный Nova_<mono>) + NovaRes_-ветка в emit_field_eq/==-операторе через novares_ok_err. result == Ok(x) / == Err(x) ([M-153-result-eq-literal-expected-type] RESOLVED): голый Ok/Err-литерал с non-default-E (напр. binary_searchResult[int,int]) дефолтил E=str и не совпадал по типу с LHS → CC-FAIL. Фикс codegen-local: в ==-NovaRes_-ветке, если типы операндов расходятся и одна сторона — голый result-ctor, она переэмитится под concrete NovaRes_<n> другой (reemit_result_variant_as). General expected-type propagation для overload-резолва (@into) остаётся в Q-overload-result-type.
  • Generic sort/min/max (D373, ex-D185, Plan 91.8c): generic fn[T Compare] array methods — реализовано, см. D373.
  • D328 — Value-record == СТРУКТУРНОЕ (Plan 172.4 Ф.2, 2026-06-28): value-record (type P value {…} — C-репрезентация NovaValue_<Name> by-value, D228/D277/D290) сравнивается структурно (field-by-field, как sum/heap-record), а НЕ сырым C-== на struct (была acceptance-CC-FAIL «invalid operands to binary expression» — C не имеет struct-==). Обоснование: value-record — значение (нет heap-идентичности); равенство = по значению полей. Маршрутизируется через ЕДИНЫЙ emit_field_eq-диспетчер (§0 «один источник per-type операций»): добавлен NovaValue_-арм (by-VALUE доступ (l).field, не (*l)->field) + top-level-==/!=-роутинг в тот же диспетчер; user-@equal (если объявлен) приоритетен, иначе структурная рекурсия по record_schemas. Вложенные value-record-поля рекурсируют тем же армом. heap-record == (type P {…}, Nova_P*) — ОТДЕЛЬНАЯ ось: сейчас reference-eq; структурить ли — open (Plan 172.4 Ф.2 дизайн-вопрос, НЕ решается этим блоком — value-records однозначно структурны, heap-records обсуждаемо). Арифметика на value-record (@plus/…) — отдельный value-ABI концерн (Plan 172.4 Ф.3), не этот блок.

Связь

  • D26 — prelude auto-availability.
  • D39use embed.
  • D58 — structural typing.
  • D72 — bounds.
  • D109 — split policy (Hash не embeds Equal; Compare embeds Equal в D183).
  • D178str.compare -> int.
  • Plan 91.8a — implementation.

D183 amendment — Plan 91.8a.2 part 1: protocols refactor (orthogonal) + Self в param

Статус: active (Plan 91.8a.2 part 1, 2026-05-29).

Refactor: orthogonal protocols (canonical coercion form)

Было (91.8a part 1):

type Equal protocol {
    equals(other Self) -> bool
}
type Compare protocol {
    use Equal
    compare(other Self) -> int
    equals(other Self) -> bool => @compare(other) == 0   // override of embedded default
}

Стало (91.8a.2 part 1) — canonical:

type Equal protocol {
    equals(other Self) -> bool {
        ro cmp Compare = @                  // coercion-style (explicit dependency)
        cmp.compare(other) == 0
    }
}
type Compare protocol {
    compare(other Self) -> int
}

Rationale:

  • Orthogonal protocols — каждый stand-alone, без embed-зависимости.
  • Coercion canonical (Q6 decision): explicit cross-protocol dependency visible при чтении декларации; codegen devirtualizes к direct call когда тип known statically (zero runtime cost).
  • Conditional default: T satisfies Equal если has @equal explicit ИЛИ satisfies Compare (default body synth via @compare). Type только Equal (Vector3, Complex, etc.) пишет @equal явно — coercion fails potential потому что @compare отсутствует.
  • Direct form => @compare(other) == 0 тоже валидна — terser; same C output after devirtualization. Coercion form preferred в stdlib для documentation.

Display.fmt default body

type Display protocol {
    fmt(sb StringBuilder) {
        sb.append(str.from(@))
    }
}
  • Primitives — works via primitive Nova_int_to_str etc.
  • User types — implementer пишет @display явно (perf) OR provides fn str.from(MyType) -> str overload.

From identity blanket (D183 amendment)

export fn[T] T.from(t T) -> T => t
  • Аналог Rust impl<T> From<T> for T.
  • Override запрещён (Q4 strict decision): попытка fn Money.from(m Money) -> Money даёт E_BLANKET_IDENTITY_OVERRIDE. Identity is identity (D9 single canonical path).
  • Resolution order для T.from(value):
    1. Explicit fn T.from(value_type) → win
    2. Blanket identity — match только если value_type == T
    3. D77 auto-derive из From[value_type] chain
    4. Error E_NO_FROM_IMPL
  • Identity Into auto-derived через D77.
  • Coexistence: blanket additive с existing From[T] protocol decl (std/prelude/protocols.nv:81-83) + emit_c.rs::from_targets/into_targets registries (D77 4-way derive).

Self в param-type position (М-91.8a-self-in-param closed)

Раньше fn T @method(other Self) -> R давал E7001 «Self type used outside receiver context». Fix: emit_c.rs::emit_module method overload registration устанавливает current_receiver_type перед param_c_types calculation (mirror return-type path). Закрыто Plan 91.8a.2 part 1.

Codegen lazy synthesis + devirtualization — followup (Plan 91.8a.2 part 2)

Часть 1 (текущая) ограничена структурным refactor + Self fix. Часть 2 (отдельный sub-session) реализует:

  1. Lazy synthesis at use-site:
    • Bound contexts ([T Equal] etc.) — synth default body для типов которые satisfy abstract methods
    • Protocol coercion (let x Equal = m)
    • Operator dispatch (Plan 91.8b)
    • String interpolation (Plan 91.10)
    • NOT triggered: bare method call (m.equals(other) — direct lookup only)
  2. Devirtualization pass — coercion form let cmp Protocol = @ становится type ascription + direct call при synthesis для concrete T. Result: same C output что direct form.
  3. Cache per compilation unit: HashMap<(TypeId, MethodName), SynthFnDecl>.
  4. From blanket mono — extension Plan 101 mono pass на fn[T] T.method static на generic T.
  5. Error diagnostics: E_SYNTH_CYCLE, E_SYNTH_AMBIGUOUS, W_DEVIRT_FAILED, E_BLANKET_IDENTITY_OVERRIDE.

До части 2 — implementer пишет default body methods явно (boilerplate compatibility). Это работает но дублирует код.

Связь

⚠️ См. D229 — Debug sibling protocol с distinct debug semantics (diagnostic vs user-facing display); ${expr:?} syntax routes к Debug.@debug vs bare ${expr} к Display.@display (Plan 91.14, 2026-06-05).


D186 — #impl(P1 + P2 + ...) opt-in annotation для protocols

Когда: 2026-05-29 (Plan 91.9). Plan: 91.9-impl-annotation.md. Зависит от: D58 (structural protocols), D72 (generic bounds), D183 (canonical protocols Equal/Compare/Display + default body).

Проблема

Nova protocols — structural (D58). Compiler разрешает obj.method() если у типа есть соответствующий метод, без явного opt-in. С добавлением default body synthesis (D183) ситуация ухудшилась:

type Greetable protocol {
    greet() -> str { "Hello, " + @name() }
}
type User { display_name str }
fn User @name() -> str => @display_name

u.greet()  // ??? — без D186 это работало structurally (TypeScript-style)

Проблемы:

  1. Невидимая мутация behavior: добавление протокола в одном модуле тихо добавляет методы всем типам подходящей сигнатуры.
  2. Reader-hostile: глядя на type User, нельзя понять что у него есть метод greet (он синтезирован).
  3. Ambiguity: два протокола с methods одинакового имени и default bodies — порядок resolution не детерминирован.
  4. Verification: type-author не получает feedback что type соответствует intended protocol.

Решение

#impl(P1 + P2 + ...) annotation перед type declaration. Меняет два аспекта:

1. Gate semantics (bare-call / interpolation требуют opt-in)

Контексты, где synthesis fires:

ContextТребует #impl(P)?Почему
Bare call u.method()✅ даAmbient — type-author opt-in нужен
Interpolation "${u}"✅ даAmbient — Display.fmt synthesis
Generic bound [T P]❌ нетCaller opted in через bound
Coercion let x P = u❌ нетCaller opted in через annotation
Cast (u as P).method()❌ нетCaller opted in через cast
Param func(...args []P)❌ нетCaller opted in (signature)

Принцип симметрии: хотя бы один из (type-author, use-site) должен opt’нуться явно. Структура #impl — type-author side; bound/coercion/cast/ param — use-site side.

2. Verification (auto-check соответствия)

При декларации #impl(P) compiler проверяет:

  1. E_UNKNOWN_PROTOCOLP не найдено как type name.
  2. E_IMPL_NOT_PROTOCOLP найдено, но не protocol kind.
  3. E_IMPL_MISSING_METHODS — T не provides метод P:
    • не имеет explicit fn T @method(...),
    • и default body P.method не synthesizable для T (зависит от другого метода которого T не имеет).

Verification работает at type-declaration site — error появляется сразу, не при первом использовании.

Синтаксис

#impl(Equal + Compare + Display)
type Coin { value int }

fn Coin @compare(other Self) -> int => ...
fn str.from(c Coin) -> str => ...
// equals auto-derived через Equal.equals default (uses @compare)
// fmt auto-derived через Display.fmt default (uses str.from)

+ separator consistent с multi-bound [T A + B + C] (D72, Plan 101.3).

Order arbitrary: #impl(A + B)#impl(B + A).

Multiple #impl annotations не разрешены — single annotation with +.

Position

#impl(...) ставится перед type T (рядом с #stable, #from_fields):

#stable(since = "0.1")
#impl(Hash + Equal)
type UserId { value u64 }

Семантика

Use-site остаётся structural (D58 preserved). #impl не делает тип nominal. Он добавляет:

  • Gate на ambient synthesis (bare call / interpolation).
  • Verification в точке декларации.

Через bound / coercion / cast / param-coercion использование любого structurally-подходящего типа всё ещё работает — #impl не требуется.

Что НЕ делает

  • НЕ создаёт nominal typing (use-site structural preserved).
  • НЕ обязателен — opt-in, existing types работают через use-site coercion.
  • НЕ меняет runtime — #impl только compile-time проверка/gate.

Codegen

emit_c.rs::try_synthesize_default_method_with_gate(t, c, m, gate_on_impl):

  • gate_on_impl = true — bare call / interpolation; restricts candidates к protocols в type_impl_protocols[t].
  • gate_on_impl = false — vtable thunk (coercion), bound mono; structural.

type_impl_protocols: HashMap<String, HashSet<String>> populated в forward-decl pass из TypeDecl.impl_protocols.

Type-checker verification

types/mod.rs::verify_impl_protocols walks каждый Item::Type с non-empty impl_protocols:

  1. Each P lookup в self.types. None → E_UNKNOWN_PROTOCOL.
  2. Kind check — must be TypeDeclKind::Protocol. Иначе → E_IMPL_NOT_PROTOCOL.
  3. Each required method m в P.methods:
    • t_provides_method(T, m.name) → ok (explicit).
    • m.default_body.is_some() && default_body_calls_satisfy_for(body, T) → ok (synthesizable).
    • Else → list в missing, emit E_IMPL_MISSING_METHODS с hint.

default_body_calls_satisfy_for — AST walker проверяет body’s referenced calls resolve for T (через t_provides_method + t_satisfies_str_from для auto-derive str.from(@) pattern).

Compatibility

  • Existing structural use-sites (bound [T P], coercion let x P = u, cast (u as P), parameter coercion) continue работать без #impl.
  • Existing types без #impl могут потерять bare-call: fn User @name() -> str => ...; u.greet() (Greetable.greet default) — раньше работало, теперь error (без #impl(Greetable)).
  • Migration trivial: добавить #impl(Protocol) перед type decl.

Связь

  • D58 — structural protocols (use-site preserved).
  • D72 — generic bounds (use-site opt-in alternative).
  • D183 — canonical protocols + default body synthesis (что gate’ится).
  • D109 split policy.
  • Plan 101.3 — multi-bound + syntax.

⚠️ D186 AMENDED by Plan 108.4 (2026-06-09)#impl(P) annotation now checks receiver_mut in addition to method signature. If the protocol declares mut @method() and the implementing type declares fn T @method() (ro receiver), type-checker emits E_PROTO_IMPL_RO_FOR_MUT at the type-declaration site (where #impl(P) appears). The 4 new error codes are: E_PROTO_IMPL_RO_FOR_MUT, E_PROTO_IMPL_MUT_FOR_RO, E_PROTO_IMPL_MUT_FOR_CONSUME, E_PROTO_IMPL_CONSUME_FOR_MUT. See D209.


D200. Associated constants — const field в type X

Plan 114.4 Ф.2 (extracted from Plan 114 Ф.10 safety hatch). Status: 🆕 draft (финализируется в Ф.4).

Что

const declaration внутри type X { … } body — associated constant типа. Не часть instance layout; accessible через namespace Type.CONST_NAME.

type Config {
    const VERSION int = 2                  // associated const
    const PROTOCOL str = "v2"
    const MAX_PEERS int = 1024
    name str                                // instance field
    timeout Duration                        // instance field
}

// Access — только namespace
Config.VERSION                              // ✓ 2
Config.MAX_PEERS                            // ✓ 1024

// Instance access — error
ro c = Config { name: "alice", timeout: SECOND }
c.VERSION                                   // ✗ E_CONST_INSTANCE_ACCESS

// Layout
sizeof(Config) == sizeof(name) + sizeof(timeout)  // const fields НЕ в layout

Семантика

  1. Strict constexpr — RHS должен быть literal-eligible.
  2. Zero storage в instance. Codegen не emit’ит const-field в struct layout. Каждый const-field живёт как top-level C-symbol Type_FieldName в .rodata.
  3. Namespace access only. Type.NAME resolution через type’s const-table. instance.NAMEE_CONST_INSTANCE_ACCESS.
  4. Не указывается в record literal. Указание → E_CONST_FIELD_IN_LITERAL.
  5. export const field — publicly accessible cross-module.
  6. Modifier-conflicts:
    • mut const / const mutE_CONST_MUT_CONFLICT.
    • ro const / const roE_CONST_RO_REDUNDANT.
    • consume constE_CONST_CONSUME_CONFLICT.
  7. SCREAMING_SNAKE_CASE convention — lint warning (D30 carry-over).

Sum-type associated constants

const decl внутри sum-type body — associated на sum-type-level:

type Status = Active | Inactive | Pending {
    const VERSION int = 2
    const MAX_TRANSITIONS int = 100
}

Status.VERSION                              // ✓ 2

Per-variant const’ы (Active { const X = 1 }) — out-of-scope V1, followup [M-115-per-variant-const].

Generic-type associated constants

T-independent — RHS не reference’ит generic params:

type Box[T] {
    const TAG int = 0
    value T
}
Box.TAG                                     // ✓ emit single Box_TAG

T-dependent — RHS reference’ит generic param:

type Box[T] {
    const SIZE int = sizeof(T)
    value T
}
Box[int].SIZE                               // ✓ 8 — per-mono Box_int_SIZE
Box[str].SIZE                               // ✓ 16 — per-mono Box_str_SIZE
Box.SIZE                                    // ✗ E_GENERIC_CONST_REQUIRES_INSTANTIATION

Allowed в T-dependent RHS (V1):

  • sizeof(T) где T — generic param.
  • Арифметика над sizeof(T_i) и literals.
  • Ссылки на T-independent const через Type.CONST.

НЕ allowed в V1:

  • T.METHOD() calls — [M-115-t-method-in-const].
  • const fn calls с generic args — [M-115-generic-const-fn].
  • Recursive type refs (Tree[T] { const X = sizeof(Tree[T]) }) → E_GENERIC_CONST_CYCLE.

Codegen

  • Non-generic + T-independent: top-level static const T Type_FieldName = …; в .rodata. Resolution Type.FieldName → C-symbol Type_FieldName.
  • Generic T-dependent: per-mono symbol naming coherent с existing generic-fn mono (Plan 70.5). Emit при каждой monomorphization.
  • export const field: public C-symbol visibility.

Сравнение с mainstream

ЯзыкСинтаксисStorage
Javastatic final int VERSION = 2; (внутри class)top-level C-static
Rustimpl Config { const VERSION: i32 = 2; }top-level
Kotlincompanion object { const val VERSION = 2 }companion slot
Swiftstruct Config { static let version = 2 }type-metadata
TSclass Config { static readonly VERSION = 2 }class-static
Novatype Config { const VERSION int = 2; … }top-level .rodata

Use cases

  • Version / protocol identifiers: Config.VERSION, Protocol.MAGIC_BYTES.
  • Capacity / size limits: Buffer.DEFAULT_CAPACITY.
  • Math constants: Circle.PI, Complex.UNIT_IMAGINARY.
  • Per-mono sizes: Box[int].SIZE, Pair[T,U].TOTAL.

Cross-ref

  • D36 — field-decl extended.
  • D184 — Plan 114 master keyword refresh.
  • D199const fn (могут использоваться для assoc const RHS).
  • D27[N]T arrays.

Acceptance

См. Plan 114.4 A5-A13 (T2 series).

D214. ptr opaque pointer type + tuple FFI returns + opaque handle pattern

Plan 115 (foundational FFI). Status: ✅ V1 closed 2026-06-01.

⚠️ SUPERSEDED by Plan 134 (2026-06-09)ptr built-in primitive type removed. Replace all occurrences with *() (pointer to unit type = void* in C). *() is the idiomatic expression of an opaque pointer in the *T type system (Plan 118 D216) — no compiler special-case required. Migration: ptr*(); 0 as ptr0 as *(); type X(ptr)type X(*()). nova check emits E_TYPE_UNKNOWN (type ptris removed — use*()) on ptr/nova_ptr in type position, with a migration hint (type ptr = *() user-level alias). A defensive codegen-time error mirrors the same message if a use ever bypasses the checker.

Pointer types after Plan 134:

  • *() — opaque pointer (pointer to unit type = void* in C)
  • *T — typed pointer to T (read-only pointee by default, D216)
  • *mut T — typed pointer to mutable T (writable pointee)
  • *unsafe T — pointer to possibly-uninit T (pointee contracts off; pointer itself non-null — nullable = Option[*unsafe T], Plan 138.5 §1/§V2.4)

⚠️ AMENDED by Plan 118 (D216)ptr redefined as type ptr Option[*unsafe ()] newtype над nullable unsafe void pointer (D216 §11). ABI preserved (single void*); semantics formalized as nullable Option (NPO emits NULL). null ptr literal retracted (E_NULL_PTR_RETRACTED_USE_OPTION); migrate к None.

Closes followup [M-115-null-ptr-to-option-after-npo] от Plan 115. See Plan 118 §«ptr redefine» и D216 §11. Existing usages (handle pattern, tuple FFI returns) — no migration required (semantically equivalent post-amend).

Plan 91.12 V2 amend (2026-06-02): generic tuple-newtype type X[T](ptr) now supported (was V1-limited to non-generic type X(ptr)). См. §«Generic opaque handle» ниже. Closes [M-115-newtype-constructor-generic].

⚠️ AMENDED by Plan 118 Ф.5.7 (A23) 2026-06-02null ptr literal hard-retracted (E_NULL_PTR_RETRACTED_USE_OPTION). After Ф.5 NPO codegen (A19/A21), Option[ptr]/Option[*T] provide null-safety через type-system (single-pointer NPO layout, NULL=None convention). null ptr literal становится redundant и ambiguous (Some(null ptr) indistinguishable от None под NPO). Migration: null ptr(0 as ptr) (mechanical, NULL=(void*)0 в C ABI) либо Option[ptr] = None для new code. Closes followup [M-115-null-ptr-to-option-after-npo].

Что

Foundational FFI infrastructure для bindings к произвольным C libraries (libsqlite3, libpng, libcurl, etc.) без участия compiler-team.

Три компонента:

  1. ptr built-in primitive type — opaque pointer-sized integer, ABI-эквивалентен void* в C.
  2. Tuple-by-value returns в external fn — multi-value через struct-return calling convention.
  3. Opaque handle pattern через type X(ptr) (D52 tuple newtype) — compile-time-distinct typed wrappers.

1. ptr built-in primitive type

ro p ptr = null ptr                          // NULL pointer literal
ro q ptr = some_external_fn()                // получили ptr из FFI
if p == null ptr { /* handle NULL */ }       // null check
ro as_int = q as u64                         // explicit cast → integer
ro back_to_ptr = (0x1000 as ptr)             // explicit cast int → ptr

Семантика

  • Size. int / intptr_t (8 bytes на 64-bit). Bootstrap таргетит только 64-bit платформы (Linux x86_64, Windows x64, macOS ARM64/x86_64). Note: usize удалён (Plan 133) — pointer-sized = int.
  • ABI. void* в C. Передаётся в registers по платформенному ABI; identity при passing через external fn boundary.
  • Opaque. Nova не имеет *p deref-операции (никогда не было), нет field/method access на ptr. Только comparison + cast + pass-through.
  • Default value. null ptr (bitwise 0). Zero-init valid и обозначает «нет указателя».
  • Equality. == / != — bitwise pointer comparison (стандартная C semantics).
  • Casts.
    • ptr as u64 / ptr as i64 — извлечь integer representation.
    • u64 as ptr / i64 as ptr — собрать ptr из integer (для opaque handle storage).
    • ptr as ptr — no-op (identity).
  • Arithmetic banned. ptr + N, ptr - ptr, ptr * 2E_PTR_ARITHMETIC_BANNED. Pointer arithmetic — unsafe operation, отложен на followup ([M-115-ptr-arithmetic]).
  • GC. Conservative GC сканирует pointer-sized слоты как potential references — ptr слот с GC-allocated адресом будет pin’ить allocation (defensive, correct). ptr слот с non-GC адресом (e.g. sqlite3 handle) не tracs ничего (адрес вне GC arena). Зачем это работает: Boehm-style conservative collector реагирует только на адреса внутри tracked heap.
  • Memory ownership. FFI domain — user responsibility. ptr, returned’ый из C library, должен быть освобождён matching C-side call (sqlite3_close, png_destroy_read_struct, etc.). Pattern: типизированный handle + consume close() метод на Nova-wrapper.

null ptr литерал

ro p = null ptr                              // valid expression
if p == null ptr { ... }                     // null check

Синтаксис: keyword null + type-name ptr. Two-token literal, parser expects null followed by ptr ident. Распространение синтаксиса на другие pointer types (Plan 118 *T family) — null *T — спроектировано forward-compatible, но не реализуется в V1.

V1 ограничение: только null ptr valid. null int, null str, null SomeRecordE_NULL_LITERAL_REQUIRES_PTR.

INTERIM construct (Plan 115 V1 only). null ptr дублирует функциональность None из Option[T] (sum-type из D-блока Option/ Result). Идиоматический Nova-путь — Option[ptr] с явной None / Some(p) диспозицией и compiler-enforced null check’ом.

Почему null ptr существует в V1. Option[ptr] в bootstrap представлен как NovaOpt_nova_ptr struct (tag + value) — НЕ ABI-совместим с raw void* из C library. FFI shim пришлось бы оборачивать pointer’ы в Option struct’у — лишний overhead + struct return convention вместо register return. null ptr = bitwise 0 = идентично C NULL → zero-cost FFI.

Plan 118 NPO (Null Pointer Optimization). После Plan 118 V2 добавит Option[*T] с NPO codegen — None представляется как bitwise 0, Some(p) как p. Zero-cost + type-safe + ABI-compatible одновременно. См. [[project-plan118-status]] §«Option[*T] NPO codegen».

После Plan 118 landed: null ptr полностью удаляется — retract из spec, parser emit’ит E_NULL_LITERAL_REPLACED_BY_OPTION с migration hint к Option[ptr] / None. См. marker [M-115-null-ptr-to-option-after-npo] в docs/simplifications.md для migration tracking.

Type-checker rules

ОперацияРезультатDiagnostic
null ptrTy::Ptr
ptr == ptr / ptr != ptrbool
ptr == null ptrbool
ptr as u64 / ptr as i64integer
u64 as ptr / i64 as ptrptr
ptr as ptrptrno-op
ptr + N / ptr - ptr / etc.errorE_PTR_ARITHMETIC_BANNED
ptr.field / ptr.method()errorE_PTR_NO_MEMBER (нет деf членов на opaque)
int as ptr (для int = i64-style)ptr— (transparent через i64 path)
ptr as intint
ptr в record-fieldOK— (storage в struct slot)

ptr distinct от i64/u64/int на type-check уровне (нельзя смешать без cast’а). Distinction enforced через отдельный Ty::Ptr variant.

2. Tuple-by-value returns в external fn

external fn nova_sqlite3_open(path str) -> (Sqlite3Handle, i64)
//                                          ↑              ↑
//                                          handle         error code

Соответствующий C shim:

typedef struct {
    void*   _0;   // handle slot
    int64_t _1;   // error code slot
} Nova_Sqlite3OpenResult;

Nova_Sqlite3OpenResult nova_sqlite3_open(nova_str path) {
    sqlite3* db;
    int rc = sqlite3_open(path.data, &db);
    return (Nova_Sqlite3OpenResult){ db, (int64_t)rc };
}

ABI rules

  • Layout Nova tuple type (T1, T2, ..., Tn) ↔ C struct { T1 _0; T2 _1; ...; Tn _{n-1}; }. Element order preserved, no padding inserted beyond what C compiler emits по target ABI.
  • Mangling. Compiler emits _NovaTuple_<arity>_<elem_mangles> typedef (Plan 59 mechanism, существующий — переиспользуется). C-side shim должен иметь struct с тем же layout (struct typedef name произвольное — ABI layout совпадает).
  • Calling convention — определяется C компилятором на target платформе:
    • Sys V AMD64 (Linux, macOS x86_64): structs ≤ 16 bytes (2 GPR) → return через %rax:%rdx registers. Bigger → caller passes hidden out-pointer в %rdi.
    • AArch64 (macOS ARM64, Linux ARM64): structs ≤ 16 bytes → X0:X1 registers. Bigger → hidden out-pointer.
    • Win x64 MSVC: structs ≤ 8 bytes → RAX. Bigger → hidden out-pointer в RCX, shifting all other args.
  • Compiler responsibility. Codegen эмитит struct return-type declaration; платформенный C compiler делает rest. Nova не пытается override calling convention — соответствие platform ABI делегировано toolchain.
  • Element type compatibility. Tuple elements должны быть:
    • Primitives (int/i32/etc., f64, bool, u8-u64, ptr),
    • Newtype handles (type X(ptr)),
    • Pointer-like types (str — actually { data ptr; len u64 } layout-equivalent struct),
    • Other tuples (nested struct return) — supported, transitive.
  • Прохибиции (V1). Elements типа []T (NovaArray pointer), Option, Result, sum-types — не рекомендуется, т.к. GC-tracked layouts. Pass them отдельно через out-params (если действительно нужно) или переупаковывайте в opaque handle. Followup [M-115-tuple-gc-types] — formal V2 support.

Layered FFI pattern

LAYER 1  Public Nova API (Database.open)
   ↓
LAYER 2  Nova wrapper (construct typed handle from raw)
   ↓
LAYER 3  external fn declaration (typed handle + tuple return)
            external fn nova_sqlite3_open(path str) -> (Sqlite3Handle, i64)
   ↓
LAYER 4  C shim (~5-10 lines per fn — adapts out-param convention → struct)
            Nova_Sqlite3OpenResult nova_sqlite3_open(nova_str path) { ... }
   ↓
LAYER 5  Actual C library (libsqlite3.so / sqlite3.dll)
            int sqlite3_open(const char* path, sqlite3** db_out);

Layer 4 (shim) — единственное место «где Nova ABI встречается с C library ABI». User пишет один раз per fn. ~5-10 строк per shim.

3. Opaque handle pattern через type X(ptr) (D52 tuple newtype)

type Sqlite3Handle(ptr)                       // typed wrapper
type PngImageHandle(ptr)
type CurlEasyHandle(ptr)

// Construct
ro h = Sqlite3Handle(some_raw_ptr)

// Destructure inner ptr (used rarely; usually pass-through)
ro raw_ptr = h.0

// Type safety: distinct types prevent mixing
fn close_sqlite(h Sqlite3Handle) -> i64 { ... }

ro png = PngImageHandle(other_raw_ptr)
close_sqlite(png)                             // ✗ E_TYPE_MISMATCH — PngHandle ≠ Sqlite3Handle

Семантика

  • D52 tuple newtype (type X(Y)) — existing mechanism, leveraged как-есть. Никаких новых parser/checker rules для handle pattern — он buisness layer convention, не language feature.
  • ABI. Newtype = transparent wrapping. C-level Sqlite3Handle ≡ ptr ≡ void*. Zero runtime overhead.
  • Distinct type. Compile-time check Sqlite3Handle ≠ PngHandle ≠ ptr — нельзя передать без явного wrap/unwrap.
  • Construct: Sqlite3Handle(ptr_value) — standard tuple constructor.
  • Destructure: handle.0D52 tuple field access.

Generic opaque handle — type X[T](ptr) (Plan 91.12 V2, 2026-06-02)

Generic newtype над ptr поддерживается для type-parameterized FFI handles (phantom T для compile-time discrimination):

type Region[T](ptr)             // generic phantom T
type RegionKind = Persistent
type RegionKind = Transient

// Distinct types at compile-time, identical ABI at runtime
ro p = Region[Persistent](some_ptr)
ro t = Region[Transient](other_ptr)
// fn drop_persistent(r Region[Persistent]) — нельзя передать Region[Transient]

// Multi-param OK
type DualHandle[T, U](ptr)
ro h = DualHandle[int, str](raw)

Семантика. T параметр — type-system fiction; C-level ABI identical (Nova_Regionnova_ptr). All monomorphizations share typedef. Codegen emit’ает single typedef nova_ptr Nova_X; (не per-T), .0 access

  • constructor — identity cast same как non-generic case.

Use case: phantom type discrimination для same-runtime-shape handles (prepared statement kinds, region/arena ownership classes, FFI buffer mutability flags, и т.д.).

Inner non-ptr types (Plan 91.12 V2 followup, 2026-06-02) — generic newtype над любым primitive типом supported: type Counter[T](int), type Tag[T](str), type Flag[T](bool), type Measure[T](f64). Семантика идентична ptr-case: phantom T для compile-time discrimination, single shared typedef над inner C type, zero runtime overhead. Use cases: typed int counters, tagged strings (Email/UserId), tagged booleans (Visible/Hidden), tagged floats (measurement units).

Inner uses generic param (type Wrap[T](T)) — REJECTED type-checker’ом с [E_GENERIC_NEWTYPE_INNER_USES_PARAM]. Tuple newtype = transparent typedef (shared C ABI across T’s); per-T storage variance — record-semantics:

// ✗ E_GENERIC_NEWTYPE_INNER_USES_PARAM
type Wrap[T](T)                  // inner depends on T → not newtype

// ✓ Correct migration to record form (per-T mono)
type Wrap[T] { value T }         // properly mono'd по T

Closes [M-91.12-generic-newtype-non-ptr-inner].

consume close() cleanup convention

Recommended pattern для handle types с resource ownership:

type Database { ro handle Sqlite3Handle }

fn Database.open(path str) Fail[DbError] -> Database {
    ro (h, rc) = nova_sqlite3_open(path)
    if rc != 0 { Fail.throw(DbError.OpenFailed(rc)) }
    Database { handle: h }
}

fn Database consume @close() -> () {
    nova_sqlite3_close(self.handle)
    // Plan 100.4 defer machinery интегрируется автоматически:
    // failable cleanup body допустим, ошибки propagate'ятся caller'у.
}

Combined с D90 defer / errdefer для automatic cleanup — leak-resistant без runtime cost.

4. Coexistence с D126 external type

Plan 115 не retracts D126. Оба паттерна остаются valid:

PatternUse caseTrade-offs
D126 external type Xstdlib internals (Nova-team владеет C struct)Tighter integration; C-side knows Nova types; no .0 boilerplate
D214 type X(ptr)user FFI к third-party libs ИЛИ stdlib opting inUniversal; C-side не знает Nova internal layouts; .0 для inner access

Recommendation. Stdlib мигрирует на Plan 115 pattern для consistency с user-FFI conventions (Plan 91.12 amend в Pattern B). D126 deprecation — followup [M-115-d126-deprecation] после migration audit.

Diagnostic codes

  • E_PTR_ARITHMETIC_BANNED — попытка арифметики на ptr (V1 banned).
  • E_PTR_NO_MEMBER — попытка ptr.field / ptr.method()ptr opaque.
  • E_NULL_LITERAL_REQUIRES_PTRnull T где T ≠ ptr (V1 ограничение; Plan 118 expand для *T).
  • E_PTR_CAST_INVALID_TARGETptr as T где T ≠ {i64, u64, int, ptr} — string/float/bool casts не имеют semantic meaning для opaque pointer.

Implementation notes

  • Parser добавляет "ptr" в is_primitive_type allowlist (для ptr.method / static-dispatch namespace). null ptr literal — special case в parse_atom / parse_primary.
  • Type-checker добавляет Ty::Ptr variant; ty_of_ref mapping "ptr" => Ty::Ptr; arithmetic / member access reject hooks.
  • Codegen добавляет "ptr" => "void*" mapping в type_ref_to_c; null ptr((void*)0); cast emissions ((void*)(uint64_t)(...)) для int→ptr; ((uint64_t)(...)) для ptr→int.
  • GC — no changes. Conservative GC handles void* слоты by-default.
  • Tuple FFI — leveraging existing _NovaTuple_* mono’d struct pipeline (Plan 59 mechanism). C-side shim author writes matching struct typedef с теми же elements.

Mainstream comparison

ЯзыкOpaque pointer typeTyped wrappers
Rust*mut c_void / *const c_voidstruct H(*mut c_void)
Zig*anyopaque / ?*anyopaqueconst H = opaque {}; *H
Gounsafe.Pointertype H = unsafe.Pointer
Haskell FFIPtr ()newtype H = H (Ptr ())
OCaml ctypesunit ptrtype h = unit ptr
Python ctypesc_void_psubclass c_void_p
Java JNIjlong(just long)
.NET P/InvokeIntPtr / nintstruct H { IntPtr h; }
Nova V1(нет) — нужны compiler hacks
Nova V2 (Plan 115)ptr (built-in)type H(ptr) (D52 tuple newtype)

Nova V2 = Rust/Zig tier (typed wrappers без runtime overhead, opaque deref, arithmetic banned by default).

Use cases

  • libsqlite3 binding (type Sqlite3Handle(ptr), type Sqlite3StmtHandle(ptr)).
  • libpng / libjpeg / libwebp image processing.
  • libcurl HTTP client (Plan 117/118 prerequisite).
  • rustls / OpenSSL TLS handles (Plan 116 prerequisite).
  • Plan 91.12 std/net Pattern B migration (replaces D126 для TcpListener / TcpStream / UdpSocket если migration deemed worthwhile).
  • Any third-party C library без Nova-team coordination.

Cross-ref

  • D52 — tuple newtype type X(Y) (leveraged).
  • D82 — external fn syntax (extended для tuple returns).
  • D126 — external type (coexists; alternative pattern для stdlib internals).
  • D54as-cast operator (added ptr↔integer casts).

Acceptance

См. Plan 115 A1-A10 (T1, T2, T3 series).


D216. Typed pointer family + unsafe model + null-safety через NPO

Коллизия номера разрешена 2026-07-03: второй блок «D216 Generic anonymous tuple monomorphization» перенумерован в D354; typed-pointers сохраняет D216 (несёт цепочку V2/V3-амендментов и сеть ссылок D246/118.x/174.5). Plan 118 (typed pointers + unsafe model). Status: 🟢 ACTIVE 2026-06-02 (Ф.0 + Ф.1.5 + Ф.2 scaffold + Ф.3 + Ф.3.2 + Ф.3.3 + Ф.3.5 + Ф.4 partial + Ф.5 partial + Ф.6 partial — 13 acceptance criteria closed).

D216 АМЕНДМЕНТ «всё через методы» (Plan 174.5, 2026-07-09, решения владельца 2026-07-06 — таблица §3 плана): value-доступ и адресная арифметика указателей — ТОЛЬКО unsafe-методы; операторные формы РЕТРАКТИРОВАНЫ ошибкой E_POINTER_OP_USE_METHOD (§6/§8 этого блока в операторной части устарели):

МетодСемантика
p.read() -> T / p.write(v T)голый deref *(p) (D141; заменяют *p)
p.read_at(i) -> T / p.write_at(i, v)*(p+i) (заменяют p[i]); write_at — единая точка write-cap (E_POINTER_RO_ASSIGN)
p.offset(n) -> *Tадресная арифметика element-units (заменяет p±i); тип НЕ деградирует
p.dist(q) -> intsigned element count (заменяет p-q; порядок = знак dist, p<q ретрактирован)
p.read_unaligned() / p.write_unaligned(v)memcpy-семантика (невыровненный доступ)
p.read_volatile() / p.write_volatile(v)volatile
p.write(v *T) -> *mut Tкопия из указателя-источника (без value-копии)
p.copy_from/copy_to(src, n)memmove; _nonoverlapping — memcpy (обёртки RawMem byte-level)

ОСТАЮТСЯ операторами: p == q/!=, auto-deref p.field/p.method() (one-level), p as *U (cast; unsafe при U≠T). []-индексация — ТОЛЬКО безопасные контейнеры (D138), у указателей её нет. wrapping_offset отложен ([M-174.5-wrapping-offset-deferred]). Ретрактированы: *p, *p=v, p[i], p[i]=v, p±i, p-q, p</<=/>/>=q. Conformance: d174_5_* pos+neg (8 neg — по одному на форму). Эталон 79/0.

D216 §4 AMEND (Plan 118.6, 2026-06-17): &x safe for all types (no unsafe {} required for promote path). addr_of() / addr_of_mut() retired → E_ADDR_OF_REMOVED. mut binding → *mut T auto; ro binding → *T auto. Escape analysis extended to primitives. 15/15 tests PASS.

D216 §4 AMEND 2 (Plan 118.7, 2026-06-18): raw &x — новый унарный оператор для сырого стек-адреса без escape analysis / auto-promote. Требует unsafe {} контекст (E_UNSAFE_REQUIRED). raw — контекстное ключевое слово (не зарезервировано в lexer, аналог bench/measure).

Инвариант после 118.7:

  • &xвсегда safe + escape analysis + auto-promote. Работает везде.
  • raw &xвсегда сырой стек-адрес, без промоута. Только в unsafe {}.
  • unsafe { &x } — эквивалентен &x (unsafe-контекст не влияет на &).

Дополнительные диагностики:

  • E_UNSAFE_REQUIRED теперь также для raw &x вне unsafe (§4 amend 2).
  • E_AMP_LITERAL / E_AMP_RECORD_LITERAL / E_ARRAY_INDEX_PTR_BANNED — применяются и к raw &expr (те же lvalue-ограничения).

4/4 plan118_7 tests PASS. Migration: 7 файлов unsafe { &x }unsafe { raw &x }.

Enforced diagnostics (V1):

  • E_UNSAFE_REQUIRED (D216 §8) — A8 ✅ commit 5c0d2c975ce
  • E_UNSAFE_CALL_REQUIRES_WRAP (D216 §9) — A11 ✅ commit abd4be4603b
  • E_CALLBACK_THROWS_OVER_C_ABI (D216 §10/§20) — A25 ✅ commit e4cff57142e
  • E_EXTERNAL_FN_FAIL_EFFECT (D216 §20) — A26 ✅ commit 7ff3007f3af
  • E_REALTIME_POINTER_OP (D216 §20 + D172 cross-ref) — A33 ✅ commit 6752565f453
  • E_INVALID_POINTER_MODIFIER (D216 §1) — commit 6d6a18a2ab7
  • E_AMP_LITERAL / E_AMP_RECORD_LITERAL / E_ARRAY_INDEX_PTR_BANNED (D216 §4 amend + §15) — commits d9d3084ed69 + 986fdb04c0d + 7d61617bcf8
  • E_ADDR_OF_REMOVED (D216 §4 amend, Plan 118.6) — addr_of / addr_of_mut retired

Remaining Session 4+ work (V1.1):

  • Ф.4 full auto-deref codegen integration (A12-A17)
  • Ф.5 NPO codegen (A19-A23 + closes [M-115-null-ptr-to-option-after-npo])
  • Ф.6 full *fn cast checks (A24 — E_CLOSURE_HAS_ENV)
  • Ф.7 W_UNSAFE_GC_TRIGGER + Debug fmt (A27, A28)
  • Ф.8 cross-platform CI + ABI snapshot + perf bench (A31, A32)
  • Plan 118.1/118.2/118.3 sub-plans

Cross-amend: D2 (unsafe keyword restored as effect-handler sugar), D214 (ptr redefined как newtype), D32 (&value is typed pointer construction, NOT Rust borrow), D52 (tuple newtype type Handle(*T) canonical для FFI handles).

Что

Foundational language addition: typed pointer family *T + unsafe gating model + NPO null-safety. Replaces ptr opaque-only model из Plan 115 V1 с typed alternative; backward-compatible через D214 amend.

Plan 118 family scope:

  • Plan 118 core (этот D216): *T family + unsafe + NPO + escape + *fn + GC honor-system
  • Plan 118.1 (D217): FFI memory intrinsics + C-string convention
  • Plan 118.2 (D218): slice fat-pointer + MaybeUninit + ManuallyDrop
  • Plan 118.3 (D219): pointer concurrency safety + AtomicPtr[T]

§1. *T family типов

✅ FINAL — три оси мутабельности (Plan 147 D246, 2026-06-12): flip-scan (flip-scan-draft) ОТКЛОНЁН adversarial-критикой (*T контекстно-зависим → тип не самодостаточен). FINAL = L3 pointee-capability из ТИПА, позиционно-независимо (D246 ниже). Восстановлено *T ≡ *ro T УНИВЕРСАЛЬНО (во всех позициях: param/return/generic/alias/cast/field/local). Pointee-mut НЕ наследуется от binding (running-current/flip-scan убраны). *mut T — единственный опт-ин mut-pointee. Текст ниже про postfix-canonical / prefix-ban / Option-NPO сохраняется.

138.5 baseline (postfix pointee, prefix-ban) — KEPT под D246: указательный ТИП несёт pointee-мутабельность постфиксом, сразу после * (*mut T / *unsafe T). *ro T — HARD ERROR E_REDUNDANT_POINTER_RO (избыточно: *T уже ro; fix-it *T). Перепривязываемость самого указателя (p = other_ptr) — это L1 binding (ro = фиксирован, mut = переприсваиваемый, D36), НЕ часть типа и НЕ влияет на pointee-capability. Запрещены ВСЕ prefix-модификаторы перед * (mut * T / ro * T / unsafe * T) — E_POINTER_PREFIX_MODIFIER (§1 ниже). Nullable = Option[*T] только (NPO §7). Это ретрактит D216 V2 «outer pointer-mut как type-wrapper» (mut * T = Mut(Pointer(T))) и V3 propagation/safe-stopper машинерию — см. §V2.2/§V2.6/§V3.2/§V3.3/§V3.4 ниже.

  • *T — typed pointer; pointee ro (дефолт). *T ≡ *ro T универсально (D246: pointee-capability из типа, НЕ наследуется от binding).
  • *mut T — explicit mut pointee (postfix only) — единственный опт-ин на запись *p = …. *ro THARD ERROR E_REDUNDANT_POINTER_RO (fix-it *T)

Flagship *u8 (ro-pointee) use-case (Plan 139; D246, 2026-06-12): str — Nova value-record lang-item type str value priv { ptr *u8, len int }. Поле ptr *u8 — указатель на иммутабельный UTF-8 буфер (immutability строки выражена типом указателя: *T = ro-pointee, не отдельной меткой; *ro u8 избыточен → E_REDUNDANT_POINTER_RO). *u8 (ro) гарантирует: нет write-path сквозь str.ptr, поэтому clone = shallow 16-байт handle-copy с общим буфером безопасен, а compile-time interning литералов (один общий rodata-буфер на distinct content) семантически невидим. ABI поля — T* в C (const uint8_t*), layout-идентично старому nova_str. См. D26 MAJOR AMEND + D228 content-eq override.

Amend (Plan 139.2 Ф.0+Ф.2, 2026-06-12): str { ptr, len } record-литерал + producer-миграция. str — declared lang-item, поэтому str type-методы (receiver strcurrent_recv_type == "str", privacy type-based, не module-based — D220) конструируют str value-record литералом str { ptr: …, len: … } в своём модуле. Codegen НЕ эмитит NovaValue_str (str ∈ RUNTIME_DEFINED_TYPES skip-list), поэтому str{…} лоуэрится спец-кейсом в emit_record_lit напрямую в C compound-literal (nova_str){.ptr=(const uint8_t*)(…), .len=(int64_t)(…)} (без schema, без NovaValue-структуры). Внешние caller’ы по-прежнему ловят E_PRIV_FIELD_INIT (priv-поля). Это разблокировало миграцию producer-форм external-C → Nova-body:

  • @split(sep) -> ro []str — byte-scan, каждый сегмент = zero-copy sub-view str{ptr:@ptr+off, len} (raw-ptr арифметика под unsafe), push в Vec[str];
  • from_bytes_unchecked / from_bytes_lossy — читают (ptr,len) источника через публичные Vec-геттеры @as_ptr()/@len(), alloc(len+1)+memcpy+NUL на data[len] (D26 §3); lossy валидирует UTF-8 и заменяет невалид на U+FFFD;
  • from_bytes_unchecked_steal(consume bytes []u8) — zero-copy reuse буфера при cap>len (NUL in-place), иначе alloc+copy. consume-обязательство закрыто новым Vec[T] consume @into_raw() -> *mut T (инверс Vec.from_raw_parts: потребляет Vec-обёртку, отдаёт сырой writable-буфер).

Amend (Plan 139.2 Ф.3, 2026-06-12): @concat / @compare → Nova-body (operator-lowering ОСТАЁТСЯ на C — option (b)). @concat(other) -> str и @compare(other) -> int мигрированы из external-C в Nova-body:

  • @concat: alloc []u8 размера @len()+other.len(), копирует байты обоих операндов через @as_bytes() (zero-copy view), затем str.from_bytes_unchecked (owned + NUL-term D26 §3). Байт-в-байт идентично C nova_str_concat.
  • @compare: byte-loop над @as_bytes() обоих операндов (как C strcmp / memcmp), length-aware tiebreak; возвращает a_byte - b_byte на первом различии (u8 0..255 ⇒ тот же знак, что memcmp), иначе sign(@len() - other.len()). Идентично C nova_str_compare (array.h:989). РЕШЕНИЕ по operator-lowering — option (b) (оставить C-fn для операторов): операторы + / < / <= / > / >= / == / != над nova_str лоуэрятся ОТДЕЛЬНО, НАПРЯМУЮ в C nova_str_concat / nova_str_lt / … / nova_str_eq (emit_c.rs, BinOp-arm lty == "nova_str"), НЕ через method-dispatch. ПРЯМЫЕ method-вызовы (s.concat(t) / s.compare(t), Compare-протокол @compare(o)==0-synthesis, @plus-body, @replace chained .concat()) маршрутизируются в Nova-body (убраны "concat"/"compare" из str_method_to_rt). Почему option (b), не (a) (роутить операторы через методы + retire C-fn): (1) perf — C nova_str_concat = один nova_alloc
  • два memcpy; Nova-body = with_capacity + два byte-push-loop’а (по байту, с bounds-check на каждом push). C nova_str_cmp = один memcmp; Nova-body = byte-loop с as int-конверсией на байт. Operator-формы — горячий путь (string building, sort-сравнения), C оптимальнее. (2) ортогональность — operator- lowering (BinOp codegen) и method-dispatch (str_method_to_rt / Nova-body) — независимые механизмы; миграция тела метода НЕ требует трогать operator-arm, и наоборот. Чистое retirement C-fn потребовало бы СОВМЕСТНОЙ миграции обоих + perf-харнесс для подтверждения отсутствия регрессии — orthogonal, низкий приоритет. Дубль (Nova-body метод + C-fn для оператора) — приемлемая цена: C-fn’ы малы (inline), и они единственные горячие; метод-форма редка. См. reframed [M-139.1-operator-lowered-methods].

Остаётся C только @hash (SipHash-1-3 + crypto-seed, DoS-resistance — см. [M-139.1-hash-irreducible-crypto-seed]). 9/10 str-методов — Nova-body (@concat/@compare закрывают Ф.3); operator-lowering +/</… — сознательно C (perf, option (b)).

  • *unsafe T — pointer к possibly-uninit T (pointee init/layout contracts off); также degraded-форма после арифметики (alignment/bounds gone)
  • Size: pointer-width (8 bytes на 64-bit; bootstrap = 64-bit only)
  • ABI: T* в C (compiler emits соответствующий C-type для FFI)
  • Validity: always non-null (compile-time invariant); nullable variant — Option[*T] (NPO §7)

Pointee (L3) vs binding (L1) — две ОРТОГОНАЛЬНЫЕ оси (D246):

L1 binding (ro/mut перед именем) задаёт reassignability указателя (p = other_ptr) — и больше ничего про pointee. L3 pointee-capability — из типа, позиционно-независимо: *T=ro (нельзя *p = …), *mut T=mut. Оси НЕ влияют друг на друга — pointee-mut НЕ наследуется от mut-binding (flip-scan убран).

ЗаписьL1 binding (p =)L3 pointee (*p =)Вердикт
mut p *T✅ reassign❌ pointee ro*p = … запрещён (E_POINTER_RO_ASSIGN)
mut p *mut T✅ reassign✅ pointee mutоба ✅
mut p *ro TE_REDUNDANT_POINTER_RO (use mut p *T)
ro p *T❌ фиксирован❌ pointee roоба ❌
ro p *mut T❌ фиксирован✅ pointee mutp = …❌, *p = …
ro p *ro TE_REDUNDANT_POINTER_RO (use ro p *T)

*T ≡ *ro T (восстановлено D216 §V2.6; flip-scan-draft отклонён): pointee-mut задаётся только через *mut T; *ro T избыточен → E_REDUNDANT_POINTER_RO. Reassignability указателя — L1 binding (ro/mut, D36), независима от L3.

АМЕНДМЕНТ (2026-07-08, Plan 172.13, [M-redundant-param-ro-diagnostic]) — избыточные модификаторы на границе fn, тот же принцип redundancy: (а) явный ro в позиции параметра избыточен (параметры — ro-вид по умолчанию, D176) → hard error E_REDUNDANT_PARAM_RO, обе синтаксические формы: префикс (ro x T) и тип-модификатор x ro T (fix-it: x T); комбинация V3-амендмента ro x mut T (ro-binding + явный mut content-view, ортогональные оси Plan 118.5 V3) — НЕ избыточна, остаётся легальной; (б) явный mut в позиции возврата избыточен (возвращённое значение — собственность вызывающего, мутабельность решает его биндинг) → hard error E_REDUNDANT_RETURN_MUT (fix-it: -> T); НЕ задевает -> *mut T (L3 pointee-capability) и -> ro T (осмысленный readonly-view, oracle row D). Тесты: conformance/neg/d246_redundant_param_ro_{prefix,type}_neg, d246_redundant_return_mut_neg; позитив-граница d246_param_ro_mut_view.

Запрет prefix-модификаторов (E_POINTER_PREFIX_MODIFIER): токены ro/mut/unsafe непосредственно перед * в type-position запрещены. Расширяет E_INVALID_POINTER_MODIFIER (D216 §1, commit 6d6a18a2ab7).

mut * T             // ❌ E_POINTER_PREFIX_MODIFIER — prefix перед *
ro * T              // ❌ E_POINTER_PREFIX_MODIFIER
unsafe * T          // ❌ E_POINTER_PREFIX_MODIFIER

Сообщение: «модификаторы указателя — на pointee (после *: *mut T/*ro T/ *unsafe T) или на binding (mut x *T); перед * не допускаются». Валидно: *mut T/*ro T/*unsafe T/*T (pointee, postfix), mut name *T (binding).

*T              // pointee ro (≡ *ro T, D246); pointee-mut из типа, не от binding
*ro T           // ❌ E_REDUNDANT_POINTER_RO (fix-it: *T) — избыточно
*mut T          // explicit mut pointee — единственный опт-ин на запись *p = …
*unsafe T       // pointer к possibly-uninit T; deref требует unsafe layer

§2. Binding (L1) vs pointee (L3) — ортогональны (D246)

L1 binding (ro/mut перед именем) задаёт только reassignability указателя. L3 pointee-capability — из типа (*T=ro / *mut T=mut), позиционно-независима, НЕ наследуется от binding (flip-scan/current отклонены D246).

ro p *Acc           // p фиксирован; pointee ro (*p = … ❌)
mut p *Acc          // p reassignable; pointee ro (*p = … ❌ — L1 mut НЕ даёт mut-pointee)
mut p *mut Acc      // p reassignable; pointee mut (*p = … ✅)
ro p *mut Acc       // p фиксирован; pointee mut (*p = … ✅) — оси независимы
mut q = &acc        // pointer mut auto (no &mut acc needed); pointee из контекста

Восстановление D216 §V2.6: mut-pointee при mut-binding НЕ автоматиченmut p *T даёт ro-pointee; writable pointee требует явного mut p *mut T. Тип самодостаточен (C1): *T всегда ro независимо от позиции/binding. «One canonical syntax» per смысл: ro-pointee — только *T, mut-pointee — только *mut T.

§3. Chain order (multi-level pointers)

Pointee-модификатор пишется постфиксом, сразу после каждого *, и относится к target этого *-уровня; читается left-to-right.

*mut *ro Acc        // mut pointer НА (ro pointer на Acc)
*ro *mut Acc        // ro pointer НА (mut pointer на Acc)

Prefix-форма (mut * ro * Acc) запрещенаE_POINTER_PREFIX_MODIFIER (§1).

Canonical Rust grammar.

§4. &value operator + escape analysis с auto-promote

⚠️ D216 §4 AMEND (Plan 118.6, 2026-06-16): &x is now safe (no unsafe {} required) for all types including primitives. addr_of() / addr_of_mut() retired → E_ADDR_OF_REMOVED. Escape analysis extended to primitives (previously only records). See amendment block at end of §4.

Safe pointer creation (no unsafe {} required, Plan 118.6+):

ro p = &x       // *ro T  — safe; heap-promote if &x escapes function scope
ro p = &y       // *mut T if y is mut binding, otherwise *ro T

Raw stack pointer (unsafe required):

unsafe { ro p = &x }   // no heap-promote; raw stack address; programmer's responsibility

Field address:

ro p = &x.field  // chain root (x) is promoted to heap if pointer escapes scope;
                 // whole binding moves to heap, NOT individual field.

Heap-promote semantics:

  • Compile-time static escape analysis decision (not runtime).
  • x starts on stack; promoted to heap at its declaration point if address escapes function scope (return / closure / heap-field store / fn arg).
  • Conservative V1: promote if ANY uncertainty. Precise inlining followup: [M-118-escape-precise].
  • Primitives (int, bool, f64, etc.) now subject to same escape analysis as records (Plan 118.6 extension).

Records (heap references): &record creates pointer to the reference. Result C type: Nova_Record** (double-pointer because record is already Nova_Record* in C ABI). Used primarily for FFI out-params: external fn try_init(out *Acc) -> i64 — C side fills *out.

&Record { ... } literal без named binding forbiddenE_AMP_RECORD_LITERAL. Anonymous-local auto-promote from temporary слишком implicit для production-grade reader clarity. Required pattern:

// ❌ implicit anonymous local
ro p = &Acc { name: "Piter" }
// ✓  explicit named local
ro acc = Acc { name: "Piter" }
ro p = &acc

D32 amend rationale: &value — typed pointer construction, не Rust borrow. Safety через escape analysis + auto-promote (не lifetime checker). До Plan 118.6 дополнительно требовал unsafe {} block; amend снимает это требование для safe-promote path. Raw stack pointer остаётся unsafe.

D216 §4 AMEND (Plan 118.6, 2026-06-16):

  • &x safe for all types (no unsafe {} for the promote path).
  • addr_of(x) / addr_of_mut(x) retired → E_ADDR_OF_REMOVED. Use &x instead.
  • Escape analysis extended to primitives.
  • E_UNSAFE_REQUIRED is NOT triggered by AddrOf starting Plan 118.6 (only by Deref *expr and unsafe fn calls).

addr_of / addr_of_mut builtins (Plan 118.1 Ф.3 closeout, 2026-06-05) — RETIRED Plan 118.6

addr_of(x) / addr_of_mut(x)RETIRED Plan 118.6 (2026-06-16). Use &x instead. Calling addr_of(x) or addr_of_mut(x) now emits E_ADDR_OF_REMOVED. History preserved below for reference.

Historical (Plan 118.1 Ф.3, 2026-06-05 — Plan 118.5): Zig-style builtin function aliases для &x (UnOp::AddrOf). Identical codegen path; rewriter-desugared в const_fn_eval pass. Used when explicit function-call syntax preferred over operator syntax (FFI patterns).

Retired in Plan 118.6 — &x is now safe and universally preferred. E_ADDR_OF_REMOVED fires on any remaining call site.

Enforcement (same as UnOp::AddrOf):

  • E_UNSAFE_REQUIRED — outside unsafe {} block (AddrOf no longer triggers this; only Deref and unsafe fn calls do, starting Plan 118.6)
  • E_REALTIME_POINTER_OP — inside #realtime fn
  • E_AMP_LITERAL / E_AMP_RECORD_LITERAL — invalid lvalues (literal / record literal)
  • E_ARRAY_INDEX_PTR_BANNED — operand is, or its field-access chain passes through, an array index (arr[i] / arr[i].field) — unstable base (buffer resize / GC compaction), D216 §15
  • E_ADDR_OF_NON_LVALUE — the operand’s field-access chain roots in an rvalue (call result, arithmetic, …). The lvalue check walks the WHOLE chain to its root (Plan 118.1 [M-118.1-addr-of-chains], amended 2026-06-08): a.b.c / (*p).f / self.x.y rooted in a named local or self are accepted, while make().f / arr[i].f / (x+1).f are rejected (previously only the top operand node was inspected, so chains rooted in temporaries were wrongly accepted — a dangling-pointer gap). addr_of(x) and &x share one walker (ast::addr_of_chain_root), so the intrinsic and operator forms agree, and addr_of(*p)&(*p) (the walker descends an explicit deref to the pointer root, matching the p.f auto-deref sugar).
  • E_ADDR_OF_MUT_REQUIRES_MUT_BINDING — addr_of_mut on a binding whose root is not mut; the mut-check also walks the field-chain root (addr_of_mut(s.field) requires mut s), not just a bare Ident (NEW).

Known V1 gaps (2026-06-08 followups): (1) addr_of_mut((*p).field) is not yet gated on p being *mut/mut-bound — the mut-check skips deref roots and the desugar is a bare UnOp::AddrOf with no *mut cast CLOSED Plan 118.6addr_of_mut retired; &x with mut binding auto-infers *mut T. (2) The addr_of(...) intrinsic chain-check runs in the const-fn rewrite pass, so nova check/LSP does not surface it MOOTaddr_of retired Plan 118.6; &x chain-check runs at check-time. ([M-118.1-addr-of-chains-checktime] — closed by retirement).

Closes [M-118.1-addr-of-macros] (was: «add addr_of! macro» — macro framework not shipped, builtin-fn alternative landed). Closes [M-118.1-addr-of-mut-deref-ptr-mut] (Plan 118.6 — addr_of_mut retired; &x mut-inference covers this).

§5. Auto-deref

unsafe {
    p.field             // ✓ auto-deref one-level read
    p.method()          // ✓ auto-deref method call (one-level)
    p.field = v         // ✓ auto-deref assignment (requires *mut T)
    *p                  // ✓ explicit deref read
    *p = v              // ✓ explicit assignment (requires *mut T)
    (*p).field          // ✓ multi-level chain через explicit *
}

Rules:

Op*ro T*mut TNotes
p.field readauto-deref one-level
p.field = v❌ E_POINTER_RO_ASSIGNrequires *mut
p.method() (ro recv)auto-deref
p.method() (mut recv)❌ E_POINTER_RO_MUT_METHODrequires *mut
*p readyields T
*p = v❌ E_POINTER_RO_ASSIGNrequires *mut

One-level only для auto-deref (Go-style); multi-level requires explicit (*p).field chain. Только в unsafe context — все pointer ops gated. Pattern match Option[*T] — safe outside unsafe (inspection, не deref).

§6. Pointer arithmetic + order comparison

unsafe {
    ro p1 = some_ptr + 1            // *unsafe T (degraded)
    ro p2 = some_ptr + offset
    ro diff = p2 - p1               // int (element count, signed)
    unsafe { *p1 }                   // *unsafe T deref требует ещё unsafe layer
    ro lt = p1 < p2                  // order-compare allowed inside unsafe
}

// Equality `==`/`!=` — safe anywhere (identity check, no ordering):
ro p = unsafe { &x }
ro q = unsafe { &x }
ro same = p == q                     // OK outside unsafe — identity check
  • +/-/+=/-= only в unsafe { } block
  • Result *unsafe T для ptr ± int; int для ptr - ptr (signed element count)
  • Units: sizeof(T)-scaled (C/Rust convention)
  • *///etc. — E_PTR_ARITHMETIC_INVALID (не математически осмыслено)
  • Order compare <, <=, >, >= — require unsafe context — E_PTR_ORDER_COMPARE_REQUIRES_UNSAFE ACTIVE 2026-06-02 (V1 syntactic, commit 601af30fc30) — closes acceptance A17 partial. Rationale: pointer addresses не stable ordinals (GC-relocation invariant + OS ASLR random layout). V2 (Session 4+): full type-aware enforcement через infer_expr_type.
  • Equality ==/!= — safe everywhere (identity check; OK outside unsafe).

Nova codegen note (Plan 131, 2026-06-08): For typed pointer *mut T, the expression ptr + n emits (ptr + n) in C — the C compiler scales by sizeof(T) automatically (standard C pointer arithmetic). *(ptr + n) = v emits an lvalue deref-write. p as *mut T emits (T*)(p) reinterpret cast. This is the foundation Vec[T] uses for its element buffer (see D232).

§7. Null safety: Option[*T] + NPO codegen

*T — non-null guaranteed. Option[*T] — nullable через NPO codegen.

Status: ACTIVE 2026-06-02 (Plan 118 Ф.5 V1 landed, commit 6b90e698437). Closes acceptance A19 ✅ (sizeof verification + struct layout).

  • Layout: single pointer (8 bytes), не tagged struct (16 bytes)
    // NPO-eligible: pointer-typed inner (c_ty ends_with('*'))
    typedef struct NovaOpt_const_Nova_X_p { const Nova_X* value; } ...;
    // Non-NPO: scalar/composite inner — tagged form retained
    typedef struct NovaOpt_nova_int { int tag; nova_int value; } ...;
    
  • Construction: Some(p){.value = p}; None{.value = NULL}
  • Pattern match: if (ptr == NULL) None_branch else Some_branch(ptr)
  • Direct C-FFI compatible (matches malloc / fopen / dlopen returns)

V1 detection (c_ty.ends_with('*')): covers *T family (T*, const T*, void*), pre-existing stdlib pointer types (UdpSocket, TcpStream, SocketAddr, TcpListener, File, etc — все benefit automatically).

V2 detection (Plan 118 Ф.5.4 ACTIVE 2026-06-02, commit cd168a4d53b):

  • Option[ptr]nova_ptr typedef (= void*) — now NPO-eligible (A21 partial closes). Plan 115 backward-compat preserved.

V3 detection (Plan 118 Ф.5.8 ACTIVE 2026-06-02, commit 9fe42f39c51):

  • Option[X] где type X(*T) / type X(ptr) — newtype-over-pointer transparent typedef. Lookup Nova_X в type_aliases (registered emit_type_decl); if underlying alias_c ends ’*’ or == “nova_ptr”, NPO-eligible. Closes A20 ✅. Note: для canonical Plan 115 pattern Nova type system pre-collapses к underlying (nova_ptr) — V2 branch fires directly; V3 defensive coverage для future paths.

V4 detection (Plan 118 Ф.5.9 ACTIVE 2026-06-02, commit 3725af23fcd):

  • Option[Option[*T|ptr]] nested — emits W_OPTION_DOUBLE_NESTED warning через lint framework (lints.rs lint_option_double_nested). Outer Option uses tagged fallback (correctly — inner c_ty = struct); semantically ambiguous (None vs Some(None)). Closes A22 ✅.

V5 detection (Plan 118 Ф.5.10 ACTIVE 2026-06-02):

  • Option[*fn(...)] — function pointer types. После audit type_ref_to_c lowering: TypeRef::Pointer(modif, TypeRef::Func{...}, _)void** (Func → void*, outer Pointer adds another *). c_ty ends with * → V1 detection (Ф.5 A19) ALREADY triggers NPO. Closes A21 remainder ✅ через existing infrastructure без code changes. Test fixture t5_7_npo_option_fn_pointer_ok verifies.

All Ф.5 NPO acceptance criteria CLOSED: A19 ✅, A20 ✅, A21 ✅, A22 ✅, A23 ✅.

Other deferred (Session 4+):

  • null ptr literal retractionA23 ✅ CLOSED 2026-06-02. D214 amended; parser emits E_NULL_PTR_RETRACTED_USE_OPTION; 14 fixtures migrated к (0 as ptr). Closes [M-115-null-ptr-to-option-after-npo].
external fn malloc(sz int) -> Option[*u8]
// → C: uint8_t* malloc(size_t); (codegen casts intptr_t→size_t)

unsafe {
    match malloc(1024) {
        Some(buf) => use(buf),       // buf: *u8 non-null
        None      => Fail.throw(OutOfMemory),
    }
}

NPO applies к:

  • Option[*T] всех вариантов
  • Option[*fn(...) -> ...]
  • Option[ptr] (D214 amend)
  • Option[NewtypeOверPtr] где type X(*T) / type X(ptr)

Excluded: nested Option[Option[*T]] — fallback к tagged repr + W_OPTION_DOUBLE_NESTED warning.

§8. unsafe { } block

  • Pointer ops require unsafe context (compile-time gating через E_UNSAFE_REQUIRED)
  • Implementation: sugar над with unsafe_handler { perform UnsafeOps.* } (D2-consistent; см. D2 amend)
  • unsafe_handler — built-in, не user-overridable (E_UNSAFE_HANDLER_BUILTIN_ONLY)
  • Effect не propagates up (encapsulates per fn — canonical Rust pattern)

Inside unsafe разрешено: &value, *p, p[i] (pointer index), p.field, p.method(), p.field = v, pointer arith, int as *T, </> compare, &record.field, calling unsafe fn, newtype construction wrapping pointer.

ptr[i] pointer index (D216 §8, [M-118-ptr-index-unsafe], 2026-06-09): ptr[i]*(ptr + i) — derefs the pointer without bounds guarantee. Semantically identical к explicit *ptr deref, hence requires the same unsafe context. E_UNSAFE_REQUIRED fired when ptr[i] is used outside unsafe { } block or unsafe fn body. Detection: syntactic — expr_is_typed_pointer(obj) (covers *T/*mut T/*unsafe T bindings via ptr_vars frame OR explicit type-annotation *T on binding). Example migration: unsafe { *(@data + i) }unsafe { @data[i] } (more ergonomic; enables C (data)[i] pointer-arithmetic emission which the C compiler scales automatically by sizeof(T)).

Outside unsafe safe: type declarations *T, external fn declarations, field read acc.next (where next *T), pattern match Option[*T], ==/!= compare, newtype declarations, p as int (hash hazard warning).

§9. unsafe fn keyword syntax (Plan 118.1.7 amend, 2026-06-09)

Plan 118.1.7 migrates from #unsafe attribute to unsafe fn keyword (type-consistent, per Plan 118.5 TypeRef::Unsafe + Plan 118.1.6 *unsafe fn ptr type). #unsafe fn → hard error E_UNSAFE_ATTR_DEPRECATED.

  • unsafe fn foo(...) — declares function of unsafe fn type
  • external unsafe fn foo(...) — external fn of unsafe type
  • Body of unsafe fn — implicit unsafe context (pointer ops без unsafe { } wrap)
  • Call unsafe fn — requires unsafe { ... } wrap у caller (visual marker) — E_UNSAFE_CALL_REQUIRES_WRAP иначе
  • Type: unsafe fn(Args) -> Ret (consistent with *unsafe fn(...) fn-ptr type, Plan 118.1.6)
  • No propagation up — каждая fn decides encapsulate or propagate
  • #unsafe fn / #unsafe external fnE_UNSAFE_ATTR_DEPRECATED (hard error)

§10. *fn(...) function pointers

  • *fn(Args) -> Ret distinct от fn(Args) -> Ret closure
  • Cast fn → *fn — captureless required (E_CLOSURE_HAS_ENV иначе)
  • Cast *fn → fn — unsafe (wraps в captureless closure; E_CAST_RAW_FN_TO_CLOSURE без unsafe)
  • Callback no-throw: Fn-with-Fail effect cast → *fn — E_CALLBACK_THROWS_OVER_C_ABI (C ABI не propagates Nova exceptions)
  • External fn no-Fail: external fn ... Fail -> ...E_EXTERNAL_FN_FAIL_EFFECT
  • Calling convention: default C ABI текущей платформы (single ABI V1; stdcall/vectorcall — [M-118-stdcall-fn-ptr] followup)
  • Vararg — E_VARARG_NOT_SUPPORTED ([M-118-vararg-ffi] followup)

unsafe fn as part of fn-ptr type (Plan 118.1.6 closeout, 2026-06-08; amend Plan 118.1.7, 2026-06-09)

Function pointer тип encodes unsafe fn keyword:

  • *fn(...) — safe function pointer
  • *unsafe fn(...) — unsafe function pointer (postfix pointee; prefix unsafe * fn(...) retired — E_POINTER_PREFIX_MODIFIER, Plan 138.5)

Coercion rules:

  • *fn → *unsafe fn: ✅ allowed (covariant — safe это «подмножество» unsafe)
  • *unsafe fn → *fn: ❌ E_UNSAFE_FN_PTR_COERCION (нельзя «забыть» unsafe)

Call-site:

  • Call через *unsafe fn ptr без unsafe { } → E_UNSAFE_CALL_REQUIRES_WRAP (mirrors direct unsafe fn call).

addr_of propagation:

  • addr_of(safe_fn) → *fn(…)
  • addr_of(unsafe fn) → *unsafe fn(…) (тип propagated из FnDecl.unsafe_attr)

Rust precedent: fn() ≠ unsafe fn() — same model.

Закрывает [M-118.1.5-unsafe-fn-pointer-type].

ABI-тег fn-ptr: *extern "C" fn (Plan 174.6 M0 cross-amend, 2026-07-04)

*fn(...) / *unsafe fn(...) (выше) — Nova-ABI captureless fn-ptr: Nova-типы в сигнатуре допустимы (Nova ABI их передаёт; «captureless» — про отсутствие env, не про типы). Для передачи Nova-функции как настоящего C-callback введён C-ABI-тегированный fn-ptr тип *extern "C" fn(...) — параллель к объявлению extern "C" fn (08-runtime.md#d282). Типы его сигнатуры (параметры + возврат) обязаны быть C-ABI-совместимы (рекурсивный тип-лист, D282 rule 2); коэрция fn → *extern "C" fn проверяет C-ABI + captureless + effect-free/total (callback не должен объявлять никакого эффекта — C зовёт его без Nova-handler-фрейма на стеке, поэтому любая effect-операция unsound; это обобщает Fail-специфичный гейт §20 / E_CALLBACK_THROWS_OVER_C_ABI на все эффекты). Полная спецификация ABI-тега и обоснование условия (3) — D353. Реализация (парсер/чекер/тесты) — Plan 174.6 M1–M3; M0 = только спека.

Полная cast/коэрция-матрица fn / *fn / *extern "C" fn (какой источник во что коэрцится и с какой диагностикой) + правило «*fn и *extern "C" fn — разные типы, нет неявной конверсии» + легальность тега в non-extern "C" fn позициях (Nova-fn-параметр / поле value-record) — D353 «Cast/коэрция-матрица» (Plan 174.6 M2). Строка «Calling convention: default C ABI» выше (§10 список) относится к эмиссии fn-ptr на платформе; ABI-тег на ТИПЕ (*fn = Nova-ABI против *extern "C" fn = C-ABI) — ортогональное измерение, введённое здесь (полная ретракция формулировки §10 про «default C ABI» у bare *fn — остаток [M-174.6-ffi-abi], семантически связан с дефолтным ABI *fn).

§11. ptr redefine (D214 amend cross-ref)

⚠️ RETIRED by Plan 134 (2026-06-09) — the ptr built-in name is fully removed; there is no type ptr Option[*unsafe ()] builtin anymore. The idiomatic opaque pointer is *() (pointer-to-unit = void*). ptr/nova_ptr in type position → E_TYPE_UNKNOWN (use *()). See the D214 SUPERSEDED banner. The note below is preserved for historical context only.

type ptr Option[*unsafe ()]
  • ABI preserved (single void*)
  • null ptr literal retracted (use None); closes [M-115-null-ptr-to-option-after-npo]
  • Backward-compatible для existing ptr usages (handle patterns, tuple FFI returns, etc.)

§11a. Typed pointer instance methods (Ф.4 V1, amend 2026-06-03)

Primitive-T typed pointer instance methods landed (V1 scope — primitive T only, struct-T deferred):

MethodReceiverReturnsC codegen
(*ro T).read()any *T/*ro T/*mut T/*unsafe TT(*p)
(*mut T).write(v T)*mut T / *unsafe Tnova_unit((*p) = v, NOVA_UNIT)

Detection: obj_ty ends в * AND not a known Nova typedef (Nova_*/NovaArray_*/NovaOpt_*/NovaRes_*/NovaBox_*/NovaValue_*) AND not void* / nova_ptr. is_const derived от const prefix on obj_ty; controls write availability.

Safety convention: caller wraps в unsafe { ... } block. Enforcement [M-118.1-unsafe-attr-on-external-fn] ✅ RESOLVED (Plan 118.1.5 — capability ships; note: syntax later superseded by 118.1.7).

Diagnostic:

  • (*ro T).write(v) — currently emits generic “method not found” via fall-through to default dispatcher; typed E_PTR_WRITE_ON_RO_TARGET deferred — followup [M-118.4-typed-ro-write-error]

Limitations (V2 follow-up):

  • Struct T (obj_ty starts с Nova_) — read/write not dispatched (deep copy + ownership semantics required) — [M-118.4-struct-ptr-read]
  • Pointer arithmetic (p.add(n), p.offset(n)) — [M-118-ptr-arithmetic]
  • Volatile variants (read_volatile/write_volatile) — [M-118.1-volatile-ops]

Closes followup [M-118.1-typed-pointer-instance-methods] для primitive T scope.

§12. Casts

FromToSafe?
*Tint✓ (см. hash hazard)
int*Tunsafe
*ro T*mut Tunsafe
*mut T*ro T / *T
*T*unsafe T
*unsafe T*Tunsafe
*T1*T2 (T1≠T2)unsafe
fn → *fn✓ если capturelessE_CLOSURE_HAS_ENV иначе
*fn → fnunsafewraps
*Tbool / f64 / etc.E_PTR_CAST_INVALID_TARGET

Hash hazard: p as int для GC-tracked objects + HashMap key → W_PTR_AS_INT_GC_HASH_HAZARD (address can change via GC compaction). Note: usize/isize removed (Plan 133) — use int for pointer-as-integer casts.

§13. Comparison

  • ==/!= safe (identity check)
  • </>/<=/>= unsafe (cross-allocation UB + moving GC concern)

§14. &record.field only в unsafe

GC compaction concern: address меняется при collection. Inside unsafe — user обещает no GC trigger (honor-system §16).

§15. Forbidden ops

  • &arr[i] всегда — E_ARRAY_INDEX_PTR_BANNED (array buffer может relocate via realloc / GC compaction)
  • null literal — E_NULL_LITERAL_USE_NONE (use None; one-way-to-do)
  • undefinedE_UNDEFINED_USE_NONE_INIT_PATTERN (use Option[*T] = None + init; полноценный MaybeUninit[T] — Plan 118.2)
  • Vararg calls — E_VARARG_NOT_SUPPORTED

§16. GC honor-system

Контракт unsafe-блока: внутри unsafe { ... } user обещает no GC trigger между pointer creation и use. GC trigger = heap allocation, yield-point (await/spawn/supervised{}), string formatting which allocates, #parks/#wakes fn calls.

Compiler warns: W_UNSAFE_GC_TRIGGER per violation site. Silence: // noqa: W_UNSAFE_GC_TRIGGER comment marker.

Rationale V1:

  • Boehm-style conservative GC не двигает объекты → V1 безопасно (warning = awareness, not error)
  • Future moving GC → potрebует formal pin API ([M-118-pin-api] followup)
  • Honor-system + warning = pragmatic trade-off (no runtime cost, spec contract clear, future-compatible)

§17. Pointer Debug formatting

Canonical form (Plan 91.14, D229 — 2026-06-05):

  • *T implements Debug (sibling protocol — см. D229) через built-in @debug(sb StringBuilder) который emits hex address + type name ("0x7f... -> Account").
  • Canonical interpolation: "${p:?}" — routes к Debug.@debug (debug semantics: diagnostic, machine-oriented).
  • *T НЕ implements Display — bare "${p}" остаётся ошибкой (forces explicit decision; pointer debugging = deliberate; addresses non-deterministic, leak ASLR info).

Legacy alias (backwards-compat):

  • (*T).to_debug_str() -> str — built-in method (in unsafe context only). Эквивалент let sb = StringBuilder.new(); p.@debug(sb); sb.to_str(). Сохраняется для пред-D229 кода; новый код пишет "${p:?}".

Bare ${p} enforcement:

  • "${p}" interpolation → E_PTR_NO_DISPLAY_USE_DEBUG_STRACTIVE 2026-06-02 (V1 syntactic, commit a9327c65d3f) — closes acceptance A28 partial. V1 detects:
    • direct ${&x} / ${*p} (Unary AddrOf/Deref)
    • ${expr as *T} (cast к pointer type)
    • ${var} где var bound через let var = AddrOf/Deref/As(*T) V2 (Session 4+): full type-aware enforcement через infer_expr_type — fires на returned pointer values, field access, generic-bound *T.
  • Hint в диагностике после Plan 91.14: «use ${p:?} for pointer debug formatting (Debug, D229)».

См. также: D229 — Debug protocol + ${expr:?} format-spec syntax.

§18. FFI handle allocation contract

Production-grade guidance:

FormAllocationABIWhen
type Handle(*T) tuple newtypestacksingle pointeropaque handles, no extra state
type Handle(ptr) tuple newtypestacksingle pointeruntyped opaque handles
type Handle { p *T, extra State } recordheappointer-to-structhandle с extra state

Canonical (zero-overhead):

type Sqlite3Handle(*sqlite3)
external fn open(path str) -> (Option[Sqlite3Handle], i64)

Plan 115 V1 cookbook examples (record form type Db { ro value ptr }) — migrated к tuple newtype в Plan 118 Ф.9 ([M-118-handle-migration]).

§19. Function call argument passing

  • *T parameters — pass by value (single pointer-word; standard C ABI)
  • &value at call site creates *T argument
  • Auto-promote applies к escape-via-fn-arg (conservative: ESCAPE always for fn args; precise inlining [M-118-escape-precise] followup)

§20. extern "C-unwind" story (NEGATIVE — not V1)

V1: external fn + *fn callbacks must not have Fail effect on Nova→C boundary. Diagnostics: E_EXTERNAL_FN_FAIL_EFFECT, E_CALLBACK_THROWS_OVER_C_ABI. Workaround: catch внутри callback, return sentinel.

V2 — research extern "C-unwind" (Rust 2024 model); [M-118-extern-c-unwind] followup.

§22. CStr type (Plan 118.1 Ф.4 closeout, 2026-06-05)

type CStr(*u8) newtype declared в std/ffi/cstr.nv — FFI-compatible C-string handle. ABI: marshals к const char* / uint8_t* (single positional *u8 field).

Invariant: instances must satisfy ptr[strlen(ptr)] == '\0'. Per D26 §«Nul-termination», full Nova str already ships с this invariant, enabling zero-copy conversion.

Conversion methods (Plan 118.1 closeout amend, 2026-06-06): str → CStr conversions реализованы как pure-Nova methods в std/ffi/cstr.nv:

export fn str @as_cstr() -> CStr {
    ro bytes = @as_bytes()
    for b in bytes {
        if b == 0 { panic("as_cstr: embedded NUL byte in str (would truncate C-string)") }
    }
    unsafe { CStr(bytes.as_ptr()) }
}
export unsafe fn str @as_cstr_unchecked() -> CStr {  // scan-free O(1) hatch
    ro bytes = @as_bytes()
    unsafe { CStr(bytes.as_ptr()) }
}
// @to_cstr() — owning always-copy form; NOT in V1, deferred to Plan 118.2.

Использует existing builtins: str.as_bytes() (D176 zero-copy view) + []u8.as_ptr() (Plan 118.2 Ф.1, commit e80a57e54e7). C primitives НЕ требуются — D26 ptr[len]==‘\0’ invariant + Nova-side wrapping достаточно.

V1 simplifications (explicit followups, not silent):

  • [M-118.1-cstr-nul-check] — ✅ CLOSED 2026-06-08. @as_cstr() scans the str bytes for an embedded NUL and panics (interior 0x00 would truncate the C-string at that byte); @as_cstr_unchecked() is the scan-free unsafe fn hatch. panic is reachable via a module-private external fn panic decl — cstr.nv is ExternalRegistry-loaded and gets no auto-prelude, and a plain import std.prelude.* would trip the R27 auto-import opt-out for importers.
  • [M-118.1-cstr-to-cstr-distinct-copy] — DEFERRED → Plan 118.2. @to_cstr() is NOT shipped in V1: the as_X/to_X convention makes to_cstr an OWNED copy (buffer outliving the source str), which needs an allocator/free API. Rather than ship a misleadingly-named zero-copy alias, the method was removed (2026-06-08); the owning copy lands in Plan 118.2.

Closes [M-118.1-cstr-literal] (was: «add c”hello” prefix-literal»; superseded by D26 invariant). Closes [M-118.1-cstr-runtime-wiring] (was: «C primitive ABI wiring»; pure-Nova approach makes it unnecessary).

Diagnostic codes (new)

Errors:

  • E_UNSAFE_REQUIRED — pointer op (*expr Deref / unsafe fn call) outside unsafe context (block.is_unsafe = false AND not в unsafe fn body). Active enforcement через check_unsafe_context_in_module walker pass с depth counter — D216 §8 V1 ENFORCED 2026-06-02. Plan 118.6 amend (2026-06-16): &x AddrOf (safe promote path) no longer triggers E_UNSAFE_REQUIRED. Only raw stack unsafe { &x } and Deref/unsafe fn calls remain gated.
  • E_UNSAFE_CALL_REQUIRES_WRAP — calling unsafe fn без unsafe { } wrap. Active enforcement через check_unsafe_context_in_module walker с pre-collected unsafe_fns: HashSet. D216 §9 V1 ENFORCED 2026-06-02 (commit abd4be4603b)
  • E_UNSAFE_ATTR_DEPRECATED#unsafe fn / #unsafe external fn syntax; use unsafe fn / external unsafe fn instead (Plan 118.1.7)
  • E_ARRAY_INDEX_PTR_BANNED&arr[i]
  • E_NULL_LITERAL_USE_NONEnull literal (general)
  • E_NULL_PTR_RETRACTED_USE_OPTIONnull ptr (Plan 115 V1) retracted
  • E_UNDEFINED_USE_NONE_INIT_PATTERNundefined used
  • E_CLOSURE_HAS_ENV — fn → *fn cast attempted с closure env
  • E_CALLBACK_THROWS_OVER_C_ABI — Fn-with-Fail → *fn cast. Active enforcement — D216 §10/§20 V1 ENFORCED 2026-06-02 (commit e4cff57142e)
  • E_EXTERNAL_FN_FAIL_EFFECT — external fn declaration с Fail
  • E_PTR_ARITHMETIC_INVALIDp * 2, p / 4, etc.
  • E_POINTER_RO_ASSIGN*p = v / p.field = v где p ro
  • E_POINTER_RO_MUT_METHODp.mut_method() где p ro
  • E_PTR_CAST_INVALID_TARGETp as bool / f64 / ...
  • E_INVALID_POINTER_MODIFIER*const T и др.
  • E_POINTER_PREFIX_MODIFIERro/mut/unsafe перед * в type-position (mut * T / ro * T / unsafe * T); use postfix *mut T/*ro T/*unsafe T или binding mut x *T. Extends E_INVALID_POINTER_MODIFIER (Plan 138.5 §1).
  • E_DUPLICATE_POINTER_MODIFIER*ro mut T
  • E_PARSE_POINTER_TYPE_INCOMPLETE* без type
  • E_REALTIME_POINTER_OP — pointer op в #realtime fn body. Active enforcement — D216 §20 + Plan 113 D172 V1 ENFORCED 2026-06-02 (commit 6752565f453)
  • E_UNSAFE_HANDLER_BUILTIN_ONLY — user-defined unsafe_handler attempt
  • E_AMP_CONST_BINDING&const_value
  • E_AMP_LITERAL&42
  • E_AMP_RECORD_LITERAL&Record { ... } без named binding (Plan 118 §4 amend)
  • E_ADDR_OF_NON_LVALUEaddr_of / addr_of_mut applied к non-Ident / Member / SelfAccess expression (rvalue / temporary). Mirrors Rust’s «cannot take address of a temporary». Plan 118.1 closeout 2026-06-05.
  • E_ADDR_OF_MUT_REQUIRES_MUT_BINDINGaddr_of_mut applied к ro binding (let без mut, ro parameter, ro field). Mirrors E_PARAM_NOT_MUT / E_LOCAL_NOT_MUT pattern (D108.1 / D108.2). Plan 118.1 closeout 2026-06-05.
  • E_ADDR_OF_REMOVEDaddr_of() / addr_of_mut() called after retirement (Plan 118.6 D216 §4, 2026-06-16). Use &x instead. Both functions are removed from prelude; any surviving call site raises this error.
  • E_PTR_NO_DISPLAY_USE_DEBUG_STR"${p}"
  • E_VARARG_NOT_SUPPORTED — vararg FFI call
  • E_CAST_RAW_FN_TO_CLOSURE*fn → fn cast outside unsafe

Warnings:

  • W_UNSAFE_GC_TRIGGER — GC trigger внутри unsafe с pointer in scope
  • W_PTR_AS_INT_GC_HASH_HAZARDp as int как HashMap key
  • W_OPTION_DOUBLE_NESTEDOption[Option[*T]] NPO fallback

Mainstream comparison

ЯзыкTyped ptrUnsafe modelNull safetyAuto-derefArithmetic
Rust*const T/*mut T/&T/&mut Tunsafe { } + unsafe fnOption<&T> + NPOчерез refunsafe only
Zig*T/*const T/[*]T(нет keyword; intrinsics)?*T + NPO.* postfix + .+ для [*]T only
C#T* (unmanaged) / ref T / in T / out Tunsafe modifierT?p->fieldunsafe only
SwiftUnsafePointer<T> / UnsafeMutablePointer<T>Type-based (Unsafe* prefix)Optional + NPO.pointeeonly через advanced(by:)
DT* / ref T / scope T*@safe/@trusted/@systemNullable!Tp.field auto@system only
Go*T (managed); unsafe.Pointerunsafe packageNil runtimep.field autounsafe.Pointer only
Nova V1 (Plan 115)ptr only(нет)null ptr(нет)banned
Nova V2 (Plan 118)*T family + unsafeunsafe { } + unsafe fn (D2 amend)Option[*T] + NPOp.field/p.method() one-levelgated unsafe → *unsafe T

Use cases

  • Typed FFI buffers (libpng image data, libcurl headers, sqlite blobs) — full impl Plan 118.1 (memory primitives) + 118.2 (slice fat-pointer)
  • Memory-mapped I/O (registers, framebuffers) — Plan 118.1 volatile RW
  • Manual linked structures (intrusive lists, lock-free queues, custom allocators) — Plan 118.3 AtomicPtr
  • Performance-critical hot loops (escape analysis + GC-pressure reduction)
  • Out-params для FFI (int func(out int* result)) — Plan 118.1 addr_of_mut!

Cross-ref

  • D2 (amend)unsafe { } keyword restored
  • D32 (amend)&value not Rust borrow
  • D52 — type forms (tuple newtype canonical для FFI handles)
  • D214 (amend) — ptr redefine
  • D215 — Plan 120 stack tuples (escape interaction)
  • D172#realtime ban для pointer ops
  • D217 — Plan 118.1 (FFI intrinsics)
  • D218 — Plan 118.2 (slice + uninit)
  • D219 — Plan 118.3 (concurrency)
  • Plan 118 — implementation

Acceptance

См. Plan 118 A1-A35 (T1-T8 + R1-R5 series).


D216 V2 amend (2026-06-04) — universal right-binding rule для type-level modifiers + unsafe T first-class

⚠️ PARTIALLY SUPERSEDED — Plan 138.5 (2026-06-11): часть V2, касающаяся указателей (outer pointer-mut как type-wrapper: mut * T = Mut(Pointer(T)), unsafe * T = Unsafe(Pointer(T)), NPO-таблица §V2.4 по outer-wrapper), РЕТРАКТИРОВАНА. FINAL pointer model = pointee-mut постфикс только (*mut T/*ro T/*unsafe T), reassignability = binding (let/mut, D36), nullable = Option[*T] только. Все prefix-формы перед * запрещены (E_POINTER_PREFIX_MODIFIER, §1). СОХРАНЯЕТСЯ: §V2.3 unsafe T value-wrapper (MaybeUninit-style) — про значение, ортогонально указателям; и универсальное right-binding для value-T модификаторов (ro T/mut T/ unsafe T wrappers, codegen-transparent). См. retract-пометки в §V2.1/§V2.2/ §V2.4/§V2.6 и §V3.2/§V3.3/§V3.4 ниже.

Status: 🆕 SPEC LANDED 2026-06-04 (parser/codegen migration — Plan 118.5 NEW sub-plan, см. follow-up markers ниже). Этот amend был breaking change для existing *ro T / *mut T / *unsafe T syntax — pointer-часть позже ретрактирована (Plan 138.5, см. banner выше).

Motivation

Inconsistency discovered 2026-06-04: parser применяет «right-binding rule» для ro T (TokenKind::KwRo → recursive parse_type()Readonly(inner)), но pointer-modifier syntax *ro T / *mut T / *unsafe T использует inline modifier-after-star form. V2 попытался унифицировать через prefix-wrappers; Plan 138.5 (2026-06-11) выбрал обратное — postfix pointee canonical, prefix запрещён (один указатель-модификатор = pointee, постфикс).

TokenСемантика (FINAL, Plan 138.5)
ro TReadonly(T) — value-T wrapper, codegen-transparent (KEPT)
mut TMut(T) — value-T wrapper, codegen-transparent (KEPT, §V2.2b)
unsafe TUnsafe(T) — value-T wrapper, MaybeUninit (KEPT, §V2.3)
consume Tconsume wrapper (receiver/field/decl, см. D162)
*TPointer(T) — pointee ro (≡ *ro T, D246; pointee-mut из типа, не от binding)
*ro TE_REDUNDANT_POINTER_RO (избыточно: *T уже ro; fix-it *T) — D246
*mut TPointer(Mut(T)) — explicit mut pointee (единственный опт-ин на *p = …)
*unsafe TPointer(Unsafe(T))CANONICAL pointer к possibly-uninit T
mut * TE_POINTER_PREFIX_MODIFIER (prefix перед * запрещён; use mut x *T binding)
ro * TE_POINTER_PREFIX_MODIFIER
unsafe * TE_POINTER_PREFIX_MODIFIER (RETIRED Unsafe(Pointer); nullable = Option[*T], FFI nullable-uninit = Option[*unsafe T])

§V2.1 — universal right-binding rule (value-T modifiers)

Правило (KEPT для value-T): type-level modifier применяется к ВСЕМУ что справа от него до конца type-expression, либо до следующего modifier. Это про value-T wrappers (ro/mut/unsafe/consume), codegen-transparent.

⚠️ Pointer-часть RETRACTED (Plan 138.5): * НЕ «pure constructor с prefix-модификатором снаружи». Указатель несёт postfix pointee-модификатор, сразу после * (*mut T/*ro T/*unsafe T). Prefix перед * (mut * T/ro * T/unsafe * T) — E_POINTER_PREFIX_MODIFIER (§1).

Modifiers (выровненная иерархия) — применимы к value-T и как postfix pointee:

  • ro — readonly (compile-time immutability)
  • mut — mutable (compile-time mutability marker)
  • unsafe — unsafe (init/layout/aliasing contracts off)
  • consume — consume (unique ownership, D162 follow rules)

Parser pattern (FINAL):

TYPE     := MODIFIER TYPE | BASE_TYPE | '*' POINTEE | '[' ']' TYPE | ...
POINTEE  := POINTEE_MOD POINTEE | TYPE        // постфикс после '*'
MODIFIER := 'ro' | 'mut' | 'unsafe' | 'consume'
POINTEE_MOD := 'ro' | 'mut' | 'unsafe'

Каждый value-modifier — TypeRef::<Modifier>(Box<TypeRef>) wrapper. * — конструктор Pointer(Box<TypeRef>), чей pointee может нести ro/mut/unsafe постфиксом. Prefix-модификатор перед * запрещён (см. banner выше).

§V2.2 — chain semantic (multi-modifier / multi-pointer)

⚠️ RETRACTED (Plan 138.5): трактовка «mut * T = Mut(Pointer(T)) (outer pointer-mut как type-wrapper)» отозвана. Outer-pointer-mut в типе больше не существует — reassignability указателя выражается binding’ом (let/mut, D36), а pointee-мутабельность — postfix после *.

FINAL chains читаются с postfix-pointee; reassignability — отдельно через binding:

*T                  // Pointer(T) ≡ Pointer(Readonly(T)) — pointee ro (D246; *T ≡ *ro T)
*mut T              // Pointer(Mut(T))       — explicit mut pointee (единственный опт-ин)
*unsafe T           // Pointer(Unsafe(T))    — valid (non-null) ptr к possibly-uninit T
ro p *mut *T        // L3 из типа (D246): внешний *mut (writable), внутренний *T (ro pointee)
                    // позиционно-независимо; binding ro = только p не reassignable
ro p *T             // binding ro: p фиксирован; pointee ro
mut p *T            // binding mut: p reassignable; pointee ro (*p = … ❌ — L1 mut ≠ mut-pointee)
mut p *mut T        // binding mut: p reassignable; pointee mut, writable (*p = … ✅)
mut p *unsafe u8    // binding mut: p reassignable; pointee possibly-uninit byte

RETRACTED (теперь parse error E_POINTER_PREFIX_MODIFIER):

mut * T             // ❌ — вместо: binding `mut p *T`
ro  * T             // ❌ — вместо: binding `let p *T` (или `ro p *T`)
unsafe * T          // ❌ — RETIRED Unsafe(Pointer); вместо: Option[*T] / *unsafe T (pointee)
mut * ro * T        // ❌ — вместо: `mut p *ro *... ` postfix-chain
ro p mut * unsafe T // ❌ — вместо: `mut p *unsafe T` (binding mut + pointee unsafe)

Канонический пример FFI out-param (FINAL):

external fn os_read(fd int, buf *unsafe u8, n int) -> int
//                              pointee uninit byte; buf non-null *; OS fills, returns count.
// Если sam buf переприсваивается в теле — `mut buf` на стороне caller's binding.

§V2.3 — unsafe T semantic (MaybeUninit-style)

✅ KEPT (Plan 138.5): unsafe T value-wrapper сохраняется без изменений — он про значение (maybe-uninit T-typed память), ортогонален указателям и prefix-запрету. mut x unsafe T = mut-binding к maybe-uninit value. Указательная форма «ptr к uninit T» = *unsafe T (postfix pointee, §1), а НЕ * unsafe T с пробелом и НЕ unsafe * (последнее retired).

unsafe T означает «T-typed memory с снятыми init/layout/aliasing contracts». Caller asserts validity at use sites. Concretely:

  • Init: значение может быть uninitialized — read без prior write — UB
  • Layout: alignment / size — same as T (не bitwise opaque)
  • Identity: bit-pattern valid для T at каждом read site
  • Aliasing: Nova exclusivity rules off (но atomicity не гарантирована)

Operations:

  • Read unsafe T value — requires unsafe { } block (caller asserts init)
  • Write unsafe T slot — safe (transitions to valid)
  • Cast unsafe T → Trequires unsafe { } + value-level assertion (e.g. unsafe { x as T } или dedicated assume_init builtin)

Соответствует Rust MaybeUninit<T> semantic, но как type modifier вместо generic wrapper.

Default-init для unsafe T bindings: mut x unsafe T — slot выделена, но не initialized. Compiler-emitted runtime check НЕ выполняется (это и есть escape hatch).

§V2.4 — Option B / niche optimization (FINAL, Plan 138.5)

⚠️ RETRACTED-and-simplified (Plan 138.5): старая таблица зависела от «outermost pointer modifier» с Unsafe(Pointer) (16-байтные строки). После retire unsafe * (Unsafe(Pointer)) — указатель ВСЕГДА non-null, поэтому NPO применяется универсально (8 байт), без зависимости от модификатора.

Все указатели (*T/*ro T/*mut T/*unsafe T) — guaranteed non-null (§1). Поэтому Option[*…] всегда NPO-eligible (8 байт, null = None):

ТипМожет содержать null?NPO размер
Option[*T] (≡ Option[*ro T])8 байт ✅
Option[*mut T]8 байт ✅
Option[*unsafe T]❌ pointer non-null; pointee uninit OK8 байт ✅
Option[*mut *ro T] (chain)8 байт ✅

Nullable raw-uninit (FFI, C T* может быть NULL): Option[*unsafe T] — None = NULL, Some = non-null ptr к possibly-uninit T (validity асертится на deref в unsafe {}). Это единственная nullable-форма; отдельного raw-nullable unsafe * T больше нет. Nested Option[Option[*T]] — fallback к tagged repr + W_OPTION_DOUBLE_NESTED (без изменений, см. §7 V4).

§V2.5 — migration path

⚠️ REVISED (Plan 138.5): шаги про prefix-pointer-форму отозваны. FINAL = postfix pointee canonical; prefix перед * — hard error E_POINTER_PREFIX_MODIFIER. Value-T wrappers (Mut/Unsafe/Readonly, codegen-transparent) сохраняются (AST/codegen/checker шаги ниже про них KEPT).

  1. AST changes (KEPT для value-T):

    • TypeRef::Mut(Box<TypeRef>) / TypeRef::Unsafe(Box<TypeRef>) — value-T wrappers (codegen-transparent / MaybeUninit). Также используются как pointee содержимое внутри Pointer(...) (*mut T = Pointer(Mut(T))).
    • TypeRef::Pointer(Box<TypeRef>) — конструктор; pointee несёт ro/mut/ unsafe постфиксом. Outer-pointer-mut wrapper (Mut(Pointer)) больше не строится из синтаксиса (prefix запрещён).
  2. Parser changes (FINAL):

    • mut T / unsafe T parse arms (value-T) — recursive. KEPT.
    • * Star branch парсит postfix pointee modifier (*mut/*ro/*unsafe).
    • Reject ro/mut/unsafe token непосредственно перед *E_POINTER_PREFIX_MODIFIER (НЕ warning — hard error в Plan 138.5 enforce-фазе).
  3. Codegen changes (KEPT):

    • Mut(T) / Unsafe(T) value-wrapper — C-level no-op для primitive T.
    • Pointer(Mut(T)) / Pointer(Readonly(T)) / Pointer(Unsafe(T)) — pointee модификаторы; все emit T* ABI (mut/ro различаются на assignment-check).
  4. Type-checker changes (KEPT):

    • Unsafe(T) value read → E_UNSAFE_T_READ_REQUIRES_WRAP (§V2.3 value-wrapper).
    • Unsafe(T) value write — safe; T → Unsafe(T) implicit; Unsafe(T) → T explicit (unsafe cast).
    • NPO — универсальный (§V2.4, все *… non-null → 8 байт).
  5. Migration sweep (Plan 138.5 Ф.2 enforce):

    • prefix usages mut */ro */unsafe * → postfix *mut T/*ro T/*unsafe T (или binding mut x *T / nullable Option[*T]). Основной call-site: std/runtime/raw_mem.nv (dst mut * u8dst *mut u8).
    • Retire Unsafe(Pointer) (unsafe * T): → Option[*T] / Option[*unsafe T].
  6. Spec amends downstream:

    • D36 (binding readonly default) — reassignability указателя = binding cross-ref.
    • D184 (return mut default) — pointer-return = pointee-mut.
    • D216 §1-3 — postfix pointee canonical (выполнено).
    • D217 (FFI intrinsics) — RawMem signatures postfix (Plan 138.5 Ф.2).

§V2.6 — backward compatibility

✅ RESTORED (Plan 147 D246, 2026-06-12): утверждение «*T ≡ *ro T» (always-ro pointee) восстановлено — flip-scan-draft (*T наследует binding-current) ОТКЛОНЁН (тип не самодостаточен). Под D246 pointee-mut задаётся только типом (*mut T), позиционно-независимо. Поэтому mut p *mut Tявная и единственная форма writable-pointee при mut-binding; mut p *T даёт ro-pointee (L1 mut ≠ mut-pointee). *ro TE_REDUNDANT_POINTER_RO (избыточно). Codegen promote_pointer_pointee_mut (flip-scan seed) УДАЛЯЕТСЯ в Ф.3 (наследование pointee-mut от binding запрещено D246). Постфикс-canonical и prefix-ban (ниже) — сохраняются.

Codegen-status UPDATE (D246, Plan 147 Ф.3, 2026-06-12): legacy codegen inherit-current (commit 38360c30d80, field_type_with_binding_mut / promote_pointer_pointee_mut — auto-promote mut p *T-поля в Pointer(Mut(…))) — УДАЛЯЕТСЯ под три-осевой моделью: наследование pointee-mut от binding запрещено (L1 ⊥ L3). После удаления writable buffer требует явного поля mut data *mut T (vec_owned.nv уже его несёт, line 104). [M-138.2-v2-propagation-impl-gap] остаётся закрытым (V2-propagation как механизм — moot под D246).

FINAL (Plan 138.5, amend Plan 147 D246) — НЕТ grace-period для prefix:

  • *mut T / *unsafe T (postfix) — canonical. Pointer(Mut(T)) / Pointer(Unsafe(T)). *mut T — единственный опт-ин на mut-pointee.
  • *T — pointee ro, *T ≡ *ro T УНИВЕРСАЛЬНО (D246; pointee-mut из типа, не от binding).
  • *ro T (postfix) — hard error E_REDUNDANT_POINTER_RO (избыточно: *T уже ro; fix-it *T). D246.
  • mut * T / ro * T / unsafe * T (prefix перед *) — hard error E_POINTER_PREFIX_MODIFIER (НЕ warning). Migrate → postfix или binding-mut.
  • unsafe * T (Unsafe(Pointer), старый raw-nullable) — RETIRED. Nullable = Option[*T] (NPO); FFI nullable-uninit = Option[*unsafe T].
  • *unsafe T (postfix) сохраняет смысл «ptr к possibly-uninit T» (pointee unsafe).

Historical (V2 grace-period draft, отозвано Plan 138.5): ранее планировался W_DEPRECATED_POINTER_INLINE_MODIFIER для postfix-формы и миграция на prefix mut * T. Это направление обратно финальной модели и не реализуется.

§V2.7 — follow-up markers

  • [M-118.5-right-binding-migration]SUPERSEDED by Plan 138.5 (prefix pointer-form retired; postfix pointee canonical + E_POINTER_PREFIX_MODIFIER enforce — Plan 138.5 Ф.2). Value-T wrapper parsing остаётся.
  • [M-118.5-unsafe-t-readwrite-semantics] — type-checker E_UNSAFE_T_READ_REQUIRES_WRAP (KEPT, §V2.3)
  • [M-118.5-mut-t-vs-binding-distinction] — ✅ CLOSED (§V2.2b: mut T value-wrapper transparent; pointer-mut = pointee postfix / binding, Plan 138.5)
  • [M-118.5-consume-as-type-modifier] — generalize consume (currently receiver/field/decl) к универсальный type wrapper
  • [M-118.5-d218-maybeuninit-duplication] — ✅ CLOSED (D218 RETRACTED — MaybeUninit subsumed unsafe T)
  • [M-118.5-npo-recalculation] — NPO теперь универсальный (§V2.4 FINAL — все *… non-null → 8 байт); recalculation сводится к «всегда NPO для pointer-inner»
  • three-axis (Plan 147 D246, supersedes flip-scan-draft): *T ≡ *ro T восстановлено УНИВЕРСАЛЬНО; pointee-mut из типа (*mut T), не от binding; *ro TE_REDUNDANT_POINTER_RO. См. D246.

Cross-amend impact

  • D33 (binding propagation) — extend rule к value-T mut / unsafe / consume
  • D216 §1-3 — pointer modifier syntax = postfix pointee canonical (Plan 138.5; prefix перед * запрещён)
  • D36 (binding readonly default) — reassignability указателя = binding (не тип)
  • D184 (return mut default) — pointer-return ставит pointee-mut
  • D217 (FFI intrinsics) — RawMem signatures postfix pointee
  • D218 (slice + MaybeUninit) — MaybeUninit subsumed unsafe T (см. D218 RETRACTED)
  • D162 (consume types) — extend для consume T type wrapper position

Why now

Right-binding rule уже работает для ro (parser-verified) — для value-T модификаторов это правило сохраняется. Для указателей Plan 138.5 выбрал postfix-pointee canonical: один модификатор = pointee, постфикс после *; reassignability = binding (let/mut). Это убирает путаницу «двух mut» (outer pointer-mut в типе vs pointee-mut), особенно в return-позиции (D184×D216).

unsafe T first-class также unlock’ает MaybeUninit semantic без duplication с Plan 118.2 D218 — это simplification, не addition.


flip-scan-draft. Указатели: running-current flip-scan модель (RETRACTED)

Status:RETRACTED 2026-06-12 (Plan 147 Ф.1). Черновик flip-scan (commit befe92c, SPEC-ONLY — кода никогда не было) ОТКЛОНЁН adversarial- критикой: модель делала *T контекстно-зависимым (наследует binding-current), поэтому указательный тип переставал быть самодостаточным (4 BLOCKER: один и тот же *T означал ro в одной позиции и mut в другой; double-ptr flip-chain нечитаем; cast/generic-позиции требовали спец-исключений; «redundant»-проверка ломалась на параметрах). Заменён на D246 — три оси мутабельности. *T ≡ *ro T восстановлено универсально. Ошибка E_REDUNDANT_POINTER_MODIFIER (была бы flip-scan-only) никогда не реализовывалась; под D246 заменена на E_REDUNDANT_POINTER_RO (*ro T → fix-it *T). Всё содержимое flip-scan-draft ниже удалено как недействительное; оставлен только этот retract-баннер для якоря ссылок.


D246. Три оси мутабельности: L1 binding / L2 view / L3 pointee

Status: ✅ FULLY IMPLEMENTED. Ф.1-Ф.6 LANDED 2026-06-12 (Plan 147). AMENDED Ф.7 2026-06-17 (Plan 147 Ф.7): checker enforcement gaps closed — ro-binding + param index-freeze (E_READONLY_CONTENT), redundant modifier oracle. Реализация — Plan 147 (Ф.2 parser / Ф.3 checker / Ф.4 migration / Ф.5 tests / Ф.7 enforcement). Supersedes flip-scan-draft (отклонён). Восстанавливает D216 §V2.6 «*T ≡ *ro T». Источник: 2 design-workflow (critique wkx3dytr1, value-side wlqgc2nyk, synthesis w9nktq8x1) + ~15 раундов ревью. Гейтит Plan 139 [M-139-f0-lang-item-decl] (str-поля ptr *u8).

Что

Происхождение: ro = read-only, mut = mutable.

Мутабельность в Nova задаётся тремя ортогональными осями; каждая самодостаточна (C1 — тип/binding читается без контекста):

ОсьЧто задаётСинтаксис
L1 — bindingпереприсваиваемость имени (x = …) + корень прав записи через имяro/mut перед именем (никогда в типе)
L2 — viewтранзитивный ro/rw по owned-графу значения (.field/[i]); СТЕНА на каждом *ro/mut перед типом value/record
L3 — pointee-capabilityможно ли писать за * (*p = …); реально В ТИПЕ, позиционно-независимопостфикс: *T(ro) / *mut T(mut)

Принцип (1 строка): ro — дефолт везде; пишется только опт-ин (mut x, mut T, *mut T). L2 транзитивно морозит owned-граф и упирается в стену на *; за указателем — только L3 из типа. Soundness в GC (нет borrow-checker, есть aliasing): ro = «это имя/путь не пишет», НЕ «объект заморожен».

Отмена flip-scan (flip-scan-draft): *T НЕ наследует binding-current. *T ≡ *ro T во всех позициях (param/return/generic/alias/cast/field/local). Pointee-mut — только через явный *mut T.

Канон синтаксиса

  • *T = ro-pointee (канон, дефолт, ≡ *ro T). *mut T = mut-pointee (единственный опт-ин на запись *p = …).
  • *ro T → HARD ERROR E_REDUNDANT_POINTER_RO («избыточно → используй *T»; fix-it *T). Выбор (a): потребителей мало, std в формировании.
  • mut *T / ro *T (prefix перед *) → E_POINTER_PREFIX_MODIFIER (модификатор на * запрещён; reassign = L1 binding).
  • ro T/mut T (перед типом value/record) = L2 content-view. ro x ro T / *ro ro TE_REDUNDANT_TYPE_MODIFIER. mut x mut TE_REDUNDANT_TYPE_MODIFIER (тип без модификатора уже mutable по умолчанию; явный mut T при mut-binding избыточен). Аналогично для параметра: func(mut a mut T)E_REDUNDANT_TYPE_MODIFIER.
  • Параметр: binding ro по умолчанию (D176) → явный ro T на типе избыточен. func(a ro T)func(ro a ro T)E_REDUNDANT_TYPE_MODIFIER (fix-it: убери ro с типа, используй func(a T)). Исключение: func(mut a ro T) — валидно (явный mut на binding снимает ro-default; ro T на типе = L2 freeze, не redundant).
  • Параметр *T: func(a *T) — pointee уже ro по умолчанию (L3 дефолт). Явный func(a *ro T)E_REDUNDANT_POINTER_RO (то же что в любой другой позиции).
  • **T ≡ *(*T), дефолт ro вниз; mut-уровни — явный *mut на нужном уровне (*mut *T, **mut T, *mut *ro … нельзя — *ro redundant).
  • *T ≡ *ro T УНИВЕРСАЛЬНО — во ВСЕХ позициях. НЕТ наследования pointee-mut от binding. НЕТ cast-исключения (x as *T, не as *ro T).

Дефолты

binding — пишешь явно ro/mut; параметр ro (D176); return mut-binding у caller’а (D184 — свойство binding, не значения); pointee ro (*T); поле mutable-у-mut-binding (D175).

Асимметрия L2 vs L3: тип value/record без модификатора (T) — content mutable по умолчанию (L2; заморозка = явный ro T). Указатель без модификатора (*T) — pointee ro по умолчанию (L3; запись = явный *mut T). Разные дефолты намеренны: value-тип — твой, владеешь, пишешь; указатель — чужая/aliased память, по умолчанию не пишешь.

Таблица мутабельности (binding × content)

Знаки: ✅ разрешено · ❌ запрещено · E = ошибка

Локальные binding’и:

Формаreassign.field/[i]
ro a T❌ (P7 freeze)
mut a T
ro a mut T✅ (R2-split)
mut a ro T❌ (R2-split)

Параметры (ro по умолчанию, D176):

Формаv = x.field/[i] write
v Tro v T❌ E_LOCAL_NOT_MUT❌ E_READONLY_CONTENT
mut v T
mut v ro T❌ E_READONLY_CONTENT

v = x внутри fn — reassign локальной копии binding’а (не виден снаружи).

Указатели в параметре (L1 × L3):

Формаv = q*v = x
v *Tro v *T
mut v *T
v *mut Tro v *mut T
mut v *mut T

Локальные указатели (L1 × L3):

Формаp = q*p = v
ro p *T
mut p *T
ro p *mut T
mut p *mut T

10 принципов (P1-P10)

  1. P1 — три оси ортогональны. L1×L2×L3 не влияют друг на друга. mut-binding НЕ даёт mut-pointee; mut-pointee НЕ делает имя reassignable.
  2. P2 — тип самодостаточен (C1). *T означает ro-pointee в ЛЮБОЙ позиции, без running-current/контекста.
  3. P3 — ro дефолт везде; опт-ин на запись явный (mut x / mut T / *mut T).
  4. P4 — L2 freeze СТОИТ на каждом *. Транзитивный ro-view не проникает за указатель; за * действует только L3 (из типа pointee).
  5. P5 — L3 из типа, позиционно-независимо. *mut T = writable target где угодно; *T = ro где угодно.
  6. P6 — split (L1,L2) явны. ro r mut Point (reassign❌/content✅), mut r ro Point (reassign✅/content❌). Разрешает [M-138-binding-type-mut-conflict].
  7. P7 — голый ro r = freeze (binding dominates, D175 §V2): и reassign, и весь owned-граф (до стены на *).
  8. P8 — coercion по оси content (L2), независимо от L1. ro-источник → mut-content-цель = E_READONLY_COERCE; → ro-цель OK. *mut T → *T авто-сужение; *T → *mut T ❌.
  9. P9 — deep-immutable НЕ навязывается снаружи сквозь *mut (trade-off): -> ro VR морозит свои слоты, но unsafe{*v.p=w} проходит (L2 не лезет за *). Deep-ro → производитель объявляет поле *T (как str { ptr *u8 }).
  10. P10 — owned-vs-aliased heap статически неразличим → граница рисуется синтаксически на * (L2 стоп на *), не по aliasing-статусу. ro = per-path write-ban, не object-freeze (GC, нет эксклюзивности).

R1 vs R2 (обе живут — на разных осях)

  • R1 (transitive-ro) = закон L2: -> ro Value/-> ro HeapValue морозят owned-граф (стена на *).
  • R2-split = явный opt-in пары (L1,L2): ro r mut Point / mut r ro Point (см. P6). Голый ro r = freeze (P7).

Нормативный ORACLE (тест-корпус; чтение всегда ✅, знаки = ЗАПИСЬ)

A. VALUE-record Point (копия): mut r: r=Xr.x=5✅ · ro r: ❌/❌ · mut r ro Point: ✅/❌ · ro r mut Point: ❌/✅

B. HEAP-record Acc (handle): те же знаки (семантика: запись видна co-handle’ам; ro = это имя не пишет).

C. POINTER (unsafe-ops): mut p *T: p=q*p=v❌ · mut p *mut T: ✅/✅ · ro p *T: ❌/❌ · ro p *mut T: ❌/✅ · ro p **T: p*p**p❌ · ro p *mut *T: ❌/*p=q✅/**p❌ · ro p **mut T: ❌/*p❌/**p=v

D. RETURN: -> Value: caller mut-default (a=X✅,a.x=5✅) · -> ro Value: mut a Value=f()E_READONLY_COERCE, mut a ro Value=f()✅, ro a mut Value=f()E_READONLY_COERCE, ro a Value=f()✅ · -> *mut T: *a=v✅(unsafe) · -> *T: *a=v❌ · -> *ro T: ❌ E_REDUNDANT_POINTER_RO.

E. Generic/Option/cast: Vec[*T]: *v[i]=x❌ (L3 элемента) · Vec[*mut T]: *v[i]=x✅¹ · Option[*mut T]: Some(p)→*p=v✅ · x as *mut T; ro a=x: a=y*a=v✅ (из типа) · mut a=x: ✅/✅ · vr{p *mut T}ro v: v.p=qunsafe{*v.p=w}✅ · str{ptr *u8,len int}: s.ptr=q❌, буфер ro.

¹ Vec[*mut T]: *v[i]=x — семантически верно (тип элемента *mut T), но codegen не реализован: Vec.new() для pointer-element-type вызывает generic-заглушку Nova_Vec_static_new() → NULL → SEGFAULT. Граница [M-138-vec-pointer-element-mono] (Plan 138), P2. Option[*mut T]: Some(p)→*p=v работает (проверено e7_option_mut_ptr_deref_write).

Error codes

  • E_REDUNDANT_POINTER_RO (NEW, Plan 147 Ф.2) — postfix *ro T (избыточно: *T уже ro). Fix-it: «используй *T». Применяется во ВСЕХ позициях (тип самодостаточен).
  • E_POINTER_RO_ASSIGN (NEW, Plan 147 Ф.3) — запись *p = … через *T/*ro pointee (ro-pointee read-only). Pointer-ops — в unsafe {}.
  • E_POINTER_PREFIX_MODIFIER (существует, D216 §1) — модификатор перед *.
  • E_REDUNDANT_TYPE_MODIFIER (существует) — ro x ro T / *ro ro T / func(a ro T) / mut x mut T / func(mut a mut T). (Ф.7: oracle-test f7_neg3 подтверждает parser-level enforcement для ro a ro T; func(a ro T) — parser аналогично.)
  • E_READONLY_CONTENT (существует, D176) — запись через ro-view L2: view[i] = x на ro T-типе ИЛИ (NEW Ф.7) через ro-binding (L1 dominates P7): ro a = [...]a[i] = xE_READONLY_CONTENT; func(v []int)v[i] = xE_READONLY_CONTENT (param ro-by-default D176, P7 freeze).
  • E_READONLY_COERCE (существует) — ro-content-источник → mut-content-цель (P8).

ORACLE F — Index-write through ro binding/param (Ф.7, 2026-06-17)

// F1: ro local binding → index write forbidden (P7 freeze)
ro a = [1, 2, 3]
a[0] = 99                  // ❌ E_READONLY_CONTENT (ro binding dominates, P7)
_ = a[0]                   // ✅ reads always ok

// F2: mut binding → index write allowed
mut a = [1, 2, 3]
a[0] = 99                  // ✅

// F3: param without mut → index write forbidden (ro by default D176, P7 freeze)
fn fill(v []int, val int) {
    v[0] = val             // ❌ E_READONLY_CONTENT
}

// F4: mut param → index write allowed
fn fill(mut v []int, val int) {
    v[0] = val             // ✅
}

// F5: ro local via fn return → index write forbidden
fn make_slice() -> []int { [1, 2, 3] }
ro a = make_slice()
a[1] = 99                  // ❌ E_READONLY_CONTENT (ro binding P7)

Осознанные trade-off’ы (намеренно)

  1. Deep-immutable сквозь *mut нельзя навязать снаружи (P9, C++ shallow-const): deep-ro → производитель объявляет поле *T.
  2. Shared-mut heap-record под чужим ro возможен (GC, нет эксклюзивности): ro = per-path write-ban, не object-freeze (P10).
  3. owned-vs-aliased heap статически неразличим → граница на * синтаксическая (P4).

Cross-amend impact

  • D216 §V2.6 — «*T ≡ *ro T» (always-ro pointee) RESTORED (flip-scan-draft retract).
  • D33 (binding propagation) — L1 ось; не propagates в L3 (стоп на *, P4).
  • D36 (binding readonly default) — L1 binding = reassignability, только; НЕ задаёт pointee-capability (L3 из типа).
  • D175 §V2 (binding dominates / access-time) = L2 view-семантика — KEEP; добавлено «freeze STOPS at every *» (P4) + пример vr-с-*mut-полем.
  • D176 (ro T тип-модификатор) — L2 content-view на параметре (ro дефолт).
  • D184 (return mut default) — свойство binding у caller’а (L1), не значения.
  • D26 / Plan 139 — str lang-item type str value priv { ptr *u8, len int }: ptr *u8 (ro-pointee, ≡ *ro u8); *ro u8 избыточен → E_REDUNDANT_POINTER_RO. Снимает гейт [M-139-f0-lang-item-decl].

Связь

  • [M-138-binding-type-mut-conflict]разрешён P6 (split на оси L1×L2).
  • [M-ptr-cast-reinterpret-unsafe] — учитывается в L2-coercion (P8, авто-сужение *mut → *T).
  • [M-138-double-pointer-codegen-test] — multi-level pointer (oracle C: ro p *mut *T).
  • Гейтит Plan 139 [M-139-f0-lang-item-decl].

Acceptance

См. Plan 147 A1-A6: A1 — oracle A-E (~20 форм): pos компилируются, neg дают E_REDUNDANT_POINTER_RO / E_POINTER_PREFIX_MODIFIER / E_READONLY_COERCE / E_POINTER_RO_ASSIGN; A2 — *T ≡ *ro T ВЕЗДЕ (позиционные фикстуры); A3 — L2 freeze транзитивен + СТЕНА на * (vr с *mut-полем); A4 — split + return-coercion 4 случая; A5 — flip-scan-draft retracted, pointer-таблица + str переписаны; A6 — 0 регрессий pointer-dirs.


D220. Per-field visibility — priv keyword + type-level default flip

Status: V1 ACTIVE (spec + parser/AST infrastructure landed, 2026-06-02). AMENDED by D281 (2026-06-15): type-level priv теперь = module-private (не type-private); type-private type-level default = priv(type). Field-level explicit priv остаётся type-private (без изменений). Реализация — Plan 124. Empirical validation — docs/research/06-field-visibility-go-kubernetes.md. Amends D47 (replaces deprecated _prefix convention с compile-time enforcement).

Что

Per-field visibility modifier priv для records + named tuples (D215). По умолчанию все поля публичны (D47 unchanged, validated: kubernetes 92% public в API surface). Explicit priv — field accessible только из методов own type’а (instance + static).

Type-level default flip syntax type X priv { ... } — для invariant-heavy types где majority of fields private; explicit pub modifier overrides priv default.

Правило

§1 Syntax

// Per-field priv modifier (field-level).
export type Account {
    priv mut money f64
    ro name str
    priv id u64
}

// Type-level default flip — fields default = priv.
export type Secret priv {
    pub ro tag str
    mut salt u64
    key u64
}

Modifier ordering в field decl: priv/pub → ro/mut/consume → name TYPE. priv и pub mutually exclusive (E_PRIV_PUB_CONFLICT).

§2 Effective visibility

Field’s effective priv_field = first matching:

  1. Explicit pub field modifier → priv_field = false (public).
  2. Explicit priv field modifier → priv_field = true (private).
  3. Type-level default (type X priv {...} → priv_field = true).
  4. Otherwise (D47 default) → priv_field = false (public).

§3 Access rules

priv field access РАЗРЕШЁН только из методов own type’а:

  • Instance methods: fn TypeX @method() { @priv_field }
  • Static methods: fn TypeX.factory(...) { ... }
  • Cross-instance: fn TypeX @eq(other TypeX) -> bool => @f == other.f — доступ к priv полям другого экземпляра того же типа разрешён внутри метода этого типа. other — параметр типа TypeX, метод принадлежит TypeX → privacy scope совпадает.

priv field access ЗАПРЕЩЁН во всех других контекстах:

  • Read: outside.priv_field → E_PRIV_FIELD_READ
  • Write: outside.priv_field = X → E_PRIV_FIELD_WRITE
  • Init via record literal: Foo { priv_f: X } → E_PRIV_FIELD_INIT
  • Pattern destructure: Foo { priv_f } → E_PRIV_FIELD_PATTERN

§4 Diagnostic codes

  • E_PRIV_FIELD_READ — read priv field outside type-method scope.
  • E_PRIV_FIELD_WRITE — write priv field outside type-method scope.
  • E_PRIV_FIELD_INIT — init priv field via literal outside.
  • E_PRIV_FIELD_PATTERN — destructure priv field в pattern outside.
  • E_PRIV_PUB_CONFLICT — both priv и pub modifiers на одном field.
  • E_PRIV_FIELD_PROTOCOL (V4 deferred).
  • E_PRIV_TUPLE_POSITIONAL_ACCESS (V4 deferred).

§5 Семантика: организация, не security

priv/priv(type)организационный инструмент, не security-барьер. Цель: защита от случайного обращения к деталям реализации, не от намеренного.

Nova не ограничивает добавление методов на тип из любого модуля. Следствие: пользователь намеренно может написать:

fn Test @id() -> int => @id   // в любом модуле — легально

Это не считается «вскрытием» — это осознанный выбор пользователя. Nova — «публичное по умолчанию» (D47, validated в docs/research/06-field-visibility-go-kubernetes.md), и priv(type) означает «используй методы типа», а не «запрещено».

Настоящая граница инкапсуляции — модуль (priv = module-private, D281): другой модуль не может случайно прочитать поле — только намеренно добавив метод.

Аналог: Go unexported защищает от случайного обращения из другого пакета, но не является security-boundary.

Nova не имеет reflection API → priv enforcement compile-time, без reflection-bypass (в отличие от Java/Kotlin/C#).

§6 Composition

priv composes orthogonally с:

  • ro/mut/consume mutability modifiers
  • use NAME Type (D39 embed) — V2 deferred [M-124.2-priv-embed]
  • const NAME T = expr — reserved future use

§7 Backward compatibility

Existing Nova code = all-public fields → migration purely additive. priv opt-in keyword — старый код не ломается. _prefix convention deprecated 2026-06-02.

Почему

Empirical validation: kubernetes audit 35239 fields — 92.4% public в API surface. Public-default minimum boilerplate. Bimodal distribution → bimodal syntax (field-level priv + type-level priv {} flip).

Compile-time enforcement vs convention: prior _prefix hint-only privacy — false safety. priv keyword вводит compile-time guarantee → refactoring safety + invariant enforcement + API clarity.

Что отвергнуто

  • Private-by-default — отклонено после kubernetes data.
  • Edition default flip — отклонено (per-type granular лучше).
  • #strict_visibility per-module attribute — отклонено (fragmentation).

Cross-refs

  • D5 (07-modules.md) — module-level visibility.
  • D29 (07-modules.md) — modules.
  • D35 (03-syntax.md) — method declaration.
  • D47 (07-modules.md) — export keyword; _prefix deprecated.
  • D52 (this file) — record/sum/alias syntax.
  • D131 (05-memory.md) — consume types.
  • D215 (this file) — named tuples.

Acceptance

V1 (Plan 124.1) — ALL closed 2026-06-02:

  • A1.1-A1.3 ✅ Parser/AST infrastructure (Ф.1 + Ф.4 commits).
  • A1.4 ✅ E_PRIV_FIELD_READ enforcement (Ф.2 — f3_check_member hook).
  • A1.5 ✅ E_PRIV_FIELD_WRITE enforcement (Ф.2.2 — check_target_readonly hook).
  • A1.6 ✅ E_PRIV_FIELD_INIT enforcement (Ф.2.3 — RecordLit walk_expr hook).
  • A1.7 ✅ E_PRIV_FIELD_PATTERN enforcement (Ф.2.4 — Pattern::Record f1_block hook).
  • A1.8 ✅ Regression 0 new FAIL.
  • A1.9 ✅ plan124_1 fixtures 9/9 PASS (4 positive + 5 negative).
  • A1.10 ✅ Spec D220 NEW (this section).

Followup markers

  • ✅ [M-124.1-checker-enforcement] CLOSED 2026-06-02 — all 4 codes via TypeCheckCtx current_recv_type RAII tracking.
  • ✅ [M-124.2-pattern-sites-extension] CLOSED 2026-06-02 — Match/IfLet/WhileLet/For/ParallelFor + nested + spread (D221).
  • [M-124.2-priv-embed] — priv use NAME Type.
  • [M-124.4-tuple-priv] — named tuple priv (D215 ext).
  • [M-124.4-protocol-impl-boundary].
  • [M-124.5-doc-lsp].
  • [M-124.6-test-access].

D221. Pattern destructure + literal init edge cases (Plan 124.2)

Status: ✅ ACTIVE since 2026-06-02 (Plan 124.2 closure). Extends: D220 §3-§4. Self-contained sub-decision: covers pattern-site и literal-spread edges not addressed в D220 §4. Plan: Plan 124.2. Cross-refs: D220 (core priv semantics), D52 §1-§2 (record declaration / @field shorthand), D17 (pattern syntax).

§1 Scope

D220 §4 описывает priv-access rules для базовых форм (Member access

  • Stmt::Let pattern + RecordLit named fields). D221 расширяет coverage на:
  1. Дополнительные pattern sites: match, if-let, while-let, for-in, parallel-for.
  2. Nested Pattern::Record — recursive descent через sub-field types.
  3. Record literal spread Type { ...other }.
  4. Rest pattern { field, .. } — non-binding semantics.

§2 Pattern sites — complete enumeration

Following pattern-bearing forms ALL apply priv-pattern enforcement (Plan 124.2 implementation hook each site):

SiteAST nodeScrutinee source
let PAT = EXPRStmt::Let { pattern, value }type of value
if PAT = EXPR { ... }ExprKind::IfLet { pattern, scrutinee }type of scrutinee
while PAT = EXPR { ... }ExprKind::WhileLet { pattern, scrutinee }type of scrutinee
match EXPR { PAT => ... }ExprKind::Match { scrutinee, arms[].pattern }type of scrutinee
for PAT in EXPR { ... }ExprKind::For { pattern, iter, elem_type }elem_type ∥ inferred element type
parallel for PAT in EXPR { ... }ExprKind::ParallelFor { pattern, iter, elem_type }same

В каждой точке: for each Pattern::Record outside type-method scope, each explicitly-named RecordPatternField corresponding к priv-field → E_PRIV_FIELD_PATTERN.

§3 Rest pattern .. — non-binding

ro Account { name, .. } = acc    // outside-of-Account ok if `name` public.
                                  // `..` does NOT bind priv `money`.

Pattern::Record.rest = true маркирует syntactic ... Семантика: игнорировать остальные поля, no bindings produced. Priv-fields не leak’аются через .. потому что нет binding’а.

NB: explicit field names ARE checked даже если .. присутствует — { money, .. } outside Account → E_PRIV_FIELD_PATTERN на money.

§4 Nested Pattern::Record

type Address { priv mut zip str, ro city str }
type User { ro name str, ro addr Address }

// Outside any method scope:
ro User { addr: Address { zip }, name } = u   // ❌ E_PRIV_FIELD_PATTERN
                                               //   on `zip` (Address-internal)

Recursive descent: для каждой RecordPatternField { name, pattern: Some(sub), .. } sub-pattern проверяется against sub-field’s declared type (via outer type’s RecordField.ty). Outer field accessibility (User.addr public) не освобождает inner check (Address.zip priv).

§5 Record literal spread — E_PRIV_FIELD_INIT_SPREAD

type Account { priv mut money f64, ro name str }

// Outside Account-method scope:
Account { ...orig, name: "new" }   // ❌ E_PRIV_FIELD_INIT_SPREAD

Spread ...src implicitly копирует все fields (включая priv). Outside type-method scope, это нарушает encapsulation. Эмитим E_PRIV_FIELD_INIT_SPREAD на spread field span с hint’ом использовать factory method.

Inside type-method scope — allowed (recv = T → каноническая ситуация).

Note: type без priv fields → spread OK везде (нет encapsulation boundary).

§6 Diagnostic codes

CodeWherePlan
E_PRIV_FIELD_PATTERNPattern sites §2 + nested §4D220 §4 (reused)
E_PRIV_FIELD_INIT_SPREADRecordLit spread §5D221 NEW

Format (Plan 50 D102):

[E_PRIV_FIELD_INIT_SPREAD] cannot use spread `...` in record literal
of `T` outside type-method scope: type has private fields which would
be implicitly initialized via copy (Plan 124 / D221 §5). Hint: use
factory method `T.new(...)` or list each public field explicitly.

§7 Cross-refs

  • D17 — pattern syntax.
  • D52 §2 — record literal + field shorthand.
  • D220 — core priv semantics (default vis, scope, access rules).
  • D215 — named tuples; D221 covers ONLY record form, tuple-pattern priv в D222 (Plan 124.4).

§G1 Generic types — uniform enforcement (Plan 124.3 amend)

Added 2026-06-02. Plan 124.3 closure.

Per-field priv modifier applies uniformly к generic record types:

export type Stack[T] {
    priv mut len int
    ro capacity int
}

Enforcement architecture: check site reads RecordField.priv_field из AST (pre-monomorphization). Mono’d instances (Stack[int], Stack[str]) inherit identical enforcement — T-substitution не изменяет field metadata.

Receiver-type tracking (TypeCheckCtx.current_recv_type) uses type-name only:

  • fn Stack[T] @push(...) body sees recv = Some("Stack").
  • Generic parameters не factor в name comparison.
  • Inside generic methods, accessing priv fields на ЛЮБОЙ T instantiation OWN type’а — allowed.
  • Cross-type access (Stack[int].@field из Queue[T].method) — blocked (recv = “Queue”, не “Stack”).

Bootstrap parser limitation: explicit generic prefix в record literal expression position (Stack[int] { len: 5, capacity: 10 }) не парсится в bootstrap (parser-ambiguity с array-literal opening [). Canonical form — anonymous literal { len: ..., capacity: ... } с return-type inference:

export fn Stack[T].with_len(initial int, cap int) -> Stack[T] =>
    { len: initial, capacity: cap }      // ✅ anonymous, inferred

Pattern destructure аналогично: Stack { fields } = expr (без generic args) — resolved through scrutinee type.

INIT path (E_PRIV_FIELD_INIT) testing для generic types использует non-generic specialized variant ИЛИ relies на anonymous literal form’s type inference (target type known via expected return type of enclosing method).

Acceptance — Plan 124.3

ALL closed 2026-06-02:

  • A3.1 ✅ Generic type Stack[T] { priv ... } parser PASS.
  • A3.2 ✅ Mono’d instance external access: write → E_PRIV_FIELD_WRITE, read → E_PRIV_FIELD_READ.
  • A3.3 ✅ Inside Stack[T].method — @field access OK.
  • A3.4 ✅ Generic method calling another OK.
  • A3.5 ✅ Multiple instantiations (Stack[int] + Stack[str]) — same enforcement.
  • A3.6 ✅ Option[Account] — outer Option public, inner Account rules unchanged.
  • A3.7 ✅ plan124_3 10/10 fixtures PASS.
  • A3.8 ✅ Regression plan124_1 9/9 + plan124_2 14/14 unchanged.

D222. Named tuple priv + protocol impl boundary (Plan 124.4)

Status: ✅ ACTIVE since 2026-06-02 (Plan 124.4 closure). Extends: D220 (per-field priv) + D215 (named tuples, Plan 120) + D221 (pattern check). Self-contained sub-decision: covers named tuple form + protocol impl boundary explicitly. Plan: Plan 124.4. Cross-refs: D215 (named tuple form), D220 (core priv), D221 (pattern), D52 §2 (record field syntax).

§1 Named tuple priv syntax

type Vec3(priv x f64, priv y f64, priv z f64)
type Account(priv balance f64, name str)    // mixed
type Secret(pub key str, priv salt []u8)    // explicit pub override

Same modifier semantic as RecordField: priv before field name (or pub for explicit public override; reserved для D220 type-level flip extension Plan 124.7). Mutual exclusion enforced — priv pub x f64 / pub priv x f64E_PRIV_PUB_CONFLICT.

§2 Access rules — uniform with D220

Read (v.x):

  • Inside named tuple’s own methods (instance @field or static T.method(...)) — OK regardless of priv.
  • Outside → E_PRIV_FIELD_READ если field marked priv.

Init via named-arg constructor (Vec3(x: 1.0, y: 2.0, z: 3.0)):

  • Inside type-method scope — OK (recv = T).
  • Outside → E_PRIV_FIELD_INIT для каждого priv-named arg.

Pattern destructure (Vec3 { x, y, z } = v record-style):

  • Same as record (D221 §2-§4): outside → E_PRIV_FIELD_PATTERN per priv field; .. rest is non-binding; nested descent recursive.

Write — N/A: named tuple fields are immutable by D215 design (no mut modifier на field). Все assignments к v.x = ... fail E_READONLY_FIELD before priv check.

Positional access .0/.1 — already blocked by Plan 120 E_TUPLE_POSITIONAL_ACCESS_ON_NAMED (D215 Q120 Option B); priv не нужно добавлять отдельный код.

§3 Protocol implementation boundary

Protocol satisfaction в Nova реализуется ДВУМЯ способами (D186 / Plan 91.9):

  1. Type-method implfn Vec3 @to_string() -> str. Receiver = T; current_recv_type = Some("Vec3") → priv access OK канонически. ✅ Allowed.

  2. External free-fnfn compute_sum(v Vec3) -> f64 => v.x + v.y + v.z. No receiver tracking; current_recv_type = NoneE_PRIV_FIELD_READ fires при touching priv. ✅ Blocked.

→ Encapsulation guarantee: protocol impls cannot bypass priv boundary unless declared as type-method. Mirrors Rust trait impl rules; stricter than Go/Kotlin (pkg-wide-allowed).

§4 Diagnostic codes

CodeSitePlan
E_PRIV_FIELD_READMember access на priv named-tuple fieldD220 §4 (reused)
E_PRIV_FIELD_INITT(field: ...) named-arg ctor priv fieldD220 §4 (reused)
E_PRIV_FIELD_PATTERNT { field, ... } = v priv fieldD221 §2 (reused)
E_PRIV_PUB_CONFLICTpriv pub / pub priv mutual exclusionD220 §6 (reused)
E_TUPLE_POSITIONAL_ACCESS_ON_NAMED.0 accessD215 / Plan 120 (preexisting)

No new codes — D222 reuses D220/D221 codes uniformly. Spec mentions named-tuple context в hint text.

§5 Implementation hooks

LayerSiteChange
ASTNamedTupleField structAdded priv_field: bool
LexerKwPriv, KwPubAlready declared (Plan 124.1)
Parseris_named_tuple_declRecognize priv/pub as named-marker
Parserparse_named_tuple_fieldsAccept priv/pub modifier с conflict-check
Checkerf3_check_member NamedTuple armAdded priv check (mirror Record)
Checkerf5_check_tuple_constructINIT priv check on named-args
Checkercheck_priv_pattern_recursiveUnified для Record + NamedTuple

§6 Cross-refs

  • D215 — named tuple syntax + access rules.
  • D220 — core priv semantics, error codes definition.
  • D221 — pattern destructure + spread.
  • D52 §2 — record field syntax (mirror form).
  • D186 — protocol satisfaction (Plan 91.9): type-method primary, external-fn-impl secondary; D222 §3 formalizes boundary impact.

Acceptance — Plan 124.4

ALL closed 2026-06-02:

  • A4.1 ✅ Named tuple type Vec3(priv x f64, ...) parser PASS.
  • A4.2 ✅ Positional .0 access — handled by preexisting Plan 120 E_TUPLE_POSITIONAL_ACCESS_ON_NAMED (priv-orthogonal).
  • A4.3 ✅ Named .x access на priv field outside → E_PRIV_FIELD_READ.
  • A4.4 ✅ Inside type-method scope — read + init + pattern + protocol method все allow.
  • A4.5 ✅ Protocol impl (type-method-based, fn Vec3 @method()) — priv access OK.
  • A4.6 ✅ Protocol impl external-fn-based (fn compute(v Vec3)) — priv access BLOCKED (E_PRIV_FIELD_READ).
  • A4.7 ✅ plan124_4 10/10 fixtures PASS.
  • A4.8 ✅ Regression Plan 120 (8/8) + plan124_1 (9/9) + plan124_2 (14/14) unchanged.
  • A4.9 ✅ D222 NEW + D215 cross-ref + D220/D221 code reuse.
  • A4.10 ✅ plan120 backward compat — все existing named-tuple fixtures без priv modifier работают unchanged.

§T1 nova doc + LSP integration (Plan 124.5 amend)

Added 2026-06-02. Plan 124.5 closure. Cross-references D107 (nova doc schema) + Plan 104.x (LSP infrastructure).

nova doc behavior:

  • Default: priv fields hidden from rendered documentation (markdown / HTML / JSON).
  • --include-private flag shows all fields с priv keyword preserved in signature rendering (type X { priv mut f T }).
  • JSON output emits "priv_field": true|false per field regardless of --include-private — consumed by tooling.

LSP integration (forward-ref):

  • AST RecordField.priv_field + NamedTupleField.priv_field flags available для LSP hover (Plan 104.2) и completion (Plan 104.3) integration once these ship.
  • Expected behavior: priv-field filter в autocomplete outside type-method scope; 🔒 priv badge в hover popups; priv-field code-lens decoration.

User-facing documentation:

  • docs/field-visibility-guide.md — comprehensive guide: use cases, syntax, composition, diagnostics, tooling, comparison vs Go/Rust/TS/Java/Swift/C#, migration, common patterns.

Acceptance — Plan 124.5

ALL closed 2026-06-02:

  • A5.1 ✅ nova doc hides priv fields by default.
  • A5.2 ✅ nova doc --include-private shows priv с keyword preserved.
  • A5.3 🟡 LSP autocomplete filter — forward-ref Plan 104.3 (infra data source ready).
  • A5.4 🟡 LSP hover badge — forward-ref Plan 104.2 (infra ready).
  • A5.5 🟡 Quick-fix suggestion — forward-ref Plan 104.x (Plan 50 D102 format hints уже в error messages).
  • A5.6 ✅ plan124_5 fixtures 3/3 PASS (parser + smoke; doc behavior e2e verified manually).
  • A5.7 ✅ docs/field-visibility-guide.md created (~330 lines).
  • A5.8 ✅ Regression: existing nova doc fixtures unchanged.

D224. Escape hatches — #test_access + #visible_to (Plan 124.6)

Status: ✅ ACTIVE since 2026-06-02 (Plan 124.6 closure). Extends: D220 §3 (scope rules) + D222 §3 (protocol boundary). Plan: Plan 124.6. Cross-refs: D220, D221, D222 (priv core + pattern + tuple).

§1 Motivation

D220 устанавливает strict type-method-only scope для priv field access — strictнее чем эталоны (Kotlin internal module-wide, Rust pub(crate) crate-wide, Java package-private). В некоторых production scenarios нужна controlled relaxation:

  1. Unit tests должны verify internal state (balance, cache size, internal cursor pos) без публикации getter в public API.
  2. Sibling helper types (Account + Bank audit utilities) — coordinated access без friend boilerplate.

D224 вводит two explicit opt-in escape hatches — каждый syntactically marked, никаких неявных relaxation.

§2 #test_access(TypeX[, TypeY...]) — fn-level access grant

Attribute перед fn declaration: body fn получает priv-field access ко всем listed types (READ + WRITE + INIT + PATTERN).

export type Account {
    ro name str
    priv mut balance f64
}

#test_access(Account)
fn assert_balance_eq(acc Account, expected f64) -> bool =>
    acc.balance == expected        // ✅ allowed by #test_access

Multi-type form:

#test_access(Account, Vault)
fn cross_audit(a Account, v Vault) -> bool =>
    a.balance == 0.0 && v.amount == 0.0

Scope: applies only к body of marked fn. Caller scope unchanged. Composable: можно combine с #realtime, #blocking, #verify, etc. — порядок attribute parsing уже supports multi-attribute.

§3 #visible_to(OtherType[, ...]) — field-level friend declaration

Attribute перед field declaration в type X { ... } или type X(...): methods listed types получают priv access только к этому field.

export type Account {
    ro name str
    #visible_to(Bank) priv mut balance f64
}

export type Bank {
    ro id str
}

export fn Bank @audit_account(a Account) -> f64 =>
    a.balance        // ✅ allowed: Bank ∈ Account.balance.visible_to

Per-field granularity:

  • Different fields могут have different friend lists.
  • Other Account fields without #visible_to — strict type-only.
  • Other types (НЕ Bank) — no access:
    export fn Auditor @check(a Account) -> f64 =>
        a.balance     // ❌ E_PRIV_FIELD_READ — Auditor not in visible_to
    

§4 Combined access predicate

priv-field access allowed когда любое из:

  1. current_recv_type == tname — canonical type-method scope (D220).
  2. tname ∈ current_fn.test_access_for#test_access grant.
  3. current_recv_type ∈ field.visible_to — friend grant.

Implementation: TypeCheckCtx::priv_field_access_allowed(tname, &visible_to) combines all three checks. priv_access_allowed_base(tname) covers (1)+(2); per-field visible_to requires field-specific context (handled at each callsite).

§5 Diagnostic codes

D224 reuses Plan 124.1-124.4 codes (no new codes), but hints now mention escape hatches:

[E_PRIV_FIELD_READ] cannot read private field `Account.balance` ...
Hint: add public getter method on `Account`, move accessing code
into a method of `Account`, or use `#test_access(Account)` on test
fn (escape hatch — D224).

Parser-level errors:

  • #test_access(...) without ( → “требует list: #test_access(TypeX, ...)”.
  • Empty list #test_access() → “требует хотя бы один Type”.
  • #visible_to(...) same shape.

§6 Anti-patterns + lint guidance

Escape hatches — opt-in, syntactically explicit. Recommended discipline:

  • #test_access — only on test fns or dedicated assertion helpers. Production-code uses должны trigger code-review concern.
  • #visible_to — explicit, named friend types only. Cross-module abuse — code-smell.
  • Future lint (Plan 124.x): warn if #test_access used >N times per project (suggests missing public API).

§7 Cross-refs

  • D220 — core priv semantics, scope rules.
  • D221 — pattern destructure / spread sites.
  • D222 — named tuple + protocol impl boundary.
  • D102 — diagnostic format (Plan 50).
  • Plan 104.x — LSP hover/completion will display escape-hatch badges (forward-ref).

Acceptance — Plan 124.6

ALL closed 2026-06-02:

  • A6.1 ✅ #test_access(TypeX) attribute parser PASS.
  • A6.2 ✅ Test fn с attribute получает priv access к TypeX.
  • A6.3 ✅ #visible_to(TypeY) field attribute parser PASS.
  • A6.4 ✅ TypeY’s methods get access к marked priv field of TypeX.
  • A6.5 ✅ Conservative: только marked fields, не whole type (per-field granular).
  • A6.6 ✅ plan124_6 fixtures 7/7 PASS (4 positive + 3 negative).
  • A6.7 ✅ Regression plan124_1 9/9 + plan124_2 14/14 + plan124_4 10/10 unchanged.
  • A6.8 ✅ D224 NEW + cross-refs к D220-D222.

D225. Type-level priv flip для named tuples (Plan 124.7)

Status: ✅ ACTIVE since 2026-06-02 (Plan 124.7 closure). Extends: D220 §3.3.1 (record-form type-level flip) + D222 (named tuple priv per-field) + D215 (named tuple form, Plan 120). Plan: Plan 124.7. Cross-refs: D220, D215, D222.

§1 Syntax

Symmetric extension to record-form D220 §3.3.1:

// Record form (Plan 124.1 / D220 §3.3.1)
type Account priv {
    pub ro name str          // explicit pub override
    mut balance f64          // default = priv (inherits type-level)
}

// Named tuple form (Plan 124.7 / D225 — this section)
type Secret priv (key str, salt str)
//             ^^^^ priv ПОСЛЕ имени type'а, ДО `(`

type Credential priv (pub id str, secret str)
//                    ^^^ explicit pub override per field

priv keyword position между type name (+ optional generics) и opening ( — same position как для record form’s {.

§2 Effective priv_field resolution

Per-field priv_field для named-tuple field resolves в parser identical к record form (D220 §3.3.1):

field-level modifiertype-level flipeffective
explicit pubflip or no-flipfalse (overrides)
explicit privflip or no-fliptrue
neitherflip = falsefalse (default public)
neitherflip = truetrue (inherits)

Bidirectional priv pub / pub privE_PRIV_PUB_CONFLICT (D220 §6).

§3 Implementation hooks

  • AST TypeDecl.default_field_priv: bool — пере-used (no extension needed; Plan 124.1 уже добавила).
  • Parser parse_type_decl: KwPriv после type-name установится в default_field_priv (existing — Plan 124.1).
  • Parser parse_named_tuple_fields_with_default(default_priv) — NEW wrapper around old parse_named_tuple_fields. Propagates default в effective priv_field resolution per field (mirror к parse_record_fields_with_default precedent).
  • Backward-compat shim parse_named_tuple_fields() calls _with_default(false).

§4 Access rules — unchanged (D220 §4 / D221 / D222 / D224)

Effective priv_field после resolution applied identically к explicit per-field priv. All Plan 124.1-124.6 enforcement sites (READ / WRITE / INIT / PATTERN / spread, + escape hatches #test_access, #visible_to) work uniform.

§5 Use cases

Invariant-heavy types где majority of fields should be private:

// Encapsulated handles (private impl detail)
type Mutex priv (state u32, owner_fid u64, pub kind MutexKind)

// Sensitive data + opaque session
type Session priv (token []u8, expires_at Instant, pub user_id u64)

// Tightly-coupled coordinate types
type Vec3 priv (x f64, y f64, z f64)

Bimodal coverage matches kubernetes empirical: core/v1 API surface 92% public (use no flip), pkg/internal 53% private (use flip + few pub).

§6 Cross-refs

  • D215 — named tuple base syntax.
  • D220 §3.3.1 — record-form type-level flip (D225 symmetric).
  • D222 — named tuple per-field priv (D225 builds on this).
  • D102 — diagnostic format.

Acceptance — Plan 124.7

ALL closed 2026-06-02:

  • A7.1 ✅ Parser принимает type X priv { ... } syntax (record form — Plan 124.1 preserved).
  • A7.2 ✅ Parser принимает type X priv (...) syntax (named tuple form — D225 NEW).
  • A7.3 ✅ pub modifier на field overrides type-level priv default.
  • A7.4 ✅ Field без modifier inherits type-level default (priv).
  • A7.5 ✅ Type-level + field-level combinations 4 cases verified: default-default ✅, default-explicit ✅, flip-default ✅, flip-explicit ✅.
  • A7.6 ✅ plan124_7 fixtures 8/8 PASS (5 positive + 3 negative).
  • A7.7 ✅ Regression Plan 120 8/8 + plan124_1 9/9 + plan124_4 10/10 unchanged.
  • A7.8 ✅ D225 NEW + cross-refs.

Acceptance — Plan 124.2

ALL closed 2026-06-02:

  • A2.1 ✅ Match arm pattern outside → E_PRIV_FIELD_PATTERN.
  • A2.2 ✅ IfLet pattern outside → error.
  • A2.3 ✅ WhileLet pattern outside → error.
  • A2.4 ✅ For-loop pattern outside → error (positive case verifies no false-positive on public-only types).
  • A2.5 ✅ Nested Pattern::Record с priv inner → error.
  • A2.6 ✅ Spread outside → E_PRIV_FIELD_INIT_SPREAD.
  • A2.7 ✅ Inside type-method scope — все hooks allow.
  • A2.8 ✅ .. rest pattern — no false-positive.
  • A2.9 ✅ plan124_2 fixtures 14/14 PASS (8+ positive, 6 negative).
  • A2.10 ✅ Regression plan124_1 9/9 unchanged.

D226. Signed indexing convention — int для len / capacity / index

D226 RETIRED (Plan 133, 2026-06-09): usize alias удалён. int = address-sized signed integer на 64-bit Nova target. Используй int для размеров, индексов, счётчиков байт. usize/isize больше не являются допустимыми Nova-типами — компилятор выдаёт ошибку с подсказкой «use int».

Принято 2026-06-03. Формализует существующую практику: extracts из D130 Q3 (2026-05-19, «Indexing → Keep int, no change»), поднимает в самостоятельный D-блок.

Что

Все API длины, ёмкости и позиции в коллекциях Nova принимают и возвращают signed int (D129 alias i64), а не unsigned uint/u64. Это касается @len(), @capacity(), with_capacity(n), reserve(n), truncate(n), индексных параметров (arr[i], s.byte_at(i)), позиций (indexOf/find возвращают int с -1 или Option[int]), и slice-границ (arr[a..b] где a, bint).

Правило

  1. Stdlib invariant. Любая публичная функция в std/, принимающая или возвращающая «количество элементов» / «размер в байтах» / «индекс позиции», использует int.

  2. Защита от негатива через контракты. Capacity-API (with_capacity/reserve и аналоги) добавляют requires n >= 0 (D24). Это compile-time проверка при Z3 backend / runtime debug-assert при TrivialBackend. Compile-error на with_capacity(-1) без attempted конверсии типа.

  3. Negative-as-sentinel разрешён. Поиск/позиция могут возвращать -1 как «не найдено» (Java/Go convention) ИЛИ Option[int] — stdlib-конвенция в пользу Option[int] для type-safety, но -1 sentinel допустим в low-level API (str.find_byte).

  4. Разности — естественно signed. a.len() - b.len(), xs.len() - 1, обратные циклы for i in (0..n).reverse() работают без явных кастов или underflow-паник. На пустой коллекции xs.len() - 1 даёт -1, что валидно как loop-guard вход (for j in 0..-1 — пустой range).

  5. uint/u64 — только для bit-twiddling, FFI и pointer bridge. Hash-значения, битовые маски, raw memory addresses, sized-integer аргументы C-API — это u64/uint. usize/isize удалены (Plan 133, 2026-06-09) — используй int для address-sized операций и FFI size parameters. FFI-сигнатуры теперь пишут n int, C-codegen кастит intptr_tsize_t внутри.

  6. Future-arch path. При миграции Nova на multi-arch (32-bit / WASM) int = platform-pointer-width signed (= intptr_t), i64 остаётся fixed-64. Index API не меняется — auto-scale без breaking change. См. D129 migration note.

  7. Pointer interactions. Pointer arithmetic и pointer-integer bridges имеют свою numeric matrix, ортогональную stdlib index-API (Rule 1). Все signed для offset/diff (в духе Rule 4 «разности естественно signed»). usize/isize удалены — FFI-ABI через int.

    ОперацияТипГде
    coll.len() / coll[i]intstdlib index-API (Rule 1)
    arr[a..b] slice boundsintsub-slice views (D144)
    ptr + N / *T + N offsetintpointer arith (D216 §6) — scaled by sizeof(T)
    ptr - ptr / *T - *T diffintelement count (D216 §6) — signed
    external fn(..., sz int)intFFI ABI (D214, D216 FFI) — codegen casts intptr_tsize_t
    p as int / int as *Tintexplicit address cast — opaque handle, hash key, GC-hazard (D214 §casts)
    ptr as u64 / i64 as ptru64/i64opaque handle storage (D214 §casts)

    Правило: stdlib API никогда не использует uint/u64 для index/len/capacity (Rule 1); FFI / pointer arithmetic / cast bridges — единственные легальные exemptions.

Почему

Industry baseline (2026-06).

ЯзыкIndex/len типЗнакHindsight
Goint (platform-word)signedСознательный выбор после C
SwiftInt (platform-word)signedApple: «harder to make off-by-one errors»
Javaint (i32)signedИсторическое; принято
KotlinInt (i32)signedMirror Java
C#int (i32)signedLongLength для >2B
Pythonint (arbitrary)signedNegative-index slicing
TypeScriptnumber (f64)signed (de facto)Один тип
Rustusize (platform)unsignedCommunity regrets vocal
C++ STLsize_t (platform)unsignedStroustrup: «I regret using unsigned for size in STL»
Zigusize (platform)unsignedEmbedded-first рационал

Счёт 7:3 в пользу signed. Двое из трёх unsigned-языков (C++ и Rust) имеют публичные authorial regrets.

Конкретные выгоды signed int для Nova:

  1. Нет underflow-trap. xs.len() - 1 на пустом vec не паникует (даёт -1), в отличие от Rust 0_usize - 1 → overflow panic. Это — самая частая newbie-trap в Rust.

  2. Sentinel -1. Эргономика find/indexOf без обязательной Option-аллокации.

  3. Разности и diff-логика. a.len() - b.len() валидно signed; sorting comparators, position deltas, scroll offsets — все естественны.

  4. Mixed arithmetic без ceremony. Никакого (x as int) + i, (len as int) - 1. AI-first killer-use (D10): LLM пишет signed-индексацию правильно чаще, чем балансирует uint/i64 касты.

  5. Bit-width аргумент мёртв на 64-bit. Signed-int (= i64) даёт 2⁶³ − 1 ≈ 9.2 × 10¹⁸ элементов — никакая коллекция в адресном пространстве этого не достигнет.

  6. Совместимость с overflow-семантикой. Plan 33.8 Ф.1: int overflow → nv_panic (__builtin_*_overflow). Если ввести uint для len, мы заменим один trap (overflow on saturation) на другой (underflow on 0 - 1) — без выигрыша.

  7. Effect/protocol симметрия. Все примитивные методы (hash/eq/lt/etc., D109) уже работают с int. Введение второго numeric vocabulary для размеров удвоит type-checker complexity без semantic gain.

Type-encoded invariant («n ≥ 0») — частично покрывается контрактами (requires n >= 0), которые при Z3 backend (D24) дают compile-time гарантию того же уровня, что unsigned type.

Что отвергнуто

  1. uint/u64 для index/len (как Rust usize, C++ size_t). Отвергнут D130 Q3 (2026-05-19): breaking change для 100+ APIs; underflow-trap хуже missing type-invariant; runtime contract-based check покрывает основной use-case.

  2. Mixed convention (uint для capacity, int для index). Отвергнут: создаёт постоянные касты на границе API, удваивает protocol-method matrix.

  3. Refinement type nat = {x int | x >= 0} как параметр капасити. Отвергнут на bootstrap: refinement-types — long-term Plan 33.x (после full SMT integration); сейчас requires n >= 0 даёт ту же проверяемость без grammar-changes.

Связь

  • D129int = i64 alias decision (foundation).
  • D130uint symmetric pair + Q3 indexing decision (historical origin).
  • D24requires/ensures контракты для compile-time проверки.
  • D54int as uint saturation (cross-type bridge).
  • D109 — встроенные методы примитивов (включая int).
  • D141byte_at/bulk slice API использует int индексы.
  • D144 — sub-slice views arr[a..b] — границы int.
  • D214ptr opaque type + cast rules (usize removed, use int for ABI bridge).
  • D216*T typed pointer family + arithmetic (int offset + diff) + FFI.
  • Plan 33.8int overflow → panic (soundness).

Эволюция

  • 2026-05-19 (D130 Q3): Решение «keep int for indexing, no change» принято внутри uint plan’а — внутри одного из четырёх Q-вопросов, не findable отдельно.
  • 2026-06-03 (D226, этот блок): формализация в самостоятельное D-решение + правило requires n >= 0 на capacity-API + cross-language baseline + future-arch migration path.
  • 2026-06-03 (D226 amend, pointer-aware): §5 расширен для usize ABI bridge + pointer-integer casts; §7 «Pointer interactions» с numeric matrix для всех ptr ops; cross-refs на D214 + D216. Закрывает gap research’а §3 (docs/research/08).
  • 2026-06-09 (Plan 133): usize/isize удалены из Nova. int = address-sized signed integer (intptr_t на 64-bit). FFI-сигнатуры используют int, codegen кастит intptr_tsize_t внутри. D226 RETIRED в части usize/isize alias-semantics.

Acceptance criteria

  • std/collections/hashmap.nv with_capacity(min_capacity int) requires min_capacity >= 0
  • std/collections/set.nv with_capacity(cap int) requires cap >= 0
  • std/runtime/string_builder.nv with_capacity(n int) requires n >= 0
  • std/runtime/write_buffer.nv with_capacity(n int) requires n >= 0
  • D226 spec block с industry baseline + rationale + rejected alternatives
  • []T.with_capacity / []T.reserve built-in: requires-clause в compiler-codegen external_registry — followup [M-D226-builtin-capacity-requires]
  • nova check lint W_D226_NEGATIVE_LITERAL — warn на with_capacity(-N) при literal-args (без Z3) — followup [M-D226-negative-literal-lint]
  • _experimental/ capacity APIs (Queue.with_capacity) — sweep после promotion в stable.
  • isize / usize удалены (Plan 133, 2026-06-09) — closes [M-D226-isize-usize-alias-D-block].

Amend 2026-06-03 — usize / isize formal alias D-block

RETIRED (Plan 133, 2026-06-09): usize и isize удалены как Nova-типы. Используй int для размеров, индексов и address-sized операций. uint остаётся для беззнаковых битовых операций и FFI. Раздел оставлен как исторический контекст.

Closes followup [M-D226-isize-usize-alias-D-block].

Definition (HISTORICAL — типы удалены в Plan 133):

AliasBootstrap (64-bit)Future arch
usizeu64 (= uint64_t)platform-pointer-width unsigned
isizei64 (= nova_int)platform-pointer-width signed

Use cases:

  1. FFI ABI bridge (primary use) — C size_t / ptrdiff_t (HISTORICAL, до Plan 133):

    // БЫЛО (до Plan 133):
    external fn malloc(sz usize) -> Option[*u8]             // C: size_t
    external fn read(fd int, buf *mut u8, n usize) -> isize // C: size_t, ssize_t
    // СТАЛО (Plan 133):
    external fn malloc(sz int) -> Option[*u8]              // C: size_t — codegen casts intptr_t→size_t
    external fn read(fd int, buf *mut u8, n int) -> int    // C: size_t, ssize_t
    
  2. Pointer differences (D216 §6) — ptr - ptr → int, signed semantically.

  3. Platform-pointer-widthint = intptr_t на текущих 64-bit targets (Plan 133).

НЕ для:

  • len/capacity/index APIs — используют int (per D226 Rule 1). Reason: signed convention; arithmetic safety (signed underflow detectable).
  • General-purpose unsigned arithmetic — используют uint (= u64 alias) per Plan 70.5.

Casts (после Plan 133):

  • ptr as int / int as *T — explicit, allowed для opaque handles + address-as-integer (D216 §6, D214).

Spec drift fix: isize/usize использовались в D216/D214 examples и Plan 118 FFI без formal D-block aliasing. Plan 133 удаляет эти типы целиком.

Implementation: compiler-codegen/src/codegen/emit_c.rs + types/mod.rs type_ref_to_c + TyCat::Int + BUILTIN_TYPE_NAMES registry updated 2026-06-03.

Cross-refs:


Plan 124.8 — Tuple+Value-Record design refinement (2026-06-02)

Sub-plan Plan 124 V2 refinement. Amends 6 D-blocks + introduces 1 NEW. Status: ✅ ACTIVE since 2026-06-02 (Ф.0-Ф.5 closed).

D33 amend §«binding propagation» (Plan 124.8 Ф.2)

⚠️ AMENDED by D216 V3 §V3.1 (2026-06-04, refined 2026-06-05 Ф.6) — rows «ro x mut T» / «mut x ro T» в этой таблице storage-class qualified:

  • Type-form ro mut T / mut ro T (без имени между modifier’ами): forbidden когда T = value type (см. §V3.1 — primitives, value records, named/anonymous tuples, Unit). Error E_MUTABILITY_CONFLICT_VALUE_TYPE.
  • Binding-form ro x mut T / mut x ro T (с именем между): allowed regardless of T storage class (Ф.6 relaxation, 2026-06-05).

ro/mut на binding по default распространяется на тип справа. Explicit повторение модификатора — redundant error.

ДекларацияПарситсяСемантика
ro x T✅ defaultbinding ro, type implicit ro
mut x T✅ defaultbinding mut, type implicit mut
ro x ro TE_REDUNDANT_TYPE_MODIFIERто же что ro x T
mut x mut TE_REDUNDANT_TYPE_MODIFIERто же что mut x T
ro x mut T✅ NEWbinding ro, content mut (cannot reassign, can mutate)
mut x ro T✅ existingbinding mut, content ro (can reassign, cannot mutate)

Closes D176 §«type-modifier в любой позиции» partial parser implementation gap — mut T теперь принимается в binding type annotation position.

D33 amend §«consume binding-only — distinction rationale» (Plan 118.5 V2, 2026-06-04)

Closes [M-118.5-consume-as-type-modifier].

consume НЕ становится type-level wrapper parallel к ro / mut / unsafe. Stays binding-only modifier (per D131, D133, D162, D164).

Rationale:

ro / mut / unsafe — syntactic compile-time modifiers expressing mutability / safety contract на the type. consume — fundamentally different: it expresses ownership transfer / linearity / drop obligation — semantic D-блоки D131 (linearity), D133 (consume types), D162 (consume types implementation), D164 (D-block consume types).

Examples of the asymmetry:

  • ro T value — readonly view; multiple readonly aliases allowed.
  • mut T value — mutable; subject к binding-dominates rule (D175 amend).
  • unsafe T value — MaybeUninit; read requires assertion (D216 V2 §V2.3).
  • consume T value — owned uniquely; passing transfers ownership, drops invariants at scope exit. Not a syntactic property of T; a structural property of the binding.

Hypothetical consume * T («consume pointer») would mean a pointer that the caller must consume — но это уже expressed via record-wrapped pointer (type Handle consume(* T)). Plain consume T за T уже-not-consume makes no semantic sense.

Decision: keep current consume design (D162, D164). Right-binding rule applies only к ro/mut/unsafe. Reject Plan 118.5 V2 followup [M-118.5-consume-as-type-modifier] as NO ACTION — consume already fits its semantic-binding role correctly.

D33 amend §«Projection-chain mutability check» (Plan 128.2, 2026-06-06)

Closes [M-128.1-ro-binding-field-chain-not-mut] (P1 safety hole opened в Plan 128.1 Ф.3).

D33 locality-of-mutation invariant (Plan 108.2 D36 enforcement) is extended: mut-method dispatch проверяет mutability root binding’а lvalue projection chain, не только когда receiver — голый identifier.

Rule:

Для Call obj.method(...), где method — mut-method (fn T mut @method), type-checker walks Member/IndexAccess chain от obj к root:

walk_root(e) =
  | Ident(name)         → Some(name)
  | Member { obj, .. }  → walk_root(obj)
  | IndexAccess { obj, .. } → walk_root(obj)
  | _                   → None

Если walk_root(obj) = Some(name):

  • local_mut[name] == Some(false) (ro local) → E_LOCAL_NOT_MUT (или E_RECEIVER_BINDING_NOT_MUT — chain hint в note).
  • param_mut[name] == Some(false) (ro param) → E_PARAM_NOT_MUT.
  • otherwise — OK.

Если walk_root(obj) = None (chain начинается с rvalue base — Call result, literal, …): no enforcement; mutation в hoisted temp семантически no-op (D32 «mutate-by-copy для rvalue» — D215 amend «Method receiver passing» Ф.2 §rvalue receiver).

Receiver shapes table:

Receiver shapebindingResult
b.set_x()ro bE_LOCAL_NOT_MUT (existing)
b.v.set_x()ro bE_LOCAL_NOT_MUT (NEW — chain root)
arr[0].set_x()ro arrE_LOCAL_NOT_MUT (NEW — chain root)
b.parts[i].v.set_x()ro bE_LOCAL_NOT_MUT (NEW — chain root)
b.v.set_x()mut b, mut v fieldOK
make_body().v.set_x()rvalue baseOK (no-op semantically — temp)

Symmetry с D175 (readonly field freeze): projection-chain root check независимая ось от per-field readonly enforcement. mut b; b.v.set_x() с ro v field остаётся E_FIELD_NOT_MUT (D175 invariant); orthogonal к D33 root walk.

Cross-ref: D215 amend «Method receiver passing» (Plan 128.1 Ф.1) implements call-site codegen (&(b->v), &(arr->data[i])) для lvalue projection — это codegen pair того же chain-walking; D33 root check — type-checker pair. Symmetric infrastructure: оба обходят Member/IndexAccess chain (codegen — для emit, type-checker — для binding-mutability gate).

Implementation: helper lvalue_root_ident в types/mod.rs вызывается из consume_walk_expr Call arm для receiver. Pure-read methods (x.abs()) не подпадают под gate — registered || builtin_mut_method guard сохраняется.

D215 cross-ref §«projection root binding mutability» (Plan 128.2, 2026-06-06)

Pair note к D33 amend §«Projection-chain mutability check».

D215 lvalue-projection mut-method ABI (Plan 128.1 Ф.1) — &(b->v) для b.v.method(), &(arr->data[i]) для arr[i].method() — corresponds к codegen pair того же invariant’а. Type-checker side (D33 projection-chain root walk) проверяет, что root binding lvalue chain’а — mut; codegen side (D215 §«lvalue projection receivers») emit’ит pointer на slot.

Implication для users: writing b.v.set_x() requires mut b binding AND mut v field (D175 cross-ref) AND mut-receiver target method (D32). All three axes independent; missing любой — distinct error code (E_LOCAL_NOT_MUT / E_FIELD_NOT_MUT / receiver-mode mismatch).

Plan 128.2 §Markers closure: [M-128.1-ro-binding-field-chain-not-mut] (P1) — closed via root-walking enforcement.

D216 V2 amend §V2.2b «mut T transparent» (Plan 118.5 V2, 2026-06-04)

Closes [M-118.5-mut-t-vs-binding-distinction].

TypeRef::Mut(T, span) AST wrapper introduced in Plan 118.5 V1 (per V2 right-binding rule §V2.1) is purely transparent at C codegen level:

  • AST representation: Mut(inner, span) carries no extra semantic vs inner
  • C codegen: mut T emits same C type as T (через transparent recurse)
  • Type-checker: mut T does NOT impose mutability requirement at the type level — the mutability semantic belongs к binding-level mut (Plan 108 D176), не к type-level.

Why mut T exists at all:

Only purpose is syntactic uniformity under right-binding rule §V2.1. Without mut T arm в parse_type, the user’s natural extension of ro T к mut T would fail к parse (no recursive arm). Adding the arm makes the grammar regular and predictable. The wrapper has zero semantic at runtime.

Practical implications:

  • mut int parses as Mut(Named("int")) — wrapper transparent for codegen (emits just nova_int). Type-checker doesn’t validate Mut(non-Pointer) as anything special.
  • *mut T parses as Pointer(Mut(T))Mut is the pointee modifier (postfix, INNER of Pointer), означает writable target. mut * T (Mut снаружи Pointer) НЕ строится из синтаксиса — prefix перед * запрещён (E_POINTER_PREFIX_MODIFIER, §1, Plan 138.5). «Mut pointer» (reassignable binding) выражается binding’ом mut p *T, не type-wrapper’ом.
  • let mut x int = ... — the binding mut (Plan 108) provides mutation rights. let x mut int = ... would parse but the mut wrapper on int doesn’t grant mutation (binding x is implicit ro per Plan 108).

Disambiguation reference:

FormMeaningSource
let mut x T = ...Binding x is mutablePlan 108 D176
let x mut T = ...Binding x is ro; type wrapper transparent (no mutation rights)Plan 118.5 V2 §V2.2b
let mut x mut T = ...Binding mut; type wrapper transparent (same as let mut x T = ... semantically)combined

User-visible recommendation: prefer binding-level mut; type-level mut T is for syntactic uniformity only.

D218 RETRACTED (Plan 118.5 V2, 2026-06-04)

Closes [M-118.5-d218-maybeuninit-duplication].

D218 (Plan 118.2 — «Slice fat-pointer + MaybeUninit[T] + ManuallyDrop») partially retracted — the MaybeUninit[T] sub-design is subsumed by Plan 118.5 V2 §V2.3 first-class unsafe T wrapper.

What D218 proposed:

MaybeUninit[T] generic wrapper providing «memory typed as T but may be uninitialized» semantic. Caller asserts validity via assume_init() method.

Why subsumed:

Plan 118.5 V2 §V2.3 promoted unsafe T to first-class type wrapper with exactly the MaybeUninit semantic:

  • init/layout/aliasing/identity contracts off (per §V2.3)
  • read requires unsafe { } wrap (E_UNSAFE_T_READ_REQUIRES_WRAP)
  • write safe (transitions к valid)
  • narrow unsafe T → T requires explicit unsafe cast (E_UNSAFE_T_NARROW_REQUIRES_UNSAFE)

This first-class wrapper:

  • Composes orthogonally с pointer modifier («ptr к uninit T» = *unsafe T, postfix pointee — Plan 138.5)
  • Doesn’t require generic instantiation
  • Uses universal right-binding rule for grammar uniformity
  • Provides finer-grained codegen control (NPO recalc per §V2.4)

What survives in D218:

The «slice fat-pointer» portion of D218 remains a valid sub-plan (Plan 118.2 Ф.1+Ф.2). ManuallyDrop redesign is separate.

Migration path для users:

D218 form (deprecated)Plan 118.5 V2 form
MaybeUninit[i32]unsafe i32
MaybeUninit<T>::uninit()mut x unsafe T = uninit_value (write-safe init)
m.assume_init()unsafe { m as T } (narrow cast)
*mut MaybeUninit<T>*unsafe T (postfix pointee к uninit T)

MaybeUninit[T] type itself not added в std. D218 spec section marked RETRACTED для MaybeUninit subset; slice + ManuallyDrop subsets remain unchanged pending Plan 118.2 implementation.

D216 V2 amend §V2.3b «E_UNSAFE_ARG_REQUIRES_WRAP + E_UNSAFE_T_NARROW_REQUIRES_UNSAFE» (Plan 118.5 V2)

Closes [M-118.5-narrow-cast] + [M-118.5-arg-coerce-unsafe] spec slots.

Two new error codes added для Plan 118.5 V2:

  • E_UNSAFE_T_NARROW_REQUIRES_UNSAFE — explicit narrow cast x as T (where x: unsafe T binding and T is non-unsafe target) outside unsafe block. Caller must assert value validity via unsafe { x as T }.
  • E_UNSAFE_ARG_REQUIRES_WRAP — passing unsafe-T binding as Ident argument к function/method parameter whose declared type is NOT unsafe T. Param- level mismatch detected via ConsumeRegistry fn_non_unsafe_params / method_non_unsafe_params registries.

Both errors are suppressible by:

  • Wrapping the entire enclosing expression в unsafe { ... } block (depth > 0 disables the checks).
  • Re-declaring the callee parameter as unsafe T (then arg-coerce matches).

D216 V3 amend (Plan 118.5 V3, 2026-06-04) — 4 modifier composition rules

⚠️ LARGELY SUPERSEDED — Plan 138.5 (2026-06-11): V3 строился вокруг prefix-модификаторной пропагации (outer modifier перед * распространяется через Pointer на pointee) + safe-стоппера. После запрета prefix перед * (E_POINTER_PREFIX_MODIFIER, §1) пропагировать нечего:

  • §V3.3 (right-binding propagation через Pointer) — SUPERSEDED (нет outer-модификатора над Pointer → ничего не пропагируется).
  • §V3.4 safe стоппер + Unsafe(Pointer) пропагация — RETIRED (safe останавливал outer-unsafe-пропагацию, которой больше нет). Type-level E_REDUNDANT для ro * ro T / unsafe * unsafe T — moot (prefix запрещён).
  • §V3.2 ordering — flip на ro unsafe T (safety-INNER, вплотную к базе, как external unsafe fn); касается только value-T / pointee, редкий случай.
  • §V3.1 (ro+mut adjacency на value-T) — KEPT (про value-T mutability class, не про указатели); pointer-примеры в нём заменены на postfix/binding. Builds on (residual): D216 V1 (postfix pointee) + V2 §V2.3 (unsafe T value).

§V3.1 — Storage-class-aware ban на ro+mut adjacency

ro и mut — modifiers same mutability class. Their combination at type level OR binding+content level requires storage-class qualification.

Rule:

Distinction binding-form vs type-form (V3 amend 2026-06-05):

§V3.1 storage-class ban applies ONLY к type-position ro mut T / mut ro T (where both modifiers appear consecutively на одном уровне TypeRef). Binding-position ro x mut T / mut x ro T (modifiers вокруг имени параметра/локала) — orthogonal binding modifiers, ALWAYS allowed regardless of T’s storage class. Closes [M-118.5-V3-binding-context-relaxation] (was V4 deferred).

  • For value-type T (storage IS value):

    • primitives (int, bool, f64, etc.)
    • value records (type X value { ... } per Plan 124.8 D228)
    • named tuples (type Point(x f64, y f64) per Plan 120 D215)

    Type-position conflicting combinations → E_MUTABILITY_CONFLICT_VALUE_TYPE:

    fn f(p *ro mut int)              // ❌ — pointee ro+mut on value T (postfix chain)
    fn f(p *mut ro Point)            // ❌ — same (postfix)
    fn f(p ro mut int)               // ❌ — type-form (name absent before modifiers)
    fn f() -> mut ro str             // ❌ — return type-form
    type X { field ro mut Acc }      // ❌ if Acc is value record
    

    (Note Plan 138.5: prefix * ro mut int сам по себе — E_POINTER_PREFIX_MODIFIER; pointee-adjacency *ro mut int postfix — это §V3.1 value-T conflict.)

    Binding-form ALLOWED для value-T:

    fn f(ro x mut int)               // ✅ — binding-form (ro pre-name, mut post-name)
                                     //   ro x: no rebind (binding-level)
                                     //   mut: mut-method access (binding-level)
    fn f(mut x ro int)               // ✅ — symmetric
    let ro x mut int = 5             // ✅ — local-binding form (parser may flag E_LOCAL_*
                                     //   for non-mut mutation attempts — orthogonal)
    
  • For reference-type T (T-as-pointer-к-data semantically):

    • records (type X { ... } default — heap)
    • arrays []T
    • heap-tracked types

    Both type-form AND binding-form VALID:

    fn f(ro mut Acc)                 // ✅ type-form, ref-T (Readonly(Mut(Acc)))
                                     //   semantically: ro binding to mut content
    fn f(ro acc mut Acc)             // ✅ binding-form
                                     //   ro acc: no rebind / mut access on binding
    fn f(mut acc ro Acc)             // ✅ symmetric binding-form
    

For-loop exception: for y in iter — loop variable y semantically ro but reassigned per iteration. Plan 108.3 loop-var rule preserves this behavior; V3 §V3.1 does NOT fire on loop-var-introduced rebindings.

Value types per V3 (user-confirmed 2026-06-04):

  1. Primitives (full list):
    • Numeric: int (address-sized, = i64 on 64-bit; use for sizes, indices, counts), uint, i8/i16/i32/i64, u8/u16/u32/u64, f32, f64
    • Other: bool, char, byte (alias u8), str, ptr
    • Note: usize/isize removed (Plan 133, 2026-06-09) — use int.
  2. Value records: type X value { ... } (Plan 124.8 D228)
  3. Named tuples: type Point(x f64, y f64) (Plan 120 D215)
  4. Anonymous tuples: (A, B, C) literal type syntax
  5. Unit: () (zero-size value)

Reference types per V3:

  • Records (type X { ... } default — heap)
  • Arrays []T / FixedArray [N]T (heap-tracked для elements)
  • Pointer (any modifier wrapping)
  • Func, Protocol

Storage class detection (compiler-codegen/src/types/mod.rs helper):

fn is_value_type_for_v3(ty: &TypeRef, type_decls: &TypeDeclRegistry) -> bool {
    use TypeRef::*;
    match ty {
        Named { path, .. } if path.len() == 1 => {
            let name = path[0].as_str();
            // Primitives (Plan 133: isize/usize removed; int = address-sized)
            if matches!(name,
                "int" | "uint"
                | "i8" | "i16" | "i32" | "i64"
                | "u8" | "u16" | "u32" | "u64"
                | "f32" | "f64"
                | "bool" | "char" | "byte" | "str" | "ptr") { return true; }
            // User type: value record OR named tuple
            if let Some(td) = type_decls.get(name) {
                return td.is_value_record() || td.is_named_tuple();
            }
            false
        }
        Tuple(..) => true,    // anonymous tuples — value
        FixedArray(..) => false,  // [N]T — heap-tracked elements
        Array(..) => false,       // []T — heap
        Pointer(..) => false,
        Func { .. } => false,
        Protocol { .. } => false,
        Unit(..) => true,
        // Modifier wrappers strip к inner
        Readonly(inner, _) | Mut(inner, _) | Unsafe(inner, _) => {
            is_value_type_for_v3(inner, type_decls)
        }
    }
}

Note re int (Plan 133 amend, 2026-06-09):

int = address-sized signed integer (= i64 on 64-bit); isize и usize удалены. uint = address-sized unsigned (= u64 on 64-bit). V3 storage-class check распознаёт int/i64 как одно и то же value type.

Conflict detection — at check_decl_type (compiler-codegen/src/types/mod.rs):

fn check_v3_ro_mut_conflict(
    ty: &TypeRef,
    type_decls: &TypeDeclRegistry,
    errors: &mut Vec<Diagnostic>,
) {
    // Recursive walk:
    // For each Readonly(Mut(inner)) or Mut(Readonly(inner)) AST shape found,
    // determine inner's storage class. If value-type → error.
    //   Pure-type-level (in pointer/array interior) — also error for
    //   value-type T (no binding context to disambiguate).
    //   For reference-type T, allow at binding-context (let/param/field),
    //   disallow at nested-constructor position.
    ...
}

Retracts D33 amend rows: the previous unconditional «ro x mut T ✅» and «mut x ro T ✅» entries get storage-class qualification per §V3.1.

§V3.2 — Modifier ordering (FLIPPED to safety-inner: ro unsafe T)

⚠️ FLIPPED (Plan 138.5): прежнее правило «safety-outer / mutability-inner» (unsafe ro T) перевёрнуто на safety-inner / mutability-outer (ro unsafe T) — unsafe вплотную к базе T, как external unsafe fn ставит unsafe вплотную к fn. Семантически свободно: оси ортогональны (Readonly(Unsafe(T))Unsafe(Readonly(T))). Касается только value-T и pointee (после forbid-prefix *ro unsafe T — редкий случай); для указателя-binding ordering не возникает.

Rule (FINAL): ro/mut (mutability class) — outer; unsafe (safety class) — inner (вплотную к базе). Reverse → E_MODIFIER_ORDER.

Rationale: консистентность с keyword-формой external unsafe fn (unsafe непосредственно перед тем, что оно квалифицирует). Оси независимы, выбор — вопрос единообразия записи.

FormASTStatus
unsafe TUnsafe(T)
ro unsafe TReadonly(Unsafe(T))✅ (safety-inner)
mut unsafe TMut(Unsafe(T))
unsafe ro TUnsafe(Readonly(T))❌ E_MODIFIER_ORDER
unsafe mut TUnsafe(Mut(T))❌ E_MODIFIER_ORDER
*ro unsafe TPointer(Readonly(Unsafe(T)))✅ (pointee: ro outer, unsafe inner)
*unsafe ro TPointer(Unsafe(Readonly(T)))❌ E_MODIFIER_ORDER (pointee)

Note (Plan 138.5): prefix-формы (ro * unsafe T / unsafe * ro T) сами по себе теперь E_POINTER_PREFIX_MODIFIER (§1) — ordering-проверка применяется только к value-T и к pointee содержимому (постфикс после *).

Detection: parser KwRo/KwMut arms check inner.contains_unsafe_in_chain() helper — recursive walk через Readonly/Mut wrappers (stopping at Pointer / Named/Array/Tuple/Func boundaries). If found, emit E_MODIFIER_ORDER.

§V3.3 — Right-binding propagation semantics (SUPERSEDED, Plan 138.5)

⚠️ SUPERSEDED (Plan 138.5): §V3.3 описывал, как outer модификатор (перед *) семантически распространяется через Pointer на pointee. После запрета prefix перед * (E_POINTER_PREFIX_MODIFIER, §1) outer-модификатора над Pointer не существует → пропагировать нечего → правило отозвано.

⚠️ FINAL amend (Plan 147 D246, 2026-06-12; supersedes flip-scan-draft): «никакого наследования нет» — подтверждено. Под три-осевой моделью (D246) bare *T = pointee ro (*T ≡ *ro T) во ВСЕХ позициях; pointee-mut НЕ наследуется от binding. Только *mut T пишется явно для mut-pointee; *ro TE_REDUNDANT_POINTER_RO. Outer-prefix-пропагация остаётся отозванной (prefix-ban).

В FINAL-модели (D246) bare *T = pointee ro (*T ≡ *ro T); writable pointee — только через явный *mut T:

mut p *T    // Pointer(T) ≡ Pointer(Readonly(T)) — pointee ro (D246; L1 mut ≠ mut-pointee)
ro p *T     // Pointer(T)                        — pointee ro; p фиксирован
*mut T      // Pointer(Mut(T)) — explicit mut pointee (единственный опт-ин на *p = …)
*ro T       // ❌ E_REDUNDANT_POINTER_RO (избыточно; fix-it *T)
*unsafe T   // Pointer(Unsafe(T)) — pointee possibly-uninit

Реассайнабельность указателя — binding (let/mut, D36), не пропагация типа. Старые prefix-примеры (ro * T, unsafe * T, unsafe * safe T) — теперь parse error (E_POINTER_PREFIX_MODIFIER). Helpers contains_unsafe_in_chain / contains_same_class_in_chain остаются нужны лишь для value-T ordering (§V3.2), не для pointer-пропагации.

§V3.4 — safe keyword RETIRED + E_REDUNDANT_TYPE_MODIFIER (binding-level only)

⚠️ RETIRED (Plan 138.5): safe модификатор был propagation-stopper для outer-unsafe, существовавшей только в prefix-форме unsafe * safe T. После запрета unsafe * (prefix, §1) outer-unsafe-пропагации нет → стопить нечего → safe бесполезенRETIRED. usage в std = 0. Standalone safe TT и так был no-op. Lexer-токен safe в type-position — теперь ошибка (E_POINTER_PREFIX_MODIFIER family / E_SAFE_RETIRED, см. §V3.5); safe_stoppers / is_safe_stopped_between parser-машинерия — dead.

E_REDUNDANT_TYPE_MODIFIER (FINAL scope): V2 covered binding-level (ro x ro T) — KEPT. V3 type-level prefix-chain extension (ro * ro T / unsafe * unsafe T) опиралась на prefix-пропагацию — moot, т.к. prefix перед * сам по себе E_POINTER_PREFIX_MODIFIER (§1).

FormStatus
ro x ro T — binding ro + duplicate type-level ro❌ E_REDUNDANT_TYPE_MODIFIER (binding-level, KEPT)
ro T ro — duplicate ro at same level❌ E_REDUNDANT_TYPE_MODIFIER
*ro ro T — duplicate pointee ro (postfix)❌ E_REDUNDANT_TYPE_MODIFIER (pointee chain)
ro * ro T (prefix)❌ E_POINTER_PREFIX_MODIFIER (prefix перед * — §1; не доходит до redundancy)
unsafe * unsafe T (prefix)❌ E_POINTER_PREFIX_MODIFIER (§1)
ro * safe ro T / unsafe * safe unsafe TRETIRED — safe отозван

Detection (parser/mod.rs, FINAL):

// Value-T / pointee chains only (no prefix-before-* allowed):
// In KwRo/KwMut/KwUnsafe arm after recursive parse_type:
//   if inner immediately repeats same modifier class → E_REDUNDANT_TYPE_MODIFIER
// `safe` stopper machinery removed (no propagation to stop).

§V3.5 — New error codes registered

CodeDescriptionSpec section
E_MUTABILITY_CONFLICT_VALUE_TYPEro+mut adjacency on value-type T§V3.1
E_MODIFIER_ORDERunsafe wrapping ro/mut (safety-INNER rule, flipped Plan 138.5)§V3.2
E_REDUNDANT_TYPE_MODIFIERsame-class modifier repetition (binding-level + value-T/pointee chains)§V3.4
E_POINTER_PREFIX_MODIFIERro/mut/unsafe token перед * в type-position (Plan 138.5; extends E_INVALID_POINTER_MODIFIER)§1

safe модификатор RETIRED (Plan 138.5): safe T в type-position больше не валиден (был V3.4 propagation-stopper; пропагации нет). Parser трактует safe перед */типом как E_POINTER_PREFIX_MODIFIER family (или dedicated E_SAFE_RETIRED — выбор enforce-фазы Ф.2). safe_stoppers machinery dead.

E_PARAM_MOD_CONFLICT preserved для дисциплинирующих случаев:

  • mut consume name T / consume mut name T (D131 conflict)
  • mut readonly name T (legacy form)

§V3.1 amend (2026-06-05): E_PARAM_MOD_CONFLICT LIFTED для ro x mut T (pre-name ro + post-name mut) — orthogonal binding modifiers per binding-context relaxation. Symmetric mut x ro T уже работал (pre-name mut + post-name unhandled — falls to type-level Readonly(T)).

§V3.6 — Migration impact (V2 → V3)

Low breakage per discovery audit:

  • nova_tests/plan108_1/readonly_mut_conflict_neg.nv + mut_readonly_conflict_neg.nv — already NEG tests; keep expected error code OR migrate к new E_MUTABILITY_CONFLICT_VALUE_TYPE (binding-level distinction preserved per §V3.5)
  • nova_tests/plan118/t1_9_chain_modifiers_ok.nv:15*ro mut Acc (postfix pointee chain) — Acc context-dependent. Plan 138.5 Ф.2: rewrite к value-record для NEG demonstration, OR drop the redundant modifier.
  • nova_tests/plan118_5/* + plan118_5_v3/*built on prefix forms + safe-stopper (retired). Plan 138.5 Ф.2: convert к NEG fixtures (mut * T / safe TE_POINTER_PREFIX_MODIFIER / E_SAFE_RETIRED) OR delete/rewrite к postfix-pointee equivalents.
  • stdlib std/runtime/raw_mem.nv — uses *u8 and prefix mut * u8 → migrate prefix к postfix *mut u8 (Plan 138.5 Ф.2). bare *u8 unchanged.

§V3.7 — Followup markers opened

  • [M-118.5-V3-safe-keyword-impl]RETIRED by Plan 138.5 (safe модификатор отозван; пропагации нет — стоппер бесполезен).
  • [M-118.5-V3-ro-mut-storage-class] — Ф.2 type-checker storage-class detection + check (§V3.1 value-T — KEPT)
  • [M-118.5-V3-modifier-order] — §V3.2 ordering (FLIPPED к safety-inner, Plan 138.5) — value-T / pointee only
  • [M-118.5-V3-redundant-extension] — E_REDUNDANT binding-level + pointee chains (prefix-chain part moot — safe escape retired)
  • [M-118.5-V3-binding-context-relaxation]CLOSED 2026-06-05 — binding-form ro x mut T (и симметричное mut x ro T) allowed regardless of T storage class. Parser E_PARAM_MOD_CONFLICT lifted для pre-name ro + post-name mut combo. Type-form §V3.1 storage-class check unchanged.
  • [M-138.5-pointer-prefix-enforce] — Ф.2 parser/checker enforce E_POINTER_PREFIX_MODIFIER + retire safe/Unsafe(Pointer) + migrate prefix usages (Plan 138.5).

D52 amend (Plan 124.8 Ф.2)

7-я форма declaration: value recordtype X value { ... }. Stack-allocated reference type с copy-on-pass semantics (D32 amend). Composable с consume/priv модификаторами в каноническом порядке value consume priv (см. amend ниже — order-independence RETIRED).

valuecontextual keyword (recognized только в type Name[Generics] [modifiers] value [modifiers] { position; identifier value остаётся валидным во всех других позициях для backward compat).

Canonical modifier order: type X value consume priv { ... } — allocation → ownership → visibility (outer → inner).

AMEND 2026-06-12 (Plan 148 Ф.1 / D241, [M-138-canonical-modifier-order]): Parser больше НЕ order-independent — out-of-canon порядок модификаторов теперь hard error E_MODIFIER_ORDER (с machine-applicable fix-it), а не отложенный lint W_NON_CANONICAL_TYPE_MODIFIER_ORDER. Полное правило (canonical ranks, обобщение на новые модификаторы) — D241 в 03-syntax.md.

D175 amend §«binding dominates» (Plan 124.8 Ф.3)

ro acc binding теперь блокирует write к любому полю объекта, даже если поле помечено mut field T. Rust-style правило.

Field declarationro acc bindingmut acc binding
field T❌ переприсвоить, ❌ мутировать✅ / ✅
ro field T❌ / ❌❌ / ❌ (always frozen)
field ro T❌ / ❌✅ / ❌
mut field T❌ / ❌ (was ✅ / ❌ before amend)✅ / ✅
mut field ro T❌ / ❌ (was ✅ / ❌ before amend)✅ / ❌

Changed: mut field теперь НЕ “always-mutable” — binding dominates.

D175 amend §V2 «binding dominates — explanatory consolidation» (2026-06-04)

Rationale: Clarifying amend без semantic change. Consolidates rules разбросанные между D33 amend, D36, D175 amend, D176 V1, Plan 108.1-108.3 в единое explanatory section. Никаких behavior changes — все existing rules stay valid.

✅ KEEP = L2-ось (Plan 147 D246, 2026-06-12): этот §V2 «binding dominates / access-time enforcement» — в точности L2 view-семантика (транзитивный ro/rw freeze по owned-графу значения, access-time). Под три-осевой моделью (D246) сохраняется без изменений + добавлено уточнение P4: L2 freeze ОСТАНАВЛИВАЕТСЯ на каждом * (см. ниже §«L2 wall-at-*»). За указателем транзитивный freeze не действует — там работает только L3 (pointee-capability из типа). L1 (reassignability имени) и L3 (pointee-mut) — отдельные оси.

Принцип: binding dominates → access-time enforcement

Mutability при доступе к value (поля, индексы, mut-методы) определяется комбинацией двух факторов, в порядке приоритета:

  1. Binding mutability (call-site decision) — DOMINATES
  2. Type / field declaration (definition-site intent) — refines what binding permits

Никакой transitive type-modifier propagation в spec НЕТ. Вместо неё — runtime/check-time enforcement: каждое acc.field/arr[i] access валидируется по обоим уровням. Это эквивалентно Rust &T vs &mut T philosophy — modifier живёт на binding/reference, не propagates через type structure.

Полная таблица combination (5 axes × 2 binding modes)

Field declarationAccess под ro accAccess под mut accReasoning
field T (default)❌ read-only, ❌ mutate✅ read, ✅ mutatebinding dominates ro; mut binding allows default
ro field T❌ / ❌❌ / ❌ (always frozen)type-author intent enforced regardless
mut field T❌ / ❌ (dominates!)✅ / ✅binding ro blocks even «explicit mut» field
field ro T❌ / ❌✅ reassign, ❌ contentcontent modifier independent of binding
mut field ro T❌ / ❌✅ reassign, ❌ contentsame: content ro is invariant

Ключевая asymmetry: ro acc dominates ВСЕ field declarations (everything frozen). mut acc respects field declarations (ro field stays ro, mut/default field becomes mut).

L2 wall-at-* (Plan 147 D246 P4 — freeze STOPS at every pointer)

Транзитивный freeze L2 идёт только по owned-графу значения (.field / [i] — поля value/heap-record, элементы массива). Он упирается в стену на КАЖДОМ *: за указателем possibility-to-write определяется исключительно L3 (pointee-capability из типа *T=ro / *mut T=mut), а НЕ L2-binding. Причина: owned-vs-aliased heap статически неразличим (нет borrow-checker), а GC допускает shared-mut под чужим ro — поэтому ro = per-path write-ban, не object-freeze (D246 P10). Deep-immutable сквозь *mut снаружи не навязывается (D246 P9, C++ shallow-const trade-off); deep-ro → производитель объявляет поле *T (как str { ptr *u8 }).

type Cell { mut v *mut int }      // поле — mut-pointee (L3)

// 1. owned-граф: L2 freeze работает транзитивно (стоп НЕ на *, а на каждом .field)
type Tags { mut items []str }
type Account { mut tags Tags }
ro acc Account = ...
acc.tags.items.push("x")          // ❌ E_READONLY_FIELD — L2 freeze транзитивен (нет *)

// 2. за указателем: L2 freeze ОСТАНАВЛИВАЕТСЯ, действует L3 из типа
ro c Cell = Cell{ v: p }          // ro-binding морозит owned-граф c
c.v = q                           // ❌ — reassign поля .v (owned-граф, до стены) заблокирован L2
unsafe { *c.v = 7 }               // ✅ — за `*` L2 не действует; pointee mut (L3 = *mut int)
                                  //     запись разрешена ИМЕННО потому, что поле объявлено *mut int

// 3. если производитель хочет deep-ro — объявляет поле *T (ro-pointee)
type RoCell { v *int }            // *int = ro-pointee (L3)
ro r RoCell = RoCell{ v: p }
unsafe { *r.v = 7 }               // ❌ E_POINTER_RO_ASSIGN — L3 pointee ro (из типа)

Почему НЕТ transitive type-modifier inflation

User’s mental model «fn f(b []str) == fn f(ro b ro [] ro str) (полная inflation всех ro on each nesting level)» — НЕ работает в Nova spec. Reasons:

  1. D33 amend explicit: ro x ro TE_REDUNDANT_TYPE_MODIFIER. Single ro on binding is enough — propagation handled access-time.
  2. Implementation simplicity: access-time enforcement requires single binding-modifier check; transitive inflation would require type-level modifier propagation through all nested wrappers.
  3. Rust precedent: &T doesn’t inflate to &&T for nested fields — same logic.

let keyword retracted — нет «neutral binding»

Plan 114 D184 retracted let keyword. No third binding mode — binary ro / mut choice only. Rationale:

  • 2-state model symmetric с Plan 108.x default-ro rule
  • 3-state (let neutral / ro frozen / mut writable) ambiguous для default-prefix fields — would require defining «default field under let» semantic
  • Call-site explicit choice (ro or mut) dominates type author’s per-field intent — call-site has full safety knowledge

If user wants «trust type author’s per-field declarations»:

  • Use mut acc binding — ro fields stay ro, mut/default fields mut
  • This is already what type author’s intent maps к under mut binding

Hypothetical neutral let acc = X{...} mode adds no expressiveness — either синоним ro acc (least permissive) or mut acc (respects field intent). Rejected to keep binding-modifier landscape minimal.

Function return type interaction

Return type modifier (Plan 114 D184 default = mut, explicit ro allowed) participates в same rules:

// 1. Return default mut Acc, ro binding dominates
fn make_acc() -> Acc => Acc{...}
ro b = make_acc()
b.name = "x"           // ❌ E_LOCAL_NOT_MUT — binding dominates

// 2. Return default mut Acc, mut binding allows full access
mut c = make_acc()
c.name = "x"           // ✅

// 3. Explicit ro return, mut binding without type annotation
fn make_ro_acc() -> ro Acc => Acc{...}
mut c = make_ro_acc()  // type inferred as `ro Acc`
                       // → D33 amend row «mut x ro T»: binding mut,
                       // content ro — split semantics
c = different_acc      // ✅ reassign OK (binding mut)
c.name = "x"           // ❌ E_READONLY_CONTENT (content ro)

// 4. Explicit ro return + explicit mut type annotation = coerce error
mut c Acc = make_ro_acc()    // ❌ E_READONLY_COERCE
                              // ro Acc → mut Acc forbidden (D176)

Cross-refs

Status

ACTIVE since 2026-06-04 — explanatory consolidation, no behavior change. Implementation across D33 / D36 / D175 V1 / D176 / Plan 108.1-108.3 / Plan 114 unchanged. This amend documents existing rules в единую читаемую секцию.

D176 amend §«mut T в binding position» (Plan 124.8 Ф.2)

mut T теперь принимается в binding type annotation после name. Раньше parser принимал только в return-type и parameter positions. Pre-amend rendered impossible the legitimate ro view mut []u8 = arr form documented в D176 V1 §«type-modifier в любой позиции».

D215 amend (Plan 124.8 Ф.1)

Named tuples (type X(name1 T1, name2 T2)) получают:

  1. Multi-line support — newlines между fields после comma.
  2. Trailing comma supporttype X(a int, b int,).
  3. Binding-level mutability — Rust-style: mut p = Vec3(...) позволяет мутировать все поля; ro p блокирует все. Per-field mut/ro modifiers запрещены (E_TUPLE_NO_PER_FIELD_MOD).

Asymmetry с record {} form (which supports newline-as-separator) preserved: tuples требуют comma + optional newline после. Это intentional — tuples = compact pure-data form.

D215 amend — record {} same-line comma enforcement (2026-06-15)

Record {} fields тоже поддерживали только «newline-as-separator» по D49, но при fields на одной строке без запятой (type P value { x int y int }) парсер молча принимал оба поля — баг (обе ветки if/else в parse_record_fields_with_default делали одинаковый skip_newlines()). Уточнение:

  • Newline — допустимый разделитель (multi-line record).
  • Comma — допустимый разделитель (inline или multi-line).
  • Ни того ни другого (next token — не Newline/Semicolon/RBrace) → E_RECORD_FIELD_MISSING_SEPARATOR.

Иначе говоря: на одной строке запятая обязательна, как и в named-tuple (). Это унифицирует поведение обеих форм и закрывает парсер-баг. Применяется к обоим видам record (heap type X {} и value type X value {}).

D215 amend — named tuple field defaults (2026-06-17)

Поля named tuple могут иметь значение по умолчанию (default value):

type Complex(re f64 = 0.0, im f64 = 0.0)
type Rect(x f64, y f64, width f64 = 100.0, height f64 = 50.0)
const DEFAULT_SCALE f64 = 1.0
type Transform(tx f64 = 0.0, ty f64 = 0.0, scale f64 = DEFAULT_SCALE)

Конструктор может опускать любое подмножество полей с дефолтами:

ro z  = Complex()               // re=0.0, im=0.0
ro z2 = Complex(re: 3.0)        // im=0.0
ro r  = Rect(x: 1.0, y: 2.0)   // width=100.0, height=50.0
ro t  = Transform(scale: 2.5)   // tx=0.0, ty=0.0

Grammar (extends D215 original)

named_field  ::= IDENT type ("=" expr)?

До этого amend named_field ::= IDENT type — без optional default. Совместимость с существующим кодом: дефолты additive.

Семантика

  • Required field (без = expr) — обязателен в каждом вызове конструктора.
  • Optional field= expr) — можно опустить; absent → default expression инжектируется на call-site в declaration order.
  • Порядок полей в declaration не ограничен: required и optional могут чередоваться (хотя рекомендуется ставить optional в конце).
  • Default expression вычисляется на call-site (не хранится и не кэшируется); может ссылаться на module-level constants, literals, fn-calls.

Arity check (amend E_TUPLE_CONSTRUCT_ARITY_MISMATCH)

Проверка min/max arity вместо точного совпадения:

  • min_arity = количество required-полей (без дефолта)
  • max_arity = общее количество полей
  • Provided-field count ∉ [min_arity, max_arity] → E_TUPLE_CONSTRUCT_ARITY_MISMATCH

AST (compiler-codegen/src/ast/mod.rs)

pub struct NamedTupleField {
    pub name: Ident,
    pub ty:   TypeRef,
    pub default: Option<Box<Expr>>,   // NEW
}

Checker (types/mod.rs)

named_tuple_field_defaults: HashMap<String, Vec<(String, Expr)>> — ключ = bare type name, value = список (field_name, default_expr). Заполняется в step 1 (type-decl scan). При вызове конструктора — missing optional полей → default expr инжектируется в reconstructed arg list.

Codegen (emit_c.rs)

Default expressions инжектируются на call-site в emit_tuple_construct. C-struct initializer включает все поля в declaration order.

Acceptance criteria

#КритерийStatus
AC-1type X(f T = expr) принимается парсером
AC-2Mixed required+optional fields в одном типе
AC-3X() when all fields have defaults
AC-4Partial override X(a: v) — remaining defaults injected
AC-5Missing required field → E_TUPLE_CONSTRUCT_ARITY_MISMATCH
AC-6Default references module-level constant
AC-7std/_experimental/math/complex.nv migrated to named tuple
AC-8«без упрощений как для прода» — production-grade, no stubs
AC-9plan120 12/12 PASS (t4_defaults×8, t5_defaults_methods×4, neg_t4×1, neg_t5×1)

Реализация: Plan 120 D215-amend.

D222 amend (Plan 124.8 Ф.1)

«Named tuple priv» portion retract: priv/pub на tuple field — parser-error E_TUPLE_NO_PRIV. Tuples = pure data carriers (как Rust tuples, C# ValueTuple), always all-public. Encapsulation на стеке — через type X value { priv field T } form (D228 NEW).

«Protocol impl boundary» portion preserved для records (heap + value).

D225 retract (Plan 124.8 Ф.1)

«Type-level priv flip для named tuples» — fully retracted. Tuples всегда all-public; type X priv (...) syntax больше НЕ supported. Records keep type-level priv flip (D220 §3.3.1 unaffected).

D228 NEW — Value-record allocation contract (Plan 124.8 Ф.2/Ф.4)

Extended by D277 (Plan 153.2, 2026-06-15): by-value стек-codegen распространён с не-generic value-records на generic type X[T] value {…} — каждый mono-инстанс = inline NovaValue_<short>, passed/returned/copied by value, 0 nova_alloc для wrapper (зеркаля str-путь).

Renumbered from D226 (2026-06-03) — D226 in main concurrently assigned to «signed indexing convention» commit 8827f8ec132. D227 taken by «numeric literal inference» commit 41d4be096fa. D228 next free.

Optimizer consumer (Plan 123 V7.6 V2 refactor, 2026-06-05): field-cache is_reference_type_ref classifier consults AllocKind via TypeKindRegistry. Record(AllocKind::Heap) → ref-typed slot (pointer); Record(AllocKind::Value) → inline slot (D228 — mut methods write slot bits через NovaValue_X* pointer per §«Method receiver» above) → not slot-stable for V7.5/V7.7 own-field cache invalidation refinement. See docs/plans/123-followups-2026-06-05.md §2.1 для design + acceptance.

type X value { ... } — stack-allocated value type с copy-on-pass semantics. Symmetric extension D52 §«record form» через value keyword.

Semantic (V2 production-grade, landed 2026-06-03):

  • Allocation: stack (inline C struct NovaValue_X в callee frame). V2 codegen landed in Plan 124.8 V2.1-V2.4 — closes [M-124.8-value-codegen-stack].
  • Pass: copy on parameter pass (D32 amend) — C handles natively для value types.
  • Method receiver @: pointer на stack-slot (NovaValue_X*) — мутации видны caller’у. См. D215 amend «Method receiver passing» (Plan 128 Ф.2) — NamedTuple uses the same pointer pattern (NovaTuple_X* для mut receiver), wired через recv.mutable flag в emit_c.rs. Same lvalue-projection rule applies to NovaValue_X mut-receivers (D228) and NovaTuple_X mut-receivers (D215) — Plan 128.1 Ф.1: b.v.method(), arr[i].method(), @field.method(), multi-level a.b.c.method() emit &(b->v) / &(arr->data[i]) / &(nova_self->field) directly без temp hoist (mutation flows к original slot).
  • Reference fields: handles inline (ptr+len+cap для []T, ptr+len для str); data on heap (GC-tracked).
  • str — канонический reference-field value-record (Plan 139, 2026-06-11). type str value priv { ptr *u8, len int } — 16-байт stack-значение, inline handle (ptr+len) над иммутабельным heap/rodata UTF-8 буфером. Это flagship-пример паттерна «value-record несёт shared-immutable reference-поле»: copy-семантика значения (16 байт), но буфер разделяется через *u8 ro-pointee (*T ≡ *ro T, D246 — нет write-path → sharing безопасен → clone shallow, literal-interning невидим). Все остальные value-record-правила (D228) к str применяются единообразно; единственный opt-out — content-eq (ниже), потому что field-by-field над reference-полем сравнил бы pointer-identity. См. D26 MAJOR AMEND.
  • Equality of reference-field value-records (Plan 139 Ф.3, content-eq override). Default value-record == is field-by-field (Plan 141: emit_field_eq recurses each field). For a reference field whose pointee is shared + immutable (the str.ptr *u8 ro-pointee buffer), naive field-by-field would compare pointer identity — WRONG: two distinct buffers with equal bytes must be equal. Therefore str opts out of field-by-field and uses content-eq: emit_field_eq special-cases cty == "nova_str"nova_str_eq (len && memcmp) before any field-by-field path (emit_c.rs:11161), and direct ==/</… on str lower in BinOp codegen to nova_str_eq/nova_str_lt/… (emit_c.rs:16985). Consequence: str-in-tuple, str-in-record, str-in-sum eq, and str-keyed HashMap (hash via nova_str_hash SipHash-over-bytes) are all content-keyed automatically. str.@clone = 16-byte handle copy over the immutable shared buffer (no deep copy; *u8 ro-pointee makes sharing safe). General rule: a value-record carrying a shared-immutable pointer field must register content-eq for that field rather than inherit pointer-identity from the field-by-field default.
  • Fixed array fields: fully inline ([32]u8 = 32 bytes inline).
  • Forward decls: typedef struct NovaValue_X NovaValue_X; (no pointer alias unlike heap records).
  • Constructor: NovaValue_X tmp; tmp.f1 = v1; tmp.f2 = v2; (stack init, no nova_alloc).
  • Field access: .field (struct member), not ->field (pointer).
  • Call-site receiver: &v for identifier; hoisted temp + &temp для rvalue expressions (via prepare_method_recv helper).

Codegen V2 helpers (compiler-codegen/src/codegen/emit_c.rs):

  • emit_value_record_type(name, fields) — emits inline C struct + registers in record_schemas + type_aliases + value_record_names.
  • prepare_method_recv(obj_c, obj_ty) — wraps obj в & for value-record receivers (identifier-fast-path или temp-hoist для expressions).
  • is_value_type recognizes NovaValue_ prefix.
  • struct_name_from_c_type recognizes NovaValue_X strip.

Composability:

  • value + priv — composable (D220 §3.3.1 для type-level flip также применима).
  • value + consume — composable; value-record содержащий consume field автоматически становится consume (user decision; orthogonal axes).
  • value + mut/ro per-field — composable (D175 binding-dominates rule applies).

Composition с эталонами:

  • Kotlin value class (1.5+, single-field) — Nova value-record более powerful (multi-field).
  • Java Valhalla value class (incoming) — Nova alignment ahead-of-curve.
  • Rust struct (default stack) — Nova explicit allocation marker (vs Rust implicit).
  • C# struct vs class — Nova value modifier ≈ C# struct; reference record default ≈ C# class.
  • Industry: Nova становится **первым языком с single declaration syntax
    • explicit allocation modifier**.

V2 known limitations (defer-able):

  • []NovaValue_X array storage — currently boxes elements (V3 followup for inline element storage).
  • Escape analysis для &value auto-heap-promoteCLOSED by Plan 127 (V1, 2026-06-05) — см. §«escape & auto-promote» ниже.
  • Auto-derive methods (Equal / Hash / Clone / Compare / Display) — Plan 126 (orthogonal feature).
  • Generic value-record cross-module instantiation — works for simple cases; complex multi-T patterns may require V3 review.
  • #zero_on_move opt-in — followup attribute для security-critical consume value-records.

D228 amend — §«escape & auto-promote» (Plan 127, 2026-06-05)

Trigger: closes Plan 124.8 V2 followup [M-124.8-value-heap-promote]. Extends Plan 118 Ф.2 escape walker на value-record locals. Plan 127 phases landed (branch plan-127-value-record-escape): Ф.1 AllocKind tri-state — 40815f7d960; Ф.2 escape_analyze walker extension — 6ce9d2a4698; Ф.3 codegen heap-allocation path — 6948d2ba9dc; Ф.4 diagnostic codes — 0a0d7e2cf65; Ф.5 fixtures (18 = 12 POS + 6 NEG) — adb6850e7e0.

&v на value-record local разрешён. Compiler выбирает stack vs heap allocation для v based on escape analysis result (Go-style — без Rust lifetimes, без borrow checker).

AllocKind tri-state

AllocKind enum расширен с binary {Heap, Value} до tri-state:

VariantC outputКогда
AllocKind::HeapNova_X* (heap, nova_alloc)reference records (type X { ... })
AllocKind::ValueNovaValue_X (stack-inline)value records без escape
AllocKind::ValueHeapPromotedNova_X* (heap, nova_alloc)value records с detected escape

User-visible type — Vec3 (или *Vec3 для address-of), та же declaration type Vec3 value { ... }. Diff виден только в codegen (emit_c.rs). prepare_method_recv helper расширен: ValueHeapPromoted → obj уже указатель, не нужен & (vs Value где emit’ится &v).

5 escape trigger conditions (V1 OVER-promote)

Local value-record v promote’ится на heap если любое из:

  1. Return: &v возвращается из функции — fn f() -> *Vec3 => &v.
  2. Heap field store: &v сохраняется в heap-аллоцированное поле — acc.field = &v (где acc: Nova_X*).
  3. Closure capture: &v захватывается closure — let cb = || &v.
  4. Global / module binding: &v сохраняется в module-level let/const — escape вне fn scope.
  5. Fn arg sink (conservative): &v передаётся в fn arg — conservative assume sink escape transit (V1 OVER-promote: callee analysis не делается, любой &v → fn triggerит promote).

Conservative fallback (V1): если chain dataflow analysis cannot prove «no escape», promote. Matches Plan 118 Ф.2 V1 OVER-promote stance — любая uncertainty → heap. Precise mode = followup [M-127-precise-escape] (gated на Plan 118 [M-118-escape-precise]).

Mixed branch (escape в одной ветке, no-escape в другой) — conservative promote. Path-sensitive analysis = [M-127-path-sensitive-escape].

Cross-ref D228 ↔ D216 §4 (Plan 118 escape machinery reuse)

D228 escape rules используют ту же walker infrastructure, что и D216 §4 «&value operator + escape analysis с auto-promote» (Plan 118 primitives + named tuples). Контракт reuse:

  • escape_analyze walker (compiler-codegen/src/types/mod.rs, Plan 118 Ф.2) — единая dataflow walker, value-record locals добавлены как новая type category поверх существующих primitives/tuples.
  • Trigger conditions унифицированы — same 5 conditions работают identically для primitives (int), tuples ((a, b)), и value-records (Vec3 value). Different type category, same analysis.
  • AllocKind decision route — для primitives/tuples: stack vs heap-box; для value-records: Value vs ValueHeapPromoted. Decision point shared, codegen branches diverge.
  • V1 OVER-promote stance shared — Plan 118 и Plan 127 promote on ANY uncertainty. Precise mode landings будут coordinated (V2 followups обоих планов могут landed independently).
  • E_AMP_RECORD_LITERAL (D216 §4) применяется и к value-record literals — anonymous &Vec3 { ... } без named binding forbidden, требует pattern ro v = Vec3 { ... }; ro p = &v.

См. также D216 §4 для primitive/tuple side контракта.

Diagnostic codes (Plan 127 Ф.4)

CodeKindTrigger
W_VALUE_RECORD_UNNECESSARY_PROMOTELintEscape detected, но user мог return by-value; suggestion hint emit
E_VALUE_RECORD_ESCAPE_AFTER_CONSUMEError&v после consume vD162 violation, escape после ownership transfer

W_VALUE_RECORD_UNNECESSARY_PROMOTE suppressed на synthesized FnDecl bodies (Plan 126 auto-derive coordination — compiler_generated flag в FnDecl). Auto-derived Clone::clone/Equal::equal/ Hash::hash bodies могут эмитить &self без необходимости user attention; lint бы создавал noise. Suppression channel прописан в compiler-check/src/lints/value_record_promote.rs — skip emit, если fn.compiler_generated == true.

Composability (D228 amend)

  • consume value-record: &v после consume v → hard error E_VALUE_RECORD_ESCAPE_AFTER_CONSUME. До consume — escape analysis как обычно.
  • priv field: на promoted value-record (Nova_X*) field privacy preserved (Nova_X имеет те же priv markers, что и NovaValue_X).
  • readonly binding: ro binding propagates через promote — Nova_X* const в C output для ro path.

Method receiver compatibility

@-методы работают идентично в обоих modes via prepare_method_recv:

fn prepare_method_recv(obj_expr, alloc_kind) -> CExpr {
    match alloc_kind {
        AllocKind::Value => emit_address_of(obj_expr),     // &v
        AllocKind::ValueHeapPromoted => obj_expr,           // already Nova_X*
        AllocKind::Heap => obj_expr,                        // already Nova_X*
    }
}

User-side syntax не меняется — v.method() works в обоих modes.

Field-access codegen для ValueHeapPromoted (Plan 127.1 Ф.1)

Member-access на value-record local выбирает . vs -> operator based on AllocKind:

AllocKindC outputReason
AllocKind::Valueobj_c.fieldstack struct member access
AllocKind::ValueHeapPromotedobj_c->fieldheap pointer dereference (Nova_X*)
AllocKind::Heapobj_c->fieldexisting reference-record behavior

До Plan 127.1: Member-access path в codegen всегда emit’ил .field для value records, что давало invalid C code (Nova_Vec3* obj; obj.x) после Plan 127 Ф.3 heap-promote — runtime miscompile (3 Plan 127 fixtures broke: t3/t8/t9).

Plan 127.1 Ф.1 (commit a2b6f9c9518) добавил AllocKind::ValueHeapPromoted branch в Member-access emit path — symmetric с prepare_method_recv helper из D228 Method receiver compatibility section. Closes [M-127-codegen-field-access-promoted-ptr] P1 runtime bug.

Plan 127 regression: 12/6 → 15/3 (t3 + t8 + t9 now PASS).

Nested record-literal per-field type resolution (Plan 124.9 Ф.1)

Trigger: discovered Plan 128.2 Ф.2 — nested record literal A { b: B { c: C { d: D { value: 0 } } } } (4-level) сообщённо аллоцировал Nova_A на каждом уровне вместо declared field types B/C/D; workaround был explicit .new() constructors. Plan 124.9 closes [M-124.9-nested-record-literal-codegen].

Record-literal field-value codegen (emit_record_lit, compiler-codegen/src/codegen/emit_c.rs) резолвит тип каждого field-value по declared field type из record schema (record_schemas[struct_name][field]), а НЕ по outer/expected record type родителя. Вложенный RecordLit с собственным type_name (B { ... }) всегда аллоцирует свой declared field-type, независимо от контекста окружающего литерала:

/* mut a = A { b: B { c: C { d: D { value: 0 } } } } */
Nova_A* _t1 = nova_alloc(sizeof(Nova_A));   /* outer */
Nova_B* _t2 = nova_alloc(sizeof(Nova_B));   /* field b's declared type B */
Nova_C* _t3 = nova_alloc(sizeof(Nova_C));   /* field c's declared type C */
Nova_D* _t4 = nova_alloc(sizeof(Nova_D));   /* field d's declared type D */

Каждый уровень аллоцирует свой declared field-type — outer type НЕ leak’ится во вложенные typed литералы. Правило применяется к reference records, value records (D228), и generic nested literals; nested literal в fn-arg / return-position резолвится идентично. Empty-context fallback (top-level binding без expected type) не переопределяет inner type_name. Lifts Plan 128.2 explicit-.new() workaround.

Plan 124.8 Acceptance (A8.1-A8.20) — ALL ✅

  • A8.1 ✅ Multi-line tuple type X(\n a, \n b\n) parses.
  • A8.2 ✅ Trailing comma type X(a, b,) parses.
  • A8.3 ✅ Multi-line + trailing comma parses.
  • A8.4 ✅ type X(priv f int) → E_TUPLE_NO_PRIV.
  • A8.5 ✅ type X(pub f int) → E_TUPLE_NO_PRIV.
  • A8.6 ✅ type X(mut f int) → E_TUPLE_NO_PER_FIELD_MOD.
  • A8.7 ✅ mut p = Vec3(...) + binding tuple works.
  • A8.8 ✅ ro p blocking (D175 amend).
  • A8.9 ✅ type Vec3 value { ... } parses.
  • A8.10 ✅ V2 LANDED 2026-06-03: value-record real stack codegen через NovaValue_X inline struct (closes [M-124.8-value-codegen-stack]).
  • A8.11 ✅ V2 LANDED 2026-06-03: method receiver = NovaValue_X* pointer to stack-slot через prepare_method_recv helper.
  • A8.12 ⚠️ V2 partial: scalar/struct fields inline; []NovaValue_X array elements currently boxed (V3 followup для inline element storage).
  • A8.13 ✅ V2 LANDED 2026-06-03: param pass = value copy (C-native); return-by-value works через RVO.
  • A8.14 ✅ type Token value consume { ... } works (composition).
  • A8.15 ✅ type X value priv { ... } works (D220 §3.3.1 preserved).
  • A8.16 ✅ ro x ro T → E_REDUNDANT_TYPE_MODIFIER.
  • A8.17 ✅ mut x mut T → E_REDUNDANT_TYPE_MODIFIER.
  • A8.18 ✅ ro x mut T parses + works (D176 gap closed).
  • A8.19 ✅ ro acc blocks acc.mut_field = X (D175 amend).
  • A8.20 ✅ Regression: plan120 8/8 + plan124_1 9/9 + plan124_2 14/14 + plan124_3 10/10 + plan124_6 7/7 + plan108_3 14/14 unchanged.

Plan 124.8 V2.1 Acceptance — followup markers closed (A8.21-A8.30)

V2.1 closes 3 [M-124.8-*] markers landed 2026-06-03:

  • A8.21 ✅ [M-124.8-ro-binding-scope]: ro_binding_names block-scoped. f1_block snapshots/restores на entry/exit. Inner ro x = ... не leak’ит в outer scope. Cross-module contamination (stdlib ro v → user mut v) fixed.
  • A8.22 ✅ [M-124.8-ro-binding-scope] shadow: Stmt::Let shadow-aware. ro x; { mut x; x.field = ... } works через inner mut shadow remove prior ro entry; outer state restored на block exit.
  • A8.23 ✅ [M-124.8-tuple-mut-field-write-codegen]: end-to-end positive (single field write, multi-field write, sequential overwrite, compound expressions, cross-field arithmetic, write-then-method-read).
  • A8.24 ✅ [M-124.8-tuple-mut-field-write-codegen]: negative — D175 binding-dominates на tuple field write через ro binding → E_READONLY_FIELD.
  • A8.25 ✅ [M-124.8-zero-on-move] V1 parser: #zero_on_move attribute recognized наряду с #from_fields/#from_pairs/#impl. Duplicate detection. Только перед type декларацией валидно.
  • A8.26 ✅ [M-124.8-zero-on-move] V1 AST: TypeDecl.zero_on_move: bool flag (default false; backward-compat preserved).
  • A8.27 ✅ [M-124.8-zero-on-move] V1 checker validation: allowed kinds Record (heap + value), NamedTuple, Newtype. Reject Effect/Protocol/ Sum/Alias/Opaque с E_ZERO_ON_MOVE_INVALID_KIND.
  • A8.28 ✅ [M-124.8-zero-on-move] V1 codegen: per-type static inline void Nova_T_zero_storage(<C_type>* p) helper emit. Picks correct C type (Nova_T для heap+newtype, NovaValue_T для value, NovaTuple_T для named tuple).
  • A8.29 ⚠️ [M-124.8-zero-on-move-auto-inject] V2: auto memset at consume call sites — deferred (requires consume codegen Plan 100.x integration; regression risk для sync primitives).
  • A8.30 ✅ Regression V2.1: plan120 8/8 + plan124_1 9/9 + plan124_3 10/10
    • plan108_3 14/14 unchanged. plan124_8 27/27 → 40/40 PASS (+13 new fixtures для 3 markers).

Followups

  • [M-124.8-value-codegen-stack] — V2 LANDED 2026-06-03 — proper stack codegen реализован (emit_value_record_type + prepare_method_recv).
  • [M-124.8-tuple-mut-field-write-codegen] — CLOSED 2026-06-03. 4 fixtures cover named-tuple field-write end-to-end через release nova-cli + clang: single field write, multi-field write, sequential overwrite, compound expression (p.value = p.value * 2 + p.step), cross-field arithmetic, write-then-method-call visibility, binding-dominates negative (ro v блокирует write).
  • [M-124.8-ro-binding-scope] — CLOSED 2026-06-03. Root cause: ro_binding_names была monotonic per-ctx (никогда не очищалась). Stdlib ro v = ... (sha1.nv / semver.nv) leak’ало в пользовательские fixtures с mut v = .... Fix: f1_block snapshots/restores ro_binding_names на entry/exit; Stmt::Let shadow-aware (всегда removes prior entry, добавляет назад только если ro). 3 fixtures positive + negative.
  • [M-124.8-zero-on-move] V1 CLOSED 2026-06-03 — opt-in #zero_on_move attribute. V1 parser + AST + checker validation + per-type Nova_T_zero_storage helper emit. Allowed kinds: Record (heap + value), NamedTuple, Newtype. Reject Effect/Protocol/Sum/ Alias/Opaque с E_ZERO_ON_MOVE_INVALID_KIND. Auto-injection отложено к V2 followup [M-124.8-zero-on-move-auto-inject].
  • [M-124.8-zero-on-move-auto-inject] V2 — auto memset of source при consume call. Требует deep integration с consume codegen (Plan 100.x sync primitives) — non-trivial regression risk.
  • [M-124.8-value-record-mut-literal-codegen] — pre-existing bug (exposed at 2026-06-03 testing): mut t = ValueT { ... } direct literal binding emits Nova_T* вместо NovaValue_T. Workaround: ro + constructor pattern. Not zero_on_move specific.
  • [M-124.8-value-record-array-inline][]NovaValue_X inline element storage (V3, deferred — currently boxed).
  • [M-124.8-value-heap-promote]&value escape analysis + auto-heap-promote. Scope assigned to Plan 127 (2026-06-03) после consultation с Plan 118 owner: Plan 118 Ф.2 V1 покрывает primitives + named tuples, value-records остаются вне scope. Plan 127 extends Plan 118 Ф.2 walker на value-records; reuse escape_analyze + extend trigger conditions.
  • Plan 126 — auto-derive Equal/Hash/Clone/Compare/Display.
  • Plan 127 — value-record escape & auto-promote (см. выше).

D229 — Debug protocol + format spec ${expr:?}

Plan 91.14 (2026-06-05). New protocol parallel к D183 Display — debug-specific representation.

Rationale

  1. Debug semantics distinct from Display. Plan 91.8a.2 (D183) shipped Display (user-facing display) only. После Plan 118.5 V3 closure, pointer debugging via (*T).to_debug_str() proved inelegant — leaks unsafe context, no protocol-level extensibility, can’t recursively debug struct holding pointers. Debug closes this gap.

  2. Diagnostic representation ≠ user-facing display. Default Debug output is memberwise (TypeName { field1: value, field2: value }) — matches Rust #[derive(Debug)]. Display can be user-friendly (Point(1, 2) или "hello" без escapes).

  3. Pointer integration. *T impls Debug (в unsafe context only) — emits "0x7f8a..->Account". Bare ${p} без :? остаётся E_PTR_NO_DISPLAY_USE_DEBUG_STR (per D216 §17). Only explicit ${p:?} opt-in unlocks debug formatting.

Protocol declaration

#stable(since = "0.1")
export type Debug protocol {
    @debug(mut w Write) -> ()
    // D374 AMEND: parameter renamed sb→w, type StringBuilder→Write (decoupled sink).
    // NO default body в protocol decl. Compiler synthesizes per-type
    // via inject_synthesized_methods (auto_derive.rs) for #impl(Debug) types.
}

Метод name = @debug (НЕ @display) — avoid collision с D183 Display.@display. Distinct method names enable both protocols on same type simultaneously.

Hybrid default body strategy (Plan 91.14 design decision #1, user-confirmed 2026-06-05):

  • Protocol decl ships БЕЗ default body — explicit synthesis через inject_synthesized_methods (auto_derive.rs) для #impl(Debug) типов.
  • Primitives (int/f64/f32/bool/char/str): explicit @debug bodies в std/prelude/protocols.nv; routes через @field.debug(w) в synthesized method bodies.
  • User types (records): #impl(Debug)inject_synthesized_methods appends synthesized Item::Fn to AST → codegen sees it as ordinary method.
  • Option[T Debug] и Result[T Debug, E Debug]: explicit Nova-body в std/prelude/core.nv; codegen dispatches via DeclaredBody в string interpolation path (Plan 91.15 Ф.2).
  • Known limitation: checker does not validate field Debug bounds at #impl(Debug) synthesis time — missing bound produces CC-FAIL, not E_BOUND_MISSING.

Format spec syntax

Inside Nova interp-string ${expr:SPEC}:

  • ${expr} — calls Display.@display (D183, unchanged)
  • ${expr:?} — calls Debug.@debug (NEW)
  • ${expr:foo} — E_FORMAT_SPEC_UNKNOWN (foundation, extensions per [M-91.14-format-dsl-extensions])

Default body synthesis (#impl(Debug))

Когда user type X помечен #impl(Debug):

  1. inject_synthesized_methods (auto_derive.rs) синтезирует fn X @debug(mut w Write) -> () и append’ит в module.items перед codegen.
  2. Body: w.write("X { "); w.write_str("field1: "); @field1.debug(w); w.write(", field2: "); @field2.debug(w); w.write(" }").
  3. Все поля (primitive и record) — через @field.debug(w). Primitives имеют @debug в std/prelude/protocols.nv.
  4. Known limitation: checker не проверяет Debug bounds у полей при синтезе. Отсутствие @debug у поля даёт CC-FAIL, не checker error.

Primitives (int/f64/f32/bool/char/str) — explicit @debug в std/prelude/protocols.nv:

  • int → decimal string (str.from(@))
  • f64/f32 → float string
  • bool → “true”/“false”
  • char → "${@:?}" (quoted with escapes)
  • str → "${@:?}" (quoted with escapes)

Error codes registered

CodeDescriptionTrigger
E_DEBUG_PRINTABLE_NOT_IMPLEMENTEDType doesn’t impl Debug, no auto-synthesis possible${x:?} where typeof(x) lacks impl
E_FORMAT_SPEC_UNKNOWNUnknown format spec${x:foo} (only :? valid in V1)
E_PTR_NO_DISPLAY_USE_DEBUG_STR (preserved)Bare ${p} для pointerunchanged from D216 §17

Cross-refs

  • D183 — Display protocol (sibling, distinct semantics — Display equivalent)
  • D216 §17 — Pointer Debug formatting ((*T).to_debug_str() method superseded by Debug.@debug + ${p:?} syntax; E_PTR_NO_DISPLAY_USE_DEBUG_STR diagnostic preserved для bare ${p})
  • D73 — From/Into protocol pair (orthogonal — conversion vs formatting)
  • Plan 91.14 (this D-block’s home plan)
  • Plan 91.13 — JSON conformance (sibling, just landed)
  • Plan 91.8a.2 — Display infrastructure (foundation, ~80% mechanism reused)

D230 NEW — Clone protocol (Plan 126 Ф.1)

Plan 126 (2026-06-05). New built-in protocol для deep recursive copy пользовательских типов. Аналог Rust Clone trait, completing the gap в D109 family (Equal/Hash/Compare/Display + Clone).

Rationale

  1. Value-record + heap-record нуждаются в structural deep-copy. До Plan 126 пользователь писал boilerplate fn T.copy(other T) -> T => ... per type. После Plan 124.8 (D228 value-record) этот pattern стал универсальным — auto-derive снимает boilerplate burden.

  2. Memberwise recursive semantics. Compiler synthesize’ит body Self { f1: @f1.clone(), f2: @f2.clone(), ... } — каждое поле рекурсивно clone’ится. Primitives (int/f64/bool/char/byte/str/u*/i*) копируются по значению (@field без .clone()); str clone = новая аллокация с тем же содержимым.

  3. Distinct от built-in Hash.@hash / Equal.@equal — clone возвращает Self (not bool / not u64), требует особого synthesizer’а synthesize_clone (Plan 126 Ф.3).

Protocol declaration

#stable(since = "0.1")
export type Clone protocol {
    clone() -> Self
}

Объявлен в std/prelude/protocols.nv (Plan 126 Ф.1, commit c7ff5a319ea).

Auto-derive семантика

Record / NamedTuple (Plan 124.8 D228 / Plan 120 D215):

  • Synthesized body: Self { field1: <clone_expr_1>, field2: <clone_expr_2>, ... }.
  • Per-field clone:
    • Primitive (int/f64/bool/char/byte/str/u*/i*) → shallow copy @field (compiler routes к built-in copy semantics).
    • User type (record/tuple) → recursive .clone() method call.
    • []T → new array с recursive clone элементов (Plan 90.1 deep-copy infrastructure).

Sum-type — V1 placeholder: returns @ (self-reference). Rich match-arms clone (per-variant payload recursion) — followup [M-126-sum-clone-rich].

Field eligibility check (check_field_eligibility):

  • Каждое поле либо primitive (always eligible),
  • Либо имеет #impl(Clone) annotation,
  • Либо имеет explicit fn FieldType @clone() -> FieldType.
  • Иначе → E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL.

Cycle detection (visited set (type, "Clone") pair):

  • type A { b B }; type B { a A } + #impl(Clone) на обоих → E_AUTO_DERIVE_CYCLE (unbounded synthesis non-terminating).
  • User должен переписать как explicit fn A @clone() -> A => ....

Examples

#impl(Clone)
type Vec3 {
    x f64
    y f64
    z f64
}

ro a = Vec3 { x: 1.0, y: 2.0, z: 3.0 }
ro b = a.clone()    // synthesized: Vec3 { x: @x, y: @y, z: @z }
// (primitives → shallow copy, no .clone() recursion).

#impl(Clone)
type Container {
    name str       // primitive — shallow
    items []int    // array — recursive deep-copy
    inner Vec3     // user type — calls Vec3.@clone()
}

ro c = Container { name: "test", items: [1,2,3], inner: a }
ro d = c.clone()   // d.items != c.items (new allocation), но d.items[0] == 1

Manual impl (override default):

fn Vec3 @clone() -> Vec3 =>
    Vec3 { x: @x, y: @y, z: @z }   // explicit, same result

Explicit > auto-derive — user wins resolution в verify_impl_protocols.

Error codes registered

CodeDescriptionTrigger
E_AUTO_DERIVE_CYCLECyclic recursion non-terminatingtype A↔B + #impl(Clone)
E_AUTO_DERIVE_FIELD_LACKS_PROTOCOLField type не impl Clone#impl(Clone) type X { y Y } где Y не Clone
E_AUTO_DERIVE_UNSUPPORTED_KINDNewtype/Alias/Effect/Protocol/Opaque#impl(Clone) type Foo = newtype Bar

Runtime codegen (Plan 126.2 Ф.1/Ф.2)

Plan 126 V1 (2026-06-05) синтезировал @clone body и проверял field eligibility на type-check уровне, но synthesized FnDecl не достигал runtime dispatch — method call a.clone() не находил эмитнутую функцию.

Plan 126.2 (2026-06-06) закрывает этот gap:

  • Ф.1 — method_table registration. Synthesized @clone FnDecl регистрируется в method_table наравне с user-declared методами, так что resolver видит его при разрешении a.clone() / operator dispatch.
  • Ф.2 — C codegen emit. Synthesized body эмитится как C-функция Nova_<T>_method_clone(<T> self) -> <T> с deep-copy семантикой: primitive поля копируются по значению, user-type / array поля рекурсивно через Nova_<FieldType>_method_clone. Recursive .clone() вызовы резолвятся через ту же method_table запись.

Result: a.clone() теперь runtime-dispatched, completing Plan 126 V1 promise. То же codegen применяется к остальным auto-derived методам (@equal/@hash/@compare/@display).

Cross-refs

  • D109 amend — auto-derive rules для всех 5 built-in protocols.
  • D109 method_table dispatch note — Plan 126.2 runtime dispatch via method_table.
  • D186#impl(P) annotation foundation.
  • D183 — Display protocol (sibling, Display semantics).
  • D229 — Debug (sibling, Debug semantics).
  • Plan 126 (this D-block’s home plan).
  • Plan 124.8 — value-record D228 (primary use-case).
  • Plan 120 — NamedTuple D215 (secondary use-case).
  • Plan 90.1 — []T extend/copy_from family (used для array field clone).

D237. Protocol naming convention: method-name capitalized (Plan 137, 2026-06-09)

Status: ACTIVE

Prelude-протоколы именуются по принципу «имя протокола = заглавная форма имени метода». Принцип: [T Hash] означает ровно один метод @hash(); [T Display]@display(). Conversion protocols (From/Into/TryFrom/TryInto) уже следовали принципу. Domain-specific protocols (Consumable, WithExitTimeout) — исключения.

Таблица переименований

Старый протоколНовый протоколСтарый методНовый метод
HashHash@hash()@hash() (неизменён)
CompareCompare@compare()@compare() (неизменён)
CloneClone@clone()@clone() (неизменён)
EqualEqual@equal()@equal()
DisplayDisplay@display()@display()
DebugDebug@debug()@debug()

Диагностика (E_PROTOCOL_RENAMED)

Использование старого имени протокола в #impl(...) или bound [T OldName] производит ошибку E_PROTOCOL_RENAMED с подсказкой использовать новое имя:

error[E_PROTOCOL_RENAMED]: protocol `Hash` was renamed to `Hash`
  --> file.nv:3:12
   |
 3 | #impl(Hash)
   |       ^^^^^^^^ use `Hash` instead

D109 amend (Plan 137)

Протоколы Hash, Equal, Compare, Display, Debug, Clone переименованы в Hash, Equal, Compare, Display, Debug, Clone соответственно. Методы @equal@equal, @display@display, @debug@debug.

D183 amend (Plan 137)

Equal.@equalEqual.@equal; Compare.@compare unchanged. Display.@displayDisplay.@display; default-body synthesis обновлена.

D229 amend (Plan 137)

Debug.@debugDebug.@debug. ${expr:?} по-прежнему маршрутизируется к Debug.@debug.

D230 amend (Plan 137)

CloneClone; метод @clone неизменён. Все auto-derive references обновлены в auto_derive.rs и stdlib.

Cross-refs

  • D109 — built-in protocol family
  • D183 — Display protocol (was Display)
  • D229 — Debug protocol (was Debug)
  • D230 — Clone protocol (was Clone)
  • Plan 137 — home plan

D230 amend (Plan 138.3, 2026-06-10) — Clone = deep/recursive; collections element-wise

Status: ACTIVE

Фиксирует семантический контракт Clone и отделяет его от shallow value-copy. Триггер: ревью Vec.clone (Plan 138.2 design-cleanup) — текущая реализация была shallow (bitwise RawMem.copy), что нарушает протокол-контракт Clone.

Контракт: Clone — DEEP / рекурсивный

@clone() возвращает полностью независимую копию. Мутация клона никогда не должна затрагивать оригинал (и наоборот). Для композитных типов @clone рекурсивно клонирует каждый член через его собственный @clone():

// memberwise рекурсия (auto-derive, см. D230 §Auto-derive выше):
Self { f1: @f1.clone(), f2: @f2.clone(), ... }

Primitive-поля (int/f64/bool/char/byte/u*/i*) копируются по значению (@field без .clone() — для них deep == shallow). str.clone = новая аллокация с тем же содержимым. User-type / []T поля — рекурсивный deep-clone.

Collections (Vec / HashMap / Set) — element-wise deep + conditional bound

Коллекции клонируются поэлементно через @clone() каждого элемента, с conditional bound на параметре типа (зеркало Rust impl<T: Clone> Clone for Vec<T>):

ТипСигнатура @cloneСемантика
Vec[T]Vec[T Clone] @clone() -> Selfper-element out.push(@data[i].clone())
HashMap[K,V]HashMap[K Clone, V Clone] @clone() -> Selfper-entry copy.insert_new(k.clone(), v.clone())
Set[T]Set[T Clone] @clone() -> Selfделегирует @map.clone() (deep HashMap[T, ()])

Bound [T Clone] означает: Vec[T].clone() компилируется только если T сам реализует Clone (примитивы — всегда; records — через #impl(Clone) или manual fn T @clone; см. D230 §Field-eligibility). Это намеренно: коллекция non-Clone-типа не может дать deep-копию.

SHALLOW value-copy — ОТДЕЛЬНАЯ операция (не @clone)

Следующие операции — shallow value-copy: bit-copy значений элементов, любой T (без Clone-bound). Для ref-типовых элементов копия разделяет pointee с источником (это корректно для move/переноса/построения буфера, не для clone):

ОперацияBoundНазначение
Vec.from(items) / @extend / @pushлюбой Tпостроение буфера, move-перенос
@copy_from / @copy_within / @insert / @remove / @appendлюбой Tсдвиг/перенос значений в буфере
@realloc_toлюбой Tрост capacity (перенос байт)
HashMap.from_iter / Set.from_iterлюбой Tпостроение из итератора (per-element insert)

RawMem.copy / RawMem.copy_nonoverlapping (bitwise) — только для этих shallow-контекстов. Никогда не для @clone (bitwise-копия pointer’ов = aliasing-баг: shallow-clone коллекции ref-типов тихо разделяет вложенные объекты, мутация «копии» бьёт оригинал — ровно класс багов, который закрывает этот amend).

Различие deep vs shallow (резюме)

@clone()from/extend/push/copy_from/realloc
Семантикаdeep — рекурсивный клон via element @clone()shallow value-copy (bit-copy)
Bound[T Clone] (conditional)любой T
ref-T элементынезависимый pointee (рекурсия)разделяет pointee
Реализацияper-element .clone() loopRawMem.copy / per-element push
Назначениенезависимая копияmove / перенос / построение

KNOWN GAP — ✅ ЗАКРЫТ (Plan 138.4 Ф.1, 2026-06-11)

UPDATE (2026-06-11): этот gap ЗАКРЫТ — impl догнал prose. Deep element-wise clone для Vec/HashMap/Set теперь РЕАЛИЗОВАН и GREEN (Plan 138.4 Ф.1 G-C, commits 88432dd6f02 + 363f4b53788). Блокер [M-138.3-clone-bound-unsupported] CLOSED. ROOT CAUSE отличался от гипотезы ниже: НЕ «монформизатор мис-диспатчит», а single-key last-wins method_receivers["clone"] instance-fallback (lookup ТОЛЬКО по имени метода, игнорируя receiver-тип) роутил unbound primitive-T .clone() в произвольный неродственный @clone. FIX: PrimBuiltin::Identity variant — .clone() на любом primitive-C-типе = bitwise self (вариант (a)/(c) рекомендации ниже) + record/heap identity-clone arm + зеркало в infer_expr_c_type. Реальный user/synthesized @clone сохраняет precedence. Текст ниже — историческое описание gap’а до фикса.

Контракт выше — целевой. Деталь auto-derive для records (memberwise field.clone() recursion) РЕАЛИЗОВАНА и работает (#impl(Clone) — plan126/plan126_2 p3_cloneable_runtime_ok + p7_nested_record_clone_deep_ok PASS; record @clone() корректно эмитит рекурсию по полям).

(ИСТОРИЧЕСКОЕ, до 138.4) deep element-wise clone для коллекций (Vec/HashMap/Set) НЕ был реализован — все три были shallow (@clone() с bound «любой T», без [T Clone]): bit-copy / per-(k,v) value-copy. Блокер — [M-138.3-clone-bound-unsupported]: bootstrap-монформизатор мис-диспатчил per-element generic T.@clone() / K.@clone() / V.@clone() для примитивного T/K/V (нет int.@clone() / str.@clone() — примитивы copy-built-in per этому D), резолвя unbound generic .clone() в произвольный неродственный @clone. Эмпирика: deep Vec[int].clone() → runtime crash + регрессия plan131/vec_clone_pos; deep HashMap[str,int].clone() → CC-FAIL passing 'nova_str' to parameter of incompatible type. Bound [T Clone] сам по себе парсится и type-check’ается (R1 Plan 138.3 — подтверждено), и для record element-типов emit корректен (Vec[Point].clone() даёт верную Point.@clone() рекурсию); сломан был только primitive-T dispatch.

Следствие для bound-audit (G5): так как collection-clone оставались shallow (@clone() любой T, без T: Clone-требования), нового нарушения bound на call-site’ах не было — deep-направление, требующее T: Clone, удерживалось. Для примитивных элементов shallow == deep (нет разделяемого pointee), для record элементов расхождение было gap’ом. После 138.4 deep-форма с [T Clone] активна — collection-clone теперь требует T: Clone, recursion корректна для record-элементов.

Cross-refs

  • Q31 — conditional [T Clone] bound на generic instance-методе (design clarification)
  • Plan 138.3 — home plan (deep-clone collections)
  • [M-138.3-clone-bound-unsupported] — primitive-T generic .clone() мис-диспатч; блокирует deep collection-clone
  • [M-138-rawmem-bulk-ops] — clone→RawMem.copy был shallow-баг; этот amend фиксирует контракт
  • Plan 90.1 — []T deep-copy infrastructure (used для array-field clone)

D231. RawMem allocator API — nova_alloc / nova_alloc_uncollectable / nova_free_uncollectable

Status: ACTIVE (Plan 131, 2026-06-08)

Low-level GC-tracked allocation exposed to Nova for implementing Nova-native data structures (Vec[T], custom allocators, FFI).

Что

Three extern C functions wrapped as RawMem static methods in std/runtime/raw_mem.nv, all gated behind unsafe fn (E_UNSAFE_CALL_REQUIRES_WRAP per D216 §9).

API

// GC-tracked allocation. Memory zeroed. 8-byte aligned.
// Must be called inside unsafe {} block.
export external unsafe fn RawMem.alloc(n usize) -> *mut u8

// Not GC-tracked. Caller must call RawMem.free_uncollectable.
export external unsafe fn RawMem.alloc_uncollectable(n usize) -> *mut u8

// Free pointer from alloc_uncollectable. UB on GC-tracked pointer.
export external unsafe fn RawMem.free_uncollectable(ptr *mut u8) -> ()

C mapping

NovaC (nova_rt/alloc.h)
RawMem.alloc(n)nova_alloc(n)
RawMem.alloc_uncollectable(n)nova_alloc_uncollectable(n)
RawMem.free_uncollectable(ptr)nova_free_uncollectable(ptr)

Safety rules

  1. nova_alloc returns zeroed, GC-collectable, 8-byte aligned memory.
  2. Do NOT call nova_free on GC-tracked pointers — Boehm GC handles collection.
  3. alloc_uncollectable for long-lived buffers not visible to the conservative GC scanner (e.g. Windows fiber arena buffers where fiber stacks shadow heap).
  4. Every call must be inside unsafe {}unsafe fn keyword enforced (E_UNSAFE_CALL_REQUIRES_WRAP from D216 §9, ACTIVE 2026-06-02).
  5. n = 0 is implementation-defined; use n > 0 in practice.

Типичный use case

// Allocate a typed buffer of n elements of T:
fn alloc_buf[T](n int) -> *mut T {
    unsafe {
        RawMem.alloc((n as usize) * (size_of[T]() as usize)) as *mut T
    }
}

Cross-refs

  • D216 §8 — unsafe gating model.
  • D216 §6 — pointer arithmetic (ptr + N scaled by sizeof(T)).
  • D232 — Vec[T] built on D231.
  • std/runtime/raw_mem.nv — implementation.

D232. Vec[T] — Nova-native generic growable array

Status: ACTIVE (Plan 131, 2026-06-08)

A production-grade growable array implemented entirely in Nova on top of D231 (RawMem.alloc), D199 (size_ofT), and D216 pointer arithmetic. Demonstrates that a collection with correct typed storage needs no compiler magic beyond what Plans 118/114.4 provide.

Layout

export type Vec[T] {
    priv mut data *mut T   // raw element buffer, cap slots wide (writable pointee — explicit *mut T, D246)
    priv mut len  int      // number of live (initialised) elements
    priv mut cap  int      // number of allocated element slots
}

Pointer model note (Plan 147 D246, 2026-06-12; supersedes flip-scan-draft): priv mut data *mut T — writable buffer требует явного *mut T (L3 pointee-mut из типа). mut data (L1) даёт лишь reassignability поля; pointee-mut НЕ наследуется от mut-binding. Прежний flip-scan-вариант mut data *T давал бы ro-pointee (*T ≡ *ro T) → запись @data[i] = … была бы запрещена. См. D246 P5.

Construction

CallEffect
Vec[T].new()empty, no allocation (cap = 0)
Vec[T].with_capacity(n)empty, pre-allocated n slots
Vec[T].from(items []T)copy from built-in slice
Vec[T].from_raw_parts(ptr *T, len, cap)build from a raw (ptr,len,cap) triple (cross-type bridge, unsafe-obligated; D247)

Key methods

MethodSignatureDescription
pushmut @push(v T) -> ()Append element, grow if needed
popmut @pop() -> Option[T]Remove and return last
get@get(i int) -> Option[T]Element by index, bounds-checked
get_mutmut @get_mut(i int) -> Option[*mut T]Raw mutable pointer (unsafe)
insertmut @insert(i int, v T) -> ()Shift-insert at index
removemut @remove(i int) -> TShift-remove at index
swap_removemut @swap_remove(i int) -> TO(1) order-disrupting remove
len@len() -> intLive element count
cap@cap() -> intAllocated slot count
is_empty@is_empty() -> boolTrue if len = 0
clearmut @clear() -> ()Set len = 0 (retains buffer)
truncatemut @truncate(n int) -> ()Shorten to n elements
reservemut @reserve(additional int) -> ()Ensure room for more elements
shrink_to_fitmut @shrink_to_fit() -> ()Cap → len
shrink_tomut @shrink_to(min_cap int) -> ()Cap → max(len, min_cap)
reversemut @reverse() -> ()Reverse in place
extendmut @extend(items []T) -> ()Append all from slice
appendmut @append(mut other Vec[T]) -> ()Move all from other
retainmut @retain(pred fn(T) -> bool) -> ()Keep matching elements
first@first() -> Option[T]First element
last@last() -> Option[T]Last element
as_slice@as_slice() -> []TCopy into built-in slice
as_ptr@as_ptr() -> *T / mut @as_ptr() -> *mut TRaw data-buffer pointer; recv-mut overload yields writable *mut T (cross-type bridge getter, D247)
into_rawconsume @into_raw() -> *mut TConsume the Vec, surrender its buffer pointer (inverse of from_raw_parts; powers zero-copy str.from_bytes_unchecked_steal, D247)
iter@iter() -> VecIter[T]Index-cursor iterator
clone@clone() -> Vec[T]Deep copy (Clone)
equals@equal(other Vec[T]) -> boolElement-wise equality
fmt@display(mut sb StringBuilder) -> ()Display (format: Vec[e0, e1, ...])
debug_fmt@debug(mut sb StringBuilder) -> ()Debug

Growth strategy

  • Initial capacity: 8 slots on first push into an empty Vec.
  • Doubling: new_cap = current_cap * 2 until new_cap >= needed.
  • Amortised O(1) push. Realloc copies live prefix via typed pointer loop.

Typed storage (key property)

Elements are stored at the real C type of T in a T* buffer. Pointer arithmetic data + i is C-scaled by sizeof(T) automatically by the C backend. This means:

  • Vec[Option[int]] stores each NovaOpt_nova_int struct inline (16 bytes/element).
  • Vec[MyRecord] stores record pointers (Nova_MyRecord* 8 bytes/element).
  • No int64-slot erasure — pre-D239 []T built-in used NOVA_ARRAY_DECL(T) macro with int64 element slots; after D239 []T ≡ Vec[T] so this gap is closed.

[]T = Vec[T] — D239

[]Tсинтаксический псевдоним Vec[T] (D239). Typed-storage gap закрыт: элементы хранятся по реальному C-типу T в буфере T*. Никакого int64-erasure.

Criterion[]T / Vec[T] (unified, D239)
Default choice✅ yes
Primitives (int, f64, str, …)✅ typed
Records (heap pointer)✅ pointer-in-slot
Value-struct T (Option[U], tuple, >8-byte value-record)✅ typed (gap closed)
for x in loop✅ via VecIter
Compiler magic neededno (pure Nova)

[]T и Vec[T] взаимозаменяемы: fn f(a []int) принимает Vec[int] и наоборот. Лексически предпочтительна краткая форма []T; Vec[T] используется там где явность важна (generic bounds, type params).

Note (Plan 138.2): в единицах компиляции без явного Vec-импорта компилятор временно использует NovaArray_T C-backing для примитивных типов ([]int, []str, []u8). После того как Vec войдёт в prelude (Plan 138.2 [M-138.1-vec-in-prelude]), NovaArray будет удалён полностью. Поведение идентично — layout { data*, len, cap } совпадает.

Protocols implemented

  • Index[int, T] (via @index(i int) -> T, panic OOB) — powers v[i] (D238)
  • Index[Range, Vec[T]] (via @index(r Range) -> Vec[T]) — powers v[a..b] zero-copy (D238)
  • MutIndex[int, T] (via mut @index(i int, val T)) — powers v[i] = val (D240)
  • Iter[VecIter[T]] (via @iter()) + Next[T] на VecIter[T] (via @next())
  • Equal (element-wise via @equal)
  • Clone (deep copy via @clone)
  • Display (via @display)
  • Debug (via @debug)

Cross-refs

  • D239[]T = Vec[T]; этот D232-тип является backing-типом []T.
  • D238Index[K,V] protocol; v[i] и v[a..b] через @index.
  • D240MutIndex[K,V]; v[i] = val через mut @index.
  • D231 — allocator used by Vec[T].
  • D199size_of[T]() const fn used in buffer size calc.
  • D216 §6 — ptr arithmetic (data + i C-scaled).
  • D226int for len/cap/index.
  • D228 — value-records are valid T (stored by value in buffer).
  • D247from_raw_parts/as_ptr/into_raw cross-type bridge (Plan 139.2 str-method migration).
  • std/collections/vec_owned.nv — implementation.
  • Plan 131 — home plan.

D239. []T — синтаксический псевдоним Vec[T]

Status: ACTIVE (Plan 138, 2026-06-10)

Added: Plan 138 Ф.1–Ф.4 (D239 NEW, 2026-06-10). Closes: D144 «future language version» wording; amends D27 (arr[i] now through @index). Depends on: D232 (Vec[T] implementation); D238 (Index protocol).

Что

[]Tсинтаксический псевдоним Vec[T]. Компилятор разворачивает любое использование []T в Vec[T] на уровне type-resolution.

// Эти объявления полностью эквивалентны:
mut a []int = [1, 2, 3]
mut b Vec[int] = [1, 2, 3]

// Передача по типу:
fn process(xs []int) { ... }
mut v Vec[int] = [4, 5, 6]
process(v)                // OK — []int = Vec[int]

// Литерал строит Vec[T]:
[1, 2, 3]  →  Vec[int] { push 1, push 2, push 3 }

Typed storage — gap закрыт

До D239 []T использовал NOVA_ARRAY_DECL(T) C-макрос с int64-slot erasure. Vec[T] хранит элементы по реальному C-типу T в буфере *mut T:

Элемент T[]T до D239Vec[T] / []T после D239
int, f64, bool✅ типизировано✅ типизировано
str, указатель на record✅ pointer-in-slot✅ pointer-in-slot
Option[int] (16 байт)❌ int64-erasure → UBNovaOpt_nova_int inline
(int, str) tuple❌ int64-erasure✅ typed tuple inline
value-record > 8 байт❌ int64-erasure✅ typed by-pointer

Индексация через @index (D238)

После D239 v[i] и v[a..b] работают через Index[K, V] protocol (D238), а не через compiler built-in magic:

mut v []int = [10, 20, 30]
v[1]       // → v.index(1)      → 20 (panic OOB)
v.get(1)   // → Some(20)        (safe)
v[0..2]    // → v.index(Range { start: 0, end: 2 })  // [10, 20] zero-copy view
v[1] = 99  // → v.@index(1, 99)   write-overload через MutIndex (D240)

Статус реализации

Plan 138 Ф.1–Ф.4 (2026-06-10): []T → Vec[T] flip активен в единицах компиляции, которые импортируют Vec (явно или транзитивно). Примитивные единицы без Vec-импорта продолжают временно использовать NovaArray_T C-backing с идентичным layout.

Plan 138.2 Ф.0-final (2026-06-11): Vec/VecIter вошли в prelude (PRELUDE_VERSION 13→14) → flip УНИВЕРСАЛЕН: []T лоуэрится в Nova_Vec____<T>* в КАЖДОМ prelude-юните (Vec-free юнит []intNova_Vec____nova_int*, typed storage). Юниты с #no_prelude graceful-degrade на legacy NovaArray_T-путь (4 gate-сайта generic_type_templates.contains_key("Vec") в emit_c.rs остаются — проходят для prelude-юнитов, fallback для #no_prelude).

Plan 153.0 CONFIRM (2026-06-13): Vec[T] переехал в folder-module std/collections/vec/ (co-equal core/access/mutate/slice/iter/ protocols + _module.nv), модуль collections.vec_owned ретайрнут (имя исчезло; ~55 import-сайтов мигрированы vec_ownedvec; prelude re-export’ит Vec/VecIter из folder). Eager-комбинаторы (map/filter/fold/any/ all) вынесены в отдельный explicit-import collections.vec_seq (НЕ prelude-global — иначе их generic/param-идентификаторы засоряют каждый юнит, [M-153-vec-combinators-prelude-global]). []T ≡ Vec[T] подтверждён для инферированных значений; ОСТАЁТСЯ residual: ЯВНАЯ аннотация v Vec[int] не коэрсится в []int-параметр (E7301, [M-153-d239-explicit-vec-to-slice-param]). См. docs/vec-internals.md.

NovaArray retirement — частичный (BLOCKED на Plan 139 Ф.2)

Status (Plan 138.2 Ф.2-Ф.5, 2026-06-11): ЧАСТИЧНО — полный retire BLOCKED. Универсальный flip (Ф.0-final) приземлён, но физическое удаление NOVA_ARRAY_DECL/IMPL из array.h заблокировано пятью load-bearing NovaArray-потребителями, которые переживают flip:

  1. Строковый/byte слой (главный блокер — Plan 139 Ф.2 scope-out). nova_str_as_bytesNovaArray_nova_byte*, nova_str_splitNovaArray_nova_str*, from_bytes_lossy/from_bytes_unchecked/steal остаются C-примитивами (требуют str @ptr field-access value-record’а — gated на [M-139-f0-lang-item-decl]). Эмпирически: nova_byte-NovaArray = ~35 700 вхождений по корпусу, nova_str = ~2 100, nova_char = ~1 200 (to_chars). WriteBuffer/StringBuilder bulk-ops (nova_array_append_nova_byte ~3 300, compare/append_zero/truncate/reserve на nova_byte) маршрутят через C-builtin bridge. Удалить NOVA_ARRAY_DECL(nova_byte/nova_str) = сломать base64/json/encoding/text. Risk RG.[M-139-f2-ptr-field-producers].
  2. #no_prelude graceful-degrade. #no_prelude-юниты (tested feature: plan107/plan62/plan110_9_np) лоуэрят []T в NovaArray_T (Vec-template отсутствует). 4 gate-сайта contains_key("Vec") это и обеспечивают. Risk RE.
  3. Closure-array []fnNovaArray_void_p* (намеренное исключение, [M-138.1-closure-array] / [M-138.2-closure-array-vec]).
  4. parfor (D71) — internal result-collection буферы (NovaArray_{nova_int, nova_bool,nova_f64,nova_str}), layout-identical с Vec, никогда не escape’ят как user []T; миграция = риск без семантического выигрыша → [M-138.2-parfor-vec].
  5. Literal-bridge Vec[T].from(items []T) — static-method param []T всё ещё лоуэрится в NovaArray_nova_int* items (dead stub в каждом flipped-юните, класс [M-138.2-self-in-param] — generic-static-method param-type substitution).

После того как Plan 139 Ф.2 мигрирует string/byte слой на Nova_Vec____nova_byte* (закрытие [M-139-f2-ptr-field-producers]), retirement можно завершить:

  • NovaArray_T C-макрос и array.h NOVA_ARRAY_DECL/IMPL — удаляются
  • nova_array_* runtime helpers для примитивов — заменяются Vec-методами (уже сделано для generic-Vec пути; остаётся nova_byte string-layer bridge)
  • Строковый слой (nova_str_to_bytes, nova_str_to_chars, split, string_builder.h) — мигрирует на Nova_Vec____nova_byte*

§2. Scalar min/max методы на числовых типах (D239 amend, 2026-06-16)

Added: [M-153-scalar-min-max] CLOSED (2026-06-16). Скалярные методы сравнения нужны для shrink-to-min идиомы (Vec capacity management) и @min/@max-терминаторов lazy-итератора (D260). Реализованы в std/runtime/defaults.nv.

// Все числовые типы: int / u8 u16 u32 u64 uint / i8 i16 i32 i64 / f32 f64
(5).max(3)    // → 5
(5).min(3)    // → 3
(10).min(20).min(5)  // → 5  (chaining)

// Сигнатура (int; аналогично для u8…f64 с Self):
fn int @min(other int) -> int
fn int @max(other int) -> int

Семантика: равенство → возвращается @ (левый операнд). Для f32/f64 не используются C-макросы fmin/fmax (нет специальной NaN-семантики — NaN не меньше и не больше ничего, результат детерминирован через </>). Не протокол — встроенные методы-примитивы, как @compare.

Связь

  • D232Vec[T] — backing тип для []T
  • D238Index[K,V]; v[i] и v[a..b] через @index
  • D240MutIndex[K,V]; v[i] = val через mut @index
  • D144 — sub-slice views; D144 «future language version» снято D239
  • D27[]T синтаксис сохраняется; семантика меняется: []T ≡ Vec[T]
  • Plan 138 — полный план миграции; Plan 138.2 — NovaArray retirement

Amended (vec-sweep, 2026-07-06): []T — КАНОНИЧЕСКАЯ запись во всех контекстах: аннотации типов, возвраты, вложенные формы ([][]u8), кортежи ([](str, str)), статические вызовы-конструкторы ([]u8.new(), []int.of(1,2,3), []int.from(...)). Vec[...] остаётся ТОЛЬКО definition-site — внутри самого модуля std/collections/vec/ (реализация Vec[T]). Везде за пределами этого модуля предпочтителен []T.

Исключение — известный compiler-gap [M-153.x-array-new-not-vec]: bracket- spelling []T в VALUE-позиции (конструктор-вызов, напр. []T.new()) иногда лоуэрится через legacy int64-erased NovaArray-путь, а НЕ через типизированный Vec-путь, что при определённых сочетаниях (см. std/collections/hashmap.nv::new_buckets) даёт RUN-FAIL. В таких точечных местах Vec[...] остаётся исключением из канона с явным маркером в коде.


D248. Запрет chained comparison + relational-операнды требуют ordered-категорию (Plan 150)

Status: 🆕 SPEC LANDED 2026-06-13 (Plan 150 Ф.0). Реализация — Plan 150 Ф.1 (parser + checker). Резолвит Q35. Решение автора: hard-error (как Rust); chained comparison НЕ добавляем (только Python чейнит). Разблокирует [M-140-bounds-as-contract].

Проблема (security-дефект)

0 <= i < n парсится как (0 <= i) < n = bool < n. Так как bool ∈ {0,1}, выражение вакуумно-истинно для любого n > 1 — предикат нейтрализуется молча. Следствие: канонический bounds-контракт requires 0 <= i < @len молча вакуумен → проверка границ тихо обходится. Nova была хуже всех peers (даже untyped JS коэрсит в детерминированно-неверный результат; Nova аннулирует предикат).

Решение

  1. Chained comparison ОТКЛОНЁН (hard error). Цепочка из ≥2 relational/equality операторов одного precedence-уровня (a OP1 b OP2 c, где OP ∈ {< <= > >= == !=}) → E_CMP_CHAIN_UNSUPPORTED с machine-applicable fix-it «a OP1 b && b OP2 c». Применяется даже к ordered-типам (1 < 2 < 3 — тоже ошибка) и к equality-цепочкам (a == b == c). Rationale: только Python чейнит; Go/Rust/Kotlin/Java/Swift — hard-error. && явно, без нового синтаксиса/парсер-сложности.

  2. Relational-операнды требуют ordered-категорию. < / <= / > / >= требуют mutually-ordered тип (int/float/str/char или @compare-несущий тип). bool / unit как операнд → E_RELATIONAL_OPERAND_NOT_ORDERED. ptr-relational → E_PTR_ARITHMETIC_BANNED (existing, Plan 115 — см. ptr type-checker rules). == / != на bool/unitостаются легальны (под баном только relational). Консистентно с дизайном: bool ordering уже method-only через @compare (D183, std/runtime/defaults.nv), а не через оператор <.

Канон

  • Диапазон: a <= b && b < c. requires 0 <= i && i < @lenреальный (не вакуумный) bounds-контракт.
  • Permissive-на-Unknown/generic-категориях сохраняется (чекер фейлит только на definitively-known bool/unit
    • concrete cross-category mismatch — не ломать generics).

Диагностические коды

  • E_CMP_CHAIN_UNSUPPORTED — ≥2 сравнения в цепочке (a < b < c, 0 <= i < n, a == b == c); сообщение «comparison operators cannot be chained» + fix-it «split into a OP1 b && b OP2 c». Эмитится в parser.
  • E_RELATIONAL_OPERAND_NOT_ORDEREDbool/unit как операнд </<=/>/>=. Эмитится в checker.

Связь

  • D183 — comparison-протоколы (@compare/@equal); ordered-категория = @compare-несущие типы.
  • Plan 115 / E_PTR_ARITHMETIC_BANNED — ptr relational ban (reuse, без дублирования).
  • Q35 — резолвит. [M-140-bounds-as-contract] — разблокирует (requires 0 <= i && i < @len).
  • Отложено (сознательно): Python-style chaining (a<b<ca<b && b<c) — НЕ добавляем; если будет спрос — отдельное предложение в будущем.

D374. Write-sink протокол — декаплинг Display/Debug от StringBuilder (Plan 152.7.1)

Status: CLOSED 2026-06-16 (Plan 152.7.1, commits a313926b + 3d0e30fa). Depends on: D183 (Display/Debug протоколы), D229 (Debug протокол), Plan 137 (protocol naming convention). Amends: D183 (@display sig), D229 (@debug sig). Breaking: да.

Решение

Вводится тонкий sink-протокол Write, от которого зависят Display и Debug:

protocol Write {
    mut @write_str(s str) -> ()
}

protocol Display {
    @display(mut w Write) -> ()
}

protocol Debug {
    @debug(mut w Write) -> ()
}

StringBuilder реализует Write через @write_str, делегируя в @buf.append(s):

fn StringBuilder @write_str(s str) -> () {
    @buf.append(s)
}

Кодогенерация (статическая мономорфизация)

Write в C-кодогене статически мономорфизируется в Nova_StringBuilder* — vtable отсутствует, нулевая overhead. Интерполяция "${x}" всегда монтирует StringBuilder как единственный sink-тип на данном этапе.

  • type_ref_to_c: "Write""Nova_StringBuilder*"
  • extract_protocol_type_name: bypass для "Write" → не эмитит E7201

Мигрированные реализации

Все встроенные @display/@debug impl переведены с sb StringBuilder на w Write: int, f64, f32, bool, char, strstd/prelude/protocols.nv), Vec[T]std/collections/vec/protocols.nv), auto-derive синтезируемые методы.

Прецеденты

  • Rust: fmt::Write (write_str), Display::fmt(&self, f: &mut Formatter) — не в String.
  • Go: io.Writer (Write([]byte)) — Fprintf/Fprintln принимают io.Writer.
  • Java: Appendable (append(CharSequence)) — базовый sink для StringBuilder/Writer.

Связь

  • D183Display протокол (amended: sbw Write).
  • D229 — Debug протокол (amended: sbw Write).
  • Plan 152.7.1 — план; Plan 137 — источник Display/Debug; StringBuilder — единственный concrete-sink в codegen.
  • [M-152.7-write-sink] — CLOSED 2026-06-16.

D259. Конструктор-конвенция Vec[T]of для литерала, from для конверсии (Plan 153.1)

Status: ACTIVE (Plan 153.1, формализована 2026-06-14). Depends on: D232 (Vec[T]), D239 ([]T ≡ Vec[T]).

AMEND (2026-07-06, of-guard) — пустой of() запрещён контрактом. Я закрываю footgun, который ниже сам же и называю «идиоматичнее new(), но можно»: пустой вызов вариадика Vec[T].of() (0 аргументов) теперь запрещён контрактом requires args.len() > 0 на Vec[T].of (std/collections/vec/core.nv). Пустой вектор строится ТОЛЬКО через Vec[T].new() — двух равнозначных путей для одного и того же значения больше нет. Нарушение — runtime-panic (requires failed: args.len() > 0); фикстура — std/collections/vec/neg/vec_of_empty_panic.nv. Мигрированы все прежние вызовы .of() без аргументов на .new() (std/http/server/server.nv, std/http/server/wire.nv, nova_tests/http_decompress/decompress_test.nv); тесты, нарочно проверявшие легальность пустого of() (spec_tests/conformance/d259_vec_of_vs_from.nv, nova_tests/plan153_0/variadic_of.nv), приведены в соответствие. Правило «КАНОН» ниже устарело в части Vec[int].of() — актуальный текст только в этом амендменте.

Что

Два конструктора Vec[T] несут разные роли, и их нельзя путать:

  • Vec[T].of(a, b, c) (вариадик) — построить вектор из литерального списка элементов. Аналог Rust vec![a, b, c].
  • Vec[T].from(coll) (from(items []T)) — конвертировать существующую коллекцию / слайс в новый Vec. Аналог Rust Vec::from(iter) — это clone-подобная копия.

Правило

// КАНОН
Vec[int].of(1, 2, 3)          // литерал элементов → of (1 аллокация)
Vec[int].new()                // пустой вектор — ЕДИНСТВЕННЫЙ путь (см. AMEND выше)
Vec[int].from(existing_vec)   // конверсия существующей коллекции → from
Vec[u8].of(1, 2, 3)           // of сужает так же, как from (args []T)

// АНТИ-ПАТТЕРН
Vec[int].of()                 // ❌ contract violation (AMEND 2026-07-06): requires
                              //    args.len() > 0 — пустой of() запрещён, → new()
Vec[int].from([1, 2, 3])      // ❌ избыточно: под D239 литерал [1,2,3] УЖЕ Vec[int],
                              //    поэтому from его КОПИРУЕТ во второй буфер (2 аллокации).
                              //    `of(1,2,3)` берёт элементы напрямую (1 аллокация).
Vec[int].from([])             // ❌ → Vec[int].new()
  • from([литерал]) — избыточный clone. Под D239 массив-литерал [1, 2, 3] сам по себе уже Vec[int] (одна аллокация в точке литерала). from копирует его во второй буфер ⇒ две аллокации ради того же результата, что of(1, 2, 3) даёт за одну. Поэтому from([литерал]) — анти-паттерн; doc-comment fromstd/collections/vec/core.nv) направляет на of.
  • from — ТОЛЬКО для конверсии существующей коллекции (from(переменная) / from(другой_слайс)). Это легитимный clone-подобный путь и НЕ анти-паттерн.
  • Когда тип выводится из контекста — просто [a, b, c] (литерал = Vec[T]), без of/from. of/from нужны лишь для inline-указания типа (return-position, generic-контекст, когда контекст не выводит элементный тип).

Почему

Cost-transparency (D135): идиома, которая выглядит как «построить вектор из этих элементов», должна стоить ровно одну аллокацию. of(...) это даёт; from([...]) — скрыто удваивает. Разделение ролей убирает footgun и читается как Rust (vec![] vs Vec::from(iter)), Kotlin (listOf vs toList), Swift ([a,b] vs Array(seq)).

Связь

  • D239[]T ≡ Vec[T]; именно поэтому литерал уже вектор и from([литерал]) избыточен.
  • D232Vec[T]-тип и его конструкторы.
  • Миграция тестов/stdlib from([литерал])of(...) — Plan 153.1 sweep ([M-153.1-of-vs-from-sweep]).

D260. Ленивый итератор Vec[T] — boxed-fluent адаптеры (Plan 153.2)

AMEND (2026-07-06, решение владельца): терминатор @nth(n) РЕТРАКТИРОВАН (вместе с CharsIter @nth) — тождествен skip(n).next() и провоцирует индексные привычки на итераторах (скрытый O(n)-«индекс», в циклах O(n²)). Канонический общий набор адаптеров: skip/take/step_by/enumerate/filter/chain + терминаторы count/collect/ fold/find/… Миграция ~57 мест — волной [M-d73-d77-retraction-migration] (переписывание на целевую итерацию, НЕ механику skip(n).next()).

Status: ACTIVE (Plan 153.2 Phase A, 2026-06-14). Amended by D277 (2026-06-15): BoxIter[T] помечен value → wrapper-рекорд лоуэрится by-value (0 heap-аллокаций обёртки, Stage 1); добавлен allocation-free generic-over-source sibling collections.vec_iter_zc (Stage 2). Depends on: D232 (Vec[T]), D239 ([]T ≡ Vec[T]), D58 (Iter/NextVecIter). Закрывает: Q-iterator-laziness, Q-iter-mut (Phase A).

Решение

Ленивый итератор Vec[T] реализован по boxed-fluent-модели. Канон лени — этос cost-transparency (D135): цепочка v.lazy().map(f).filter(p).collect() не делает промежуточных аллокаций; каждый адаптер оборачивает upstream-step-замыкание и тянет по одному элементу на запрос; цепочку приводит в движение только терминатор.

type BoxIter[T] { priv step fn() -> Option[T] }      // boxed-курсор
fn Vec[T] @lazy() -> BoxIter[T]                       // вход (мост VecIter→BoxIter)
fn BoxIter[T] @map[U](f fn(T) -> U) -> BoxIter[U]     // адаптер → новый BoxIter
fn BoxIter[T] mut @collect() -> Vec[T]               // терминатор драйвит цепочку
  • BoxIter[T] держит единственный step-thunk: Some(x) (следующий элемент) / None (исчерпан). Адаптеры строят новые BoxIter обёрткой step; ничего не выполняется, пока терминатор не потянет.
  • Вход v.lazy() мостит VecIter[T]BoxIter[T] (free-fn box_iter[T], захватывающий курсор). []T тождественно Vec[T] (D239) → lazy() есть и на слайсе.
  • Адаптеры (lazy, возвращают новый BoxIter, без аллокации): map/filter/ filter_map/enumerate/take/skip (Phase A). Каждый копирует receiver (mut src = @) в свежее захватывающее замыкание → цепочка реентерабельна на терминатор-вызов и не мутирует BoxIter вызывающего, пока терминатор её не сдренит.
  • Терминаторы (драйвят/коротят): collect/fold/reduce/count/sum(zero T)/ any/all/find/for_each/min/max/last (Phase A; nth — ретрактирован, см. AMEND выше). min/max — на [T Compare]; sum(zero T) — аддитивная идентичность вместо числового протокола.

Модульное размещение

BoxIter/адаптеры/терминаторы — в sibling FILE-модуле std/collections/vec_lazy.nv (module collections.vec_lazy), доступном через import std.collections.vec_lazy, НЕ внутри prelude folder-модуля collections.vec. Причина та же, что у eager vec_seq (D239 status-note): prelude-global generic-type-метод с CLOSURE-телом утекает свои method-level generics ([U]/[Acc]) и callback-параметры (f/pred) в merged-body КАЖДОГО юнита → коллизия с top-level fn f/type Acc ([M-codegen-var-types-fn-scope] + D145). Адаптеры closure-dense → opt-in import.

Eager vec_seq сосуществует

Eager collections.vec_seq (v.map(f) -> new Vec, материализует каждый шаг) оставлен без изменений как переходный eager-surface. Lazy — канонический allocation-free путь (Q-iterator-laziness); оба за раздельными explicit-import (eager НЕ переписан в сахар над lazy, чтобы не навязывать lazy-import eager-пользователям).

Codegen-инварианты (обязательны для мономорфизации closure-несущих методов)

Реализация потребовала фиксов в compiler-codegen/src/codegen/emit_c.rs (без них — silent CC-FAIL / drain-0 / segfault), зафиксированных как контракт:

  1. mut-capture box-реестр (var_boxed) флашится per-test (emit_test) — box _box_<name> не должен утекать между C-функциями тестов.
  2. Stmt::Return эмитит значение с типом возврата функции как target — голый return None в mono-замыкании резолвится в NovaOpt_<mono>, не erased NovaOpt_nova_int.
  3. infer_expr_c_type регистрирует generic-инстанс типа-возврата, когда generic free-fn ИЛИ метод generic-типа возвращает generic-инстанс (box_vec[int](it) -> BoxIter[int], Vec[T] @lazy() -> BoxIter[T]) — иначе .method() на временном промахивается мимо generic-instance dispatch-path (block 5b) и попадает в erased NULL-stub.

(Лифт mono×closures — register_generic_instances_in_typeref + closure-capture в loop-arms, commit 996ca01a.)

Phase B — ✅ ЗАКРЫТА (2026-06-16, амендмент D260)

Критерии приёмки Phase B (все выполнены):

  • G-B1. zip возвращает правильные пары; останавливается на более коротком операнде.
  • G-B2. flat_map корректно обрабатывает пустой внешний итератор, пустые внутренние итераторы и смешанные пустые/непустые внутренние.
  • G-B3. Все адаптеры компилируются через C-codegen без CC-FAIL — без упрощений как для прода.
  • G-B4. 0 новых регрессий по blast-radius (plan153_0/138/139/147/165/91_12).

Реализовано:

  • step_by(n int) (BoxIter, vec_lazy.nv) + zero-cost StepByIter[I,T] (vec_iter.nv): yield каждый n-й элемент. Contract n > 0 (requires). Тест: plan153_2/phase_b_lazy + plan153_2_zc/step_by_zc.
  • chain(other BoxIter[T]) (BoxIter): дренирует self, затем other. Тест: plan153_2/phase_b_lazy.
  • zip(other BoxIter[B]) (BoxIter): возвращает BoxIter[(A,B)], останавливается когда любой из операндов исчерпан. Codegen-фикс: receiver typevar alias A (fn BoxIter[A] @zip[B]) теперь биндится в type_subst при dispatch — tuple return (A,B) резолвируется в mono _NovaTuple_2_8_nova_int_8_nova_int вместо erased _NovaTuple2. Тесты: plan153_2/zip_basic (9 pos), plan153_2/zip_neg (3 neg), plan153_2/zip_min.
  • flat_map(f fn(T)->BoxIter[U]) (BoxIter): дренирует каждый inner-итератор, возвращённый f. Codegen-фикс: NovaOpt typedef для BoxIter[T] payload (NovaValue_ by-value) теперь эмитируется ПОСЛЕ generic struct body через novaopt_vr_typedefs_buf. Тесты: plan153_2/flat_map_basic (7 pos), plan153_2/flat_map_neg (4 neg).

Остаток Phase B (не реализован): unzip/flatten/scan/inspect/take_while/skip_while/peekable/ min_by[_key]/max_by[_key]/partition/chunk_by/into_iter; мут-итерация for mut x/mut @iter() (Q-iter-mut write-through — отдельный путь).

collect-target FromIterator — ✅ ЗАКРЫТ (Plan 153.6 / D264): @collect()->Vec (default) + @collect_set()->Set (терминаторы) + from/from_iter/@extend (прочие таргеты/источники); статический generic-конструктор + tuple-@collect_map gated ([M-153.6-collect-static-generic]/[M-153.6-collect-map-tuple-receiver]). Zero-cost generic-over-source — реализован Stage 2, см. D277 ([M-153.2-generic-over-source-zerocost] → 🟡 PARTIAL). Tuple-PRESERVING-адаптер сразу после enumerate[M-153.2-tuple-elem-adapter] (residual Option[<mono-tuple>] closure-typing gap; схлопнуть tuple через map).

Связь

  • D232Vec[T]; D239[]T ≡ Vec[T].
  • D58Iter/Next (VecIter — upstream-источник для lazy()).
  • D135 — cost-transparency (no hidden O(n)) — обоснование лени.
  • Q-iterator-laziness — закрыта (lazy = канон).
  • Q-iter-mut — Phase A закрывает терминаторами/адаптерами; мут-итерация — Phase B.
  • Plan 153.2 — план; vec_seq.nv / vec_lazy.nv — реализация.

D264. Vec-протоколы: Hash + FromIterator / collect-target (Plan 153.6)

Статус: ✅ IMPLEMENTED — Hash (2026-06-13) + FromIterator/collect-target (2026-06-14).

Vec[T] дополняет набор протоколов (Equal/Compare/Clone/Display/DebugD230 amend) двумя возможностями: content-Hash и FromIterator / collect-target (мост к ленивому слою D260). Оба — под conditional-bound на T (Rust impl<T: Hash> Hash for Vec<T> / impl<T> FromIterator<T> for Vec<T>).

Vec[T Hash] @hash() -> u64

Order- и length-sensitive content-hash (protocols.nv): FNV-1a (64-bit), сворачивает длину + per-element @hash() (h = (h ^ x) * prime). Consistency с @equal: равные Vec (равная длина + element-wise ==) → равный hash (контракт Hash+Equal). u64-mul врапается (Nova-семантика = FNV mixing-шаг); offset-basis — hex-литерал (десятичная форма > i64::MAX). Делает Vec[T: Hash] сам Hash → элемент HashSet. (Вторая, equality-половина ключ-контракта HashMap[M-153.6-vec-hashmap-key-eq], pre-existing generic-key-dispatch gap; @hash готов.)

FromIterator / collect-target

Nova структурно-типизирует итераторы (D58: любой mut @next() -> Option[T] итерируем; Next[T]/Iter[I] — протоколы), поэтому FromIterator — НЕ отдельный enforced-протокол с одним методом, а набор конструкторов/терминаторов, строящих коллекцию из любого итератора. Канон:

  1. Default collect-target → Vec (ленивый слой D260): BoxIter[T] mut @collect() -> Vec[T] — материализует pipeline в один проход, без промежуточного Vec на стадию.
  2. Set collect-target: BoxIter[T Hash] mut @collect_set() -> Set[T] (dedup; Rust iter.collect::<HashSet<_>>()). Allocation-free над pipeline (pull + insert на лету).
  3. Прочие таргеты — композицией над собранным Vec: Set[T].from_iter(it.collect()), HashMap[K, V].from(pairs.collect()). Set.from_iter([]T) и HashMap.from([](K,V)) уже принимают собранный Vec (под D239 []T ≡ Vec[T]).
  4. FromIterator из произвольного Iter-источника (без ленивой стадии): Vec[T].new().extend(src) — instance-метод @extend[S Iter[T]] (mutate.nv) мономорфизируется корректно для любого S (Range/VecIter/Vec). Прямой call-site идиом — НЕ требует обёртки.

Gated (compiler-gaps, не упрощение)

  • [M-153.6-collect-static-generic]статический generic-конструктор Vec[T].from_iter[S Iter[T]](src S) с for-in по S в теле не компилируется: bound S Iter[T] не резолвится для for-in dispatch внутри static generic-метода (typevar остаётся Nova_S). Тот же класс, что generic-method-dispatch-collapse (@cap/@splice). Instance-@extend (#4) — рабочий обход. NEG-фикстура collect_static_generic_neg лочит границу (EXPECT_COMPILE_ERROR for-in: type 'S').
  • [M-153.6-collect-map-tuple-receiver] — прямой терминатор BoxIter[(K, V)] mut @collect_map() -> HashMap[K, V] не парсится: receiver type-аргумент кортежем (BoxIter[(K, V)]) отвергается парсером (expected identifier, got '('). HashMap collect-target остаётся HashMap.from(pipeline.collect()) (#3).

Зачем структурный набор, а не enforced-протокол

Один enforced FromIterator[T]-протокол с методом-конструктором потребовал бы static-generic-method-dispatch (gated, см. выше). Структурный набор — это паритет Rust (collect/FromIterator/extend) при существующей инфре: @collect/@collect_set (терминаторы), from/from_iter (конструкторы из собранного), @extend (из источника). Cost-transparency сохраняется: ленивый путь без промежуточных аллокаций (D260), материализация именами collect*/from*/extend.

Связь

  • D260 — ленивый слой; @collect/@collect_set — его терминаторы.
  • D58Iter/Next структурный duck-typing (основа FromIterator).
  • D239[]T ≡ Vec[T] (собранный Vec = []T-аргумент from_iter).
  • D230 amend — conditional-bound протоколы коллекций.
  • Plan 153.6 — план; vec_lazy.nv (@collect_set) / protocols.nv (@hash) / set.nv (from_iter) — реализация.

D277. By-value мономорфизация generic value-records + generic-over-source zero-cost адаптеры (Plan 153.2 Ф.2)

Status: ACTIVE (Plan 153.2 Stage 1 + Stage 2 + Stage 3 + Stage 4, 2026-06-14/15). Amends: D228 (value-record allocation contract — распространён на generic type X[T] value {…}), D260 (lazy-итератор — добавлен allocation-free sibling-слой). Зависит от: D226 (always-pointer receiver ABI), D123/ D354 (mono-инфраструктура). Маркеры: [M-153.2-generic-over-source-zerocost] → 🟡 PARTIAL (Stage 2 done), [M-153.2-Z-closure-devirt] → 🟡 PARTIAL (Stage 3 alloc-elimination done), [M-153.2-Z-noalloc-terminator] → ✅ DONE (Stage 4), [M-153.2-closure-as-mono-type] (P3 остаток — call-инлайн).

Контекст

D228 дал by-value стек-codegen для не-generic value-рекордов (6 языковых типов: str + 5). D260 зашипил lazy-итератор по boxed-fluent-модели: BoxIter[T] — единый erased курсор, держащий step-thunk; адаптеры не аллоцируют промежуточный Vec, но сам wrapper-рекорд BoxIter[T] (как любой generic-рекорд) лоуэрился в heap (nova_alloc(sizeof(Nova_BoxIter…)), один на адаптер), плюс per-element fn-ptr индирекция через step(). Решение закрывает обе статьи накладных расходов в две композируемые стадии.

Stage 1 — by-value мономорфизация generic value-records

type X[T] value {…} теперь лоуэрится BY VALUE для КАЖДОЙ конкретной mono-инстанции, зеркаля не-generic value-record-путь (D228) и str-путь (D26). Mono-инстанс X[int] = inline-struct NovaValue_<short> (<short> = sanitized mono-имя), передаётся/возвращается/копируется по значению, без nova_alloc для wrapper-рекорда. Receiver-ABI — always-pointer (D226): NovaValue_<short>* на стек-слот (&obj / hoist+&temp на call-site через prepare_method_recv), как у не-generic value-record. BoxIter[T] помечен value (std/collections/vec_lazy.nv) — тот же fluent API D260, но теперь 0 wrapper-аллокаций.

Codegen-контракт (обязателен — gate на AllocKind::Value, heap-generics не тронуты):

  1. receiver_c_type value-generic-mono receiver → NovaValue_<short>*, order-free относительно type_aliases (helpers value_generic_mono_short / value_aware_generic_c_type).
  2. generic-instance method-dispatch (block 5b) маршрутит value-receiver через prepare_method_recv → передача по адресу.
  3. fn-typed-field вызов (@step)() — accessor . vs -> выбирается по value-ness receiver’а ((*nova_self).step).
  4. return-type inference (infer_mono_method_ret_with_args + dispatch 5b + overload-pool rt-strip) снимает NovaValue_ ПЕРЕД Nova_, чтобы Nova_<rt>-lookup в реестре попал (иначе method-level-generic-цепочка .map[U] коллапсировала в int).

Stage 2 — generic-over-source zero-cost адаптеры

Новый sibling FILE-модуль std/collections/vec_iter_zc.nv (module collections.vec_iter_zc, opt-in import — тот же D145/leak-rationale, что у vec_lazy/vec_seq). Rust-style Map<I,F>/Filter<I,P>: каждый адаптер — generic-over-source value-рекорд (MapIter[I,T,U] / FilterIter[I,T] / FilterMapIter[I,T,U]), держащий upstream-итератор INLINE полем src I (НЕ boxed step-замыкание). @next() диспетчит (@src).next() статически, мономорфизованно. Цепочка v.ziter().zmap(f).zfilter(p).zcollect() мономорфизуется в ОДИН вложенный конкретный тип FilterIter[MapIter[VecIter[int],int,int],int]; каждый next() инлайнится до базового VecIter.next(). Вход — Vec[T] @ziter(); адаптеры zmap/zfilter/ zfilter_map; per-type терминаторы zcollect/zfold/zcount/zsum/zfor_each/zany/zall/ zfind.

Дополнительные codegen-фиксы (все gate на AllocKind::Value):

  1. value_aware_subst_to_ref (новый &self-зеркало статического apply_type_subst_to_ref): nested-generic-арг несёт NovaValue_-префикс → worklist-enqueued mono-имя СОГЛАСУЕТСЯ с type_ref_to_c/field/type-decl именем (иначе две расходящиеся инстанции → undefined-struct CC-FAIL).
  2. split_top_level_mono_args + mono_type_args_of (registry-backed, depth-aware): наивный args_str.split("__") рвал nested generic-over-source арг на 3 фрагмента, мис-биндя I/T → Vec[nova_int*] garbage. Применено в 3 split-сайтах.
  3. erased_type_ref_c — type-param-чек сделан РЕКУРСИВНЫМ (uses_any_type_param) → erased-stub возвращает erased-base-pointer, не placeholder-laden mono-имя.
  4. drain_generic_type_worklist placeholder-guard (value-GATED mangled_has_nested_placeholder) — skip эмита value-mono, чьё by-value поле ссылалось бы на undefined inner-placeholder struct, БЕЗ подавления нужного heap Vec[Slot[K,V]] forward-typedef. Регресс-урок: over-eager ранняя версия guard’а (НЕ value-gated) сломала 15 HashMap/value-record файлов (plan139 t3/ neg_t3, plan152_4 case/normalize/graphemes/sentences/words+conformance, plan152_5 collation) — пойман baseline-бинарём @0da18125, FIXED гейтингом на value-шаблон; все 15 восстановлены.

Stage 3 — devirtualizация capture-free замыканий (alloc-elimination)

Замыкание БЕЗ свободных переменных (env = {int _dummy}) stateless: каждый инстанс байт-идентичен и тело-функция никогда не читает env. Вместо ДВУХ nova_alloc на каждый call-site (env-box + NovaClos_xx-box) эмитится ОДИН file-scope static singleton (nova_lambda_N_clos_singleton + nova_lambda_N_env_singleton) на closure-литерал, а call-site возвращает (void*)(&singleton). Хирургическая правка — emit_lambda (compiler-codegen/src/codegen/emit_c.rs ~31427), capture-free fast-path. Соундно безусловно: static-адрес immortal — может escape/store/outlive любой scope без dangling (Boehm видит его как root). Захватывающие замыкания (free_vars ≠ ∅) — heap-путь БЕЗ изменений (immutable by-value snapshot + mut by-ref box нужны per-instance, singleton нельзя шарить). Это alloc-elimination половина closure-devirt’а: сам per-element ВЫЗОВ (@f)(x) ещё идёт через NOVA_CLOS_CALL fn-ptr-макрос — true call-devirt = закладка env как конкретного type-param (MapIter[I,T,U,F], [M-153.2-closure-as-mono-type]). Маркер [M-153.2-Z-closure-devirt] (P3, PARTIAL).

Stage 4 — alloc-free терминаторы + collect_into

В vec_iter_zc добавлен терминатор mut @zcollect_into(out mut Vec[T]) -> () на каждый адаптер (MapIter/FilterIter/FilterMapIter): тело = zcollect-drain МИНУС Vec[U].new() header-аллокация — пушит в переданный буфер out. Семантика APPEND (НЕ чистит out; для свежего sink caller делает out.clear()len=0, буфер сохранён → амортизированный 0 аллокаций при переиспользовании). Возвращает () (буфер виден через caller-биндинг — Vec[T] heap-ref). Стриминг-терминаторы (zfold/zsum/zcount/zfor_each/zany/ zall/zfind) уже alloc-free по конструкции (скаляр/bool/Option-аккумулятор, без out-Vec). Маркер [M-153.2-Z-noalloc-terminator] (✅ DONE).

Дизайн-решение: sibling, не замена

vec_iter_zcНОВЫЙ sibling-модуль, boxed-fluent vec_lazy/BoxIter сохранён как closure-fluent-альтернатива (единый erased курсор, единообразный BoxIter[T] на каждой стадии). vec_iter_zc — allocation-free сиблинг (один вложенный mono-тип на цепочку). Оба за раздельными explicit-import. Задокументировано в шапке vec_iter_zc.nv.

Измерено (канон v.lazy/ziter().map().filter().collect())

boxed-fluent vec_lazyzero-cost vec_iter_zc
wrapper-record heap allocs (адаптер-цепочка)6 → 0 (Stage 1: by-value NovaValue_<short>)0
source-box (_box_src)90 (source inline полем src I)
per-element step() fn-ptr индирекцияестьубрана (статический dispatch)
capture-free closure env/box (Stage 3)4→0 (collect) / 6→0 (fold) — static singletonто же
терминатор-тело (Stage 4)0 nova_alloc (collect_into/fold/sum/count/for_each/any/all/find)
остаточный heapenv ЗАХВАТЫВАЮЩИХ замыкания f/pred + VecIter source-курсорто же — irreducible без closures-as-mono-types

Stage 1 verify: grep nova_alloc(sizeof(Nova_BoxIter = 0 во всех сгенерённых plan153_2/*.c; NovaValue_BoxIter… by-value struct — повсюду (adapters 89 / chains 98 / laziness 64 / terminators 74 вхождений).

Stage 3 verify: nova_alloc(sizeof(nova_lambda_N_env)) / nova_alloc(sizeof(NovaClos…)) для capture-free замыканий = 0 (заменены file-scope static singleton); канон zmap(f).zfilter(p).zcollect() driver-тело closure-allocs 4 → 0, та же цепочка .zfold(0,…) 6 → 0.

Stage 4 verify: все четыре мономорфизованных …method_zcollect_into тела = 0 nova_alloc (vs zcollect с …_static_new()); zfold/zsum/zcount/ zfor_each/zany/zall/zfind мономорфизованные тела = 0 nova_alloc каждый.

Остаток (честно)

  • per-element ВЫЗОВ f/pred ещё fn-ptr-индирекция (void* + NOVA_CLOS_CALL на элемент) — Stage 3 убрал АЛЛОКАЦИЮ closure-env (capture-free → singleton), но не сам вызов. Rust-style инлайн мэппера требует closures-as-mono-types (env как конкретный type-param) — отдельный крупный лифт. [M-153.2-closure-as-mono-type] (P3).
  • захватывающие замыкания всё ещё heap-env (per-instance; singleton нельзя шарить — by-value snapshot / by-ref box нужны свежими).
  • VecIter source-курсор — heap-ref-type alloc на .ziter() (свойство VecIter[T], не замыкание; вне scope ступеней 3–4).
  • take/skip/enumerate (stateful / tuple-element) остаются на boxed vec_lazy — порт = wiring, не новая compiler-способность.

Связь

  • D228 — value-record allocation contract (распространён на generic).
  • D260 — boxed-fluent lazy (0 wrapper-allocs via Stage 1; zero-cost sibling via Stage 2; capture-free closure devirt via Stage 3; alloc-free терминаторы + collect_into via Stage 4).
  • D226 — always-pointer receiver ABI (mono value-receiver).
  • Plan 153.2 — план; vec_lazy.nv / vec_iter_zc.nv — реализация; emit_c.rs::emit_lambda — Stage 3 singleton.
  • Cross-ref: D355 — blanket methods on Next[T] implementors (терминаторы blanket).

D281. Module-level field privacy — type X priv { … } (Plan 160)

Status: ACTIVE (2026-06-15, Plan 160). Amends: D220 — расширяет type-level privacy-flip двумя уровнями. Зависит от: D78 (module-path convention), D29 (module model).

Мотивация

D220 дал priv (field-level, только own-type) и type X priv {...} (type-level flip, тоже only-own-type). Недоставало среднего уровня: module-private — поле видно во всём модуле (паттерн Go unexported + Rust pub(crate)). Позволяет folder-module или multi-file-module иметь shared internal state без публичного поля и без экстра accessor-boilerplate.

Синтаксис

// type-level modifier         → meaning (задаёт дефолт для полей без явного modifier'а)
// (bare)                      → fields default public      (D47, unchanged)
// priv                        → fields default module-private
// priv(type)                  → fields default type-private (only own methods)
// priv(file)                  → fields default file-private (only methods in same file)

export type Job value priv {      // module-private by default
    mut id   int                  // module-private (наследует type-level default)
    kind     int                  // module-private
    priv(type) secret int         // type-private (stronger: only Job methods)
    priv(file) internal int       // file-private (только методы в этом файле)
}

priv без квалификатора = module-private — на уровне типа (задаёт дефолт полей) и на уровне поля (задаёт видимость явно).
priv(type) = type-private — аналогично: на уровне типа (дефолт) и на уровне поля (явно).
priv(file) = file-private — аналогично: на уровне типа (дефолт) и на уровне поля (явно). Поле доступно только из методов, определённых в том же файле.
Правило симметрично: все priv-квалификаторы ведут себя одинаково на type-уровне и field-уровне.
priv(module)ОШИБКА (E_PRIV_QUALIFIER); используй priv без квалификатора.

Правило

§1 Effective visibility (четыре уровня)

КонтекстEffective visibility
Explicit pub fieldpublic
Explicit priv fieldmodule-private
Explicit priv(type) fieldtype-private
Explicit priv(file) fieldfile-private
Type-level priv(file) default, no explicit field modifierfile-private
Type-level priv(type) default, no explicit field modifiertype-private
Type-level priv default, no explicit field modifiermodule-private
(ничего — D47 default)public

Лесенка строгости: public ⊃ module-private ⊃ file-private ⊃ type-private.

§2 Module identity

Модуль = папка (Nova module model, D29/D78). Файл принадлежит модулю P.Q если его rev-3 path = [P, Q]. Все файлы в project/src/foo/ декларируют module project.foo — они один модуль.

module_priv_access_allowed(type T): true iff current_module == type_defining_module(T).

§3 Access rules

Module-private field (из type-level priv default, без explicit field priv):

  • Same module: read / write / init / pattern — РАЗРЕШЕНЫ.
  • Other module: read → E_FIELD_MODULE_PRIVATE, write → E_FIELD_MODULE_PRIVATE, init → E_FIELD_MODULE_PRIVATE, pattern → E_FIELD_MODULE_PRIVATE.

Type-private field (explicit priv(type) field, OR type-level priv(type) default):

  • Same module, non-method: read → E_PRIV_FIELD_READ (D220 error codes unchanged).
  • Own-type method: РАЗРЕШЁН — включая cross-instance: fn T @eq(other T) -> bool => @f == other.f читает other.f внутри метода T (см. D220 §3).

§4 Error codes

CodeContext
E_FIELD_MODULE_PRIVATERead/write/init/pattern of module-private field from outside module
E_PRIV_FIELD_READRead of type-private field from non-method (includes same-module free fn)
E_PRIV_FIELD_WRITEWrite of type-private field (D220, reused)
E_PRIV_FIELD_INITRecord-literal init of type-private field (D220, reused)
E_PRIV_FIELD_PATTERNPattern destructure of type-private field (D220, reused)
E_PRIV_QUALIFIERpriv(module) or unknown qualifier — use bare priv

§5 Критерии приёмки (без упрощений, как для прода)

  1. Позитив: type T priv { f int } в модуле M — свободные функции, методы и record-literal constructors в том же модуле читают/пишут/инициализируют f без ошибок.
  2. Негатив-read: import T из другого модуля, t.fE_FIELD_MODULE_PRIVATE.
  3. Негатив-write: t.f = x из другого модуля → E_FIELD_MODULE_PRIVATE.
  4. Негатив-init: { f: v } конструктор из другого модуля → E_FIELD_MODULE_PRIVATE.
  5. Layering: priv(type) field внутри type T priv {...} остаётся type-private — E_PRIV_FIELD_READ даже в том же модуле из свободной функции. Bare priv field внутри type T priv {...} = module-private (accessible in same module free fn).
  6. Public export: type + его методы (возвращающие/принимающие T) публично экспортируются — клиентский модуль может использовать API без доступа к внутренним полям.
  7. Regression: nova test core-suite без новых FAIL.
  8. Негатив-pattern: ro { f } = t из другого модуля → E_FIELD_MODULE_PRIVATE (не E_PRIV_FIELD_PATTERN). Позитив-pattern: ro { f } = t в том же модуле — работает без ошибок.

AST / Checker-реализация

  • ast::FieldDefaultVisibility::Module — новый вариант enum (наряду с Public, Private).
  • RecordField.priv_module_field: bool — true если поле получило module-private (из type-level default ИЛИ из explicit priv field modifier).
  • TypeCheckCtx.type_defining_modules: HashMap<String, Vec<String>> — строится из peer_files.items_here.
  • TypeCheckCtx.current_module: RefCell<Vec<String>> — RAII CurrentModuleGuard при входе в check_module.
  • module_priv_access_allowed(tname) — compare maps.
  • 5 check-сайтов: INIT (record-literal), READ (member expr), WRITE (assign), PATTERN (destructure) — каждый distinguishes priv_module_fieldE_FIELD_MODULE_PRIVATE vs priv_field → old D220 codes.

Связь

  • D220 — per-field priv + type-level flip (D281 amends: priv = module-private, priv(type) = type-private).
  • D78 — module-path enforcement (defines module identity).
  • D47 — default-public baseline (unchanged).
  • Plan 160 — план реализации.

D307. File-private visibility — priv(file) (Plan 170)

Status: ACTIVE (2026-06-19, Plan 170). Зависит от: D281 / D220 (инфраструктура priv/priv(type)), D29 (folder-module model), D78 (module-path). Нумерация: план назвал блок «D304», но к моменту реализации D304 уже был занят (Test Category Selectors, 09-tooling.md, Plan 169.1.1, 2026-06-19); D305/D306 были временно зарезервированы за proposed-планом 104.10 (LSP), но при реализации 104.10 получил D378-D380 (09-tooling.md) → D305/D306 СВОБОДНЫ; этому блоку присвоен D307.

Мотивация

folder-module = один compile unit из co-equal peer-файлов (D29/D78): все .nv в папке с одинаковым module X делят одно top-level пространство имён — fn/type/const каждого файла видны всем peer-файлам. Текущая лесенка видимости top-level бинарна:

  • export — виден снаружи модуля;
  • (без модификатора) — module-private (виден всем peer-файлам модуля).

Недоставало самого узкого уровня — file-private. Без него одноимённые helper-функции/типы в разных файлах одного folder-module конфликтуют (E_DUP_DEFINITION) и требуют некрасивого ordinal-rename (helper1/helper2). priv(file) закрывает дыру: символ виден только в своём файле, не утекает к peer-файлам. Аналог Rust pub(self). Польза шире тестов — любой folder-module (std/collections/vec/ и т.п.) получает file-local helper’ы без загрязнения общего namespace.

Синтаксис

priv(file) type Acc { … }       // тип виден только в этом файле (prefix-форма)
type Job priv(file) { … }       // эквивалент — priv(file) как type-modifier после имени
priv(file) fn helper() -> int   // free fn не виден peer-файлам модуля
priv(file) const K = 42         // file-local константа
// (без модификатора)           // module-private (как сейчас, D281)
export     fn api() …           // публичный (как сейчас)

Для type: priv(file) допустим и как prefix (priv(file) type X), и как modifier после имени (type X priv(file) { … }) — обе формы эквивалентны, выбор стилевой. Для fn и const: только prefix-форма.

Лесенка видимости top-level символов: priv(file) ⊂ (module-default) ⊂ export.

priv(file) применим на двух уровнях:

  • Top-level символ (priv(file) type X, priv(file) fn f, priv(file) const K) — символ не виден peer-файлам модуля.
  • Поле типа (priv(file) secret int) — поле доступно только из методов, определённых в том же файле. Симметрично priv (module) и priv(type) (D281).

priv(file) — это visibility-hint, НЕ смена module-резолва: модуль остаётся один (D29 не нарушается), символ лишь помечен «не виден peer-файлам». file — НЕ ключевое слово (распознаётся как Ident("file") внутри priv(...)), что исключает коллизию с идентификаторами.

Нейминг-обоснование (priv(file), не local): единая ось видимости под priv + scope-квалификатор — симметрично priv(type) (D281). local двусмысленно (вложенные функции — тоже «локальные») и потребовало бы нового KW с риском коллизии идентификаторов.

Применимость top-level. fn / type / const. Не применим к test/bench/lemma/let/ro и к методам (fn T @m — receiver-qualified, вне scope file-private). Scope-local const внутри тела fn — уже block-scoped, priv(file) не нужен.

Правило

§1 Резолв (checker)

При построении группового namespace folder-module из shared-набора исключаются priv(file)-имена каждого peer-файла. Для файла F видимый набор имён = (shared group namespace без любых file-private) ∪ (собственные file-private имена F). Поэтому sibling-файл никогда не резолвит чужой priv(file) символ.

Если файл F ссылается на имя, которое priv(file) в ДРУГОМ peer-файле той же группы (и иначе не резолвится) → специфичная диагностика E_FILE_PRIV_LEAK (вместо родового «undefined identifier»), с подсказкой «remove priv(file) … or move the symbol».

§2 Дедупликация (no-conflict)

Два priv(file) символа с одинаковым именем в разных peer-файлах — НЕ конфликт (непересекающиеся file-scope). Проверка «duplicate top-level name» для такого имени снимается тогда и только тогда, когда каждая декларация этого имени file-private И они в разных файлах. Если хотя бы одна декларация module-private/export — имя живёт в общем namespace → коллизия как раньше (E_DUP_DEFINITION, D29).

§3 Codegen (mangling)

priv(file) free-fn получает file-дискриминированное C-имя — nova_fn_<module>_f<file_id>_<name> — где дискриминатор = стабильный file_id объявляющего файла. Два одноимённых priv(file) fn helper из разных файлов дают разные C-символы → нет коллизии линковки. Call-site внутри файла резолвит в свой вариант по тому же (file_id, name) ключу, что и checker §1. File-private free-fns НЕ регистрируются в shared overload-registry (file-local, не участвуют в cross-file overload resolution). Точный паттерн переиспользует существующий private_const_c_names (Plan 160, уже keyed по file_id).

§4 Error codes

CodeContext
E_FILE_PRIV_LEAKСсылка из peer-файла на priv(file) символ другого файла группы
E_PRIV_QUALIFIERpriv(file) + export вместе; bare top-level priv без (file); priv(<other>) на top-level; priv(file) перед не-fn/type/const

§5 Критерии приёмки (без упрощений, как для прода)

  1. Парсинг: priv(file) fn/type/const парсится; priv(file) + exportE_PRIV_QUALIFIER.
  2. Позитив own-file: priv(file) fn h() вызывается в своём файле → компилируется и работает.
  3. Позитив co-exist: два peer-файла, оба объявляют priv(file) fn helper() с разным телом, каждый вызывает свой → компилируются (file-discriminated codegen, нет линк-коллизии) и работают.
  4. Негатив leak: ссылка из peer на чужой priv(file)E_FILE_PRIV_LEAK.
  5. Регрессия: module-private (priv/default) символ из одного peer ВИДЕН другому (D281 без изменений); export без изменений.
  6. Регресс: plan160 / plan124* / modules / std — 0 новых FAIL.

AST / Checker / Codegen-реализация

  • ast: FnDecl.file_private: bool / TypeDecl.file_private: bool / ConstDecl.file_private: bool (default false). Минимальный путь — bool (без enum-рефактора is_export).
  • parser: распознавание priv ( file ) в общем parse-item ДО KwExport-eat; пробрасывается в parse_fn/parse_type_decl/parse_const_decl. Взаимоисключение с export.
  • types: file-private dedup в check_module_impl (снятие dup-проверки для disjoint file-scope); NameResCtx.file_priv_leak: HashMap<FileId, HashMap<name, owner_path>> — per-file leak-table; групповой namespace вычитает file-private каждого peer.
  • codegen: file_priv_fn_c_names: HashMap<(FileId, String), String> + current_emit_file_id — file-дискриминированный C-символ, резолвится в free_fn_c_name/mangle_fn.

Связь

  • D281 — module-private (соседний уровень лесенки); этот блок добавляет узкий file-private.
  • D29 — folder-module model (не нарушается: модуль остаётся один).
  • D78 — module-path / file identity (file_id источник дискриминатора).
  • Plan 170 — план реализации.

D355 — Blanket protocol-receiver methods (Plan 161, 2026-06-15)

Renumber 2026-07-03: блок был D282 — номер коллидировал с D282 extern “nova”/“C” fn (двух-ABI FFI, Plan 91.12; тот сохраняет D282 — канон README-индекса, амендится Plan 174.6). Перенумерован в D355 (Plan 174 §6).

Status: ACTIVE (Plan 161 Ф.0-Ф.4, 2026-06-15). Amends: D241 (добавлен §3 «≤1 impl, cross-ref D355 (ex-D282)»). Зависит от: D72 (generic bounds), D119 (method-level type params), D277 (D277/vec_iter_zc). Маркеры: [M-161-blanket-receiver] → ✅ Ф.0-Ф.4 CLOSED; [M-161-parametric-return] (V2 followup).

§1 Синтаксис. fn[I Proto[T₁,…,Tₙ]] I @name[U₁,…](params) -> R { body } — blanket-объявление: I — typevar-ресивер, Proto[…] — bound. T₁,…,Tₙ выводятся из bound (не нужно объявлять явно). Запись fn[…] (glued, без пробела) = prefixed-generic header (уже разобрана парсером).

§2 Диспетч. При вызове expr.name(args), где тип expr — конкретный C, реализующий Proto[…]: blanket-метод виден на C, typevar I биндится в C. Конкретный метод (method_table[C]) всегда имеет приоритет над blanket.

§3 Мономорфизация. Mono-key = (C, name, extra_type_args). Тело компилируется как обычный generic-метод с заполненным I. Внутри тела I = конкретный C, typevar’ы из bound (T) = конкретные типы из impl-записи C.

§4 Инвариант (≤1 impl). Тип не может реализовывать Next[T] для двух разных T одновременно. Нарушение = E_DUPLICATE_PROTOCOL_IMPL.

§5 Область действия. Blanket-метод виден в модуле где объявлен + его importers (те же правила видимости, что у обычных методов). Конфликт двух blanket-методов с одним именем на одном протоколе = E_BLANKET_CONFLICT.

§6 Ограничения V1. Работает для методов с конкретными или fully-resolved возвращаемыми типами. Параметрические возвращаемые типы T, Option[T], Vec[T] — V2 ([M-161-parametric-return]). Один bound-уровень (I Proto[…]); цепные bounds — V2. Ресивер должен быть typevar, не конкретный тип.

Реализовано в. std/collections/vec_iter_zc.nv: @zfold, @zcount, @zfor_each, @zany, @zall — blanket на Next[T] (5 терминаторов, concrete return type). Перевод 12 per-adapter → 5 blanket деклараций (O(N²) → O(N)).

D284 — EnumerateIter — zero-cost enumerate adapter (Plan 162)

Status: ACTIVE (Plan 162 Ф.0-Ф.5, 2026-06-16). Зависит от: D277 (value-record mono / generic-over-source), D355 (blanket protocol-receiver для терминаторов, ex-D282), D260 (lazy-iterator layer). Маркер: [M-153.2-enumerate-zc] → ✅ CLOSED Plan 162; [M-153.2-tuple-elem-adapter] OPEN (chained adapter сразу после enumerate гейтнут на closure-type propagation). Было: [M-153.2-enumerate-zc] (enumerate deferred из Plan 153.2, было в boxed vec_lazy).

§1 Синтаксис. EnumerateIter[I, T] — zero-cost value-record adapter:

export type EnumerateIter[I, T] value { mut src I, mut i int }

Поля: src I — источник (inline by-value, статически диспетчится), i int — текущий индекс (стартует с 0). Результат @next()Option[(int, T)]; Some((i, elem)) на каждый Some у источника, None транзитивно. Реализует Next[(int, T)] → совместим с blanket-терминаторами D355 (@zcount, @zfold, @zfor_each, @zany, @zall, @zfind, @zsum).

§2 Диспетч (@zenumerate). Метод @zenumerate() объявлен per-type (не blanket), потому что возвращаемый тип явно называет EnumerateIter (не параметрический конкретный тип в смысле D355 §6):

export fn VecIter[T]          @zenumerate() -> EnumerateIter[Self, T]         => { src: @, i: 0 }
export fn MapIter[I, T, U]    @zenumerate() -> EnumerateIter[Self, U]         => { src: @, i: 0 }
export fn FilterIter[I, T]    @zenumerate() -> EnumerateIter[Self, T]         => { src: @, i: 0 }
export fn FilterMapIter[I,T,U]@zenumerate() -> EnumerateIter[Self, U]         => { src: @, i: 0 }
export fn TakeIter[I, T]      @zenumerate() -> EnumerateIter[Self, T]         => { src: @, i: 0 }
export fn SkipIter[I, T]      @zenumerate() -> EnumerateIter[Self, T]         => { src: @, i: 0 }

Self — mono-ресивер (D66 §«Self как вложенный generic type-arg»). Каждый adapter-тип регистрирует собственный @zenumerate. Не может быть blanket, потому что тело конструирует EnumerateIter[Self, _] — return-тип содержит конкретное имя адаптера, не generic typevar, раскрываемый из протокола-bound.

§3 Мономорфизация (tuple parametric return). @next() возвращает Option[(int, T)]. Компилятор разрешает тип кортежа (plan162 fix, emit_c.rs): перед вызовом type_ref_to_c для Option[(int, T)] биндинги type-variable из protocol-bound (I Next[T] → T = elem-тип источника) устанавливаются в type_subst_overrides. Arm Tuple в type_ref_to_c получает T уже разрешённым → эмитит типизированный mono’d struct (NovaTuple_2_8_nova_int_11__nova_int для int), не erased _NovaTuple2. Без этого фикса T оставался нераспознанным → fallback на erased legacy-форму → CC-FAIL при использовании .0/.1 полей.

§4 Инвариант (счётчик i). i стартует с 0. Инкрементируется ровно на 1 при каждом Some-ответе источника (пропуски на None не считаются — EnumerateIter.@next() прозрачно прокидывает None, не инкрементируя). Это соответствует enumerate() в Rust/Python: индекс = порядковый номер доставленного элемента, непрерывно с 0.

§5 Ограничения V1. (a) Нет blanket @zenumerate — return-тип называет EnumerateIter конкретно (contra D355 §6). Добавление нового adapter-типа требует явного @zenumerate. (b) Tuple-PRESERVING chained adapter сразу после enumerate (enumerate().filter(..) когда элемент остаётся (int,T)) гейтнут на closure-type-propagation codegen fix → [M-153.2-tuple-elem-adapter]; workaround: enumerate().map(|p| ...) (Map схлопывает кортеж). (c) EnumerateIter был deferred из Plan 153.2 (маркер [M-153.2-enumerate-zc] в boxed vec_lazy) — зашиплен в Plan 162.

Кросс-ссылки: D355 (blanket-receiver, гейтирует терминаторы на EnumerateIter), D260 (boxed lazy layer — предшественник), D277 (generic-over-source model — база для EnumerateIter).

Реализовано в. std/collections/vec_iter_zc.nv: EnumerateIter[I,T] value-record + @next() + chaining methods + 6 per-type @zenumerate() adapters. compiler-codegen/src/codegen/emit_c.rs: tuple parametric T-subst fix в blanket infer_type_refs_for_blanket. Тесты: nova_tests/plan162/ (9 basic + 8 chain + neg). D284 NEW.

D290 — Value-record iterator types (Plan 165, 2026-06-16)

Status: ACTIVE (Plan 165, 2026-06-16). Зависит от: D277 (generic value-record mono), D215 (value/reference allocation contract), D228 (fiber-arena GC root coverage для value-record с GC-pointer полями). Маркеры: [M-codegen-value-type-generic-forward-decl] ✅ CLOSED для VecIter/Range*Iter (Plan 165 Ф.1).

§1 Правило. Iterator-тип объявляется как value (stack-allocated), если выполняется одно из условий:

(a) Тип содержит только примитивные поля (int, u8, bool, f64, и т.д.) без GC-managed указателей → stack-аллокация безопасна без каких-либо оговорок.

(b) Тип содержит GC-pointer-поля, но является cursor’ом (итерационным состоянием над уже существующей структурой данных), не новым владельцем памяти → fiber arena автоматически корнирует value-record со ссылочными полями (D228 §escape), поэтому GC-безопасность сохраняется.

§2 Типы (Plan 165). Применено к:

  • VecIter[T] value (std/collections/vec/iter.nv) — содержит Vec[T] (GC-pointer-ссылка); cursor-тип, правило (b). Stack-slot + fiber-arena root.
  • Range value, RangeIter value, StepRangeIter value, ReverseRangeIter value (std/collections/range.nv) — содержат только int-поля; правило (a). Чистый stack, zero GC involvement.

§3 Эффект на производительность. for x in v { } компилируется в VecIter[T] на стеке — malloc отсутствует. Цепочка 0..n (RangeRangeIter) — два int64_t на стеке. Adapter-цепочка zero-cost iterators (Plan 153.2 / D260 / D277) остаётся нулём аллокаций при нулевой escape (стековый инлайнинг адаптеров).

§4 Codegen инварианты (Ф.1 fix, коммит 1f92f106). Generic value-тип type X[T] value { … } при мономорфизации:

  • Forward declaration должна содержать полное mono-имя: typedef struct NovaValue_X____nova_int NovaValue_X____nova_int; (не NovaValue_X).
  • Несоответствие forward-declaration и struct definition → CC-FAIL «incomplete return type». Исправлено: emit_forward_decl_for_generic_value_type передаёт mono-имя в полной форме.
  • field_cache.rs предикат примитивного листа включает "never" (строчные).

§5 Конвенция для новых iterator-типов. При добавлении нового iterator-адаптера (type FooIter[…] { … }):

  1. Если поля — только примитивы или ссылки на уже живущие GC-объекты (cursor-semantics) → объявить value.
  2. Если адаптер создаёт новую GC-память (например, буферизующий адаптер) → оставить heap-record.
  3. Проверить, что forward declaration генерируется с полным mono-именем (см. §4).

Реализовано в. std/collections/vec/iter.nv (VecIter value), std/collections/range.nv (Range/RangeIter/StepRangeIter/ReverseRangeIter value), compiler-codegen/src/codegen/emit_c.rs (generic value forward-decl fix), compiler-codegen/src/field_cache.rs ("never" лист).


D364 — Net C FFI pattern: opaque handles + value-record wrapping (Plan 91.12)

Source: Plan 91.12 Ф.0–Ф.4, 2026-06-16. Status: ✅ ACTIVE. Связь: D214, D282 (extern "C" fn), D365.

Паттерн

Стандартный способ обёртки C-библиотечного ресурса в Nova (используется в std/net, типичен для std.ffi-слоя):

Шаг 1. Opaque handle (D214 §newtype): type CX(*()) — newtype над *() (void*). Даёт типобезопасность на FFI-границе без раскрытия внутренней C-структуры. Nova не может перепутать CTcpListener и CUdpSocket.

type CTcpListener(*())
type CTcpStream(*())
type CUdpSocket(*())

Шаг 2. Приватный FFI-слой (ffi.nv, без export): все extern "C" fn — module-private. Именование: <resource>_<action> (snake_case, без Nova-префиксов). C-side принимает typed handle pointer, возвращает новый handle (NULL = error) или числовой результат.

extern "C" fn tcp_listener_bind(addr CSocketAddr) -> CTcpListener  // NULL on error
extern "C" fn tcp_stream_write(s CTcpStream, data str) -> int       // bytes or -1

Шаг 3. Публичный value-record: export type TcpListener value { priv handle CTcpListener }. priv делает handle module-private (Plan 160 D281). Публичные методы делегируют в эффект (не в C напрямую).

export type TcpListener value { priv handle CTcpListener }
export fn TcpListener.bind(addr SocketAddr) TcpNet -> Result[TcpListener, NetError] {
    TcpNet.bind(addr)
}

Шаг 4. Эффект-handler: real_tcp_net() — конкретный Effect[TcpNet] (D365), содержит прямые вызовы C FFI. Тесты заменяют handler на mock без изменения кода пользователя.

Инварианты

  • FFI-функции НЕ экспортируются из модуля (export запрещён на extern "C" fn в ffi.nv-слое).
  • Конструктор public-типа через _from_raw(h CX) — package-private factory.
  • Опасный close()consume @close(): потребляет тип, предотвращает double-close.
  • Ошибки: null/отрицательный результат + TLS net_last_error() (cooperative-safe: нет yielding между C-вызовом и чтением ошибки).

Реализовано в

std/net/ffi.nv (Ф.1), std/net/tcp.nv (Ф.3), std/net/udp.nv (Ф.4). Тесты: nova_tests/plan91_12/ (19/19 PASS). D364 NEW (ex-D292 — renumber 2026-07-03, коллизия с ModuleSigTable-D292 07-modules).


D299 — AsSlice[T] protocol: contiguous-buffer abstraction (Plan 153.1, 2026-06-17)

Source: Plan 153.1 [M-153.1-append-extend-consolidation], 2026-06-17. Status: ✅ ACTIVE. Связь: D141 (@extend / bulk-copy family), D238 (@index protocol).

Мотивация

Vec[T] @append(other Vec[T]) принимал только конкретный Vec[T]. Чтобы @append мог принять и slice-view ([]T = тот же тип Vec[T] с интерьерным указателем), и любой пользовательский contiguous-буфер, вводится protocol AsSlice[T] с двумя методами: @as_ptr() -> *T и @len() -> int.

Спецификация

// std/prelude/protocols.nv
export type AsSlice[T] protocol {
    @as_ptr() -> *T
    @len() -> int
}

Vec[T] реализует AsSlice[T] через #impl(AsSlice[T]) на @as_ptr(). Это покрывает и []T (алиас Vec[T]), включая slice-views (v[a..b]).

@append переписан с конкретного аргумента на generic bound:

// std/collections/vec/mutate.nv
export fn Vec[T] mut @append[S AsSlice[T]](other S) -> @ {
    ro m = other.len()
    if m > 0 {
        @reserve(m)
        unsafe {
            RawMem.copy(other.as_ptr() as *u8, (@data + @len) as *mut u8, m * size_of[T]())
        }
        @len = @len + m
    }
    @
}

RawMem.copy (memmove) корректен для self-append: после @reserve регионы [0, m) и [@len, @len+m) не пересекаются.

Правила

  • Реализующий тип должен гарантировать, что @as_ptr() возвращает указатель на непрерывный буфер из не менее @len() живых элементов.
  • Дереференс @as_ptr() вне unsafe { } запрещён (как у Vec[T] @as_ptr).
  • @extend (итерация через Iter) остаётся для не-contiguous источников.

Реализовано в

std/prelude/protocols.nv (protocol declaration), std/collections/vec/access.nv (#impl(AsSlice[T]) на Vec[T] @as_ptr), std/collections/vec/mutate.nv (обновлённый @append). Тесты: nova_tests/plan153_1/append_as_slice.nv (6 кейсов: vec→vec, пустые случаи, self-append, slice view). D299 NEW.

D300 — Vec generic forward-decl: body-site scan + tuple-elem fwd-decl (Plan 168, 2026-06-17)

Проблема: Vec[u32] в теле функции (локальная переменная, TurboFish-конструктор) генерировал C-тип Nova_Vec____Nova_u32_p (через generic stub path), тогда как в сигнатурах и полях тот же тип даёт Nova_Vec____uint32_t. Pre-pass collect_array_elem_typerefs не заходил в тела функций → typedef struct Nova_Vec____Nova_u32_p Nova_Vec____Nova_u32_p; отсутствовал в глобальном preamble → CC-FAIL «unknown type name» в tuple typedefs.

Два исправления:

  1. Body scan (emit_c.rs): добавлены collect_array_elem_typerefs_in_fnbody, collect_array_elem_typerefs_in_block, collect_array_elem_typerefs_in_stmt, collect_array_elem_typerefs_in_expr. scan_item для Item::Fn теперь вызывает collect_array_elem_typerefs_in_fnbody(&f.body, acc). При нахождении ExprKind::TurboFish { base: Ident("Vec"), type_args } — напрямую добавляет type_args как Vec elem TypeRefs.

  2. Tuple-elem fwd-decl (emit_c.rs, строки ~3915): перед splice MONO_TUPLE_TYPEDEFS проходит по всем mono’d tuple instances, и для каждого pointer-field вида Nova_...__...* (mono’d instance = содержит __) добавляет typedef struct X X; в начало tuple_decls. Это обеспечивает forward-decl для Nova_Vec____Nova_u32_p и любых аналогичных типов, которые появляются в tuple field-types до своего полного struct-определения.

Результат: nova_tests/plan168 2/2 PASS; nova_tests/plan153_1 8/9 PASS (1 pre-existing CODEGEN-FAIL resize_with_free_fn_shadow — не связан с fix’ом).

Инварианты:

  • Nova_Vec____<elem> — полная struct-definition эмитируется в generic_type_defs_buf (до fn-definitions, via marker splice)
  • typedef struct Nova_Vec____<elem> Nova_Vec____<elem>; — в user_type_fwd_decls (до tuple typedefs, via marker splice)
  • Tuple typedef может ссылаться на Nova_Vec____<elem>* как incomplete pointer — OK по C99

D300 NEW.


D373 — Generic array API: sort/min/max/binary_search + _by variants (Plan 91.8c, 2026-06-17)

Статус: ACTIVE.

Мотивация

Plan 91.3 давал []int @sort() (concrete), Plan 91.8a закрепил Compare.compare -> int. После 91.8a str и user-types с @compare должны быть sortable через generic dispatch. D373 добавляет полный generic API поверх Compare.

API surface

Bound-based (T Compare)

fn[T Compare] []T mut @sort_of() -> @
fn[T Compare] []T @min_of() -> Option[T]
fn[T Compare] []T @max_of() -> Option[T]
fn[T Compare] []T @binary_search_of(target T) -> Option[int]

Суффикс _of избегает name collision с concrete []int @sort() / @min() / @max() (те сохранены как fast-path для int; codegen выбирает concrete для []int exact-receiver).

Callback-based (без bound на T)

fn[T] []T mut @sort_by_of(cmp fn(T, T) -> int) -> @
fn[T] []T @min_by_of(cmp fn(T, T) -> int) -> Option[T]
fn[T] []T @max_by_of(cmp fn(T, T) -> int) -> Option[T]

_by_of variants не требуют Compare — порядок задаётся callback’ом.

Utility (без bound на T)

fn[T] []T mut @reverse_of() -> @
fn[T] []T @position_of(pred fn(T) -> bool) -> Option[int]
fn[T] []T @count_of(pred fn(T) -> bool) -> int
fn[T] []T @find_of(pred fn(T) -> bool) -> Option[T]

Алгоритмы

  • sort_of / sort_by_of: stable insertion sort, O(n²). Подходит для массивов до ~1000 элементов. Followup [M-91.8c-pdq-sort] для pdq/intro-sort на крупных данных.
  • binary_search_of: classic O(log n) binary search; requires pre-sorted array. Returns Option[int] (index если найдено, None если нет). Отличие от Vec canonical @binary_search -> Result[int,int] (D239 / vec/access.nv): тот возвращает Ok(idx) / Err(insertion_point) — более информативно; sort.nv вариант — legacy Option form для backward compat.
  • min_of / max_of: linear scan O(n), None для empty.
  • min_by_of / max_by_of: linear scan с callback, None для empty.

Backward compat

Concrete []int @sort() / @sort_by() / @min() / @max() в std/sort.nv сохранены. Generic sort_of / min_of / max_of работают на любом T Compare включая int и str. Overload resolution: конкретный receiver ([]int) выбирает concrete method, generic — _of.

Связь

  • D183 (Plan 91.8a) — Compare.compare -> int convention.
  • D178 — str.compare via native nova_str_compare.
  • D72 — generic bounds [T Protocol].
  • D239[]T ≡ Vec[T] alias.
  • Plan 91.3 — concrete []int @sort.

D373 NEW (ex-D185, renumber 2026-07-03).

D373 §amend-1 — direct @[i].method() dispatch in generic array methods

Дата: 2026-06-17. Закрывает: [M-91.8c-direct-index-method].

Codegen fix: при вызове @[j].compare(key) внутри generic fn[T Compare] []T-тела компилятор не мог вывести тип элемента для SelfAccess-объекта и пропускал dispatch.

Изменения в compiler-codegen/src/codegen/emit_c.rs:

  1. compute_array_elem_type_for_obj — добавлен arm для ExprKind::SelfAccess (ранее только ExprKind::Ident и другие; SelfAccess без arm → элемент-тип не выводился).
  2. emit_monomorphized_method — при входе в метод заполняет array_element_types["nova_self"] из receiver-типа mono-инстанса, делая тип элемента доступным при обработке вложенных @[i].method()-вызовов до того, как в теле появится explicit присвоение.

Результат: @[j].compare(key) в fn[T Compare] []T-телах (sort_of, binary_search_of и т.д.) теперь диспетчится корректно без промежуточного binding’а.

D373 §amend-2 — @sort_unstable* переведены с heapsort на pdqsort (Plan 153.3.1, 2026-06-18)

Дата: 2026-06-18. Закрывает: [M-153.3-sort-pdqsort] + [M-91.8c-pdq-sort].

@sort_unstable / @sort_unstable_by / @sort_unstable_by_key теперь вызывают @_pdqsort вместо @_heapsort.

Алгоритм @_pdqsort (итеративный, без рекурсии — Nova-safe):

  • n ≤ 1 → return immediately (no work)
  • n ≤ 16 → @_ins_sort_range (insertion sort, cache-warm)
  • stack.len ≥ depth_limit (depth_limit = 2·ilog2(n)+2) → heapsort fallback на диапазон через temp Vec
  • иначе: median-of-3 pivot (→ @_median3_to_end) + Lomuto partition + Vec[int] work-stack (lo/hi pairs)

O(n log n) worst case, O(log n) stack space. Heapsort сохранён как depth-guard fallback и для @select_nth_unstable (не удалён). Stable @sort* (merge sort) не тронут.

D315. ResolvedType — единый канонический носитель типа (Plan 172.1, 2026-06-21)

Статус: ACTIVE. Single source of type truth. Реализует compiler-conventions.md §0. Supersedes врезку M2 Plan 172.1 («ResolvedType достаточен как носитель» — спайк 2026-06-21 доказал обратное).

Что

Тип в компиляторе имеет ОДНО каноническое представление — ResolvedType. Из него выводится ВСЁ: проверки, совместимость типов, конверсии и перевод в C. Legacy-путь «TypeRef → C» (type_ref_to_c, который резолвит И переводит в одном проходе) — ретайрится.

Правило

  • TypeRef — только синтаксис (выхлоп парсера). Может нести Self, свёрнутые алиасы, неразрешённые имена, generic-параметры. Это сырая, неразрешённая форма → НЕ вход для перевода в C (сперва его надо разрешить).
  • Семантический анализ разрешает тип ОДИН раз в канонический ResolvedType: Self→приёмник, алиасы развёрнуты, имена→конкретные объявления, дженерики. Каноничность: семантически равные типы → структурно равные ResolvedType (иначе совместимость = сравнение типов ломается).
  • ResolvedType несёт ПОЛНУЮ семантическую личность: разрешённая идентичность (а не path.last()), generic-аргументы, ширина/знак (Scalar{width,signed,wide_default}), эффекты, все оси изменяемости (L1 binding / L2 view / L3 pointee, D246), верность модификаторов указателя.
  • Сахар нормализуется прочь (намеренно): имя алиаса, написание Self. Это не потеря — это и делает окно единым. Исходное написание, нужное для текста ошибки, — отдельный диагностический канал, не часть канонического типа.
  • ABI/бэкенд-факты — НЕ хранятся в типе, а ВЫВОДЯТСЯ. Мангл-имя, erasure (Option[void*]NovaOpt_nova_int), NovaValue_-префикс (D228), int64-слоты — это решения лоуэринга, не свойства типа. Их выводит resolved_type_to_c (живёт в codegen: финальная mono-подстановка + побочки эмиссии typedef’ов, но БЕЗ повторного резолва — резолв уже сделан чекером).
  • Один вывод (чекер), один лоуэринг (resolved_type_to_c). type_ref_to_c ретайрится: объявленные типы (сигнатуры, поля) тоже идут через «разрешить → опустить».

Почему

  • §0 (единый источник истины). type_ref_to_c сегодня делает ДВЕ вещи: (1) резолвит — разворачивает алиасы, превращает Self в приёмник, подставляет mono; (2) переводит в C. Первая половина дублирует чекер — ровно §0-анти-паттерн «codegen re-derive». Разделение «разрешить один раз (чекер) → опустить (codegen)» убирает дубль.
  • Два окна правды неизбежно дрейфуют. ResolvedType для проверок + TypeRef-driven перевод в C = тот самый класс багов Vec[u32]-мис-манглинга (simple_type_ref_to_c дрейфнул, пропустил u32). Одно каноническое окно убивает класс дрейфа в корне.
  • Спайк (2026-06-21) доказал: текущий ResolvedType лосси для C — берёт path.last() (теряет модуль), схлопывает *mut T (форма Pointer(Mut)) в TypedPtr(Ro,…), разворачивает L2 readonly. Значит прежнее допущение плана (M2: «носитель достаточен») неверно; D315 ставит целью обогащение до lossless-canonical + ретайр type_ref_to_c.

Что отвергнуто

  • «TypeRef → C — нормальный лоуэринг». Отвергнуто: TypeRef неразрешён (Self/алиас) → не вход для перевода, его надо сперва разрешить.
  • «Ничего не обогащать; реконструировать TypeRef из ResolvedType на лоуэринге». Отвергнуто: лосси round-trip (выбрасываемый костыль); возвращает резолв-в-codegen — тот самый анти-паттерн.
  • «Второй лоуэринг resolved_type_to_c рядом с type_ref_to_c». Отвергнуто: два лоуэринга = фрагментация, которую §0 запрещает; цель — ОДИН.
  • «Хранить ABI-факты в ResolvedType». Отвергнуто: засоряет семантический тип бэкенд-заботами; ABI выводится, не хранится.

Связь

  • compiler-conventions.md §0 — D315 это его конкретная формулировка про носитель типа (§10 анти-паттерн «два окна правды»).
  • D246 — три оси мутабельности; ResolvedType обязан нести все три.
  • D129 / D227int=i64 wide-default vs sized; несётся через Scalar{width,signed,wide_default}.
  • D239 ([]T≡Vec[T]), D228 (value-record), D216 (typed pointers) — это лоуэринг (ABI) факты, выводятся не хранятся.
  • Plan 172.1 U.4/U.5/U.6.1 — реализация: U.5 унифицировал внутреннее представление чекера; U.4 делает ResolvedType носителем для codegen; ретайрит type_ref_to_c.

Эволюция

  • U.5 (2026-06-20): ResolvedType введён как внутренний width/sign-тип чекера (заменил Ty/TyCat/cat_of).
  • 2026-06-21 (D315): поднят из «внутренний тип чекера» в «единый канонический тип компилятора»; спайк нашёл текущий носитель лосси → обогащение-до-lossless + ретайр type_ref_to_c поставлены целью. Supersedes врезку M2 Plan 172.1.
  • 2026-06-21 (Plan 172.1 U.4.6→U.4.8): цель реализована. U.5.5(a) сделал ResolvedType lossless для C (модуль-путь / *mut / L2 readonly); U.4.6 построил единый resolved_type_to_c (ABI-лоуэринг ЧТЕНИЕМ полей ResolvedType, без повторного резолва) до byte-identical паритета; U.4.7 флипнул type_ref_to_c на делегирование; U.4.8 (e1f1d96a) удалил дублирующий type_ref_to_c_implresolved_type_to_c стал Result<String,String> (несёт причину отказа сам: usize/isize/ptr removed, Self-no-recv). Production type→C теперь ОДИН лоуэринг. Остаток — свернуть синтаксический адаптер-хоп TypeRef→ResolvedType на объявленных-тип сайтах (U.6.1).
  • 2026-06-21 (Plan 172.1 U.5.5c, f7511bda): носитель стал lossless и для ЭФФЕКТОВ — ResolvedType::Func.effects был Vec<String> (только имя, from_type_refpath.last(), дропал generics → Fail[E] терял E, нарушение «несёт ПОЛНУЮ семантическую личность»). Обогащён до Vec<ResolvedType> (имя + module + type-args через lossless Named). Разблокирует typed-errors Plan 173 (Fail[E]-dispatch по type_id) + Plan 174.3 (any/is) — садятся на готовый носитель, не переделывая. Byte-identical конструкцией (effects write-only до consume).

D326 — ref как режим передачи параметра (safe in-out / borrow); @/-> @ формализация (Plan 172.5, 2026-06-26)

⚠️ РЕВИЗИЯ (Plan 184, 2026-07-06, sign-off владельца). Нижеследующая исходная формулировка D326 («ref — режим, НЕ тип»; явные формы mut ref x T / ro ref x T; call-site маркер f(ref x)) ПЕРЕВЁРНУТА. Актуальная нормативная модель — раздел «## Ревизия D326 (Plan 184): ref T — ограниченный тип» ниже (правила Р1–Р14). Читать исходные R1–R12 как историю: R1 (ref не тип) и R4 (call-site маркер) — ретрактированы; их места помечены «⛔ РЕТРАКТИРОВАНО Р-184». R2/R3-формы параметра (mut ref/ro ref) заменены на mut x T / x T (Р10).

Source: Plan 172.5 (in-out ref params), 2026-06-26 — owner переоткрыл Q29 в param-mode. Status: 🔄 REVISED (Plan 184, 2026-07-06): исходное ядро (Plan 172.5) реализовано, но модель пересмотрена — ref T становится ограниченным типом (параметры-внутренне / возвраты / локалы), маркер вызова и формы mut ref/ro ref в сигнатуре удалены. Актуальные правила — раздел «Ревизия D326 (Plan 184)» ниже. Историческая справка о landed-ядре 172.5 (mut ref in-out params, эксклюзивность E_REF_ALIAS_OVERLAP, addressability/mut-place/escape-ban; 2 pos + 11 neg фикстуры) сохранена в исходном тексте. Amends: Q29 (open-questions.md — снимает отвержение param-mode; ref-ТИП остаётся отвергнут), D132 (03-syntax.md — alias-гарантия -> @ ↔ R7), D228 (value-record @ escape-decay R8). Adopt verbatim: D181/D184 (режим возврата @). Bounds: D157 (05-memory.md) + D246-P10 (эксклюзивность УЗКАЯ, не Rust/Swift). Cross-ref: Plan 172.4 / Q-value-abi-auto-placement (авто-ro ref + heap↔stack — НЕ дублировать), D315 (ABI выводится), D246 (L3 pointee-cap / RETURN-оракул), D131/D133/D180 (consume — borrow≠move), D156 (consume-bound), Plan 174.5/174.6 (raw pointers / FFI).


Ревизия D326 (Plan 184): ref T — ограниченный тип

Source: Plan 184 (ref-type-revision), обсуждение 2026-07-06, дизайн надиктован владельцем (четыре раунда решений). Status: 🟢 РЕАЛИЗОВАНО ПОЛНОСТЬЮ (заходы 1-6, 2026-07-07). Факт по правилам: Р1✅ Р2✅ Р3✅ Р4✅ Р5✅ Р6✅ Р7✅ Р8✅ Р9✅ Р10✅ Р11✅ Р12✅ Р13✅ Р14✅ (заход-6: режим параметра {ro,mut,consume} — ось перегрузки, единая с receiver-mut; раздельные C-символы через MethodSig.param_modes + collision-triggered mode-tag; диспатч narrow_by_param_mode по изменяемости аргумента-биндинга / owned- временному; см. D84-подраздел «Ось режима» в 10-overloading.md и docs/plans/184). Носитель — TypeRef::Ref(T); коды E_REF_TYPE_POSITION (Р1/Р6), E_REF_ESCAPE (Р8), E_REF_ALIAS_OVERLAP (Р9/Р12), E_MUT_ARG_NOT_MUTABLE (Р10); ref-локалы — истинный указатель-алиас (write-through). Amends: переворот исходного D326-R1 («режим, НЕ тип» → «ограниченный тип»); ретракция D326-R4 (call-site маркер); замена форм D326-R2/R3 (mut ref/ro ref в сигнатуре) на mut x T / x T (Р10). Q29-история уточняется: Q29 отвергал НЕограниченный ref-тип (ссылки в полях/коллекциях/кучевые утечки → лайфтаймы); ограниченный ref T (только параметры-внутренне, возвраты, локалы; запрет утечки в кучу) — НЕ то, что отвергал Q29.

Суть

ref Tвалидный тип (аналог C++ T&), но ограниченный позициями: параметр (внутренне), возврат, локальная переменная. Мотивация-первопричина: легализовать тип приёмника — @ это всегда ref Self (для стековых value-типов), и -> @ — тоже. Ссылка на стек не должна утекать в долгоживущую кучу — отсюда позиционное ограничение.

Правила (нормативно)

(Р1) Позиции ref T. Легален: возврат (-> ref Self — де-сахар -> @ для value-типов), локал-алиас (ro y ref T = x.a.b / mut y ref T = … — именованная ссылка на место; истинный указатель-алиас в кодогене, mut-запись достигает цели), тип приёмника (@ = ref Self). ЗАПРЕЩЁН в: полях record/value, коллекциях (Vec[ref T]), суммах (enum), Option[ref T], тип-аргументах дженериков (turbofish f[ref T], size_of[ref T] и любой f[...]) И в параметрах (формы ref сняты Р3/Р10). Диагностика в запрещённой позиции — E_REF_TYPE_POSITION. Причина запрета: ссылка на стек-сторадж не должна попасть в долгоживущую кучу (без этого нужны лайфтаймы); ref нехраним и не имеет «размера как тип» — только ABI-деталь передачи, поэтому в тип-аргументе он = код, зависящий от режима передачи. Взаимодействие с Р6: проверка позиции идёт ПОСЛЕ нормализации ref H ≡ H — для КУЧЕВОГО H ref H легален в любой позиции (в т.ч. тип-аргументе: f[ref H] ≡ f[H], size_of[ref H] == size_of[H]), реджектится только ref <value/generic/unknown>. Реализация: types/mod.rs walk_typeref Ref-арм + ref_target_confirmed_heap; легальные top-level позиции снимают ведущий ref через walk_ref_return.

(Р2) mut при ссылке. В mut-контексте ссылки mut = право ПИСАТЬ через ссылку в цель. Сама ссылка непереселяема (как C++ T&: связал — навсегда; переприсваивания ссылки на другой сторадж нет).

(Р3) ref ИСЧЕЗАЕТ из сигнатур параметров (Р10-развёртка). Формы mut ref x T / ro ref x T / ref x T в списке параметров — удалены. Синтаксис параметра — тройная ось режима БЕЗ слова ref:

  • ro: f(x T) — представление (копия ≤~16 Б либо скрытая ссылка) выбирает компилятор по размеру; ненаблюдаемо (с оговоркой Р12). Явного ro ... ref в параметре нет.
  • mut: f(mut x T)in-out ссылка ВСЕГДА (унификация стека/кучи: на куче приватной мутируемой копии и так нет). Локальная приватная копия — явным локалом mut y = x.
  • consume: f(consume x T) — владение; представление считает компилятор.
  • ref остаётся ТОЛЬКО как: тип приёмника (@ = ref Self у value), -> @ (= -> ref Self у value), локальные алиасы (Р1). Диагностики парсера при попытке старых форм: E_REF_PARAM_FORM_REMOVED (mut ref/ro ref/ref в параметре → hint «пишите mut x T (in-out)»), E_REF_CALL_MARKER_REMOVED (маркер вызова, ниже Р4).

(Р4) МАРКЕР ВЫЗОВА ref x — УДАЛЁН. Вызов везде f(x) — без ref x. Обоснование владельца: кучевые объекты и так мутируются без маркера — маркер на стековых давал ложное чувство «нет ref = нет мутации». f(ref x) → парс-ошибка E_REF_CALL_MARKER_REMOVED (hint «маркер удалён (D326-ревизия): пишите f(x)»). ⛔ РЕТРАКТИРУЕТ исходный D326-R4.

(Р5) Автоконверсия. ref T -> T автоматически (чтение = разыменование). T -> ref T на месте вызова автоматически для адресуемых аргументов (неадресуемый аргумент к mut-ссылке — ошибка E_REF_ARG_NOT_ADDRESSABLE, как в исходном D326).

(Р6) Нормализация над кучей. Для кучевого H: ref HH (значение уже handle; ссылка на handle не вводится). Важно для обобщённых f[T](mut x T) при T = H — mono не плодит ref-обёрток над handle.

(Р7) Типизация приёмника (таблица — заменяет эвристики D181/R7-R8).

Категория Selfro @mut @-> @
Стековый (value) типref Self (или копия по размеру, невидимо)mut ref Self-> ref Self
Кучевой (heap) типSelfSelf-> Self

Магия D181-R7/R8 («heap алиас / value копия-с-распадом» через RETURN-оракул и escape-decay) ЗАМЕНЯЕТСЯ этими типами: -> @ теперь имеет конкретный тип (-> ref Self у value, -> Self у heap), а не разрешается эвристикой на bind-site. consume @ -> @ остаётся парс-ошибкой E_CONSUME_RECEIVER_RETURNS_AT.

(Р8) Безопасность без лайфтаймов. Висячие ссылки исключаются связкой: запрет утечки в кучу (Р1) + существующий escape-анализ с авто-промоутом (D216 §4: убегающий источник поднимается в кучу) + запрет захвата ссылок замыканиями/spawn/parallel. Только синхронный lifetime вызова.

Уточнение Р8 (заход-5): &<ref-цель> и авто-промоут. Для ССЫЛКИ-цели (приёмник @ value-типа = ref Self; ref-локал) авто-промоут D216 §4 НЕ применяется — промоутится только СВОЁ значение-локал, а ref указывает на ЧУЖОЕ хранилище (промоут копии тихо меняет семантику; промоут исходного места функции недоступен). Правило:

  • &<ref-цель> эскейпящий НАРУЖУ (return / захват замыканием / запись в поле) → реджект E_REF_ESCAPE с подсказками (взять владение consume @ + промоутить своё; либо явная локальная копия ro v = @ и вернуть &v). Живой пример-негатив: fn T @addr() -> *T => &@.
  • &<ref-цель> ВНИЗ по стеку (аргумент вызова, ffi out-параметр) → легален без промоута (заём короче исходного) — действующий канон nova_str_parse_f64(s, &v).
  • адрес КУЧЕВОГО содержимого через ресивер (Vec @ptr => @data) — легален (не путать: у heap-приёмника @ = handle, промоут копии handle корректен). Реализация: types/mod.rs check_ref_addr_escape — value-приёмник, &@ в escaping-позиции (возврат/trailing/return/тело замыкания/RHS-в-поле). Захват ref-локала замыканием — follow-up.

(Р9) Эксклюзивность/адресуемость/FFI — без изменений от исходного D326 (узкая синтаксическая E_REF_ALIAS_OVERLAP; на extern-границе только сырые */*mut, никогда ref), но эксклюзивность расширяется — см. Р12.

(Р10) ref исчезает из сигнатур параметров вовсе (развёрнуто в Р3): единая тройная ось {ro, mut, consume} без ref-слова; представление ro выбирает компилятор по размеру; mut = in-out всегда; consume = владение. Неоднозначность перегрузок исчезает по построению (форма параметра одна на режим).

(Р11) Аудит-миграция mut-параметров. Семантика mut x T меняется (была приватная копия → станет in-out): перед реализацией Ф.2 — grep-аудит всех mut-параметров std/тестов; кто полагался на приватность копии (мутирует параметр как рабочую копию, а вызывающий использует исходное значение после) — переписать на явный локал mut y = x. Результат аудита — в отчёте Ф.0/Ф.1 плана 184.

(Р12) Расширение узкой эксклюзивности. ro-автопредставление наблюдаемо при алиасинге ro×mut в ОДНОМ вызове (f(a, a) c f(x Big, mut y Big): ссылка vs копия дают разное чтение x после записи y). E_REF_ALIAS_OVERLAP расширяется с пар mut×mut на пары mut×(любой параметр того же root-пути) — тот же синтаксический критерий R9 (стирание индексов, prefix-overlap). Реализовано (заход-5): check_ref_arg_modes собирает места НЕ-mut value-параметров и проверяет пересечение с mut-местами; f(a,a) при f(x Big, mut y Big)E_REF_ALIAS_OVERLAP; ro×ro того же места и distinct — легальны.

(Р13) Перегрузка по режиму параметра — ЛЕГАЛЬНА, по прецеденту ресивера. Ось «изменяемость ресивера» уже существует (Plan 135, D84fn T @m() vs fn T mut @m(): разные символы, диспатч по изменяемости биндинга). Для параметров — то же правило: f(x T) vs f(mut x T) различаются изменяемостью аргумента-биндинга; mut-аргумент предпочитает mut-перегрузку, иначе ro (приоритет как у ресивера).

(Р14) @ — форма параметра; единая тройная ось режима. Ресивер = нулевой параметр. Ось диспатча {ro, mut, consume} едина для ресивера и параметров: различимы @func / mut @func / consume @func И f(x T) / f(mut x T) / f(consume x T). Правило выбора: ro-аргумент → только ro-версия; mut-биндинг → mut-версия приоритетнее ro (прецедент ресивера, Plan 135); consume-версия участвует в резолве ТОЛЬКО когда аргумент в последней точке использования (consume-чекер это уже вычисляет) — тогда она специфичнее mut; иначе исключена. Детерминизм без молчаливого потребления живого биндинга. См. амендмент D84 (тройная ось режима параметров).

✅ СТАТУС Р13/Р14 (заход-6, 2026-07-07): РЕАЛИЗОВАНО. f(x T) / f(mut x T) / f(consume x T) — легальные перегрузки (ось режима параметра входит в overload-сигнатурный ключ: types/mod.rs dup-детект сравнивает is_mut+consume каждого параметра наряду с типом/арностью/возвратом/receiver-mut). Раздельные C-символы — MethodSig.param_modes + мангл-суффикс режима, добавляемый ТОЛЬКО при коллизии одинаковых param_c_types (кучевой случай Р6; существующие символы byte-identical). Диспатч — emit_c.rs::narrow_by_param_mode: ro-аргумент → ro-версия; mut-биндинг → mut-версия (приоритетнее ro, прецедент Plan 135); owned-временное (последняя точка) → consume-версия. Неоднозначность невозможна по построению (классы аргумента взаимоисключающи). Полные факты и правило выбора — D84-подраздел «Ось режима {ro, mut, consume}» в 10-overloading.md. Ограничение: value/прим mut-mode-overload меняет ABI на указатель — scoped follow-up [M-184-value-mut-mode-overload-abi]; кучевой T (канонический in-out) покрыт полностью.

Что ретрактировано ревизией Р-184

  • D326-R1 (частично): «ref — режим, НЕ тип; запрещены ref T-локалы/возвраты» — ретрактировано. ref T теперь ограниченный тип (Р1): легален в параметрах-внутренне, возвратах, локалах-алиасах. Запрет сохранён только для полей/коллекций/сумм/Option.
  • D326-R2/R3 (формы параметра): mut ref x T / ro ref x T / ref x T в сигнатуре — удалены; заменены на mut x T / x T (Р10). Диагностика E_REF_PARAM_FORM_REMOVED.
  • D326-R4 (call-site маркер ref x): удалён полностью (Р4). Диагностика E_REF_CALL_MARKER_REMOVED.
  • D181/R7-R8 эвристики (RETURN-оракул + escape-decay для -> @): заменены типами Р7.

Точки возобновления (для последующих заходов)

  • Ф.2 (чекер): in-out семантика mut x T для стековых типов (тип Ref(T) в носителе, нормализация Р6, запрет позиций Р1, эскейп-правила Р8, расширение эксклюзивности Р12, перегрузка Р13/Р14). До Ф.2 парсер принимает mut x T, но чекер оставляет старую семантику (приватная копия) — зелёное дерево не ломается.
  • Локалы-ссылки ro y ref T = expr (Р1): парсер/чекер — если не влезло в Ф.1, ref в тип-позиции пока остаётся E_REF_NOT_A_TYPE.

Что

refрежим передачи параметра (borrow), НЕ тип. Даёт безопасную in-place мутацию caller-значения (mut ref) и zero-copy чтение больших стек-значений (ro ref, авто) без сырых указателей и без лайфтаймов. Модель = Swift inout / C# in+ref. @-ресивер — частный случай: mut @mut ref @, ro @ro ref @, -> @ref @.

Правило

(R1) ref — режим параметра, НЕ тип.РЕТРАКТИРОВАНО Р-184 (см. ревизию выше: ref T теперь ограниченный тип; запрет сохранён только для полей/коллекций/сумм/Option). Запрещены: ref T-локалы/биндинги, ref-поля, ref в Vec/коллекции/sum/Option, ref-возвраты. Единственное исключение — ресивер @ и его -> @. Лайфтаймов нет. Это НЕ реинтродукция отвергнутого Q29 ref-ТИПА — это param-mode, консистентный с обоснованием самого Q29.

(R2) Две формы. ro ref a T — read-only borrow (авто). mut ref a T — mutable in-out borrow (единственный явный user-facing; callee пишет в caller-сторадж, видно после синхронного вызова).

(R3) ro ref — авто/невидимо (это Plan 172.4 / Q-value-abi-auto-placement, не дублируем). Компилятор передаёт value-параметр скрытым ro-указателем вместо копии, когда sizeof > ~2*sizeof(ptr) (≈16B) и копия ненаблюдаема; семантически тождественно by-value для ro. Маркеров нет.

(R4) call-site маркер ref.РЕТРАКТИРОВАНО Р-184 (маркер вызова удалён; вызов везде f(x); f(ref x)E_REF_CALL_MARKER_REMOVED). mut ref-аргумент на месте вызова помечается: inc(ref x) (короткий ref, не mut ref). Цель — читатель видит возможную мутацию x, не открывая сигнатуру (как C# ref, Swift &, Rust &mut; & занят addr_of, Plan 118.1). ro ref (авто) маркера НЕ имеет.

(R5) ABI ресивера. mut @mut ref @всегда by-pointer (любой размер; нужно для видимости мутации; даже 1-байтный type Flag value {b bool}). ro @ro ref @ — size-discretionary (мелкий by-value, большой hidden-ptr; невидимо — ro content-immutable). Это две РАЗНЫЕ ref-нормы (auto-size для params vs always-by-ptr для mut-ресивера), не сливать.

(R6) -> @ — режим = режим ресивера (D181 дословно, не расширяется). fn T @m() -> @ro ref @; fn T mut @m() -> @mut ref @; fn T consume @m() -> @ → parse-error E_CONSUME_RECEIVER_RETURNS_AT. В цепочке x.a().b() режим @ от a() гейтит вызываемость b(): ro ref @ → только ro @-методы; mut @ на нём → E_RECEIVER_BINDING_NOT_MUT (c.peek().bump() = ошибка).

(R7) -> @ при биндинге — НЕ новое decay-правило, а D246 RETURN-оракул для типа ресивера:

  • heap-record / consume / builder-ресивер → биндинг (ro y = И mut y =, вкл. mid-chain mut b = sb.append(); b.append()) — ВСЕГДА АЛИАС, не копия (гарантия D131/D132, load-bearing для consume-чекера; копия раздвоила бы два хэндла на один буфер → потеря use-after-consume).
  • value-record-ресивер → биндинг = D246 -> Value-оракул = копия (те же ro/mut-знаки коэрсии, ORACLE D).

(R8) value-record @ НЕ эскейпит указателем. В пределах одного полного выражения (пока сторадж ресивера жив) -> @ может оставаться ref; при биндинге/возврате/хранении — decay by-value (D246-оракул). Для rvalue-ресивера (P{x:0}.inc()) хвостовой @ decay’ится в слот биндинга/возврата, НЕ указателем в __tmp_recv (умирает в конце выражения). D228-escape-walker @ не покрывает (срабатывает на явный &v) → этот decay — замещающее escape-правило. E_AT_RETURN_OUTSIDE_METHOD (free-fn -> @) сохраняется.

(R9) Эксклюзивность mut ref — УЗКАЯ синтаксическая, НЕ Rust/Swift. E_REF_ALIAS_OVERLAP срабатывает ⟺ два mut ref-аргумента в ОДНОМ вызове — проекции одного root-локала и один путь — префикс другого (после стирания индексов, не доказуемо-различных int-литералов). f(mut ref x, mut ref x) reject; f(mut ref x.a, mut ref x.b) OK; f(mut ref x.a, mut ref x) reject; arr[1] vs arr[2] OK; любой неконстантный arr[i] vs arr[j] → консервативно overlap (нет SMT/i≠j-прувера в V1, over-reject sound). Явно оговорить в спеке: это анти-footgun, НЕ гарантия — aliased-мутация остаётся sound-под-GC везде ещё (D157, D246-P10 «нет эксклюзивности (GC)»). Через указатели/heap-хэндлы/два []T-слайса над одним буфером эксклюзивность НЕ заявляется (undecidable).

(R10) Эскейп ref запрещён (кроме @, который сам decay’ится R8): не хранить в поле/heap, не захватывать closure/spawn/parallel/supervised/detach, не возвращать. Только синхронный lifetime вызова — это и делает no-lifetimes-модель звучной.

(R11) Слоинг / FFI. ref = safe non-null non-escaping pointer (повседневный инструмент); *T/*mut T = сырые (FFI/unsafe, D246 L3). mut ref ≈ pointee-mut от *mut T но safe+non-escape; ro ref*T. На extern-границе — только сырой */*mut, никогда ref (у ref нет стабильного ABI — это lowering-выбор).

(R12) Дженерики / consume. fn f[T](mut ref x T) — ок (mode ортогонален type-param, не в type-arg-позиции → mono не затронут). [T consume]-bound (D156) несовместим с ref на том же месте (borrow ≠ move). mut ref НЕ консьюмит аргумент (D131/D133 без изменений). Overlap-отношение — НОВОЕ (per-pair prefix), строится РЯДОМ с consume place-анализом (делит только парсинг места Ident/Member/Index, не решётку MOVE/CONSUME) — НЕ «субсумировано D131».

Почему

mut @ + lvalue (как считал Q29 2026-06-21) НЕ покрывает: out-параметры, мутацию НЕ-ресивера, несколько mut-аргументов (swap(mut ref a, mut ref b)). Swift (inout) и C# (in/ref) оба имеют user-facing in-out РЯДОМ с авто-оптимизацией — не полагаются только на методы. Узкий mut ref закрывает дыру минимальной ценой (param-only, без лайфтаймов, локальная call-site-проверка). Формализация @/-> @ делает reference явной сущностью лоуэринга. Для одиночной мутации канон — mut @, НЕ mut ref (mut ref — узкий инструмент для мульти-mut / control+мутация, не «способ менять любой параметр»).

Что отвергнуто

  • ref как ТИП (ref T, ref-локалы/поля/возвраты, ref в коллекциях) — Q29 (остаётся отвергнут); вернуло бы лайфтаймы.
  • Rust/Swift-уровень exclusive-borrow soundness — Nova сознательно разрешает aliased-mut под GC (D157/D246-P10); берём лишь узкий анти-footgun.
  • codegen-субсумция [M-177-ifexpr-value-materialize-codegen] — снята: тот баг = infer_If/emit_if_expr desync (R3-repair), закрыт отдельно (836befcb, 2026-06-26); ссылки его НЕ чинят. Его fixture (fluent -> @-хвост в if-цепочке) → лишь acceptance-гейт 172.5 (должна компилиться после ref-формализации).

Связь

D181/D184 (режим @), D246 (L3 / RETURN-оракул / P10 no-exclusivity), D131/D132/D133/D180 (consume / alias-гарантия), D228 (escape), D315 (ABI выводится), D156 (consume-bound), D157 (multi-mut sound под GC), Q29 (amend), Plan 172.4 (авто-ro ref/@/heap↔stack — реализует часть), Plan 174.5/174.6 (raw pointers / FFI). Новый код ошибки ровно один: E_REF_ALIAS_OVERLAP; остальное переиспользует existing (E_RECEIVER_BINDING_NOT_MUT, E_CONSUME_RECEIVER_RETURNS_AT, E_AT_RETURN_OUTSIDE_METHOD).

Амендменты по факту реализации (Plan 172.5 Ф.1-Ф.5, 2026-07-06)

  • mut ref = единственная user-facing реализованная форма. ro ref синтаксис принят (R2), но его zero-copy lowering НЕ дублируется — это size-driven авто-механизм Plan 172.4 (R3). Explicit ro ref — семантическая аннотация, которая иначе передаётся как обычный value-параметр; call-site маркер ref на ro ref-параметре — ошибка (E_REF_MARKER_NOT_ALLOWED, R4: маркер сигнализирует ВОЗМОЖНУЮ мутацию, только для mut ref).
  • Lowering mut ref (Ф.4): параметр → C-указатель T*params_c, единый для forward-decl и definition); body-использования имени авто-разыменовываются (name(*name), набор ref_params в эмиттере); call-site ref x&x (узел ExprKind::RefArg). Форвардинг ref-параметра в другой mut ref-вызов (&(*v) ≡ v) работает. Скаляр + record проверены (pos-фикстуры зелёные).
  • AST: ref — глобальное keyword (TokenKind::KwRef; ref не используется идентификатором нигде в std/examples/tests → non-breaking). Param.ref_mode: ParamRefMode{None,RoRef,MutRef}; call-site — ExprKind::RefArg(place) (не тип-узел, не UnOp — производится парсером ТОЛЬКО в arg-позиции).
  • Коды ошибок (checker/parser). Новый headline-код — E_REF_ALIAS_OVERLAP (R9, per-pair prefix-overlap; поддержаны литерал-дизъюнктные индексы, dynamic → консервативный overlap). Механические диагностики (не заявленные в исходном дизайне как existing, объявлены как новые по факту): E_REF_NOT_A_TYPE (R1, ref в тип-позиции — parse), E_REF_MODE_REQUIRES_RO_OR_MUT (голый ref без ro/mut — parse), E_REF_MARKER_REQUIRED (пропущен ref на mut ref, R4), E_REF_MARKER_NOT_ALLOWED (ref на не-mut ref, R4), E_REF_ARG_NOT_ADDRESSABLE (не-lvalue / index-в-цепочке, R4), E_REF_ARG_NOT_MUT (borrow ro-места, R2), E_REF_ESCAPE_CAPTURE (захват mut ref-параметра closure/spawn, R10). E_CONSUME_RECEIVER_RETURNS_AT реюзнут на существующей parse-проверке consume @ -> @ (R6).
  • R6 mid-chain gating (E_RECEIVER_BINDING_NOT_MUT) — ОТЛОЖЕНО (followup [M-172.5-chain-gating-ro-at]). parse-часть R6 (consume @ -> @) сделана; но гейтинг «mut-метод на ro -> @-хвосте» (c.peek().bump()) требует моделирования режима @-возврата сквозь method-chain — глубокое взаимодействие с fluent-машинерией 172.4, вне soundness in-out mut ref. Сейчас такой вызов компилируется (value-record -> @ = копия по R7b, мутация копии безвредна); диагностика — отдельная задача.
  • Generic fn f[T](mut ref x T) (R12) — codegen отложен ([M-172.5-generic- mut-ref-codegen]): params_c/ref_params покрывают неген. путь (concrete); erased/mono-пути mut ref не лоуэрят указатель. Checker-часть R12 (mode ортогонален type-param) не блокирует.

D327 — Codepoint = u32 (а не int): тип кодпоинта в std.unicode (Plan 172.2, 2026-06-26)

Source: Plan 172.2 (scalar narrowing через method-arg), 2026-06-26. Миграция std.unicode под narrowing-чек (D54) вскрыла int↔u32-импеданс: кодпоинты типизированы int, но хранятся Vec[u32] — каждый push был неявным сужением. Owner предложил хранить кодпоинт как u32; обсуждение выявило, что D226 «Signed indexing convention» к кодпоинтам неприменим. Status: ✅ ADOPTED (sign-off владельца 2026-06-26). Amends: nv-coding-style.md §числовые-ширины (новый пункт «Codepoint = u32»). Снимает аномалию is_alphabetic(cp int) (был int-кодпоинт по инерции signed-правила). Cross-ref: D128 (char = nova_char = uint32_t), D226 (signed indexing — ОТДЕЛЬНАЯ категория), D54 (implicit narrowing — мотивация), D77 (fallible → Option).

Что

Unicode codepoint (scalar value, 0..0x10FFFF) — u32, НЕ int. Кодпоинт — character-data интринсик-ширины 32 бит, как UTF-16 code unit — u16 (nv-coding-style: «u32 — когда значение само по себе этой ширины»). Это категория значение-идентификатор, ОТЛИЧНАЯ от D226-категории index/len/offset/счётчик (мера, где underflow/-1-сентинел/mixed-arith мотивируют signed int). D226 к кодпоинтам не относится — кодпоинт не индексируют и не вычитают как длину.

Правило

  1. Хранилище последовательностей кодпоинтов — Vec[u32] (4 байта; ср. Rust Vec<char>, Go []rune=[]int32). НЕ Vec[int] (вдвое память/кэш на горячих путях коллации/нормализации — §2 perf).
  2. Поток и арифметика внутри unicode-движков — u32. Это убирает int↔u32-границу by-construction (нет неявного narrowing, нет россыпи as u32).
  3. char (= u32, D128) — семантический тип кодпоинта на границе str: s.as_chars()char, char.try_from(u32) → валидный скаляр, char-методы ('a'.is_alphabetic()).
  4. Публичные cp-функции принимают u32 (general_category(cp u32), is_alphabetic(cp u32)). Целочисленные литералы адаптируются к u32-контексту → general_category(0x41) остаётся валидным (контракт Plan 159 / plan152_3 сохранён). char-методы делегируют через @ as u32.
  5. Fallible-функции, выдающие кодпоинт → Option[u32] (D77), НЕ -1-сентинел. (-1 в int-функции — идиоматичен для find/мер D226, но кодпоинт — не мера.)
  6. Bit-packing нескольких кодпоинтов в один ключ ((a<<21)|b, > 32 бит) → ключ int, явный as int на упаковке (packed key — не кодпоинт).

Почему не оставить int + Vec[u32]-хранилище

Хранилище обязано быть 4-байтным (perf/идиома; 8-байтный Vec[int] кодпоинтов неидиоматичен). При int-потоке + u32-хранилище int↔u32-граница на каждом push неустранима — это и есть источник narrowing-боли 172.2. u32-поток её снимает.

Звучность / footgun

u32-арифметика cp - lo молча заворачивается при cp < lo (D226 §«нет underflow-trap» — аргумент за signed). В unicode эти вычитания (hangul cp - SBASE) уже под range-guard’ами (cp >= lo && cp <= hi), underflow недостижим, footgun локализован и под охраной. Приемлемо для домена.

D310. Type-set bounds (Plan 172.3)

Статус: дизайн закреплён 2026-06-28 (owner sign-off; Plan 172.3 Ф.0). Amends D72 + D145: «bound = только protocol» → «protocol ИЛИ type-set».

Что

Новая kind-форма объявления типа — type-set — задаёт именованное множество конкретных типов, используемое как generic-bound (fn[T IntSet] …). Это Go-style type-constraint: код, общий для семейства примитивов (int.parse/u32.parse/…), выражается одним generic вместо per-type обёрток. Частично закрывает Q-representation-boundтолько explicit-member-set; ~underlying/repr/structural — по-прежнему Plan 102.

// inline
type SignedInt   set i8 | i16 | i32 | i64 | int
type UnsignedInt set u8 | u16 | u32 | u64 | uint

// многострочный — | обязателен у каждого члена включая первый
type AnyNumber set
    | i8 | i16 | i32 | i64 | int
    | u8 | u16 | u32 | u64 | uint

fn[T UnsignedInt] T.parse(s str, radix int) -> Result[T, ParseUIntError] => ...

Амендмент (R3, 2026-07-07, я): примеры этого блока были T.try_parse(...) (Option-контракт, до-R3 текст). try_parse без infallible-сиблинга нарушает R3 (D325); я ретрактировал компиляторный f64.try_parse builtin и заменил на f64.parse(s) -> Result[f64, ParseFloatError] ([M-f64-try-parse-to-parse-f64]). Примеры выше переписаны на T.parse — future generalization (Plan 174.1) читай как T.parse, не T.try_parse.

Правило

  • Синтаксис. Очередная kind-форма под type (D52/D53/D406): type Name set Member1 | Member2 | …. Диспетчеризация по первому токену после имени — контекстный kind-токен set однозначно отличает type-set от sum-type (type X enum A | B, D406) и остальных форм. Backtracking нет (один токен lookahead). set — контекстное слово (только в позиции после type Name), НЕ глобально-зарезервированное. Члены — TypeRef через |. Многострочная форма: если первый член на новой строке — | обязателен у каждого члена включая первый (аналогично D406 enum и D310 set); несколько членов в одной строке допускаются.
  • Члены — по ИДЕНТИЧНОСТИ. Примитивы и любые объявленные конкретные типы (newtype / named-tuple / record), каждый перечислен ЯВНО. Newtype type MyI8 i8 не член set’а {i8} — нужен явный листинг. ~underlying НЕТ (в Nova нет implicit-coercion; D52/D215).
  • Bound = membership-предикат. В [...]-позиции type-set ведёт себя как protocol-bound (D72): [T SignedInt]. Композиция с протоколами через + (D145, conjunction): [T SignedInt + Hash] ⇒ T ∈ set И реализует Hash; проверки независимы, per-member. Не более одного type-set в bound-листе (E_MULTIPLE_TYPE_SETS); протоколов — сколько угодно.
  • Семантика тела. Мономорфизация per член (как обычный fn[T], Plan 48 worklist). T.MAX/T.MIN/T.new/литералы резолвятся per-instance через numeric_type_constant_mapping по Nova-имени подставленного члена (нужен Nova-name subst-канал T→“i8” ПЕРЕД lookup, отдельный от C-name subst T→“int8_t”). Операторы в теле — пересечение легальных для ВСЕХ членов; чекер материализует resolved-тип каждого T-выражения в per-ExprId канал (codegen лоуэрит, не ре-резолвит). Без nova_int-fallback (§1): неразрешённый член = диагностика чекера, не угадывание.
  • Знаковость. Один set НЕ смешивает signed/unsigned целые (u64.MAX = 2^64−1 ∉ i64 → несовместимые value-domains; единое тело несоундно для обеих групп). Чекер: E_TYPE_SET_MIXED_SIGNEDNESS на объявлении. Stdlib даёт два готовых: SignedInt, UnsignedInt. Без рантайм-ветки по T.MIN==0 (§2: не платим рантаймом за статически известное).

Проверки / диагностика (чекер, §1/§6; новые коды в 09-tooling)

  • E_TYPE_NOT_IN_SET — конкретный T не член set’а (фиксируется на инстанцировании, не на use-site внутри тела; сообщение перечисляет членов + fix).
  • E_TYPE_SET_MEMBER_NOT_CONCRETE — член set’а не конкретный тип (protocol / effect / другой type-set).
  • E_TYPE_SET_MIXED_SIGNEDNESS — set смешивает знаковые/беззнаковые целые.
  • E_MULTIPLE_TYPE_SETS — >1 type-set в одном bound-листе.

Почему

  • Reuse через семейства примитивов — один fn[T SignedInt] T.parse вместо ×10 обёрток (разблокирует Plan 174.1, вариант B).
  • Zero-ambiguity синтаксис через существующий D52-диспетч (kind-токен, как alias/protocol под D53) — без нового top-level keyword, без backtracking, без конфликта с sum-|.
  • Звучность в чекере, лоуэринг в codegen (§0/§1): membership и легальность операторов — чекер; T.MAX — лоуэринг подставленного имени, без nova_int-fallback.
  • Знаковость разрешена на уровне декларации (§2/§5), не рантайм-веткой.

Связь

D52 (формы type, first-token dispatch) · D53 (kind-токен под type) · D72 (bound = тип в […]-позиции, amended) · D145 (+ multi-bound conjunction, amended) · D237 (capitalized naming) · D315 (ResolvedType несёт ширину/знак). Q-representation-bound — частично (explicit-member-set); ~/repr → Plan 102. Потребитель: Plan 174.1.


D405 — Арифметика смешанных целых ширин: требовать явный cast (2026-06-30)

Статус: закреплён 2026-06-30 (owner sign-off).

Что

Выражение u8_val + u16_val (или любые два целых операнда разных ширин/знаковостей) — ошибка компиляции E_MIXED_WIDTH_ARITH. Компилятор не делает implicit widening. Программист обязан явно указать целевой тип через as-cast:

ro a u8 = 200
ro b u16 = 300
// ro result = a + b      // E_MIXED_WIDTH_ARITH: u8 + u16 — implicit widening запрещён
ro result = a as u16 + b  // ok: a явно расширен до u16

Правило

  • E_MIXED_WIDTH_ARITH — оба операнда бинарного арифметического оператора (+, -, *, /, %, >>, <<, &, |, ^) должны иметь одинаковый конкретный целочисленный тип. Разные ширины (u8+u16) или разные знаки (i8+u8) — compile error.
  • Исключения: int-литералы без явной ширины инференсируются к типу другого операнда (D55 literal coercion). Только когда OBA операнда именованные — error.
  • Widening явен — через as-cast (D54). Выбор ширины — на программисте. Нет автоматического «бери больший».

Почему Rust/Go/Zig-стиль, не Java/Kotlin

ЯзыкПоведениеПроблема
Java/Kotlinbyte + short → int (implicit, always signed)Результат signed даже для unsigned операндов; теряется знаковость
Cu8 + u16 → uint (integer promotion, C-зависимо)Платформо-зависимо; signed promotion удивляет
Rust/Go/Zig/Swiftcompile error → явный castНикаких сюрпризов; программист контролирует
Nova (D405)compile error → явный as castСогласуется с «no implicit conversions» (D54)

Nova не имеет implicit numeric coercion нигде (D54as для всех width-changes). D405 последовательно расширяет это на бинарные операторы: нет молчаливого widen или truncate.

Связь

D54 (as явный cast) · D55 (literal coercion — исключение для нетипизированных литералов) · D315 (ResolvedType несёт ширину/знак — необходим для этой проверки) · D310 (SignedInt/UnsignedInt type-sets — generic-альтернатива per-width обёрткам).


D358 — HTTP message-model (std/http, Plan 178 Ф.1) {#d358}

Статус: ✅ landed (Ф.1, 2026-07-04) — message-model + URL + валидаторы. Http/HttpServer effect-контракт (D357) и client/server-политики (D360/D361) — Ф.2+.

Pure value-типы поверх net byte-surface (Ф.0.5). Всё fallible → Result[T, HttpError] (D325). Формы:

  • Method| Get | Head | Post | Put | Delete | Connect | Options | Trace | Patch | Other(str); parse валидирует RFC 7230 tchar; сравнение case-sensitive; @is_safe/@is_idempotent/@allows_body.
  • StatusCode — value-newtype над u16 (100..599); @class -> StatusClass; @reason (RFC 9110); zero-arg-фабрики (ok()/not_found()/…, Q17). 4xx/5xx = валидный Response, НЕ ошибка (Q4).
  • Version| Http10 | Http11 | Http2; OPEN (forward-compat Http3 — wildcard рекомендуется; языкового #open-атрибута нет, свойство конвенциональное).
  • HeaderName/HeaderValue/HeaderMap — case-insensitive по имени, ordered, multi-value. Имя = ASCII tchar (lowercase-канон); значение = []u8 (latin1 fast-path from_str, fallible @to_str на non-ASCII, Q18). Безопасность (by construction): insert/append/from_* ОТВЕРГАЮТ CR/LF/NUL → response-splitting невозможен; @content_length ловит CL+TE-конфликт (request-smuggling, RFC 7230 §3.3.3). @insert = replace, @append = add (§13.1).
  • Url (промоут _experimental/encoding/url.nv) — parse -> Result[Url, HttpError] (было from/Fail, D325 R2), @to_str. Строгий host/SSRF-валидатор: bracket-IPv6, canonical dotted-quad IPv4, REJECT control/NUL/whitespace/non-ASCII, REJECT decimal/octal/hex IP-обфускации (0x7f.1/0177.0.0.1/2130706433/127.1); @is_private_target (loopback/link-local/RFC1918/ metadata). encode_query percent-encodit КАЖДЫЙ UTF-8-байт (был баг: один байт для >127); decode_query — self-contained UTF-8-валидация. Байт-корректный парсер (byte-offsets, срезы по ASCII-делимитерам). Детали ошибок — ParseUrlError (tuple-варианты) через ErrSource.UrlParse.
  • Mime/ContentTypetype/subtype (lowercase-канон) + параметры (charset/boundary).
  • Cookie/SetCookie/SameSite — RFC 6265bis SEND-инварианты enforce’ятся на parse: Secure-cookie не по http:// (@is_sendable); __Host-/__Secure--префиксы; SameSite=None ⇒ Secure.
  • Request/Response — несут must-consume Body (D359) → сами consume; метаданные — borrow-методы; тело разряжается делегирующими consume-методами (resp.text()).

Амендменты по факту реализации (Ф.1)

  • SameSite.NoneSameSite.Cross (wire-value «None» сохранён): вариант None в public-enum коллидирует с Option.None в namespace любого импортёра std.http → переименован.
  • ErrSource.Url(ParseUrlError)ErrSource.UrlParse(...): имя-вариант == имя-тип Url ломает codegen (cast вместо wrap).
  • ParseUrlError — tuple-варианты (InvalidScheme(str) …), НЕ record-варианты: auto-eq для record-вариантов внутри Option[sum] mis-lower’ит (_0 на named-fields).
  • HttpError — non-value record: value + Option[Url]/Option[ErrSource]-поля → codegen emit’ит Option-typedef ПОСЛЕ struct-а (forward-ref «unknown type»).

Амендмент Ф.2 (auto-decompress landing, 2026-07-06)

  • ErrSource + Compress(CompressError) (OPEN enum → non-breaking): типизированный source для провалившегося decode Content-Encoding (gzip/deflate). Разблокирован фиксом D381 (collision-aware module-qualified mangling) — compress.ErrorKind и http.ErrorKind теперь СОСУЩЕСТВУЮТ в одном CU (доказано nova_tests/http_decompress). Bomb (превышение max_decompressed, D334) НЕ несётся через Compress, а мапится в HttpError{BodyTooLarge} (DoS-guard). br (brotli) закрыт — нет кодека [M-178-autodecompress-br]. Клиент шлёт Accept-Encoding: gzip, deflate по умолчанию (opt-out через HttpClientBuilder.@no_decompress()); при декоде заголовок Content-Encoding снимается, а Content-Length переписывается на декодированную длину (headers описывают тело, которое видит вызыватель).

D359 — must-consume Body (std/http, Plan 178 Ф.1) {#d359}

Body — линейный must-consume (D133): единственный способ «разрядить» — потребляющий метод (@bytes/@text/@drain/@into_reader). Незакрытое тело = compile-error — чинит главный Go-footgun (resp.Body-leak) на compile-time. Response/Request держат Body как consume-поле (двойной D133-маркер), разряжают делегированием in-place.

Repr = InMemory([]u8) | Stream(BodyReader); BodyReader — чистый Nova-декодер над byte-source (Q19), НЕ C-handle. @with_limitBodyTooLarge (DoS-guard). @text = строгий UTF-8 (Ф.1).

Амендменты / гейты по факту (Ф.1)

  • Bodyconsume (НЕ consume value): value-копия оставила бы consume-поле владельца (Request/Response) неразряженным (@body копировался, не move’ился).
  • Конструктор из СЫРЬЯ, не из pre-built Body: запись с consume-полем строится ТОЛЬКО с полем-значением как СВЕЖИМ inline-выражением (Body.from_bytes(..) внутри конструктора); move consume-переменной/параметра в поле НЕ распознаётся checker’ом → конструкторы принимают []u8/ BodyReader. [M-178-consume-field-ctor-from-var]
  • BodyReader — non-consume в Ф.1 (in-memory, ресурса нет; transport-backed reader держит socket → станет consume в Ф.2). @next_chunk -> Result[[]u8] + @at_eof (план-форма Result[Option[[]u8]] (None=EOF) упирается в codegen-ordering-баг eq Option[Option[[]u8]][M-178-bodyreader-option-eof-eq-ordering]).
  • ОТЛОЖЕНО в Ф.2 (гейты, НЕ упрощения): Http-effect на потребляющих методах (park над транспортом) [M-178-body-http-effect-surface]; @copy_to (fs-gate 176) / @json[T] (serde-gate 180 Ф.4) / @trailers (Ф.2) [M-178-body-copy-json-trailers]; charset-aware @text (latin1-fallback по Content-Type) [M-178-body-text-charset]; typed expires Timestamp в SetCookie (date→epoch, Plan 175) [M-178-setcookie-expires-timestamp].

D340 — serde data-model + protocols (Plan 180)

Format-agnostic typed serialization. Protocols Serialize / Deserialize (contract on a type) + Serializer / Deserializer (backend), generic-bound notation [S Serializer] (D72/D119, NOT impl Trait — Q12). Lean 12-case data-model (bool int uint float str bytes option unit seq map struct enum, Q2; int=i64-wide widen/range-check).

  • Serialize = push: @serialize[S Serializer](mut s S) -> Result[(), SerError]. Deserialize = static pull: .deserialize[D Deserializer](mut d D) -> Result[Self, DeError] (D35 static).
  • Serializer = single mutable stack-machine (realized form): composites framed by begin_struct/struct_field/end_struct, begin_seq/end_seq, begin_map/map_key/end_map; scalars terminal serialize_X. The backend owns an internal frame stack (format-agnostic). This REPLACES the aspirational “consume sub-serializer returned per composite” form: in value-semantics Nova a sub-serializer mutating shared parent state has no clean ownership; the stack machine is the sound realization (the synthesizer always emits matched begin/end pairs — same balance guarantee).
  • Deserializer = single cursor: enter_field / enter_field_or_null / enter_index / enter_key return a sub-cursor (Self) positioned at the child; sub-deserializers only READ, so no write-back/ownership problem (keyed-access model = Swift KeyedDecodingContainer). No Visitor companion TYPE is synthesized — the keyed/indexed model makes it unnecessary.
  • SerError / DeError = value-record (D215/D322 pattern), OPEN kind + path (D325 R5). Kinds use DISJOINT variant names (SerErrorKind: NonFiniteFloat/SerDepthLimit/SerCustom/SerOther; DeErrorKind: UnexpectedType/MissingField/UnknownField/OutOfRange/LossyInteger/DepthLimitExceeded/Syntax/Custom/Other) — Nova bare-variant construction does not disambiguate a shared variant name by expected-arg type, so overlaps are avoided by construction.
  • PURE (no effect) — codec over values/bytes.

D341 — record auto-derive contract (compiler synthesis)

#impl(Serialize + Deserialize) opt-in — the 7th/8th members of the auto-derive family (Equal/Hash/Clone/Compare/Display/Debug); is_builtin_protocol extended. SUM is supported (Plan 180 Ф.2-sum, externally-tagged — see D345); the record-path shapes below apply to record/named-tuple types. Emitted shapes:

  • @serialize: s.begin_struct(name, N)?; per field s.struct_field("k")?; @field.serialize(s)?; s.end_struct() — UNIFORM memberwise push (like @debug).
  • .deserialize: per field mut sub = d.enter_field[_or_null]("k")? then TYPE-DIRECTED read — scalar → sub.deser_X()? (instance); record/Vec/HashMap<T>.deserialize(sub)? (static); Option[T] → inline if sub.is_null()? { None } else { Some(<inner>) } (built-in Option does not dispatch a user static method). Then Ok(Type{ f1, f2, … }).
  • Field-eligibility: primitive / Option·Vec·HashMap[str,_] (recurse) / #impl(P) / provides-method — else E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL (named field; no silent drop). HashMap key must be str (Q16). priv fields serialize (structural synth). User method wins (D77).
  • Injection ordering: serde synth is injected BEFORE type-check (its bodies call other methods whose return types codegen’s annotation-free infer_expr_c_type cannot always resolve; type-checking annotates them). Non-serde protocols inject AFTER check as before (some bodies, e.g. @display’s w.write_str, are intentionally not type-checkable). Bound satisfaction: a #impl(P) type satisfies [T P] for a built-in auto-derivable P even before the method is materialized.

D342 — data-model ↔ synthesis mapping (Plan 180)

record→struct, Vec→seq, HashMap[str,_]→map, Option→option (None→null default; absent accepted on deser, Q7), scalars→prim (int i64-wide widen / exact-integer range-check, Q2/Q15), []u8→bytes. numeric-fidelity (Q15): on deser, int/uint require an f64 that is an exact integer in [-2^53, 2^53] — else LossyInteger; negative→uint → OutOfRange. Option[Option[T]] ambiguity documented (Some(None)==None on wire). []u8→base64 and Timestamp/Duration mappings are followups ([M-180-bytes-base64], Plan 175 coordination).

D344 — JSON backend over std/encoding/json (Plan 180)

JsonSerializer (stack-machine building JsonValue@into()) / JsonDeserializer (cursor over JsonValue) layered on the existing JsonValue/Json.parse (Q11, reuse — not a new parser). Public API = free functions json_encode[T] / json_decode[T] / json_encode_pretty / json_to_value / json_from_value / json_decode_bytes / json_decode_with (NOT Json.encode namespace-static: turbofish on a namespace/type-static generic method does not monomorphize — Ф.0-verify empirics; free-fn turbofish does. Followup [M-180-namespace-static-generic-mono]). depth-guard (Q14 default 128 → DepthLimitExceeded). ParseJsonErrorDeError{Syntax(msg)} with line/col preserved in the message. Map encode sorts keys (determinism). Contract for Plan 178 record-DTO (json_decode[T] / json_encode).

D345 — sum auto-derive + tagging (Plan 180)

Ф.1 — sum rich data-protocol synth (✅ landed 2026-07-06). The six built-in protocols (Equal/Hash/Clone/Compare/Display/Debug) now synthesize match @ { … } with one arm per variant instead of the old placeholders (equal=identity, hash=0, clone=self, compare=0, display/debug=typename). Per SumVariantKind: Unit (no payload / bare-ctor reconstruction), Tuple(tys) (positional binds, V(a0,a1)), Record(fields) (named binds, V { f }).

  • @equal: V(a..) => match other { V(b..) => a==b && …, _ => false } — same variant + payload-wise ==; different variant → false.
  • @hash: variant-index seed (idx+1) combined with each payload’s .hash() via the record-path rotate-XOR; distinct unit variants hash apart.
  • @clone: match-arm reconstruction — primitives shallow-copied, composites .clone()d; Unit→bare ctor, TupleV(clone…), RecordV { f: clone }.
  • @compare: extract both variant indices, compare those first; on tie compare payloads lexicographically (ro c = a.compare(b); if c != 0 { return c }).
  • @display/@debug: "V" / "V(x, y)" / "V { f: x, g: y }"; display routes primitives via w.write_str(str.from(x)), debug uniformly x.debug(w). These inject AFTER type-check (like the record-path), so the emitted match / variant-patterns / variant-construction are lowered by codegen’s annotation-free inference (scrutinee @ + other: Self types known). [M-126-sum-*-rich] CLOSED.

Ф.2-sum — serde sum-derive, externally-tagged (✅ landed 2026-07-06). #impl(Serialize + Deserialize) on a sum synthesizes match-arm-per-variant bodies over the Ф.1 pattern/ctor infra. Externally-tagged (Q4, default):

  • unit variant V → bare string "V"
  • single-payload V(x){"V": <x>}
  • multi-tuple V(a, b){"V": [<a>, <b>]} (inner array)
  • record variant V{f, g}{"V": {"f": <f>, "g": <g>}} (inner struct)

Serialize emits over the existing Serializer primitives (begin_struct/ struct_field/begin_seq/serialize_str/…) — no new enum-specific serializer methods. Deserialize reads the tag (d.is_str()? → bare string for unit; else the single object key via map_keys/enter_key), then an if/else-if chain on the tag name reconstructs the variant, reading payload from the tagged cursor (tuple → enter_index, record → enter_field, single → direct). Unknown tag → DeError{UnknownVariant{name, expected}} (new DeErrorKind variant); malformed (non-single-key object) → DeError{Syntax}. Payload eligibility mirrors the record-field check (typed E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL by variant, never a bad synth). Runtime additions: Deserializer.@is_str() + DeErrorKind UnknownVariant/NoVariantMatched. NOT on Plan 178’s critical path (record-DTO suffices). Codegen: a static T.deserialize(sub)? whose return-type inference degrades (mono-collection order perturbation once a sum ALSO derives Deserialize) is pinned to Result[T, DeError] at the ?-lowering site (emit_c.rs Try arm), mirroring the .serialize? pin.

Ф.5 — internal/adjacent tagging (✅ landed 2026-07-06, D382); untagged gated. Now that the #serde(...) declaration-attribute infra exists (D382), the non-external tagging modes are synthesized from the type-level attributes:

  • #serde(tag="k")internally-tagged ✅: unit V{"k":"V"}; record V{f}{"k":"V","f":…} (fields inlined beside the tag). Tuple/positional payloads are rejected — E_SERDE_INTERNAL_TAG_NON_STRUCT (no object to inline the discriminator into; serde rule).
  • #serde(tag="t", content="c")adjacently-tagged ✅: unit→{"t":"V"}; single→{"t":"V","c":x}; tuple→{"t":"V","c":[…]}; record→{"t":"V","c":{…}}.
  • #serde(untagged)untagged 🔴 GATED ([M-180-untagged-codegen-mono]): synthesized correctly (unit→null; single→x; tuple→[…]; record→{…}; deserialize buffers the JsonValue, Q17, and tries each variant in declaration order — value-semantics cursor makes each attempt a non-destructive retry; DeError{NoVariantMatched} if all fail). BUT compiling an untagged-derive body perturbs std/encoding/json codegen in the same CU (mono-collection ordering → Json.parse mis-tags a number as a bool), so #serde(untagged) is rejected at compile time (E_SERDE_UNTAGGED_GATED) until that codegen-hardening prerequisite lands. A compiler bug, NOT a serde-logic defect. Serialize/deserialize are synthesized over the SAME Serializer/Deserializer primitives as external (begin_struct/struct_field/enter_field/enter_index /is_null/…) — no new backend methods. The mode is computed from the type’s serde_attrs by serde_tagging_mode (auto_derive.rs) with static validation (conflict / content-without-tag / on-non-sum / internal-on-tuple / untagged-gate). Externally-tagged (no attribute) is unchanged. [M-180-serde-tagging-modes] CLOSED for internal+adjacent; untagged → [M-180-untagged-codegen-mono].

Codegen soundness note (Plan 180 Ф.6). The synthesized deserialize bodies exercise json.nv’s Deserializer methods heavily; their match m.get(k) { None => Err(..), Some(v) => Ok(..) } shape exposed a latent match/if result-type bug — Ok(x) alone infers a stub ERR side (NovaRes_<ok>_nova_str) and Err(e) a stub OK side (NovaRes_nova_int_<err>), so neither arm yields the full Result[JsonDeserializer, DeError] and the returned cursor was mis-laid-out (decode returned spurious UnexpectedType). Fixed by reconciling the concrete OK (from an Ok(..) arm) with the concrete ERR (from an Err(..) arm) across emit_match/emit_if_expr + their infer_expr_c_type mirrors — splitting the already-computed arm/branch Result-types via novares_ok_err (side-effect-free; an earlier re-inference variant perturbed mono-collection order). Order- independent; genuine Result[int, E] matches unchanged. Zero-regression verified (~50 dirs, byte-identical to parent). This fixed internal+adjacent; untagged needs a further, distinct mono-ordering fix (the json.nv corruption above).

D346 — serde soundness invariants (Plan 180)

Q14 depth-guard (both sides, default 128); Q15 exact-integer-check (no silent-lossy); Q16 str-only map keys; Q18 dup-field reconciled with Json.parse’s strict DuplicateKey.

D382 — declaration attributes #serde(...) (Plan 180 Ф.6) {#d382}

Grammar. A declaration attribute is #name(arg (, arg)*), arg := ident [ = StrLit ] — i.e. bare flags (#serde(untagged)), string-valued keys (#serde(tag="type")), and comma-separated combinations (#serde(tag="t", content="c")). Multiple #serde(...) annotations on one declaration accumulate. The only recognized namespace in V1 is #serde. Parsed by parse_serde_attr (shared across the three positions), extending the #visible_to/#impl marker-parsing precedent.

AST. A new serde_attrs: Vec<SerdeArg> field on TypeDecl, SumVariant, and RecordField (empty Vec = default, backward-compat). SerdeArg = Tag(String) | Content(String) | Untagged — a structured list, general enough that field-customization keys (rename/skip/…) drop in without a grammar change. Type-level #serde is threaded through parse_type_attrsparse_type_decl; field-level alongside #visible_to in the record-field loop; variant-level as a leading marker in parse_one_sum_variant.

Recognized keys (V1). tag, content, untagged — sum-type enum tagging (consumed by D345 Ф.5 via serde_tagging_mode; tag/content land, untagged is parsed+validated but its derive is gated E_SERDE_UNTAGGED_GATED, see D345). Unknown-attribute policy (convention, mirrors #impl/#from_fields: unknown marker → hard error, never silent): any other key inside #serde(...)E_SERDE_BAD_ATTRIBUTE at parse time (beats Go/Jackson silent tag-typo). Field-customization attributes (rename/rename_all/skip/default/flatten/alias/ deny_unknown_fields) are parsed by the SAME grammar but their synthesis- consumption is a scoped follow-up ([M-180-serde-field-attributes]) — they currently reject as not-yet-supported rather than silently ignore.

Static validation (serde_tagging_mode, surfaced as compile errors): E_SERDE_TAGGING_CONFLICT (untagged with tag/content; or tag==content); E_SERDE_CONTENT_WITHOUT_TAG (content without tag); E_SERDE_TAGGING_ON_NON_SUM (tagging attr on a record/non-sum); E_SERDE_INTERNAL_TAG_NON_STRUCT (internal tag on a type with a tuple variant); E_SERDE_UNTAGGED_GATED (untagged derive gated on a codegen-mono fix, [M-180-untagged-codegen-mono]). See D345 Ф.5 for the emitted wire per mode.