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 |
| D406 | Sum-type синтаксис: enum маркер вместо leading |; inline enum в type-позиции | active |
| D53 | Унификация: protocol под type, protocol как kind-токен | active |
| D55 | Literal coercion в позиции с явным типом: sum-конструкторы и record-литералы | active |
| D42 | protocol keyword для структурных интерфейсов | revised → D53 |
| D15 | Структурные интерфейсы | revised → D42 → D53 |
| D39 | Embed и delegation: use name Type (alias обязателен) | active |
| D32 | Семантика передачи параметров | revised для полей → D36 |
| D36 | Поля типа: дефолт mutable у mut-binding’а, ro для never-mut | active |
| D175 | ro field — полный freeze, транзитивность (амендмент D36) | active |
| D176 | ro T — тип-модификатор, coercion rules, zero overhead | active |
| D66 | Self universal: ссылка на обобщающий тип в методах, effects, protocols | active |
| D72 | Generic bounds через [T Protocol] — protocol как тип | active |
| D110 | Ghost state — spec-only bindings | active |
| D122 | Hybrid dispatch для bound-K methods | active |
| D123 | Tuple monomorphization | active |
| D215 | Named tuple fields + value/reference allocation contract | active |
| D119 | Method-level type parameters в generic methods | active |
| D372 | Canonical .new() constructors (convention) | active |
| D181 | Array methods — -> @ fluent mut chain + slice syntax | active |
| D182 | Self в return-type static methods — required form для parametric types | active |
| D183 | Canonical comparison protocols + default method bodies (Plan 91.8a) | active |
| Pointer mutability — running-current flip-scan (Plan 147; RETRACTED 2026-06-12 → D246) | retracted | |
| D246 | Три оси мутабельности (L1 binding / L2 view / L3 pointee); restores *T ≡ *ro T universally; E_REDUNDANT_POINTER_RO (Plan 147) | active |
| D281 | Module-level field privacy type X priv { … } — bare priv = module-private (Plan 160, D281) | active |
| D355 | Blanket protocol-receiver methods (ex-D282, renumber 2026-07-03) fn[I Next[T]] I @m — typevar-ресивер + bound-dispatch (Plan 161, G-F) | active |
| D284 | EnumerateIter[I, T] — zero-cost enumerate adapter; per-type @zenumerate() dispatch; tuple parametric return (Plan 162) | active |
| D290 | Iterator 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 |
| D307 | File-private visibility priv(file) fn/type/const — лесенка priv(file) ⊂ module ⊂ export; E_FILE_PRIV_LEAK; file-discriminated codegen; dedup одноимённых в peer-файлах (Plan 170) | active |
| D315 | ResolvedType — единый канонический носитель типа; проверки/совместимость/конверсии/перевод-в-C выводятся из него; type_ref_to_c ретайрится; сахар нормализуется; ABI выводится, не хранится (Plan 172.1, D315) | active |
| D310 | Type-set bounds: type Name set T1 | T2 | … — именованное множество конкретных типов как generic-bound; SignedInts/UnsignedInts; Go-style type-constraint (Plan 172.3) | active |
| D429 | #coerce — декларативные неявные zero-cost конверсии (view + finalize) | 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 — две эквивалентные формы:
AMEND 2026-09-28 (D486 §3): неявная форма РЕТРАКТИРОВАНА.
..обязателен при частичном списке полей во ВСЕХ позициях — match, биндинг, if/while-let, for (E_RECORD_PATTERN_NEEDS_REST). Текст ниже сохранён как история решения.
// явная — с маркером ..
match @buckets[idx] {
Occupied { value, .. } => Some(value)
_ => None
}
// неявная — без маркера, остальные поля игнорируются
// ОТМЕНЕНО D486 §3: сегодня это E_RECORD_PATTERN_NEEDS_REST
match @buckets[idx] {
Occupied { value } => Some(value)
_ => None
}
Явная форма — visual cue «здесь ещё поля». Неявная — краткость. Действует
только явная форма (D486 §3).
Переименование при деструктуризации остаётся явным: Occupied { key: k, value }.
Construction всегда требует все обязательные поля — частичное
заполнение типа Rust ..default отдельным синтаксисом не зафиксировано.
Почему
- Один разделитель списка на весь язык — запятая. Параметры,
элементы массивов, поля записи, варианты sum-type — везде
,. Меньше правил, меньше ошибок LLM. =означает «справа выражение типа». Когда справа форма данных —=лишний.- Парсер по первому токену — никакого 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
— префиксы полей (
ro,mut) и group-syntax внутри record.
D52. Объявление типов revised: newtype, alias, sum через leading | {#d52}
Уточнено D326 (2026-10-03, решение владельца): newtype над кучевым типом сам кучевой для правила
mut-параметра (Р3): представление — основы. Уточнено D156 (2026-10-03, решение владельца): newtype над must-consume типом (type Tx2 Txприtype Tx consume …) сам must-consume — обёртка из одного значения, линейность наследуется, как у контейнера.
⚠️ REVISED. Синтаксис sum-type заменён D406:
enum Variant | ...вместо leading| Variant | .... Остальные формы (newtype, alias, record, tuple, unit) без изменений.
Что
Полная пересборка D17.
Один keyword type для всех data-форм, никаких = в декларациях,
форма различается первым токеном после имени. Шесть форм:
- newtype —
type X Y(X — новый тип, типизированно отличный от Y, Go-style) - alias —
type X alias Y(X и Y совместимы, для длинных дженериков) - record —
type X { поля }— heap-allocated reference type (GC-managed) - tuple —
type X(типы)— stack-allocated value type (позиционные поля.0/.1) - named tuple —
type X(name1 T1, name2 T2)— stack-allocated value type (именованные поля.name) (D215, Plan 120) - unit —
type X(ничего после имени) - sum —
type 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]) идёт | Форма |
|---|---|
enum | sum (D406) |
protocol | protocol-тип (D53) |
effect | effect-тип (D53) |
set | type-set bound (D310) |
value { | value-record — stack-allocated (D228/D277/D290) |
( + ident + bare-type | named tuple (D215) — (name1 T1, name2 T2) |
( + bare-type | positional tuple — (T1, T2) |
{ | record — heap-allocated (GC-managed) |
alias | alias |
<base-type> enum | sum с явным базовым типом для discriminants |
| идентификатор/тип, конец строки | newtype |
| конец строки сразу | unit |
Парсер видит первый токен — сразу знает форму. Для ( — один
дополнительный lookahead: если (IDENT type → named tuple,
иначе → positional tuple. Никакого backtracking.
Модификаторы type-declaration
Помимо kind-токена, type-declaration может нести модификаторы — они не меняют «форму» типа, а добавляют квалификаторы:
| Модификатор | Семантика | D-block |
|---|---|---|
export | виден снаружи модуля | D47 |
value | stack-allocated (по значению, не в GC-куче) | D228/D290 |
priv | поля module-private по умолчанию | D281 |
priv(type) | поля type-private по умолчанию | D281 |
priv(file) | символ виден только в этом файле | D307 |
consume | must-be-consumed affine type | D133 |
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] [consume] [value] [priv|priv(type)] { … }
Модификаторы комбинируются — в единственном каноническом порядке D241 (consume → value → priv): export type Job consume value priv { … }.
Поля внутри {…} могут иметь 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парсер отвергает. Работает только дефолтная форма (без базового типа, implicitint). См. Plan 105.
Правила discriminants:
- Базовый тип — дефолт
int. Опционально явный (type X i32 enum,type X u8 enum). - Auto-increment от первого варианта:
- Первый без значения → 0.
- Каждый следующий без значения → предыдущий + 1.
- Отрицательные значения — разрешены.
- Decreasing/non-monotonic последовательности — разрешены.
- Конфликт значений (два варианта с одинаковым discriminant) — запрещён компилятором.
- 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
Амендмент 2026-10-02 (решение владельца; вопрос облачной сессии
p274-carina-cast): конструктор newtype принимает значение ПРЕДСТАВЛЕНИЯ, а не самого newtype.W(e)приtype W intзаконно, когдаe—int(или литерал, D489). Еслиeуже типаW, это ошибкаE_NEWTYPE_CTOR_SELFс подсказкой «eужеW— конструктор не нужен»: повторная обёртка либо пустая операция, либо знак того, что автор считаетeзначением представления. Доноры — Rust (конструктор tuple-struct принимает внутренний тип) и Swift; Go разрешает конверсию в тот же тип, у нас — нет, как нет и прочих неявных превращений. Оракул на 2026-10-02 принимаетW(e)приe Wкак тождество — реестр 221.1 №1596.
Альтернативу 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 в scope | shorthand { name } обязателен |
name | @name (self-поле) | shorthand { @name } обязателен |
name | переменная other (другое имя) | полная форма { name: other } |
name | @other или выражение | полная форма { name: @other } |
name | obj.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» выше.
Почему
- Системность. В D17 правило «
=для выражений типа, без=для форм данных» работало для alias, но спотыкалось на sum-type:type Color = Red, Green, Blue— справа не «выражение типа» в обычном смысле, а список конструкторов. С D52 sum обрабатывается как именованная форма (через|), как и record/tuple/unit. - Никаких
=в декларациях типов — устраняется напряжение «иногда есть, иногда нет».=остаётся за binding’ом значений (let x = ...) и parameter defaults (если будут). - Newtype как first-class. Domain-modeling (
type Email str,type Score f64) даёт реальную защиту типов без шумной record-обёртки. Прецедент Go (type UserId int64). - Discriminants для wire-протоколов. HTTP-коды, syscall-коды, serialization tags — программист может задать стабильные значения, как в C/TS/Swift enum.
- Парсер однозначен по первому токену — никакого lookahead глубже одного-двух токенов. AI-friendly: LLM с одного взгляда понимает форму.
- Leading
|для sum — visual symmetry: все варианты выровнены, прецедент OCaml/F#/Scala 3. - Согласованность с D1 «protocols + data, без классов» —
typeтолько для данных,protocolотдельно для поведения. - 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.
Цена
- Большой breaking change. Все существующие декларации в spec/, decisions/, examples/ переписать. Кода пока мало, миграция разовая.
aliasстановится keyword’ом. Раньше был обычным идентификатором.- Программистам с фоном Rust/TypeScript:
type X = Yбольше не alias, а ошибка. Адаптация через документацию. - Парсинг
type X Y(newtype) vstype X(unit) — различие по следующему токену (тип vs конец строки). Просто, но требует внимательности. |имеет двойную роль — разделитель в sum и@orв операторах (D46). Парсер различает по контексту.
Связь
- D17 — старая версия, помечена revised → D52.
- D42 —
protocolостаётся отдельным keyword’ом для поведения. - D36
— префиксы полей (
ro,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.Закрыто (Plan 200, 2026-07-12) — D55 §Obvious single-wrapper coercion: литерал и нетипизированная константа (ro u UserId = 42,40 + 2) оборачиваются сами; переменная — только явно,UserId(n)илиn as UserId(D55, амендмент 2026-08-21, решение владельца; прежняя фраза «binding авто-коэрсится так же» снята 2026-10-02 как противоречащая ему).
Эволюция
D17 был первой
итерацией, основанной на правиле «= для выражений типа». Со
временем выяснилось, что:
- Sum-type с
=— натяжка («справа выражение типа» не точно описывает список вариантов). - Newtype отсутствовал как явная фича — программистам приходилось
делать record-обёртки
type X { value u64 }, что шумно. - Discriminants на sum-вариантах не были специфицированы — но реальные wire-протоколы их требуют.
D52 решает все три, ценой breaking change по syntax-site всех type-объявлений. Подробно — history/evolution.md.
Амендмент (ОКНО-5, 2026-07-23): newtype над fn-типом + call-through + alias-прозрачность
Проблема. type Handler fn(ServerRequest) -> ServerResponse (newtype,
underlying — fn-тип) не парсился: expected identifier, got '('.
Причина — parser-эвристика «пустой sum» (empty-sum body-end detection,
см. Plan 72 P1-B выше) трактовала ЛЮБОЙ голый KwFn сразу после type X
как начало СЛЕДУЮЩЕЙ top-level декларации (type X без тела + отдельная
fn name(...)), а не как начало fn-типа. Настоящий top-level fn ВСЕГДА
несёт identifier сразу после keyword (fn name(); fn-TYPE body, наоборот,
начинается fn( — сразу (, без имени. Однозначный lookahead одним
токеном: fn считается концом декларации (следующая fn-декларация)
ТОЛЬКО когда токен ПОСЛЕ него — НЕ (.
Форма (закрывает синтаксический пробел, D52 §Полный синтаксис остаётся
без изменений — это РАСШИРЕНИЕ newtype-формы type X Y, где Y теперь
может быть fn-типом, не только именованным типом):
type Handler fn(ServerRequest) -> ServerResponse // newtype над fn-типом
type HandlerAlias alias fn(int) -> str // alias fn-типа (тоже валиден)
Call-through (единственный newtype-род, форвардящий операцию над underlying). Значение newtype-над-fn-типом вызывается НАПРЯМУЮ:
fn call_it(h Handler, req ServerRequest) -> ServerResponse => h(req)
Обоснование — асимметрия с int-newtype (UserId), который НЕ форвардит
+: вызов — ЕДИНСТВЕННАЯ осмысленная операция над значением функционального
типа (в отличие от арифметики, у которой для домена UserId попросту нет
единственно верной трактовки). Прецедент — Go type HandlerFunc func(...),
который одновременно и вызывается (h(w, r)), и несёт методы. Newtype
как имя ОСТАЁТСЯ (методы, внятная диагностика «ожидался Handler», типы не
путаются) — call-through не отменяет nominal-типизацию, просто добавляет
ОДНУ разрешённую сквозную операцию.
Alias fn-типа — прозрачность включает вызов. До этого амендмента
type X alias fn(A) -> B объявлялся без ошибок, но значение типа X не
вызывалось ([E_CALL_NOT_CALLABLE]) — резолв вызова смотрел на ИМЯ типа
локала, не разворачивая alias. D52 alias уже требует полной прозрачности
(X и Y совместимы) — вызываемость теперь часть этой прозрачности,
разворачивается ТРАНЗИТИВНО через любую цепочку alias’ов.
Авто-коэрсия fn → Handler. См. D55 §Obvious single-wrapper
coercion
— fn-типы добавлены в структурную «таблицу родов», используемую тем же
механизмом, что и int/str/[]u8 (никакого нового кода
coercion-стороны не потребовалось — механизм уже был обобщён на
произвольный Newtype(inner), не только скалярный inner).
Однонаправленно: Handler → fn НЕ коэрсится (nominal-типизация не
растворяется — два РАЗНЫХ newtype над ОДНИМ fn-типом взаимно
неподставимы, см. регресс d52_newtype_fn_reverse_coerce_neg.nv).
Известный, задокументированный остаток (НЕ в объёме амендмента): два
overload’а, различающихся ТОЛЬКО newtype-fn-параметром с ОДИНАКОВОЙ
underlying-сигнатурой, и вызов с голой fn-функцией — checker НЕ детектирует
двусмысленность на Nova-уровне (тот же класс, что и pre-existing
[M-172.1-free-fn-multi-overload-ambiguous]); компиляция падает ЧЕСТНО, но
на C-уровне (мангл-коллизия, оба erased-параметра — void*) — см. регресс
d55_fn_newtype_ambiguous_lift_neg.nv (EXPECT_CC_ERROR, НЕ
EXPECT_COMPILE_ERROR). Отдельно найден, ОРТОГОНАЛЬНЫЙ newtype-у,
pre-existing пробел: assignable_direct для fn-типизированного expected
коллапсирует категорию в Any (структурная проверка fn-сигнатур на
call-arg позиции отсутствует ВООБЩЕ, для любого fn-значения, newtype или
голого) — заведено как [M-fn-type-expected-any-bypass] в
docs/plans/backlog-followups.md, вне объёма этого окна.
Реализация: compiler-codegen/src/parser/mod.rs (parse_type_decl
empty-sum lookahead), compiler-codegen/src/types/mod.rs
(BoundCtx::fn_type_names pre-scan + check_call_callee_not_local_shadow),
compiler-codegen/src/codegen/emit_c.rs (fn_newtype_sigs pre-scan +
resolve_fn_typeref — call-dispatch через существующий NovaClosBase/
NOVA_CLOS_CALL_* механизм, ноль нового codegen). Регресс:
spec_tests/conformance/d52_newtype_fn_type.nv (4 куска) +
spec_tests/conformance/neg/d52_newtype_fn_reverse_coerce_neg.nv +
spec_tests/conformance/neg/d55_fn_newtype_ambiguous_lift_neg.nv.
Амендмент (2026-09-04, решение владельца): операторы над числовым newtype — только внутри одного newtype
Что обнаружено (исследование docs/dev/research/2026-09-04-typed-index-vec.md, реестр
строка «операторы стирают newtype», проба docs/plans/repro/904-newtype-operators-erase/).
Newtype объявлен «типизированно отличным от Y, Go-style» (§Что), и вызовы это соблюдают:
fn f(r FnRow) отвергает аргумент TyId (E7301). Но бинарные операторы — нет:
FnRow == TyId, FnRow < TyId, FnRow + TyId и FnRow + n при n int проходят чекер,
собираются и исполняются (печатают true false 2 true 2). Различие newtype компилятор
знает и применяет на одной двери (вызов), а на другой (оператор) стирает. Ни этот блок, ни
D46 (операторы через @-методы) случай не описывали.
Норма. Для newtype X над числовым представлением Y:
- Бинарные операторы арифметики (
+ - * / %) и сравнения (== != < <= > >=) определены между операндами одного и того же newtypeX. Результат арифметики —X; результат сравнения —bool. - Числовой литерал в позиции операнда адаптируется к
X(как и при присваивании, Plan 200):a + 1приa FnRowзаконно, результатFnRow. - Смешение операндов —
Xс другим newtypeZ,Xс значением типаY(переменная, поле, результат вызова) — compile error; диагностика называет явныйasкак путь:a + (n as FnRow)или(a as int) + n, по смыслу места. - Унарные операторы и составные присваивания (
-a,a += b) — по тем же правилам.
Это ровно семантика донора: в Go type FnRow int даёт FnRow == TyId → mismatched
types, FnRow + 1 законно (untyped constant), FnRow + n при n int → ошибка.
Почему (а), а не «стирание намеренно». Смысл newtype над числом — чтобы два
пространства номеров нельзя было спутать; самое частое место такой путаницы — сравнение
if row_id == ty_id. Стирание в операторах оставляло бы гарантию newtype дырявой ровно там,
где она нужнее всего, и обесценивало бы типизированные индексы (IndexVec[I, T], то же
исследование): защита на [] и вызовах ничего не стоит, если индексы можно сравнить между
собой напрямую.
Реализация (требование): операнды оператора проверяются той же дверью, что аргументы
вызова — не второй копией сверки; иначе дыра вернётся при следующем операторе. Запись
положена до фикса: спека прежде реализации. Фикстуры — у чинящего: негатив на ==/</+
между двумя newtype и между newtype и Y-переменной (line-pinned), позитив на операторы
одного newtype с результатом того же типа и на литерал.
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]) | Форма |
|---|---|
enum | sum |
<base-type> enum | sum с явным базовым типом (Plan 105) |
В type-expression position enum — prefix, парсер строит EnumTypeExpr.
Почему
- Симметрия с
alias.type X alias Yиtype X enum A | B— одна структура: keyword даёт форму, далее описание. Единый паттерн. - Явный grep-маркер.
enumв любой type-позиции мгновенно идентифицирует sum-type — в IDE, grep, LLM-prompt. - Устраняет неоднозначность
|. В D52 leading|конфликтовало с оператором@orи вызывало удивление.enum— незвуковой маркер без операторных коннотаций. - Inline enum. Анонимные sum-типы в позициях параметра/возврата/поля становятся возможными — естественный extension грамматики.
- 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 — заменяемый синтаксис
- D53 —
protocol/effectkind-токены в той же системе - D310 —
setkind-токен в той же системе;|в type-set тоже разделитель, не оператор - D55 — literal coercion в позиции sum-type (inline
enumтоже) - 03-syntax.md → D46 —
|как@orоператор — разрешается по контексту (keywordenum/setили expr-контекст) - Plan 105 — явный базовый тип discriminants
- D478 — как читается имя варианта, написанное БЕЗ имени типа
D478. Разрешение ГОЛОГО имени варианта: по ожидаемому типу, иначе квалификация (2026-09-16) {#d478}
Статус: ПРИНЯТО (решение владельца 2026-09-16; поднято окном Карины, план 274.7 волна В14). Дополняет D406 — тот задаёт СИНТАКСИС сумм, этот блок отвечает на другой вопрос: как читается ИМЯ варианта, написанное без имени типа.
Задача
Две суммы в области видимости могут объявить вариант с одним именем:
type Color enum Red | Green
type Signal enum Red | Amber
До этого решения язык на вопрос «что значит голое Red» не отвечал вовсе, и две
реализации разошлись: нынешний компилятор принимал такую программу, выбирая сумму сам
(реестр 221.1 №962, №964 — выбирал он её неверно),
а Карина отвергала любое голое имя, видимое дважды. Пробы обеих половин лежат в
docs/plans/repro/b14-d406-rejected/.
Правило
- Имя варианта, видимое РОВНО ИЗ ОДНОЙ суммы, разрешается в неё. Квалификация не требуется, и существующий код не меняется — это большинство случаев.
- Имя, видимое из ДВУХ И БОЛЕЕ сумм, разрешается ПО ОЖИДАЕМОМУ ТИПУ позиции. Ожидаемый тип — это тип, ОБЪЯВЛЕННЫЙ в самой программе для этой позиции, а не выведенный из соседних выражений.
- Если ожидаемого типа у позиции нет — это ошибка, и она требует квалификации
Type.Variant. Не «выбрать любую», не «выбрать первую объявленную»: молчаливый выбор из двух и есть то, что уже дало дефект №964.
Что считается ОЖИДАЕМЫМ ТИПОМ — закрытый перечень позиций
Перечень закрыт по построению: тип объявлен синтаксически, значит позиция либо объявляет его, либо нет. Ожидание есть у:
- хвоста и
returnфункции с объявленным типом возврата; - аргумента вызова, у чьего параметра объявлен тип;
- биндинга с объявленным типом —
ro c Color = Red; - поля в конструкторе записи — тип поля объявлен в типе;
- ветви
match/if, стоящей в значащей позиции из перечисленных — ожидание протекает внутрь ветвей, потому что ветвь и есть значение этой позиции.
Ожидания НЕТ, и потому требуется квалификация, у:
- биндинга без объявленного типа —
ro c = Red; - элемента литерала массива без объявленного типа элемента;
- операнда сравнения или арифметики, когда вторая сторона тип не фиксирует;
- выражения-утверждения, значение которого отбрасывается.
Отказ
Диагностика обязана назвать ОБЕ суммы и потребовать форму Type.Variant. Сообщение «имя
объявлено более чем одной суммой» без имён сумм заставляет читателя искать их самому —
а он и так уже не знает, какие это суммы.
Примеры (обе половины — из проб волны В14, не выдуманы)
module probe.d478
type Color enum Red | Green
type Signal enum Red | Amber
fn pick() -> Color {
Red \ ЗАКОННО: ожидаемый тип объявлен возвратом
}
fn main() {
ro c = Red \ ОШИБКА: ожидания нет -- писать Color.Red
ro s Signal = Red \ ЗАКОННО: тип биндинга объявлен
println("ok")
}
Что этот блок НЕ решает (границы)
- Не вводит вывод типа по соседям. Ожидание берётся только из объявления; «понятно из контекста» без объявления — не основание, иначе правило перестанет быть проверяемым.
- Не трогает квалифицированную форму.
Color.Redзаконна всегда и была законна до этого блока. - Не отменяет дефекты реализаций: нынешний компилятор выбирал сумму неверно (№962, №964) — это чинится отдельно и к формулировке правила отношения не имеет.
Связь
- D406 — синтаксис объявления сумм;
- реестр 221.1 №962, №964 — дефекты разрешения по голому имени в обеих реализациях;
- план 274.7, волна В14 — отказ Карины
AMBIGUOUS_VARIANT_MSGпосле этого блока становится ЧАСТИЧНО неверным (он отвергает любое голое имя, видимое дважды, даже когда ожидаемый тип есть) и требует работы, а не переезда.
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
@equal(other Self) -> bool
}
// Теперь (D53): kind-токен в системе D52
type Hash protocol {
@hash() -> u64
@equal(other Self) -> bool
}
type Logger effect {
log(msg str) -> ()
}
type Iterator[T] protocol {
mut @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]) идёт | Форма |
|---|---|
protocol | protocol-тип |
effect | effect-тип |
enum | sum (D406) |
set | type-set bound (D310) |
value { | value-record — stack-allocated (D228/D277/D290) |
( | tuple |
{ | record — heap-allocated |
alias | alias |
<base-type> enum | sum с явным базовым типом (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» тип,
определяется при удовлетворении (см. также D66 — Self
universal во всех type-контекстах).
Почему
- Унификация под одним keyword. Все типы (data + behavior) идут
через
type. Один keyword для объявления, kind-токен различает форму. Согласовано с D52, который вводитaliasкак kind-токен —protocolвстаёт в тот же ряд. - Снимается асимметрия. До D53:
protocol Foo— отдельный keyword, ноFooиспользовался как тип (в позиции параметра). Программист спрашивал «если protocol — тип, почему не объявляется через type?». D53 отвечает: теперь объявляется. - Анонимные protocol-типы становятся явными. Раньше
fn f(x { ... })без префикса — двусмысленно (record-литерал? record-тип? protocol-тип?). Сprotocol { ... }— намерение явно. any— пустой именованный protocol. Простое и согласованное решение для top-type, через ту же систему. Прецедент Go (type any = interface{}), Swift (protocol AnyObject { }).- Прецедент Go. Go объявляет
type X struct { }иtype X interface { }через единыйtypeс kind-токеном. D53 повторяет эту схему точно (толькоinterface→protocol). - 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 строже, ноanylowercase привычнее (Go, TS) и согласовано с примитивами.- Анонимный protocol без префикса
{ ... }. Двусмысленно с record-литералами и блок-выражениями.protocol { ... }всегда явно. - Литеральные protocol’ы со значениями полей (как
interface{}в Go допускает методы и встраивание других interface’ов через composition). Composition protocol’ов (Foo : Bar) — открытый вопрос (см. D42 раздел «Открытые вопросы»), не входит в D53.
Цена
- Большой breaking change. Все
protocol Foo { ... }в spec/, decisions/, examples/ переписать вtype Foo protocol { ... }. Это — повторение масштаба D52 миграции. - На одно слово длиннее.
type Hash protocol { ... }противprotocol Hash { ... }— лишнийtype(5 символов). protocolтеперь kind-токен, не keyword. Грамматически разные роли (kind-token ≠ leading keyword), хотя пишется одинаково.- Анонимные protocol-типы в позиции параметра — новая форма,
старая (без префикса) запрещена. Все
fn f(x { method() })→fn f(x protocol { method() }). - Q22 закрывается этим решением — больше не открытый вопрос.
Связь
- D17 — старая система объявлений, revised → D52.
- D52
— D53 расширяет:
protocolвстаёт в ряд kind-токенов рядом сalias. - D42 — D53
заменяет
protocolkeyword на kind-токен. Семантика структурной типизации и generic-параметров сохраняется. - 04-effects.md → D18 — эффект как использование protocol-типа в позиции эффекта. Меняется только объявление.
- 08-runtime.md → D26 —
anyдобавлен в prelude. - 03-syntax.md → D30 — naming:
anylowercase как исключение, по аналогии с примитивами.
Открытые вопросы
- 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-блоке: @ ОБЯЗАТЕЛЕН
НОРМА (D209, Plan 108.4). У instance-метода в объявлении протокола префикс @
обязателен; голое имя — ошибка разбора E_PROTO_METHOD_NEEDS_AT («add @ before
method name»). Статический метод пишется ТОЧКОЙ. Модификатор приёмника стоит ПЕРЕД @,
по умолчанию он ro:
type Hash protocol {
@hash() -> u64 // instance, приёмник `ro` по умолчанию
@equal(other Self) -> bool
}
type Iter[T] protocol {
mut @next() -> Option[T] // mut-приёмник
}
type FromIter[T] protocol {
.from_iter(it Iter[T]) -> Self // static — через точку
}
Грамматика и полный список ошибок соответствия реализации — D209.
ОТМЕНЕНО D209 (Plan 108.4, 2026-06-09). Здесь стояло правило, что в protocol-объявлении instance-методы можно писать в ОБЕИХ формах — с
@и без, — и что они эквивалентны:type Hash protocol { hash() -> u64 // голое имя — БОЛЬШЕ НЕ ПРИНИМАЕТСЯ eq(other Self) -> bool }Довод был: в protocol-блоке метод всегда instance, контекст однозначен, поэтому
@факультативен; выбор предлагался по вкусу — «с@ради симметрии с реализацией, без@ради краткости». Исключением уже тогда были mut-методы:mut @next()требовал@, потому что голоеmut next()двусмысленно с mut-binding’ом.Чем отменено: D209 сделал
@обязательным и добавил модификаторы приёмника (mut/ro/consume) с проверкой соответствия реализации; тогдашнее исключение для mut-методов стало общим правилом. ВопросQ-protocol-method-prefixзакрыт ЭТИМ решением, а не прежней секцией.
Правка редакционная (2026-09-16, окно Карины, приказ владельца; форма — интегратора, согласована по
/peers). Раздел стоял с 2026-05-08 и показывал голую форму как законную — то есть спека учила писать то, что компилятор отвергает ошибкой разбора. Норма не менялась и rev не выдавался: текст приведён к решению D209. Тем же проходом@дописан в 32 объявлениях протоколов по всей спеке, причём ТРИ статических метода получили точку, а не@(строка реестра №1144).
Реализация в 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’ом «Xis an effect, not a protocol — declare astype 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.
D53 amend (2026-07-20, [M-checker-protocol-typed-arg-any-bypass], worktree nova-protoany) — PLAIN protocol-typed параметр требует СТРУКТУРНОГО соответствия
Status: ACTIVE. Язык-меняющее по строгости (то, что раньше молча проходило, теперь — compile error).
Что было
Строка 940 этой же секции (log_one(my_user) // ok, User совместим со Display)
документирует PLAIN (не-generic-bound) protocol-typed параметр как «type value /
existential» — заявленный, легальный способ использовать protocol (D53 сам называет
его прямо, наравне с generic-bound-формой). Но checker-реализация (resolved_cat_of_depth,
compiler-codegen/src/types/mod.rs) мапила любой TypeDeclKind::Protocol
expected-тип в ResolvedType::Any — permissive-коллапс, унаследованный от старого
cat_of («protocol/effect/opaque permissive»), появившийся ДО структурной
protocol-машинерии (D53/D72/D142) и никогда не пересмотренный. Из-за этого
assignable_direct пропускал любой аргумент в PLAIN protocol-typed позицию БЕЗ
проверки, что аргумент реально реализует протокол — структурная типизация (§«Структурная
совместимость») энфорсилась только для generic-bound-формы ([T Protocol], отдельный
механизм — BoundCtx::check_satisfaction, D72/D142), никогда для голого x Protocol.
Симптом-прецедент: Fmt = protocol { use Write, @width()..., ... } (D422),
StringBuilder реализует только Write (embedded под-протокол), НЕ полный Fmt — но
.debug(sb)/.display(sb) с sb: StringBuilder компилировался БЕЗ ошибки (вместо
корректного «нужен FmtCtx.bare(sb, ...)», см. d374_write_sink_decouple.nv). На C
Nova_StringBuilder* шёл туда, где ожидался Nova_FmtCtx* (оба — указатель на offset 0)
→ тихая type confusion (пустая строка) вместо чистой compile-error.
Что теперь
assignable_direct СТРУКТУРНО проверяет PLAIN protocol-typed позицию — той же
проверкой «имя+арность метода присутствует (прямо, через use-embed (D145)
рекурсивно, или через default_body-фолбэк, D183)», что generic-bound-форма уже
применяет. Несоответствие → чистая compile error (переиспользует существующие коды
[E7301]/[E_NO_MATCHING_OVERLOAD] — новый код ошибки не заводился). Соответствующий
аргумент по-прежнему проходит — правило log_one(my_user) (строка 940) не изменилось,
изменилось только то, что НЕсоответствующий аргумент больше не проходит молча.
Не затронуто (намеренно, риск-контейнмент): resolved_cat_of_depth сам НЕ менялся
(Any-коллапс для protocol остаётся — используется другими consumer’ами); generic-bound
путь (BoundCtx) не менялся — уже был корректен; protocol-литералы
(expression-position, protocol Name { ops }, D142) не проверяются этим правилом —
их completeness уже проверяется на месте конструкции (missing-method там уже была
compile error, neg_protocol_lit_missing_method.nv).
Гейт: spec_tests/conformance standalone single-CU 508 PASS / 0 FAIL / 14 SKIP
(baseline 504/0/14 + 4 новых neg/neg_protocol_param_* фикстуры). Реализация —
protocol_mismatch_found/protocol_required_missing/protocol_missing_methods
(compiler-codegen/src/types/mod.rs, рядом с assignable_direct). Подробности —
backlog-followups.md →
[M-checker-protocol-typed-arg-any-bypass].
D55. Literal coercion в позиции с явным типом: sum-конструкторы и record-литералы {#d55}
Уточнено D491 (2026-10-02): обёртка значения в вариант суммы сверяет ЧИСЛОВОЙ тип точно (
int≠i64); «int-семья взаимозаменяема» снята.
Уточнено D489 (2026-10-02): тип литерала в позиции — единое правило литерала (пункт 4 и амендмент 2026-09-04 читаются через него).
Уточнено D488 (2026-10-01): размещение и передача значения (в т.ч. вид
str→ro []u8из подраздела ниже: заголовок вида на стеке вызывающего, без аллокации в любой позиции) — там единственный дом правила.
Что
В позиции, где компилятор явно знает целевой тип T (let с
аннотацией, аргумент функции, return-выражение), литерал
автоматически подгоняется под T. Четыре случая:
- Sum-coercion. Значение типа
Sоборачивается в единственный unary-конструкторC(S)sum-типаT. - Record-coercion. Анонимный record-литерал
{ field: value, ... }получает типTбез необходимости писать имя типа перед{}. - Map-coercion. Анонимный record-литерал
{ name: value, ... }в позиции, ожидающей str-keyed map (тип помечен атрибутом#from_fields, какHashMap[str, V]), превращается в map: имена полей становятся строковыми ключами. Это не record-coercion (поля литерала ≠ поля struct’аHashMap) — отдельное правило, см. ниже. - 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 errorE_LIT_OUT_OF_RANGE:ro a u8 = 300→300 > u8.MAX (255),ro b u8 = -1→-1 < u8.MIN (0).
Без runtime-cost, без subtyping. После coercion тип значения — сам T.
Амендмент 2026-09-04 (решение владельца; исследование
docs/dev/research/2026-09-04-numeric-widening.md§3, пробыdocs/plans/repro/926-mixed-numeric-doors/float_lit_ok,float_lit_neg). Пункт 4 перечислял только целые цели; компилятор при этом уже принималro x f32 = 1.5иb + 1приb f64(печатает3.5 1.5) и отвергалro n int = 1.5(E7301) — поведение разумное, но не записанное. Записывается:
- Float-литерал (с точкой или экспонентой) в позиции float-типа (
f32,f64) принимается безas— продолжение правила для целых;f32получает ближайшее IEEE-значение. В позиции целого типа — по-прежнему ошибка (E7301), без сужения.- Литерал без точки — «число», а не «целое», пока у него нет типа. В позиции float-типа и в операнде числового оператора против float-операнда он адаптируется к этому float-типу:
b + 1приb f64естьb + 1.0. Донор здесь Go (untyped constant), не Rust (там нужно1.0): D55 уже трактует литерал как подгоняемый под цель, и запрет дал бы1.0ради формы, не смысла.- Оба правила касаются только литералов. Значения типа
int/f32с float-операндом другой ширины — по-прежнему ошибка (D405, амендмент 2026-09-04): одинаковый конкретный тип, явныйas.
Амендмент 2026-10-02 → D489. Какой тип литерал получает в позиции и когда это ошибка — теперь одно правило в своём блоке; пункт 4 и амендмент 2026-09-04 выше читаются через него.
Амендмент 2026-10-01 (интегратор; реестр 221.1 №1517, ветка
p1517-variant-ctor-check). Конструктор варианта в позиции с явным типом судится по тому же правилу, что и литерал, — вcheck, а не в C:
- payload сверяется с ИНСТАНСОМ ожидаемой суммы.
fn bad() -> Option[str] => Some(1)—E7301:Option[int]иOption[str]— разные инстансы (D405), и поле варианта инстансаOption[str]естьstr.- число значений payload — как в объявлении варианта.
Some(1, 2)—E_VARIANT_CTOR_ARITY.- конструктор без какого-либо источника типа — отказ. Голое
NoneилиErr(e)в биндинге без аннотации (ro x = None) не получает тип «по умолчанию»:E_VARIANT_CTOR_UNTYPED, лечится аннотацией. Payload, из которого параметр выводится (ro x = Some(1)→Option[int]), источником типа является.Донор — вывод по ожидаемому типу на месте, без вывода из последующего использования: у литерала его нет и в пунктах 1–4. Родня, ещё открытая: №1528 (инстанс из переменной), №1529 (обёртка
Optionпо пункту 1), №1531 (ro x = []).
// 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) — record-элементы | да для каждого элемента | ⛔ ещё нет |
Литерал коллекции с явным типом ([]T) — sum/newtype-элементы (obvious single-wrapper, см. ниже) | да для каждого элемента | ✅ (Plan 200, 2026-07-12) |
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) для record-coercion помечена ⛔ — coercion анонимного{...}на элементах массива пока не работает. Пример станет валиден после расширения Ф.3a на element-positions (за scope Plan 52). Пока там нужен[User{...}, ...]с явным именем типа на каждом элементе.sum/newtype element-position — реализовано (Plan 200, 2026-07-12), см. «Obvious single-wrapper coercion» ниже.
ro args []SqlValue = [1, "alice", true]работает безSqlValue.Int(1)-обёрток — это отдельный, более узкий, механизм от record element-coercion выше (годится ровно для one-level sum-variant/newtype wrap, не для произвольных record- литералов).
Запрет дублирования имени типа (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
оборачивается, если:
- У
Tровно один unary-конструкторC(S), принимающий типS. - Значение точного типа
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
| Int(int) | I8(i8) | I16(i16) | I32(i32) | I64(i64)
| U8(u8) | U16(u16) | U32(u32) | F32(f32) | F64(f64)
| Text(str) | Bool(bool) | Bytes([]u8) | Null
ro args []SqlValue = [42, "alice", true] // [Int(42), Text("alice"), Bool(true)]
// В sql`...` тэге интерполяции тоже оборачиваются, каждое значение — в вариант
// СВОЕГО типа: int → Int, i32 → I32, str → Text, bool → Bool
ro q = sql`SELECT * FROM users WHERE id = ${42}` // args = [Int(42)]
ro n = 1
ro id i32 = 7
ro q2 = sql`UPDATE t SET name = ${"alice"} WHERE id = ${id} LIMIT ${n}`
// args = [Text("alice"), I32(7), Int(1)] — без единой ручной обёртки
// (варианты по числовым типам — решение владельца 2026-10-02, реестр 221.1 №1637;
// у `u64`/`uint` варианта нет: в 64-битное знаковое целое базы они не входят)
Obvious single-wrapper coercion (D55 amend, Plan 200, 2026-07-12)
АМЕНДМЕНТ 2026-08-21 (решение владельца) — NEWTYPE-ПОЛОВИНА СУЖЕНА ДО НЕТИПИЗИРОВАННЫХ КОНСТАНТ. Это ОТМЕНА части решения 2026-07-12, а не его уточнение.
Правило ниже читается с одной поправкой: пункт 1 (newtype) применяется ТОЛЬКО к нетипизированной константе — литералу, унарной операции над константой и арифметике констант (
40 + 60). Типизированная переменная и любое выражение над ней требуют явной формы:W(expr)илиexpr as W. Пункт 2 (sum) действует БЕЗ ИЗМЕНЕНИЙ, включая переменные.Почему половины разошлись. В сумму коэрсия ВЫВОДИТ тег: подходящий унарный вариант ровно один, и автор написал бы ровно то же самое (
SqlValue.Int(1)). В newtype коэрсия ПРИДУМЫВАЕТ утверждение — «это число есть строка реестра», — а требовать именно этого утверждения newtype и заводят. Обёртка, которая надевается сама, обёрткой не является.Граница взята у Go, на который D52 ссылается прямо («привычнее программистам с фоном Go»): неявно конвертируется нетипизированная константа, типизированная переменная — нет. Форма была заимствована в 2026-07-12, а граница сдвинута; замер 2026-08-21 показал цену:
ro i int = 7; take_row(i)иtake_row(i + 1)принимались молча, то есть в доменную обёртку втекало любое целое.Цена перехода измерена, а не оценена (2026-08-21): скалярных newtype (
type X int-форма) — НОЛЬ вstd/srcи НОЛЬ во всех пакетных репах (nova-http,nova-tls,nova-polaris,nova-compress,nova-bignum,nova-socks,nova-comments); вexamplesдве декларации (orm_demo:UserId,PostId), и обе уже пишутid as UserIdявно. Единственным носителем прежнего поведения был сам корпус — фикстураspec_tests/conformance/d55_literal_coercion.nv, перевёрнутая тем же слиянием, плюс новая негативнаяspec_tests/conformance/neg/d55_newtype_var_coercion_neg.nv.Выход для автора, которому нужна прежняя мягкость, — D429
#coerce: параY → Wобъявляется в.nv, как объявляетсяstr → ro []u8. Правило R11 (запрет дубля с встроенной обёрткой) с этим амендментом для newtype больше не срабатывает — иначе правило ужесточили бы, а дверь оставили запертой; для суммы R11 действует по-прежнему.Честно о доктрине: вводная D429 называет «single-wrapper вкл. переменные» частью доктрины «безопасных, очевидных, zero-cost конверсий». Этот амендмент ту доктрину для newtype-половины пересматривает: она написана для доменных обёрток в пользовательском коде и не рассматривала случай, ради которого newtype и понадобился в компиляторе, — РАЗВЕДЕНИЕ ПРОСТРАНСТВ ИНДЕКСОВ, где втекающий голый
intи есть тот самый дефект.
Уточнение и расширение sum-coercion, закрывающее компилятор-gap (не только «литералы», но и переменные и любые выражения, принимаемые чекером по этому правилу — см. «Какие выражения оборачиваются» ниже; не только sum, но и newtype — см. открытый вопрос в D52):
Значение выражения expr в позиции с явным ожидаемым типом W
авто-оборачивается, если W — ровно одна из двух форм «обёртки над
значением», и КАНДИДАТ однозначен:
- Newtype (
type W Y, D52) —exprтого же структурного «рода» (см. ниже), что иY→expr as W(компилятор вставляетas-cast автоматически;newtype— та же C-repr, что иY, поэтому это zero-cost, не runtime wrap). - Sum с ровно одним unary-вариантом
C(Y), чейYсовпадает по «роду» с типомexpr→W.C(expr)(как ручная формаSqlValue.Int(1), которую авторы писали раньше вручную).
Совпадение по «роду»: f32/f64, bool, str, []u8/Vec[u8], именованный тип по имени; для
ЧИСЕЛ — ТОЧНЫЙ тип значения (int ≠ i64, решение владельца 2026-09-10; D491): int-переменная
в вариант с i64-payload’ом не оборачивается — пишется n as i64. Литерал же типа не имеет и берёт тип
payload’а по D489: если вариант его семьи (целый payload — для целого литерала, f32/f64 — для дробного) ровно один, литерал уходит в него, когда представим ТОЧНО. Если таких вариантов несколько, литерал берёт тип по умолчанию D44 — int для целого, f64 для дробного — и оборачивается в вариант с payload’ом ровно этого типа (ro v SqlValue = 1 — Int(1), ${1.5} — F64(1.5)); варианта с таким payload’ом нет — коэрсия неоднозначна, обёртки нет, действует прямая проверка типов. (Решение владельца 2026-10-02, реестр 221.1 №1637.) Прежняя фраза «int-семья взаимозаменяема, ширина
доводится as-cast’ом, „прячет“ int→i64 widening» снята 2026-10-02 как противоречащая D405/D491. Int и
float — РАЗНЫЕ «роды», иначе ${1} был бы неоднозначен между I(i64)/F(f64) и ничего бы не обернулось;
то, что реализация (cat_compatible_rt) сегодня пропускает значение float в целую позицию и обратно, — дефект
(реестр 221.1 №1611), а не норма.
Амендмент (ОКНО-5, 2026-07-23): fn-типы в таблице родов. Род Fn
добавлен для структурного совпадения fn(A, …) -> R с fn(A, …) -> R
(параметры и возврат совпадают структурно) — тот же механизм, что и для
int/str, БЕЗ отдельного нового кода: single_wrap_candidates уже был
обобщён на произвольный Newtype(inner) (не только скалярный inner), а
WrapKind::Other-fallback для TypeRef::Func УЖЕ различал fn-значения от
не-fn (единственный практический сценарий этого амендмента —
type Handler fn(A) -> B с ОДНИМ таким newtype в скоупе — уже корректно
разрешался существующим кодом; расширение зафиксировано here как явное
намерение, а не побочный эффект). Пример:
type Handler fn(ServerRequest) -> ServerResponse
fn make_response(req ServerRequest) -> ServerResponse => { body: "hi" }
fn accept(h Handler) -> str => "ok"
ro r = accept(make_response) // fn -> Handler, авто-подъём
Как и для int/str — однонаправленно: Handler → fn (или
Handler → Middleware, другой newtype над ТЕМ ЖЕ fn-типом) НЕ коэрсится —
единственный существующий механизм (single_wrap_candidates) ключуется
ТОЛЬКО по expected-стороне и всегда ОБОРАЧИВАЕТ голое значение в
именованную обёртку, никогда не разворачивает названную обёртку обратно
(нет и не было обратной ветки). Регресс:
spec_tests/conformance/neg/d52_newtype_fn_reverse_coerce_neg.nv. См.
D52 §Амендмент (ОКНО-5)
для call-through/alias-прозрачности этого же newtype-рода.
Однозначность обязательна: если ⩾2 кандидата совпадают по «роду» (гипотетический sum с двумя int-payload’ами) или 0 совпадают — auto-wrap НЕ срабатывает, экспрешн остаётся как есть (существующая ошибка/поведение).
Ровно один уровень — кандидат-обёртка НЕ разворачивается рекурсивно:
int → UserId → Wrapper (обёртка над обёрткой) остаётся отвергнутой
(нужен явный промежуточный cast/конструктор). Аналогично — значение
без явной целевой типизации (ro x = "test" — тип str, никакой
обёртки) coercion не касается: правило работает только там, где
target-тип явно известен (см. таблицу позиций выше — let/const,
return, call-arg на РЕЗОЛВЛЕННОГО callee, element-позиция
[]T/Vec[T]).
Явный конструктор (SqlValue.Int(1), n as UserId) остаётся валиден без
изменений — значение УЖЕ целевого типа W, поэтому обёртка не
запускается (дважды не оборачивается). Форма выражения здесь ни при чём:
обёртка не ставится потому, что тип выражения уже W, а не потому, что
выражение — не «голый» литерал/Ident.
Какие выражения оборачиваются (уточнение 2026-10-01). Sum-половина
правила применяется к любому выражению, которое проходит проверку
D55, — то есть чей тип совпадает по «роду» с payload’ом единственного
unary-варианта C(Y) суммы W: литералу, переменной (в том числе
связке образца match/if let/for), ro/mut без аннотации,
инициализированной вызовом, чтению поля, вызову. Форма выражения
значения не имеет; значение имеет только его тип. Правило действует во
всех позициях, где действует D55: аргумент вызова, return, элемент
литерала массива, поле литерала записи, ro/mut с аннотацией. Каждое
принятое чекером выражение материализуется как W.C(expr) (см.
«Реализация» ниже) — принятое и не обёрнутое выражение есть дефект
компилятора, не свойство языка. Newtype-половина (п. 1) по-прежнему
ограничена нетипизированными константами (амендмент 2026-08-21 выше).
type UserId int // newtype
ro id UserId = 100 // 100 as UserId (было: E7301)
ro n = 100
ro id2 UserId = UserId(n) // переменная — только явно (амендмент 2026-08-21);
// `ro id2 UserId = n` — E7301
Реализация: assignable/single_wrap_candidates/wrap_kind_of в
compiler-codegen/src/types/mod.rs (accept-сторона, единое правило);
материализация (AST-rewrite ${1} → SqlValue.Int(1)) — в уже
существующем mutable-проходе annotate_map_literals/
MapLitAnnotator::try_wrap_leaf (тот же expected-type-propagated walker,
что и map-литерал coercion — без отдельного нового прохода).
Генерики (амендмент, реестр 221.1 №320, окно p320, 2026-08-04):
[M-generic-sumlift-mono-missing-variant-wrap] закрыт для варианта, чей
payload — ИМЕНОВАННЫЙ тип, зависящий от generic-параметров expected
(type Node[K, V] enum Empty | Leaf(Wrap[K, V]), Wrap[K, V] — своя
value-декларация): w Wrap[K, V] в ro-return-позиции с ожидаемым
Node[K, V] авто-оборачивается в Node.Leaf(w) — было ICE (return w; без
обёртки → C-типовая несовместимость на границе функции) до Plan p320.
До фикса single_wrap_candidates безусловно бейлилась на ЛЮБОЙ generic
expected (гейт !generics.is_empty()); теперь Sum-плечо этот гейт не
проверяет — WrapKind-дизамбигуация уже была name-only для Named-рода, так
что собственные generics expected ей были не нужны. Материализация ТАКЖЕ
сменила форму для generic-цели: голый Leaf(w) (как Some(x)/Ok(x) для
Option/Result), а не квалифицированный Node.Leaf(w) (как для
не-generic SqlValue.Int(1)) — emit_c.rs’s try_emit_explicit_variant_ctor
намеренно отклоняет квалифицированный вызов на generic-сумме (её ctor
дорога — mono-aware bare-Ident-путь).
НЕ закрыто (пример ниже — ИСТОРИЧЕСКИЙ, был добавлен как желаемое
поведение и НЕ РАБОТАЛ уже на момент этого амендмента, независимо от
Plan p320’s фикса выше): payload = ГОЛЫЙ generic-параметр сáмого sum’а
(type Wrapper[T] enum W(T) | Empty, W(T) — T тут не именованный тип, а
буквальное имя типового параметра). wrap_kind_of для такого payload’а даёт
WrapKind::Named("T") (T не резолвится ни в один известный тип), что
никогда не совпадёт с WrapKind::IntFamily/Str/etc. литерала/переменной
без подстановки T → конкретный тип из expected.generics — эта
подстановка НЕ реализована ни до, ни после Plan p320. ro w Wrapper[int] = 42 сегодня даёт [E7301] cannot assign value of type int to w declared as Wrapper[int]. Закрытие этого случая — отдельная, более инвазивная работа
(генерик-aware WrapKind-подстановка в single_wrap_candidates/
wrap_kind_of), не начатая.
Str → ro []u8 coercion (D55 amend, Plan 208 Ф.0, 2026-07-15; правило подтверждено владельцем 2026-10-01)
Уточнено D488 (2026-10-01): размещение и передача значения — там единственный дом правила (место решает убегание, передачу
ro— размер, порог три слова;mut— указатель). Где этот блок расходится с D488, действует D488.
Правило (решение владельца 2026-10-01): у
strесть@bytes()с неявной коэрсией через#coerce, поэтому строка доступна везде, где нужен[]u8. Механизм — D429: std объявляет пару#coerce export fn str @bytes() -> ro []u8(std/src/runtime/string/core.nv, «view-lane pairstr → ro []u8»); D55 задаёт позиции, D429 — механизм вставки. Отдельного правила «только литерал» нет и не было: литерал — частный случайstr-значения. Поправка №492 ниже — не исключение, а само правило.
АМЕНДМЕНТ 2026-10-01 (решение владельца, №1480, вариант (а)): коэрсия — только в позиции только-для-чтения. «Везде, где нужен
[]u8» выше уточняется: везде, где нужен читающий[]u8. Позиции коэрсии — те, где значение остаётся видом: параметр по умолчанию (x []u8, онro),ro-связь с аннотацией (ro b []u8 = s), и любая позиция, чей тип написанro []u8— возврат-> ro []u8, элемент[]ro []u8, поле записиf ro []u8. В позициях, где значение СОБСТВЕННОЕ и потом может стать изменяемым, — возврат-> []u8, элемент[][]u8, поле записиf []u8— коэрсии НЕТ: там нужен явныйs.bytes().clone(), без него —E_READONLY_COERCEна месте значения. Причина:fn f() -> []u8 => "abc"; mut b = f(); b[0] = 65 as u8проходил проверку и падал SIGSEGV — вид строки утекал в собственный[]u8и отмывался обычнойmut-связкой (пробыdocs/plans/repro/d55-str-bytes/launder_*_segfault.nv.txt). Правило общее для всех view-пар D429 (I @m() -> ro O), не только дляstr: позиция, не названная выше, считается собственной (отказ по умолчанию). Ниже места прежнего текста, называвшие-> []u8и[][]u8позициями коэрсии, помечены этим амендментом, а не удалены.
В позиции с явным ожидаемым типом []u8 любое выражение типа str — литерал,
переменная, результат вызова, чтение поля — принимается: компилятор АСТ-переписывает его
в .bytes() (D429 R7), байт-в-байт как явный вызов, без обёртки и без копии:
fn Write mut @write(bytes []u8) -> ()
w.write("Point(") // ✅ литерал → []u8 по паре #coerce str @bytes()
w.write("Point(" as []u8) // эквивалент, явная форма тоже валидна
ro s = "Point("
w.write(s) // ✅ переменная — то же самое (D429; поправка №492)
w.write(s.bytes()) // ✅ явный .bytes() (D176) валиден, но избыточен: канон — голое значение (D429 R9)
Почему это допустимо. str — буфер {ptr,len} из UTF-8-байт (D139); @bytes() строит
над ним вид без копии и без владения. Поэтому пара относится к «view»-полосе D429 (R2):
zero-cost, без скрытой аллокации, и поэтому её цель — именно ro []u8, а не []u8-владелец.
Коэрсия эквивалентна тому, как 100 принимается в позиции u8/UserId (case 4 /
single-wrapper выше): пишущему не нужен явный жест, потому что ничего не копируется и
ничего не передаётся во владение.
[]u8 и ro []u8. Коэрсия даёт ТОЛЬКО вид ro []u8 (буфер str иммутабелен). Поэтому
она действует в позициях, где читающий []u8 — это read-only вид: параметр x []u8
(параметры ro по умолчанию, D176/D246 — ro писать нельзя, E_REDUNDANT_PARAM_RO),
ro-связка с аннотацией []u8 (связка ro уже даёт ro-вид), возврат, элемент
коллекции (амендмент 2026-10-01, №1480: только -> ro []u8 и []ro []u8; -> []u8 и
[][]u8 — собственные позиции). В позиции, требующей ИЗМЕНЯЕМЫЙ []u8 (mut x []u8, consume), ro O не матчит
(D429 R6): там нужен явный жест — s.bytes().clone() — иначе запись пошла бы в буфер str.
То же — в СОБСТВЕННОЙ позиции (-> []u8, [][]u8, поле f []u8; амендмент №1480): её
значение может стать изменяемым через mut-связку результата.
Позиции — таблица «явно ожидаемого типа» выше (case-таблица) и D429 R6: call-arg на
резолвленный []u8-параметр (свободная функция, метод конкретного типа, метод через
protocol-приёмник), ro с явной []u8-аннотацией, return-позиция (-> []u8
и-> ro []u8), элемент коллекции ([][]u8,[]ro []u8), поле записи f ro []u8
(зачёркнутое снято амендментом 2026-10-01, №1480: собственные позиции, нужен
s.bytes().clone()). Не ограничено @write — это
ОБЩЕЕ правило для ЛЮБОЙ такой позиции (не отдельный @write-overload, отвергнутый в
D422
§1: протокол Write остаётся чисто @write([]u8), без str-перегрузки).
ПОПРАВКА 2026-08-09 (реестр 221.1 №492) — теперь это и есть правило. Прежняя редакция ограничивала коэрсию литералом: «str-литерал (не переменная, не str-выражение)». Реализация этого ограничения НЕ имела — проверено прямой сборкой:
take("hi")иro s = "hi"; take(s)даютPASS: 1 FAIL: 0одинаково. Расхождение стоилоstdлишнего метода:TcpStream.@write_str(s str), состоявший изNet.write(@, s.bytes()), был написан как обход ограничения, которого нет, и удалён вместе с этой поправкой (13 носителей переведены на.write(...)). Спека приведена к реализации: коэрсия для переменных удобна, безопасна (strуже держит байты, копии не возникает) и уже работала. 2026-10-01 владелец подтвердил это как правило; устаревшие места этого подраздела («w.write(s)—E_TYPE_MISMATCH», «Почему литерал, не любое str-значение», name-gated реализация) сняты и заменены текстом выше.
Статус реализации (проверено компилятором 2026-10-01 на главной ветке; nova check +
nova build + запуск). Плоский «только метод write с литералом»
(synthesize_write_str_lit_bytes_coercion, Plan 208 Ф.3) давно снесён; вставка идёт общим
D429-проходом по значению. Фактически:
- Работает (литерал, переменная и результат вызова —
check✅,build✅, запуск ✅): аргумент свободной функции с параметром[]u8; метод конкретного типа (StringBuilder.write,BytesWriter.write); метод через protocol-приёмник (fn emit(mut w Write)— прежняя дыра «CC-FAIL на protocol-приёмнике» закрыта); элемент литерала[][]u8и[]ro []u8. Литерал и переменная — также вro b []u8 = …и в возврате-> []u8/-> ro []u8. Чтение поля и интерполяция"${s}"как аргумент — ✅. - Не работает (расходится с правилом; правило остаётся, строки реестра заводит интегратор):
(1)
ro b []u8 = <вызов str>и= <чтение поля str>(такжеmut), а также return вызова или поля (-> []u8 => nm(),return rec.name) —checkпроходит,buildпадает в C (CC-FAIL: initializing/returning 'nova_str' … 'Nova_Vec____nova_byte *'); литерал и голый идентификатор в этих позициях работают; (2) scope-локальнаяconst c []u8 = "…"—E_CONST_BYTES_NOT_CONSTEXPRна стадии codegen ([]u8— куча, не constexpr; пишитеro); (3)strпринимается в позицииmut []u8(fn zap(mut b []u8),mut b []u8 = s) —checkпроходит, запись в такой буфер даёт SIGSEGV (литерал лежит в.rodata; для строки из кучи запись молча испортила быstr): это нарушает R2/R6 D429 (ro Oне матчитmut-позицию) и оформлено как дефект, а не как часть правила. Пробы —docs/plans/repro/d55-str-bytes/.
АМЕНДМЕНТ СТАТУСА 2026-10-01 (после слияний №1451/№1452 и №1480). Абзац выше снят до №1452 и в двух пунктах устарел; текст оставлен как запись замера, верное сегодня — здесь:
- пункт (1) неверен: вызов, чтение поля,
if/match-значение вro-связке и в возврате больше не дают CC-FAIL — проверяющий записывает вердикт коэрсии по выражению, и переписывание вставляет.bytes()ровно туда, независимо от формы (одна дверь,compiler-codegen/src/types/coerce_door.rs, №1451);- пункт (3) неверен:
strв позицииmut []u8(mut/consume-параметр,mut-связь, присваивание в неё) —E_READONLY_COERCE(№1452);- собственные позиции (
-> []u8, элемент[][]u8, полеf []u8) —E_READONLY_COERCEпо амендменту №1480 выше; правило общее для всех view-пар, и позиция, которую ни однаro-по-режиму позиция (параметр по умолчанию,ro-связь) не признала своей, считается собственной;- протокольный приёмник: коэрсия аргумента закрыта (№1451/№1452: дверь применяется к аргументам по объявлению метода протокола), но сама сверка ТИПОВ аргументов с параметрами протокольного метода не делается —
intпроходит как[]u8; это открытый №1481, не часть правила;- пункт (2) (
const c []u8 = "…"в области видимости) — без изменений. Фикстуры:spec_tests/conformance/standalone/p1452_str_bytes_positions.nv,standalone/p1480_str_bytes_owned_positions.nv,neg/p1452_str_bytes_mut_*_neg.nv,neg/p1480_*_neg.nv.
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
Правила:
- Все обязательные поля должны присутствовать в литерале — как и для именованного record-литерала (D17 construction всегда требует все поля).
- Имена и типы полей должны точно соответствовать
T. Лишнее поле или несовпадение типа — ошибка компиляции. - Field punning (D17)
работает:
let u User = { id, name }еслиidиname— переменные в скоупе. - Без явного целевого типа литерал
{ 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 захардкожен дляАМЕНДМЕНТ 2026-08-30 (реестр №842): «позже» УЖЕ НАСТУПИЛО, и абзац описывал состояние до собственного расширения. Оракул читает АТРИБУТ, а не имя типа:HashMap. ПротоколFromFields[V]как точка расширения (OrderedMap,BTreeMap[str, V]) — позже.compiler-codegen/src/ast/mod.rs:889определяет#from_fieldsкак маркер str-keyed map-типа ВООБЩЕ, а:2529прямо обрабатывает случай «#from_fieldstype OTHER thanHashMap». Значит любой пользовательский тип, объявивший#from_fields/#from_pairsи давший протокольныеSelf.new(cap),mut @cap(n)иmut @insert_new(см. пункт ниже), получает ту же коерсию — точка расширения открыта, отдельныйFromFields[V]не понадобился. Абзац оставлен зачёркнутым, а не удалён: он объясняет, почему в коде рядом живут упоминания «bootstrap».Почему это важно читателю, а не только историку: пока строка стояла как действующая, она говорила «свой map-тип написать нельзя, ждите». Нашло окно 274 при подготовке волны коерсий — и нашло КОДОМ, а не аналогией, отдельно оговорив, что предыдущую свою находку (про
HashMapбез D-блока) оно само же отозвало как ложную аналогию. -
Протокол
#from_fields: тип обязан иметь:Self.new(cap int = …) -> Self— конструктор с ёмкостью параметром-умолчанием;mut @cap(n int) -> ()— сеттер ёмкости (предаллокация подnзаписей);mut @insert_new(key str, val V) -> ()— вставить новую запись (без возвратаOption; дублей нет по construction). Отсутствие любого из методов → CC-FAIL при десугаринге.
АМЕНДМЕНТ 2026-08-30 (реестр №844): здесь требовался
static with_capacity(n int) -> Self— МЕТОД, СНЯТЫЙ АМЕНДМЕНТОМ D372 ОТ 2026-07-06. То есть тип, написанный ПО ЭТОЙ СПЕКЕ, реализовал бы несуществующий контракт и получил CC-FAIL на ровном месте. Сверено КОДОМ, четыре адреса:std/src/collections/hash_map/core.nv:173(«instance-method replacement for the removedHashMap[K, V].with_capacity»),:108(«sizing-helper дляnew()и сеттераmut @cap(n)(ex-with_capacity)»),:123(живой конструкторexport fn HashMap[K, V].new(cap int = 16)) иcompiler-codegen/src/ast/mod.rs:893— оракул требует дословно «ExpectedType.new()+.cap(n)+insert_new(k, v)per pair».Найдено окном 274 и владельцем; способ нахождения есть часть урока: окно скопировало эту строку в свой план КАК ФАКТ О РЕАЛИЗАЦИИ, не сверив с кодом. Правило отсюда: цитата из спеки, говорящая о том, ЧТО КОМПИЛЯТОР ТРЕБУЕТ, сверяется с компилятором, а не пересказывается.
Правила:
- Ключи — только str (имена полей литерала). Нестроковые ключи,
не-идентификаторные строки, вычисляемые ключи — это map-литерал
[k: v](03-syntax.md → D108), не{...}. - Значения гомогенны — все поля одного типа
V(после возможной sum-coercion на каждом значении). - Композиция с sum-coercion:
ro j HashMap[str, JsonValue] = { name: "alice", age: 30.0 } // "alice" → Str("alice"), 30.0 → F64(30.0); оба → JsonValue // (целый литерал 30 лёг бы в Int(30): у JsonValue с №1638 есть оба) - Десугаринг — без промежуточных объектов: block-expression с
new(cap)+@insert_new, никакой промежуточный record не материализуется (литерал — только синтаксис):
({ mut _m0 = HashMap[str, V].new(n) _m0.insert_new("debug", true) _m0.insert_new("verbose", false) _m0 }@insert_newвместо@insert— мапа только что создана, дублей нет, нет нужды вOption[V]-возврате.) - Пустой
{}— это НЕ пустая мапа.{}всегда парсится как пустой 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). Правило удалено; пустая мапа — только[]. - Дубликаты ключей невозможны — имена полей 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 не применяется
Почему
- Огромный win в эргономике для prelude-типов.
Option[T]иResult[T, E]— самые частые sum’ы языка. Без coercion программист пишетSome(42),Ok(user)каждый раз. С coercion —42,user. Убирает значительную часть boilerplate. - Без subtyping. Тип значения после coercion — сам sum или
сам record, не подтип. На уровне типов всё чисто: pattern match
exhaustive, variance не возникает. Anonymous unions (TS-style
string | number) не вводятся — coercion не делает того же эффекта семантически. - Без runtime-cost. Sum-обёртка — обычный конструктор, runtime-tag уже есть в representation sum’а (D52). Record-coercion — это просто подстановка имени типа, никакого runtime-преобразования.
- Закрывает use-case’ы
any(sum) и убирает шум именования (record).sql\…${value}`теперь type-safe —valuecoerce'ится вSqlValueбез[]anyи безis-extract.let u User = { id: 2, name: “Bob” }` — без повтора имени типа. - AI-friendly. LLM пишет
[42, "alice"]для SQL-аргументов естественно, без думания о конструкторах.{ id: 2, name: "Bob" }в позиции с явным типом — естественный способ создать record. Имя типа из аннотации — единственный «локальный контекст», который нужно прочитать, и он уже рядом. - Прецеденты:
- 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 в позиции с явным типом, но только в этой позиции.
- Swift
Что отвергнуто
- 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.
Цена
- Implicit conversion — первая в Nova. До D55 язык избегал неявного. Это философский сдвиг, обоснованный эргономикой prelude-типов и анонимных record. AI-friendly: LLM не должна угадывать конструктор или имя типа.
- Type-checker сложнее. В позиции с явным типом нужно проверить exact match, потом coercion (sum или record). Стандартное расширение, но code path не нулевой.
- IDE-подсказки усложняются. «Ожидается
StrOrInt, переданstr→ coerce вS», «ОжидаетсяUser, передан анонимный record → подгонка подUser» — IDE должна это показывать. - Migration sum’а опасна: добавление нового unary-конструктора
с тем же типом параметра ломает существующий код (был exact match
через coercion в
S(str), стал ambiguous из-заS(str) | S2(str)). Это breaking change для sum’а — программист должен учитывать. - Migration record’а тоже: добавление обязательного поля в record ломает все анонимные литералы без него. Это известная проблема record-типов вообще, не специфическая для D55.
- Закрывает большую часть use-case’ов
any— это плюс, но требует пересмотра примеров (args []any→args []SqlValue). - Парсер — без type-driven decisions. Coercion работает в
позициях, где целевой тип уже известен type-checker’у —
парсер по-прежнему чисто синтаксический.
{...}парсится как record-литерал/block-выражение по обычным правилам D17/D49, а тип ему присваивает type-checker по аннотации.
Связь
- D52 — sum-типы и unary-конструкторы, на которых coercion работает.
- D53
—
anyостаётся для подлинно открытых случаев (plugins, reflection), D55 закрывает большую часть use-case’ов через closed sum’ы. - 03-syntax.md → D44 — numeric literal coercion
(
100подгоняется подu8/u32в позиции типа) — D55 расширяет эту идею на sum’ы и record’ы. - 03-syntax.md → D54 —
as/isостаются явными для конвертации/проверки. D55 не вводит implicit cast между обычными типами, только для sum-обёрток и record-литералов. - 08-runtime.md → D26 —
Option[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 (
42→f64для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" }в позицииUser→User { id: 1, name: "alice" }).
Это эргономический сдвиг уровня D52, без слома типовой модели.
Альтернатива (anonymous unions через subtyping) рассмотрена и отвергнута — слишком серьёзное расширение системы типов для эргономического выигрыша. D55 даёт похожее удобство более узким и контролируемым механизмом.
Уточнение 2026-10-01 (решение владельца; №1260). Авто-обёртка в вариант суммы (
W.C(expr)) применяется к каждому выражению, которое чекер принимает по правилу D55, а не только к «голому» литералу илиIdent: к литералу, переменной (включая связки образцаmatch/if let/for),ro/mutбез аннотации от вызова, чтению поля и вызову — во всех позициях D55 (аргумент, return, элемент литерала массива, поле литерала записи,ro/mutс аннотацией). Прежняя фраза «матчится только голый литерал/Ident, неCall/As» в §Obvious single-wrapper coercion описывала узость прежней материализации, а не правило, и снята; смысл абзаца про явный конструктор (значение уже типаWне оборачивается повторно) сохранён. Поведение компилятора — слияние №1260 (spec_tests/conformance/standalone/p1260_sumwrap_roundtrip.nv).
Уточнение 2026-10-01 (решение владельца):
str→ro []u8по#coerce, переменные включены. Уstrесть@bytes()с неявной коэрсией через#coerce(параstr @bytes() -> ro []u8, D429,std/src/runtime/string/core.nv), поэтому строка — литерал, переменная, результат вызова — доступна везде, где нужен[]u8(видro []u8, без копии и без владения). Поправка 2026-08-09 (реестр 221.1 №492) — это и есть правило; прежнее «только литерал», примерw.write(s) // ❌ E_TYPE_MISMATCH, абзац «Почему литерал, не любое str-значение» и «Статус реализации (Plan 208 Ф.3)» в подразделе «Str →ro []u8coercion» сняты. В позицию изменяемогоmut []u8коэрсия не идёт (D429 R6); амендмент 2026-10-01 (№1480, вариант (а)): не идёт и в собственную позицию —-> []u8, элемент[][]u8, полеf []u8(нуженs.bytes().clone(); вид — в-> ro []u8,[]ro []u8,f ro []u8). Что из позиций сегодня реально работает, а что расходится с правилом — там же, «Статус реализации».
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
@equal(other Self) -> bool
}
type Iterator[T] protocol {
mut @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 {
mut @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 | |
|---|---|---|
| Объявление T | protocol P[T] | method[T] в сигнатуре |
| Scope T | весь protocol | один метод |
| Один handler работает с | одним T | любым T (per-call) |
| Использование | with P[Int] = ... | with P = ...; P.method[Int](...) |
| Реализация | мономорфизация по T | rank-2 polymorphism в handler’е |
В одном protocol’е можно комбинировать оба механизма:
type Stream[T] protocol {
mut @next() -> Option[T] // T на protocol-уровне
@fold[Acc](init Acc, f fn(Acc, T) -> Acc) -> Acc // Acc на методе
}
T фиксирован для stream (Stream[int]), Acc независим — fold
может собирать в разные accumulator-типы из одного и того же stream’а.
Почему
- Намерение должно быть явным. Старая форма
type X = { методы }визуально совпадала с record-формойtype X { поля }, различаясь только знаком=. LLM и человек различали намерение по единственному символу — хрупко. Отдельный keyword делает намерение явным с первого токена. - Прецедент.
protocolкак keyword для интерфейсов используется в Swift, Objective-C, Clojure, Elixir, Python (typing.Protocol). Семантически Nova ближе всего к Pythontyping.Protocol— чисто структурный subtyping. - Эффекты в сигнатурах методов делают 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 → D15 — D15 ввёл структурные интерфейсы; D42 уточняет грамматику отдельным keyword.
- 02-types.md → D39
—
use Typeдля делегации между record-типами;protocolне embed’ится. - 03-syntax.md → D35 — методы через
@как способ удовлетворить protocol. - 01-philosophy.md → D1
—
protocols+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. Роль перешла к
protocolkeyword (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’ов — требования описываются типом параметра.
Почему
- Следует из принципа «не добавлять фичи без оправдания центральной идеей или AI-first». Rust-style traits ни тому, ни другому не служат.
- Унификация: одна концепция «структурный тип» вместо двух («record»
- «interface»). Меньше синтаксиса — проще для LLM.
- Эффекты в сигнатурах методов делают структурный тип строже, чем Go interface — это уникальное свойство Nova, которое нельзя получить простым заимствованием Go.
Что отвергнуто
trait/interfaceкак отдельный keyword с nominal-семантикой (Java/C#/Rust).impl Trait for Typeблоки.[T: Trait]bounds в дженериках.dyn Traitvsimpl Traitразделение.- Ассоциированные типы.
- Дефолтные методы.
- Trait-наследование, specialization, HKT.
Цена
- Нет имени для контракта иначе как через
protocol. В IDE нельзя «найти всех, кто реализует X» так же легко, как в Rust/Java — поиск идёт по совпадению методов. - Нет номинальности. Если очень нужна — через newtype-обёртку (паттерн, не фича).
Связь
- 02-types.md → D42
—
protocolкак явное имя для контракта. - 02-types.md → D39 — embed/delegation как механизм композиции, не subtyping.
- 03-syntax.md → D35 —
@-методы как способ удовлетворить protocol.
Эволюция
Ранние черновики описывали контракт через 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)
с двумя расширениями:
- Анонимные embed-методы регистрируются в overload registry с
kind = MethodKind::Delegated(via_use_anonymous)— флагом «delegated». - 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 Type | use _ Type |
|---|---|---|
| Имя поля | явное (name) | нет |
| Auto-proxy | да | да |
| Override через own-method | да | да |
Доступ к base через @<name>.method() | да | нет |
| Multiple embed одного типа | да (разные имена) | нет (compile error) |
| Construction через literal | T { 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 не
возникает — нет иерархии.
Почему
- Замена наследования (D1) — embed решает 80% задач композиции без сложности subtyping.
- Согласованность с D30 naming. Поля Nova — snake_case (D30). Default-имя по типу (Go-style) дало бы PascalCase-поле — нарушение D30. Явный alias обязывает программиста выбрать snake_case, всё единообразно.
- Согласованность с language-wide порядком.
use name Type— тот же порядок «имя тип», что параметры, поля, let-bindings, for-loop. Одно правило для всего языка. - AI-friendly. Никакой magic-conversion (
HashMap→hashmap/hash_map?), программист явно выбирает имя поля. LLM не догадывается.
Что отвергнуто
- Default-имя поля по типу (
use Account→ полеAccount, Go-style). Создаёт исключение в D30 (поля PascalCase в одном record-блоке с snake_case полями). Auto-conversion PascalCase → snake_case (HashMap→hash_map?) — magic, не очевидное правило. use Type as name(Rust import-style).asзафиксировано для cast в выражениях (D54) и импортов (07-modules.md → D29). В embed — «объявление поля», порядок «имя тип» согласован с остальным языком.- Subtyping — противоречит D1; полиморфизм через protocol.
- Множественное наследование — известный антипаттерн (diamond, fragile base).
Связь
- 01-philosophy.md → D1
—
useкак замена наследования. - 02-types.md → D17
—
useвнутри record-блока. - 02-types.md → D15, D42 — полиморфизм для embed-типов идёт через protocol, не через subtyping.
- 03-syntax.md → D30 — naming convention (поля snake_case, типы PascalCase). Обязательность alias следует из D30.
- 03-syntax.md → D35 —
@field.method()для явного вызова из метода обёртки. - 03-syntax.md → D38 — generic-применение в
embed:
use iter HashMapIter[K, V].
Эволюция
Первая редакция 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 HashMap → hash_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_fieldsregistry 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 — открытый вопрос.
D443. use — hard keyword → контекстный keyword (Plan 239, 2026-08-01)
Что
use retracted из hard keyword (TokenKind::KwUse, безусловный, как let/
const) в контекстный keyword — тем же механизмом, что bench/measure
(D121) и apply/null (D278 §3): лексер выдаёт Ident("use") всегда, парсер
распознаёт use позиционно в трёх местах, где у него уже была синтаксическая
роль:
- import-synonym —
use path.to.mod(aliasimport, с bootstrap). - record-field embed (D39) —
use alias Typeвнутриtype { ... }. - protocol embed (D145 §Protocol composition) —
use TypeNameв началеprotocol { ... }тела.
Вне этих трёх позиций use — обычный идентификатор: имя поля, переменной,
функции, параметра, namespace-сегмента.
Правило
Disambiguation — lookahead на 1-2 токена вперёд от Ident("use"), зеркало
bench-техники (D121 «parses как item только когда за bench идёт
string-literal»):
- Top-level item position:
Ident("use")+ следующий токенIdent/./..(похоже на начало dotted-path или relative-anchor) ⇒ import-synonym. Иначеuseне участвует в top-level item dispatch (падает в generic «expected fn/type/…» путь, как любой другой стрей-идентификатор на этой позиции). - Record-field list (внутри
type { ... }):Ident("use")+Ident(alias или_) + токен ПОСЛЕ него НЕ разделитель полей (,/newline/;/}) ⇒ embed (use alias Type/use _ Type). Иначе — обычное поле с именемuse(use SomeType— 1 идентификатор перед типом, как у любого другого поля). Единственный известный remaining edge-case: полеuseс generic- типом (use Vec[T]) синтаксически неотличимо отuse Vec [T]-подобного embed по этому 2-токенному lookahead — задокументировано как компромисс (Plan 239 §2), не заблокировано отдельным механизмом (тот же класс trade-off, что уbench/apply). - Protocol body leading items:
Ident("use")+Ident(имя типа) ⇒ embed.use((метод по имениuse, bare-ident effect-syntax) НЕ embed — проваливается в обычный method-parse путь. Раньше (hard keyword) метод с именемuseбыл НЕВОЗМОЖЕН даже bare-ident’ом в effect-теле — теперь разрешено.
Почему
Будучи hard keyword, use был единственным из этой группы контекстных
слов, полностью недоступным как идентификатор — при том что у bench/
measure/apply/null (D121, D278 §3) ровно такая многопозиционная
семантика уже жила как Ident с parser-side disambiguation. Асимметрия не
имела отдельного обоснования: все три существующие роли use синтаксически
однозначно определяются позицией + 1-2 токенами lookahead, без коллизий с
уже написанным кодом ( grep std/examples на \buse\b: 100% вхождений —
use TypeName-embeds, комментарии/строки; ни одного идентификатора use не
найдено — retraction расширяет допустимые программы, не сужает).
Что отвергнуто
- Оставить hard keyword — статус-кво до этого плана; отклонено по
просьбе владельца (симметрия с
bench). - Диагностика
E_RESERVED_WORDпри misuse (запасной вариант из брифа на случай «резерв без формы невозможен») — не понадобилась: уuse, в отличие от гипотетического «зарезервировать под будущее», уже есть три РЕАЛЬНЫЕ синтаксические формы; misuse просто не парсится какuse-форма и проваливается в generic diagnostic соответствующей позиции (тот же silent-fallthrough, что уbench). - Подсветка
useв редакторах наравне сbench(буквальная формулировка исходного брифа: «добавить use рядом с bench» в tmLanguage) — ПРОТИВОПОЛОЖНО верному направлению. D278 §3 нормативно требует ОБРАТНОГО: контекстные keyword’ы (apply/bench/measure/null, теперь+use) намеренно НЕ подсвечиваются — лексер держит их идентификаторами во избежание поломки пользовательских имён, и живой conformance-тест (compiler-codegen/tests/syntax_highlight_conformance.rs,vscode_grammar_has_no_phantom_keywordsи парные vim/zed) фейлится, если контекстное слово остаётся в keyword-паттерне хайлайтера.useснят изeditors/vscode/syntaxes/nova.tmLanguage.json,editors/vim/syntax/nova.vim,editors/zed/languages/nova/highlights.scmэтим планом — брифовая формулировка была основана на неверной посылке «useпока не зарезервирован» (на деле был hard keyword).
Связь
- D39 — record-field embed; семантика embed НЕ меняется, меняется только классификация токена.
- D145 §Protocol composition — protocol embed; аналогично, без изменений семантики.
- D121 — прецедент
техники (
bench/measurecontextual lexing). - D278 —
§3 governance-правило «контекстные keyword’ы не подсвечиваются»;
useдобавлен в перечень. - Q-embed-syntax —
вопрос выбора keyword’а (
usevsembed) остаётся ОТКРЫТ; эта retraction снимает один аргумент «за» пересмотр (useбольше не занимает identifier- пространство целиком), но не разрешает многопозиционную перегрузку семантики самого слова. - Plan 239 — план окна, полный список затронутых файлов.
D32. Семантика передачи параметров
Уточнено D488 (2026-10-01): размещение и передача значения — там единственный дом правила (место решает убегание, передачу
ro— размер, порог три слова;mut— указатель). Где этот блок расходится с D488, действует D488.
Status: revised для полей. D36 переписал семантику
mutна поле типа. Семантикаmutна параметре (этот D32) — без изменений.⚠️ AMENDED by Plan 118 (D216) —
&valueoperator 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 §1E_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);letretracted. Семантика 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/stack | by value (копия) | forbidden — E_PRIMITIVE_MUT_METHOD (Plan 128 Ф.3) |
| Tuples (positional или named) | type X(T1, T2), type Vec3(x f64, ...) | stack | by value (копия) | NovaTuple_<X>* pointer (Plan 128 Ф.2) — &v/hoist+&temp call-site |
| Value records | type X value { ... } | stack | by value (копия) | NovaValue_<X>* pointer (D228) — &v/hoist+&temp call-site |
| Records | type X { ... } | managed heap | by reference (указатель) | Nova_<X>* pointer (unchanged — already by-reference) |
| Sum types | type X enum A | B | stack (по значению, D477); рекурсия — indirect на варианте | by value (копия) | не нормировано здесь — см. D477 |
| Arrays | []T | managed heap (handle inline) | by reference | Nova_<X>* pointer |
str (Plan 139) | str | stack (16-байт value {ptr,len}; буфер на heap/rodata) | by value (копия) | nova_str value — handle-copy |
AMEND (2026-09-25, D32; форма — интегратор, текст — окно Карины): строка «Sum types» приведена к D477. Она стояла как
type X | A | B/ «managed heap» / «by reference» /Nova_<X>*— протухла дважды: синтаксис объявления суммы —type X enum A | B, а размещение — ПО ЗНАЧЕНИЮ, рекурсия разрывается маркеромindirectна варианте (D477, решение владельца 2026-09-13; подтверждено владельцем 2026-09-25). Следствие, ради которого правка понадобилась: сумма — value-тип, и==на ней идёт черезEqualпо заявке#impl(Equal)БЕЗ identity-фоллбэка — правило value-типов (08-runtime.md, «Value-record / NamedTuple …#impl(P)opt-in»), а не heap-record. Оракул сегодня пропускает==без заявки — реестр 221.1 №1352. Колонка mut-ABI для суммы здесь не нормирована: прежнееNova_<X>*описывало кучу.
strreclassified (Plan 139, 2026-06-11): ранееstrстоял в одной строке с[]Tкак «managed heap / by reference». Теперьstr— value type, несущий heap-backed буфер: само значение — 16-байт stack-valuetype 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 int | by value | нет (локальная копия) |
o Order (объект) | managed reference | нет (immutable view) |
mut o Order | managed reference | да |
Почему
- Согласовано с managed heap (05-memory.md → D6) — объекты уже в куче, передача указателя дешёвая, копировать бессмысленно.
- AI-first видимость в типах (01-philosophy.md → D10)
— сигнатура
fn deposit(mut acc Account, …)противfn show(acc Account)сразу показывает контракт. Java/C#: всё mutable references по умолчанию, программист помнит наизусть. mut— единый префикс для разных случаев (let, поле, параметр). Везде «mut = разрешена мутация» — одно понятие, не разные. Согласовано с D36 и 03-syntax.md → D33.
Что отвергнуто
- By-value для всех типов (Go-стиль). Копирование больших structs дорого, несовместимо с managed heap, программист удивляется «изменил поле — не сохранилось».
- By-reference с обязательным
&mut(Rust-стиль). Слишком много синтаксиса для прикладного кода; в Novamutуже работает для 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 → D62 —
Mut[T]как generic эффект удалён; мутация черезmutполя/параметры (локально) или специализированные state-эффекты (Counter/Cache/IdGen). - 01-philosophy.md → D10 — AI-first видимость мутации в типе.
- 03-syntax.md → D35 —
fn Type mut @methodиспользует тот жеmutдля self-binding’а.
Эволюция
В D32 поле типа mut field мутировалось только у mut-binding’а.
Для аккумуляторов (все поля mutable) приходилось писать mut 18 раз —
шум без пользы. D36 переписал это: дефолт mutable у mut-binding’а,
ro для never-mut, mut per-field — только для cache/lazy.
Семантика параметров не менялась.
D36. Поля типа: дефолт mutable у mut binding’а, ro для never-mut
Уточнено D35 (2026-10-02, №1636): вызов мутирующего метода на цепочке полей приёмника
@безmut— отказ, как наro-привязке; стена — поле*mut(D175 §V2).
Amended Plan 114 D184 (2026-05-31):
readonly→rokeyword rename в полях. Sample обновлён. Error codeE_READONLY_FIELDсохранён как stable API. Семантика per-field freeze не меняется.
Что
Поле без префикса мутируется, если binding mutable. ro
запрещает мутацию даже у mutable binding’а (для id, foreign keys,
invariants). Снято амендментом D175 «binding dominates» (Plan 124.8 Ф.3; см. таблицу там):
mut per-field разрешает мутацию даже у immutable
binding’а (для cache, lazy init, atomic counters — аналог C++
mutable).mut-поле через ro-привязку, параметр без mut и приёмник @ без mut НЕ мутируется —
E_READONLY_FIELD (для @ — D35 амендмент 2026-10-02, реестр №1635). Пример
LazyConfig ниже пишется с fn LazyConfig mut @get(). 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'ами — ro для 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 mut @get() -> str { // `mut @` — D175 binding dominates, D35 амендмент 2026-10-02
if Some(v) = @cached_value { return v }
ro v = read_file(@path)
@cached_value = Some(v) // мутация только через `mut @`
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 acc | Mutable у let mut acc | Use case |
|---|---|---|---|
field T (без префикса) | нет | да | большинство полей |
ro field T | никогда | никогда | id, immutable invariants |
mut field T | да | да | cache, lazy init, atomic counters |
Почему
- Меньше шума для типичного случая. Аккумулятор с 18 mutable
полями писать без префиксов — все поля «обычные», никаких
акцентов. Раньше 18 раз
mut— визуальный мусор. - Сигнатура показывает только важное. Префикс ставится только
на исключения (
roдля invariants,mutдля cache). LLM, читая тип, видит:ro id— «не трогай», обычное поле — «можно мутировать с mut-binding’ом». - Прецедент Rust/Go/C++ — поля без префикса мутируются у
mut-binding’а;
roдля never-mut близко к C++constmember.
Что отвергнуто
- Старая семантика 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).roпрямо говорит «только для чтения».letдля never-mut полей. Короче (3 символа), прецедент Swift, ноletуже значит «binding имени со значением» (03-syntax.md → D33). На поле без=необычно, не самообъясняемо.roпрямо говорит цель.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 → D33 —
letэто immutable binding; на поле — аналогия в ролиro. - 03-syntax.md → D35 —
fn Type mut @methodдаёт mutable-binding self, поля затем по правилам D36.
Эволюция
До D36 поле помечалось mut field T, мутируемое только у
mut-binding’а (D32). Для аккумуляторов это требовало 18 раз
повторить mut — шум без пользы. D36 инвертировал дефолт: «обычное
поле — мутируется у mut-binding’а», ro — для исключений.
Семантика параметров (D32) не менялась. Подробно — в
history/evolution.md.
Enforcement (Plan 108.2, 2026-05-30)
Плановое поведение D36 («let без 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_REBIND | ✓ | n/a (move) |
consume X = ... неявно подразумевает mut (как consume param в D176
amend Plan 108.1) — ownership transfer → владелец может мутировать.
Symmetry с D176 (Plan 108.1):
| Контекст | Default = ro? | 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).
Связь
- 02-types.md → D175 — ro field полный freeze.
- 02-types.md → D176 — ro T modifier + Plan 108.1 param default flip.
- 02-types.md → D216 §1/§V2.6 — указатель: pointee-mut в типе (postfix), reassignability = binding (Plan 138.5).
- 03-syntax.md → D33 —
letэто immutable binding.
D175. ro field — полный freeze (амендмент D36)
Уточнено D35 (2026-10-02, №1636): транзитивность и для приёмника
@безmut:@tags.items.push(x)— отказ; цепочка через поле*mutзаконна (§V2 «L2 wall-at-*»).
📌 Полная модель мутабельности — 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+mutadjacency: type-formro mut Tзапрещён на value-T (E_MUTABILITY_CONFLICT_VALUE_TYPE), binding-formro x mut Tallowed regardless of T storage class (Ф.6 relaxation).
Status: active (Plan 108, 2026-05-28); amended Plan 114 D184 (2026-05-31):
readonly→rokeyword rename. Error codeE_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 binding | cache, lazy init |
mut field ro T | ✅ всегда | ❌ никогда | swappable ro 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 (транзитивно)
Связь
D176. ro T — тип-модификатор
Уточнено D35 (2026-10-02, №1636): приёмник
@безmut— параметр: mut-метод на цепочке его полей —E_PARAM_NOT_MUT; через поле*mut— законно (D175 §V2).
Уточнено D488 (2026-10-01): размещение и передача значения — там единственный дом правила (место решает убегание, передачу
ro— размер, порог три слова;mut— указатель). Где этот блок расходится с D488, действует D488.
📌 Полная модель мутабельности — 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+mutadjacency: type-formro mut Tзапрещён на value-T (E_MUTABILITY_CONFLICT_VALUE_TYPE), binding-formro x mut Tallowed 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):
readonly→rokeyword rename; return-type defaults +@-inheritance section added. Error codesE_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] = x→E_READONLY_CONTENT T→ro Tcoercion разрешён автоматически (сужение прав)ro T→Tзапрещён: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 T — E_REDUNDANT_POINTER_RO (используй -> *T).
| Return type | Pointee (L3, из типа) | L1 binding результата |
|---|---|---|
-> *T (≡ -> *ro T) | read-only | bind-site (ro p/mut p) |
-> *mut T | writable | bind-site (ro p/mut p) |
-> *uninit T | possibly-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:
| Receiver | Return -> @ | Пример |
|---|---|---|
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_AT | consume 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 chainsxs.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,-> consume T,-> *mut T,-> *ro T) — берётся как написан (для указателей модификатор всегда относится к pointee — postfix; prefix перед*=E_POINTER_PREFIX_MODIFIER, D216 §1).-> consume T— префиксная форма, симметричная-> ro T/-> mut T
⚠️ ПЕРЕКРЫТО D445 §616 (пометка 2026-08-17 по аудиту самосогласованности). Форма
-> consume Tв позиции ВОЗВРАТА снята ЦЕЛИКОМ — «не переопределена, а удалена»:E_RETURN_CONSUME_PREFIX_RETRACTEDдля префиксной иE_RETURN_CONSUME_POSTFIX_RETRACTEDдля постфиксной (№301). Пять носителей вstd/src/runtime/sync.nvпереписаны на голый-> MutexGuardтем же слиянием. Класс держится стражемcheck-retracted-param-form(храповик осадка по зонам). Текст выше сохранён как история. (D445, №301/221.1, окно p-lang, 2026-08-04); старая постфиксная-> T consume(Plan 103.9) РЕТРАКТИРОВАНА.
-> Self(статический Self-тип, D182) — owned-by-caller; не наследует receiver-мут.-> @без receiver-method context (free fn) →E_AT_RETURN_OUTSIDE_METHOD.
Escape hatch
Снять ro в Nova-коде нельзя. Кому нужен mutable доступ —
явно копирует: let copy []u8 = view.to_owned(). Если необходим
обход через FFI, это делается в external fn на C-стороне.
Рантайм
Zero overhead — ro только compile-time проверка, не влияет
на codegen. ABI ro []u8 = NovaArray_uint8_t* (идентично []u8).
Применение
str.as_bytes() -> ro []u8 — zero-copy view в UTF-8 буфер строки
без memcpy. UTF-8 invariant защищён: записать в буфер нельзя.
Параметры функций (Plan 108.1)
Default = read-only. Параметр без явного модификатора эквивалентен
ro 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 T | ro (default) |
mut param T | mutable view |
ro param T | ro (явно) — synonym default |
consume param T | owned 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 ro param T | ✗ parser-level E_PARAM_MOD_CONFLICT |
ro mut param T | ✗ parser-level E_PARAM_MOD_CONFLICT |
Coercion (передача аргумента в параметр).
После Plan 108.1 T в позиции параметра уже ro по умолчанию.
Поэтому ro T → T (param) — это ro → ro (тождество),
а единственное реальное нарушение это ro → mut:
| caller-type → callee-param-type | OK? |
|---|---|
T → T (param default ro) | ✓ (caller-T → callee-ro = сужение) |
T → ro T (param explicit ro) | ✓ (synonym default) |
T → mut T (param explicit mut) | ✓ (caller разрешает mut доступ) |
ro T → T (param default ro) | ✓ — оба ro |
ro T → ro T | ✓ |
ro T → mut T (param explicit mut) | ✗ E_READONLY_COERCE — единственное нарушение |
mut T → T (param default ro) | ✓ (сужение, mutable можно показать как ro) |
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 дефакто: старая формулировка маркера предполагала, что paramTmutable; после Plan 108.1 paramTуже ro, поэтому coercero T → T (param)— этоro → ro(no violation). Единственный остаточный case —ro T → mut T (param explicit)— отдельный followup[M-108.1-readonly-to-explicit-mut-coerce](узкий нишевый сценарий, не блокирует).
Связь
- D36 —
ro fieldпредшественник - D175 — ro field enforcement
- D144 — слайсы
arr[a..b] - D157 — view-borrow для consume-типов (Plan 108.1 распространяет принцип на не-consume)
- Plan 108 — реализация D175/D176
- Plan 108.1 — params ro by default + закрытие 2 markers
D445. Модификатор ВСЕГДА перед тем, что описывает (ro/mut/consume) {#d445}
Статус: ✅ IMPLEMENTED. Решение владельца, 2026-08-03 (реестр 221.1 №301); реализовано окном p-lang, 2026-08-04.
AMEND (2026-08-12, owner decision, реестр 221.1 №616/№611/№615, окно p616-mode-modifiers). Три формы, у которых была ЗАПИСЬ, но не было ПРАВИЛА за ней, — сняты без периода deprecated:
-> consume T(позиция возврата) снят ЦЕЛИКОМ — не переопределён, а ОТМЕНЁН. Пробой (docs/plans/wip/repro-616-consume-return.nv.txt) доказано: модификатор не добавляет семантики. Тип, объявленныйconsume, держит линейность САМ ПО СЕБЕ —ro g = make_guard()дляfn make_guard() -> Guard(без-> consumeна функции) уже даётE_CONSUME_KEYWORD_MISSING(D180). А тип, НЕ объявленныйconsume, возвращённый как-> consume Box, компилируется и пропускаетro b = make_box()БЕЗ потребления — модификатор в этом случае ОБЕЩАЕТ линейность, которой у типа нет. Ни в одном из двух случаев модификатор не делает ничего полезного, во втором — вредит (ложное обещание). Обе строки таблицы ниже (-> consume Tи пример-код) — удалены; return-позиция дляconsumeбольше не существует НИ В КАКОЙ форме —E_RETURN_CONSUME_PREFIX_RETRACTED(новая, эта форма) рядом с уже существовавшейE_RETURN_CONSUME_POSTFIX_RETRACTED(постфикс, №301). Пять носителей вstd/src/runtime/sync.nv(Mutex@lock,RwLock@read/@write,Once@make_guard,Semaphore@acquire) — переписаны на голый-> MutexGuardи родню в том же слиянии.- Постфикс
имя mut Типв параметре — исключение отменяется по имени. Амендмент 2026-07-17 к D374 (AMEND ×3, канон mut-параметров) объявил голую постфиксную форму ЗАПРЕЩЁННОЙ лint’ом (W_PARAM_TYPE_POS_MUT), но оставил ЖИВОЕ исключение «легитимна ИСКЛЮЧИТЕЛЬНО за view-слайсами ([]u8) и fixed-size массивами ([N]u8)» — именно то исключение, которое этот D-блок отменял ПО СМЫСЛУ («без исключений и без постфиксной формы где бы то ни было»), но не назвал по имени, из-за чего исключение и дожило почти на девять дней. Теперь названо и закрыто: исключение для view-слайсов/fixed-массивов снято,W_PARAM_TYPE_POS_MUT(лайт, лишь предупреждал) заменён на жёсткую ошибкуE_PARAM_TYPE_POS_MUT_RETRACTED. Канон параметра — РОВНО три формы:buf Type/mut buf Type/consume buf Type.- R2-split
ro имя mut Типв параметре — санкционированность снята. Тот же амендмент 2026-07-17 называлro name mut Type«санкционированным, не каноническим, исключением», которое «самодокументирует „пишу в содержимое, не подменяю биндинг“» (ссылки на D246 P6, Plan 118.5 V3 amend). Проба показала: запись через такой параметр даётE_READONLY_CONTENT— форма, придуманная для записи в содержимое, саму запись и запрещает. Санкционированность снята вместе с постфиксом из пункта 2 (та же синтаксическая форма, просто с явнымroспереди);spec_tests/conformance/d246_param_ro_mut_view.nv(позитивный дискриминатор, доказывавший легальность формы) удалён — его роль теперь играет негативная фикстураspec_tests/conformance/neg/param_type_pos_mut_r2_split_retracted_neg.nv. Не путать с R2-split ЛОКАЛЬНОГО биндинга (ro r mut Point— ЛОКАЛЬНАЯ переменная, не параметр, D246 §«R1 vs R2»,ro a mut Tв таблице мутабельности) — тот механизм НЕ затронут, живёт своей жизнью на другой оси (L1×L2 локального биндинга, а не позиция параметра).Новый D-блок не заводится — амендмент правит существующий D445.
Что
Единое правило размещения для всех трёх модификаторов режима (ro/mut/
consume) во ВСЕХ позициях грамматики: модификатор всегда стоит перед
тем, что он описывает, без исключений и без постфиксной формы где бы то
ни было.
| Позиция | Форма | Модификатор описывает |
|---|---|---|
| Имя привязки | ro v / mut v / consume v | саму привязку |
| Ресивер | fn T @m() / fn T mut @m() / fn T consume @m() | ресивер @ |
| Тип возвращаемого значения | -> ro T / -> mut T | возвращаемое значение (consume в этой позиции СНЯТ ЦЕЛИКОМ — AMEND 2026-08-12 выше, №616) |
| Тело типа | type X { … } / type X value { … } / type X consume { … } / type X enum A | B | сам тип (тот же слот, что value/enum) |
// Возврат: ro/mut — по-прежнему ПЕРЕД типом. `consume` в этой позиции
// СНЯТ (AMEND 2026-08-12, №616) — линейность несёт объявление типа
// (`type MutexGuard consume { … }`, D180), не return-аннотация.
fn Mutex @lock() -> MutexGuard
fn RwLock @read() -> ReadGuard
fn RwLock @write() -> WriteGuard
Зачем
До 2026-08-03 была асимметрия. ro/mut в позиции возврата писались
ПЕРЕД типом (-> ro Self, -> ro []u8 — так уже было в std), а consume
(Plan 103.9) — ПОСЛЕ (-> MutexGuard consume, 5 мест в
std/src/runtime/sync.nv: Mutex@lock, RwLock@read/@write,
Once@make_guard, Semaphore@acquire). В позиции возврата все три модификатора
отвечают на один и тот же вопрос («что можно делать с полученным
значением»), но стояли по разные стороны типа — единственный постфикс во
всей грамматике модификаторов.
Постфикс ретрактирован, не переопределён. Ранняя формулировка этого
правила («единственный постфикс — на объявлении типа», зафиксированная в
черновике решения владельца 2026-08-03) была ошибочной и в этот
D-блок не входит: type X consume { … } — это НЕ постфикс на имени типа
X, а модификатор ПЕРЕД телом { … } — тем же слотом, который занимают
value (type X value { … }) и enum (type X enum A | B). Модификатор
здесь тоже стоит перед тем, что описывает (тело типа), просто «то, что
описывает» идёт не сразу за именем типа. Постфиксной позиции для
модификатора режима не существует нигде в грамматике.
Как
Текущее состояние (после AMEND 2026-08-12, №616). Парсер: return-позиция
(-> …) видит consume СРАЗУ после -> — E_RETURN_CONSUME_PREFIX_RETRACTED
(hard error, fix-it: убрать consume, -> T). consume СРАЗУ после типа
возврата (старый постфикс, №301) — E_RETURN_CONSUME_POSTFIX_RETRACTED, тот
же fix-it. Обе формы — parser/mod.rs, ветка после ->, ДО и ПОСЛЕ
parse_type() соответственно. Параметр: постфикс имя mut Тип (голый И
ro имя mut Тип) — E_PARAM_TYPE_POS_MUT_RETRACTED (parser/mod.rs
parse_param, ветка после parse_ident), без исключений по типу (view-слайсы
и fixed-массивы включены). Лint W_PARAM_TYPE_POS_MUT (lints.rs) снят вместе
с формой, которую он предупреждал, а не запрещал.
Связь
- D176 —
-> ro T/-> mut Tв позиции возврата (симметрия сconsume, пока последний не сняли целиком, №616) - D133 —
consume-семантика типа/значения (несёт линейность и БЕЗ модификатора в return-позиции, №616) - D156 —
[T consume]generic bound (комбинация с протокольной цепочкой — см. правку раздела «Синтаксис bound» выше, №300/221.1) - 10-overloading.md D84 — режим
{ro,mut,consume}как ось перегрузки (диспатч по форме привязки уточнён тем же окном — №309/221.1) - D374 AMEND ×3 (2026-07-17) — канон mut-параметров, чей carve-out для view-слайсов/fixed-массивов и R2-split этот амендмент отменяет (№611/№615)
- D246 — три оси мутабельности; R2-split ЛОКАЛЬНОГО биндинга (не параметра) этим амендментом не затронут
D66. Self universal — ссылка на обобщающий тип в методах, effects, protocols
Что
Self — keyword-ссылка на «тот тип, к которому принадлежит метод»,
валиден в любом контексте, ассоциированном с конкретным типом:
- Внутри
protocol { ... }—Self= тип, удовлетворяющий контракту (как сейчас по D42 (REVISED)/D53). - Внутри
effect { ... }—Self= тип эффекта (Db,Net, …). - В static-методе
fn T.name(...)—Self≡T. - В instance-методе
fn T @method(...)/fn T mut @method(...)—Self≡T. - Для generic-типа
T[A, B]—Self≡T[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
@equal(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)Self≡T. - Для generic
T[A, B]Self≡T[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, СТАТИЧЕСКИЙ — точка, не `@` (D209)
}
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получаетclsruntime — мы делаем то же на compile-time черезSelf.
Где запрещено
- На top-level (вне типа/protocol/effect) — compile error «Self не в type-контексте».
- Внутри лямбды, объявленной не в method-теле — compile error.
- В сигнатуре свободной (top-level) функции
fn name(...)— compile error.
Почему
- DRY. До D66 в каждом методе
fn Box[T].of(v T) -> Box[T]имя типа повторялось 2-3 раза. Refactoring (Box→Container) ломал копипастой.Selfустраняет повтор. - Generic-параметры наследуются автоматически.
fn Box[T].ofсSelfкорректно подставитBox[T], неBoxбез параметров — программисту не нужно указывать generics в методе. - AI-friendly. LLM генерирует
Selfдля return type без знания точного имени — снижает количество ошибок при автогенерации builder-методов. - Унификация. До D66
Selfработало только в protocol — это создавало впечатление, что для других контекстов нужен другой механизм. На самом деле семантика одинаковая — «текущий тип». Один keyword для всех контекстов = D40 «один способ». - Прецеденты. Swift, Rust используют
Selfуниверсально (везде где естьimpl T { ... }блок). Nova следует тому же паттерну.
Что отвергнуто
@type— конструкция вида@typeдля ссылки на свой тип в методе. Отвергнуто:@уже занят под self-field, добавление второго смысла создаёт двусмысленность.- Имя типа повторять везде. Отвергнуто: см. п.1 «DRY».
Selfтолько в generic-методах (как в Java<T extends Self>). Отвергнуто: семантика остаётся та же, ограничение лишнее.
Связь
- D42 (REVISED) / D53 —
Selfв protocol’ах (исходное правило, расширено D66). - 03-syntax.md → D35 —
@-методы и@field. - 04-effects.md → D61 — effect-типы и handler’ы.
Эволюция
В 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
биндить Self → value_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 SignedInts]). Тот же Hash стоит и в
позиции типа значения (fn f(x Hash) — existential), и в позиции
bound’а (fn f[T Hash](x T) — universal). Одна сущность —
тип со структурным контрактом — в трёх позициях:
- Тип значения:
fn f(x Hash) -> u64 - Bound:
fn f[T Hash](x T) -> u64 - Эффект (между
)и->):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 не нужно держать «отложенный контекст»).
⚠️ D72 §«Порядок объявления параметров» — ПРАВИЛО СНЯТО (решение владельца 2026-08-16; реестр №702). Имена одного generic-списка видны друг другу ЦЕЛИКОМ:
fn f[T From[K], K](v K) -> T— законно, как иfn f[K, T From[K]]. Прецедент — rustc (fn f<T: From<K>, K>законно). Основание снятия — три факта, а не вкус: (1) правило никогда не действовало — чекер порядок не проверял (check_generic_bound_declarations,types/mod.rs:26054, валидирует только имена бáундов); (2) std САМ пишет forward-order и компилируется (std/src/testing/property.nv:224,304—[G Generator[T], T]); (3) D145 §«Bound syntax (через D72)» даёт эту форму как валидный пример. Собственное обоснование правила — «ради простоты type-checker’а» — истекло: чекер справляется без него. Снятие не меняет ни одной компилирующейся программы; реализация правила сломала бы std и D145. Что НЕ затронуто: D88 (forward-ref в ДЕФОЛТАХ[T = X]) остаётся запретом — там порядок нужен для вычисления значения, здесь — нет. Пример-ошибка выше сохранён как ИСТОРИЯ и более не нормативен.
Bound — это protocol-тип
Hash, From[T], Into[T] и т.д. — обычные protocol-типы (D53):
type Hash protocol {
@hash() -> u64
@equal(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: effect ≠ protocol для
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.
Почему
-
Закрывает Q-bounds. Generic-инфраструктура (HashMap, From/Into, collect, FromIter) требует bound’ов. Без них либо безопасности нет, либо ошибки откладываются до места использования с непонятным сообщением.
-
Согласовано с правилом «name type». Параметр функции
x int, полеid u64, generic-параметрT Hash— единая грамматика. Двоеточие в Nova зарезервировано под key-value, использовать его для bound — нарушение D17. -
Protocol = тип (D53).
Hashуже тип в Nova. Использовать его как bound — естественное расширение, не новый механизм. Existential (x Hash) и universal ([T Hash]) различаются позицией. -
Прецедент Go. Go 1.18+:
interface { M() }используется и как тип значения, и как constraint в generics. Один синтаксис, два контекста, проверено в большом продакшне. -
Структурная проверка вместо impl. Nova не имеет orphan rule (D42/D53) — нет
impl Trait for Typeблоков. Bound удовлетворяется автоматически, как и existential. Это последовательно. -
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](Swiftsome-style). Нестандартно,implне используется в Nova ни для чего ещё.- Bounds через контракты (
requires implements(T, Hash)). Контракты (D24) проверяются SMT на значениях, bound — type-checker’ом на типах. Разные уровни. - Sealed/closed bound’ы («только эти типы»). Открытый вопрос, не входит в D72.
Цена
- Type-checker сложнее. Проверка structural-bound при мономорфизации — дополнительная работа.
- Сообщения об ошибках. «
Userне реализуетHash: missing method@hash» — нужно генерировать понятные диагностики. - Множественные bounds через анонимный protocol — многословно
для частых пар (
Hash + Eq). Сокращённая форма откладывается.
Связь
- 02-types.md → D53 — protocol = тип, основа D72.
- 02-types.md → D42 — структурная типизация, две модели generic-параметров.
- 02-types.md → D66
—
Selfв protocol-контексте. - 03-syntax.md → D16
—
[T]синтаксис для generic’ов. - 04-effects.md → D18 — protocol в effect-position, отличается от bound-position.
- 08-runtime.md → D73 —
From[T]/Into[T]используют bound[U From[T]]для generic-функций конверсии. - Q-bounds — closed by D72.
- Q-collect-mechanism — становится решаемой после D72.
Открытые вопросы
Множественные bounds— ЗАКРЫТО планом 101.3 (ред. 5, 2026-05-25): форма композиции —+,[T Hash + Equal]. Реализовано и проверено пробой 2026-08-03. Кандидаты&и(Hash, Eq)из прежней редакции ОТКЛОНЕНЫ; запись оставалась в «открытых вопросах» по недосмотру (№299).- 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 requiringmut @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(mut xs []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:
-
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 — паритет Rustimpl<T: Hash>. -
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 стратегия.
-
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. Три изменения:
- Blanket pre-decl retired. Раньше каждый C-файл получал
typedef … _NovaTuple1; … _NovaTuple8;(8 all-nova_intструктур) в преамбуле — вне зависимости от использования. Теперь legacy_NovaTupleNэмитится on demand только для арностей, которые erased-generic fallback реально запрашивает (на практике — только arity 2, от erasedHashMap[K,V]/Set(K, V)пар). Concrete tuples всегда используют typed mono’d путь. Регистрируется черезregister_legacy_tuple(n), splice в/*__LEGACY_TUPLE_TYPEDEFS__*/.- Self-describing field decode. Field access (
t.0,t.0.1) и type inference больше не зависят исключительно от per-Ident side-tabletuple_element_types— элемент-тип декодируется напрямую из имени mono’d структуры вobj_tyчерезparse_mono_tuple_elements. Это чинит field-read на fn-параметрах, call-result кортежах и вложенныхt.0.1цепочках (раньше collapse’или вnova_intfallback → дроп второго.0/ неверный тип). Закрыло 5 pre-existing CC-FAIL в Plan 59 (f2/f10/f13/f15/f16).- 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:
- All elements concrete (resolved via current_type_subst,
no type-param placeholders) → use mono’d
_NovaTuple_<arity>_<L1>_<T1>...struct. Zero erasure cost. - 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 (erasedHashMap/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 destructureSome((k, v))/Ok((k, v))propagate mono’d element types через registry (Phase 6 + Plan 63 Fix F+). - Tuple in collections (
HashMap[K, V]returnsOption[(K, V)]fromiter().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.
Field literal style (related, D52 §2)
Record literal для tuple struct полей ({ end, idx: 0 } для
{end int, idx int} где end — variable в scope) — shorthand
обязателен при совпадении имени поля с источником ({ end: end }
запрещено, см. D52 §2).
Почему
- Correctness — struct value types (nova_str, user records)
не fit’ят в nova_int slot. Без mono
(str, int)was broken. - Zero-cost — direct field access, no intptr_t cast, no heap alloc для tuple value.
- Параллель Rust/C++ — индустриальный standard для tuples.
- Diagnostics quality — Plan 36 R7 bar (file:line + hint).
- 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 (✅ 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.(x: T1, y: T2)) — ОТКЛОНЕНО окончательно (Plan 59 Ф.7.4, 2026-05-21). Именованные поля кортежа почти идентичны record’у; заводить два почти одинаковых синтаксиса для одной семантики в Nova нет причин. Нужен агрегат с именованными полями — это record (type T { x int, y int }). Tuple остаётся позиционным (.0/.1).- 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 (✅ РЕАЛИЗОВАНО (Plan 59 Ф.7.5 increment 2, 2026-05-21): Result полностью мономорфизирован — per-(T,E) C-типNovaRes_<T>_<E>typedefs analogous Option) — Plan 63 Fix F+ targeted boxed-pointer tracking покрывает все observable cases без full sum-type mono refactor. Defer до Plan 65.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
Уточнено D488 (2026-10-01): размещение и передача значения — там единственный дом правила (место решает убегание, передачу
ro— размер, порог три слова;mut— указатель). Где этот блок расходится с D488, действует D488.
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 tuple | stack | value (копия при передаче) |
type X(name1 T1, name2 T2) | named tuple | stack | value (копия при передаче) |
type X { name T } | record | heap (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 — ПОЗИЦИОННО (амендмент 2026-08-05; прежние примеры показывали
// именованные аргументы для полей БЕЗ дефолтов, что противоречит D102).
// ЦИТАТА ПОЧИНЕНА 2026-09-04: здесь стояло «см. ниже "Конструирование и
// деструктуризация"» — раздела с таким именем в spec/ НЕТ НИ ОДНОГО (проверено
// грепом окном 274), то есть правило два месяца опиралось на несуществующий
// адрес. Настоящие адреса: конструирование — D102 (03-syntax.md, редакция
// 2026-09-04: обязательный позиционно, дефолтный по имени); разбор на части —
// D411 (03-syntax.md): фигурная форма для именованных полей, круглая для
// позиционных. Заодно исчезла и причина спора: на день амендмента D102 говорил
// обратное тому, на что амендмент ссылался, и был приведён в соответствие
// решением владельца 2026-09-04.
ro v = Vec3(1.0, 2.0, 3.0)
ro c = Color(255, 0, 128, 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 + other.x, @y + other.y, @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 tuple | E_TUPLE_POSITIONAL_ACCESS_ON_NAMED |
.name на positional tuple type | E_TUPLE_NAMED_ACCESS_ON_POSITIONAL |
| mixed named+positional в declaration | E_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 mode | Parameter type | Call-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_vardirectly. Var must bemutbinding (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-levela.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.
Use cases (recommended patterns)
| Паттерн | Тип | Почему |
|---|---|---|
| Hot-path math (Vec3, Matrix, Quaternion) | named tuple | zero GC, predictable |
| Pixel formats (Color, Pixel) | named tuple | small, copy-cheap |
| FFI multi-value returns | named tuple | stack return, fit в registers |
| Iterator state | named tuple | local-lifetime, no heap |
| Domain entities (User, Order, Account) | record | identity, sharing |
| Large aggregates | record | copy 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
- Call-site
f[T1, T2, ...](args)lookupsmono_fn_decls[f.name]. resolve_mono_type_argsстроит type_subst из turbofish + arg-inference.compute_mono_name(base, subst)→ unique mono fn name.register_mono_instanceenqueue в worklist.- Args emit без erasure boxing (concrete types).
- 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 ИЛИ packedNovaArray_<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/sumNova_<Name>*) этот класс РЕШЁН выбранным здесь подходом «align infer с body fallback» + завершением side-channelarray_element_types. Контракт хранения composite-массивов: элементы record/sum — boxed-pointer вnova_intслоте (NovaArray_nova_int*), а реальный elem C-тип проносится черезarray_element_types(var→Nova_<X>*) и проставляется на результат genericmap/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 declarationfn[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_intWrapper____nova_int_method_map____nova_strWrapper____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):
- Receiver T — резолвится из obj C-type (
Nova_Wrapper____<T>*→ T =<T>). Существующая infrastructure (D72 + Plan 48 Ф.0). - 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.
- Non-closure args:
- 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.rspath 5b.) Раньше unresolved method-level params silently dropped →Nova_U_pplaceholder leak в emitted C → undefined-struct CC-FAIL. -
Per-(T, U) instances — каждая уникальная пара получает свою mono’d function. Worklist enrollment предотвращает дубликаты.
-
Return type substitution —
Wrapper[U]в return type корректно resolves вNova_Wrapper____<U>*(неNova_U_pplaceholder).
Почему
- Параллель Rust/C++ — индустриальный standard для generic methods.
- Zero-cost — каждая mono-instance это direct call, инлайнится, no void* boxing/cast.
- Composability —
w.map(f).map(g).filter(p)typical functional chain работает без erasure penalty. - Был 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- параметры/поля типа
byte→u8 - в примитивном перечислении:
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 на четыре категории:
| Cat | Pattern | Семантика | Действие |
|---|---|---|---|
| A1 | type_ref_to_c(...).unwrap_or_else(|_| "nova_int") | Silent fallback при resolution failure | Strict error |
| A2 | _ => "nova_int" wildcard без комментария | Wildcard fallback unknown type | Strict error или Cat D classification |
| B | _ => "nova_int", // erased T (commented) | Pre-mono generic body emit — type-param ещё unresolved | Documented intentional erasure |
| C | WithResultCategory::IntLike => "nova_int" | Categorical mapping для int-family aliases | Legit, keep |
| D | Dispatch wildcard на известный receiver | Known 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/dev/codegen-erasure-sites.md.
Strict-error architecture. Две helper-функции в emit_c.rs:
-
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) )?; -
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/guards/lint-no-silent-int-fallback.sh
greps compiler-codegen/src/ против baseline counts из
docs/dev/codegen-erasure-sites.md. Bumping baseline требует:
- Inline comment с rationale «почему erasure безопасна»
- Entry в
docs/dev/codegen-erasure-sites.mdсо file:line + причина - 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/guards/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/dev/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_t → uint32_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).
- Typedef:
typedef int64_t nova_char;→typedef uint32_t nova_char;(D128 AMEND Plan 152.8) вcompiler-codegen/nova_rt/nova_rt.h. - Codegen mapping:
type_ref_to_c "char" => "nova_char"(was"nova_int") вemit_c.rsиexternal_registry.rs(двойная sync). - Array element:
[]char → NovaArray_nova_char*(separate instantiation parallelNovaArray_nova_int*). - Option element:
NovaOpt_nova_chartypedef + constructors +nova_opt_eq_nova_charhelper. - CharLit emission:
'x' → ((nova_char)<codepoint>U)(wasLL; D128 AMEND Plan 152.8 — uint32_t requires U-suffix). - infer_expr_c_type:
CharLit => "nova_char"(was"nova_int"). - Runtime fn signatures:
nova_str_char_atupdated returnNovaOpt_nova_char(wasNovaOpt_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_charconstructors + 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)
- D54 —
as-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.
int→nova_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) */.i64→int64_t— ВСЕГДА ровно 64 бита, независимо от платформы.- На bootstrap-таргете (x86_64, 64-битный указатель)
intиi64СОВПАДАЮТ по ширине и значению — отсюда историческое слово «alias» в заголовке/теле ниже — НО это РАЗНЫЕ C-типы:primitive_name_to_cдаётint → nova_intиi64 → int64_t, поэтому их mangle-имена РАЗЛИЧАЮТСЯ (NovaOpt_nova_int≠NovaOpt_int64_t;Map[int,V]≠Map[i64,V]по C-имени).- «int ≡ i64» — совпадение ШИРИНЫ на 64-бит, НЕ тождество типов. Не считать
intравнымi64/int64: аналогия — Goint≠int64, Rustisize≠i64(platform-pointer-width). На 32-bit/WASMintстанет 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-типы РАЗНЫЕ: int→nova_int=intptr_t, i64→int64_t). Оба маппируются в C-тип
[УСТАРЕЛО Plan 133]. Отсутствие distinction в codegen
nova_int (typedef int64_t)намеренно относится к 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), int ≡ i64 является семантическим инвариантом — оба
означают 64-bit signed integer без разницы в значении или поведении.
Nova bootstrap targets x86_64 only (fixed 64-bit pointer width).
Industry baseline.
- Rust:
isizedistinct отi64(platform-pointer width varies на 32-bit) - Go:
intdistinct отint64(platform-pointer width) - C#:
int= aliasSystem.Int32(semantically identical) - Python/Java: нет fixed-width integer aliases
- Nova:
int= aliasi64— правильная аналогия 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.
Связь:
- D54 —
as-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-тип uint — nova_uint (typedef uintptr_t, address-sized
unsigned), а не сырой uint64_t. На x86_64-bootstrap (фикс. 64-бит указатель) nova_uint
≡ uint64_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
→ @-операции беззнаковые ПО ПОСТРОЕНИЮ. int≡i64→nova_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 (существующее поведение сохранено).
АМЕНДМЕНТ 2026-09-04 (решение владельца; исследование
docs/dev/research/2026-09-04-numeric-widening.md§5, реестр №928, пробаdocs/plans/repro/928-as-int-uint-saturation/). Две правки к этому блоку.1.
uint— РАЗНЫЙ тип сu64, какintсi64(D129, AMEND Plan 133). Слово «alias» в заголовке и в Q1 — историческое, из эпохи до Plan 133:uint→nova_uint(uintptr_t, address-sized),u64→uint64_t(всегда 64 бита); на bootstrap они совпадают по ширине, но это РАЗНЫЕ типы с разными mangle-именами (NovaOpt_nova_uint≠NovaOpt_uint64_t), как Gouint≠uint64, Rustusize≠u64. Следствие для D405: «одинаковый конкретный числовой тип» значит одинаковый ПО ИМЕНИ —int + i64,uint + u64,ro e u64 = cприc uint— ошибки, явныйas. Сегодня чекер сравнивает ширины и пропускает всё это (строка реестра «чекер считает int ≡ i64»); это дефект по букве D129.2. Q2 СНЯТ:
int as uint— bit-pattern, как всякийiN → uM(таблица D54). Q2 вводил насыщение для одной пары (-1 as uint → 0), тогда как-1 as u64,(-5 as i32) as uintи все прочие знаковые→беззнаковые — bit-pattern: одна операция, три ответа (№928). Довод Plan 70.5 Q2 («Rust bit-cast hostile») был общим, норма — частной; владелец выбрал единую таблицу D54:as— «я явно попросил переинтерпретацию», для отсечения есть имена (@to_uint()D430,max(0, x) as uint), для битов имени, кромеas, нет; доноры Rust/Go/Java. Helpernova_int_to_uintи acceptance-строка «int as uintsaturates» — ретрагированы; насыщение остаётся только у float → int (донор Rust 1.45). Страницыconversions.*(«между целочисленными ширинами всегда wraparound») были правы.
Дизайн (Q1-Q4, подтверждены 2026-05-19).
| Вопрос | Решение | Обоснование |
|---|---|---|
| Q1: alias или distinct? | Alias u64 (= uint64_t) | Mirror int = i64 alias pattern; нет multi-arch story в bootstrap |
| Q2: int→uint cast | as uint saturates (neg → 0) | |
| Q3: Indexing | Keep int (no change) | Breaking change для 100+ APIs; Swift/Go/Kotlin используют signed indexing |
| Q4: Literal default | int (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компилируется -
СНЯТО 2026-09-04 (амендмент выше)int as uintsaturates (neg → 0) —nova_int_to_uinthelper -
int as u64остаётся bit-cast (no saturation) -
[]uint→NovaArray_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.
Связь:
- D54 —
as-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). СМ. ТАКЖЕ D432 и его амендмент rev-2 (2026-09-16): у типа с эффект-free
@cleanupлинейность смягчается до аффинной НЕ ТОЛЬКО для формыconsume X = e, но и дляconsume-ПАРАМЕТРА. Extends D131 affineconsumequalifier.
Что
Квалификатор 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илиMaybeConsumed→ compile 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/match—Live ⊔ Consumed = MaybeConsumed.
defer / errdefer могут покрывать обязательство (см. D158+ Plan
100.4 family).
Что считается consume
| Действие | Эффект на VarState |
|---|---|
tx.commit() — вызов consume-метода | tx → Consumed |
f(tx) где f(consume tx Tx) — consume-param | tx → Consumed |
f(make_tx()) где f(consume t Tx) — rvalue → consume-param | rvalue ownership передаётся напрямую (без binding) ✅ |
return tx (тип consume) | tx → Returned (передача caller’у) |
record.field = tx где field declared consume | tx → Moved (в record) |
T { field: tx, … } — init consume-поля record-литерала голым binding’ом | tx → Consumed (move при конструировании) |
p.write_consume(tx) / p.write_consume_at(i, tx) — запись во владение памяти (D216, амендмент 2026-09-25) | tx → Consumed (владение в памяти); p.read_consume() / p.read_consume_at(i) — изъятие, результат владеющий |
(a, b) в позиции возврата — кортеж из consume-биндингов | каждый элемент → Returned (передача caller’у, как return tx) |
consume new_owner = tx (transfer alias) | tx → Consumed, new_owner → Live |
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-param | tx остаётся 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 |
Амендмент 2026-09-14 (реестр 221.1 №1092, решение владельца): строка «
(a, b)в позиции возврата» — НОРМАТИВНОЕ дополнение перечня. До неё перечень покрывалreturn txи конструирование record-литерала (T { field: tx, … }), но НЕ кортеж, и потомуfn f(consume a T, consume b T) -> (T, T) => (a, b)отвергалась как «не consumed до scope-exit» — при том что тело функции ЕСТЬ весь её результат.Почему это дополнение, а не смена решения. Кортеж — такое же конструирование, как record-литерал, только без имён полей; строка про record-литерал в перечне стоит с 2026-07-13 и называет это «move при конструировании». Перечень был неполон по недосмотру, а не по решению: ни одного места, где кортеж в позиции возврата объявлялся бы НЕ потреблением, в спеке нет.
Цена умолчания измерена. Обход стоял в
std/src/net/tcp.nv:113(split_pair) с 2026-07: параметры оставлены view-формой, а результат помеченro, и собственный комментарий признавал компромисс словами «rowould be the wrong canon here» с меткой[INV-PROPERTY]. Второй носитель той же формы —std/src/net/udp.nv:40(recv_bytes_pair). Обе функции отдают владение и обе были вынуждены лгать о нём в подписи.Санкция: владелец 2026-09-14, прочитав подписи
split_pairи@into_split()рядом, утвердил формуfn split_pair(consume rh TcpReadHalf, consume wh TcpWriteHalf) -> (TcpReadHalf, TcpWriteHalf). Это правило — то, без чего утверждённая форма не компилируется.
Амендмент 2026-07-13 (fix
[M-178-consume-field-ctor-from-var]): строка «init consume-поля record-литерала» — НОРМАТИВНОЕ уточнение, закрывающее языковой обход. Инициализация consume-поля record-литерала голой owned-переменной или consume-параметром ({ tcp: stream, session }) — потребление этого биндинга, ровно как передача в consume-параметр вызова. Действует для всех форм литерала: типизированного (T { … }/ record-вариант суммыV { … }), анонимного с типом из контекста (=> { tcp: stream, session }— best-effort структурный резолв; неоднозначность → консервативно без move, остаётся D133-not-consumed) и D52-punning ({ tx }). Использование биндинга ПОСЛЕ литерала — use-after-consume (D131); два consume-поля из одного биндинга — то же. До амендмента распознавалось только свежее inline-выражение/вызов в позиции поля, что вынуждало pass-through-обёртки видаfn tcp_move(consume s TcpStream) -> TcpStream => s.
Заразность через поля + 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]— хотя бы одинTiconsume? - 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 param | OK? |
|---|---|
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 состояний:
- Closed locally —
tx.commit()(consume-метод). - Returned —
return tx. - Transferred —
f(tx)гдеf(consume tx T). - Stored in record-field, который сам уходит наверх:
consume tx = begin() return Wrapper { tx: tx } // tx → record-field, record returns - 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:
Live≡Some(t).Consumed≡None.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-метод. Смягчено
D432 (Plan 217, 2026-07-20): аргумент «размывает выбор»
относился к безусловному drop БЕЗ доступа к исходу. После введения
ScopeOutcome(D314) авто-@cleanup(outcome)ветвится по исходу (Success/Failure(e)/Panic(m)) так же явно, как ручной вызов вconsume X = e { body }(D188) — размывания больше нет. D432 разрешает авто-вызов КАК OPT-IN: только для типов, объявивших эффект-чистый@cleanup(§8а п.1 плана 217). Тип БЕЗ@cleanup(напримерStringBuilder— потребление извлекает ценность, тихий drop = потеря работы) остаётся строго-линейным без изменений — «явный 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 =«обязан передать наверх» vsconsume tx =«обязан закрыть здесь») — отвергнуто (overspec, refactor friction). Финальная модель:consumekeyword mandatory для ownership;letдля consume-types = error либо view-alias (в alias-position). view Tkeyword как explicit qualifier — отвергнуто (default- view достаточно).viewmode = absence ofconsume/mutqualifier (см. D157 Plan 100.3).- Implicit
_ = txdiscard — суррогат suppress; force compile- error.
Сравнение с другими языками
| Свойство | Rust | TS (ES2024) | Kotlin | Go | Nova D133 |
|---|---|---|---|---|---|
| Compile-time enforcement | ⚠️ #[must_use] warning, suppressable | ❌ runtime via dispose | ❌ runtime via use{} | ❌ | ✅ error |
| Suppress escape hatch | ✅ mem::forget(v) / let _ = v | n/a | n/a | n/a | ❌ by design |
| Distinct cleanup methods (commit/rollback) | ⚠️ enum-в-Drop, awkward | ⚠️ single dispose | ⚠️ use{} block | ⚠️ convention | ✅ native (consume-методы) |
| Lifetime / borrow-checker cost | ❌ есть | n/a | n/a | n/a | ✅ нет (поверх GC) |
D133 строже Rust на suppress (нет mem::forget), expressive Rust на
distinct cleanup methods. Не требует lifetime’ов / move-семантики.
Связь
- D131 — affine
consumefoundation. D133 — extension on type-decl level. - D132 —
-> @fluent-return; sound builder-chain alias через-> @нужен для consume-checker’а builder API. - D75 — почему universal consume отвергнут.
- D90 —
defer/errdeferfoundation; интеграция через 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 Tread-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.
- D432 Plan 217 — авто-
@cleanup(гибрид C): смягчает «Что отвергнуто»/Drop-method пункт выше для эффект-чистых@cleanup-типов; оставляет строгую линейность (эта секция, без изменений) для типов без@cleanup.
D432. Авто-@cleanup для непотреблённых consume-переменных (гибрид C) {#d432}
Статус: ПРИНЯТО (Plan 217, владелец 2026-07-20, решения §8а).
Амендирует: D133 §«Что отвергнуто» (Drop-method auto-cleanup, смягчено
выше), D131/D180 Rule 6 (не применяется к
@cleanup-типам, см. ниже), D314 (переиспользует
defer-kernel как транспорт, §3 ниже).
Суть (гибрид C)
Для type X consume, объявившего @cleanup, — семантика сдвигается с
линейной (=1, D133 «ровно один раз») на аффинную (≤1, D131-стиль
«забыть можно»): непотребление к концу скоупа БОЛЬШЕ НЕ ошибка,
компилятор сам вставляет вызов @cleanup(outcome) на висячих exit-путях.
Для type X consume БЕЗ @cleanup — D133 действует без изменений
(строгая линейность, явное потребление обязательно).
Выбор аффинный/линейный делается САМИМ фактом объявления @cleanup —
видимый в исходнике признак, без скрытой магии и без нового keyword’а.
Канонический пример линейного класса — StringBuilder (@into_str
извлекает ценность; тихий авто-drop потерял бы построенную строку) —
такие типы @cleanup НЕ объявляют и остаются строго-линейными.
§1. Ограждение 1 — эффект-чистота (ГЛАВНОЕ, обязательное)
Авто-cleanup применяется ТОЛЬКО к типам, чей @cleanup(outcome ScopeOutcome) -> () объявлен с ПУСТЫМ effect-row (эквивалент
Cleanup[never]). Прецедент 214
R12 (эффект-свобода — E_COERCE_EFFECTFUL): скрытый эффект в неявной
позиции запрещён под --strict-effects. Fallible-@cleanup (непустой
effect-row, например Fail[IoError]) остаётся строго-линейным — D133
диагностика (D133-not-consumed) продолжает срабатывать для него
БЕЗ ИЗМЕНЕНИЙ, как если бы D432 не существовал. Включение
fallible-cleanup (эффект-пропагация в сигнатуру объемлющей функции ИЛИ
иная политика) — отдельное будущее решение, вне периметра D432.
АМЕНДМЕНТ 2026-08-04 (решение владельца) — дверь, оставленная выше, открыта: эффектная и падающая очистка РАЗРЕШЕНА с пропагацией.
Ограничение «только пустой effect-row» снимается. Правило:
- Эффекты авто-вставленного вызова
@cleanup— ПРЯМЫЕ эффекты объемлющей функции. Не транзитивные: вызов физически генерируется в теле ЭТОЙ функции, значит эффект происходит здесь. Различие существенно — по D62 транзитивные эффекты дают лишь предупреждение, и без этой фразы правило выродилось бы в необязательное. Fail[E]очистки обязан появиться в сигнатуре объемлющей функции. Формулировка владельца 2026-08-04: «эффектFailдолжен появиться в функции, если его требует процесс авто-очистки». Новым правилом это НЕ является — действующая норма уже говорит ровно это:Failесть исключение из «только прямые», он строго транзитивен и обязателен в сигнатуре везде, где может произойти, и не ослабляется никаким флагом (обзорspec/effects.ru.md, раздел «Прямые эффекты, не транзитивные»; D65). Отсюда важный вывод для планирования: №315 — не языковой пробел, а дефект реализации. Проверка не видит синтезированный вызов очистки, потому что десугаринг блочной формы идёт ПОСЛЕ проверок эффектов. Правило менять не нужно, нужно чинить чекер.- Кто пишет — по D28/D62, без
новой политики: в private-функции компилятор выводит и добавляет сам;
в
export fnпрямые эффекты обязательны явно, поэтому отсутствиеFail[E]/Fs— ошибка компиляции с указанием на биндинг, который её породил. - Отказ очистки на уже падающем пути ПРИСОЕДИНЯЕТСЯ, а не замещает.
Решается по
outcome, который в очистку уже передаётся: приSuccessотказ очистки распространяется как отказ функции; приFailure/Panic— присоединяется к исходной причине. Модель подавленных исключений Java. Альтернатива «замещать» теряет первопричину, а «молча глотать» — путь Go сdefer f.Close(), где ошибка закрытия исчезает; оба отвергнуты.
Следствие: File и TcpStream перестают быть исключением из авто-очистки
(см. std/src/fs/fs.nv:181 — комментарий об исключении подлежит снятию тем
же слиянием, что и реализация). Контейнеров это НЕ касается — см. §2
ниже: drop-glue не вводится, и Vec[File] по-прежнему не прибирается
поэлементно. Для контейнеров действует наследование линейности
(амендмент к D156), а не авто-очистка.
§2. Ограждение 3 — НЕ рекурсивен (нет drop-glue)
Авто-cleanup применяется ТОЛЬКО к именованным bare consume-биндингам
(consume X = e;, одиночный Ident-паттерн). НЕТ drop-glue: record с
@cleanup-полем, Vec[TlsStream] и прочие агрегаты/контейнеры полем/
поэлементно НЕ прибираются — правила передач владения (Stored-in-
escaping-field = Transferred, D131) не меняются. Деструктурирующие
паттерны (let (a, b) = e, let { x, y } = e) — НЕ покрываются (остаются
под старым D133, если применимо).
Амендмент 2026-08-15 (реестр 221.1 №667): «паттерн» здесь — ЛЮБОЙ паттерн, включая арм match и
if let. Формулировка выше перечисляла толькоlet (a, b)/let { x, y }, и это прочли как «про кортежи и записи», а не как правило. Биндинг, введённый ПАТТЕРНОМ —match e { Ok(consume r) => … },if let Ok(consume r) = e { … }— авто-cleanup НЕ получает: кодоген армитDeferEntryтолько для bare-Stmt::Letс одиночнымIdent(auto_cleanup_qualifies(&LetDecl)), у паттерн-биндинга точки постановки нет вовсе. Значит непотребление такого биндинга — честная ошибкаD133-not-consumed, а не тихое принятие: чекер обязан снимать D432-исключение ровно там, где кодоген ничего не вставляет. Что это стоило до амендмента:nova-polarisбрал слот семафора арм-биндингом (Some(consume p) => …) с комментарием «D432 вернёт слот на выходе» — слот не возвращался никогда, и послеmax_inflightсоединений сервер вставал молча. Норму держат фикстурыspec_tests/conformance/neg/m667_arm_binding_leak_neg.nv(утечка = ошибка) иstandalone/m667_arm_binding_and_variant_ctor_ownership.nv(все законные маршруты владения компилируются).
АМЕНДМЕНТ 2026-08-21 (решение владельца; реестр 221.1 №672): исключение §2 освобождает не «всё, кроме перечисленного», а РОВНО ОДНУ ФОРМУ — ту, что заведена bare
Stmt::LetсPattern::Ident.Амендмент 2026-08-15 (№667) сказал правильную вещь про паттерны и оставил формулировку перечислительной. Перечисление и есть дефект: форм биндинга в языке больше, чем три, и каждая неназванная молча освобождалась исключением, которое кодоген не подкрепляет. Замер интегратора 2026-08-19/20 нашёл ПЯТЬ таких форм, и каждая была увидена НАБЛЮДЕНИЕМ —
printlnвнутри@cleanupне печатался ни разу.НОРМА. Авто-
@cleanupснимает обязательство ТОЛЬКО с биндинга, заведённого какconsume X = e;с одиночнымIdent-паттерном. Правило формулируется через ПРОИСХОЖДЕНИЕ биндинга, а не через список исключений, именно чтобы следующая форма биндинга, которую добавят в язык, попадала под норму автоматически, а не текла до тех пор, пока её не впишут в список. Обязательство потребления заводится НА ВСЕХ формах; исключение — одна.Пять форм, поимённо:
- consume-ПАРАМЕТР —
fn eat(consume r T). Вызывающий дизармит СВОЙ флаг при передаче (§4 п.3), вызываемый не чистит никогда.while Pat = expr— биндинг паттерна цикла, свежий на каждой итерации и обязанный быть потреблённым в теле на ВСЕХ путях.- consume-РЕСИВЕР —
fn T consume @m(). ОСТАЁТСЯ ОТКРЫТОЙ, см. ниже.- результат-биндинг consume-области —
consume out = consume s { … }.- параметр ЗАМЫКАНИЯ и хендлер-опа. Типизированные формы (closure-full
fn(consume v T) -> …, типизированный параметр lambda, хендлер-оп) — под нормой. Closure-light|x| …НЕ покрыта: по D22-rev у её параметров типов нет в грамматике, и происхождение биндинга там нечем заполнить.ФОРМА (3) ОТКРЫТА, И ЭТО ВОПРОС К ЯЗЫКУ, А НЕ К ЧЕКЕРУ. Попытка завести обязательство на consume-ресивер была сделана и снята ЗАМЕРОМ 2026-08-22. Вопрос, на который язык сегодня не отвечает: чем РАЗРЕШАЕТСЯ обязательство приёмника. Четыре признака распоряжения были найдены по живым носителям и каждый работал: делегат
@cleanup(File.close), must-consume в типе возврата (TcpStream.into_split), вынос@хотя бы на одном пути (TcpStream.close), пустой@cleanup. Пятый носитель не берётся ниодним:fn D188MvBoom consume @defuse() -> () { }— НАМЕРЕННЫЙ сброс ресурса, чей cleanup паникует, и он СТРУКТУРНО НЕОТЛИЧИМ от пробы №672fn R672 consume @swallow(), которая намеренно течёт. Пока в языке нечем объявить «этот метод — терминальный освободитель» либо «этот сброс намерен», форма (3) закрыта быть не может без ложных отказов на законном коде.Что НЕ стало нормой, хотя выглядело убедительно: «пустой
@cleanup— терять нечего». Применённое к исключению вообще, это правило делаетneg/m667_arm_binding_leak_negЗЕЛЁНОЙ: в корпусе пустой cleanup — рабочий маркер ResourceTrace, а не признак отсутствия ресурса. Записано, чтобы не пробовать второй раз.Норму держат фикстуры
spec_tests/conformance/neg/m672_form{1,2,4,5}_*_neg.nv(утечка = ошибка, каждая с построчнымnova:expect) и их позитивные близнецыspec_tests/conformance/m672_form{1,2,4,5}_*_ok.nv(законные маршруты владения компилируются).
§3. Ограждение 4 — отброшенный временный результат
Несвязанный результат @cleanup-типа как bare-statement (acquire_lock()
без биндинга) — авто-cleanup НЕ применяется (нет именованного биндинга,
на который можно было бы повесить flow-анализ). Примечание к
реализации (2026-07-20): на момент D432 общий D133 uncomitted-temporary
diagnostic для АНОНИМНОГО discard-результата любого consume-типа (не
только @cleanup-типа) эмпирически НЕ производит отдельную ошибку —
это ПРЕДСУЩЕСТВУЮЩИЙ (pre-D432) пробел в D133-диагностике, НЕ регрессия
от D432 (D432 расширяет обработку строго на именованные Stmt::Let,
анонимные значения не трогает вовсе — поведение до/после D432 идентично
для этого случая). Закрытие пробела — отдельный follow-up
([M-d133-discarded-temporary-diagnostic]), вне периметра D432.
§4. Flow-sensitive семантика (дизарм на передаче владения)
Авто-cleanup — НЕ безусловный defer (тот срабатывает на КАЖДОМ
выходе безусловно). Это flow-sensitive вставка: @cleanup(outcome)
эмитится ТОЛЬКО на exit-путях, где биндинг ещё Live/MaybeConsumed
(D131 VarState) в момент выхода. На путях, где биндинг уже Consumed
(явно потреблён: return X голым идентификатором, передача в
consume-параметр, вызов consume-метода на биндинге) — cleanup НЕ
запускается (семантика Rust Drop: перемещённое значение не дропается).
Доказанно-безопасные дизарм-точки (реализация, Ф.2):
return X(голый идентификатор) — D133 «Returned»-способ удовлетворения обязательства, безусловный (не зависит от режима параметра вызываемой функции).- Bare-statement (или любая позиция, где значение отбрасывается)
receiver-вызов
X.method(), гдеmethod— ЗАРЕГИСТРИРОВАННЫЙ consume-метод типаX(совпадает с checker’sis_consume_method) — ЛЮБОЙ такой метод (не только@cleanup), напр.g.unlock()наряду с гипотетическимg.cleanup().Амендмент 2026-09-30 (реестр 221.1 №1100): «зарегистрированный consume-метод» читается как «вызов РАЗРЕШЁН в объявление с
consume- приёмником». Тёзка с видовым приёмником (fn T @m(tag int)рядом сfn T consume @m()) не дизармит — правило п.3 ниже, применённое к@. - Прямая передача биндинга аргументом вызова (
foo(g), free-fn ИЛИrecv.method(g)) на позиции, котораяconsume-режима хотя бы на ОДНОМ overload’е этого имени (free_fn_consume_param_positions/method_consume_param_positions— консервативный union по имени, зеркало checker’sconsume_args/consume_idxs). Найдено ОБЯЗАТЕЛЬНЫМ (не опциональным) folder-CU-регрессом:guard_cross_scope_transfer.nv(consume g = mu.lock(); do_work_under_lock(g, counter)— обычный std-паттерн «передать guard в хелпер») ломался двойнымunlockбез этого дизарма — то, что изначально казалось «редким гипотетическим пробелом», оказалось активным сценарием в существующем корпусе.Амендмент 2026-09-30 (реестр 221.1 №1100): union по имени СНЯТ — дизарм судит РАЗРЕШЁННЫЙ кандидат ЭТОГО вызова. Позиция дизармит, если она
consumeу объявления, в которое чекер разрешил вызов (канал 196,resolved_callees); вид у выбранного кандидата — не дизармит, что бы ни объявляли тёзки. Union по имени снимал флаг из-за overload’а, который не вызывался:use(g, 1)разрешался в видовойuse(x, tag), аfn use(consume x)рядом снимал cleanup — тихая утечка. Если канал кандидата не записал, решает единогласие объявлений под именем: дизарм — только когдаconsumeна этой позиции у ВСЕХ; разногласие без разрешённого кандидата оставляет флаг взведённым. Для метода (recv.m(g)) — то же.guard_cross_scope_transfer.nvостаётся зелёным: там кандидат один. - Вход в ЛЮБОЙ RE-CONSUME БЛОК (
consume X { body }, D188/201-амендмент) на бывшем bare-биндингеX— блок берёт cleanup «на себя» целиком (exactly-once, свой tail/return-дизарм), поэтому OUTER auto-cleanup флаг дизармится БЕЗУСЛОВНО при входе в блок (до эмиссии тела), иначе cleanup срабатывает дважды. Тоже найдено folder-CU-регрессом (d188_reconsume_block.nv, D201Boom-сторож).
К итогу ниже — амендмент 2026-09-30 (№1100): из четырёх точек о КОНКРЕТНОМ вызове решают две (п.2 и п.3), и обе теперь спрашивают разрешённого кандидата; п.1 (
return X) и п.4 (вход в re-consume блок) безусловны и от выбора overload’а не зависят.
Все четыре точки реализованы; известных пробелов в disarm-механике на
момент принятия D432 нет (folder-CU регресс — spec_tests/conformance
одним CU — прогнан до зелёного после КАЖДОГО из вскрытых случаев).
Amended (D432 rev-2, владелец 2026-09-16, поднято окном Карины — тот же тип освобождался в биндинге и НЕ освобождался в параметре):
consume-ПАРАМЕТР получает авто-@cleanupнаравне сconsume X = e.
- Область расширена. Авто-
@cleanupприменяется не только к переменной, объявленной формойconsume X = e, но и к параметру, объявленномуconsume, если у его типа есть@cleanupс ПУСТЫМ effect-row. К концу тела функции непотреблённый такой параметр освобождается сам, на тех же висячих exit-путях и тем же транспортом (§3 D314), что и переменная.- Причина — не удобство, а устранение разрыва. До этой правки гарантия висела на ФОРМЕ ЗАПИСИ, а не на ТИПЕ: одно и то же значение с эффект-free
@cleanupосвобождалось само вconsume X = eи требовало явного потребления, будучи параметром. Тип обещал «забыть можно», позиция отвечала «обязан потребить», и читатель не мог узнать правду из типа. Донор-подтверждение: в Rust владеющий параметр дропается ровно какlet.- Все ограждения D432 действуют без изменений и не ослабляются: §1 эффект-чистота (fallible-
@cleanupостаётся строго линейным — D133), §2 отсутствие рекурсии, §4 дизарм на передаче владения, §5 drop-флаг на общем exit-пути, §6 циклы. Параметр — ещё одна ПОЗИЦИЯ того же правила, а не новая механика.- Без
@cleanupничего не меняется.consume-параметр типа, не объявившего@cleanup, остаётся строго линейным: непотребление — ошибка D133, как и было.- ГРАНИЦА, НАЗВАННАЯ ЯВНО:
consume-ПРИЁМНИК (fn X consume @method()) этим амендментом НЕ затрагивается — спека оставляет его открытым, и молчание здесь означает «вопрос не решён», а не «решено так же».
§5. Drop-флаг (§8а п.6, MaybeConsumed на общем exit-пути)
Владелец выбрал (а) рантайм drop-флаг (паритет с Rust drop-flags).
Реализация: скрытый int _active per биндинг (см. Ф.2), взводится ПОСЛЕ
захвата ресурса (partial-init safety — если инициализация throw’ит, флаг
остаётся невзведён), сбрасывается в 0 на каждой доказанно-безопасной
дизарм-точке (§4), читается ТОЛЬКО на exit (normal/return/throw/panic/
interrupt/cancel) — если MaybeConsumed (потреблён в одной ветке
if/match, не в другой), ветка, где дизарм произошёл, НЕ вызывает cleanup
повторно; ветка, где дизарма не было, вызывает его корректно. Это
буквально семантика Rust drop-flag (динамический, не статически
элиминированный — элиминация «эта переменная гарантированно Live на
ВСЕХ путях сюда, флаг не нужен» — оптимизация, НЕ требуется для
корректности, вне периметра D432).
§6. Циклы
Consume-биндинг, объявленный ВНУТРИ тела цикла (while/for/loop), —
авто-cleanup срабатывает на КАЖДОЙ итерации (флаг/машинерия объявлены
внутри тела цикла — переисполняются с нуля на каждый проход, идентично
существующей семантике defer внутри цикла). break/continue —
exit-пути тела итерации, покрыты тем же early-exit-cleanup механизмом,
что и return (D314 §2 exit-таблица логически расширяется этими двумя
строками — маппинг на ScopeOutcome::Success, т.к. break/continue
не несут исход throw/panic).
§3a (нумерация плана). Судьба блока consume X = e { body }
Решение владельца: 3a — блок ОСТАЁТСЯ (см. план §3а). После D432 обе
формы дают одинаковое авто-закрытие для @cleanup-типов: bare
consume X = e; закрывается в конце ОБЪЕМЛЮЩЕГО скоупа; блок-форма
consume X = e { body } закрывается в конце body — явное СУЖЕНИЕ
времени жизни (аналогия: Rust — авто-drop в конце блока по умолчанию,
вложенный { }/явный drop(x) — для раннего освобождения). Блок-форма
technической реализации не меняется (D188/D314 remain as-is); bare-форма
получает НОВУЮ codegen-машинерию (per-block DeferEntry.consume_policy,
см. Ф.2 ниже).
§9 (амендмент 2026-08-15, реестр 221.1 №671). Конструктор варианта — точка передачи владения
Передача consume-значения аргументом конструктора варианта —
Ok(r), Some(r), любой Variant(r) пользовательской суммы — есть
потребление ровно в том же смысле, что передача в consume-параметр
функции (D133): владение уходит в payload варианта и покидает область
вместе со значением. Канонический конструктор ресурса
fn open() -> Result[Res, IoError] {
consume r = Res.acquire()
Ok(r) // владение ушло вызывающему
}
обязан компилироваться, и r обязан считаться потреблённым.
Исключение — биндинг consume X { … }-блока. Внутри блока владением
безусловно распоряжается сам блок (D188-амендмент, Plan 201), а законный
вынос return Ok(s) разбирает его собственная машинерия; правило выше на
такой биндинг не распространяется.
Что это стоило до амендмента: обе стороны компилятора были слепы к этой
точке — кодоген чистил уже отданное значение (№666: @cleanup на
переданном наружу сокете, туннель умирал сразу после рукопожатия), а чекер
требовал потребления, которое уже произошло (№671: для строгого
consume-типа без @cleanup форма выше не компилировалась вовсе; для типа с
@cleanup это маскировалось исключением из §2).
Реализация (сводка, Ф.1-Ф.2)
- Чекер (
compiler-codegen/src/types/mod.rs):LinearityRegistry. cleanup_pure_types(тип → объявлен ли эффект-чистый@cleanup);check_obligations_at_exitне эмититD133-not-consumed/D156-strict-forgetдляLive/MaybeConsumedсостояний таких типов — но только для bare-биндингов (амендмент 2026-08-15, №667: на выходе арм-паттерна исключение снимается флагомarm_pattern_exit_check, и диагностика эмитится честно; см. §2). - Codegen (
compiler-codegen/src/codegen/emit_c.rs): bare-consume-let qualifying-типа получает СВОЙDeferEntry{consume_policy: Some(...)}внутри УЖЕ СУЩЕСТВУЮЩЕГО per-block стека (enter_defer_scope/leave_defer_scope/emit_early_exit_cleanup— все три уже умелиconsume_policyгенерически из D188/D314; ДВА inline run-сайта внутриenter_defer_scopeсамого (FAIL/INTERRUPT — тело блока throw’ит/ interrupt’ится мидвей) потребовали нового кода, зеркалящегоenter_consume_defer_scope’s собственные копии). Cancel-shield ЕСТЬ (nv_consume_enter_shield/leave_shield); watchdog-порог/ResourceTrace из D188 R1/R3 — ResourceTrace переиспользован (дёшево, наблюдаемость паритетна блок-форме), watchdog-таймаут НЕ подключён (threshold_var = "0"— secondary feature, вне keystone).
§7. Раскатка на std-ресурсы (Plan 217.1)
Механизм §1-§6 не хардкодит список типов — cleanup_pure_types строится
структурно (любой consume-тип, объявивший @cleanup(outcome ScopeOutcome) -> () с пустым effect-row, автоматически становится аффинным). Plan 217.1
(2026-07-22) добавил такое объявление ещё четырём std-ресурсам сверх
исходных шести (4 lock-guard’а + TcpStream + TlsStream, nova-tls):
TcpListener, TcpReadHalf, TcpWriteHalf (std/src/net/tcp.nv),
UdpSocket (std/src/net/udp.nv) — каждый @cleanup = вызов уже существующего
@close() (эффект-чистый по сигнатуре сверху, как у TcpStream). Никакого
нового языкового механизма — чистое применение §1-§6 к дополнительным
типам, поэтому это НЕ отдельный D-блок.
Осознанно НЕ включены (явная причина, не забывчивость, владелец Plan 217 §8а п.4):
File(std/src/fs/fs.nv) /BufWriter[W](std/src/io/buffered.nv) —@close()фаллибелен (Fs/Fail-несущийResult); §1 (эффект- чистота) уже отсекает их формально, а по духу — оба типа документируют «ошибка close/flush НИКОГДА не глотается на drop» как СВОЙ differentiator (см. комментарии у типов) — авто-cleanup, проглатывающий ошибку, был бы прямым регрессом design-intent. Остаются строго линейными; см. §8 п.1 (fallible-@cleanup— отдельное будущее решение, если вообще будет).OnceGuard(std/src/runtime/sync.nv) — два discharge-метода (.commit()/.abort()) НЕ взаимозаменяемы (в отличие от единственного idempotent-release у lock-guard’ов):@cleanup(outcome)не может отличить «настоящийSuccess» от «код дошёл до конца скоупа, не завершив setup, по не-throw пути» — молчаливый auto-commit()в последнем случае необратимо помечаетOnceкакDONEбез реального выполнения (в отличие отTransaction, где спонтанный (spurious) commit стоит недорогого отменяемого rollback). Остаётся строго линейным.Body(nova-http, D359) и FFISqlite Db/Stmt(examples/ffi) — вне периметра std/**, не рассмотрены этой волной (соседние репозитории/non-std примеры).
Связь
D131 · D133 (амендмент выше) · D180 Rule 6 (не
применяется к @cleanup-типам — «consume obligation в-scope check»
пропускает типы из cleanup_pure_types) · D314
defer-kernel (транспорт переиспользован без изменений интерфейса) ·
Plan 217 (весь) ·
Plan 217.1 (раскатка,
§7 выше) · Plan 214 R12
(эффект-свобода, прецедент для §1) ·
Plan 216 (ортогонален —
периметр видимости передач владения не меняется, D432 ослабляет только
«непередачу», не «передачу»).
D156. Generic [T consume] bound + collection-aware iteration {#d156}
Plan 100.2. Принято 2026-05-23 (proposed; implementation pending). Extends D133 на generic-код. Closes silent-leak hole для consume-T в generic-функциях.
АМЕНДМЕНТ 2026-08-04 (решение владельца) — линейность НАСЛЕДУЕТСЯ контейнером; два семейства различаются бáундом. Мотив — дефект реестра 221.1 №325: линейное значение клалось в коллекцию молча, оставалось живым и снаружи, обязательство исчезало вместе с контейнером. Формы проверены пробой на компиляторе 2026-08-04, не предположены:
fn Vec[T consume] mut @push(consume v T) -> @ // семейство 1 fn Vec[T consume Cleanup[E]] @m() -> Option[E] => None // семейство 2
Vec[T consume]— контейнер сам линеен. ЕслиTmust-consume, то и контейнер must-consume. Потребляется тремя уже существующими способами: обход с изъятием (for consume, ниже), передача дальше целиком, возврат. Проверки «пуст ли контейнер» НЕ вводится: пустота — свойство времени выполнения и статически неразрешима, а потребление ЗНАЧЕНИЯ компилятор уже отслеживает. Это и есть ответ на вопрос «что происходит при выходе непустого линейного контейнера из области».Vec[T consume Cleanup[E]]— контейнер объявляет СВОЮ@cleanup, обходящую элементы, и по D432 становится аффинным: забыть можно, компилятор вставит вызов.Vec[File]закрывает файлы сам. ПараметрEвводится САМИМ бáундом — формаfn[E] Vec[T consume Cleanup[E]]отвергается ([E_UNUSED_PREFIX_TYPEVAR]: переменная в бáунде не считается использованием), канон — короткая форма без префикса.- Строка эффектов очистки контейнера в исходнике НЕ пишется и выводится на инстанциации из настоящей очистки
T: дляVec[File]тамFs, дляVec[int]пусто. Это не новая сущность, а перенос уже действующего вывода эффектов (D28: приватные — выводятся) на generic-инстанс. Эффект-переменная в языке НЕ вводится.- Drop-glue не появляется — D432 §2 цел: очистка контейнера есть обычный библиотечный метод, а не магия компилятора.
Почему это стало возможно только сейчас: условная доступность метода по бáунду до 2026-08-04 была декорацией — бáунд объявлялся, но на месте вызова не проверялся (реестр 221.1 №303, закрыт тем же днём). Без №303 вся конструкция держалась бы на честном слове.
Амендмент 2026-10-03 (решение владельца; вопрос облачной сессии p274-carina-strarm, шаг 4 D133) — newtype над must-consume типом сам must-consume.
type Tx2 Tx(D52) приtype Tx consume …обязан быть потреблён, как иTx. Newtype — однополевая обёртка, то есть контейнер ровно из одного значения, и правило п.1 выше распространяется на него без изъятий: иначе копия обёртки унесла бы одно обязательство дважды, а забытая обёртка — потеряла бы его молча. Снимается тем же, чем у представления: формой потребления (D432 §2) — передачей вconsume-позицию, разбором, возвратом. Обёртка над аффинным типом (с@cleanup) аффинна так же. До этого амендмента спека о newtype здесь молчала; Карина (шаг 4 D133) уже вела себя так.
АМЕНДМЕНТ 2026-09-24 (решение владельца, план 246) — обычный
[T]принимает ТОЛЬКО обычные типы. Мотив — замер плана 246 того же дня: тело с обычным[T]компилятор на обращение с must-consume значением не проверяет («silent-ignore» раздела «Что» ниже), и потомуclone,clear,truncateи перезапись по индексу уVecмолча теряли или удваивали линейные элементы; заслон №325 держался списком имён контейнеров и не видел запись[]T(реестр 221.1 №1327).
Параметр типа без бáунда
consume— у функции, у метода и у объявления типа — принимает только обычные (не must-consume) типы. Подстановка must-consume типа — ошибкаE_CONSUME_TYPE_ARG_UNBOUNDEDс подсказкой «объяви параметр[T consume]». Это ЗАМЕНЯЕТ «silent-ignore» раздела «Что» и список имён заслона №325: правило стоит на параметре типа, а не на имени контейнера, и не зависит от записи ([]TиVec[T]— один тип).
[T consume]— любые типы; тело проверяется строго (раздел «Strict mode внутри[T consume]body» ниже) — без изменений.Объявление типа.
type Vec[T consume] priv { … }— тип принимает оба вида элементов; экземпляр с must-consume аргументом сам must-consume (наследование, амендмент 2026-08-04 выше), с обычным — обычный. Модификаторconsumeу самого типа (type Job consume value priv { … }, D241) — другое: экземпляр must-consume ВСЕГДА, каким бы ни был аргумент.Методы обобщённого типа. Метод, чей приёмник записан с обычным
[T](fn Vec[T] mut @clear()), доступен только при обычномT; с[T consume]— при любом. Отсюда разметка контейнера: методы, которые только перекладывают элементы, —[T consume]; выбрасывающие и удваивающие — обычный[T]; кладущие внутрь — пара форм, копирующаяfn Vec[T] mut @push(v T)и забирающаяfn Vec[T consume] mut @push(consume v T), между которыми выбирает правило 3 оси режима D84 (10-overloading.md).Режим параметра
consume x Tправилом не затрагивается — для обычного типа он работает, как прежде (D131: временное передаётся, именованная привязка гаснет), — за одним исключением, пункт 6.Одна форма забирающего метода (решение владельца 2026-09-25). В типе, объявленном
[T consume], метод с приёмником, записанным обычным[T], не берёт параметрconsume x T: для обычногоTон делает ровно то же, что та же подпись с приёмником[T consume], то есть был бы второй записью одного метода.fn Vec[T] mut @push(consume v T)— ошибкаE_CONSUME_PARAM_UNBOUNDEDс подсказкой «запиши приёмникVec[T consume]». Копирующий параметрv Tв таком методе законен. Цена названа: метод, которыйxлибо кладёт, либо выбрасывает и при этом гасит привязку вызывающего, так не записывается — его пишут безconsumeлибо возвращаютxвызывающему (-> Option[T]).Пара по линейности (решение владельца 2026-09-25). Метод или функция может иметь две формы, различающиеся только маркером: общую
[T consume]и быструю обычную[T](fn Vec[T consume] @index_of(v T)/fn Vec[T] @index_of(v T)). ОбычныйTберёт[T], must-consume —[T consume]; есть одна форма — её берёт всякийT, которого она допускает. Для обычногоTформы обязаны давать один результат. Правило выбора и почему это не ранжирование бáундов — D464, амендмент 2026-09-25.Порядок (решение владельца 2026-09-25). Обе семьи остаются: семейство 1 (
Vec[T consume]) линейно и опустошается явно — какBufferв Austral, где автоочистки нет вовсе (removeLastв цикле, затемdestroyEmpty); семейство 2 ([T consume Cleanup[E]]) аффинно и очищается само. Порядок различается по назначению:
- пользовательский
for consume x in v— элементы идут С ПЕРВОГО по последний, каждый забирается ровно один раз,vпосле цикла потреблён; длина берётся один раз до первой итерации (D58:iter()один раз);- очистка контейнера (
@cleanupсемейства 2) — С ПОСЛЕДНЕГО по первый (popс конца), тот же LIFO, что у несколькихdefer(D161) и у стека областей (D188 §R5); сбой одного элемента не останавливает очистку остальных (D161: «LIFO continues после partial failure»).Прецедент — универсы Austral:
T: Free(только копируемые),T: Type(любые, тело обязано обращаться со значением линейно), а тип-контейнер линеен, если линеен аргумент.
Что
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), включая комбинацию с протокольной цепочкой (№300/221.1
уточнение; окно p-lang, 2026-08-04): [T consume Hash + Equal] —
модификатор consume ВСЕГДА первым, затем +-цепочка протоколов, которым
тип обязан удовлетворять («модификатор перед тем, что описывает», см.
D445). consume в саму +-цепочку не входит — она перечисляет то,
что тип реализует, а линейность не реализуется, она объявляется. Обратный
порядок ([T Hash + Equal consume]) остаётся отвергнут — канон один.
Bootstrap-ограничение снято (было: [T consume + Clone] — parse error).
// №300/221.1: consume-bound + протокольная цепочка.
fn dedup_first[T consume Hash + Equal](consume v T) -> T => v
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-migrateCLI (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() -> Tconsume-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.
Сравнение
| Capability | Go | Rust | TS | Kotlin | Nova D156 |
|---|---|---|---|---|---|
| Generic linear bound | n/a | ✅ T: Move (default) | n/a | n/a | ✅ [T consume] opt-in |
| Detection «generic drops linear arg» | n/a | ✅ compile-error | n/a | n/a | ✅ |
| Backward-compat: generic без bound | n/a | n/a | n/a | n/a | ✅ silent-ignore остаётся |
Vec<T> ownership iteration | n/a | ✅ | n/a | n/a | ✅ for 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 идиоматически близок. - D157 —
view 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). Structurallyneeds 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-семантика).consumekeyword на параметрах 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 fnkeyword. Ред. 3 (2026-05-27): РЕАЛИЗОВАНО — parserneedsclause, type-checker D163-missing-cap, C codegen стабы. Extends D82external fn+ D126external type+ D63 capability.Удалено (Plan 91.10):
- Parser
needsclause (hard error w/ migration hint).check_external_fn_needs_caps(D163-missing-cap diagnostic).emit_d163_external_stub(C codegen стаб generator).FnDecl.needs_capsAST 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)— assertf != 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).
Сравнение
| Capability | Rust | Kotlin/JNI | Go cgo | TS Node N-API | Nova D163 |
|---|---|---|---|---|---|
| Ownership через FFI | ✅ unsafe fn + manual contract | ⚠️ manual | ⚠️ manual | ⚠️ manual | ✅ type-driven, без extra keyword |
| Auto-close на panic при FFI handle | ✅ через Drop wrapper | ⚠️ try-finally | ⚠️ defer | ⚠️ try-finally | ✅ через D162 |
| Capability tracking | ⚠️ unsafe fn | ⚠️ manual | ⚠️ manual | n/a | ✅ D63 needs-clause |
unsafe keyword нужен | ✅ да | n/a | n/a | n/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 fnkeyword (Ред. 1) — избыточный, return-type уже carries consume-ness. Drop в Ред. 2.- Vacuous-marker warning (Ред. 1 W D163-vacuous-consume) — отпадает вместе с keyword.
Связь
- D82 —
external fnfoundation; D163 расширяет. - D126 —
external typeopaque; 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’ов.
Сравнение
| Capability | Rust | Kotlin/Java | Go | TS | Nova D164 |
|---|---|---|---|---|---|
| Pub visibility consume-маркера | ✅ pub Drop visible | ⚠️ AutoCloseable interface | ⚠️ exported method | ⚠️ TS types | ✅ D164 propagation |
| ABI mangling включает ownership-info | ✅ через type | ⚠️ via signature | ❌ | n/a | ✅ consume-bit |
| Cross-package consume contracts | ✅ Cargo + Rust types | ⚠️ Maven coordinates | ⚠️ go modules | ⚠️ npm types | ✅ nova.toml |
| Re-export preserves marker | ✅ через pub use | n/a | n/a | n/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 79 | Severity |
|---|---|---|
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]для не-genericFoo). Опущенные аргументы (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 / D77 —
into/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 — нет «двух жаргонов» (
handlervsprotocolvseffect). - Анонимный 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 handleralias — отвергнут (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
handler→effect(Plan 97 Ф.3). - D87 —
Effect[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):
- Литерал
protocol Reader { read() => state.value }создаёт synthetic structNova_ProtoLit_<N>с capture-fieldstate. - Free fn
Nova_ProtoLit_<N>_method_read(self, ...)используетself->state->value. - Allocate
NovaVtable_Reader*+ ctx; patch vt->read = impl_fn. - Возврат
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 и str — arr[a..b] / s[a..b]
Amended (Plan 138 D238+D239, 2026-06-10):
arr[i]для user-типов (Vec[T],HashMap[K,V]и т.д.) теперь через@indexprotocol (D238).[]T=Vec[T](D239); typed-storage gap закрыт —[]Option[int],[]Recordи другие exotic-element типы получают правильное typed хранение. Range-slicingv[2..5]дляVec[T]— через@index(Range)overload. Предложение «future language version» из D232 Migration path снято: D239 фиксирует[]T≡Vec[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[]TAPI; 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).
ОТОЗВАНО D249-амендментом (2026-06-13): срез
strБАЙТОВЫЙ, не codepoint. Абзац выше описывает прежнюю семантику, развёрнутую D238-амендментом по D249 (03-syntax.md:11179-11183,:11603-11607;open-questions.ru.md:4192-4196):strреализует толькоIndex[Range, str], границы — байтовые смещения, граница внутри кодовой точки — паника (D470 §8); тело —std/src/runtime/string/slice.nv:66. Пометка-указатель добавлена 2026-09-25 (окно Карины, форма согласована с интегратором): два места D144 противоречили D249 без пометки об отмене, и это нашёл разбор волны срезов (274.7 §У).
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 view — len берётся 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→ panicto < from→ panicto > 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]).
ОТОЗВАНО D249-амендментом (2026-06-13): срез
str[a..b]байтовый, а не codepoint-indexed — см. указатель в разделе «Семантика — sub-slice view» выше.
Старый s.slice(a, b) метод — сохраняется с clamp-семантикой
для backwards-compat; align на panic откладывается в Plan 94
(см. [P-str-slice-clamp-vs-panic] в docs/dev/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 —
[]TAPI; §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[]intelement + 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-sitexs.m().- 101.3 ✅ — Multi-bound
[T A + B]: GenericParam.bound → bounds Vec, parser+ Typechain, 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 compositionuse Foo.Ред. 5 (2026-05-25): Plan 101.3 (multi-bound
[T A + B]) и Plan 101.4 (protocol compositionuse TypeName— pivot от earlier discussion A1use 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 compositionuse Foo.
Что
Generic-параметры функции в receiver-position декларируются по одному из двух механизмов, в зависимости от формы receiver’а:
- 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(...).
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-shape | Carrier? | 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]) mix | T нет, U через Option | да fn[T] (T, Option[U]) |
[]Option[T] composite | T через 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-prefix | Receiver | type 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 в scope —
type-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 SignedInts set i8 | i16 | …), используемое как bound:
fn[T SignedInts] T.parse(...). Композиция type-set ∧ protocol — через тот же +
([T SignedInts + 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 C≡use 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 B↔B 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-typevarTв непосредственный элемент (Vec[int]), не во внутренний (int) — вложенность[][]Tсхлопывалась в один"[]T"-ресивер → тело строилоout []T == Vec[Vec[int]], возвращало неверный тип (verified probe RUN-FAIL, mono’dout=Nova_Vec____Nova_Vec____nova_int_p).
Правило (после AMEND):
- Обе формы записи приняты и эквивалентны под D239:
fn[T] Vec[Vec[T]] @m≡fn[T] [][]T @m. Парсер несёт полный структурированный тип ресивера вReceiver.receiver_ty(type_nameего flatten’ит в"[][]T"и теряет глубину — поэтому нужен отдельный структурный слот). - Receiver-typevar биндится в самый внутренний элемент, рекурсивно (depth-agnostic):
для
Vec[Vec[T]]/[][]TT = element-of-element; дляVec[Vec[Vec[T]]]/[][][]TT= 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):
- AST —
Receiver.receiver_ty: Option<TypeRef>несёт полный структурированный тип вложенного ресивера (единственное место, где глубина переживает —type_nameflatten’ит в"[][]T"). - Parser — slice
[][]T: счёт глубиныArray+ спуск до внутреннегоNamed→ строитArray(Array(Named T)). CarrierVec[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 (legacyNovaArray_-путь); override гейтитсяreceiver_ty_is_nested— только для реально вложенных ресиверов. - Checker — вложенные typevar’ы из
receiver_tyсобираются вreferenced-множество дляE_UNUSED_PREFIX_TYPEVAR; scopegsНЕ сидится из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).
AMEND (2026-07-25, Plan 221.1 №88 [M-structured-receiver-generic-not-enforced]): энфорс + юзер-carriers + []-алиас в слотах + запрет затенения
Четыре дельты поверх предыдущего AMEND, закрывающие дыры карты владельца
(вопросы Type[[]T]/Type[Vec[T]]/двусмысленность T, 2026-07-24).
(i) Call-site энфорс формы (главная звучность). До этого AMEND метод со
структурным receiver’ом (fn Vec[Vec[T]] @flatten, юзер-carrier ниже) вызванный
на НЕ-унифицирующемся ресивере (Vec[int].flatten()) не диагностировался НИГДЕ
— либо случайный CC-FAIL глубоко в codegen, либо (для receiver’а без вообще
никаких typevar’ов) молчание: verified-проба — OneBox[int].first(), где
@first объявлен fn OneBox[Vec[T]] @first() -> T, проходил nova check И
nova build без единой диагностики; собранный бинарь на print(b.first())
печатал ничего (не панику, не мусор — тихий no-op где-то в codegen).
Теперь: на call-site метода со структурным receiver’ом (Receiver.receiver_ty
— Plan 153.5/D263 слот) чекер прогоняет ту же depth-agnostic структурную
унификацию, что мономорфизатор уже использует для биндинга (build_recv_subst
→ const_fn_trampoline::unify_type, НЕ shallow unify_coerce_receiver
Plan 214.1 — тот намеренно one-level, R4 «без рекурсии вглубь», непригоден для
многоуровневого mismatch). Bindable typevar-имена — объединение
receiver.generics (carrier-декларация, покрывает и flat, и (ii) nested-
harvest) И fn.generics (fn[T]-префикс/method-level — иначе ЛОЖНО отвергло
бы ВСЕ 7 fn[T] []T @method методов vec.nv, verified regression class).
Несовпадение → E_RECV_SHAPE_MISMATCH («method X requires receiver shape
Y, got Z»). Единственный кандидат (по (receiver-база, имя-метода)) —
best-effort posture, зеркалит check_method_call_bounds (Plan 101.2): ≥2
overload’ов по arity → пропуск (не второе гадание за резолвом overload’а, а
исключительно форма ОДНОЗНАЧНОГО кандидата).
(ii) Юзер-carriers легальны. 153.5-механика (harvest + depth-agnostic
структурная унификация) НЕ Vec-специфична — верифицировано: fn OneBox[Vec[T]] @first() -> T => @v[0] (произвольный юзер-тип OneBox[T] как
внешний carrier, БЕЗ fn[T]-префикса — по доктрине carrier-скобки САМИ есть
декларация, fn Vec[Vec[T]] @flatten-идиома) работает без единой правки
codegen/mono — parser уже строил receiver_ty depth-agnostic для ЛЮБОГО
именованного carrier’а, не только Vec. fn[T] OneBox[Vec[T]] @first()
(С префиксом) корректно отвергается E_DUPLICATE_GENERIC_DECL — carrier уже
декларирует T, префикс дублирует; это ОЖИДАЕМОЕ поведение, не баг. Известное
ортогональное ограничение (verified, вне scope): мономорф юзер-carrier’а
(в отличие от std-шного Vec[Vec[T]]) CC-FAIL’ит на НЕ-int T (напр.
OneBox[Vec[str]] → returning 'Nova_Vec____nova_str *' from a function with incompatible result type 'nova_str') — codegen-гэп для generic-value-типов
с вложенным generic-аргументом, отдельный от 88-объёма (чекер/грамматика).
Также verified отдельно, вне 88-объёма: синтезированный .new()-конструктор
для generic value-типа с вложенным Vec-аргументом (OneBox[Vec[int]].new(v: ...)) резолвится в НЕСВЯЗАННЫЙ C-символ (Nova_EmbeddedDir_static_new) —
воспроизводится БЕЗ единого метода на типе; обход — anon-литерал + let-
аннотация (прецедент d277, OneBox[Vec[int]] = { v: ... }).
(iii) []-алиас в carrier-слотах (D239). Грамматика раньше отвергала
OneBox[[]T]/Vec[[]u8] («expected identifier, got [») — carrier-слот
парсился ТОЛЬКО через parse_ident/Ident[-nested-detect, слот, начинающийся
с [, не имел ветки. Новая ветка в parse_generic_decl_params_inner
(in_carrier_position) парсит слот, начинающийся с [, через parse_type
(годится []T, [][]T, …), затем канонизует результат в Vec[...]-форму
(общий хелпер const_fn_trampoline::canonicalize_array_to_vec, рекурсивный,
любая глубина) — так и flat, и nested carrier-слоты хранят ОДНУ каноническую
форму, независимо от того, какой синоним D239 автор написал. Тот же хелпер
применяется к call-site’овому инферренному типу receiver’а перед унификацией
(i) — без этого ro v Vec[[]u8] = ... (обычная type-annotation, идущая через
ОБЩИЙ парсер типов, не через carrier-слот-канонизацию) породила бы ложный
E_RECV_SHAPE_MISMATCH на чисто косметическом расхождении спеллинга.
OneBox[[]T] @m() теперь ≡ OneBox[Vec[T]] @m() byte-for-byte структурно.
(iv) Запрет затенения (решение владельца). Имя в receiver-carrier-скобках,
СОВПАДАЮЩЕЕ с уже объявленным реальным типом ИЛИ примитивом (int/str/…),
двусмысленно: это опечатка typevar’а, случайно попавшая на имя реального типа,
или намеренная специализация конкретным типом? Специализация НЕ
поддерживается (может стать фичей позже, явно) — раньше это молча принималось
(имя не typevar-формы — короткое uppercase, ident_is_typevar — не харвестится
как generic, остаётся литеральной ссылкой на тип) И не проверялось вообще
— метод регистрировался под method_table[base] и (до (i)) диспатчился на
ЛЮБОЙ ресивер той же базы. Проба владельца: type Wid + fn Vec[Vec[Wid]] @wsum — раньше молча компилировался (мусор), теперь E_RECV_GENERIC_SHADOWS_TYPE
(«receiver generic param Wid затеняет объявленный тип Wid — переименуй
параметр»). Примитивы отвергаются той же нормой (fn OneBox[Vec[int]] @m() —
тоже E_RECV_GENERIC_SHADOWS_TYPE).
Область (iv) — ТОЛЬКО nested-слоты (depth ≥ 2 от корня receiver’а),
намеренно НЕ «любая глубина». Verified regression при первой (broader)
реализации: std/src/encoding/serde/serde.nv — fn HashMap[str, V Serialize] @serialize[...] — прямой (depth-1) carrier-слот str, намеренная
частичная конкретная специализация ключа (K = str буквально, V остаётся
generic) — production-код, давно и молча так работающий (flat bare-ident
carrier-слот харвестится безусловно, без ident_is_typevar-гейта, парсер
parse_generic_decl_params_inner, non-nested ветка — отдельная, СТАРШАЯ,
известная permissive-механика, НЕ предмет этого окна). depth-1 remains
permissive БЕЗ проверки (out-of-scope находка, не тронуто; вероятно свой
маркер — см. отчёт окна). Аналогично []-slice-sugar top-level ресивер
(fn []u8 @to_str_unchecked, std/runtime/string) исключён из (iv) целиком —
отдельная, уже звучная (свои E_UNDECLARED_TYPEVAR_IN_RECEIVER/
E_BARE_TYPEVAR_NEEDS_PREFIX гейты) механика, не carrier-форма.
№91 [M-nested-vec-concrete-extension-unresolved] — честный вердикт, НЕ
закрыт полностью. Slice-sugar top-level форма (fn [][]u8 @m(), конкретный
элемент u8) резолвится и работает end-to-end (build+run verified) — но эта
форма НЕ трогалась этим окном (отдельный, уже звучный путь), похоже была
рабочей и раньше. Carrier-форма (fn Vec[[]u8] @m() ≡ Vec[Vec[u8]])
теперь честно отвергается правилом (iv) (u8 — примитив, nested, depth
2) — специализация конкретным примитивом не поддерживается по явному решению
владельца («Примитивы… тоже отвергать»), так что №91’s carrier-спеллинг НЕ
становится вызываемым, а получает громкую, осмысленную ошибку взамен прежнего
непрозрачного E7320. Если владелец хочет carrier-спеллинг ТОЖЕ рабочим —
это отдельная фича (явная поддержка concrete-специализации), не входит в
текущую доктрину «специализация НЕ поддерживается».
#coerce-заглушка (E_COERCE_GENERIC_PATTERN_UNSUPPORTED, p2141f) —
СОХРАНЕНА, не снята. Причина: #coerce’s собственный унификатор
(unify_coerce_receiver, Plan 214.1) остаётся ПРИНЦИПИАЛЬНО shallow (R4 «без
рекурсии вглубь» — design-инвариант того окна, не забытая недоделка) и НЕ
переиспользует (i)‘s глубокий unify_type — эти два окна намеренно не
слиты. Снятие заглушки потребовало бы ОТДЕЛЬНОГО решения — либо углубить
#coerce’s унификатор (рискует R4-инвариантами того плана), либо провести
#coerce-путь через (i)‘s механизм (архитектурная работа вне 88-объёма).
Regression-verified: spec_tests/conformance/neg/ d429_1_generic_coerce_structured_receiver_neg.nv по-прежнему падает тем же
кодом байт-в-байт.
Гейт: nova check std/src 142/27/1040 (байт-идентично, без
NOVA_STD_PATH); nova test std/src/collections/vec — зелёный (restructure/
flatten живут там, один агрегированный CU-отчёт per test-conventions.md);
новые фикстуры spec_tests/conformance/standalone/p88_structured_receiver_pos.nv
spec_tests/conformance/neg/p88_recv_shape_mismatch_neg.nv+spec_tests/conformance/neg/p88_recv_generic_shadows_type_neg.nv+spec_tests/conformance/neg/p88_recv_generic_shadows_primitive_neg.nv—nova testдословно PASS на все четыре.
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] @method— D145 строго аддитивно. std/collections/vec.nvсодержит 7 методов patternfn []T @method[U](написан как-если-бы T дженерик). Это bug — T silently трактуется как named type, codegen падает. Plan 101.1 включает migration vec.nv →fn[T] []T @method[U].
Параллель индустрии — таблица
| Lang | Synтакс для array-method | Bound syntax |
|---|---|---|
| Rust | impl<T> Vec<T> { fn map<U> } | <T: A + B> |
| Go | func (v Vec[T]) Map[U] | [T A | B] (union, не intersection!) |
| TypeScript | function map<T, U>(arr: T[], f) | T extends A & B |
| Kotlin | fun <T, U> Array<T>.map(f) | <T : A> + where T : B |
| Scala 3 | extension [T](arr: Array[T]) def map[U] | T <: A & B |
| Java | <T, U> U[] map(T[] arr, ...) | <T extends A & B> |
| Nova D145 | fn[T] []T @map[U] | [T A + B] (Rust-style +) |
Nova edge:
- Cleanest receiver syntax —
fn[T] []T @mapкороче Rustimpl<T> Vec<T> { fn map<U> }(2 nested blocks → 1 line). - Bound syntax без двоеточия —
[T Hash](D72) — параллель Novaname typeconvention (params, fields, let). - Multi-bound
+familiar — Rust audience узнаёт. - Protocol composition через
use— параллель D39 record-embed, единое правило. - Loud disambiguation —
E_BARE_TYPEVAR_NEEDS_PREFIX/E_PREFIX_SHADOWS_NAMED_TYPEявные, не silent miscompile. - Future-proof —
Q-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ет).
- D39 —
use Typeembed для records. D145 переиспользует pattern для protocol composition (Plan 101.4). - D53 —
type X protocol { ... }. D145 закрывает open question «Composition protocol’ов» через 101.4.
См. также
- D72 — bound syntax.
- D119.
- D39 —
useдля embed. - D53 — protocol decl.
- Plan 101 master
- 5 sub-plan’ов:
- 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, u8–u64, i8–i64 | 0 | std/runtime/defaults.nv |
f32, f64 | 0.0 | std/runtime/defaults.nv |
bool | false | std/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:
- Явный конструктор виден в
nova docи IDE. - Имена кодируют намерение (
User.new(name, email)vsUser.guest()). - Валидация инвариантов в момент создания.
- Эволюция типа: добавление поля заставляет обновить конструктор — 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() // []
mut buf = []u8.new(cap: 1024) // pre-alloc: ровно 1024 слота (D372-amend2)
// 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.
- D66 —
Selfв return type. - D131 — consume / fluent.
- D182 —
Selfrequirement. - 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_newreturn type unknown”) при компиляции нескольких файлов вместе в одной CU. Обход (принят): pre-sizing убран из desugar’а полностью — map-литералы строятся через голый.new()+ amortized growth в insert_new-цикле (perf-only регрессия, corretness не затронута).
Амендмент 2 —
Vec[T].new(cap int = 0)точный pre-alloc конструктор (решение владельца 2026-07-12). Каноничная предвыделяющая форма — default-аргумент, НЕ chain и НЕ arity-overload:mut v = Vec[T].new() // cap = 0, пусто, без аллокации (default) mut v = Vec[T].new(cap: 1024) // ТОЧНО 1024 слота, len = 0 (именованно — предпочтительно) mut v = Vec[T].new(1024) // то же, позиционно (легально)
new(cap)иcap(n)= ТОЧНАЯ ёмкость (без округления;new(cap)≡new().cap(cap), но одной аллокацией и одним вызовом). Предпочтительное написание pre-alloc; chain.new().cap(n)остаётся легальным.- Контраст с
reserve(n):@reserve(additional)— АМОРТИЗИРОВАННЫЙ рост, округляет ёмкость ВВЕРХ до степени 2 (8→16→32…) ради O(1)-amortized push;new(cap)/cap(n)— ровноcap, без округления. Три намерения:new(cap)= «дай ровно столько с рождения»,cap(n)= «переустанови ровно на n»,reserve(n)= «место ещё под n, можно с запасом».- Почему default-arg, а НЕ overload: одна функция
new(cap int = 0)заменяет 0-аргnew()— НЕ создаёт набор перегрузок → не триггерит[M-vec-new-static-arity-overload](см. поправку ниже). Применимо к типам с точной ёмкостью (Vec/[]T;HashMap/Set/WriteBuffer/StringBuilder/Queue— по мере миграции, семантикаcap(n)того типа). План внедрения — Plan 200 (зонтичный std-improvements). КОД внедрён 2026-07-12 (std/collections/vec/core.nv,Vec.new(cap int = 0)). Компиляторный дефект[M-vec-new-cap-default-arg-backfill](default-arg backfill на generic-static ctor не покрывалType[Args].method(...)/[]T.method(...)call-сайты) — ЗАКРЫТ вcallnorm.rs(try_normalize_callclassify-match расширен на turbofish/__array-Path static-receiver формы) + 3 hand-formatted ctor-call сайта вemit_c.rs. НЕ тот же класс, что[M-vec-new-static-arity-overload](arity-overload cross-wiring) — см. поправку ниже: тот дефект ЗАКРЫТ отдельно, вне зоны Plan 196.2.ПОПРАВКА 2 к Амендменту 1 (from_raw_parts → new-overload): ЗАКРЫТО 2026-07-12 (форс-фикс, Plan 200 П5). Складывание
from_raw_partsв 3-арг перегрузкуVec[T].new(ptr,len,cap)состоялось в коде (std/collections/vec/core.nv):fn Vec[T].new(ptr *mut T, len int, cap int) -> Self requires len >= 0 && cap >= len => { data: ptr, len, cap }— рядом сnew(cap int = 0). Дефект[M-vec-new-static-arity-overload]— ЗАКРЫТ, вне 196-зоны (неinfer_call_ret_c/W2, как предполагалось ранее — тот план остаётся про ДРУГОЙ баг-класс, PRE-mono class-C резолв). Корень был в ДВУХ co-located name-only (arity-blind) overload- резолвах, оба «первый-по-имени», игнорирующие арность: (1)compiler-codegen/src/callnorm.rs(Sigs::static_methods— default-arg backfill раньше ФИЛЬТРОВАЛ прочь любой(type,method)с >1 сигнатурой, т.е. просто НЕ бэкфиллил default для overloaded ctor; фикс — хранить ВСЕ overload’ы +pick_static_paramsдизамбигуирует поbind_call_args-совместимости на каждом call-site); (2)compiler-codegen/src/codegen/emit_c.rs— ветка «1b» (turbofish static-ctor call,Type[Args].method(...), ~emit_call строка 32577) резолвилаgeneric_type_methods[base].find(name)первым совпадением по имени, тогда как соседняя ветка «5b» (instance-method generic dispatch) уже имела арность/param-type дизамбигуацию ([M-138.2-generic-method-overload-mono], 2026-06); фикс — та же схема (арность → param-C-type →resolved_callees-span чекера) + per-overload__<paramtype>суффикс у mono-имени, портированные в ветку «1b». Гейты:vec_of_empty_panicneg-тест зелёный,nova test --full std/collections/vecбез cross-wiring,nova test --full std/collections(14/14) +std/checksums+std/crypto(используютstr.@bytes()через foldednew) зелёные, conformance single-CU 95/0. Целевая сигнатура (сложена): см. выше.
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 ro []T) | -> @ |
@extend_from(src ro []T) | -> @ |
@insert_from(i int, src ro []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 96 —
arr[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:
- Explicit-form дублирует тип-параметры — redundant.
Selfустойчив к переименованию типа (rename-safe).Selfявно говорит «возврат того же receiver-типа» — semantic clarity.- 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 форма документирована здесь.
Связь
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 |
Display | Display | std/prelude/protocols.nv |
Equal.eq(other Self) -> bool | Equal.@equal(other Self) -> bool | std/prelude/protocols.nv |
Compare.cmp(other Self) -> Ordering | Compare.compare(other Self) -> int | std/prelude/protocols.nv |
Hash.hash() -> u64 | unchanged | std/prelude/protocols.nv |
Rationale renames:
-ablesuffix convention — unified naming (Iterable/Equal/Compare/Hash/Display).Compare.compare -> int— единый стиль сstr.compare()(D178) и Cmemcmp/strcmp.Orderingsum-type удалён.Equal.@equal— явнее чемeq(Java convention). Написание приведено к действующему 2026-09-16 (окно Карины, приказ владельца): D183 писался до Plan 108.4, где@у методов протокола стал ОБЯЗАТЕЛЬНЫМ, аequalsстал@equal(таблица переименований —04-effects.md, раздел «Stdlib migration (Ф.3)»). Прежнее написание здесь было не вариантом, а ошибкой: такой метод не соберётся.Display→Display— действие через-able, не имя-noun.
Равенство и порядок: протоколы ОРТОГОНАЛЬНЫ
НОРМА. Equal и Compare — независимые протоколы: Compare НЕ встраивает Equal.
Тело по умолчанию висит на самом Equal, и тип, объявивший один лишь @compare,
получает @equal через него:
export type Equal protocol {
@equal(other Self) -> bool => @compare(other) == 0
}
export type Compare protocol {
@compare(other Self) -> int
}
Это ровно то, что стоит в дереве: std/src/prelude/protocols.nv:83-105, с комментарием
«Total order. Orthogonal to Equal (no embed); Equal gets its default body via coercion
to Compare». Разбор формы и довод — ниже, в разделе «D183 amendment — Plan 91.8a.2
part 1: protocols refactor (orthogonal)».
ОТМЕНЕНО амендментом Plan 91.8a.2, часть 1 (протоколы ортогональны). Прежняя форма выглядела так:
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с телом по умолчанию overrides embedded default, и implementer писал только@compare.Чем отменено: амендмент того же блока (Plan 91.8a.2, часть 1, 2026-05-29), решение Q6 — каждый протокол stand-alone, зависимость между ними выражается coercion’ом и видна при чтении декларации. Форма внутри этой цитаты набрана как БЫЛО, включая написание
equalsбез@: с Plan 108.4 так писать нельзя,@у методов протокола обязателен.
Правка редакционная (2026-09-16, окно Карины, форма задана интегратором). Раздел назывался «Compare embeds Equal» и открывался отменённой формой, набранной как норма, — амендмент стоял на 130 строк ниже, и читатель, попавший сюда поиском, брал отменённое за действующее (замер: так и случилось в этот день). Норма не менялась и rev не выдавался: текст приведён в соответствие с решением, которое уже принято.
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
@equal(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
Orderingsum-type удалён изstd/prelude/core.nv.Less/Equal/Greaterexports удалены изstd/prelude.nv.std/sort.nvsort_by(cmp fn(int, int) -> int)— memcmp-style convention.PRELUDE_VERSIONbumped 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.@equaloverrides embeddedEqual.@equaldefault.- Codegen synthesis для defaults: followup
[M-91.8a.2-default-codegen]. Уточнено 2026-09-16 (окно Карины): здесь стояло, что implementer пишет default-method явно ради совместимости, и образец был записан какequals(o) => @compare(o) == 0. Неверно дважды: метод зовётся@equal(Plan 108.4 сделал@обязательным), а явно писать его больше не требуется — замер трёмя пробами в разделе «Известные ограничения» ниже. Действующий образец тела по умолчанию:@equal(o) => @compare(o) == 0.
Известные ограничения / followups
- Codegen synthesis (
[M-91.8a.2-default-codegen]) — ЗАЯВЛЕНИЕ ОПРОВЕРГНУТО ЗАМЕРОМ 2026-09-16 (окно Карины). Здесь стояло: тип с@compareи без@equalкомпилируется только при явном@equal. Проба из трёх клеток на оракуле: (а)type Money { cents int, note str }с одним лишьfn Money @compare(other Money) -> int => @cents - other.cents— СОБИРАЕТСЯ, иMoney{5,"x"} == Money{5,"y"}даётtrue, то есть равенство идёт через@compare, а не пополево; (б) тот же тип БЕЗ@compareдаётfalse— значит структурное равенство полеnoteВИДИТ, и «true» в (а) другого объяснения не имеет; (в) первая редакция пробы (оба поля равны) ничего не различала и отброшена. НАЗВАННЫЙ ПРОБЕЛ: проба судит ПОВЕДЕНИЕ, а не механизм: синтез ли это тела по умолчанию или прямой фолбэк operator-dispatch на@compare— чтением кода не установлено. Но старое утверждение ложно в любом случае, а именно его читают как норму. - Operator dispatch (D363, Plan 91.8b) — ЗАКРЫТО, проверено 2026-09-16 (окно Карины). НОРМА одна и живёт в
03-syntax.md(таблица операторов):a == b→@equal(b)через протоколEqual. Переименование сделано и в обоих компиляторах: оракул диспатчит на"equal"(compiler-codegen/src/codegen/emit_c.rs:36497), Карина — наEQUAL_METHOD(novac/src/builtins/builtins.nv:99, то же имя). Прежний текст здесь утверждал, что==всё ещё идёт в@eq(D46) и implementer пишет оба метода, — запись о долге пережила сам долг и читалась как норма, противореча таблице операторов. - Structural
==для mono’d generic-sum + Result ✅ (Plan 153.3, commit1cc82de5): дефолтное структурное==(tag + payload, без user@equal/@compare) теперь покрывает мономорфизированные generic-sum (Foo[int].A(1) == A(1)) и Result (NovaRes_*). Раньше оба тихо деградировали в pointer-identity: legacysum_schemaskeyed 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_search→Result[int,int]) дефолтилE=strи не совпадал по типу с LHS → CC-FAIL. Фикс codegen-local: в==-NovaRes_-ветке, если типы операндов расходятся и одна сторона — голый result-ctor, она переэмитится под concreteNovaRes_<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.
- D39 —
useembed. - D58 — structural typing.
- D72 — bounds.
- D109 — split policy (Hash не embeds Equal; Compare embeds Equal в D183).
- D178 —
str.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 {
@equal(other Self) -> bool
}
type Compare protocol {
use Equal
@compare(other Self) -> int
@equal(other Self) -> bool => @compare(other) == 0 // override of embedded default
}
Стало (91.8a.2 part 1) — canonical:
type Equal protocol {
@equal(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.@display default body (было fmt)
type Display protocol {
@display(sb StringBuilder) {
sb.append(str.from(@))
}
}
- Primitives — works via primitive
Nova_int_to_stretc. - User types — implementer пишет @display явно (perf) OR provides
fn str.from(MyType) -> stroverload.
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):- Explicit
fn T.from(value_type)→ win - Blanket identity — match только если
value_type == T - D77 auto-derive из From[value_type] chain
- Error E_NO_FROM_IMPL
- Explicit
- 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_targetsregistries (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) реализует:
- 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.equal(other)— direct lookup only)
- Bound contexts (
- Devirtualization pass — coercion form
let cmp Protocol = @становится type ascription + direct call при synthesis для concrete T. Result: same C output что direct form. - Cache per compilation unit:
HashMap<(TypeId, MethodName), SynthFnDecl>. - From blanket mono — extension Plan 101 mono pass на
fn[T] T.methodstatic на generic T. - Error diagnostics: E_SYNTH_CYCLE, E_SYNTH_AMBIGUOUS, W_DEVIRT_FAILED, E_BLANKET_IDENTITY_OVERRIDE.
До части 2 — implementer пишет default body methods явно (boilerplate compatibility). Это работает но дублирует код.
Связь
- D183 (part 1) — base D183.
- D26 — prelude.
- D58 — structural typing.
- D77 — From/Into 4-way auto-derive.
- Plan 91.8a.2.
⚠️ См. 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)
Проблемы:
- Невидимая мутация behavior: добавление протокола в одном модуле тихо добавляет методы всем типам подходящей сигнатуры.
- Reader-hostile: глядя на
type User, нельзя понять что у него есть методgreet(он синтезирован). - Ambiguity: два протокола с methods одинакового имени и default bodies — порядок resolution не детерминирован.
- 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 проверяет:
- E_UNKNOWN_PROTOCOL —
Pне найдено как type name. - E_IMPL_NOT_PROTOCOL —
Pнайдено, но не protocol kind. - E_IMPL_MISSING_METHODS — T не provides метод P:
- не имеет explicit
fn T @method(...), - и default body P.method не synthesizable для T (зависит от другого метода которого T не имеет).
- не имеет explicit
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 => ...
// @equal auto-derived через Equal.@equal 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 +.
АМЕНДМЕНТ 2026-09-18 (решение владельца): СПИСОК НЕПУСТ. Аннотация несёт МИНИМУМ ОДИН протокол. Обе пустые формы — ОШИБКА:
#impl() // ошибка: пустой список #impl // ошибка: списка нет вовсе #impl(Display) // нормаПочему это амендмент, а не уточнение. Блок нигде не говорил о пустом списке ни да, ни нет, и это был НЕДОСМОТР, а не намеренная свобода: смысл аннотации — ОПТ-ИН автора типа (раздел «Gate semantics» выше), а опт-ин ни во что не открывает ни одних ворот и ничего не утверждает. Пустая аннотация — не «нейтральный случай», а строка, которая выглядит решением автора и им не является; читатель такого кода решит, что опт-ин есть.
Диагностика —
E_IMPL_NO_PROTOCOLS, в семье type-уровневых кодов этого блока (E_UNKNOWN_PROTOCOL,E_IMPL_NOT_PROTOCOL,E_IMPL_MISSING_METHODS). Обе пустые формы дают его же: разница между#impl()и#impl— в написании, а не в том, что автор сказал, и два кода на одну ошибку заставили бы читателя искать разницу, которой нет.Как нашлось. Окно Карины реализовывало чтение
#implи спросило агента-читателя перечень ВСЕХ форм (правило владельца того же дня). Агент ответил, что про пустой список и про#implбез скобок спека не говорит НИЧЕГО — и этот НАЗВАННЫЙ ПРОБЕЛ оказался ценнее половины ответов: без него каждый реализующий решал бы за спеку сам и по-разному.
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:
- Each
Plookup вself.types. None → E_UNKNOWN_PROTOCOL. - Kind check — must be
TypeDeclKind::Protocol. Иначе → E_IMPL_NOT_PROTOCOL. - 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], coercionlet 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 declaresmut @method()and the implementing type declaresfn T @method()(ro receiver), type-checker emitsE_PROTO_IMPL_RO_FOR_MUTat 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.
⚠️ D186 AMENDED by Plan 221.1 (2026-07-21) [M-interp-numeric-fallback-silent-garbage] — closes an ENFORCEMENT GAP in the “Gate semantics” table above:
Interpolation "${u}" | ✅ даwas already normative (interpolation requires#impl(P)same as bare call), but the codegen path for the built-in canonicalDisplayprotocol specifically (emit_c.rs’s bare-${x}lowering) had NO corresponding compile-time check — a user record/sum/namedtuple/newtype interpolated bare ("${p}", not"${p:?}") WITHOUT#impl(Display)compiled silently and reached a LAST-RESORT numeric-cast fallback (nova_int_to_str((nova_int)(v)), emit_c.rs ~42903) — printing the value’s heap address as a decimal integer instead of erroring per the gate this section already mandates.Fix: new type-checker diagnostic
E_INTERP_NO_DISPLAY(types/mod.rs::check_interp_no_display, called from thef1_expr_innerExprKind::InterpolatedStrarm) fires when a bare-Display interpolated expression’s static type is a non-genericRecord/Sum/NamedTuple/Newtypedeclared type that has neither (a) an explicit@displaymethod, (b) a gate-satisfied#impl(Display)auto-derive synthesis (already registered in the checker’ssynth_methodsoverlay before this pass runs — same predicatefind_method_decl(T, "display")covers both), nor (c) the pre-existing D410str.from(T)/T.to_str()fallback route. rustc precedent:Displayis never auto-derived; a missing impl is a compile error, not a best-effort runtime fallback.Scope — deliberately narrow (avoids overreach into adjacent, already- handled or mono-time-only concerns):
- generic type-parameters in scope (
fn f[T](x T)) — an UNBOUNDEDThas no contract to check pre-monomorphization (D72: structural at use); a BOUNDED[T Display]is decided by the checker from the bound itself — see D464 (amended 2026-08-16: the earlier wording «bound-satisfiability is a mono-time concern» over-generalised the unbounded case);- generic (parametrized) declared types —
Vec[T], builtinOption/Result, a userBox[T]— routed viatry_generic_mono_interp_dispatch/ the Option-ResultDeclaredBodyspecial-case inemit_c.rs, neither visible to this pre-mono checker pass;- typed pointers (
&v,*p,e as *T) — separately covered byE_PTR_NO_DISPLAY_USE_DEBUG_STR(Plan 91.14/118, D216 §15);*TDebug auto-derive remains a distinct open item ([M-91.14-ptr-auto-derive]), untouched by this amendment;${x:?}(Debug format-spec) is handled by a SIBLING diagnostic,E_DEBUG_PRINTABLE_NOT_IMPLEMENTED(check_interp_no_debug) — see the D229 amendment below. ⚠️ CORRECTION (same-day follow-up, coordinator repro): an earlier revision of this bullet claimed Debug synthesis is unconditional (gate_on_impl=false) and therefore always available with no#implat all — that claim was WRONG, verified false by a standalone runtime repro (println("${d:?}")on a plain record with ZERO#implannotations printed a raw heap address on both Windows and Linux). See D229 amendment for the actual mechanism and why thegate_on_impl=falseflag never accomplishes what its comment claims for Debug specifically.Fixture:
spec_tests/conformance/neg/d186_interp_no_display_neg.nv(pin),spec_tests/conformance/d186_interp_no_display_pos.nv(both legitimate escapes: explicit#impl(Display), and${x:?}with no#implat all). One real pre-existing occurrence found + fixed in std during the pre-enforcement audit:std/src/data/sql.nv’sSqlValue @expect_*error-message interpolations ("expected I, got ${@}") —SqlValue(a closed sum, no#impl(Display)) was silently printing its own heap address inDbError.Constraintmessages; changed to${@:?}(Debug).
D200. Associated constants — const Type.NAME (вне тела типа)
Plan 114.4 Ф.2 (extracted from Plan 114 Ф.10 safety hatch). Status: 🆕 draft (финализируется в Ф.4).
AMEND (решение владельца 2026-07-23): каноническая форма — ВНЕ тела типа,
const Type.NAME <Тип> = <значение>. In-body форма (constвнутриtype X { … }body — весь исходный текст блока ниже) РЕТРАКТИРОВАНА до финализации. Причина: in-body ставила статики В ТЕЛО, вперемешку с полями инстанса, тогда как ВСЁ остальное, привязанное к типу, в Nova объявляется ВНЕ тела через квалификаторType.— конструкторыfn Type.new(…), методыfn Type @name(…). Out-of-body const восстанавливает симметрию (тело типа = ТОЛЬКО layout инстанса; всё с квалификаторомType.— снаружи) и ближе к rustc-эталону (implдержит консты+методы отдельно от полей структуры; у Nova нетimpl→ свободностоящаяconst Type.X, как свободностоящаяfn Type.x). «Одна дверь»: остаётся ОДНА форма — out-of-body. Миграция 0 сайтов (in-body формой никто не пользовался: греп std/nova-http/examples = 0).Каноническая запись:
type Config value { name str, timeout Duration } // тело = ТОЛЬКО поля инстанса const Config.VERSION int = 2 // ← статик, ВНЕ тела const Config.MAX_PEERS int = 1024 export const Config.PROTOCOL str = "v2" // export — cross-module Config.VERSION // ✓ 2 (namespace-доступ)Грамматика = обычный module-const
const <имя> [<Тип>] = <значение>, где<имя>квалифицировано типом:const Config.VERSION int = 2.УТОЧНЕНИЕ 2026-08-29 —
<Тип>НЕОБЯЗАТЕЛЕН, и дом этого правила НЕ ЗДЕСЬ. Раньше эта строка цитировала грамматику только в типизированном виде (const <имя> <Тип> = <значение>), потому что у АССОЦИИРОВАННЫХ констант тип в примерах стоит везде. Читалось это как «тип обязателен», и так его прочло окно 274, не найдя отдельного D-блока на голую форму и сочтя это дырой в спеке. Дыры нет: домconst— D184, и голая форма записана там нормативно («const X = exprпринимает только constexpr-eligible RHS»), с примерамиconst MAX_PAYLOAD = 4096,const TIMEOUT_SEC = 60 * 5,const GREETING = "hello"; обе формы стоят там рядом —const ORIGIN Point = { … }с типом иconst COMPUTED = make_point(7, 14)без. Замер на день уточнения: вstd/srcголыхconst— 157, типизированных — 61, то есть основная форма именно голая. Компилятор принимает обе (проверено сборкой). Здесь исправлена ЦИТАТА, а не правило: правило не переезжает, иначе уconstстанет два дома и они разойдутся на первой правке. Sum-type и generic — так же (const Status.VERSION int = 2,const Box.TAG int = 0; T-dependent —const Box[int].SIZE …синтаксис уточнить на Ф.4).Семантика — БЕЗ ИЗМЕНЕНИЙ (меняется только место объявления): §Семантика ниже целиком в силе — zero-storage в инстансе, top-level C-symbol
Type_NAMEв.rodata, namespace-only доступ (instance.NAME→E_CONST_INSTANCE_ACCESS), strict-constexpr RHS, SCREAMING_SNAKE-lint. Парсер:[M-assoc-const-out-of-body-syntax]— сейчасconst Type.NAMEдаётexpected type, got '.'. Составные значения ({ code: 200 }дляStatusCode.OK) — ОРТОГОНАЛЬНЫЙ codegen-пробел[M-d200-assoc-const-composite-value], с этой сменой поверхности не связан (нужен при любой форме).
AMEND (финализация парсера [M-assoc-const-out-of-body-syntax], окно №66, 2026-07-24): реализовано целиком. Парсер (
parser::parse_const_decl) принимаетType.NAMEкак qualified module-level const name; резолв (imports::attach_out_of_body_assoc_consts, после import-flatten, ДО type-check) переносит qualified decl вTypeDecl.assoc_consts— тот же const-table путь, что и in-body форма (namespace-доступ,E_CONST_INSTANCE_ACCESS,.rodataemission — БЕЗ изменений downstream). In-body форма отвергнута целиком (не deprecated) —[E_CONST_IN_BODY_RETRACTED]с migration-hint на любую попыткуconst NAME = valueвнутриtype X { … }body: «одна дверь» реализована на парсер-уровне, не только текстом амендмента. Cross-file (тип в одном peer-файле folder-module,const Type.NAME— в другом) работает (merge — над плоским flatten’ённым item-списком). T-dependentBox[int].SIZE— синтаксис по-прежнему followup, парсер такую форму не распознаёт (падает раньше, на[). Фикстуры:spec_tests/conformance/d200_associated_const.nvмигрирован на out-of-body целиком (δ0 по PASS-результатам — 7 test-блоков, тот же набор assert’ов + новый namespace-only regression test); pos-комбоStatusCode.OK/Rect.UNIT(составное значение, №60) работает через out-of-body форму без изменений в codegen. Neg:nova_tests/plan114/neg/d200_oob_instance_access_neg.nv(E_CONST_INSTANCE_ACCESSна out-of-body decl),d200_in_body_const_retracted_neg.nv(E_CONST_IN_BODY_RETRACTED); мигрированы на out-of-body синтаксис (тот же код ошибки)plan114_4_1_literal_includes_assoc_neg.nv,plan114_4_1_instance_access_neg.nv,d200_composite_ctor_call_neg.nv,d200_composite_str_field_neg.nv.
AMEND (финализация [M-d200-assoc-const-composite-value], 2026-07-23): codegen дореализован до составных (record-литерал) constexpr-значений — «only scalar» драфт-оговорка СНЯТА в границах ниже. Пример переписан out-of-body формой (окно №66, парсер финализирован — см. AMEND выше; исходно этот блок демонстрировал in-body форму, до её ретракции):
type StatusCode value { priv code int } const StatusCode.OK StatusCode = { code: 200 } // ✓ top-level struct-init в .rodata const StatusCode.NOT_FOUND StatusCode = { code: 404 } StatusCode.OK.code // ✓ 200 — namespace + field read type Rect value { origin Point, size int } const Rect.UNIT Rect = { origin: { x: 0, y: 0 }, size: 1 } // ✓ вложенная запись Rect.UNIT.origin.x // ✓ 0 — рекурсивный field-chainГраницы (не расширено):
- Только скаляры + вложенные записи-из-скаляров.
str-поле внутри составного значения → честный отказ с диагностикой (nova_strна file-scope нуждается в отдельном адресуемом.rodata-буфере байт, которого этот путь не строит) —[M-d200-assoc-const-composite-value]в тексте ошибки. Скалярныйstr(не внутри записи) — работает, как и раньше (const PROTOCOL str = "v2"— отдельная ветка emit).- Конструктор-вызов НЕ constexpr.
const OK StatusCode = StatusCode.mk(200)→E_CONST_NOT_CONSTEXPR(runtime dispatch, не литерал) — не расширяется.- Grammar/место объявления на момент ЭТОГО амендмента (2026-07-23) было БЕЗ ИЗМЕНЕНИЙ (in-body, как и скалярный случай) — синтаксис-поверхность сменилась ПОЗЖЕ, окном №66 (AMEND выше); семантика/границы этого блока переносятся дословно на out-of-body форму. Codegen: record-литерал эмитится designated-initializer’ом (
{ .field = <value>, ... }) поверх уже зарегистрированнойrecord_schemas/type_aliasesсхемы типа; рекурсия на вложенные поля идёт через тот же constexpr-emitter (emit_const_expr_typed), так что reference на другой top-level/assoc const в поле резолвится тем же путём, что и скалярный RHS. Self-referential составное значение (константа ссылается на СВОЙ ЖЕ тип) требует, чтобы struct-typedef типа был emit’нут ДО const-инициализатора — assoc-const emission-loop поэтому эмитится ПОСЛЕ struct/value-record body своего типа (было — до).
AMEND (implementation-note, [M-assoc-const-chained-method-call-p67], окно №73, 2026-07-24): цепной method-вызов НАПРЯМУЮ на голом assoc-const —
Type.CONST.method(), без промежуточной привязки в локаль (StatusCode.NOT_FOUND.into_response(),StatusCode.OK.code()) — резолвится КАК метод на значении const: 3-сегментный Path-call (Type.CONST.method()) эквивалентен bind-then-call (ro x = Type.CONST; x.method()). Уточнение реализации, не смена семантики D200: namespace- доступ и границы (скаляр/составное значение) выше — без изменений; это закрывает единственный оставшийся разрыв между «const как значение» (работало) и «метод на этом значении без привязки» (ранее — ICE[P67-LEGACY] Path call return type unknown, парсер сворачивает цепочку в одинExprKind::Path([Type, CONST, method]), симметрично уже закрытому 2-сегментному top-level-const кейсуBUDGET_MS.to_millis()). Фикстура:spec_tests/conformance/assoc_const_chained_method_call.nv.
Amend D200 (Plan 157, §157, 2026-07-31):
ro Type.NAME [Тип] = <выражение>— associated ro-value (не constexpr). Владелец лично предложил форму («черезroсделать константу», конкретноro BigInt.ZERO BigInt = { sign: Zero, limbs: []u32.new() }) как ответ на измеренный факт:const Type.NAMEв принципе не может держать поле с кучевой аллокацией (Vec-поле —E_CONST_REFERS_NON_CONSTEXPR/E_CONST_NOT_CONSTEXPR, strict-constexpr RHS — п. «Семантика» №1 выше, без изменений). До этого амендмента такая форма парсилась (дефолтная дот-нотацияparse_patternсворачиваетType.NAMEвPattern::Variant), но не была узнана НИГДЕ дальше по пайплайну — декларация тихо терялась (CC-FAIL «undeclared identifier» на use-site) либо (при call-подобном использовании) падала в ICE[P67-LEGACY] Path call return type unknown([M-associated-ro-const-path-call-ice], №157 в 221.1).Каноническая запись:
type BigInt value { sign Sign, limbs []u32 } export ro BigInt.ZERO BigInt = { sign: Zero, limbs: []u32.new() } // ✓ runtime-init BigInt.ZERO // ✓ read — та же namespace-семантика, что у constГрамматика:
[export] ro Type.NAME [Тип] = <выражение>на module-level — ровно тот же qualified-name синтаксис, чтоconst Type.NAME(D200 AMEND окно №66), сroвместоconst.Чем
ro Type.NAMEотличается отconst Type.NAME(когда что выбирать):
const Type.NAMEro Type.NAMEInitializer Strict constexpr (literal / арифметика над literals / record-литерал из constexpr-полей / ссылка на другой const— п. «Семантика» №1)Любое выражение — конструктор-вызов, кучевая аллокация ( Vec.new(),HashMap.new(), …), вызов обычной (неconst) fnИнициализация Compile-time ( .rodata-литерал,static const T Type_NAME)Runtime, ОДНОКРАТНО — переиспользует существующую машину module-level ro NAME = EXPR(«eager once-init», Plan 152.4/emit_lazy_const), КАЧЕСТВЕННО ТА ЖЕ, только keyed по квалифицированномуType_NAME, а не голому имени. Инициализация происходит ДОmain(топологически упорядоченныйnova_consts_init()) — порядок и цикл по D184, амендмент 2026-09-30 (E_MODULE_INIT_CYCLE), НЕ лениво по первому обращению — «once» ⇒ единожды за весь запуск, не per-accessInstance access / namespace / export / record-literal-запрет Без изменений — п. «Семантика» №2-6 целиком в силе для ОБЕИХ форм (обе живут в одном и том же TypeDecl.assoc_consts, отличаются только internal-флагомis_lazy_ro)То же Переприсваивание Type.NAME = …Ошибка (нет mut Type.NAMEформы — п. «Modifier-conflicts» №6)Ошибка, тот же код E_LOCAL_NOT_MUT, что у reassignment обычногоro-локала (симметрия: ассоциированное значение фиксировано СИЛЬНЕЕ локала — у локала есть escape-hatchmut x = …, уType.NAMEего нет вовсе)Constexpr-eligible RHS написан через ro— E_RO_FOR_CONSTEXPR_PREFER_CONST— та же строгая partition-политика, что у bare module-levelro/const([M-114.4-strict-partition], Plan 148 Ф.3): «одна дверь» — если RHS constexpr-eligible, он ОБЯЗАН бытьconst Type.NAME,ro Type.NAMEв этом случае — ошибка, не тихий разрешённый дубльВыбор:
const Type.NAME— когда значение можно вычислить на этапе компиляции (числа, строки-скаляры, вложенные записи из скаляров).ro Type.NAME— когда значению НУЖЕН рантайм (конструктор, кучевая аллокация, вызов не-const функции) — типовой случай: value-record с полемVec/HashMap/Set/StringBuilder/произвольным heap-record.Codegen: переиспользует машину module-level
roБЕЗ дублирования (emit_lazy_const, тот же путь, что уже эмитит eager-init global + topo-sort зависимостей для barero NAME = expr); квалифицированный C-symbol (Type_NAME, тот же формат, что уconst-эмиссии) передаётся И как ключ Nova-уровня, И как C-qualifier — коллизий с реальными Nova-идентификаторами не бывает (голый Nova-идентификатор не содержит_на стыке type/const в этой позиции по построению квалификации). Эмиссия — В ТОЙ ЖЕ фазе пайплайна, что bare module-levelro(после generic-type-defs, чтобы кучевое generic-поле типа[]u32/Vec[T]уже имело typedef), НЕ в той же точке, чтоconst-эмиссия (сразу после struct-тела типа) — иначе кучевой generic-C-тип мог бы быть недоступен.Известный узкий разрыв (НЕ этим амендментом; см. отчёт волны Plan 157):
emit_expr_c_type’s финальный P67-LEGACY Path-fallback резолвит НЕАННОТИРОВАННЫЙ локал-биндинг (ro z = Type.NAME) через ГОЛЫЙ последний сегмент имени в глобальной (не type-qualified) таблице — если ДВА разных типа имеют associatedconst/roс ОДИНАКОВЫМ последним сегментом имени где-то в одном compile unit (например, чужойType.ZEROрядом с уже существующим bare top-levelexport const ZERO Duration = {...},std/src/time/duration/core.nv:82, транзитивно тянущимся в КАЖДЫЙ CU) — инференс типа локала может ошибочно выбрать НЕ ТОТ тип. Это ПРЕДшествующий дефект (воспроизводится идентично и для уже отгруженногоconst Type.NAME), не специфичный дляro-формы; явная типовая аннотация на биндинге (ro z Type = Type.NAME) — обходной путь. Отдельно зарегистрирован разрыв в юнбаунд-4-сегментной цепочкеType.NAME.field.method()(унаследованный от того же класса, что уже известный 3-сегментный[M-assoc-const-chained-method-call-p67], но сейчас покрывающий толькоType.CONST.method(), НЕType.CONST.field.method()) — тот же ICE[P67-LEGACY] Path call return type unknown, воспроизводится идентично для уже отгруженногоconst Type.NAME, не специфичен дляro.Фикстуры:
spec_tests/conformance/plan157_ro_assoc_value.nv(pos — namespace access / выражение / match / eager-once-init identity),spec_tests/conformance/neg/plan157_ro_assoc_reassign_neg.nv(neg —E_LOCAL_NOT_MUTнаType.NAME = …),spec_tests/conformance/neg/ plan157_ro_assoc_prefer_const_neg.nv(neg —E_RO_FOR_CONSTEXPR_PREFER_CONSTна constexpr-eligible RHS написанный черезro).
АМЕНДМЕНТ 2026-09-18 (решение владельца, реестр 221.1 №701 (а)): ЭКСПОРТИРУЕМАЯ ФОРМА — ТОЛЬКО КВАЛИФИЦИРОВАННАЯ; ГОЛЫЙ
export ro NAME = …ОТВЕРГАЕТСЯ (E_EXPORT_RO_UNQUALIFIED).До этого амендмента блок описывал ровно одну экспортируемую форму —
[export] ro Type.NAME [Тип] = …, — а про голую на уровне модуля не говорил НИЧЕГО. Молчание спеки читалось как разрешение, и компилятор его принимал: словоexportперед модульнымroразбиралось и ТЕРЯЛОСЬ в парсере. Владелец модуля собирался молча, а импортёр падал сundefined identifierна месте использования — то есть отказ приходил ЧУЖОМУ коду, в другом файле и без указания на причину.Почему форма отвергается, а не чинится. Научить её экспортироваться значило бы завести ВТОРУЮ форму записи для одного и того же. Цену второй формы платит не тот, кто её добавил, а каждый читатель языка: он обязан знать обе и помнить, чем они различаются. Замер, снятый перед решением: в дереве (std, spec_tests, novac, examples) голая экспортируемая форма встречается НОЛЬ раз — запрет не ломает ни одной строки, он закрывает дверь, в которую никто не ходил, но через которую уходили символы.
Что НЕ затронуто. Приватный модульный
ro NAME = …законен и остаётся — в std он живёт десятками (fs.nv,addr.nv,unicode/category.nv). Отвергается ровно СОЧЕТАНИЕexport+ голое имя.Диагностика обязана называть обе дороги: квалифицированную запись (канон) и «снять
export», если значение задумано модульно-приватным.Фикстуры:
spec_tests/conformance/neg/d200_export_ro_unqualified_neg.nv(neg),spec_tests/conformance/standalone/d200_export_ro_private_ok.nv(контроль — приватный модульныйroработает; без него запрет был бы неотличим от «модульныйroсломали вообще»).ЗАМЕР, НАЙДЕННЫЙ ПРИ ПОСТРОЙКЕ КОНТРОЛЬНОЙ КЛЕТКИ И НЕ ЗАКРЫТЫЙ ЗДЕСЬ (реестр 221.1 №701 (б)): у самой КАНОНИЧЕСКОЙ формы
ro Type.NAMEсейчас не остаётся ни одной рабочей записи. Рантаймный инициализатор (ro Cfg.NAME int = f()) не собирается —lld-link: undefined symbolна функцию-инициализатор, в ОДНОМ файле и без всякого экспорта; а constexpr- инициализатор запрещён правилом строгого раздела выше (E_RO_FOR_CONSTEXPR_PREFER_CONST), которое требует писатьconst. То есть то, что правило разрешает, компилятор не собирает, а то, что собралось бы, правило запрещает. Форма, объявленная здесь канонической, нерабочая — это чинится отдельно и НЕ этим амендментом.
Что
⚠️ Ниже — исходный in-body текст, РЕТРАКТИРОВАН амендментом выше (форма
constв теле типа заменена наconst Type.NAMEвне тела). Семантика (storage/access/constexpr) переносится дословно; меняется только синтаксис объявления.
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
Семантика
- Strict constexpr — RHS должен быть literal-eligible.
- Zero storage в instance. Codegen не emit’ит const-field в struct
layout. Каждый const-field живёт как top-level C-symbol
Type_FieldNameв .rodata. - Namespace access only.
Type.NAMEresolution через type’s const-table.instance.NAME→E_CONST_INSTANCE_ACCESS. - Не указывается в record literal. Указание →
E_CONST_FIELD_IN_LITERAL. export constfield — publicly accessible cross-module.- Modifier-conflicts:
mut const/const mut→E_CONST_MUT_CONFLICT.ro const/const ro→E_CONST_RO_REDUNDANT.consume const→E_CONST_CONSUME_CONFLICT.
- 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 fncalls с 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. ResolutionType.FieldName→ C-symbolType_FieldName. - Generic T-dependent: per-mono symbol naming coherent с existing generic-fn mono (Plan 70.5). Emit при каждой monomorphization.
export constfield: public C-symbol visibility.
Сравнение с mainstream
| Язык | Синтаксис | Storage |
|---|---|---|
| Java | static final int VERSION = 2; (внутри class) | top-level C-static |
| Rust | impl Config { const VERSION: i32 = 2; } | top-level |
| Kotlin | companion object { const val VERSION = 2 } | companion slot |
| Swift | struct Config { static let version = 2 } | type-metadata |
| TS | class Config { static readonly VERSION = 2 } | class-static |
| Nova | type 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.
- D199 —
const fn(могут использоваться для assoc const RHS). - D27 —
[N]Tarrays.
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) —
ptrbuilt-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*Ttype system (Plan 118 D216) — no compiler special-case required. Migration:ptr→*();0 as ptr→0 as *();type X(ptr)→type X(*()).nova checkemitsE_TYPE_UNKNOWN(typeptris removed — use*()…) onptr/nova_ptrin 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)*uninit T— pointer to possibly-uninit T (pointee contracts off; pointer itself non-null — nullable =Option[*uninit T], Plan 138.5 §1/§V2.4; §10a rename Plan 174.5 2026-07-11: was*unsafe T)⚠️ AMENDED by Plan 118 (D216) —
ptrredefined astype ptr Option[*unsafe ()]newtype над nullable unsafe void pointer (D216 §11). ABI preserved (singlevoid*); semantics formalized as nullable Option (NPO emits NULL).null ptrliteral 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-generictype X(ptr)). См. §«Generic opaque handle» ниже. Closes[M-115-newtype-constructor-generic].⚠️ AMENDED by Plan 118 Ф.5.7 (A23) 2026-06-02 —
null ptrliteral 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 ptrliteral становится 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.
Три компонента:
ptrbuilt-in primitive type — opaque pointer-sized integer, ABI-эквивалентенvoid*в C.- Tuple-by-value returns в
external fn— multi-value через struct-return calling convention. - 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 не имеет
*pderef-операции (никогда не было), нет 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 * 2—E_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 SomeRecord — E_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_ptrstruct (tag + value) — НЕ ABI-совместим с rawvoid*из C library. FFI shim пришлось бы оборачивать pointer’ы в Option struct’у — лишний overhead + struct return convention вместо register return.null ptr= bitwise 0 = идентично CNULL→ 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/dev/simplifications.mdдля migration tracking.
Type-checker rules
| Операция | Результат | Diagnostic |
|---|---|---|
null ptr | Ty::Ptr | — |
ptr == ptr / ptr != ptr | bool | — |
ptr == null ptr | bool | — |
ptr as u64 / ptr as i64 | integer | — |
u64 as ptr / i64 as ptr | ptr | — |
ptr as ptr | ptr | no-op |
ptr + N / ptr - ptr / etc. | error | E_PTR_ARITHMETIC_BANNED |
ptr.field / ptr.method() | error | E_PTR_NO_MEMBER (нет деf членов на opaque) |
int as ptr (для int = i64-style) | ptr | — (transparent через i64 path) |
ptr as int | int | — |
ptr в record-field | OK | — (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:%rdxregisters. Bigger → caller passes hidden out-pointer в%rdi. - AArch64 (macOS ARM64, Linux ARM64): structs ≤ 16 bytes →
X0:X1registers. Bigger → hidden out-pointer. - Win x64 MSVC: structs ≤ 8 bytes →
RAX. Bigger → hidden out-pointer вRCX, shifting all other args.
- Sys V AMD64 (Linux, macOS x86_64): structs ≤ 16 bytes (2 GPR) →
return через
- 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.
- Primitives (
- Прохибиции (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.0— D52 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_Region ≡ nova_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:
| Pattern | Use case | Trade-offs |
|---|---|---|
D126 external type X | stdlib 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 in | Universal; 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()—ptropaque.E_NULL_LITERAL_REQUIRES_PTR—null Tгде T ≠ ptr (V1 ограничение; Plan 118 expand для*T).E_PTR_CAST_INVALID_TARGET—ptr as Tгде T ≠ {i64, u64, int, ptr} — string/float/bool casts не имеют semantic meaning для opaque pointer.
Implementation notes
- Parser добавляет
"ptr"вis_primitive_typeallowlist (дляptr.method/ static-dispatch namespace).null ptrliteral — special case вparse_atom/parse_primary. - Type-checker добавляет
Ty::Ptrvariant;ty_of_refmapping"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 type | Typed wrappers |
|---|---|---|
| Rust | *mut c_void / *const c_void | struct H(*mut c_void) |
| Zig | *anyopaque / ?*anyopaque | const H = opaque {}; *H |
| Go | unsafe.Pointer | type H = unsafe.Pointer |
| Haskell FFI | Ptr () | newtype H = H (Ptr ()) |
| OCaml ctypes | unit ptr | type h = unit ptr |
| Python ctypes | c_void_p | subclass c_void_p |
| Java JNI | jlong | (just long) |
| .NET P/Invoke | IntPtr / nint | struct 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).
- D54 —
as-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-derefp.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):
&xsafe for all types (nounsafe {}required for promote path).addr_of()/addr_of_mut()retired →E_ADDR_OF_REMOVED.mutbinding →*mut Tauto;robinding →*Tauto — норма 118.6 ВОССТАНОВЛЕНА решением владельца 2026-08-06 (в июле D246/Plan 147 временно заменял её на «вывод всегда*T»; обиход показал, что аннотационную церемонию систематически обходили кастом*T as *mut T, которому учил даже гайд). Сопровождение: каст*T as *mut TРЕТРАКТИРОВАН; от ro-биндинга*mut Tне получить никак — ни выводом, ни аннотацией, ни кастом (№375); явная аннотацияro p *mut T = &mut_xостаётся легальной.** 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 }.D216 АМЕНДМЕНТ «доступ через валидный указатель безопасен; FFI отдаёт
Option[*T]» (решение владельца 2026-09-20). Две части, и вторая делает первую честной.ЧАСТЬ 1.
unsafeбольше не требуется для ДОСТУПА. Безопасны:p*.field,p*.m()(D483),p.read(),p.write(v)— на*Tи*mut T. Правилаro/mutне меняются: запись требует*mut T.Довод — не смягчение, а то, что безопасность здесь держит ТИП.
*Tпо норме §1 «always non-null (compile-time invariant)», нулевой указатель выражается ТОЛЬКО какOption[*T](NPO §7), а&xбезопасен с escape-анализом и авто-промоутом (§4 amend, Plan 118.6). То есть указатель, полученный законным путём, валиден по построению, иunsafeвокруг чтения не проверяет ничего — он лишь требует ритуала.
unsafeОСТАЁТСЯ там, где тип перестаёт отвечать: адресная арифметика (read_at,write_at,offset,dist,copy_from/copy_to, невыровненный и volatile доступ) и любой доступ через*uninit T— тип, в который §1 деградирует указатель ПОСЛЕ арифметики («alignment/bounds gone»). Граница проходит ровно там, где компилятор перестаёт знать, куда указывает адрес.СНИМАЕТСЯ формулировка §5 «Только в unsafe context — все pointer ops gated». Замер 2026-09-20: компилятор УЖЕ принимает
ptr.xвнеunsafe— то есть норма догоняет код, а не меняет его. (Сам §5 переписывается целиком при реализации D483; реестр 221.1 №1177.)ЧАСТЬ 2.
extern "C"обязан объявлять указатель какOption[*T], никогда голым*T— и в возврате, и в out-параметре. Без этого часть 1 была бы ВРАНЬЁМ: за валидность указателя из C не отвечал никто, а «безопасно» читалось бы как гарантия компилятора. СOption[*T]место, где программист ручается, названо и видно — этоmatch, снимающийOption; после него работает инвариант non-null, и доступ законно безопасен.Цена замерена: объявлений
extern "C", отдающих указатель, вstd/srcПЯТЬ (греп 2026-09-20). Миграция копеечная, и делается она раньше, чем появится шестое.ОГОВОРКА, БЕЗ КОТОРОЙ ЭТОТ АМЕНДМЕНТ ОБЕЩАЕТ ТО, ЧЕГО НЕ ДАЁТ (замер 2026-09-20, строка реестра 221.1 №1185). Часть 2 опирается на NPO: по норме §7
Option[*T]— ЭТО ОДИН УКАЗАТЕЛЬ,None= NULL. По коду сегодня это не так. Выпущенный C дляOption[*()], дляOption[<newtype над указателем>]и для канона §3 (type X(*())) одинаково даётNovaOpt_nova_int— структуру С ТЕГОМ и полезной нагрузкойnova_int; сами ручки при этом эмитятся верно (typedef void* Nova_CHandle;), то есть указательность теряется при инстанциацииOption, а не в newtype (проверено контролем без newtype).Значит до реализации Ф.5 (NPO codegen, A19-A23 — числится в остатке этого же блока) норма записана, а граница ею НЕ ЗАКРЫТА. Тот, кто напишет биндинг по этому амендменту, получит не то, что здесь обещано. Оговорка стоит здесь, а не в реестре, по доводу интегратора, с которым я согласен: правило, выглядящее действующим, хуже отсутствующего — читатель правила не идёт смотреть, реализовано ли оно.
Приёмка: (1) чтение и запись через
*T/*mut Tбезunsafeкомпилируются; (2) те же операции через*uninit Tи вся арифметика — отказ с точным кодом; (3)extern "C" fn … -> *Tотвергается диагностикой, называющейOption[*T]; (4) проба в обе стороны на каждом из трёх.Enforced diagnostics (V1):
E_UNSAFE_REQUIRED(D216 §8) — A8 ✅ commit 5c0d2c975ceE_UNSAFE_CALL_REQUIRES_WRAP(D216 §9) — A11 ✅ commit abd4be4603bE_CALLBACK_THROWS_OVER_C_ABI(D216 §10/§20) — A25 ✅ commit e4cff57142eE_EXTERNAL_FN_FAIL_EFFECT(D216 §20) — A26 ✅ commit 7ff3007f3afE_REALTIME_POINTER_OP(D216 §20 + D172 cross-ref) — A33 ✅ commit 6752565f453E_INVALID_POINTER_MODIFIER(D216 §1) — commit 6d6a18a2ab7E_AMP_LITERAL/E_AMP_RECORD_LITERAL/E_ARRAY_INDEX_PTR_BANNED(D216 §4 amend + §15) — commits d9d3084ed69 + 986fdb04c0d + 7d61617bcf8E_ADDR_OF_REMOVED(D216 §4 amend, Plan 118.6) —addr_of/addr_of_mutretiredRemaining 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 (
&valueis typed pointer construction, NOT Rust borrow), D52 (tuple newtypetype 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):
*Tfamily + 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 ERRORE_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 T→ HARD ERRORE_REDUNDANT_POINTER_RO(fix-it*T)
Flagship
*u8(ro-pointee) use-case (Plan 139; D246, 2026-06-12):str— Nova value-record lang-itemtype 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-методы (receiverstr⇒current_recv_type == "str", privacy type-based, не module-based — D220) конструируютstrvalue-record литераломstr { ptr: …, len: … }в своём модуле. Codegen НЕ эмититNovaValue_str(str ∈RUNTIME_DEFINED_TYPESskip-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)— byte-scan, каждый сегмент = zero-copy sub-viewstr{ptr:@ptr+off, len}(raw-ptr арифметика подunsafe). Амендмент 2026-07-11: возвратro []str→ ленивыйSplitIter(Rust-паритет; см. 03-syntax split-семейство); прежний массив —.collect(). Амендмент 2026-09-15 (план 200 §23): так же переведён@split_ascii_whitespace()— возвратro []str→ ленивыйAsciiWhitespaceIter, сегменты те же zero-copy sub-view’ы, массив —.collect(). Тип ОТДЕЛЬНЫЙ, а не настроенныйSplitIter: у того разделитель — подстрока (полеsep str), здесь — КЛАСС БАЙТОВ, и общий тип нёс бы мёртвое поле в каждом из двух случаев.@lines()пока эагерный (та же фаза, отдельным шагом);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). Байт-в-байт идентично Cnova_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()). Идентично Cnova_str_compare(array.h:989). РЕШЕНИЕ по operator-lowering — option (b) (оставить C-fn для операторов): операторы+/</<=/>/>=/==/!=надnova_strлоуэрятся ОТДЕЛЬНО, НАПРЯМУЮ в Cnova_str_concat/nova_str_lt/ … /nova_str_eq(emit_c.rs, BinOp-armlty == "nova_str"), НЕ через method-dispatch. ПРЯМЫЕ method-вызовы (s.concat(t)/s.compare(t), Compare-протокол@compare(o)==0-synthesis,@plus-body,@replacechained.concat()) маршрутизируются в Nova-body (убраны"concat"/"compare"изstr_method_to_rt). Почему option (b), не (a) (роутить операторы через методы + retire C-fn): (1) perf — Cnova_str_concat= одинnova_alloc
- два
memcpy; Nova-body =with_capacity+ два byte-push-loop’а (по байту, с bounds-check на каждом push). Cnova_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)).Амендмент (владелец, 2026-07-21): оператор
+дляstrРЕТРАКТИРОВАН — string-конкатенация через+больше не существует в языке. До этого амендментаnova_str + nova_strбыл не документированной в D46 (общая operator-overloading таблица) фичей: codegen (emit_c.rs, BinOp-армlty == "nova_str") молча лоуэрил его вNova_str_method_concat, минуя@plus(см. блок option (b) выше) — де-факто рабочий, но НИГДЕ в спеке не санкционированный операторный+на строках. Закрытие дыры, не ретракция задокументированного поведения:
- Hard error
E_STR_CONCAT_PLUS— бинарный+, где хотя бы один операнд определённоstr(typed-checker,types/mod.rs::walk_expr,is_arith-блок; permissive на unknown/generic-T, симметричноE_MIXED_WIDTH_ARITHрядом). Сообщение — «string+is not part of the language; use string interpolation ("${a}${b}") instead — or, inside a loop, aStringBuilder+.append(repeated+is O(n²))».- Канон. Вне цикла — string-интерполяция
"${a}${b}". Внутри цикла —StringBuilder.new()+.append(...)+.into_str()(не repeated+/.concat(): O(n²)).@concatНЕ ретрактирован —s.concat(t)(std/src/runtime/ string/transform.nv) остаётся явным, вызываемым методом (perf- идентичен старому lowering’у: alloc + 2×memcpy). LintW_STR_CONCAT_METHOD(perf-conventions реестр, Plan 185) рекомендует интерполяцию вместо явного.concat(...)вне цикла;W_STR_CONCAT_LOOP— то же самое внутри цикла (в т.ч..to_str()- RHS), рекомендуяStringBuilder.@plus(str, transform.nv:str @plus(other str) -> str => @concat(other)) НЕ удалён, но операторный синтаксисa + bна него больше не диспатчится —E_STR_CONCAT_PLUSсрабатывает на уровне AST (ExprKind::Binary{Add}) ДО любого метод-резолва, независимо от того, определён ли@plus. D46 (03-syntax.md) — таблица «оператор → метод» перестаёт применяться кstr++конкретно (единственное исключение в языке;@plusдля остальных типов — без изменений).- Операторы сравнения/равенства
str— БЕЗ ИЗМЕНЕНИЙ.</<=/>/>=/==/!=надnova_strпродолжают лоуэриться как и раньше (option (b) выше,Nova_str_method_compare/_equal) — этот амендмент касается ИСКЛЮЧИТЕЛЬНО+(BinOp::Add).- Негатив-фикстура:
spec_tests/conformance/neg/str_concat_plus_neg.nv(EXPECT_COMPILE_ERROR E_STR_CONCAT_PLUS) пинует текст ошибки.
*uninit T— pointer к possibly-uninit T (pointee init/layout contracts off); также degraded-форма после арифметики (alignment/bounds gone). §10a rename (Plan 174.5, 2026-07-11): was*unsafe T— see the amendment at the end of this D-block. (Distinct from the UNRENAMED*unsafe fn(...)fn-pointer- type composition, §10 — that keepsunsafe.)- 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 T | — | — | ❌ E_REDUNDANT_POINTER_RO (use mut p *T) |
ro p *T | ❌ фиксирован | ❌ pointee ro | оба ❌ |
ro p *mut T | ❌ фиксирован | ✅ pointee mut | p = …❌, *p = …✅ |
ro p *ro T | — | — | ❌ E_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 (осмысленный ro-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/uninit (§10a rename, было unsafe) непосредственно перед *
в type-position запрещены. Расширяет E_INVALID_POINTER_MODIFIER (D216 §1,
commit 6d6a18a2ab7).
mut * T // ❌ E_POINTER_PREFIX_MODIFIER — prefix перед *
ro * T // ❌ E_POINTER_PREFIX_MODIFIER
uninit * T // ❌ E_POINTER_PREFIX_MODIFIER
Сообщение: «модификаторы указателя — на pointee (после *: *mut T/*ro T/
*uninit T) или на binding (mut x *T); перед * не допускаются». Валидно:
*mut T/*ro T/*uninit 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 = …
*uninit 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 // от MUT-биндинга: сразу *mut Acc (решение 2026-08-06 — восстановленная 118.6)
Восстановление D216 §V2.6 — ЧАСТИЧНО ОТМЕНЕНО 2026-08-06 (вариант Б,
решение владельца): для АННОТИРОВАННОГО типа правило держится (*T в
аннотации всегда ro-pointee; mut p *T записи не даёт), но ВЫВОД &x
снова наследует от источника: от mut-биндинга — *mut T, от ro — *T
(восстановленная 118.6). Каст *T as *mut T ретрактирован. Гарантия: от
ro-источника писабельный указатель недостижим никаким путём (№375).
§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):
&xis now safe (nounsafe {}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).
xstarts 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 forbidden —
E_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):
&xsafe for all types (nounsafe {}for the promote path).addr_of(x)/addr_of_mut(x)retired →E_ADDR_OF_REMOVED. Use&xinstead.- Escape analysis extended to primitives.
E_UNSAFE_REQUIREDis NOT triggered by AddrOf starting Plan 118.6 (only by Deref*exprand unsafe fn calls).
addr_of / addr_of_mut builtins (Plan 118.1 Ф.3 closeout, 2026-06-05) — RETIRED Plan 118.6
— RETIRED Plan 118.6 (2026-06-16).
Use addr_of(x) / addr_of_mut(x)&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 —
&xis now safe and universally preferred.E_ADDR_OF_REMOVEDfires 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.yrooted in a named local orselfare accepted, whilemake().f/arr[i].f/(x+1).fare 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&xshare one walker (ast::addr_of_chain_root), so the intrinsic and operator forms agree, andaddr_of(*p)≡&(*p)(the walker descends an explicit deref to the pointer root, matching thep.fauto-deref sugar). - E_ADDR_OF_MUT_REQUIRES_MUT_BINDING —
addr_of_muton a binding whose root is notmut; the mut-check also walks the field-chain root (addr_of_mut(s.field)requiresmut s), not just a bare Ident (NEW).
Known V1 gaps (2026-06-08 followups): (1) CLOSED Plan 118.6 —
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 castaddr_of_mut retired; &x with mut binding auto-infers *mut T.
(2) The MOOT — addr_of(...) intrinsic chain-check runs in the const-fn rewrite pass, so
nova check/LSP does not surface itaddr_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 T | Notes |
|---|---|---|---|
p.field read | ✓ | ✓ | auto-deref one-level |
p.field = v | ❌ E_POINTER_RO_ASSIGN | ✓ | requires *mut |
p.method() (ro recv) | ✓ | ✓ | auto-deref |
p.method() (mut recv) | ❌ E_POINTER_RO_MUT_METHOD | ✓ | requires *mut |
*p read | ✓ | ✓ | yields T |
*p = v | ❌ E_POINTER_RO_ASSIGN | ✓ | requires *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).
E_POINTER_RO_MUT_METHODENFORCED (2026-08-08, Plan 221.1 №387, window pptr-ro-guard). The row above (p.method()(mut recv) → ❌E_POINTER_RO_MUT_METHOD) documented the rule since Plan 118 but the checker never enforced it — a mut-receiver method (fn T mut @m(...)) called through a readonly-pointee pointer (*T) compiled clean and mutated the pointee at runtime (221.1 №387 registry). Same class as №375 (E_POINTER_MUT_FROM_RO_SOURCE, which closed materializing a writable*mut Tfrom aro-bound SOURCE) but a distinct gap: this closes the call-site use of an already-existing readonly-typed pointer to reach a mut method — orthogonal to where or how that pointer’s type was arrived at. Gated purely on the CALL-SITE receiver’s own pointee-writability type (*Tvs*mut T,pointee_is_writable) — irrelevant whether the pointer’s SOURCE binding ismutorro, or whether the pointer’s OWN local binding ismutorro(only the pointee axis matters, same asE_POINTER_RO_ASSIGN’s existing.write()/*p = vgate, whose call-site shape this enforcement mirrors exactly). Arity-scoped against a Plan 135 ro/mut overload PAIR at the same method name (@peek() -> intro-getter vsmut @peek(v int) -> ()mut-setter, D117 amend fluent idiom) — a readonly pointer may still call the RO-arity overload; only an arity-matched mut-receiver candidate (with no ro-arity sibling at THAT arity) is rejected. Not a new rule — closes an enforcement gap of an ALREADY-declared rule (same posture as №367/№368 in the same registry window: the diagnostic code and its semantics were already documented here and in Plan 118; no new D-number, no semantics change — programs that were always meant to be rejected are now actually rejected). Neg fixtures:spec_tests/conformance/neg/d387_ptr_ro_mut_method_neg.nv(explicit*Tannotation),neg/d387_ptr_ro_mut_method_ro_source_neg.nv(unannotated, inferred from arosource),neg/d387_ptr_ro_mut_method_ overload_arity_neg.nv(arity-aware regression guard). POS boundary:spec_tests/conformance/d387_ptr_mut_method_pos.nv.
§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_UNSAFEACTIVE 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 expressionptr + nemits(ptr + n)in C — the C compiler scales bysizeof(T)automatically (standard C pointer arithmetic).*(ptr + n) = vemits an lvalue deref-write.p as *mut Temits(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/dlopenreturns)
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_ptrtypedef (= 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. LookupNova_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 — emitsW_OPTION_DOUBLE_NESTEDwarning через 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+):
— A23 ✅ CLOSED 2026-06-02. D214 amended; parser emitsnull ptrliteral retractionE_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
#unsafeattribute tounsafe fnkeyword (type-consistent, per Plan 118.5 TypeRef::Unsafe + Plan 118.1.6*unsafe fnptr type).#unsafe fn→ hard errorE_UNSAFE_ATTR_DEPRECATED.
unsafe fn foo(...)— declares function of unsafe fn typeexternal unsafe fn foo(...)— external fn of unsafe type- Body of
unsafe fn— implicit unsafe context (pointer ops безunsafe { }wrap) - Call
unsafe fn— requiresunsafe { ... }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 fn→E_UNSAFE_ATTR_DEPRECATED(hard error)
§10. *fn(...) function pointers
*fn(Args) -> Retdistinct отfn(Args) -> Retclosure- 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; prefixunsafe * 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 fncall).
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
ptrbuilt-in name is fully removed; there is notype ptr Option[*unsafe ()]builtin anymore. The idiomatic opaque pointer is*()(pointer-to-unit =void*).ptr/nova_ptrin 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 ptrliteral retracted (useNone); closes[M-115-null-ptr-to-option-after-npo]✅- Backward-compatible для existing
ptrusages (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):
| Method | Receiver | Returns | C codegen |
|---|---|---|---|
(*ro T).read() | any *T/*ro T/*mut T/*uninit T | T | (*p) |
(*mut T).write(v T) | *mut T (амендмент 2026-08-05, №358: голый *uninit T БОЛЬШЕ НЕ writable — нужен составной *mut uninit T, см. §V2.2) | nova_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; typedE_PTR_WRITE_ON_RO_TARGETdeferred — followup[M-118.4-typed-ro-write-error]
Limitations (V2 follow-up):
- Struct
T(obj_tystarts сNova_) —read/writenot 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
| From | To | Safe? |
|---|---|---|
*T | int | ✓ (см. hash hazard) |
int | *T | unsafe |
*ro T | *mut T | unsafe |
*mut T | *ro T / *T | ✓ |
*T | *uninit T | ✓ |
*uninit T | *T | unsafe |
*T1 | *T2 (T1≠T2) | unsafe |
fn → *fn | ✓ если captureless | E_CLOSURE_HAS_ENV иначе |
*fn → fn | unsafe | wraps |
*T | bool / 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)nullliteral —E_NULL_LITERAL_USE_NONE(useNone; one-way-to-do)undefined—E_UNDEFINED_USE_NONE_INIT_PATTERN(useOption[*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):
*Timplements 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НЕ implementsDisplay— 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_STR— ACTIVE 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.
- direct
- 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:
| Form | Allocation | ABI | When |
|---|---|---|---|
type Handle(*T) tuple newtype | stack | single pointer | opaque handles, no extra state |
type Handle(ptr) tuple newtype | stack | single pointer | untyped opaque handles |
type Handle { p *T, extra State } record | heap | pointer-to-struct | handle с 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
*Tparameters — pass by value (single pointer-word; standard C ABI)&valueat call site creates*Targument- 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.
§21. Операции, требующие unsafe { } — авторитетная карта + E_UNSAFE_UNUSED (unsafe-cluster, Plan 174.5 followup, 2026-07-11)
Status: ✅ DONE 2026-07-11 (sonnet, worktree
nova-ntbranchunsafe-cluster). Owner asked 3× for a precise answer on «does passing a raw*Tthrough anexterncall requireunsafe { }» — this section is the single authoritative map (source: the actual checker,types/mod.rs check_unsafe_context_in_module/UnsafeCtx::walk_expr, NOT the older §4- §10 prose above, which predates several retractions — see the discrepancies noted inline below).
Карта: что ТРЕБУЕТ unsafe { } (checker-enforced, hard error if absent)
| # | Операция | Диагностика | Где в checker |
|---|---|---|---|
| 1 | raw &x — сырой stack-адрес без escape-анализа | E_UNSAFE_REQUIRED | UnOp::RawAddrOf arm |
| 2 | *expr pointer dereference (unary) | E_UNSAFE_REQUIRED | UnOp::Deref arm — но см. примечание ниже: operator-форма мертва в валидных программах |
| 3 | ptr[i] index на typed pointer (*T/*mut T/*uninit T) | E_UNSAFE_REQUIRED | Index arm, expr_is_typed_pointer(obj) — тот же caveat |
| 4 | Pointer-pointer order-compare </<=/>/>= (оба операнда typed pointer) | E_PTR_ORDER_COMPARE_REQUIRES_UNSAFE | Binary arm — тот же caveat |
| 5 | Вызов unsafe fn / external unsafe fn (free-fn, instance-метод, static-метод Type.m(...), или косвенный вызов через *unsafe fn(...)-биндинг) | E_UNSAFE_CALL_REQUIRES_WRAP | Call arm, unsafe_fns/unsafe_static_methods/unsafe_fn_ptr_vars |
| 6 | ЧТЕНИЕ (Ident/Member/Index access) локала/параметра типа uninit T (value-wrapper) | E_UNSAFE_T_READ_REQUIRES_WRAP | Ident/Member/Index arms, unsafe_t_vars. Запись — safe (переход к valid) |
| 7 | Narrow-cast uninit_T_binding as T (снятие uninit-обёртки) | E_UNSAFE_T_NARROW_REQUIRES_UNSAFE | As arm |
Caveat к пп. 2-4 (operator-семья мертва, Plan 174.5 §Ф.0 «всё через методы», 2026-07-06):
*p, p[i], p+i/p-i, p-q, p</<=/>/>=q — все РЕТРАКТИРОВАНЫ на уровне
codegen: emit_c.rs безусловно (независимо от unsafe-wrap) отклоняет их с
E_POINTER_OP_USE_METHOD, требуя .read()/.write()/.read_at()/
.write_at()/.offset()/.dist() вместо них. Типоуровневая проверка
(пп. 2-4 выше) технически ещё существует в checker’е и всё ещё формально
входит в карту (нужна для «есть ли операция вообще», см. E_UNSAFE_UNUSED
ниже), но ни одна валидная Nova-программа не может дойти до неё — любое
использование *p/p[i]/p<q падает на codegen-стадии независимо от
исхода checker-стадии. Живые, реально-используемые эквиваленты — строка 8
следующей таблицы.
Амендмент 2026-08-05 (№353, окно p-ptr): та же ретракция enforced
ТАКЖЕ в checker’е (nova check, не только codegen/nova test) —
check_target_readonly’s Deref/Index arms, types/mod.rs; проверка
срабатывает ПЕРВОЙ и безусловно по writability (на ro-указателе больше не
выскакивает сбивающий E_POINTER_RO_ASSIGN). Codegen-проверка остаётся
defense-in-depth. Формы ЧТЕНИЯ (x = *p, y = p[i]) чекером пока не
покрыты — №367 реестра.
Карта продолжение: НЕ enforced checker’ом, но контрактно unsafe (известные gaps)
| # | Операция | Почему не enforced | Что делать |
|---|---|---|---|
| 8 | Вызов raw-pointer intrinsic-метода (p.read()/p.write(v)/p.read_at(i)/p.write_at(i,v)/p.offset(n)/p.dist(q)/p.read_unaligned()/p.write_unaligned(v)/p.read_volatile()/p.write_volatile(v)/p.copy_from[_nonoverlapping](...)/p.copy_to[_nonoverlapping](...)) | Эти методы — compiler intrinsics, хардкод-диспатч в emit_c.rs (поиск method == "read" и т.д.), НЕ Item::Fn — check_unsafe_context_in_module собирает unsafe_fns/unsafe_static_methods только из AST Item::Fn объявлений, так что для этих методов регистрировать нечего. Документировано ещё в комментарии emit_c.rs 2026-06 («Caller wraps in unsafe { } — parser не enforces»). | Оставлено как соглашение (caller ответственен). E_UNSAFE_UNUSED (ниже) распознаёт вызовы этих методов как «unsafe used» — чтобы существующий unsafe { p.read() } код по всему std/ не считался мёртвым — но НЕ добавляет новый required-wrap gate. Закрытие этого gap (полноценный E_UNSAFE_CALL_REQUIRES_WRAP-стиль контроль вызовов) — отдельный, больший followup, вне рамок этой волны. |
| 9 | Вызов cross-module unsafe fn static-метода (напр. RawMem.alloc(...) из другого модуля) | unsafe_fns/unsafe_static_methods собираются ТОЛЬКО из module.items/module.peer_files — деклараций того же модуля/co-equal-файлов; import-нутые модули не просматриваются. Найдено этой волной (repro: unsafe { RawMem.alloc(n) } в std/collections/vec/*.nv, где RawMem объявлен в runtime.raw_mem, ложно флагуется E_UNSAFE_UNUSED — а до этой волны звонки RawMem.alloc(...) ВООБЩЕ БЕЗ unsafe-обёртки нигде не ловились E_UNSAFE_CALL_REQUIRES_WRAP). | E_UNSAFE_UNUSED-heuristic узко распознаёт RawMem.* (единственный известный кросс-модульный unsafe-namespace — весь его surface unsafe fn, см. std/runtime/raw_mem.nv) как unsafe-used независимо от модуля. Полноценный fix коллектора (собирать unsafe_fns по всем импортированным модулям) — отдельный, больший followup. |
| 10 | Передача raw-pointer-аргумента (.ptr()/.as_ptr()/&x/cast к *T) в НЕ-unsafe-помеченный extern/external fn | Ничего не требует этого — extern fn без unsafe fn keyword просто безопасен для вызова (см. п.5: gate только по unsafe_attr); передача *T-значения аргументом НЕ входит в карту вообще. | Наблюдаемое, но НЕ officially required соглашение по всему std/net+std/tls (см. находку ниже). E_UNSAFE_UNUSED распознаёт extern_fn(...ptr-ish-arg...) как unsafe-used, чтобы не флагать ~35 существующих сайтов, но НЕ добавляет новый required-wrap gate. |
D216 §21 AMENDMENT (2026-07-17, [M-d216-unsafe-map-single-file-gaps] закрыт)
Владельческое репро: nova check std/src/runtime/string/core.nv (single-file) был
ЛАТЕНТНО красным (E_UNSAFE_UNUSED) на str @bytes() => unsafe { []u8.new(@ptr, @byte_len()) } — единственная строка, где реальный CU (импорты/тесты/CI) шёл
зелёным, а изолированный single-file check ловил ложный “unused unsafe”. Два
независимых пробела карты, оба закрыты этой волной:
(а) Cast к pointer-типу не входил в used-tracking карту. expr as *T/*mut T/*uninit T — канонический способ получить typed pointer из сырого источника
(напр. @ptr as *mut u8 в исторической 3-арг форме VIEW-конструктора). §21-карта
уже узнаёт pointer-target cast для ДРУГИХ гейтов (expr_is_typed_pointer’s As
arm — используется deref/index/order-compare/interpolation проверками), но
used-tracker (E_UNSAFE_UNUSED) это распознавание не зеркалировал — unsafe { p as *mut T }, не содержащий больше НИЧЕГО из карты, ложно флагуется. Заодно
backfilled пропущенную запись для char-target cast (строка была в коде с
2026-07-11, в карту не попала — тот же класс проблемы, drift между кодом и
спекой). Обе записи — used-tracking ONLY (см. E_UNSAFE_UNUSED ниже), новый
required-wrap gate НЕ добавляют (значение выражения было и остаётся safe вне
unsafe-контекста для этих кастов).
(б) Generic-static/[]T-slice-sugar unsafe-fn оверлоад не резолвился ни для
used-tracking, ни для enforcement. Vec[T].new(ptr, len) / []u8.new(ptr, len) парсятся Member{ obj: TurboFish{ base: Ident(Type), .. } | Path(["__array", elem]), name } — форма, которую П.5-карта (unsafe_callee_name match в
check_unsafe_context_in_module) не распознавала вообще (знает только bare-Ident
ресивер и 2-сегментный Path, напр. RawMem.alloc(...)). Наивное расширение
матча на ЛЮБОЙ Member/Path-ресивер было бы arity-blind: у Vec.new ТРИ
арности под ОДНИМ (тип, имя) ключом (new(cap: int = 0) 0/1-арг, new(ptr *T, len int) 2-арг unsafe VIEW-конструктор, new(ptr *mut T, len int, cap int)
3-арг owned) — потребовать unsafe { } у ВСЕХ них означало бы тысячи ложных
срабатываний на safe-арностях по всему std/.
Фикс — резолв КОНКРЕТНОГО оверлоада по ARG COUNT (static_arities в
check_unsafe_context_in_module, types/mod.rs): для static-ресивер Item::Fn
собирается (min_required, max_accepted, unsafe_attr) каждого оверлоада под тем
же (тип, имя) ключом; на call-сайте фактическое число позиционных аргументов
проверяется против всех оверлоадов — если РОВНО ОДИН диапазон включает это число,
его unsafe_attr резолвится однозначно (для Vec.new диапазоны [0,1]/[2,2]/
[3,3] не пересекаются — 2-арг вызов однозначен). Genuine same-arity ambiguity
(два оверлоада с ОДИНАКОВЫМ диапазоном) оставляется неразрешённой (консервативно,
как раньше). Попытка резолвить через checker-канал resolved_callees (Plan 172.1
U.3.4 / 196.7-семья) была отклонена ПОСЛЕ эмпирической проверки — канал остаётся
ПУСТЫМ для этой формы вызова: check_call_argbind’s Member{obj,..} arm явно
делает bail-out для static/type-ресивера (resolve_instance_method ожидает
VALUE-ресивер), так что arg-валидация generic-static ctor-вызовов идёт через
СОВСЕМ другой, не пишущий в канал путь (codegen-side arity+C-type dispatch,
generic_type_methods[base].find(name), “1b” turbofish-ветка emit_c.rs) — канал
resolved_callees покрывает free-fn/instance-method/2-сегментный-Path
резолв, но НЕ generic-static ctor-форму; экземпляр (б) — из этой волны.
Enforcement (новая строка П.5-семьи, не отдельный номер — расширяет П.5):
резолв по arg-count применяется И к enforcement (E_UNSAFE_CALL_REQUIRES_WRAP
при depth==0), И к used-tracking (asymmetric fallback при неразрешённой арности:
used-tracking мягкий — засчитывает “used”, если вызванное ИМЯ имеет ХОТЬ ОДИН
unsafe-оверлоад где-либо в scope; enforcement строгий — только при однозначном
arity-резолве). До фикса unsafe fn Vec[T].new(ptr, len) был семантически
заявлен владельцем, но вызов БЕЗ unsafe { } НЕ ловился вообще ни для одной
static-generic/slice-sugar формы в языке.
Фикстуры: spec_tests/conformance/d216_unused_unsafe_pos.nv (позитив: cast-only
used-tracking + оба call-shape’а с обёрткой), spec_tests/conformance/neg/ d216_generic_static_unsafe_overload_neg.nv + neg/ d216_slice_sugar_unsafe_overload_neg.nv (негатив: enforcement без обёртки, обе
формы). Реализация: compiler-codegen/src/types/mod.rs
(check_unsafe_context_in_module, generic_static_receiver,
static_overload_arity_range, call_callee_name).
Находка (владелец спрашивал 3×): net_tcp_listen(addr.ptr(), ...) — требует ли unsafe { }?
fn SocketAddr @ptr() -> *() // сырой *(), НЕ *uninit
extern "C" fn net_tcp_listen(addr *(), backlog int, out_err *mut int) -> *() // НЕ unsafe-typed
ro h = unsafe { net_tcp_listen(addr.ptr(), 128, &err) }
Ответ: ПО ФАКТУ (текущие правила п.1-7) — НЕ требует. Ни одно правило карты не задевает этот вызов:
net_tcp_listenне помеченаunsafe fn→ п.5 не применяется (calling a plainextern "C" fnis safe per se — checker gate’ит ТОЛЬКО поFnDecl.unsafe_attr, независимо от того, C-ABI это или нет; §8 текста выше буквально говорит «Outside unsafe safe: … external fn declarations», и это распространяется на ВЫЗОВЫ, не только декларации — CALLS к non-unsafeextern fn тоже не gated).addr.ptr()— обычный (неunsafe) метод, возвращающий bare*()(неuninit-обёрнутый) → не Ident/Member/Index READuninit T-биндинга → п.6 не применяется.&err—AddrOfвсегда safe (Plan 118.6 retraction, §4 amend) → не gated ни при каких условиях.- Передача raw
*()значения АРГУМЕНТОМ (в любую позицию) — не входит НИ в одно правило карты;E_UNSAFE_ARG_REQUIRES_WRAP(см. ниже) — про СОВСЕМ другое (передачуuninit T-биндинга).
Это находка: единственный способ реально потребовать unsafe { } на
таком вызове — объявить сам extern fn как external unsafe fn /
extern "C" unsafe fn (п.5). Пока decl не помечена — обёртка в
unsafe { } вокруг подобного вызова ничего не гейтит и семантически
не требуется; она держится ТОЛЬКО соглашением (см. п.9/п.10 выше — это
ИМЕННО та закономерность, воспроизведённая на ~35 реальных сайтах в
std/net+std/tls при тестировании E_UNSAFE_UNUSED, ниже). Действительно
ли extern C-функции, принимающие raw pointers, ДОЛЖНЫ становиться
unsafe fn по умолчанию (Rust-модель: «любой extern "C" вызов unsafe»)
— это language-policy решение владельца, вне рамок этой волны (см. [M- unsafe-cluster-extern-ptr-arg-policy] followup ниже). → РЕШЕНО D424 (ниже).
D424 — raw-ptr extern/external fn ⇒ unsafe fn по инференсу + carve-out E_UNSAFE_UNUSED удаляется (Plan 174.6 M4, 2026-07-15, решение владельца) {#d424}
Закрывает открытый вопрос находки выше (net_tcp_listen/tls_cfg_verify_pem) и followup
[M-unsafe-cluster-extern-ptr-arg-policy]. Amends D216 §9 (карта unsafe-триггеров, п.10) и D282
(классификация extern "C" fn). Выбран вариант A (vs B «externs всегда plain, снести ~35 обёрток»).
Правило (нормативно):
- Инференс unsafe.
extern/external "…" fn, чья сигнатура содержит сырой указатель (*T/*mut T/*()/CStr) в параметре ИЛИ возврате, классифицируется какunsafe fnпо инференсу — без keyword (выводится из*Tв сигнатуре; дифференциатор FFI «type-driven, без extra keyword» сохранён). Её вызов требуетunsafe { }(п.5 карты,E_UNSAFE_CALL_REQUIRES_WRAP). Scalar/handle-only externs (tls_client_cfg_new() -> CTlsCfgHandle,tls_cfg_verify_system(c)) — остаются обычными безопасными (нет*Tв сигнатуре → неunsafe fn). - Carve-out удаляется. Строка-gap п.10 карты (распознавание
extern_fn(...ptr-arg...)как «unsafe-used» вE_UNSAFE_UNUSED) снимается: после п.1 такие вызовы требуют обёртки по-настоящему (п.5), поэтому обёртка genuinely-used честно, без спец-распознавания.E_UNSAFE_UNUSED(hard error, §выше) остаётся и теперь честен без исключений:unsafe { }, не покрывающий НИ ОДНОЙ реально-unsafe операции (в т.ч. обёртка над scalar-only extern, или ни над чем), → hard error. Жёсткий принцип владельца: «unused unsafe обязан флагаться, без carve-out».
Обоснование A. Сырой указатель в C разыменуется без проверки (валидность/время жизни/алиасинг
непроверяемы) — настоящий unsafe, честнее пометить, чем объявить безопасным (B недомаркировал бы реальный
риск, оставив опасные FFI-разыменования без визуального маркера). A даёт 0 churn существующего кода
(std/net+std/tls ~35 сайтов становятся корректными-по-правилу, а не терпимыми по carve-out).
Статус реализации: РЕАЛИЗОВАНО (Plan 174.6 M4, 2026-07-17, sonnet). Checker-enforcement закрыт:
check_unsafe_context_in_module’s collect-фаза (compiler-codegen/src/types/mod.rs) фолдит
extern/external fn с fn_sig_has_raw_ptr(fd) (проверяет каждый параметр И возврат — рекурсивно в
Tuple/FixedArray, чтобы поймать указатель, вложенный на один уровень, напр. multi-value-return
конвенции (*(), int); НЕ рекурсирует в Array/Named/generics — []T/Vec[T] GC-управляем, не сам
по себе raw-ptr, а user-декларированный record/newtype требует резолва типа, недоступного на этом
синтаксическом проходе) прямо в unsafe_fns на месте сбора — без keyword, зеркалит существующую
fd.unsafe_attr-ветку. Бывший carve-out E_UNSAFE_UNUSED (D216 §21, extern_fns-сет used-tracking) снят
целиком (п.2) — обычный E_UNSAFE_CALL_REQUIRES_WRAP-гейт + used-tracking теперь покрывают такие вызовы
через тот же unsafe_callee_name-матч, отдельная эвристика не нужна.
Фикстуры: 2 pos-теста (spec_tests/conformance/d424_rawptr_unsafe/d424_rawptr_unsafe_pos.nv, свой пакет +
d424_ffi_shim.h — реальный линкуемый C-шим, т.к. позитивные conformance-тесты реально исполняются: (1)
wrapped-вызов raw-ptr extern принят, (2) unwrapped-вызов scalar-only extern принят) + 3 neg-теста
(spec_tests/conformance/neg/d424_rawptr_extern_unwrapped_neg.nv → E_UNSAFE_CALL_REQUIRES_WRAP;
d424_scalar_extern_unused_unsafe_neg.nv → E_UNSAFE_UNUSED, carve-out снят;
d424_rawptr_extern_tuple_return_unwrapped_neg.nv → тот же код на tuple-nested указателе, регресс-фиксация
Tuple-рекурсии).
Взрыв-оценка (nova check std/ + examples/ffi): 12 сайтов на 3 файлах вскрылись и почтены добавлением
unsafe { } (смысл M4) — std/src/net/mock.nv (1: net_addr_loopback возвращает *()),
std/src/runtime/fmt_buf.nv (4: f64_fmt_into, buf *mut u8 параметр), examples/ffi/sqlite_mini.nv (7:
все mini_sqlite_* externs; mini_sqlite_open — указатель вложен в tuple-возврат (*(), int), живой
пример, подтвердивший необходимость Tuple-рекурсии). После фикса — дельта 0 (nova check std /
nova check examples/ffi чисты по E_UNSAFE_*).
E_UNSAFE_ARG_REQUIRES_WRAP — отдельно, НЕ про «забыл unsafe»
E_UNSAFE_ARG_REQUIRES_WRAP (Plan 118.5 V2, check_unsafe_coerce_args)
срабатывает, когда Ident-аргумент зарегистрирован в unsafe_t_locals
(т.е. его ТИП — uninit T, value-wrapper) передаётся в параметр, чей
задекларированный тип НЕ uninit-обёрнут — безусловно, вне
зависимости от unsafe { }-контекста самого call-сайта (эта проверка
живёт в отдельном ConsumeCtx-проходе, который не знает про depth/unsafe-
блоки). Единственный fix — явный narrow-cast (п.7 карты) внутри
unsafe { }: unsafe { fn_name(x as T, ...) }. Поэтому это НЕ входит
в «операции, для которых unsafe { }-обёртка сама по себе достаточна» —
обёртка БЕЗ narrow-cast’а всё равно ошибка.
E_UNSAFE_UNUSED — hard error на лишний unsafe { } (Rust unused_unsafe, но error не warning)
Правило: unsafe { ... } блок, внутри которого НИ ОДНА операция из
карты (пп. 1-10 выше, включая gap-строки 8-10 — они распознаются для
used-tracking, даже не будучи formally required) не была фактически
использована на уровне этого блока (не «съедена» вложенным unsafe { }
блоком глубже) → E_UNSAFE_UNUSED (hard error; владелец решил — не
warning, как в Rust). Nested-scope semantics — «ближайший enclosing unsafe
block» (аналог Rust): операция засчитывается ТОЛЬКО самому внутреннему
unsafe { } блоку, лексически её содержащему; unsafe fn body без
явного блока НЕ линтится (аналог Rust — атрибут функции не проверяется на
unused).
Реализация: UnsafeCtx::unsafe_block_used: Vec<bool> — стек флагов,
кадр пушится ТОЛЬКО для Block.is_unsafe == true (parallel к
ptr_vars/unsafe_t_vars); каждый существующий gate-сайт (пп. 1-7),
который раньше делал if depth == 0 { error }, теперь делает
if depth == 0 { error } else { mark_unsafe_used() } — так что карта
используется как ЕДИНЫЙ источник и для error-gating, и для used-tracking
(нет риска рассинхрона между ними). Gap-строки 8-10 добавляют СВОИ
used-tracking-условия (без нового error-gating).
Побочные находки при тестировании (nova check std/ + spec_tests/conformance),
обе устранены той же волной:
- Path vs Member parser bug (не связано с этой сессией напрямую, но
СДЕЛАЛО невозможным корректный used-tracking, поэтому исправлено здесь):
Type.lowercase_method(...)(двусегментный static-call, напр.RawMem.alloc(n)) парсится вExprKind::Path(["RawMem","alloc"]), а НЕExprKind::Member— комментарий вparser/mod.rsпро «PascalCase.lowercase → stop for Member» описывает поведение, которого код НЕ реализует (let _ = next_upper;отбрасывает именно эту проверку). Из-за этогоunsafe_static_methods-матчинг вcheck_unsafe_context_in_module(веткаExprKind::Member{ obj: Ident(tn), .. }) была МЁРТВЫМ кодом для ЛЮБОГО static unsafe-метода —E_UNSAFE_CALL_REQUIRES_WRAPникогда не срабатывал на незавёрнутыйRawMem.alloc(n)в ЛЮБОМ месте языка. Исправлено: новаяExprKind::Path-ветка в том же match’е. Второй repro — namespace-qualified static-call черезimport mod as ns/ D289 last-segment (ns.RawMem.method(...)) парсится в ВЛОЖЕННЫЙMember{ obj: Member{ obj: Ident(ns), name: "RawMem" }, name: "method" }(т.к. первый сегмент lowercase не входит в uppercase-Path-loop) — RawMem- used-tracking heuristic расширена, чтобы узнавать «receiver chain заканчивается сегментом RawMem» независимо от квалификации. expr_is_typed_pointerblind spot: не распознавалx.ptr()/x.as_ptr()zero-arg getter-вызов как typed-pointer expression (только&x/*p/as *T/Ident-в-ptr_vars). Из-за этогоlet p = buf.ptr()(БЕЗ явной type-аннотации) никогда не регистрировался вptr_vars, что тихо отключало п.2/3/4 карты (иE_PTR_NO_DISPLAY_USE_DEBUG_STR) для ЭТОГО распространённого идиома — задолго до этой сессии (репро:spec_tests/conformance/neg/d216_ptr_index_read_neg.nv, которая полагалась ИСКЛЮЧИТЕЛЬНО на позднюю codegen-стадиюE_POINTER_OP_USE_METHOD, а не на checker-стадию). Исправлено: расширенexpr_is_typed_pointer— распознаёт этот идиом напрямую (единая точка, выгодополучатели — ptr_var-регистрация, Index-gate, order-compare-gate, interpolation-ban, И новыйE_UNSAFE_UNUSED).
Разбор std/-флагов этой волной (36 сайтов, все разрешены):
std/tls/stream.nv(5 сайтов, напр.unsafe { tls_wants_write(session) }) — блок реально лишний (аргументы — толькоCTlsHandle-хендл, ни одного raw pointer) → unsafe-обёртка убрана (fix the block).std/net/mock.nv(1 сайт,unsafe { net_addr_loopback(0) }) — тот же случай (нет pointer-аргумента) → unsafe-обёртка убрана.std/net/addr.nv(9),dns.nv(1),error.nv(1),std/tls/client.nv(5),server.nv(6) (23 сайта) — extern-fn-call с raw-pointer-аргументом (.ptr()-идиом) → карта расширена (п.10) — обёртки оставлены как есть.std/collections/vec/{core,mutate,restructure}.nv(7 сайтов) — cross-moduleRawMem.*вызов → карта расширена (п.9) — обёртки оставлены как есть.
Neg/pos conformance: spec_tests/conformance/neg/d216_unused_unsafe_neg.nv
(unsafe { 1 + 1 } → E_UNSAFE_UNUSED), spec_tests/conformance/d216_unused_unsafe_pos.nv
(5 позитивных случаев — .write() intrinsic, raw &x, unsafe fn call,
.read_at() intrinsic, uninit T narrow-cast — НЕ флагуются).
Followups (вне рамок этой волны):
[M-unsafe-cluster-extern-ptr-arg-policy]— language-policy: должны лиextern/externalfn, принимающие*T-аргумент, становитьсяunsafe fnпо умолчанию (Rust-модель) или остаться opt-in (текущая модель)? Решение владельца; при «да» — retrofit ~35+std/net+std/tlsдеклараций.[M-unsafe-cluster-cross-module-collector]— расширитьunsafe_fns/unsafe_static_methodscollector, чтобы просматриватьimport-нутые модули целиком (закрывает п.9 gap полностью, не толькоRawMem).[M-unsafe-cluster-intrinsic-method-gate]— полноценный call-site gate для raw-pointer intrinsic-методов (п.8), требующий type-inference на этом checker-проходе (сейчас чисто syntactic pass).
§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'.
⚠ AMEND Plan 199 (2026-07-11) → D418. Nova
strбольше НЕ несёт trailing-NUL инвариант (retracts D26 §«Nul-termination» rules 1-3). Conversion API переименованas_cstr→@to_cstr(to_correctly names a COPY — a str→CStr conversion под D418 ВСЕГДА копирует;as_был misnomer): две D84-arity-перегрузки — GC-allocating@to_cstr()и zero-alloc caller-buffer@to_cstr(buf, size). CStr’s own invariant (satisfied by construction, not inherited from the sourcestr) не меняется. «enabling zero-copy conversion» ниже — исторический текст, более не верно.
Conversion methods (@to_cstr, copy-based; Plan 199 Ф.2/Ф.3, owner decision
2026-07-11): str → CStr conversions реализованы как pure-Nova methods в
std/ffi/cstr.nv:
// GC-allocating copy: fresh byte_len()+1 buffer + '\0'. Scans for an embedded
// NUL (a classic silent-C-truncation footgun) and panics on the safe path.
export fn str @to_cstr() -> CStr {
ro bytes = @bytes()
for b in bytes {
if b == 0 { panic("to_cstr: embedded NUL byte in str (would truncate C-string)") }
}
mut buf = Vec[u8].new().cap(@byte_len() + 1)
buf.append(bytes)
buf.push(0 as u8)
unsafe { CStr(buf.ptr()) }
}
// Zero-alloc: copy into a caller-provided buffer, clamping to buf_size-1 +
// terminator (TRUNCATING, no scan — the explicit "I own the buffer" hot path;
// the `.min(buf_size-1)` clamp keeps the terminator write in-bounds).
export fn str @to_cstr(buf *mut u8, buf_size int) -> CStr
requires buf_size > 0
{
ro n = @byte_len().min(buf_size - 1)
unsafe { RawMem.copy(@ptr(), buf, n); buf.write_at(n, 0 as u8) }
unsafe { CStr(buf) }
}
Использует existing builtins: str.@bytes()/@ptr()/@byte_len() +
Vec[u8]/RawMem. C primitives НЕ требуются — Nova-side alloc+copy+terminate
достаточно (no source-str invariant to reuse под D418).
V1 simplifications (explicit followups, not silent):
[M-118.1-cstr-nul-check]— ✅ CLOSED 2026-06-08.@to_cstr()scans the str bytes for an embedded NUL and panics (interior0x00would truncate the C-string at that byte); the caller-buffer@to_cstr(buf, size)overload truncates by design (no scan — the explicit hot path).panicis an always-available compiler intrinsic ([panic-assert-intrinsic] 2026-07-11) — needs no import regardless of cstr.nv’s prelude status.[M-118.1-cstr-to-cstr-distinct-copy]— SUBSUMED by Plan 199 Ф.3:@to_cstr()IS the owning copy now (both overloads copy — theas_/to_naming tension that deferred a distinctto_cstris resolved:to_cstris the canonical, correctly-named copying conversion;as_cstr/as_cstr_uncheckedretired).
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 (*exprDeref / unsafe fn call) outside unsafe context (block.is_unsafe = false AND not вunsafe fnbody). Active enforcement черезcheck_unsafe_context_in_modulewalker pass с depth counter — D216 §8 V1 ENFORCED 2026-06-02. Plan 118.6 amend (2026-06-16):&xAddrOf (safe promote path) no longer triggersE_UNSAFE_REQUIRED. Only raw stackunsafe { &x }and Deref/unsafe fn calls remain gated.E_UNSAFE_CALL_REQUIRES_WRAP— callingunsafe fnбезunsafe { }wrap. Active enforcement черезcheck_unsafe_context_in_modulewalker с pre-collected unsafe_fns: HashSet. D216 §9 V1 ENFORCED 2026-06-02 (commit abd4be4603b) E_UNSAFE_ATTR_DEPRECATED—#unsafe fn/#unsafe external fnsyntax; useunsafe fn/external unsafe fninstead (Plan 118.1.7)E_ARRAY_INDEX_PTR_BANNED—&arr[i]E_NULL_LITERAL_USE_NONE—nullliteral (general)E_NULL_PTR_RETRACTED_USE_OPTION—null ptr(Plan 115 V1) retractedE_UNDEFINED_USE_NONE_INIT_PATTERN—undefinedusedE_CLOSURE_HAS_ENV— fn → *fn cast attempted с closure envE_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 с FailE_PTR_ARITHMETIC_INVALID—p * 2,p / 4, etc.E_POINTER_RO_ASSIGN—*p = v/p.field = vгде p roE_POINTER_RO_MUT_METHOD—p.mut_method()где pointee readonly (*T). ENFORCED 2026-08-08 (Plan 221.1 №387, window pptr-ro-guard) — see §5 amendment above (was declared-but-toothless since Plan 118).E_PTR_CAST_INVALID_TARGET—p as bool / f64 / ...E_INVALID_POINTER_MODIFIER—*const Tи др.E_POINTER_PREFIX_MODIFIER—ro/mut/unsafeперед*в type-position (mut * T/ro * T/unsafe * T); use postfix*mut T/*ro T/*unsafe Tили bindingmut x *T. ExtendsE_INVALID_POINTER_MODIFIER(Plan 138.5 §1).E_DUPLICATE_POINTER_MODIFIER—*ro mut TE_PARSE_POINTER_TYPE_INCOMPLETE—*без typeE_REALTIME_POINTER_OP— pointer op в#realtime fnbody. Active enforcement — D216 §20 + Plan 113 D172 V1 ENFORCED 2026-06-02 (commit 6752565f453)E_UNSAFE_HANDLER_BUILTIN_ONLY— user-defined unsafe_handler attemptE_AMP_CONST_BINDING—&const_valueE_AMP_LITERAL—&42E_AMP_RECORD_LITERAL—&Record { ... }без named binding (Plan 118 §4 amend)E_ADDR_OF_NON_LVALUE—addr_of/addr_of_mutapplied к 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_BINDING—addr_of_mutapplied к ro binding (let безmut, ro parameter, ro field). MirrorsE_PARAM_NOT_MUT/E_LOCAL_NOT_MUTpattern (D108.1 / D108.2). Plan 118.1 closeout 2026-06-05.E_ADDR_OF_REMOVED—addr_of()/addr_of_mut()called after retirement (Plan 118.6 D216 §4, 2026-06-16). Use&xinstead. 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 callE_CAST_RAW_FN_TO_CLOSURE—*fn → fncast outside unsafeE_UNSAFE_UNUSED—unsafe { }block with no operation from the §21 map inside it (unsafe-cluster, Plan 174.5 followup, 2026-07-11). Hard error (owner decision — Rust’sunused_unsafeis a warning). See §21 for the full map + implementation + std/ triage.
Warnings:
W_UNSAFE_GC_TRIGGER— GC trigger внутри unsafe с pointer in scopeW_PTR_AS_INT_GC_HASH_HAZARD—p as intкак HashMap keyW_OPTION_DOUBLE_NESTED—Option[Option[*T]]NPO fallback
Mainstream comparison
| Язык | Typed ptr | Unsafe model | Null safety | Auto-deref | Arithmetic |
|---|---|---|---|---|---|
| Rust | *const T/*mut T/&T/&mut T | unsafe { } + unsafe fn | Option<&T> + NPO | через ref | unsafe only |
| Zig | *T/*const T/[*]T | (нет keyword; intrinsics) | ?*T + NPO | .* postfix + . | + для [*]T only |
| C# | T* (unmanaged) / ref T / in T / out T | unsafe modifier | T? | p->field | unsafe only |
| Swift | UnsafePointer<T> / UnsafeMutablePointer<T> | Type-based (Unsafe* prefix) | Optional + NPO | .pointee | only через advanced(by:) |
| D | T* / ref T / scope T* | @safe/@trusted/@system | Nullable!T | p.field auto | @system only |
| Go | *T (managed); unsafe.Pointer | unsafe package | Nil runtime | p.field auto | unsafe.Pointer only |
| Nova V1 (Plan 115) | ptr only | (нет) | null ptr | (нет) | banned |
| Nova V2 (Plan 118) | *T family + unsafe | unsafe { } + unsafe fn (D2 amend) | Option[*T] + NPO | p.field/p.method() one-level | gated 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) —
&valuenot Rust borrow - D52 — type forms (tuple newtype canonical для FFI handles)
- D214 (amend) — ptr redefine
- D215 — Plan 120 stack tuples (escape interaction)
- D172 —
#realtimeban для 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).
АМЕНДМЕНТ 2026-09-25 (решение владельца, план 246) — линейность и операции указателя
Вопрос, на который отвечает. Таблица D133 «Что считается consume» не знала сырой памяти:
запись p.write(v) / p.write_at(i, v) значение-вид не потребляла, а результат p.read() /
p.read_at(i) для must-consume T был копией элемента, остающегося в памяти. Поэтому ни одно
тело контейнера линейных значений не было верным (замер плана 246: забирающий push и 55 чтений
read_at в 33 методах Vec). Правило «под unsafe линейность не отслеживается» отвергнуто
владельцем: p.read() и p.write(v) законны и в safe-коде (D483), а требование unsafe у
адресной арифметики по §21 п. 8 держится соглашением — такое правило стояло бы ни на чём.
Правило — одинаковое в safe- и unsafe-коде, из уже существующих средств. Относится к
p.write(v), p.write_at(i, v), p.read(), p.read_at(i) и новым p.write_consume(v),
p.write_consume_at(i, v), p.read_consume(), p.read_consume_at(i), p.view(f),
p.view_at(i, f). Варианты _unaligned / _volatile
(регистры, упакованные данные) — только для обычного T: линейных значений там не бывает, и
комбинаций вида read_consume_volatile_at язык не заводит.
- Запись — две формы, и различает их ИМЯ, как у чтения (уточнение владельца 2026-09-25
тем же часом; правило 3 D84 для встроенных операций не нужно — эффект виден в имени):
p.write(v)/p.write_at(i, v)— копия, только для обычногоT: в память кладётся копия, источник жив; для must-consumeTнедоступна (D156, амендмент 2026-09-24, п. 1). Смысл этих операций для обычного кода не меняется;p.write_consume(v)/p.write_consume_at(i, v)— владение в память, для любогоT: параметр значения —consume, обязательство снимается как у любой передачи в consume-параметр (таблица D133).
- Чтение — две формы, и различает их ИМЯ (у чтения нет параметра, который нёс бы режим;
уточнение владельца 2026-09-25 тем же часом):
p.read()/p.read_at(i)— копия, только для обычногоT; для must-consumeTнедоступна, как копирующая запись. Смысл этих операций для обычного кода не меняется;p.read_consume()/p.read_consume_at(i)— изъятие, для любогоT: результат — владеющее значение, для must-consumeTс обязательством. Ячейка после изъятия считается пустой: второе изъятие той же ячейки без записи между ними — ответственность автора кода и не диагностируется (какptr::readв Rust). Для обычногоTизъятие — копия. Имя делает «ячейка теперь пуста» видимым в месте вызова.
- Посмотреть, не изымая, —
p.view(f)/p.view_at(i, f), гдеf fn(T) -> R, результатR. Элемент одалживается замыканию ВИД-ПАРАМЕТРОМ — единственной позицией вида по D180 («views существуют ТОЛЬКО как function params»), ячейка не изымается.view_at— адресная арифметика и живёт там же, гдеread_at(D483). Имя —view(решение владельца 2026-09-25): это слово самой нормы, элемент отдаётся ВИДОМ; с отвергнутым в первой редакции D157 ключевым словом типаview Tне связано — здесь это имя метода. (withзанято ключевым словом.) - Массовые переносы (
copy_n,copy_from,copy_toи их_nonoverlapping) значенийTне принимают и не отдают — линейность их не касается; что источник после переноса пуст, отвечает автор.
Почему не ref T (D480). Ссылка D480 — законный возврат, но о линейных значениях D480
молчит, а ссылка переживает вызов. Лайфтаймов в языке нет (D465: удержать заимствование нечем),
и после изъятия элемента ссылка указывала бы на пустую ячейку. Вид-параметр view живёт ровно
вызов и потому безопасен без лайфтаймов.
Форма записи. Это встроенные операции (§21 п. 8, не Item::Fn); их подписи задаёт этот
амендмент, а не объявление в .nv. Отдельной записи бáунда consume у приёмника-указателя в
языке нет, и этот амендмент её не вводит: для пользователя операции выглядят как пара
перегрузок по режиму значения.
Следствия. Контейнер (Vec) пишет вставку write_consume_at, изъятие —
read_consume_at, просмотр элемента — view_at и отдаёт наружу @view(i, f); план 246, волна
Vec[T consume].
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.3unsafe Tvalue-wrapper (MaybeUninit-style) — про значение, ортогонально указателям; и универсальное right-binding для value-T модификаторов (ro T/mut T/unsafe Twrappers, 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 Tsyntax — 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 T | Readonly(T) — value-T wrapper, codegen-transparent (KEPT) |
mut T | Mut(T) — value-T wrapper, codegen-transparent (KEPT, §V2.2b) |
uninit T | Uninit(T) — value-T wrapper, MaybeUninit (KEPT, §V2.3; §10a rename Plan 174.5 2026-07-11, was unsafe T) |
consume T | consume wrapper (receiver/field/decl, см. D162) |
*T | Pointer(T) — pointee ro (≡ *ro T, D246; pointee-mut из типа, не от binding) |
*ro T | ❌ E_REDUNDANT_POINTER_RO (избыточно: *T уже ro; fix-it *T) — D246 |
*mut T | Pointer(Mut(T)) — explicit mut pointee (единственный опт-ин на *p = …) |
*uninit T | Pointer(Uninit(T)) — CANONICAL pointer к possibly-uninit T (§10a rename, was *unsafe T) |
mut * T | ❌ E_POINTER_PREFIX_MODIFIER (prefix перед * запрещён; use mut x *T binding) |
ro * T | ❌ E_POINTER_PREFIX_MODIFIER |
uninit * T | ❌ E_POINTER_PREFIX_MODIFIER (RETIRED Unsafe(Pointer); nullable = Option[*T], FFI nullable-uninit = Option[*uninit 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 (единственный опт-ин)
*uninit T // Pointer(Uninit(T)) — valid (non-null) ptr к possibly-uninit T (§10a rename)
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 *uninit u8 // binding mut: p reassignable; pointee possibly-uninit, НЕ writable (*p/… ❌; №358 2026-08-05 — запись только через *mut uninit u8)
RETRACTED (теперь parse error E_POINTER_PREFIX_MODIFIER):
mut * T // ❌ — вместо: binding `mut p *T`
ro * T // ❌ — вместо: binding `let p *T` (или `ro p *T`)
uninit * T // ❌ — RETIRED Unsafe(Pointer); вместо: Option[*T] / *uninit T (pointee)
mut * ro * T // ❌ — вместо: `mut p *ro *... ` postfix-chain
ro p mut * uninit T // ❌ — вместо: `mut p *uninit T` (binding mut + pointee uninit)
Канонический пример FFI out-param (FINAL; §10a rename Plan 174.5 2026-07-11 — was *unsafe u8):
external fn os_read(fd int, buf *uninit 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 Tvalue-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 Tvalue — requiresunsafe { }block (caller asserts init) - Write
unsafe Tslot — safe (transitions to valid) - Cast
unsafe T → T— requiresunsafe { }+ value-level assertion (e.g.unsafe { x as T }или dedicatedassume_initbuiltin)
Соответствует 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-байтные строки). После retireunsafe *(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 OK | 8 байт ✅ |
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 errorE_POINTER_PREFIX_MODIFIER. Value-T wrappers (Mut/Unsafe/Readonly, codegen-transparent) сохраняются (AST/codegen/checker шаги ниже про них KEPT).
-
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 запрещён).
-
Parser changes (FINAL):
mut T/unsafe Tparse arms (value-T) — recursive. KEPT.*Star branch парсит postfix pointee modifier (*mut/*ro/*unsafe).- Reject
ro/mut/unsafetoken непосредственно перед*→E_POINTER_PREFIX_MODIFIER(НЕ warning — hard error в Plan 138.5 enforce-фазе).
-
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 модификаторы; все emitT*ABI (mut/ro различаются на assignment-check).
-
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) → Texplicit (unsafe cast).- NPO — универсальный (§V2.4, все
*…non-null → 8 байт).
-
Migration sweep (Plan 138.5 Ф.2 enforce):
- prefix usages
mut */ro */unsafe *→ postfix*mut T/*ro T/*unsafe T(или bindingmut x *T/ nullableOption[*T]). Основной call-site:std/runtime/raw_mem.nv(dst mut * u8→dst *mut u8). - Retire
Unsafe(Pointer)(unsafe * T): →Option[*T]/Option[*unsafe T].
- prefix usages
-
Spec amends downstream:
§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 T→E_REDUNDANT_POINTER_RO(избыточно). Codegenpromote_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-promotemut 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 errorE_REDUNDANT_POINTER_RO(избыточно:*Tуже ro; fix-it*T). D246.mut * T/ro * T/unsafe * T(prefix перед*) — hard errorE_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-формы и миграция на prefixmut * 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_MODIFIERenforce — 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 Tvalue-wrapper transparent; pointer-mut = pointee postfix / binding, Plan 138.5)[M-118.5-consume-as-type-modifier]— generalizeconsume(currently receiver/field/decl) к универсальный type wrapper[M-118.5-d218-maybeuninit-duplication]— ✅ CLOSED (D218 RETRACTED — MaybeUninit subsumedunsafe 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 T→E_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 ro 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 Ttype 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. AMENDED 2026-07-23 ([M-ro-launder-via-mut-binding], Plan 224): closes the L1×L2-cross-binding coercion gap — §«P8 AMEND (2026-07-23)» below RETRACTS the old P8 prose («coercion по оси content (L2), независимо от L1»); see the new §«Таблица конверсий между binding’ами (L1,L2)источник → (L1,L2)цель»** and ORACLE rows G/H. AMENDED 2026-07-24 §72 ([M-ro-launder-fullstack-value-exemption], Plan 226, owner decision: “ДА — «полностью-стековый value-тип, проба G остаётся neg»”): РАСШИРЯЕТ исключение из P8/таблицы конверсий с «голый скаляр-примитив» на «ПОЛНОСТЬЮ- СТЕКОВЫЙ value-тип» — рекурсивный предикатis_fully_stack_value(types/mod.rs): тип И ВСЕ его поля транзитивно без кучевых (Vec/HashMap/Set/heap-record/Array).str— ОТДЕЛЬНОЕ исключение по immutability (D26), не по стековости (см. §«ORACLE G» ниже). Граница НЕ сдвинута: проба G (value-record С кучевым полем) остаётся❌; проба E (value-record БЕЗ кучевых полей, все скаляр-поля) RE-RECLASSIFIED neg→pos — теперь✅(квалифицируется как fully-stack). Реализация — 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 (critiquewkx3dytr1, value-sidewlqgc2nyk, synthesisw9nktq8x1) + ~15 раундов ревью. Гейтит Plan 139[M-139-f0-lang-item-decl](str-поляptr *u8). AMENDED 2026-08-01 (решение владельца 2026-08-01, срочный пакет звучности mut/захватов): дословная норма владельца — «Присваивание ro в mut возможно, ЕСЛИ все поля тоже значения на стеке РЕКУРСИВНО; MetricsRegistry содержит типы на куче, и мы начинаем их менять после присваивания — это неверно» — ПОДТВЕРЖДАЕТ буквально уже действующий §72 (is_fully_stack_value, 2026-07-24) без изменения границы:Mutex/HashMap— кучевые поля (неAllocKind::Value-record/scalar/str), такMetricsRegistry{lock Mutex, counters HashMap[...], gauges HashMap[...]}уже❌под §72 (проба-G-класс) на всех трёх энфорснутых каналах (let-init / call-argument mut-параметр / return,is_fully_stack_valueatcompiler-codegen/src/types/mod.rs:10455,16179,35290). Живой разбор находки владельца:nova-polaris/src/metrics.nv’s методы (mut lock = @lock,mut counters = @counters) сегодня компилируются НЕ потому, что этот L1-Ident-канал имеет дыру — источник там@field(self-field access), НЕ голыйIdent, поэтомуcheck_readonly_source_coerce’sif let ExprKind::Ident(name) = &value.kindвообще не матчит. Это ВТОРОЙ, независимый канал той же дыры — field-launder — закрыт отдельно ниже (см. «§73. Field-launder канал (mut x = @field/obj.field)»,[M-router-handler-mut-capture-escape-soundness]реестр, срочный пакет звучности 2026-08-01).
Что
Происхождение: 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 ERRORE_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 T→E_REDUNDANT_TYPE_MODIFIER.mut x mut T→E_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 …нельзя —*roredundant).*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 T ≡ ro 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 *T ≡ ro v *T | ❌ | ❌ |
mut v *T | ✅ | ❌ |
v *mut T ≡ ro 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)
- P1 — три оси ортогональны. L1×L2×L3 не влияют друг на друга. mut-binding НЕ даёт mut-pointee; mut-pointee НЕ делает имя reassignable.
- P2 — тип самодостаточен (C1).
*Tозначает ro-pointee в ЛЮБОЙ позиции, без running-current/контекста. - P3 —
roдефолт везде; опт-ин на запись явный (mut x/mut T/*mut T). - P4 — L2 freeze СТОИТ на каждом
*. Транзитивный ro-view не проникает за указатель; за*действует только L3 (из типа pointee). - P5 — L3 из типа, позиционно-независимо.
*mut T= writable target где угодно;*T= ro где угодно. - P6 — split (L1,L2) явны.
ro r mut Point(reassign❌/content✅),mut r ro Point(reassign✅/content❌). Разрешает[M-138-binding-type-mut-conflict]. - P7 — голый
ro r= freeze (binding dominates, D175 §V2): и reassign, и весь owned-граф (до стены на*). - P8 — coercion по оси content (L2) —
независимо от L1RETRACTED 2026-07-23 ([M-ro-launder-via-mut-binding]). ro-источник → mut-content-цель =E_READONLY_COERCE; → ro-цель OK.*mut T → *Tавто-сужение;*T → *mut T❌. AMEND: это верно для L2 (тип-модификаторro T), но старая формулировка «независимо от L1» была ДЫРОЙ, не намерением — она разрешала L1-ro источнику (голыйro a = …биндинг ЛИБО параметр по D176-дефолту, P7 freeze) свободно затекать в mut-цель при смене биндинга, потому что coercion проверялся ТОЛЬКО по типу источника (L2), никогда по биндингу источника (L1). P7 декларирует заморозку («голыйro r= freeze: и reassign, и весь owned-граф»), но заморозка не переживала ре-биндинг/передачу аргументом — сама P7 не спорна, спорной была P8, читавшая её как «L1 источника не участвует в coercion». Исправленная норма: coercion учитывает L1 ОБЕИХ сторон — источника И цели, не только цели. Полная таблица — §«Таблица конверсий между binding’ами» ниже; норма СТРОГАЯ (владелец, 2026-07-23, ФИНАЛ — подтверждена доками+пробой): применяется ко ВСЕМ типам без исключения по классу хранения (см. эту секцию — проба G доказала «value-тип ⇒ безопасно» ЛОЖНО в общем случае: value-record С кучевым полем остаётся ❌) — КРОМЕ одного явно ограниченного случая, РАСШИРЕННОГО 2026-07-24 §72 ([M-ro-launder-fullstack-value-exemption]): исходно (Plan 224) исключение было ограничено голыми скалярными примитивами (int/i8…i64/u8…u64/f32/f64/bool/char/byte— типы БЕЗ единого поля вообще), потому что классовый предикат «value-тип» пришлось бы делать рекурсивным по полям, чтобы оставаться безопасным (проба G — value-record С кучевым полем — доказала, что нерекурсивная классификация «value ⇒ безопасно» ложна), а скаляр эту рекурсивную хрупкость обходит ПОЛНОСТЬЮ, имея вообще ноль полей. §72 строит именно этот рекурсивный предикат (is_fully_stack_value, types/mod.rs) и на нём РАСШИРЯЕТ исключение с «скаляр-примитив» на «ПОЛНОСТЬЮ-СТЕКОВЫЙ value-тип»: тип и ВСЕ его поля транзитивно не содержат ни единого кучевого поля (Vec/HashMap/Set/heap-record/Array) — базовый случай рекурсии — скаляр (как раньше), плюсstrпо ОТДЕЛЬНОЙ причине (immutability, D26, не стековость — см. ORACLE G ниже). Единственное кучевое поле, на любой глубине вложенности, дисквалифицирует ВЕСЬ тип (проба G, без изменений —ServerResponse{HeaderMap,[]u8}остаётся ❌); проба E (Point{x int,y int}, value-record БЕЗ кучевых полей вообще) под §72 RE-RECLASSIFIED neg→pos — квалифицируется как fully-stack, копия целиком независима. Санкционированная дверь для независимой копии типа, НЕ прошедшегоis_fully_stack_value, — явный.clone()(D230). - P9 — deep-immutable НЕ навязывается снаружи сквозь
*mut(trade-off):-> ro VRморозит свои слоты, ноunsafe{*v.p=w}проходит (L2 не лезет за*). Deep-ro → производитель объявляет поле*T(какstr { ptr *u8 }). - 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=X✅ r.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=q❌
unsafe{*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).
G. CROSS-BINDING re-init (AMEND 2026-07-23, [M-ro-launder-via-mut-binding];
letter G continues past the headed #### ORACLE F below to avoid a
letter-collision):
ro a = [1,2,3]; mut b = a; b[0]=99 → E_READONLY_COERCE на mut b = a (source
a — L1-ro bare binding, P7 freeze; target b — mut content-view; write через
b была бы видна a). Применяется независимо от storage-класса T — включая
value-record БЕЗ кучевых полей (Point{x,y}, проба E — по-прежнему ❌,
классовое послабление по value-record отклонено) и value-record С кучевым
полем (ServerResponse { headers HeaderMap, body []u8 } → mut resp = r; resp.header(..) пишет в HeaderMap вызывающего). ИСКЛЮЧЕНИЕ (владелец,
ФИНАЛ 2026-07-23): голые скалярные примитивы — fn f(n int) { mut m = n }
✅ ЛЕГАЛЬНО (копия int независима by construction, полей нет вообще —
скаляр не «value-тип» в смысле пробы G, у него нет owned-графа для утечки).
Дверь для НЕ-скалярных типов: mut b = a.clone() (D230) для независимой
копии, либо сделай источник mut с самого начала, если copy не нужна.
AMEND 2026-07-25 №106 ([M-ro-launder-pattern-bind-not-enforced], реестр
221.1, Plan 224/226 зона): §G/§H выше были прописаны и enforced ТОЛЬКО для
ДЕКЛАРИРОВАННЫХ ro-биндингов/D176-параметров — ПАТТЕРН-биндинги (match-arm
Ok(b0) => …, if Some(x) = …, D34 «bare pattern-bind = immutable») в L1
launder-таблицу (ro_binding_names) заведены НЕ были, так что то же самое
отмывание проходило МОЛЧА через паттерн: Ok(b0) => { mut b = b0 } на
кучевом payload (b0 — bare pattern-bind, D34-immutable) НЕ давал
E_READONLY_COERCE, хотя семантически — тот же класс §G (b содержит тот
же handle, что b0/оригинальный источник). Норма: pattern-bind
получает L1-статус ИДЕНТИЧНО explicit-биндингу — bare (Ok(b0)) = ro-
freeze (P7), mut внутри паттерна (Ok(mut b0)) = mut, участвует в §G/§H
launder-проверке на общих основаниях (включая §72 fully-stack-исключение —
bare-bind скаляра → mut остаётся ✅). Канон-запись для решения — mut
ВНУТРИ паттерна (Ok(mut b)), не отдельный cross-binding.
H. CROSS-BINDING call-argument (AMEND 2026-07-23): тот же класс без
промежуточного re-init — аргумент напрямую: fn outer(v []int) { fill(v) }
при fn fill(mut v []int) → E_READONLY_COERCE на fill(v) (v — L1-ro
параметр outer-а по D176-дефолту, П7 freeze, передаётся в mut-параметр
fill). Тот же результат для ro a = [..]; fill(a) (ro-локал). Применяется
для методов симметрично (recv.method(arg) где arg L1-ro → mut-параметр
метода). Исключение (checker precision, не норма): имена, перегруженные
по оси режима {ro,mut,consume} (D326-ревизия, Р13/Р14 — fn f(x T) /
fn f(mut x T) / fn f(consume x T) под одним именем) НЕ проверяются этим
правилом — dispatch резолвит КОНКРЕТНЫЙ overload по режиму аргумента (ro-арг
→ ro-overload), так что L1-ro аргумент, дошедший до overloaded-имени с
mut-веткой где-то среди перегрузок, не обязательно течёт в НЕЁ; текущая
registry-инфраструктура (fn_mut_params/method_mut_params) хранит mut-idx
по ИМЕНИ (все перегрузки конфлированы), поэтому прецизионная проверка
per-overload — Ф.1-followup, не в этом амендменте.
Таблица конверсий между binding’ами (L1,L2)источник → (L1,L2)цель
НОВОЕ 2026-07-23 ([M-ro-launder-via-mut-binding]) — до этого амендмента D246 описывал ТОЛЬКО права доступа ВНУТРИ одного биндинга (таблицы binding×content/параметры/указатели выше); перенос значения МЕЖДУ биндингами (инициализация нового binding’а · аргумент вызова · возврат) был свёрнут в одну строку P8, покрывавшую только L2 (тип-модификатор) — L1 (биндинг источника) не проверялся ВООБЩЕ. Ниже — полная таблица; применяется одинаково к ВСЕМ ТРЁМ позициям (инициализация · аргумент · возврат) и ко ВСЕМ типам, КРОМЕ голых скалярных примитивов (владелец 2026-07-23, ФИНАЛ: классовое послабление по value-record СНЯТО — проба G этой секции доказала «value-тип ≠ безопасно» даже без кучевых полей, проба E остаётся neg — но УЗКОЕ послабление для скаляров-примитивов ВОЗВРАЩЕНО: скаляр не имеет полей вообще, рекурсивная хрупкость пробы G к нему неприменима, копия всегда независима by construction).
| Источник (L1 биндинга) | → цель с mut content-view (L2) | → цель с ro content-view (L2) |
|---|---|---|
mut (reassignable, любой L2 у источника) | ✅ | ✅ (сужение прав, D176) |
голый ro, тип — скаляр-примитив (int/i8…i64/u8…u64/f32/f64/bool/char/byte, БЕЗ полей вообще) | ✅ ИСКЛЮЧЕНИЕ (копия независима by construction — нет owned-графа) | ✅ |
голый ro, тип — value-запись/tuple/[N]T/heap (P7 freeze — явный ro a = … ЛИБО параметр по D176-дефолту, БЕЗ явного mut T split) | ❌ E_READONLY_COERCE | ✅ |
split ro a mut T (L1 ro, L2 mut явно), любой тип | ❌ E_READONLY_COERCE¹ | ✅ |
¹ Split-источник (
ro a mut T) уже разрешает запись НАПРЯМУЮ черезa(a.x=v✅, P6/R2-split) — поэтому перенос в mut-цель НЕ добавляет новых прав записи по сравнению с прямой записью черезa, но норма всё равно отклоняет перенос МЕЖДУ биндингами единообразно (владелец: «параметр не может быть присвоен в mut-переменную», без оговорок на split-форму); прямая запись через исходный split-биндинг остаётся легальной и достаточной для тех, кому нужна mutation без re-binding. Скалярный exemption НЕ распространяется на split — split уже подразумевает составной тип с явным L2-mut content-view, скаляр в этой форме не встречается осмысленно (ro a mut intэквивалентноro a intдля примитива без owned-графа, но норма не вводит для этого отдельного правила — используй простоmut a int, если нужна мутация).
Диагностика (порядок подсказок, владелец 2026-07-23): сообщение
E_READONLY_COERCE в этом амендменте предлагает РЕШЕНИЯ в порядке (a) →
(b) → (c): (a) первым — mut-параметр/локал («если источник — параметр
текущей функции, сделай его mut x T — это ВСЕГДА in-out по D326-ревизии
§Р3, вызывающий увидит изменения после вызова; если локал — объяви mut с
самого начала»), (b) вторым — явный .clone() (D230, независимая
копия — использовать, когда (a) не подходит семантически, с одностроч-
комментом-обоснованием почему), (c) третьим — оставить цель ro. Порядок
намеренный: in-out через mut-параметр — идиоматичный канон Nova (D326),
.clone() — осознанный опт-ин с видимой стоимостью копии, не дефолт.
L2-ro источник (ro T тип-модификатор) в mut-цель уже был E_READONLY_COERCE
до этого амендмента (проба J/P8 старая) — без изменений, таблица выше просто
делает явным, что L1-ось источника учитывается СИММЕТРИЧНО L2-оси источника
(раньше проверялась только L2).
АМЕНДМЕНТ 2026-08-21 — ro ЗАРАЖАЕТ ТОЛЬКО ПО АЛИАСУ
Решение владельца 2026-08-21, реестр 221.1 №717. Амендирует таблицу конверсий (L1,L2) выше в части ЛОКАЛЕЙ.
НОРМА. Локаль, чьё значение есть ПСЕВДОНИМ ro-источника, сама ro и подчиняется P7/P8 наравне с источником. Локаль, связанная со СВЕЖИМ значением, — ОБЫЧНАЯ локаль, сколько бы немутабельной ни была её привязка.
АЛИАС определяется по ФОРМЕ инициализатора — это МЕСТО (place), укоренённое в ro-имени:
| инициализатор | ro al = … — алиас? |
|---|---|
v (ro-параметр либо ro-алиас) | ДА |
v.field, v[i] — проекция ro-источника | ДА |
f(…) / v.m(…) — результат вызова | НЕТ (свежее) |
литерал, конструктор, арифметика, .clone() | НЕТ (свежее) |
Почему различитель обязателен, в числах. Без него проверка
вырождается в «нельзя возвращать ни одну немутабельную локаль»: замер
2026-08-20 дал nova check std/src = PASS: 3 FAIL: 177 и 773
срабатывания E_READONLY_COERCE при каноне 26. Причина — ro x = …
обычная форма записи в Nova; std/src/collections/vec/mutate.nv:133
возвращает removed — СВЕЖЕЕ снятое значение, ничей не псевдоним,
и отвергалось бы только за немутабельность привязки.
ПОЗИЦИЯ ХВОСТА ВЕТВИ. Хвост ветви if/match, стоящей в позиции
значения тела функции, — ТОЖЕ возврат, и алиас-правило действует там
наравне с головным хвостом. Тела циклов в эту позицию НЕ входят.
ГРАНИЦА С ВАРИАНТОМ (в), и она намеренная. Решение владельца
2026-08-18 — выводить возврат как ro, когда внутрь попал ro-источник, —
НЕ ОТМЕНЯЕТСЯ и действует вместе с этим: (в) отвечает за ВОЗВРАТ (в
том числе через обёртку: кортеж, запись, Some/Ok, конструктор),
алиас-правило — за ЛОКАЛИ. Следствие для позиции хвоста ветви:
не-mut ПАРАМЕТР, возвращённый оттуда, сегодня НЕ судится — это
территория (в), а не алиас-правила. Цена обратного решения измерена
2026-08-22: 27 E_READONLY_COERCE в std и 3 в nova-http, и все до единого —
параметр в хвосте ветви (@min/@max/@clamp/@or/@fold/@plus), то
есть ровно то, что (в) велит ВЫВОДИТЬ как ro, а не отвергать.
Что это закрывает в реализации (реестр №717, дыры (2) и (3)): происхождение биндинга запоминается ОДИН РАЗ, в момент заведения, и поэтому восстановление ro-множества на выходе из блока (правильное само по себе) больше не стирает его до проверки возврата. Это тот же приём per-binding provenance, что и в амендменте к D432 §2 выше, и это не совпадение: оба дефекта о том, что свойство раздавалось по ФОРМЕ ЗАПИСИ, а не по ПРОИСХОЖДЕНИЮ.
Норму держат фикстуры: neg/m717_ro_alias_local_return_neg.nv (алиас
через локаль = ошибка), neg/m717_ro_alias_branch_tail_neg.nv (то же через
хвост ветви), m717_ro_fresh_local_return_ok.nv (свежее значение законно),
m717_ro_return_type_ok.nv (явный -> ro T — граница варианта (в)).
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/*ropointee (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] = x→E_READONLY_CONTENT;func(v []int)→v[i] = x→E_READONLY_CONTENT(param ro-by-default D176, P7 freeze).E_READONLY_COERCE(существует) — ro-content-источник → mut-content-цель (P8); AMENDED 2026-07-23 ([M-ro-launder-via-mut-binding]) — теперь ТАКЖЕ учитывает L1-ось источника (не только L2): голыйro-биндинг/параметр-по-D176-дефолту → mut-цель = ошибка во ВСЕХ трёх позициях (инициализация · аргумент · возврат), для ВСЕХ типов без исключения (ORACLE G/H, таблица конверсий выше). Дверь для независимой копии — явный.clone()(D230).
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’ы (намеренно)
- Deep-immutable сквозь
*mutнельзя навязать снаружи (P9, C++ shallow-const): deep-ro → производитель объявляет поле*T. - Shared-mut heap-record под чужим
roвозможен (GC, нет эксклюзивности):ro= per-path write-ban, не object-freeze (P10). - 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 ro 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]. - P7/P8 (AMEND 2026-07-23) — P8’s old «независимо от L1» retracted; coercion
now checks the SOURCE’s L1 binding too, closing the gap where P7’s freeze
(“голый
ro r= freeze”) did not survive a re-binding/argument-pass. See §«Таблица конверсий между binding’ами» + ORACLE G/H above. - D230 (
Cloneprotocol) — the sanctioned door for an independent mutable copy of an L1-ro source (.clone()), now load-bearing for this amendment’s migration (types withoutCloneneed it added, or the site restructured).
Связь
[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]. [M-ro-launder-via-mut-binding](norm+checker landed 2026-07-23; migration IN PROGRESS, NOT closed — see Plan 224) — L1-ro launder via re-binding/argument-pass.
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 explicitprivтоже module-private — по таблице D281; type-private поле пишетсяpriv(type)(правка формулировки 2026-09-24 по решению владельца: прежняя фраза «остаётся type-private (без изменений)» противоречила таблице D281 и компилятору, который уже судит по D281). Реализация — Plan 124. Empirical validation — docs/dev/research/06-field-visibility-go-kubernetes.md. Amends D47 (replaces deprecated_prefixconvention с 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:
- Explicit
pubfield modifier → priv_field = false (public). - Explicit
privfield modifier → priv_field = true (private). - Type-level default (
type X priv {...}→ priv_field = true). - 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/dev/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 /
@fieldshorthand), D17 (pattern syntax).
§1 Scope
D220 §4 описывает priv-access rules для базовых форм (Member access
- Stmt::Let pattern + RecordLit named fields). D221 расширяет coverage на:
- Дополнительные pattern sites: match, if-let, while-let, for-in, parallel-for.
- Nested Pattern::Record — recursive descent через sub-field types.
- Record literal spread
Type { ...other }. - 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):
| Site | AST node | Scrutinee source |
|---|---|---|
let PAT = EXPR | Stmt::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
| Code | Where | Plan |
|---|---|---|
E_PRIV_FIELD_PATTERN | Pattern sites §2 + nested §4 | D220 §4 (reused) |
E_PRIV_FIELD_INIT_SPREAD | RecordLit spread §5 | D221 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 f64 → E_PRIV_PUB_CONFLICT.
§2 Access rules — uniform with D220
Read (v.x):
- Inside named tuple’s own methods (instance
@fieldor staticT.method(...)) — OK regardless of priv. - Outside →
E_PRIV_FIELD_READесли field marked priv.
Init via constructor (Vec3(1.0, 2.0, 3.0) — позиционно; именованная
форма допустима ТОЛЬКО для полей С дефолтом, D102):
- 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_PATTERNper priv field;..rest is non-binding; nested descent recursive.
Write — N/A: named tuple fields are immutable by D215 design
Амендмент 2026-08-05 — конструирование и деструктуризация именованного кортежа
Правило не было записано ни в одном D-блоке (D221 §7 прямо признаёт, что покрывает только форму записи, а D222 описывает лишь доступ к приватным полям внутри уже существующей формы). Пробел вскрылся при закрытии №145: энфорс появился в компиляторе раньше, чем формулировка в спеке. Восполняется здесь.
Конструирование — позиционное. Vec3(1.0, 2.0, 3.0). Именованная форма
допустима ТОЛЬКО для полей, у которых объявлен дефолт — по общему правилу
D102: обязательный параметр передаётся позиционно,
опциональный — по имени. Прежние примеры в D215 и D222 показывали
именованные аргументы для полей без дефолтов и тем противоречили D102;
исправлены этим же амендментом.
Деструктуризация — только фигурной формой, по имени.
| Форма | На именованном кортеже |
|---|---|
{ x, y } — фигурная, по имени | разрешена |
(a, b) — круглая, по позиции | ошибка компиляции |
Круглая скобка означает разбор по позиции и остаётся формой позиционного кортежа; именованный кортеж разбирается по именам полей, как запись. Смысл различения тот же, что у конструирования: у именованного кортежа имена полей — часть контракта, и разбор по порядку молча сломается при перестановке полей, которая для именованной формы законна.
Частичный разбор — как у записей (D411): если перечислены не все
поля, обязателен явный ...
Реализация: проверка конструирования — в f5_check_tuple_construct,
запрет позиционного разбора — отдельной проверкой чекера
(check_positional_destructure_on_named_tuple), обе в чекер-канале
(волна p248tup-145, 2026-08-05). Проверка биндинг-агностична — применяется
к let/ro/mut/consume одинаково; consume-биндинг получил доступ к
деструктуризации вообще (обе формы) только окном p378 2026-08-06 (D180
амендмент «№378»), но правило круглая/фигурная было записано здесь раньше
и не менялось.
(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):
-
Type-method impl —
fn Vec3 @to_string() -> str. Receiver = T;current_recv_type = Some("Vec3")→ priv access OK канонически. ✅ Allowed. -
External free-fn —
fn compute_sum(v Vec3) -> f64 => v.x + v.y + v.z. No receiver tracking;current_recv_type = None→E_PRIV_FIELD_READfires при 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
| Code | Site | Plan |
|---|---|---|
E_PRIV_FIELD_READ | Member access на priv named-tuple field | D220 §4 (reused) |
E_PRIV_FIELD_INIT | T(field: ...) named-arg ctor priv field | D220 §4 (reused) |
E_PRIV_FIELD_PATTERN | T { field, ... } = v priv field | D221 §2 (reused) |
E_PRIV_PUB_CONFLICT | priv pub / pub priv mutual exclusion | D220 §6 (reused) |
E_TUPLE_POSITIONAL_ACCESS_ON_NAMED | .0 access | D215 / Plan 120 (preexisting) |
No new codes — D222 reuses D220/D221 codes uniformly. Spec mentions named-tuple context в hint text.
§5 Implementation hooks
| Layer | Site | Change |
|---|---|---|
| AST | NamedTupleField struct | Added priv_field: bool |
| Lexer | KwPriv, KwPub | Already declared (Plan 124.1) |
| Parser | is_named_tuple_decl | Recognize priv/pub as named-marker |
| Parser | parse_named_tuple_fields | Accept priv/pub modifier с conflict-check |
| Checker | f3_check_member NamedTuple arm | Added priv check (mirror Record) |
| Checker | f5_check_tuple_construct | INIT priv check on named-args |
| Checker | check_priv_pattern_recursive | Unified для 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
.0access — handled by preexisting Plan 120 E_TUPLE_POSITIONAL_ACCESS_ON_NAMED (priv-orthogonal). - A4.3 ✅ Named
.xaccess на 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 без
privmodifier работают 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-privateflag shows all fields сprivkeyword preserved in signature rendering (type X { priv mut f T }).- JSON output emits
"priv_field": true|falseper field regardless of--include-private— consumed by tooling.
LSP integration (forward-ref):
- AST
RecordField.priv_field+NamedTupleField.priv_fieldflags 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/guide/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 dochides priv fields by default. - A5.2 ✅
nova doc --include-privateshows 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/guide/field-visibility-guide.mdcreated (~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:
- Unit tests должны verify internal state (balance, cache size, internal cursor pos) без публикации getter в public API.
- Sibling helper types (
Account+Bankaudit 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 когда любое из:
current_recv_type == tname— canonical type-method scope (D220).tname ∈ current_fn.test_access_for—#test_accessgrant.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_accessused >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 modifier | type-level flip | effective |
|---|---|---|
explicit pub | flip or no-flip | false (overrides) |
explicit priv | flip or no-flip | true |
| neither | flip = false | false (default public) |
| neither | flip = true | true (inherits) |
Bidirectional priv pub / pub priv → E_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 oldparse_named_tuple_fields. Propagates default в effectivepriv_fieldresolution per field (mirror кparse_record_fields_with_defaultprecedent). - 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 ✅
pubmodifier на 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):
usizealias удалён.int= address-sized signed integer на 64-bit Nova target. Используйintдля размеров, индексов, счётчиков байт.usize/isizeбольше не являются допустимыми Nova-типами — компилятор выдаёт ошибку с подсказкой «useint».
Принято 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, b — int).
Правило
-
Stdlib invariant. Любая публичная функция в
std/, принимающая или возвращающая «количество элементов» / «размер в байтах» / «индекс позиции», используетint. -
Защита от негатива через контракты. Capacity-API (
with_capacity/reserveи аналоги) добавляютrequires n >= 0(D24). Это compile-time проверка при Z3 backend / runtime debug-assert при TrivialBackend. Compile-error наwith_capacity(-1)без attempted конверсии типа. -
Negative-as-sentinel разрешён. Поиск/позиция могут возвращать
-1как «не найдено» (Java/Go convention) ИЛИOption[int]— stdlib-конвенция в пользуOption[int]для type-safety, но-1sentinel допустим в low-level API (str.find_byte). -
Разности — естественно 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). -
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_t→size_tвнутри. -
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. -
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 + Noffsetintpointer arith (D216 §6) — scaled by sizeof(T)ptr - ptr/*T - *Tdiffintelement count (D216 §6) — signed external fn(..., sz int)intFFI ABI (D214, D216 FFI) — codegen casts intptr_t→size_tp 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 |
|---|---|---|---|
| Go | int (platform-word) | signed | Сознательный выбор после C |
| Swift | Int (platform-word) | signed | Apple: «harder to make off-by-one errors» |
| Java | int (i32) | signed | Историческое; принято |
| Kotlin | Int (i32) | signed | Mirror Java |
| C# | int (i32) | signed | LongLength для >2B |
| Python | int (arbitrary) | signed | Negative-index slicing |
| TypeScript | number (f64) | signed (de facto) | Один тип |
| Rust | usize (platform) | unsigned | Community regrets vocal |
| C++ STL | size_t (platform) | unsigned | Stroustrup: «I regret using unsigned for size in STL» |
| Zig | usize (platform) | unsigned | Embedded-first рационал |
Счёт 7:3 в пользу signed. Двое из трёх unsigned-языков (C++ и Rust) имеют публичные authorial regrets.
Конкретные выгоды signed int для Nova:
-
Нет underflow-trap.
xs.len() - 1на пустом vec не паникует (даёт-1), в отличие от Rust0_usize - 1→ overflow panic. Это — самая частая newbie-trap в Rust. -
Sentinel
-1. Эргономика find/indexOf без обязательнойOption-аллокации. -
Разности и diff-логика.
a.len() - b.len()валидно signed; sorting comparators, position deltas, scroll offsets — все естественны. -
Mixed arithmetic без ceremony. Никакого
(x as int) + i,(len as int) - 1. AI-first killer-use (D10): LLM пишет signed-индексацию правильно чаще, чем балансируетuint/i64касты. -
Bit-width аргумент мёртв на 64-bit. Signed-
int(=i64) даёт2⁶³ − 1≈ 9.2 × 10¹⁸ элементов — никакая коллекция в адресном пространстве этого не достигнет. -
Совместимость с overflow-семантикой. Plan 33.8 Ф.1:
intoverflow →nv_panic(__builtin_*_overflow). Если ввестиuintдля len, мы заменим один trap (overflow on saturation) на другой (underflow on0 - 1) — без выигрыша. -
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.
Что отвергнуто
-
uint/u64для index/len (как Rustusize, C++size_t). Отвергнут D130 Q3 (2026-05-19): breaking change для 100+ APIs; underflow-trap хуже missing type-invariant; runtime contract-based check покрывает основной use-case. -
Mixed convention (
uintдля capacity,intдля index). Отвергнут: создаёт постоянные касты на границе API, удваивает protocol-method matrix. -
Refinement type
nat = {x int | x >= 0}как параметр капасити. Отвергнут на bootstrap: refinement-types — long-term Plan 33.x (после full SMT integration); сейчасrequires n >= 0даёт ту же проверяемость без grammar-changes.
Связь
- D129 —
int = i64alias decision (foundation). - D130 —
uintsymmetric pair + Q3 indexing decision (historical origin). - D24 —
requires/ensuresконтракты для compile-time проверки. - D54 —
int as uintsaturation (cross-type bridge). - D109 — встроенные методы примитивов (включая
int). - D141 —
byte_at/bulk slice API используетintиндексы. - D144 — sub-slice views
arr[a..b]— границыint. - D214 —
ptropaque type + cast rules (usize removed, use int for ABI bridge). - D216 —
*Ttyped pointer family + arithmetic (intoffset + diff) + FFI. - Plan 33.8 —
intoverflow → panic (soundness).
Эволюция
- 2026-05-19 (D130 Q3): Решение «keep
intfor indexing, no change» принято внутриuintplan’а — внутри одного из четырёх 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 расширен для
usizeABI bridge + pointer-integer casts; §7 «Pointer interactions» с numeric matrix для всех ptr ops; cross-refs на D214 + D216. Закрывает gap research’а §3 (docs/dev/research/08). - 2026-06-09 (Plan 133):
usize/isizeудалены из Nova.int= address-sized signed integer (intptr_tна 64-bit). FFI-сигнатуры используютint, codegen каститintptr_t→size_tвнутри. D226 RETIRED в частиusize/isizealias-semantics.
Acceptance criteria
-
std/collections/hashmap.nvwith_capacity(min_capacity int) requires min_capacity >= 0 -
std/collections/set.nvwith_capacity(cap int) requires cap >= 0 -
std/runtime/string_builder.nvwith_capacity(n int) requires n >= 0 -
std/runtime/write_buffer.nvwith_capacity(n int) requires n >= 0 - D226 spec block с industry baseline + rationale + rejected alternatives
-
[]T.with_capacity/[]T.reservebuilt-in: requires-clause вcompiler-codegenexternal_registry — followup[M-D226-builtin-capacity-requires] -
nova checklint 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):
| Alias | Bootstrap (64-bit) | Future arch |
|---|---|---|
usize | u64 (= uint64_t) | platform-pointer-width unsigned |
isize | i64 (= nova_int) | platform-pointer-width signed |
Use cases:
-
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 -
Pointer differences (D216 §6) —
ptr - ptr → int, signed semantically. -
Platform-pointer-width —
int=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(=u64alias) per Plan 70.5.
Casts (после Plan 133):
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:
- D129 —
int=i64aliasing - D216 §6 — pointer arithmetic
- Plan 118.1 — FFI signatures use usize
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). ErrorE_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 | ✅ default | binding ro, type implicit ro |
mut x T | ✅ default | binding mut, type implicit mut |
ro x ro T | ❌ E_REDUNDANT_TYPE_MODIFIER | то же что ro x T |
mut x mut T | ❌ E_REDUNDANT_TYPE_MODIFIER | то же что mut x T |
ro x mut T | ✅ NEW | binding ro, content mut (cannot reassign, can mutate) |
mut x ro T | ✅ existing | binding 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 Tvalue — ro view; multiple ro aliases allowed.mut Tvalue — mutable; subject к binding-dominates rule (D175 amend).unsafe Tvalue — MaybeUninit; read requires assertion (D216 V2 §V2.3).consume Tvalue — 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 shape | binding | Result |
|---|---|---|
b.set_x() | ro b | E_LOCAL_NOT_MUT (existing) |
b.v.set_x() | ro b | E_LOCAL_NOT_MUT (NEW — chain root) |
arr[0].set_x() | ro arr | E_LOCAL_NOT_MUT (NEW — chain root) |
b.parts[i].v.set_x() | ro b | E_LOCAL_NOT_MUT (NEW — chain root) |
b.v.set_x() | mut b, mut v field | OK |
make_body().v.set_x() | rvalue base | OK (no-op semantically — temp) |
Symmetry с D175 (ro field freeze): projection-chain root check
независимая ось от per-field ro 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.
D33 amend §«Fluent -> @ chain-receiver mutability gate» (ex-Plan 172.5 R6, fix 2026-07-10)
Closes
[M-172.5-chain-gating-ro-at](the marker’s own description of the fix was stale — it referencedParamRefMode/mut refparam-form machinery that Plan 184 fully retracted — but the underlying soundness hole it named survived, sharpened by D326-Plan184 Р7).
The walk_root(obj) = None escape hatch above (“chain начинается с rvalue
base — Call result, literal, … → no enforcement; mutation в hoisted temp —
no-op”) was sound before Plan 184: a value-record -> @ was a copy
(D246 R7b), so mutating the tail of a chain rooted in a Call result was
truly a no-op on a temporary. After D326-Plan184 Р7,
-> @ returns a genuine ref Self even for a non-mut method — the
no-op premise no longer holds for that specific shape.
Empirically: fn T @peek() -> @ { } / fn T mut @bump() -> @ { @x += 1 } —
c.peek().bump() on mut c = T{x: 0} compiled and left c.x == 1
(not 0). peek() never declared mut, yet its -> @ aliased the same
storage as a genuine mut method would — the ro/mut method-declaration axis
was silently bypassable through a chain.
Rule (amendment): walk_root above additionally recognizes a Call
receiver shape. When obj (the receiver of a mut-method Call) is itself a
Call inner_obj.inner_method(...):
- If
inner_methodis a confirmed registered ro-instance method (Plan 135ro_methods, arity-matched — see below) that is also declared with-> @(D132 self-return,recv_returning) → chaining a mut method off it is rejected:E_RECEIVER_BINDING_NOT_MUT. - If
inner_methodis itself mut (X.inc().inc(), the ordinary D132 fluent-builder idiom) — legal, unchanged. - If
inner_methodis not a self-return (-> @) method at all — e.g.filter()/map()/chars()/a static constructor (T.new()) — it builds a genuinely fresh value, not an alias; the originalwalk_root = Noneno-op-temp reasoning still applies unchanged. Legal.
Arity-aware registries (implementation note): the pre-existing
name-only mut_methods/ro_methods sets (Plan 108.1/135) collapse
arity-overloaded same-name pairs — e.g. @cap() -> int (0-arg ro getter)
vs mut @cap(n) -> @ (1-arg setter), the canonical T.new().cap(n)
construction idiom (D117 amend) present throughout std. The gate above uses
new companion sets mut_methods_arity/ro_methods_arity
((receiver_type, method_name, arity)) to disambiguate by argument count,
and additionally requires the SAME (name, arity) pair to never appear as
mut ANYWHERE in the module before firing — a conservative, false-positive-
averse trade-off (matches this section’s existing “acceptable trade-off”
stance on name-only chain-root heuristics).
Verified still legal (regression guard): all-mut -> @ chains
(v.bump().bump()), a fresh chain rooted in a static constructor
(T.new().bump()), and a mut method chained after a non-self-return
adapter (v.doubled().bump()) — see
spec_tests/conformance/d326_chain_gating_ro_at.nv. Rejected shape —
spec_tests/conformance/neg/d326_chain_gating_ro_at_neg.nv.
Scope note: the gate fires only when BOTH ends are positively confirmed
(inner = confirmed ro AND self-return; outer = confirmed mut) — it is a
narrow, additive check layered on top of the existing root-walk, not a
replacement. It does not attempt full receiver-type resolution (out of
scope for the linear ConsumeCtx pass); a hypothetical cross-type name
collision at the SAME arity (one type’s mut foo(n) vs another’s ro
self-return foo(n)) is a known, accepted miss (favors soundness of the
existing green tree over completeness of this new check).
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 vsinner - C codegen:
mut Temits same C type asT(через transparent recurse) - Type-checker:
mut Tdoes 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 intparses asMut(Named("int"))— wrapper transparent for codegen (emits justnova_int). Type-checker doesn’t validate Mut(non-Pointer) as anything special.*mut Tparses asPointer(Mut(T))—Mutis 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 themutwrapper onintdoesn’t grant mutation (bindingxis implicit ro per Plan 108).
Disambiguation reference:
| Form | Meaning | Source |
|---|---|---|
let mut x T = ... | Binding x is mutable | Plan 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 → Trequires 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 castx as T(where x: unsafe T binding and T is non-unsafe target) outside unsafe block. Caller must assert value validity viaunsafe { x as T }.E_UNSAFE_ARG_REQUIRES_WRAP— passing unsafe-T binding as Ident argument к function/method parameter whose declared type is NOTunsafe T. Param- level mismatch detected via ConsumeRegistryfn_non_unsafe_params/method_non_unsafe_paramsregistries.
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 Tvalue).
§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 intpostfix — это §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) - primitives (
-
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 - records (
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.
Примитивы языка — полный список (Primitives, full list)
Адрес назван явно (backlog-followups.md, [M-primitives-list-lives-under-a-foreign-heading],
найдено интегратором 2026-09-04): список ниже — единственный авторитетный
перечень примитивов Nova, но своего заголовка у него не было, он жил внутри
§V3.1 («Storage-class-aware ban на ro+mut adjacency») просто потому, что
ТОМУ правилу понадобилось перечислить, что вообще является value-типом.
Грепом по «Примитивы»/«Primitives» как ЗАГОЛОВКУ список раньше не находился —
только как жирный пункт списка внутри чужого раздела; отсюда и цена ошибки,
которую нашёл интегратор: окно 274 искало этот перечень и не нашло, чуть не
сделав вывод «спека не называет десять ширин встроенными».
Текст НЕ перенесён (перенос внутри файла на 168-упоминаемый в проекте документ рискует сломать существующие якорные ссылки на этот раздел без пользы, которую отдельный заголовок уже даёт) — только явный, грепаемый на обоих языках заголовок добавлен непосредственно над списком, который физически остаётся частью §V3.1 ниже.
Не сделано в этом же заходе, названо отдельно (backlog отмечает как
смежное, но более широкое): сводной нормативной таблицы Nova→C-пар типов
(i8→int8_t … f32→float) в спеке по-прежнему нет — пары фрагментарны
(06-concurrency.md:4452) и 08-runtime.md:3259’s таблица сама помечена
многоточием как неполная. Строится отдельной работой, не эта правка.
Value types per V3 (user-confirmed 2026-06-04):
- 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(aliasu8),str,ptr - Note:
usize/isizeremoved (Plan 133, 2026-06-09) — useint.
- Numeric:
- Value records:
type X value { ... }(Plan 124.8 D228) - Named tuples:
type Point(x f64, y f64)(Plan 120 D215) - Anonymous tuples:
(A, B, C)literal type syntax - Unit:
()(zero-size value) - Fixed arrays
[N]T([M-fixed-array-value-semantics], 2026-07-10, D27-амендмент): inline value-класс (стек / поле-по-месту, CT name[N];), копирующее присваивание/передача. Элементы могут быть кучевыми ([N]str,[N]*T) — контейнер всё равно value, как и у Tuple.
Reference types per V3:
- Records (
type X { ... }default — heap) - Arrays
[]T(≡Vec[T], D239;[N]T— value, см. п.6 выше) - 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(..) => true, // [N]T — inline value ([M-fixed-array-value-semantics], D27-амендмент 2026-07-10)
Array(..) => false, // []T — heap (Vec canon, D239)
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
непосредственно перед тем, что оно квалифицирует). Оси независимы, выбор —
вопрос единообразия записи.
| Form | AST | Status |
|---|---|---|
unsafe T | Unsafe(T) | ✅ |
ro unsafe T | Readonly(Unsafe(T)) | ✅ (safety-inner) |
mut unsafe T | Mut(Unsafe(T)) | ✅ |
unsafe ro T | Unsafe(Readonly(T)) | ❌ E_MODIFIER_ORDER |
unsafe mut T | Unsafe(Mut(T)) | ❌ E_MODIFIER_ORDER |
*ro unsafe T | Pointer(Readonly(Unsafe(T))) | ✅ (pointee: ro outer, unsafe inner) |
*unsafe ro T | Pointer(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 T→E_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. Standalonesafe T≡Tи так был no-op. Lexer-токенsafeв type-position — теперь ошибка (E_POINTER_PREFIX_MODIFIER family / E_SAFE_RETIRED, см. §V3.5);safe_stoppers/is_safe_stopped_betweenparser-машинерия — 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).
| Form | Status |
|---|---|
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 Tunsafe * safe unsafe T | RETIRED — 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
| Code | Description | Spec section |
|---|---|---|
E_MUTABILITY_CONFLICT_VALUE_TYPE | ro+mut adjacency on value-type T | §V3.1 |
E_MODIFIER_ORDER | unsafe wrapping ro/mut (safety-INNER rule, flipped Plan 138.5) | §V3.2 |
E_REDUNDANT_TYPE_MODIFIER | same-class modifier repetition (binding-level + value-T/pointee chains) | §V3.4 |
E_POINTER_PREFIX_MODIFIER | ro/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_MODIFIERfamily (или dedicatedE_SAFE_RETIRED— выбор enforce-фазы Ф.2).safe_stoppersmachinery 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 к newE_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 T→E_POINTER_PREFIX_MODIFIER/E_SAFE_RETIRED) OR delete/rewrite к postfix-pointee equivalents.- stdlib
std/runtime/raw_mem.nv— uses*u8and prefixmut * u8→ migrate prefix к postfix*mut u8(Plan 138.5 Ф.2). bare*u8unchanged.
§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 —safeescape retired)- ✅
[M-118.5-V3-binding-context-relaxation]— CLOSED 2026-06-05 — binding-formro 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 enforceE_POINTER_PREFIX_MODIFIER+ retiresafe/Unsafe(Pointer)+ migrate prefix usages (Plan 138.5).
§10a rename — unsafe type-modifier → uninit (Plan 174.5, 2026-07-11)
Status: ✅ DONE 2026-07-11 (sonnet, worktree
nova-ntbranchuninit-rename-d216). Closes the[M-174.5-pointer-ops-methods]Ф.0 rename-sweep followup («Renameunsafe T→uninit T»).
Что переименовано. Слово unsafe было перегружено: (1) type-модификатор
*unsafe T (pointer к possibly-uninit T) и value-wrapper unsafe T
(MaybeUninit-style, §V2.3/§V3 выше); (2) блок unsafe { } и fn-атрибут
unsafe fn/external unsafe fn (D2 amend, §8). Это амендирует только (1):
*unsafe T→*uninit T(pointer к possibly-uninit T, postfix pointee)unsafe T(value-wrapper) →uninit T- Все производные формы выше в этом D-блоке (
mut x uninit T,Option[*uninit T],*mut uninit T, chain-примеры, таблицы §1/§11a/§12/ §V2/§V3) — та же замена.
Что НЕ переименовано (сохраняет unsafe):
unsafe { ... }блок (D2 amend, §8) — без изменений.unsafe fn/external unsafe fndeclaration-атрибут (Plan 118.1.7,FnDecl.unsafe_attr) — без изменений.*unsafe fn(...)/*extern "C" unsafe fn(...)fn-pointer-type композиция (§10 «unsafe fn as part of fn-ptr type») — сознательно НЕ переименована. Хотя структурно это тот же AST-wrapper (Pointer(Uninit(Func)), внутреннее имя варианта переименовано вUninitдля единообразия), семантика ортогональна possibly-uninit data: это «указатель на fn, вызов которой требует unsafe» (mirrorsunsafe fncall-site enforcement,E_UNSAFE_CALL_REQUIRES_WRAP/E_UNSAFE_FN_PTR_COERCION), а не «данные могут быть не инициализированы». Парсер различает по payload:unsafeлегален в type-позиции ТОЛЬКО когда обёрнутый тип —Func(постфикс*unsafe fn(...)или bareunsafe fn(...)); для любого другого (data) типа — hard error.
Цель. Развязать «possibly-uninit pointee/value» (теперь uninit) от
«unsafe-операции» (блок/fn-атрибут/fn-ptr-композиция остаются unsafe) —
два независимых понятия больше не делят одно слово в type-позиции.
Миграция. std/, spec_tests/conformance/ — 0 вхождений type-модификатора
*unsafe T/unsafe T на момент рена́йма (грепом подтверждено); миграции кода
не потребовалось. nova_tests/ (не гейт корректности) содержит старые
*unsafe T-фикстуры (plan118/plan118_5*) — не мигрированы этим заходом (вне
gate, см. feedback-nova-tests-not-correctness-gate).
Hard error. unsafe в type-позиции, обёртывающий НЕ-Func (т.е. старая
data-uninit форма) → E_UNSAFE_TYPE_MODIFIER_RENAMED с подсказкой
использовать uninit. Neg-тесты:
spec_tests/conformance/neg/d216_unsafe_type_modifier_renamed_neg.nv (bare
value-wrapper форма) и d216_unsafe_ptr_modifier_renamed_neg.nv (pointer
форма). Pos-тест (uninit T / *uninit T / legacy *unsafe fn(...) compose):
spec_tests/conformance/d216_uninit_rename_174_5.nv.
Реализация (компилятор): TokenKind::KwUninit (новый keyword uninit,
lexer/mod.rs); TypeRef::Unsafe → TypeRef::Uninit (AST variant rename,
ast/mod.rs) + PointerModifier::Unsafe → PointerModifier::Uninit
(ast/mod.rs, Ty-level tag, types/mod.rs); parser parse_type — новый
KwUninit-arm (generic, любой T) + сужение существующего KwUnsafe-arm до
ТОЛЬКО Func-payload (иначе E_UNSAFE_TYPE_MODIFIER_RENAMED), обе арки
production TypeRef::Uninit (parser/mod.rs); display/render-функции
(types/mod.rs ×4, emit_c.rs, doc/collector.rs, doc/render_json.rs,
nova-lsp/src/symbol.rs) — Func-conditional keyword при печати диагностик
(unsafe если payload = Func, иначе uninit). Editor-хайлайтеры (VSCode
tmLanguage, vim syntax, Zed scm-comment, syntax_highlight_conformance.rs
ACTIVE-список) — uninit добавлен рядом с unsafe (D278).
D52 amend (Plan 124.8 Ф.2)
7-я форма declaration: value record — type X value { ... }.
Stack-allocated reference type с copy-on-pass semantics (D32 amend).
Composable с consume/priv модификаторами в каноническом порядке D241
consume value priv (см. amend ниже — order-independence RETIRED).
value — contextual keyword (recognized только в type Name[Generics] [modifiers] value [modifiers] { position; identifier value остаётся
валидным во всех других позициях для backward compat).
Canonical modifier order: type X consume value priv { ... } —
ownership → allocation → visibility (D241; правка 2026-09-24: здесь стоял
value consume priv, который компилятор отвергает E_MODIFIER_ORDER).
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), а не отложенный lintW_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 declaration | ro acc binding | mut 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-методы) определяется комбинацией двух факторов, в порядке приоритета:
- Binding mutability (call-site decision) — DOMINATES
- 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 declaration | Access под ro acc | Access под mut acc | Reasoning |
|---|---|---|---|
field T (default) | ❌ read-only, ❌ mutate | ✅ read, ✅ mutate | binding 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, ❌ content | content modifier independent of binding |
mut field ro T | ❌ / ❌ | ✅ reassign, ❌ content | same: 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:
- D33 amend explicit:
ro x ro T→E_REDUNDANT_TYPE_MODIFIER. Singleroon binding is enough — propagation handled access-time. - Implementation simplicity: access-time enforcement requires single binding-modifier check; transitive inflation would require type-level modifier propagation through all nested wrappers.
- Rust precedent:
&Tdoesn’t inflate to&&Tfor 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 (
letneutral /rofrozen /mutwritable) ambiguous для default-prefix fields — would require defining «default field under let» semantic - Call-site explicit choice (
roormut) 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 accbinding — ro fields stay ro, mut/default fields mut - This is already what type author’s intent maps к under
mutbinding
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
- D33 amend (binding propagation) — 02-types.md:8927
- D36 / Plan 108.2 enforcement — 02-types.md:2684
- D175 V1 amend (this section, just above)
- D176 (ro T modifier + Plan 108.1 param default flip) — 02-types.md:2763
- D184 (Plan 114 —
letkeyword retracted) — 03-syntax.md#d184 - D216 V2 (right-binding rule + universal type modifiers) — 02-types.md:7790
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)) получают:
- Multi-line support — newlines между fields после comma.
- Trailing comma support —
type X(a int, b int,). - Binding-level mutability — Rust-style:
mut p = Vec3(...)позволяет мутировать все поля;ro pблокирует все. Per-fieldmut/romodifiers запрещены (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. - Поля с умолчанием идут ПОСЛЕ полей без умолчания — см. D482. До 2026-09-14 здесь стояло ОБРАТНОЕ: «Порядок полей в declaration не ограничен: required и optional могут чередоваться (хотя рекомендуется ставить optional в конце)» — рекомендация без запрета. D482 это разрешение ОТМЕНЯЕТ.
- 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-1 | type X(f T = expr) принимается парсером | ✅ |
| AC-2 | Mixed required+optional fields в одном типе | ✅ |
| AC-3 | X() when all fields have defaults | ✅ |
| AC-4 | Partial override X(a: v) — remaining defaults injected | ✅ |
| AC-5 | Missing required field → E_TUPLE_CONSTRUCT_ARITY_MISMATCH | ✅ |
| AC-6 | Default references module-level constant | ✅ |
| AC-7 | std/_experimental/math/complex.nv migrated to named tuple | ✅ |
| AC-8 | «без упрощений как для прода» — production-grade, no stubs | ✅ |
| AC-9 | plan120 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) {#d228}
Уточнено D488 (2026-10-01): размещение и передача значения — там единственный дом правила (место решает убегание, передачу
ro— размер, порог три слова;mut— указатель). Где этот блок расходится с D488, действует D488.
Extended by D277 (Plan 153.2, 2026-06-15): by-value стек-codegen распространён с не-generic value-records на generic
type X[T] value {…}— каждый mono-инстанс = inlineNovaValue_<short>, passed/returned/copied by value, 0nova_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» commit41d4be096fa. D228 next free.Optimizer consumer (Plan 123 V7.6 V2 refactor, 2026-06-05): field-cache
is_reference_type_refclassifier consultsAllocKindviaTypeKindRegistry.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. Seedocs/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.
Уточнение 2026-10-01 (интегратор; вопрос облачной сессии
p274-carina-sweep). Пункт «Method receiver@: pointer на stack-slot» ниже говорит оmut @и читается вместе с R5 этого же файла («ABI ресивера»):mut @— всегда указатель, любого размера;ro @— по размеру, какro-параметр по R3 (мелкий по значению, крупный — скрытым ro-указателем, ненаблюдаемо). Противоречия с D215 «Method receiver passing» нет. Порог R3 — см. там же.
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.mutableflag в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-levela.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 байт), но буфер разделяется через*u8ro-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_eqrecurses each field). For a reference field whose pointee is shared + immutable (thestr.ptr *u8ro-pointee buffer), naive field-by-field would compare pointer identity — WRONG: two distinct buffers with equal bytes must be equal. Thereforestropts out of field-by-field and uses content-eq:emit_field_eqspecial-casescty == "nova_str"→nova_str_eq(len && memcmp) before any field-by-field path (emit_c.rs:11161), and direct==/</… onstrlower in BinOp codegen tonova_str_eq/nova_str_lt/… (emit_c.rs:16985). Consequence: str-in-tuple, str-in-record, str-in-sum eq, and str-keyedHashMap(hash vianova_str_hashSipHash-over-bytes) are all content-keyed automatically.str.@clone= 16-byte handle copy over the immutable shared buffer (no deep copy;*u8ro-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, nonova_alloc). - Field access:
.field(struct member), not->field(pointer). - Call-site receiver:
&vfor identifier; hoisted temp +&tempдля rvalue expressions (viaprepare_method_recvhelper).
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_typerecognizesNovaValue_prefix.struct_name_from_c_typerecognizesNovaValue_Xstrip.
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/roper-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#
structvsclass— Novavaluemodifier ≈ C#struct; reference record default ≈ C#class. - Industry: Nova становится **первым языком с single declaration syntax
- explicit allocation modifier**.
V2 known limitations (defer-able):
[]NovaValue_Xarray storage — currently boxes elements (V3 followup for inline element storage).Escape analysis для— CLOSED by Plan 127 (V1, 2026-06-05) — см. §«escape & auto-promote» ниже.&valueauto-heap-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_moveopt-in — followup attribute для security-critical consume value-records.
D228 amend — §«escape & auto-promote» (Plan 127, 2026-06-05)
Уточнено D488 (2026-10-01): размещение и передача значения — там единственный дом правила (место решает убегание, передачу
ro— размер, порог три слова;mut— указатель). Где этот блок расходится с D488, действует D488.
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 (branchplan-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:
| Variant | C output | Когда |
|---|---|---|
AllocKind::Heap | Nova_X* (heap, nova_alloc) | reference records (type X { ... }) |
AllocKind::Value | NovaValue_X (stack-inline) | value records без escape |
AllocKind::ValueHeapPromoted | Nova_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 если любое из:
- Return:
&vвозвращается из функции —fn f() -> *Vec3 => &v. - Heap field store:
&vсохраняется в heap-аллоцированное поле —acc.field = &v(гдеacc: Nova_X*). - Closure capture:
&vзахватывается closure —let cb = || &v. - Global / module binding:
&vсохраняется в module-levellet/const— escape вне fn scope. - Fn arg sink (conservative):
&vпередаётся в fn arg — conservative assume sink escape transit (V1 OVER-promote: callee analysis не делается, любой&v → fntriggerит 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_analyzewalker (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:
ValuevsValueHeapPromoted. 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, требует patternro v = Vec3 { ... }; ro p = &v.
См. также D216 §4 для primitive/tuple side контракта.
Diagnostic codes (Plan 127 Ф.4)
| Code | Kind | Trigger |
|---|---|---|
W_VALUE_RECORD_UNNECESSARY_PROMOTE | Lint | Escape detected, но user мог return by-value; suggestion hint emit |
E_VALUE_RECORD_ESCAPE_AFTER_CONSUME | Error | &v после consume v — D162 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)
consumevalue-record:&vпослеconsume v→ hard errorE_VALUE_RECORD_ESCAPE_AFTER_CONSUME. До consume — escape analysis как обычно.privfield: на promoted value-record (Nova_X*) field privacy preserved (Nova_X имеет те же priv markers, что и NovaValue_X).robinding:robinding 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:
| AllocKind | C output | Reason |
|---|---|---|
AllocKind::Value | obj_c.field | stack struct member access |
AllocKind::ValueHeapPromoted | obj_c->field | heap pointer dereference (Nova_X*) |
AllocKind::Heap | obj_c->field | existing 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 typesB/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 pblocking (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_Xarray 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 consume value { ... }works (composition; канонический порядок D241). - 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 Tparses + works (D176 gap closed). - A8.19 ✅
ro accblocksacc.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_namesblock-scoped.f1_blocksnapshots/restores на entry/exit. Innerro x = ...не leak’ит в outer scope. Cross-module contamination (stdlibro v→ usermut v) fixed. - A8.22 ✅ [M-124.8-ro-binding-scope] shadow:
Stmt::Letshadow-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_moveattribute 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.2 (221.1 №465,
2026-08-09): auto memset at consume call sites — LANDED for the safe
subset (consume-param-arg + bare consume-return,
AllocKind::Valuerecord +NamedTuple+ ordinaryNewtype). See the D-amendment below for the full analysis, the two new checker guards that keep the unsafe subset from silently compiling, and the named per-primitive risk breakdown forstd’s sync/concurrency types that motivated the original DEFERRED note. - 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).
D-amendment (221.1 №465, 2026-08-09) — A8.29 auto-inject landed for copy-semantics storage; aliased storage rejected at compile time
Проблема, найденная владельцем 2026-08-08 по странице спеки: A8.25-A8.28
были landed, но A8.29 (сам вызов Nova_T_zero_storage на consume-сайтах)
остался DEFERRED — значит #zero_on_move компилировался ЗЕЛЕНО и НИЧЕГО не
зануляло. Атрибут БЕЗОПАСНОСТИ с молчаливым no-op хуже отсутствия фичи
(реестр 221.1, запись №465).
Почему это было отложено, а не сделано сразу (переоткрыто и подтверждено
здесь): Nova’s ownership-transfer для consume не однородна по видам
хранения:
AllocKind::Valuerecord /NamedTuple/ ordinary (non-runtime-backed)Newtype—consume-передача (параметр,return X) — это настоящий байтовый C-копирование (A8.13: “param pass = value copy (C-native)”). Подтверждено эмпирически чтением сгенерированного C (docs/plans/repro/p465/probe1-3.nv.txt):nova_fn_helper(NovaValue_Secret s)получает НЕЗАВИСИМУЮ копию;return s;копирует значение в return-слот ДО того, как исходный стек-слот освобождается. Зануление ИСТОЧНИКА после того, как копия сделана, безопасно — новый владелец не видит эффекта.AllocKind::Heaprecord (type X { … }, безvalue) —consume-передача — это алиасинг указателя: старая и новая переменная указывают на ОДИН И ТОТ ЖЕ heap-блок (Nova не делает deep-copy при move для heap-типов). Зануление pointee после такой передачи занулило бы значение и для НОВОГО владельца — та же память. Это не «занулить протухшую копию», а «стереть единственный существующий экземпляр из-под живой ссылки» — порча данных, а не защита.- Receiver-вызов consuming-метода (
x.method()) — даже дляvalue-типовprepare_method_recvпередаёт&x(адрес-алиас в кадре вызывающего), НЕ копию (D228).escape_analyze.rsнамеренно исключает receiver-позицию метод-вызова из своего множества escape-синков (иначе КАЖДЫЙ вызов метода форсировал бы heap-promote любого value-record — противоречило бы всей цели Plan 127). Значит для receiver-формы у компилятора НЕТ доказательства «callee не сохранил&xдольше своего вызова» — зануление здесь было бы недоказанным.
Решение (реализовано в этом слиянии):
- Auto-inject landed ТОЛЬКО для доказуемо-безопасного подмножества —
consume-param-arg (свободная функция ИЛИ метод, НЕ receiver-позиция) и
bare
return X(голый идентификатор), и ТОЛЬКО для типов с byte-copy storage (AllocKind::Valuerecord /NamedTuple/ обычныйNewtype). Реализация — copy-out-then-zero-source: хвостовой промежуточный__tmp = x;эмитится ДОNova_T_zero_storage(&x), вызов/returnиспользуют__tmp, неx(иначе зануление ДО того, как копия сделана, отдало бы callee уже занулённые данные). Компилятор:compiler-codegen/src/codegen/emit_c.rs,zero_on_move_rewrite_call/zero_on_move_hoist_and_zero(consume-param- arg, hooked в centralemit_exprchoke-point, тот же, что уже используетdisarm_auto_cleanup_receiver_call) иStmt::Return’s bare-Ident path (return-site). #zero_on_moveтеперь требуетconsumeна том же типе (E_ZERO_ON_MOVE_REQUIRES_CONSUME,compiler-codegen/src/types/mod.rs) — auto-inject цепляется ИСКЛЮЧИТЕЛЬНО за уже существующую consume- трекинг машинерию (D432 §4:consume_receiver_methods/*_consume_param_positions), которая ключуется поconsume. БезconsumeНЕТ ни одной отслеживаемой точки передачи владения — атрибут остался бы тем же самым молчаливым no-op, просто более узким. До этой поправки A8.27 разрешал#zero_on_moveбезconsume(комбинация технически проходила чекер, но была бессмысленна) — язык-меняющее сужение, но ущерба нет: носителей атрибута вstd/examplesНОЛЬ (реестр 221.1 №465, зафиксировано владельцем 2026-08-08).#zero_on_moveна heap-allocated record — HARD ERROR (E_ZERO_ON_MOVE_ALIASED_STORAGE) — по причине «алиасинг указателя» выше. Единственный вид из допустимых A8.27 kinds, который теперь ДОПОЛНИТЕЛЬНО исключается:TypeDeclKind::RecordсAllocKind::Heap.NamedTuple/Newtype/value-record остаются допустимы без изменений.- Receiver-вызов (
x.method()) НЕ покрыт и остаётся тем же недоказанным случаем, каким был — компилятор не отвергает его отдельным диагностиком (это не структурная невозможность, как heap-alias, а отсутствие доказательства; полное покрытие требует нового escape-safety-анализа именно для receiver-позиции, вне периметра этого слияния). Задокументировано как известный, а не скрытый пробел.
Именной разбор риска по std’s sync/concurrency-примитивам (ровно то,
из-за чего A8.29 изначально отложили — “regression risk для sync
primitives”; проверено поимённо, ни один сегодня НЕ несёт
#zero_on_move):
| Тип | Объявление | consume? | Вид хранения | Судьба под новыми правилами |
|---|---|---|---|---|
AtomicI64/I32/…/AtomicInt/AtomicUint/AtomicBool (std/src/runtime/sync.nv) | type X value priv { v … } | НЕТ | Value record | E_ZERO_ON_MOVE_REQUIRES_CONSUME отвергает — эти типы разделяемые/долгоживущие, не одноразово-переносимые, consume им семантически не подходит |
Mutex, RwLock, Condvar, ReentrantMutex, WaitGroup, Once, Barrier, CountDownLatch, Semaphore (std/src/runtime/sync.nv) | type X(*()) | НЕТ | Newtype, runtime-backed (debt_is_runtime_backed_newtype) | Двойная защита: E_ZERO_ON_MOVE_REQUIRES_CONSUME (не consume) на уровне чекера; ДАЖЕ если бы это правило обошли — codegen-пре-пасс НЕ регистрирует runtime-backed newtype в zero_on_move_types (helper для них вообще не эмитится, т.к. emit_type_decl’s Newtype-ветка возвращает раньше для этого списка) |
MutexGuard, ReadGuard, WriteGuard, Permit (std/src/runtime/sync.nv) | type X consume { ptr int } | ДА | Heap record | E_ZERO_ON_MOVE_ALIASED_STORAGE отвергает — ровно случай «алиасинг указателя» выше; это и есть буквальный носитель исходного «sync primitives» риска в DEFERRED-заметке |
CancelToken (std/src/prelude/concurrency.nv) | type X(*()) | НЕТ | Newtype, ОБЫЧНЫЙ (не в списке runtime-backed) | E_ZERO_ON_MOVE_REQUIRES_CONSUME отвергает (не consume — токен разделяемый, не одноразовый). Будь он consume, был бы structurally безопасен (обычный newtype = typedef-копия, зануление трогает только локальный указатель, не то, на что он указывает) — но это гипотетический случай, сегодня неприменимо |
TcpStream, TcpListener, TcpReadHalf, TcpWriteHalf, UdpSocket (std/src/net/tcp.nv, udp.nv) | type X consume value priv { handle *(), [rc *mut AtomicInt] } | ДА | Value record (не heap — уточнение к первоначальному предположению «половинки TCP — heap») | Проходит ОБА новых guard’а — если бы кто-то добавил #zero_on_move, auto-inject бы сработал на consume-param-arg/return сайтах. Structurally безопасно: зануление собственных полей wrapper’а (handle, rc) после независимой C-структурной копии НЕ трогает внешний OS-ресурс/разделяемый refcount-объект, на который эти поля указывают (та же логика, что для обычного Newtype, оборачивающего указатель) — но receiver-вызов (stream.close()) остался бы НЕ занулённым (см. п.4 выше), т.е. частичное, не полное покрытие, будь атрибут добавлен |
Вывод: каждый сегодняшний sync/concurrency-примитив либо структурно не
достигает нового кода (не consume, либо consume-но-heap → жёсткая
ошибка), либо (TCP/UDP-половинки) доказуемо безопасен под теми же
гарантиями, что и общий value-record случай. Полного покрытия
receiver-вызова НЕТ ни для одного вида — известный, документированный
предел объёма, не скрытая дыра.
Регрессия: spec_tests/conformance/standalone (140 файлов) 122/0/18,
neg (566 файлов, 6 партий) 559/4/3 — все 4 FAIL совпадают с
предсуществующим красным списком (f5_propagation_trace_full,
f5_uncaught_trace_panic, f5_uncaught_trace_throw, neg_read_oob).
std (весь nova test std) 66/7/106 — все 7 FAIL предсуществующие,
структурно недостижимы новым кодом (grep -rl zero_on_move std/src пуст).
std/src/concurrency чисто (7/0/6). std/src/net заблокирован
предсуществующим CC-FAIL в addr.nv (несвязанная ошибка типов
Result/IoError), не относящимся к #465. Проба «подсунь негодное»: снятие
инъекции (обе точки) красит новые фикстуры
(spec_tests/conformance/standalone/m465_zero_on_move_autoinject_pos.nv)
в RUN-FAIL; git checkout -- восстанавливает — git diff пуст, фикстуры
снова PASS.
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 (никогда не очищалась). Stdlibro v = ...(sha1.nv / semver.nv) leak’ало в пользовательские fixtures сmut v = .... Fix:f1_blocksnapshots/restoresro_binding_namesна entry/exit;Stmt::Letshadow-aware (всегда removes prior entry, добавляет назад только если ro). 3 fixtures positive + negative. - ✅ [M-124.8-zero-on-move] V1 CLOSED 2026-06-03 — opt-in
#zero_on_moveattribute. V1 parser + AST + checker validation + per-typeNova_T_zero_storagehelper 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.2 CLOSED 2026-08-09 (221.1
№465) — auto memset of source at consume-param-arg + bare consume-return
sites, restricted to byte-copy storage (
AllocKind::Valuerecord /NamedTuple/ ordinary newtype); heap-allocated records rejected at compile time (E_ZERO_ON_MOVE_ALIASED_STORAGE— pointer-aliasing move would corrupt the new owner’s value);#zero_on_movenow requiresconsume(E_ZERO_ON_MOVE_REQUIRES_CONSUME). Receiver-consuming-call sites remain uncovered (documented limit, not a silent gap — see the D-amendment above for the full analysis and the named per-primitive risk table forstd’s sync/concurrency types). - [M-124.8-value-record-mut-literal-codegen] — pre-existing bug
(exposed at 2026-06-03 testing):
mut t = ValueT { ... }direct literal binding emitsNova_T*вместоNovaValue_T. Workaround:ro+ constructor pattern. Not zero_on_move specific. - [M-124.8-value-record-array-inline] —
[]NovaValue_Xinline element storage (V3, deferred — currently boxed). - [M-124.8-value-heap-promote] —
&valueescape 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; reuseescape_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:?}
AMEND (Plan 208 Ф.0, 2026-07-15) — диспетч
${expr:?}через@debug(mut f Fmt), радикс черезf.kind(). D422 заменяет@debug(mut w Write)на@debug(mut f Fmt)(тот же sink-embed, что и@display, см. D374-аменд); радикс-спеки (:x/:o/:b) для user-типов, ранееE_BAD_FORMAT_SPEC, теперь читаются типом через ЕДИНУЮ осьf.kind() -> FmtKindвнутри@display/@debug(не отдельные Rust-подобные трейтыLowerHex/…). Default-body synthesis (inject_synthesized_methods) и derived-форма меняются на compact/named различие — см. D422 §5. Целевая модель, Ф.1-4 pending; текст ниже (сигнатура@debug(mut w Write), memberwise-body сw.write_str) читать как ТЕКУЩЕЕ (до-D422) поведение.
Plan 91.14 (2026-06-05). New protocol parallel к D183 Display — debug-specific representation.
Rationale
-
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. -
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). -
Pointer integration.
*Timpls 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
@debugbodies вstd/prelude/protocols.nv; routes через@field.debug(w)в synthesized method bodies. - User types (records):
#impl(Debug)→inject_synthesized_methodsappends synthesizedItem::Fnto 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; rich spec grammar extension shipped in D258/152.7-B, per-type spec dispatch via@display_fmtshipped in D419; [M-91.14-format-dsl-extensions] CLOSED by D419)
Default body synthesis (#impl(Debug))
Когда user type X помечен #impl(Debug):
inject_synthesized_methods(auto_derive.rs) синтезируетfn X @debug(mut w Write) -> ()и append’ит вmodule.itemsперед codegen.- Body:
w.write("X { "); w.write_str("field1: "); @field1.debug(w); w.write(", field2: "); @field2.debug(w); w.write(" }"). - Все поля (primitive и record) — через
@field.debug(w). Primitives имеют @debug вstd/prelude/protocols.nv. - 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
| Code | Description | Trigger |
|---|---|---|
| E_DEBUG_PRINTABLE_NOT_IMPLEMENTED | Type doesn’t impl Debug, no auto-synthesis possible | ${x:?} where typeof(x) lacks impl |
| E_FORMAT_SPEC_UNKNOWN | Unknown format spec | ${x:foo} (only :? valid in V1) |
| E_PTR_NO_DISPLAY_USE_DEBUG_STR (preserved) | Bare ${p} для pointer | unchanged 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)
⚠️ D229 AMENDED by Plan 221.1 (2026-07-21) [M-interp-numeric-fallback-silent-garbage] follow-up — wires up the
E_DEBUG_PRINTABLE_NOT_IMPLEMENTEDcode this section’s §7 table already reserved, but which no code in the compiler ever actually raised.Root cause (coordinator repro, confirmed both Windows and Linux):
println("${d:?}")fortype D { a int, b int }with NO#impl(...)annotation at all printed a raw heap address (e.g.2640040103904), not"D { a: 1, b: 2 }".emit_c.rs’s Debug branch (~42816) callstry_synthesize_default_method_with_gate(t, c, "debug", gate_on_impl=false)with a comment describing this as “zero-friction… no annotation needed” — but that call is DEAD forDebugspecifically: its candidate search requires a protocol method withdefault_body.is_some(), and this section’s own §2 protocol declaration explicitly shipsDebugwith NO default body (“Compiler synthesizes per-type viainject_synthesized_ methods”). The candidate list is therefore always empty regardless of the gate flag —gate_on_impl=falsenever fires the intended bypass for Debug. The REAL synthesis mechanism actually used everywhere in the current implementation isinject_synthesized_methods(auto_derive.rs, hand-written memberwise-body generator per §4 above), and it gates ontd.impl_protocolscontaining"Debug"literally — i.e.#impl(Debug)IS required in the shipping implementation, exactly as this section’s §4 already states (“Когда user type X помечен#impl(Debug)”) and exactly matching its own §7 error-code reservation — that diagnostic was simply never wired to fire; the type silently fell through toemit_c.rs’s generic non-primitive interpolation fallback (nova_int_to_str) instead, the SAME numeric-cast garbage class the siblingE_INTERP_NO_DISPLAY(D186 amendment above) closes for bare Display.⚠️ Retraction: an earlier pass at this same fix (D186 amendment above, same-day) asserted “Debug synthesis already uses
gate_on_impl=false(D229/D237, unconditional auto-derive)… available with no#implregardless” — that assertion was WRONG, based on the emit_c comment’s claimed intent rather than a verified runtime repro. §4/§7 of THIS section (D229, pre-existing, unchanged by this amendment) already correctly documented the#impl(Debug)-gated reality; the wrong assertion was an error in the sibling fix’s own reasoning, now corrected.Fix: new type-checker diagnostic
E_DEBUG_PRINTABLE_NOT_IMPLEMENTED(types/mod.rs::check_interp_no_debug, sibling ofcheck_interp_no_ display, called from the samef1_expr_innerExprKind::InterpolatedStrarm) fires when a bare${x:?}(spec is exactlyFormatSpec::Debug; a richSpecwithKind::Debuggoes through the separateemit_format_spec_valuelowering, which already errors honestly viaE_BAD_FORMAT_SPECwhen no@debugresolves — no gap there) interpolated expression’s static type is a non-genericRecord/Sum/NamedTuple/Newtypedeclared type with neither (a) an explicit@debugmethod, (b) a gate-satisfied#impl(Debug)auto-derive synthesis (checker’s ownsynth_methodsoverlay already mirrorsinject_synthesized_methods’s gate —find_method_decl(T, "debug")covers both), nor (c) astr.from_ debug(T)overload (the Debug-side D410 fallback; unlike Display there is noto_str()-instance equivalent for Debug).Scope — identical to
E_INTERP_NO_DISPLAY’s (shared helperresolve_interp_user_value_type): primitives (which DO have unconditional@debugper §5 above — never gated), typed pointers (E_PTR_NO_DISPLAY_ USE_DEBUG_STR, separate), generic type-params, and generic declared types are left alone.Fixture:
spec_tests/conformance/neg/d229_interp_no_debug_neg.nv(pin — bare${x:?}on a type with zero#implat all).spec_tests/conformance/d186_interp_no_display_pos.nvhardened to assert the EXACT expected string ("D186InterpDebugOnly { a: 1, b: 2 }") via#impl(Debug), replacing a priorcontains("1")/contains("2")assertion that passed FALSELY (the printed heap address happened to contain both digit substrings locally, masking the bug — and on Linux CI the address didn’t contain"2", so the weak assertion caught the regression there).
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
-
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. -
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());strclone = новая аллокация с тем же содержимым. -
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).
- Primitive (
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
| Code | Description | Trigger |
|---|---|---|
| E_AUTO_DERIVE_CYCLE | Cyclic recursion non-terminating | type A↔B + #impl(Clone) |
| E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL | Field type не impl Clone | #impl(Clone) type X { y Y } где Y не Clone |
| E_AUTO_DERIVE_UNSUPPORTED_KIND | Newtype/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
@cloneFnDecl регистрируется в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 —
[]Textend/copy_from family (used для array field clone).
D237. Protocol naming convention: method-name capitalized (Plan 137, 2026-06-09)
AMEND (Plan 208 Ф.0, 2026-07-15) — сигнатуры
@display/@debug→(mut f Fmt). Имена протоколов/методов (Display/@display,Debug/@debug— таблица переименований ниже) остаются БЕЗ ИЗМЕНЕНИЙ; меняется только сигнатура параметра под D422:(mut w Write)→(mut f Fmt)(единый sink+спек-контекст вместо гологоWrite). Целевая модель, Ф.1-4 pending.
Status: ACTIVE
Prelude-протоколы именуются по принципу «имя протокола = заглавная форма имени метода».
Принцип: [T Hash] означает ровно один метод @hash(); [T Display] — @display().
Conversion protocols (From/Into/TryFrom/TryInto) уже следовали принципу.
Domain-specific protocols (Consumable, WithExitTimeout) — исключения.
Таблица переименований
| Старый протокол | Новый протокол | Старый метод | Новый метод |
|---|---|---|---|
Hash | Hash | @hash() | @hash() (неизменён) |
Compare | Compare | @compare() | @compare() (неизменён) |
Clone | Clone | @clone() | @clone() (неизменён) |
Equal | Equal | @equal() | @equal() |
Display | Display | @display() | @display() |
Debug | Debug | @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.
AMEND (Plan 175 Ф.3(d), 2026-07-10)
Duration/Timestamp/Monotonic (std/time/duration.nv) теперь реализуют
Display/Debug (D316-amend, детали и codegen-фикс — там). @display
byte-exact ASCII (нет байтов > 0x7F — фиксирует старую μs U+03BC
регрессию, снятую ещё в Ф.1c); Monotonic.@display/@debug — offset-форма
(+1.234s/Monotonic(+1.234s)), явно НЕ дата (D124).
D183 amend (Plan 137)
Equal.@equal → Equal.@equal; Compare.@compare unchanged.
Display.@display → Display.@display; default-body synthesis обновлена.
D229 amend (Plan 137)
Debug.@debug → Debug.@debug.
${expr:?} по-прежнему маршрутизируется к Debug.@debug.
D230 amend (Plan 137)
Clone → Clone; метод @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() -> Self | per-element out.push(@data[i].clone()) |
HashMap[K,V] | HashMap[K Clone, V Clone] @clone() -> Self | per-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 | Назначение |
|---|---|---|
@extend / @push (Vec.from(items) — RETRACTED 2026-07-20, Plan 200 П16, см. D259 AMEND) | любой 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() loop | RawMem.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, commits88432dd6f02+363f4b53788). Блокер[M-138.3-clone-bound-unsupported]CLOSED. ROOT CAUSE отличался от гипотезы ниже: НЕ «монформизатор мис-диспатчит», а single-key last-winsmethod_receivers["clone"]instance-fallback (lookup ТОЛЬКО по имени метода, игнорируя receiver-тип) роутил unbound primitive-T.clone()в произвольный неродственный@clone. FIX:PrimBuiltin::Identityvariant —.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-Tgeneric.clone()мис-диспатч; блокирует deep collection-clone[M-138-rawmem-bulk-ops]— clone→RawMem.copyбыл shallow-баг; этот amend фиксирует контракт- Plan 90.1 —
[]Tdeep-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
| Nova | C (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
nova_allocreturns zeroed, GC-collectable, 8-byte aligned memory.- Do NOT call
nova_freeon GC-tracked pointers — Boehm GC handles collection. alloc_uncollectablefor long-lived buffers not visible to the conservative GC scanner (e.g. Windows fiber arena buffers where fiber stacks shadow heap).- Every call must be inside
unsafe {}—unsafe fnkeyword enforced (E_UNSAFE_CALL_REQUIRES_WRAP from D216 §9, ACTIVE 2026-06-02). n = 0is implementation-defined; usen > 0in 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
| Call | Effect |
|---|---|
Vec[T].new() | empty, no allocation (cap = 0) |
Vec[T].with_capacity(n) | empty, pre-allocated n slots |
Vec[T].from(items []T) | RETRACTED 2026-07-20 (Plan 200 П16, D259 AMEND) — same-T conversion is existing.clone(); literal is of(...); width conversion is an explicit per-element loop |
Vec[T].from_raw_parts(ptr *T, len, cap) | build from a raw (ptr,len,cap) triple (cross-type bridge, unsafe-obligated; D247) |
Amendment 2026-09-24 (owner’s word) — empty Vec/slice: null data, zero allocation, len-guarded access
Норма (соответствие реализации записанной форме). Vec[T].new() и
пустой срез []T (включая view-пустоту): аллокаций ноль, data —
нулевой указатель, cap = 0, len = 0. Строка «no allocation» в
таблице Construction выше — сегодня получила нормативную конкретизацию;
прежняя реализация (1-slot placeholder, cap.max(1)) расходилась с ней
и заменяется (замер: ~51 байт и одна аллокация на пустой Vec против
~24 без — план 200).
Потребительский контракт (универсален для Vec[T], []T-срезов и
байтового буфера str — и это единственное, что относится к строке).
При len == 0 указатель данных НЕ несёт контракта данных: он может быть
нулевым, его нельзя разыменовывать или передавать источником memcpy;
арифметика смещения над ним допустима (сложение адреса; результат при
нулевой длине не читается — правка владельца 2026-09-25). Любой доступ
с нулевой длиной — no-op предметной области, а не ошибка и не UB:
память нулевой длины не существует, и указатель на неё не обязан
существовать. При len > 0 указатель валиден на ровно [0, len).
Производящая норма — по типам раздельно. Vec[T] и []T: пустота ⇒
нулевой указатель, ноль аллокаций (нормативно, снимается только явным
reserve/cap(n)). str: пустота НЕ нормирует указатель — литерал ""
интернирован (статическая память, замер: указатель ненулевой, адрес в
пуле литералов), и это законно: норма про ноль АЛЛОКАЦИЙ (на инстанс)
для строк выполнена интернированием, а мандовать null нечего и вредно
(сломало бы единообразие пула). У пустого str указатель — unspecified,
потребительский контракт выше действует через byte_len().
FFI-граница. as_ptr/ptr() пустого Vec отдаёт нулевой указатель —
внешний C-код обязан смотреть len до разыменования; пустая передача
(NULL, 0) в C — корректная форма «пусто».
Key methods
| Method | Signature | Description |
|---|---|---|
push | mut @push(v T) -> () | Append element, grow if needed |
pop | mut @pop() -> Option[T] | Remove and return last |
get | @get(i int) -> Option[T] | Element by index, bounds-checked |
get_mut | mut @get_mut(i int) -> Option[*mut T] | Raw mutable pointer (unsafe) |
insert | mut @insert(i int, v T) -> () | Shift-insert at index |
remove | mut @remove(i int) -> T | Shift-remove at index |
swap_remove | mut @swap_remove(i int) -> T | O(1) order-disrupting remove |
len | @len() -> int | Live element count |
cap | @cap() -> int | Allocated slot count |
is_empty | @is_empty() -> bool | True if len = 0 |
clear | mut @clear() -> () | Set len = 0 (retains buffer) |
truncate | mut @truncate(n int) -> () | Shorten to n elements |
reserve | mut @reserve(additional int) -> () | Ensure room for more elements |
shrink_to_fit | mut @shrink_to_fit() -> () | Cap → len |
shrink_to | mut @shrink_to(min_cap int) -> () | Cap → max(len, min_cap) |
reverse | mut @reverse() -> () | Reverse in place |
extend | mut @extend(items []T) -> () | Append all from slice |
append | mut @append(mut other Vec[T]) -> () | Move all from other |
retain | mut @retain(pred fn(T) -> bool) -> () | Keep matching elements |
first | @first() -> Option[T] | First element |
last | @last() -> Option[T] | Last element |
as_slice | @as_slice() -> []T | Copy into built-in slice |
as_ptr | @as_ptr() -> *T / mut @as_ptr() -> *mut T | Raw data-buffer pointer; recv-mut overload yields writable *mut T (cross-type bridge getter, D247) |
into_raw | consume @into_raw() -> *mut T | Consume 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]) -> bool | Element-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 * 2untilnew_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 eachNovaOpt_nova_intstruct inline (16 bytes/element).Vec[MyRecord]stores record pointers (Nova_MyRecord*8 bytes/element).- No int64-slot erasure — pre-D239
[]Tbuilt-in usedNOVA_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 needed | no (pure Nova) |
[]T и Vec[T] взаимозаменяемы: fn f(a []int) принимает Vec[int]
и наоборот. Лексически предпочтительна краткая форма []T; Vec[T]
используется там где явность важна (generic bounds, type params).
Note (Plan 138.2): в единицах компиляции без явного
Vec-импорта компилятор временно используетNovaArray_TC-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) — powersv[i](D238)Index[Range, Vec[T]](via@index(r Range) -> Vec[T]) — powersv[a..b]zero-copy (D238)MutIndex[int, T](viamut @index(i int, val T)) — powersv[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. - D238 —
Index[K,V]protocol;v[i]иv[a..b]через@index. - D240 —
MutIndex[K,V];v[i] = valчерезmut @index. - D231 — allocator used by Vec[T].
- D199 —
size_of[T]()const fn used in buffer size calc. - D216 §6 — ptr arithmetic (
data + iC-scaled). - D226 —
intfor len/cap/index. - D228 — value-records are valid T (stored by value in buffer).
- D247 —
from_raw_parts/as_ptr/into_rawcross-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 до D239 | Vec[T] / []T после D239 |
|---|---|---|
int, f64, bool | ✅ типизировано | ✅ типизировано |
str, указатель на record | ✅ pointer-in-slot | ✅ pointer-in-slot |
Option[int] (16 байт) | ❌ int64-erasure → UB | ✅ NovaOpt_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_TC-backing с идентичным layout.Plan 138.2 Ф.0-final (2026-06-11):
Vec/VecIterвошли в prelude (PRELUDE_VERSION13→14) → flip УНИВЕРСАЛЕН:[]Tлоуэрится вNova_Vec____<T>*в КАЖДОМ prelude-юните (Vec-free юнит[]int→Nova_Vec____nova_int*, typed storage). Юниты с#no_preludegraceful-degrade на legacyNovaArray_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-modulestd/collections/vec/(co-equalcore/access/mutate/slice/iter/protocols+_module.nv), модульcollections.vec_ownedретайрнут (имя исчезло; ~55 import-сайтов мигрированыvec_owned→vec; prelude re-export’итVec/VecIterиз folder). Eager-комбинаторы (map/filter/fold/any/all) вынесены в отдельный explicit-importcollections.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/dev/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:
- Строковый/byte слой (главный блокер — Plan 139 Ф.2 scope-out).
nova_str_as_bytes→NovaArray_nova_byte*,nova_str_split→NovaArray_nova_str*,from_bytes_lossy/from_bytes_unchecked/stealостаются C-примитивами (требуют str@ptrfield-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]. #no_preludegraceful-degrade.#no_prelude-юниты (tested feature: plan107/plan62/plan110_9_np) лоуэрят[]TвNovaArray_T(Vec-template отсутствует). 4 gate-сайтаcontains_key("Vec")это и обеспечивают. Risk RE.- Closure-array
[]fn→NovaArray_void_p*(намеренное исключение,[M-138.1-closure-array]/[M-138.2-closure-array-vec]). - 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]. Literal-bridge— static-method paramVec[T].from(items []T)[]Tвсё ещё лоуэрится вNovaArray_nova_int* items(dead stub в каждом flipped-юните, класс[M-138.2-self-in-param]— generic-static-method param-type substitution). MOOT 2026-07-20 (Plan 200 П16):Vec[T].fromретрактирован целиком (см. D259 AMEND) — этот блокер физически исчез вместе с деклой; список сузился до четырёх пунктов. Полный retire NovaArray этим не закрыт (остальные 4 блокера живы) — заметка только про исчезновение источника пункта 5.
После того как Plan 139 Ф.2 мигрирует string/byte слой на Nova_Vec____nova_byte*
(закрытие [M-139-f2-ptr-field-producers]), retirement можно завершить:
NovaArray_TC-макрос иarray.hNOVA_ARRAY_DECL/IMPL — удаляютсяnova_array_*runtime helpers для примитивов — заменяютсяVec-методами (уже сделано для generic-Vec пути; остаётсяnova_bytestring-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.
Связь
- D232 —
Vec[T]— backing тип для[]T - D238 —
Index[K,V];v[i]иv[a..b]через@index - D240 —
MutIndex[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[...]остаётся исключением из канона с явным маркером в коде.
Amended (вместимость литерала, 2026-08-27): литерал массива строится конструктором с ЗАДАННОЙ вместимостью, а не с умолчальной. Понижение, показанное выше (
[1, 2, 3] → Vec[int] { push 1, push 2, push 3 }), описывало только пуши и о конструкторе молчало — здесь это молчание снято.Правило (нормативно). Вместимость литерала РАВНА числу его элементов, когда это число известно на компиляции, и НЕ МЕНЬШЕ числа известных элементов, когда неизвестно (форма со спредом,
[a, ...xs, b]). Литерал без элементов даёт вместимость 0 — это точное число, а не оценка.Сформулировано над НАБЛЮДАЕМЫМ, а не над формой понижения:
[7, 8].cap() == 2. Так правило не зависит от того, каким конструктором реализация строит литерал, и переживёт смену понижения — а смена уже была дважды:Vec[T].fromретрактирован целиком (MOOT 2026-07-20 выше),with_capacityудалён амендментом vec-sweep 2026-07-06.Почему «не меньше», а не «примерно». Нижняя граница обязательна: под «примерно» подходит и ноль, и тогда правило нельзя ни выполнить, ни нарушить. Верхняя граница НЕ задаётся: реализация вправе округлить вверх ради стратегии роста, но не вправе занизить.
Почему нормативно, а не совет. Вместимость НАБЛЮДАЕМА:
@cap()— публичный методVec[T](std/collections/vec/core.nv). Молчание канона уже развело реализации: замер 2026-08-27 — нынешний компилятор отвечаетcap=8и на[7, 8, 9], и на[7, 8]. Это не произвол: без заданной вместимости литерал идёт по умолчальному росту D232 («×2 growth, initial 8») — то есть следует канону так, как он был написан. Именно поэтому правило пишется сюда, а не заводится как дефект реализации. Реестр 221.1 №783.Механизм есть и работает (проверено тем же замером):
Vec[int].new(3)с тремяpushдаётcap=3,Vec[int].new(cap: 2)—cap=2. От реализации требуется передать число, а не завести новый конструктор.Зеркало исправлено тем же амендментом, и это главный урок здесь. Спека НЕ молчала — она себе ПРОТИВОРЕЧИЛА: зеркало D239 в
03-syntax.mdпоказывало ровно это правило (Vec[int].with_capacity(3); push …), но снятым именем, а канон конструктора не называл вовсе. Правило сгнило не оттого, что его не было, а оттого, что оно жило В КОПИИ и уехало вместе с ретрактированным API.
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 аннулирует предикат).
Решение
-
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.&&явно, без нового синтаксиса/парсер-сложности. -
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), а не через оператор<.Уточнение 2026-09-04 (D405, амендмент того же дня): «ordered-категория» — требование к КАЖДОМУ операнду, а не разрешение сравнивать операнды разных типов внутри категории. Оба операнда
</==и прочих должны иметь один и тот же конкретный числовой тип:u32 == i32,i64 < i32,f32 < f64,int < f64— ошибка компиляции, явныйas. Литерал адаптируется к типу другого операнда (D55).
Канон
- Диапазон:
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 intoa OP1 b && b OP2 c». Эмитится в parser.E_RELATIONAL_OPERAND_NOT_ORDERED—bool/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<c≡a<b && b<c) — НЕ добавляем; если будет спрос — отдельное предложение в будущем.
D374. Write-sink протокол — декаплинг Display/Debug от StringBuilder (Plan 152.7.1)
AMEND ×2 (Plan 208 Ф.0, 2026-07-15) — D422. (1) Sink для
Display/Debugменяется сWriteнаFmt(embedsWriteчерезuse, D145) —@display/@debugполучают(mut f Fmt), не(mut w Write)напрямую (Fmt даёт доступ и к@write, и к осям спека). (2)Write.@write_str(s str)меняется наWrite.@write(bytes []u8) -> ()— байтовый sink, не строковый;str-значение (литерал и переменная) → коэрсия вro []u8по#coerce str @bytes()(D55-аменд, D429; явный.bytes()(D176) допустим, но избыточен). Целевая модель, Ф.1-4 pending (см. D422 §Статус) — текст ниже (сигнатуры@write_str(s str),w Writeна@display/@debug) читать как ТЕКУЩЕЕ (до-D422) поведение.
AMEND ×3 (owner decision 2026-07-17, канон mut-параметров, обе позиции). Канон записи sink/
mut-параметров (@display(mut f Fmt),mut w Write, иmut-параметров вообще) — ПРЕФИКСНАЯ форма:mut <имя> <Тип>. Есть ДВЕ позиции, куда исторически можно поставитьmut, и они значат РАЗНОЕ: позиция ПЕРЕД именем — канон (D176/Plan 108.1: opt-in на мутацию, default — read-only); позиция ПОСЛЕ имени, ПЕРЕД типом (<имя> mut <Тип>, D6 legacy-спеллинг) — парсер принимает её как ПОЛНЫЙ поведенческий синоним префиксной формы (эмпирика:i mut intреассайнится в теле идентичноmut i int) — НЕ более узкая семантика, просто исторический альт-спеллинг. Голая постфиксная форма (без явногоroперед именем) для НЕ-slice/НЕ-fixed- array типов — под запретом lint’аW_PARAM_TYPE_POS_MUT(unconditional pipeline,compiler-codegen/src/lints.rs); позиция ПОСЛЕ имени остаётся легитимной ИСКЛЮЧИТЕЛЬНО за view-слайсами ([]u8и родня, io-канон,buf mut []u8) и fixed-size массивами ([N]u8, hash-digest out-буферы,std/crypto/sha256.nvи родня). Явный R2-splitro <имя> mut <Тип>(D246 P6, Plan 118.5 V3 amend) — санкционированное, НЕ каноническое, исключение (самодокументирует «пишу в содержимое, не подменяю биндинг»); lint её не флагует. Для sink-типов (Fmt/Write) семантикаmutна кучевом handle — «в него ПИШУТ, видно вызывающему» (Plan 184 Р2/Р10: sink ВСЕГДА кучевой handle,mut x Tне вводит in-outT*для handle-типов —param_is_inout_ptr=false вemit_c.rs; запись идёт через shared handle, переприсваивание биндинга локально/невидимо), НЕ «sink можно подменить» — см. также исследование 2026-07-16 (веткаresearch-mut-canon,docs/dev/research/2026-07-16-mut-param-sink-canon.md) для полного разбора R2-split-vs-канон trade-off’а и латентной лености чекера на protocol-typed handle-параметрах (закрыта тем же owner-decision:[M-checker-protocol-param-mut-lenient]
[M-conformance-param-mode-check],types/mod.rs). Полный текст канона и примеры —nv-coding-style.md §18б. Baref Fmt(безmutвовсе) для sink остаётся запрещён отдельно (§18E_PARAM_NOT_MUT).AMEND ×4 (owner decision 2026-08-12, реестр 221.1 №611/№615, окно p616-mode-modifiers, амендмент D445). Абзац выше (AMEND ×3) называет постфиксную позицию
<имя> mut <Тип>«легитимной ИСКЛЮЧИТЕЛЬНО за view-слайсами и fixed-size массивами» и явный R2-splitro <имя> mut <Тип>— «санкционированным, НЕ каноническим, исключением». ОБА эти исключения ОТМЕНЕНЫ: D445 (2026-08-03) уже говорит «без исключений и без постфиксной формы где бы то ни было», но не назвал ЭТО исключение по имени — теперь названо.W_PARAM_TYPE_POS_MUT(лайт-предупреждение) заменён на жёсткую ошибкуE_PARAM_TYPE_POS_MUT_RETRACTED(parser-level, без exemption по типу параметра). Канон mut-параметров остаётся ПРЕФИКСНОЙ формойmut <имя> <Тип>— но теперь БЕЗ альтернативной постфиксной записи вообще, ни голой, ни сro.nv-coding-style.md §18бпереписан в том же слиянии — «полный текст канона» больше не показывает постфикс как легитимный.
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, str (в std/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.
Связь
- D183 —
Displayпротокол (amended:sb→w Write). - D229 —
Debugпротокол (amended:sb→w 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()— актуальный текст только в этом амендменте.
AMEND (2026-07-20, Plan 200 П16) —
Vec[T].from(items []T)ПОЛНОСТЬЮ РЕТРАКТИРОВАН. Владелец: «это же простоitems.clone()» (согласовано 2026-07-16, подтверждено 2026-07-20). Разделение ролейof/fromниже частично устарело: роль «литерал» (of) остаётся канон без изменений; роль «конверсия существующей коллекции» (from) закрывается напрямую черезClone(D230, deep/recursive) —existing.clone()— а НЕ отдельным статик-конструктором:
- same-
Tконверсия (Vec[int].from(other_vec)) →other_vec.clone()(Vec/HashMap/Set уже реализуют глубокий поэлементныйClone);- литерал (
Vec[f32].from([1.5, 2.5])) → канон уже былof— теперь единственный путь:Vec[f32].of(1.5, 2.5);- width/типо-конверсия (
Vec[u8].from(int_vec), тихо сужающая элементы) → явный поэлементный цикл (for x in src { out.push(x as u8) }) — сужение больше не прячется за одним вызовом. Декларация удалена изstd/collections/vec/core.nv; все живые вызовы мигрированы ([M-lint-findings-static-conversion]-часть проVec.fromзакрыта). Текст «### Правило»/«### Почему» ниже — ИСТОРИЯ (объясняет, почемуofвообще появился рядом сfrom), актуальное поведение — только в этом амендменте.
Что
Два конструктора Vec[T] несут разные роли, и их нельзя путать:
Vec[T].of(a, b, c)(вариадик) — построить вектор из литерального списка элементов. Аналог Rustvec![a, b, c].Vec[T].from(coll)(from(items []T)) — конвертировать существующую коллекцию / слайс в новыйVec. Аналог RustVec::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-commentfrom(вstd/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([литерал])избыточен. - D232 —
Vec[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/Next — VecIter). Закрывает:
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-fnbox_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), зафиксированных как контракт:
- mut-capture box-реестр (
var_boxed) флашится per-test (emit_test) — box_box_<name>не должен утекать между C-функциями тестов. Stmt::Returnэмитит значение с типом возврата функции как target — голыйreturn Noneв mono-замыкании резолвится вNovaOpt_<mono>, не erasedNovaOpt_nova_int.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-costStepByIter[I,T](vec_iter.nv): yield каждый n-й элемент. Contractn > 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 aliasA(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-фикс:NovaOpttypedef для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).
Связь
- D232 —
Vec[T]; D239 —[]T ≡ Vec[T]. - D58 —
Iter/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/Debug —
D230 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-протокол с одним методом, а набор конструкторов/терминаторов,
строящих коллекцию из любого итератора. Канон:
- Default collect-target →
Vec(ленивый слой D260):BoxIter[T] mut @collect() -> Vec[T]— материализует pipeline в один проход, без промежуточногоVecна стадию. Setcollect-target:BoxIter[T Hash] mut @collect_set() -> Set[T](dedup; Rustiter.collect::<HashSet<_>>()). Allocation-free над pipeline (pull + insert на лету).- Прочие таргеты — композицией над собранным
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]). - 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в теле не компилируется: boundS 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— его терминаторы. - D58 —
Iter/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 не
тронуты):
receiver_c_typevalue-generic-mono receiver →NovaValue_<short>*, order-free относительноtype_aliases(helpersvalue_generic_mono_short/value_aware_generic_c_type).- generic-instance method-dispatch (block 5b) маршрутит value-receiver
через
prepare_method_recv→ передача по адресу. - fn-typed-field вызов
(@step)()— accessor.vs->выбирается по value-ness receiver’а ((*nova_self).step). - 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):
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).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-сайтах.erased_type_ref_c— type-param-чек сделан РЕКУРСИВНЫМ (uses_any_type_param) → erased-stub возвращает erased-base-pointer, не placeholder-laden mono-имя.drain_generic_type_worklistplaceholder-guard (value-GATEDmangled_has_nested_placeholder) — skip эмита value-mono, чьё by-value поле ссылалось бы на undefined inner-placeholder struct, БЕЗ подавления нужного heapVec[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_lazy | zero-cost vec_iter_zc | |
|---|---|---|
| wrapper-record heap allocs (адаптер-цепочка) | 6 → 0 (Stage 1: by-value NovaValue_<short>) | 0 |
source-box (_box_src) | 9 | 0 (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) |
| остаточный heap | env ЗАХВАТЫВАЮЩИХ замыкания 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 нужны свежими).
VecItersource-курсор — heap-ref-type alloc на.ziter()(свойствоVecIter[T], не замыкание; вне scope ступеней 3–4).take/skip/enumerate(stateful / tuple-element) остаются на boxedvec_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_intovia 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 field | public |
Explicit priv field | module-private |
Explicit priv(type) field | type-private |
Explicit priv(file) field | file-private |
Type-level priv(file) default, no explicit field modifier | file-private |
Type-level priv(type) default, no explicit field modifier | type-private |
Type-level priv default, no explicit field modifier | module-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
| Code | Context |
|---|---|
E_FIELD_MODULE_PRIVATE | Read/write/init/pattern of module-private field from outside module |
E_PRIV_FIELD_READ | Read of type-private field from non-method (includes same-module free fn) |
E_PRIV_FIELD_WRITE | Write of type-private field (D220, reused) |
E_PRIV_FIELD_INIT | Record-literal init of type-private field (D220, reused) |
E_PRIV_FIELD_PATTERN | Pattern destructure of type-private field (D220, reused) |
E_PRIV_QUALIFIER | priv(module) or unknown qualifier — use bare priv |
§5 Критерии приёмки (без упрощений, как для прода)
- Позитив:
type T priv { f int }в модуле M — свободные функции, методы и record-literal constructors в том же модуле читают/пишут/инициализируютfбез ошибок. - Негатив-read: import T из другого модуля,
t.f→E_FIELD_MODULE_PRIVATE. - Негатив-write:
t.f = xиз другого модуля →E_FIELD_MODULE_PRIVATE. - Негатив-init:
{ f: v }конструктор из другого модуля →E_FIELD_MODULE_PRIVATE. - Layering:
priv(type)field внутриtype T priv {...}остаётся type-private —E_PRIV_FIELD_READдаже в том же модуле из свободной функции. Bareprivfield внутриtype T priv {...}= module-private (accessible in same module free fn). - Public export: type + его методы (возвращающие/принимающие T) публично экспортируются — клиентский модуль может использовать API без доступа к внутренним полям.
- Regression:
nova testcore-suite без новых FAIL. - Негатив-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 ИЛИ из explicitprivfield modifier).TypeCheckCtx.type_defining_modules: HashMap<String, Vec<String>>— строится изpeer_files.items_here.TypeCheckCtx.current_module: RefCell<Vec<String>>— RAIICurrentModuleGuardпри входе вcheck_module.module_priv_access_allowed(tname)— compare maps.- 5 check-сайтов: INIT (record-literal), READ (member expr), WRITE (assign), PATTERN (destructure) — каждый distinguishes
priv_module_field→E_FIELD_MODULE_PRIVATEvspriv_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-файлам модуля).
⚠️ АМЕНДМЕНТ 2026-08-16 (решение владельца; реестр №699): ФАЙЛ
*_test.nvНЕ ИМЕЕТ ЭКСПОРТНОЙ ПОВЕРХНОСТИ. Его top-level декларации видны своим peer-файлам (Rule C не меняется — он остаётся co-equal членом модуля и компилируется вместе с ним), но НЕ входят в то, что видит импортёр модуля, даже будучи помеченнымиexport. Зачем: без этого правила тип, написанный ДЛЯ ПРОВЕРКИ модуля, становится декларацией модуля для КАЖДОГО потребителя транзитивно. Живой случай:type Nodeизstd/src/encoding/serde/tagging_test.nvсделал имяNodeколлидирующим (D381) для любого CU, тянущего serde, и через квалификацию C-имени привёл к мискомпиляции без диагностики (реестр №696); то жеToken↔encoding.json.Token. Почему правило, а не переименование: переименоватьNode— фикс носителя, класс вернётся на следующем совпадении имени (Point,User,Span…). Граница «поставляемое / проверочное» существует в конвенции («тесты рядом с модулем») и не была выражена в модели модуля — модель не знала слова «тест». Прецедент: rustc#[cfg(test)] mod tests— тестовый код не виден потребителю по построению. Приёмка (№699): pos-фикстура «потребитель объявляет свойNode, импортирует модуль с*_test.nv-пиром, объявившимNode, — компилируется без D381-квалификации»; плюс проверка, что сам*_test.nvпо-прежнему видит 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
| Code | Context |
|---|---|
E_FILE_PRIV_LEAK | Ссылка из peer-файла на priv(file) символ другого файла группы |
E_PRIV_QUALIFIER | priv(file) + export вместе; bare top-level priv без (file); priv(<other>) на top-level; priv(file) перед не-fn/type/const |
§5 Критерии приёмки (без упрощений, как для прода)
- Парсинг:
priv(file) fn/type/constпарсится;priv(file)+export→E_PRIV_QUALIFIER. - Позитив own-file:
priv(file) fn h()вызывается в своём файле → компилируется и работает. - Позитив co-exist: два peer-файла, оба объявляют
priv(file) fn helper()с разным телом, каждый вызывает свой → компилируются (file-discriminated codegen, нет линк-коллизии) и работают. - Негатив leak: ссылка из peer на чужой
priv(file)→E_FILE_PRIV_LEAK. - Регрессия: module-private (
priv/default) символ из одного peer ВИДЕН другому (D281 без изменений);exportбез изменений. - Регресс: plan160 / plan124* / modules / std — 0 новых FAIL.
AST / Checker / Codegen-реализация
ast:FnDecl.file_private: bool/TypeDecl.file_private: bool/ConstDecl.file_private: bool(defaultfalse). Минимальный путь —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 (Range → RangeIter) — два 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[…] { … }):
- Если поля — только примитивы или ссылки на уже живущие GC-объекты (cursor-semantics) → объявить
value. - Если адаптер создаёт новую GC-память (например, буферизующий адаптер) → оставить heap-record.
- Проверить, что 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.
Два исправления:
-
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. -
Tuple-elem fwd-decl (
emit_c.rs, строки ~3915): перед spliceMONO_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 вариант — legacyOptionform для 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:
compute_array_elem_type_for_obj— добавлен arm дляExprKind::SelfAccess(ранее толькоExprKind::Identи другие;SelfAccessбез arm → элемент-тип не выводился).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,…), разворачивает L2ro. Значит прежнее допущение плана (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 / D227 —
int=i64wide-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) сделал
ResolvedTypelossless для C (модуль-путь /*mut/ L2 ro); 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_impl—resolved_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_ref→path.last(), дропал generics →Fail[E]терялE, нарушение «несёт ПОЛНУЮ семантическую личность»). Обогащён доVec<ResolvedType>(имя + module + type-args через losslessNamed). Разблокирует 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) {#d326}
Уточнено D488 (2026-10-01): размещение и передача значения — там единственный дом правила (место решает убегание, передачу
ro— размер, порог три слова;mut— указатель). Где этот блок расходится с D488, действует D488.
⚠️ РЕВИЗИЯ (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)— представление (копия ≤ порога R3, 24 Б либо скрытая ссылка) выбирает компилятор по размеру; ненаблюдаемо (с оговоркой Р12). Явногоro ... refв параметре нет.mut:f(mut x T)— право изменять полученное значение (приватной мутируемой копии нет ни на стеке, ни на куче). У стекового (value) типа значение и есть хранилище вызывающего: изменение поля и присваиваниеx = …целиком видны вызывающему. У кучевого типа полученное значение — сам объект (Р6): изменение объекта (x.push(…),x.truncate(…), запись поля) видно вызывающему, а присваиваниеx = …лишь перепривязывает ЛОКАЛЬНОЕ имя к другому объекту и вызывающему не видно.mut— неinout: связи с переменной вызывающего он не даёт. Локальная приватная копия — явным локаломmut y = x. Амендмент 2026-09-24 (решение владельца). Прежняя формулировка «f(mut x T)≡ in-out ссылка ВСЕГДА» противоречила Р6 и реализации: присваивание кучевомуmut-параметру было невидимо снаружи, а текст обещал обратное. На этом споткнулся исполнитель пакетаnova-postgres(v = v[n..]не дошло до вызывающего). Поэтому такое присваивание — предупреждениеW_MUT_HEAP_PARAM_REBIND: «присваиваниеmut-параметру кучевого типа перепривязывает только локальное имя — вызывающий его не увидит; измени объект методом, верни новое значение или заведи локал». Предупреждение, а не ошибка: перепривязка законна (например, параметр как курсор внутри функции) и лишь не видна снаружи. Амендмент 2026-10-03 (решение владельца) — что такое кучевой тип для этого правила. Кучевость — свойство ПРЕДСТАВЛЕНИЯ значения, а не формы объявления: значение — ссылка на объект в управляемой куче. Кучевые: запись безvalue(type X { .. }); newtype над кучевым типом (type Wrap Bag— представление основы, D52); значение функционального типаfn(..) -> ..— ручка на код и окружение, окружение захватывающего замыкания живёт в куче и неизменяемо, так чтоmutдаёт лишь перепривязку локального имени. Стековые: примитивы,value-записи, суммы (D477), кортежи, newtype над стековым типом. Временное кучевого типа вmut-параметре законно (Р3, Р6), стекового —E_MUT_ARG_NOT_MUTABLE. До амендмента спека этого прямо не говорила; Карина решала по форме объявления и отказывала newtype над кучевой записью, который оракул принимает (реестр 221.1, находка интегратора 2026-10-03).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 H ≡ H (значение уже handle;
ссылка на handle не вводится). Важно для обобщённых f[T](mut x T) при T = H — mono не
плодит ref-обёрток над handle.
(Р7) Типизация приёмника (таблица — заменяет эвристики D181/R7-R8).
Категория Self | ro @ | mut @ | -> @ |
|---|---|---|---|
| Стековый (value) тип | ref Self (или копия по размеру, невидимо) | mut ref Self | -> ref Self |
| Кучевой (heap) тип | Self | Self | -> 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, D84 —
fn 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.rsdup-детект сравнивает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 > 3*sizeof(ptr) (24B на 64-bit) и копия ненаблюдаема; семантически тождественно by-value для ro. Маркеров нет.
Амендмент 2026-10-01 (решение владельца 2026-08-08, план 172.15 п.4; перенесено в спеку 2026-10-01). Порог R3 — три машинных слова (24 байта на 64-bit), как в Swift; прежнее «~2*sizeof(ptr) (≈16B)» снято. Значение больше порога передаётся скрытым ro-указателем, не больше — копией; это же правило выбирает представление
ro @по R5 (mut @— всегда указатель, любой размер). Порог — ОДНА именованная константа компилятора на все места, где решается представление (план 172.15 Ф.1-бис); замер может его повысить, но не понизить. Настройка порога (ключ сборки) допустима только ОДНА на всю программу: разные пороги у частей одной программы дают несовместимую передачу значений между ними, а оракул и Карина обязаны применять одно значение, иначе их C-вывод расходится.
Правило размещения и передачи целиком — D488 (2026-10-01): место решает убегание, передачу
ro— размер (этот порог R3),mut— всегда указатель.
(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-chainmut 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_exprdesync (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). Explicitro 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-siteref 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(borrowro-места, 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-outmut 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 к кодпоинтам не относится — кодпоинт не индексируют и не вычитают как длину.
Правило
- Хранилище последовательностей кодпоинтов —
Vec[u32](4 байта; ср. RustVec<char>, Go[]rune=[]int32). НЕVec[int](вдвое память/кэш на горячих путях коллации/нормализации — §2 perf). - Поток и арифметика внутри unicode-движков —
u32. Это убирает int↔u32-границу by-construction (нет неявного narrowing, нет россыпиas u32). char(=u32, D128) — семантический тип кодпоинта на границеstr:s.as_chars()→char,char.try_from(u32)→ валидный скаляр, char-методы ('a'.is_alphabetic()).- Публичные cp-функции принимают
u32(general_category(cp u32),is_alphabetic(cp u32)). Целочисленные литералы адаптируются к u32-контексту →general_category(0x41)остаётся валидным (контракт Plan 159 / plan152_3 сохранён). char-методы делегируют через@ as u32. - Fallible-функции, выдающие кодпоинт →
Option[u32](D77), НЕ-1-сентинел. (-1вint-функции — идиоматичен для find/мер D226, но кодпоинт — не мера.) - 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 SignedInts set i8 | i16 | i32 | i64 | int
type UnsignedInts set u8 | u16 | u32 | u64 | uint
> **АМЕНДМЕНТ 2026-08-17 (решение владельца; аудит самосогласованности,
> раздел 4, пункт 11).** Здесь стояло единственное число — `SignedInts` /
> `UnsignedInts`, — тогда как [D430](/spec/decisions/effects/#d430) и весь `std` пишут множественное
> (`[T Ints]`, `SignedInts`). Имя одного и того же множества расходилось между
> двумя D-блоками и реализацией, то есть читатель D310 писал bound, которого
> нет. Верное — МНОЖЕСТВЕННОЕ: множество типов, а не один тип; так же
> называется семейство `Ints`/`Floats` (`Floats` на момент этой записи существовал только
> здесь; объявлен в prelude 2026-09-04 — см. амендмент ниже).
>
> **ДОПОЛНЕНО В ТОТ ЖЕ ДЕНЬ, ПОСЛЕ ЗАМЕЧАНИЯ ВЛАДЕЛЬЦА.** Сначала было
> переименовано 16 вхождений В ЭТОМ ФАЙЛЕ — и на этом я остановился. Форма
> жила ещё в СЕМИ: `spec/syntax.md` (3), `spec/syntax.ru.md` (3),
> `spec/decisions/README.md` (4), `spec/decisions/04-effects.md` (8),
> `std/src/prelude.nv` (1), `std/src/math/overflow_policy_test.nv` (6),
> `std/src/runtime/defaults.nv` (1) — итого 28 вхождений в восьми файлах,
> из них три файла в `std`. Владелец заметил вопросом «`UnsignedInts` — ещё
> такое вроде есть».
>
> Записано здесь потому, что это ТОТ ЖЕ класс, про который часом раньше
> написан амендмент к D416 §5 («правку внесли в одно место из четырёх»), —
> и повторил его тот, кто этот амендмент писал. Правило из этого простое и
> механическое: **переименование считается сделанным не когда исправлен
> носитель, а когда греп по старой форме даёт ноль по всем зонам.**
> В `std` все 8 правок оказались в комментариях и названиях тестов —
> проверено: `nova check std/src` даёт прежние 154/26, регрессии нет.
> **АМЕНДМЕНТ 2026-09-04 (решение владельца; исследование
> `docs/dev/research/2026-09-04-typed-index-vec.md` §6; реестр №896).** Раздел
> «Семантика тела» ниже говорит «мономорфизация per член, как обычный `fn[T]`»,
> но молчал о конформансе, и чекер читал молчание как «нет»: set-blanket метод
> находится диспатчем (`fn[T SignedInts] T @ordinal() -> int`; вызов
> `(7 as i32).ordinal()` резолвится), но не засчитывается при проверке bound'а
> (`fn take[I Ordinal](i I)`, вызов `take(7)` →
> `[E_BOUND_NOT_SATISFIED] type int does not satisfy Ordinal … int is missing:
> ordinal()`). Два ответа на один вопрос «есть ли у `int` метод `ordinal`».
>
> **Норма:** set-blanket метод считается объявленным на **каждом члене** set'а — в
> том числе для проверки конформанса протоколам и generic-bound'ам. Если все
> методы, требуемые протоколом, даны blanket'ами по set'у, каждый член set'а
> удовлетворяет этому протоколу. Это не новое правило, а буква «per член»,
> доведённая до конформанса.
>
> **Реализация (требование, не подсказка):** конформанс спрашивает **ту же
> таблицу методов и тем же путём, что диспатч**. Set-blanket разворачивается по
> членам при регистрации объявления — set конечен, дальше обычный lookup; полный
> скан таблицы методов на каждую проверку (форма правки №111 для blanket'ов по
> протоколу) для set'ов не годится. Проба:
> `docs/plans/repro/896-set-blanket-conformance/`. Запись положена **до** фикса
> №896: спека прежде реализации.
>
> **Тем же днём (решение владельца): set `Floats`.** Амендмент 2026-08-17 ссылался на «семейство
> `Ints`/`Floats`», но set `Floats` нигде не был объявлен — имя существовало только в этой прозе
> (проверено грепом по спеке, std и компилятору). Объявлен: `type Floats set f32 | f64` в
> `std/src/prelude/protocols.nv` рядом с `Ints`; проба `fn[T Floats] T @half() -> T => @ / (2 as T)`
> на `f32` и `f64` — чекер принимает. Назначение — blanket-методы над обеими ширинами float, по
> образцу `fn[T Ints] T @checked_*`. Прелюдийных set'ов теперь четыре: `SignedInts`,
> `UnsignedInts`, `Ints`, `Floats`.
// многострочный — | обязателен у каждого члена включая первый
type AnyNumber set
| i8 | i16 | i32 | i64 | int
| u8 | u16 | u32 | u64 | uint
fn[T UnsignedInts] 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_parsebuiltin и заменил на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 через|. Многострочная форма: если первый член на новой строке —|обязателен у каждого члена включая первый (аналогично D406enumи D310set); несколько членов в одной строке допускаются. Вложенность (D310 amendment, Plan p424, 2026-08-07). Член МОЖЕТ сам быть другим type-set’ом (type Ints set SignedInts | UnsignedInt): на объявлении внешнего набора члены вложенного разворачиваются (flatten) в его собственный список, рекурсивно, через ЛЮБУЮ глубину вложенности. Дедупликация — по идентичности листового члена (set Ints | i32не дублируетi32, уже входящий вInts; если один и тот же лист достижим через два разных вложенных набора, входит один раз). Циклы — набор, который (транзитивно, через цепочку вложенных членов) содержит сам себя, не имеет конечного разворачивания: прямая самоссылка (type C set C) и взаимная (type A set B,type B set A) обе ловятся диагностикойE_TYPE_SET_CYCLEс путём цикла (A -> B -> A). Происхождение листового члена в диагностиках, читающих развёрнутый список (E_TYPE_NOT_IN_SET), помечается указанием вложенного набора, через который он попал (member `i32` (via `SignedInts`)) — без этого сообщение ссылалось бы на имя, отсутствующее в исходном тексте объявления набора.~underlying/representation/structural bounds по-прежнему НЕ вводятся этим амендментом — вложенность = композиция явно перечисленных множеств, не structural-matching; Q-representation-bound остаётся open question, Plan 102. - Члены — по ИДЕНТИЧНОСТИ. Примитивы, любые объявленные конкретные типы (newtype / named-tuple / record), либо (D310 amendment) другой type-set — каждый перечислен ЯВНО. Protocol и effect НЕ конкретны — отклоняются (
E_TYPE_SET_MEMBER_NOT_CONCRETE). Newtypetype MyI8 i8не член set’а{i8}— нужен явный листинг; вложенность НЕ вводит структурное совпадение, она лишь имя для уже явно перечисленного списка.~underlyingНЕТ (в Nova нет implicit-coercion; D52/D215). - Bound = membership-предикат. В
[...]-позиции type-set ведёт себя как protocol-bound (D72):[T SignedInts]. Композиция с протоколами через+(D145, conjunction):[T SignedInts + Hash]⇒ T ∈ set И реализует Hash; проверки независимы, per-member. Не более одного type-set в bound-листе (E_MULTIPLE_TYPE_SETS); протоколов — сколько угодно. - Семантика тела. Мономорфизация per член (как обычный
fn[T], Plan 48 worklist) — над РАЗВЁРНУТЫМ (post-nesting) списком листовых членов, вложенность не меняет модель мономорфизации.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): неразрешённый член = диагностика чекера, не угадывание.
Амендмент (D310 amendment, Plan p424, 2026-08-07, владелец + я) — снятие «Знаковости», вложенные наборы
Один атом, два изменения, одна причина: правило «Знаковость» запрещало ЧАСТИЧНУЮ смесь
signed/unsigned (set i32 | u32), пока то же самое, но ПОЛНОЕ объединение (Ints,
SignedInts ∪ UnsignedInts), было легально через exemption (D423
§R3 — единый .nv-бланкет fn[T Ints] T @checked_add(...) и его родня, живущий на
Ints в проде). Это самопротиворечие: {i32, u32} ⊂ Ints, и если тело корректно
мономорфизуется по всем десяти членам Ints, оно тем более корректно по любым двум из
них — правило отвергало строго более СЛАБЫЙ случай, разрешая более сильный. Мотивировка
«несовместимые value-domains» (u64.MAX ∉ i64) не выдерживает: per-member мономорфизация
уже резолвит T.MAX/T.MIN по имени ПОДСТАВЛЕННОГО члена (см. «Семантика тела» выше) —
ни один T-выражение никогда не видит значение ЧУЖОГО домена. Правило «Знаковость» и код
диагностики, которым оно проверялось, СНЯТЫ целиком — смешивать signed/unsigned в одном
наборе теперь можно как частично, так и полно; SignedInts/UnsignedInts остаются в
stdlib (полезны там, где однородность нужна ПО СУЩЕСТВУ, напр. тело, полагающееся на
отсутствие отрицательных), но перестают быть ОБЯЗАТЕЛЬНЫМ способом обойти запрет.
Вторая половина того же атома — вложенность — устраняла бы ту же ловушку с другой
стороны: без неё Ints пришлось бы держать как плоский список десяти имён (или, после
снятия «Знаковости», у автора не было бы способа выразить «сумма двух готовых наборов»
без копирования их членов). Прецедент — Go 1.18+ type-constraint’ы, где составные
ограничения (constraints.Integer = Signed | Unsigned) — канон, не частный случай.
Разворачивание НА ОБЪЯВЛЕНИИ (а не лениво на use-site) сохраняет остальную модель D310
нетронутой: мономорфизация, оператор-пересечение и diagnostics ниже по конвейеру видят
тот же плоский список членов, что видели бы, будь он списан руками. Единственная НОВАЯ
диагностика — E_TYPE_SET_CYCLE: разворачивание, в отличие от протокольного use
(D145), не терпит цикла (protocol composition допускает diamond-embed без цикла по
своей модели; здесь цикл буквально означает «бесконечный список членов», развернуть
нечего).
Проверки / диагностика (чекер, §1/§6; новые коды в 09-tooling)
E_TYPE_NOT_IN_SET— конкретный T не член set’а (фиксируется на инстанцировании, не на use-site внутри тела; сообщение перечисляет членов + fix; член, попавший в набор через вложенность, отмечен происхождением — см. «Синтаксис» выше).E_TYPE_SET_MEMBER_NOT_CONCRETE— член set’а не конкретный тип (protocol / effect).E_TYPE_SET_CYCLE— (D310 amendment, Plan p424) вложенные члены образуют цикл — прямой (type C set C) или через цепочку (A set B,B set A) — набор не имеет конечного разворачивания.E_MULTIPLE_TYPE_SETS— >1 type-set в одном bound-листе.
Почему
- Reuse через семейства примитивов — один
fn[T SignedInts] 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. - Композиция вместо копирования (D310 amendment) —
Ints=SignedInts | UnsignedInt, будущий набор = композиция готовых, а не переписанный вручную список; синтаксическая экономия БЕЗ ослабления identity-модели (структурного совпадения по-прежнему нет).
Связь
D52 (формы type, first-token dispatch) · D53 (kind-токен под type) · D72 (bound = тип в […]-позиции, amended) · D145 (+ multi-bound conjunction, amended) · D237 (capitalized naming) · D315 (ResolvedType несёт ширину/знак) · D423 §R3 (единый Ints-бланкет — мотивировка амендмента). Q-representation-bound — частично (explicit-member-set, теперь и nested-explicit-member-set); ~/repr → Plan 102. Потребитель: Plan 174.1.
D405 — Арифметика смешанных целых ширин: требовать явный cast (2026-06-30) {#d405}
Уточнено D491 (2026-10-02, решение владельца): правило «один тип» распространено с операторов на ВСЕ места — позицию с записанным типом, рукава
match/if, обёртку в вариант; это единственный дом правила о значениях, литералы — D489.
Уточнено D489 (2026-10-02): литерал во втором операнде — по единому правилу литерала (
x == 300приx u8—E_LIT_OUT_OF_RANGE, целое во float — только точно).
Статус: закреплён 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/Swift-стиль, не Java/Kotlin/C/Zig
| Язык | Поведение | Проблема |
|---|---|---|
| Java/Kotlin | byte + short → int (implicit, always signed) | Результат signed даже для unsigned операндов; теряется знаковость |
| C | u8 + u16 → uint (integer promotion, C-зависимо) | Платформо-зависимо; signed promotion удивляет |
| Zig | u8 + u16 → u16, f32 + f64 → f64 (Integer and Float Widening: coercion к типу, вмещающему все значения старого) | Расширяет молча, как C, только без потери значений; сужение — ошибка |
| Rust/Go/Swift | compile error → явный cast | Никаких сюрпризов; программист контролирует |
| Nova (D405) | compile error → явный as cast | Согласуется с «no implicit conversions» (D54) |
Поправка 2026-09-04 (владелец): Zig стоял в ряду «compile error → явный cast» — неверно, Zig расширяет и целые, и float неявно (без потери значений); перенесён в свою строку. Доноры решения — Rust/Go/Swift.
Nova не имеет implicit numeric coercion нигде (D54 — as для всех width-changes). D405 последовательно расширяет это на бинарные операторы: нет молчаливого widen или truncate.
Амендмент (2026-09-04, решение владельца): правило — категорией, не списком; сравнение, float, унарный минус
Что обнаружено (исследование docs/dev/research/2026-09-04-numeric-widening.md, пробы
docs/plans/repro/926-mixed-numeric-doors/, строка реестра «D405 запрещает смешанную арифметику,
но три двери вне его списка молчат»). Правило выше перечисляет девять операторов и говорит
«целочисленный тип»; правило relational-операндов требует лишь ordered-категорию; D427 R2 оставляет
unsigned -x «не тронутым». Компилятор исполняет букву, и три двери молчат, собираются и печатают
неверное: u32(4294967295) == i32(-1) → true; i64 + f64 → 6.5; -a при a u8 = 200 → 56.
Первое — ровно ловушка C, ради которой этот блок принят. Контроль: u8 + i16 → E_MIXED_WIDTH_ARITH.
Норма (владелец 2026-09-04: «никакого автоматического „бери больший“ — так должно везде работать»; унарный минус на unsigned — ошибка компиляции):
- Операнды любого числового оператора — бинарного арифметического (
+ - * / %), битового (>> << & | ^), сравнения (== != < <= > >=), составного присваивания (+=и прочие) — имеют один и тот же конкретный числовой тип — по имени, не по ширине (int≠i64,uint≠u64по D129/D130, хоть на bootstrap они и равны по ширине): одинаковую ширину и знаковость для целых, одинаковую ширину для чисел с плавающей точкой (f32сf32,f64сf64—f32 + f64сегодня молча расширяется доf64, пробаfloat_width); целое и число с плавающей точкой не смешиваются. Список операторов в правиле выше — иллюстрация, не граница: правило — категория «числовой оператор», не перечень. Правил про float до этого амендмента в спеке не было вовсе: D405 говорил о целых, D55 п. 4 перечисляет целые цели литералов; литеральные правила для float — открытый вопрос, решённый тем же днём амендментом к D55 (float-литерал — к любому float-типу; литерал без точки — к float-операнду). - Исключение прежнее: литерал без явной ширины адаптируется к типу другого операнда (D55).
- Унарный минус определён только для знаковых типов.
-xпри беззнаковомx— ошибка компиляции; диагностика называет путь-(x as iN). Заворот по модулю (-200u8 → 56) не производится. Это амендмент и к D427 R2: «unsigned-xне трогается» → «отвергается чекером до кодогена»; пометка поставлена там же. - Реализация — требование: одна дверь сверки операндов для всех числовых операторов; чекер, не Си
(
EXPECT_CC_ERRORдля этих случаев — не приёмка). Спека положена до фикса.
Связь
D54 (as явный cast) · D55 (literal coercion — исключение для нетипизированных литералов) · D315 (ResolvedType несёт ширину/знак — необходим для этой проверки) · D310 (SignedInts/UnsignedInts 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-pathfrom_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_querypercent-encodit КАЖДЫЙ UTF-8-байт (был баг: один байт для >127);decode_query— self-contained UTF-8-валидация. Байт-корректный парсер (byte-offsets, срезы по ASCII-делимитерам). Детали ошибок —ParseUrlError(tuple-варианты) черезErrSource.UrlParse.Mime/ContentType—type/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-consumeBody(D359) → самиconsume; метаданные — borrow-методы; тело разряжается делегирующими consume-методами (resp.text()).
Амендменты по факту реализации (Ф.1)
SameSite.None→SameSite.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-valuerecord: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 для провалившегося decodeContent-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_limit → BodyTooLarge (DoS-guard). @text = строгий UTF-8 (Ф.1).
Амендменты / гейты по факту (Ф.1)
Body—consume(НЕ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-баг eqOption[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]; typedexpires 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 terminalserialize_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_keyreturn a sub-cursor (Self) positioned at the child; sub-deserializers only READ, so no write-back/ownership problem (keyed-access model = SwiftKeyedDecodingContainer). NoVisitorcompanion 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 fields.struct_field("k")?; @field.serialize(s)?;s.end_struct()— UNIFORM memberwise push (like@debug).- Field order — amendment 2026-10-03 (owner’s decision): the order of DECLARATION.
@serializewrites the fields in the order the record declares them (a record variant: its fields after the tag, in their declared order);.deserializeaccepts any order. As Rust serde, Goencoding/json, SwiftCodable, Pythondataclasses.asdict: the output is predictable and the author controls it by ordering the fields. .deserialize: per fieldmut 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]→ inlineif sub.is_null()? { None } else { Some(<inner>) }(built-inOptiondoes not dispatch a user static method). ThenOk(Type{ f1, f2, … }).- Field-eligibility: primitive /
Option·Vec·HashMap[str,_](recurse) /#impl(P)/ provides-method — elseE_AUTO_DERIVE_FIELD_LACKS_PROTOCOL(named field; no silent drop).HashMapkey must bestr(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_typecannot always resolve; type-checking annotates them). Non-serde protocols inject AFTER check as before (some bodies, e.g.@display’sw.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). ParseJsonError → DeError{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,Tuple→V(clone…),Record→V { 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 viaw.write_str(str.from(x)), debug uniformlyx.debug(w). These inject AFTER type-check (like the record-path), so the emittedmatch/ variant-patterns / variant-construction are lowered by codegen’s annotation-free inference (scrutinee@+other: Selftypes 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 ✅: unitV→{"k":"V"}; recordV{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 theJsonValue, 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 perturbsstd/encoding/jsoncodegen in the same CU (mono-collection ordering →Json.parsemis-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 SAMESerializer/Deserializerprimitives as external (begin_struct/struct_field/enter_field/enter_index/is_null/…) — no new backend methods. The mode is computed from the type’sserde_attrsbyserde_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_attrs → parse_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, AMENDED by D435 2026-07-22 — 180.1 Ф.1). 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). rename,
rename_all, skip, skip_serializing_if, default, alias,
deny_unknown_fields, allow_unknown — field/wire customization on RECORD
types, consumed by the record synthesizer (D435). flatten is parsed and
statically validated but its synthesis remains gated (D435,
[M-180-serde-flatten]). 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).
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.
See D435 for the field-attribute-specific validations added on top
(E_SERDE_ATTRIBUTE_MISPLACED, E_SERDE_WIRE_NAME_COLLISION, etc.).
D435 — field-attribute consumption + wire-contract validation (Plan 180.1 Ф.1/Ф.10) {#d435}
What changed. D382 defined the #serde(...) grammar/AST generally but
consumed only the sum-tagging keys; every other key parsed-but-rejected
([M-180-serde-field-attributes]). This amendment lands consumption of
the field/wire-customization keys on record types (sum-type rich-attr
support remains a separate follow-up, gated the same way sum auto-derive
richness is — [M-126-sum-*-rich]) plus the compile-time wire-contract
validation that rename/alias introduce.
SerdeArg grows (compiler-codegen/src/ast/mod.rs): Rename(String),
RenameAll(RenameConvention), Skip, SkipSerializingIf(String),
Default(Option<String>) (None = bare default, Some(fn) =
default = "fn"), Alias(String) (repeatable), Flatten,
DenyUnknownFields, AllowUnknown. RenameConvention (new enum): CamelCase /
SnakeCase / KebabCase / ScreamingSnakeCase / PascalCase, parsed from the
rename_all string value at PARSE time (unknown convention name →
E_SERDE_BAD_ATTRIBUTE naming the supported set, same policy as an unknown
key) and applied via RenameConvention::apply (splits the canonical
snake_case field name on _, recombines per convention).
Semantics (per key):
rename = "wire_name"(field-level) — the field’s effective wire name. Overrides a type-levelrename_all(field-level wins — explicit beats derived).rename_all = "convention"(type-level, record only) — every field’s wire name isconvention.apply(field_name)unless that field has its ownrename.skip(field-level) — the field is never serialized and never read from the wire on deserialize; its value on decode comes from the SAME fallback resolution as baredefault(a computable zero value: numeric →0,bool→false,str→"",Option[T]→None,Vec[T]/[]T→[],HashMap/Map→.new()) or, if given,default = "fn"’s result. A field type with no computable zero value and nodefaultoverride →E_SERDE_SKIP_FIELD_NO_DEFAULT(actionable: names the field/type, tells the user to adddefault = "fn_name") — never a silent bad value or an ICE.skip_serializing_if = "predicate"(field-level) — on serialize,@field.<predicate>()is called; if it returnstruethestruct_field+value pair is omitted from the wire for that value. General form (notOption-special-cased): any zero-arg bool-returning method on the field’s type works (is_noneonOption, but also e.g.is_emptyon a collection). On deserialize the field is unaffected (absence is handled by the normal missing-field/default/alias machinery below) — this key is serialize-direction only, matching serde’s own asymmetry.default(bare) — if the field (or all itsaliascandidates, see below) is absent from the wire, use the type’s zero value instead of raisingMissingField.default = "fn_name"— same trigger, but the fallback value isfn_name()(a zero-arg function returning the field’s type) instead of a zero value.alias = "old_name"(field-level, repeatable) — an ADDITIONAL wire name accepted on READ ONLY (schema migration: old clients still sendold_name). Resolution order: the field’s own (rename/rename_all-resolved) wire name first, then eachaliasin declaration order — the first candidate PRESENT in the wire object wins (checked via the newDeserializer.has_field, not by catching aMissingField). If NONE of the candidates are present: anOptionfield (no explicitdefault) falls back toNone(Q7 semantics, now alias-aware); a field WITHdefault(bare or= fn) uses that; otherwise (Required, nodefault) the primary wire name is re-entered so the naturalMissingField(primary)diagnostic still fires, naming the CANONICAL name (not an alias) so the error message stays anchored to the type’s own schema.deny_unknown_fields(type-level) — AMENDED MEANING by D436 below: now a no-op synonym of the (new) default; kept accepted for serde-muscle- memory rather than becoming a staleE_SERDE_BAD_ATTRIBUTEtrap.allow_unknown(type-level) — see D436 (Ф.7 default reversal).flatten(field-level) — parsed, statically validated, synthesis GATED.E_SERDE_FLATTEN_DENY_CONFLICTwhen the type is (now-default) strict: a flattened field’s inner keys arrive mixed into the parent wire object, which the parent’s unknown-field scan cannot attribute without knowing the child type’s field set.E_SERDE_FLATTEN_UNSUPPORTEDonceallow_unknownremoves that specific conflict — actual flatten synthesis needs a companion “fields-only” synth variant (reads/writes the child’s fields directly into the parent’sd/scursor, with NObegin_struct/end_struct/enter_fieldwrapper of its own) that the auto-derive machine does not yet emit. Honest scope-out, tracked[M-180-serde-flatten]— the hardest item in Ф.1, deliberately not forced.
New protocol member (std/src/encoding/serde/serde.nv Deserializer):
mut @has_field(key str) -> Result[bool, DeError] — an exact presence check,
distinct from the existing enter_field_or_null (which conflates “key
absent” with “value present and JSON null” — correct for Option’s
absence-is-None semantics, WRONG for default/alias resolution, which
must distinguish the two). JSON backend (json.nv): @cur.object()?.get(key) != None.
Compile-time wire-contract validation (Ф.10), run once per record type
(validate_wire_contract, after rename/rename_all/alias resolution):
E_SERDE_WIRE_NAME_COLLISION— two fields’ effective wire names collide (afterrename/rename_all); analiascollides with another field’s wire name; analiasis declared on two different fields.E_SERDE_SKIP_RENAME_CONFLICT—skip+renametogether (rename is meaningless on a field that is never on the wire).E_SERDE_ATTRIBUTE_MISPLACED— a field-only key on a type-level#serde(...)or vice-versa (e.g.renameon the type,rename_allon a field).E_SERDE_ATTRIBUTE_ON_SUM_UNSUPPORTED—rename_all/allow_unknown/deny_unknown_fieldson a SUM type (Ф.1 v1 scope is record-only; silently ignoring would be a worse footgun than a clear gate).E_SERDE_DUPLICATE_ATTRIBUTE— the same key given more than once where that is ambiguous (rename,rename_all,skip_serializing_if,default— NOTalias, which is deliberately repeatable).
Scope note (record vs sum). All of the above targets TypeDeclKind:: Record/NamedTuple fields. SumVariantKind::Record payload fields (a
struct-shaped enum variant) do NOT yet consume field-attrs — sum rich synth
is a separate gate ([M-126-sum-*-rich]); using a field-customization key
inside a sum variant’s payload is currently a silent no-op there (unlike the
type-level misplaced-key checks above, which DO cover sum declarations).
Tracked as a known v1 scope boundary, not a defect.
D436 — unknown-field policy: strict by default (Plan 180.1 Ф.7, REVERSES Plan 180 Q5) {#d436}
Reversal. Plan 180’s Q5 (180-serde-derive.md decision table) chose
serde parity: ignore unknown wire fields by default, deny_unknown_fields
opt-in strict. Owner decision 2026-07-22 (“согласен”): REVERSED. An
unknown field in the wire object is now rejected by default —
Err(DeError{UnknownField(name)}) — with a NEW opt-out key,
#serde(allow_unknown), restoring the old ignore-silently behaviour for
types that deliberately want forward-compatible wire evolution.
Rationale (owner, verbatim intent). serde/Jackson’s ignore-by-default is
a well-known class of silent bug: a typo’d config key, or a field renamed on
one side of an API and not the other, is accepted and silently dropped —
the value the caller thought they set never takes effect, with no signal at
all. Nova already treats #serde(...) attribute typos as a hard compile
error (E_SERDE_BAD_ATTRIBUTE, D382) in the same no-magic spirit; treating a
wire-level “typo” (an unexpected key) as silently-fine while a
compile-level typo is a hard error was an inconsistency. AI-generated
client/server code in particular is prone to drifting field names — failing
loudly at the first decode is strictly better than a silently-incomplete
value discovered much later.
Mechanism. The record .deserialize synthesis (auto_derive.rs
build_unknown_field_check, called from synthesize_deserialize) emits, as
the FIRST statements of the body (unless the type carries
#serde(allow_unknown)):
ro __nv_wire_keys = d.map_keys()?
for __nv_uk in __nv_wire_keys {
if <__nv_uk not in known-wire-names> {
return Err(DeError.new(UnknownField(__nv_uk)))
}
}
known-wire-names = every non-skip field’s resolved wire name plus its
aliases (Ф.1). A skip field’s name is deliberately NOT in that set — a
skipped field is functionally invisible, so a wire key matching its name is,
correctly, still “unknown” under strict policy.
deny_unknown_fields (the OLD opt-in key, D382/Q5) is retained as an
ACCEPTED, explicit no-op — it now merely re-states the already-active
default; kept rather than turned into a stale E_SERDE_BAD_ATTRIBUTE trap
for anyone carrying serde muscle memory. Combining it with the new
allow_unknown on the SAME type is a direct self-contradiction →
E_SERDE_UNKNOWN_FIELD_POLICY_CONFLICT (D435’s validation list).
DeErrorKind::UnknownField(str) (serde.nv, D340) already existed in
the data model (originally documented “at deny_unknown_fields” — comment
updated) — this amendment is what makes the record synthesizer actually
raise it, closing that latent gap.
Migration. Audited every #impl(... Deserialize ...) type in the tree
at landing time (std/, spec_tests/, nova_tests/, examples/ including
the flagship aggregator): zero existing record type decodes a
hand-written wire literal carrying a field outside its own schema — the only
DTOs consuming Deserialize at all live beside std/encoding/serde itself
(the flagship’s JSON DTOs, examples/flagship/aggregator/src/api/ report_json.nv / main.nv, are #impl(Serialize)-only — snapshot/wire
output, never decoded back), so no pre-existing fixture needed a behavioural
migration to allow_unknown or a tightened DTO. New pos coverage:
std/src/encoding/serde/field_attrs_test.nv (strict-by-default → Err {UnknownField}; #serde(allow_unknown) opt-out pos-case).
Peer-table update. 180-serde-derive.md §2’s unknown-field-policy row
(“= IGNORE by-default ... (serde-паритет)”) is superseded by this decision
— Nova is now 🏆 strict-by-default (stricter than serde/Swift/Kotlin’s
ignore-default; matches kotlinx’s protectiveness by DEFAULT rather than by
opt-in @JsonClassDiscriminator-adjacent configuration, while still offering
an explicit, opt-in escape hatch serde-style forward-compat APIs still need).
D438 — Reflect protocol + TypeShape structural reflection (Plan 222.8 Ф.1, 2026-07-27) {#d438}
What. A 9th built-in auto-derive protocol, Reflect (std/src/reflect.nv,
module std.reflect), alongside Equal/Hash/Clone/Compare/Display/Debug/
Serialize/Deserialize (D237/D340/D341). .reflect() -> TypeShape — a
STATIC method (no receiver value: it describes a TYPE, not an instance)
returning a format- and domain-independent description of a type’s
structural shape. Not a word about HTTP/OpenAPI/JSON anywhere in the
protocol or TypeShape itself — deliberately general enough for an
OpenAPI-schema emitter, a GraphQL SDL generator, a CLI-arg generator, or a
debug pretty-printer, all reading the SAME tree. The first consumer is
nova-polaris’s OpenAPI generator (Plan 222.8 Ф.2/Ф.3, docs/plans/ 222.8-openapi-gen.md) — deliberately out of THIS decision’s scope; Reflect
carries no knowledge of HTTP roles (Path/Query/Json wrappers), response
codes, or wire formats. That interpretation lives in the consuming library,
not the compiler or std.
TypeShape (std/src/reflect.nv):
export type SumRepr enum
| External
| Tagged(tag str)
| TaggedContent(tag str, content str)
| Untagged
export type TypeShape enum
| Record(name str, fields [](str, TypeShape))
| Sum(name str, repr SumRepr, variants [](str, TypeShape))
| Ref(name str)
| Str | Int | Float | Bool | Unit
| Arr(items TypeShape)
| Opt(inner TypeShape)
| Opaque(name str)
export type Reflect protocol {
.reflect() -> TypeShape
}
Reflect is a structural protocol (D42) like every other Nova protocol:
a type satisfies it by providing a matching .reflect() method, with or
without #impl(Reflect). #impl(Reflect) on a type declaration REQUESTS
compiler synthesis of that method (opt-in, same as the other 8 auto-derive
protocols) — it is not a conformance marker. A type providing its own
hand-written .reflect() satisfies Reflect without ever writing
#impl(Reflect) (see Opaque below).
Compiler synthesis (compiler-codegen/src/protocols/auto_derive.rs,
synthesize_reflect). Same field-walk infrastructure as Serialize/
Deserialize (D340/D341/D435) — reused, not duplicated:
- Record field wire-names —
resolve_fields/wire_name_for(D435):TypeShape.Record’sfieldslist carries names AFTERrename/rename_all/alias resolution — the WIRE contract, not the raw Nova field name.#serde(skip)fields are excluded (never on the wire, so absent from the schema too — a schema entry for a field that never serializes would be actively misleading to a consumer like an OpenAPI emitter). - Sum representation —
serde_tagging_mode(D382/D435), reused WHOLESALE (not re-parsed):SumRepr.External/Tagged(tag)/TaggedContent(tag, content)mirrorSerdeTagging::External/Internal/Adjacent. This meansReflectsynthesis inherits the SAME#serde(untagged)gate as Serialize/Deserialize (E_SERDE_UNTAGGED_GATED,[M-180-untagged-codegen-mono]) even thoughReflectitself never touchesjson.nv’s mono-ordering — a deliberate, documented simplification (favor code reuse over a parallel permissive path for a single field-count of extra freedom); revisit if/when the untagged codegen gate lifts. - Sum variant shapes (no existing precedent to reuse — new Ф.1 choice):
unit variant →
Unit; single-element tuple variant → the element’s shape DIRECTLY (transparent, mirroring serde’s own “single payload → bare content” treatment, e.g. adjacent-taggedVal(int)→{"t":"Val","c":9}, not{"t":"Val","c":[9]}); multi-element tuple variant → a syntheticRecord(variant_name, [("0", ..), ("1", ..)])(positional fields named by index); record-payload variant →Record(variant_name, [(field, ..), ..])using RAW field names — sum-variant record fields do not yet consume#serde(rename...)(D435’s own scope note,[M-126-sum-*-rich]), mirrorssynthesize_serialize’s identical choice for the same reason. - Field/variant-payload eligibility — a NEW
check_field_eligibility_ reflect(mirrorscheck_field_eligibility_serde’s shape: bespokeOption/Vecrecursion, a named type must provide an explicit.reflect()or declare#impl(Reflect)) — narrower scalar set than serde’s (no wire-precision concern: every integer width maps uniformly toInt, no widen/narrow split;char/byte/i128/u128excluded, same reasoning as serde — no faithfulTypeShapeleaf for them). A nested type withoutReflectconformance is a typedE_AUTO_DERIVE_FIELD_LACKS_ PROTOCOL, mirroring Serialize’s identical diagnostic class (not an ICE, not a silent hole).
Recursion (Ref) — the one genuinely NEW mechanism, no serde precedent.
Unlike Equal/Hash/Serialize (per-INSTANCE protocols whose recursion always
terminates on finite RUNTIME data — a linked list’s @equal bottoms out
because the VALUE is finite), TypeShape describes the TYPE GRAPH itself,
which can be genuinely cyclic (self-referential: type Node { next Option[Node] }; or mutually recursive: A embeds B, B embeds
Option[A]). The synthesizer inlines nested named types’ shapes directly
(builds Record/Sum literals recursively via build_type_decl_shape, NOT
by dispatching runtime calls to each type’s own separately-synthesized
.reflect() — a dispatch chain would recurse unconditionally at runtime
with no data-driven base case, since .reflect() takes no self to pattern-
match on). An in_progress stack tracks the chain of type names currently
being inlined; a field naming one of THOSE types emits TypeShape.Ref(name)
instead of expanding further. This is the ONLY place a cycle is broken, so
the produced value is finite BY CONSTRUCTION (a compile-time graph-cycle
check), never by a depth limit, and handles indirect/mutual cycles too (by
the time B’s own inline expansion reaches its field back to A, A is
still on the SAME shared stack B’s expansion inherited). A type with an
EXPLICIT hand-written .reflect() (not #impl(Reflect)-derived) is
DISPATCHED (a plain static call), never inlined — the compiler does not
know what a hand-written body does, so it trusts it rather than expanding
it; this is also how Opaque composes safely with the recursion machinery
(see below).
Opaque(name) (owner addition, 2026-07-27, mid-Ф.1). A TypeShape leaf
meaning “this type’s shape is intentionally not described” — for raw/
unshapeable types (e.g. a raw ServerRequest handle that appears as an
extractor-bundle field per 222.8 §1.3, but is never itself part of a JSON
schema). The compiler NEVER synthesizes Opaque itself — no “which
types are opaque” policy exists anywhere in auto_derive.rs; that decision
belongs entirely to whoever writes a manual .reflect() implementation
(typically a library wrapper type). Consumers (an OpenAPI emitter, etc.)
are expected to skip/omit Opaque fields from generated schemas.
Blanket implementations (std/src/reflect.nv, .nv code, not compiler
special-cases — same style as Display/Debug’s primitive blanket bodies
in prelude/protocols.nv): int/i8..i64/uint/u8..u64 → Int;
f32/f64 → Float; bool → Bool; str → Str; fn[T Reflect] []T. reflect() -> TypeShape => Arr(T.reflect()); fn Option[T Reflect].reflect() -> TypeShape => Opt(T.reflect()). These blankets are NOT invoked by the
compiler’s own record/sum synthesis path (which builds Arr(..)/Opt(..)
directly inline for the same cycle-safety reason as above) — they exist for
general/library code that wants a container’s shape without going through
auto-derive (e.g. Vec[SomeType].reflect() called directly).
Scope boundary (explicit, Ф.1). Tuple-typed FIELDS ((A, B) as a
record field’s own type, not a sum-variant payload) are NOT supported —
check_field_eligibility_reflect has no Tuple arm, unlike the generic
check_field_eligibility used by Equal/Hash/Clone/Compare (a documented
narrower scope, [M-222.8-reflect-tuple-field] if ever needed). resolve_ fields’s wire-collision validation (E_SERDE_WIRE_NAME_COLLISION etc.) is
inherited for record types (a Reflect-only type without #impl(Serialize)
still gets that validation “for free” via the shared resolve_fields call)
— a deliberate, documented consequence of reuse, not a targeted feature.
Result[T, E] has no blanket (deferred — 222.8 §1.2 “см. 1.3-статусы”).
Doc-comments → description is explicitly OUT of scope (222.8 §3, owner
decision: a docstring may carry internal notes, auto-publishing it is
unsafe without opt-in) — TypeShape carries no description/doc field at
all.
Why not fold into Serialize (per 222.8 §0, already-rejected designs
carried forward, not re-litigated here): Serialize is a general capability
not tied to JSON (§0.3 of the 222.8 plan); baking JSON-Schema-shaped output
into it would be a layering violation, and would cost binary size for every
#impl(Serialize) type that never touches HTTP. TypeShape is a SEPARATE,
opt-in protocol for exactly this reason.
D417. Closure-литерал против скалярного return-типа — E_CLOSURE_SCALAR_RETURN (2026-07-10)
Дыра. Ни assignable (сверка типов только в позициях call-arg / annotated-let),
ни literal-coercion канал (materialize_literal_coercion — материализует ширину
INT-литерала, но НИЧЕГО не отвергает) не сверяли closure-литерал (|| body /
|x| body / typed fn(...) ...), попавший в return-позицию функции (implicit
tail — trailing блока/arrow-body, либо explicit return X), с объявленным
return-типом. Codegen лоуэрит closure в указатель на функцию; если объявленный
тип — скаляр (bool/int-family/f32/f64/char), указатель молча
бит-реинтерпретируется в скаляр (напр. bool — ВСЕГДА true) БЕЗ диагностики.
Найдено 2026-07-10 при расследовании [M-toml-repeated-fail-call-run-fail]
(std/encoding/toml.nv is_bare_key_char) — типовой триггер: многострочное &&/
||-выражение, где программист по Rust/C-привычке ставит продолжающий оператор
В НАЧАЛЕ следующей строки:
fn f(c char) -> bool {
ro n = c as int
(n >= 65 && n <= 90)
|| (n >= 97 && n <= 122) // ведущий `||` — НЕ OR-продолжение!
}
Парсер (parse_or) намеренно не продолжает OR-цепочку через ведущий ||
на новой строке — синтаксис зарезервирован за zero-arg closure-литералом
(|| body), и продолжение создало бы неоднозначность с реальным
closure-statement’ом. Следствие: вторая строка парсится как СВОЙ, ОТДЕЛЬНЫЙ
statement — closure-литерал || (n >= 97 && n <= 122) — который, будучи
последним statement’ом блока, становится implicit-return значением. До этого
амендмента компиляция проходила молча; после — nova check/nova test-build
отвергают такое тело кодом E_CLOSURE_SCALAR_RETURN.
Проверка (compiler-codegen/src/types/mod.rs,
TypeCheckCtx::check_closure_scalar_return + _in_block/_in_stmt/_in_expr
walk-семейство, зеркалящее reachable-позиции существующего
materialize_returns_in_block/_in_expr): для каждой return-позиции fn’а
(implicit trailing блока/arrow-body И каждый explicit return X, в т.ч.
вложенный в if/match/while/for/loop того же execution-context —
НЕ внутрь detach/spawn/parallel for/вложенных closures, чей return
принадлежит другому execution-context) — если значение является closure-
литералом (ExprKind::Lambda/ClosureLight/ClosureFull) И объявленный
return-тип резолвится (ResolvedType::from_type_ref, сквозь
ro/mut/unsafe-модификаторы) в Bool/Scalar/Float/голый
char — ошибка. Возврат closure против fn-типа (TypeRef::Func —
легитимный HOF-возврат) НЕ флагуется — это единственный legal target для
closure-значения.
Код: E_CLOSURE_SCALAR_RETURN (новый, по образцу E_RECORD_PATTERN_NEEDS_REST/
E_REFUTABLE_BINDING — стабильный описательный E_*, не порядковый номер).
Диагностика указывает на closure-литерал (не на fn-декларацию) и explicitly
называет типовой триггер (ведущий ||/|x| на continuation-строке) как
подсказку для читателя. Fail-fast fix для программиста: перенести
продолжающий оператор в КОНЕЦ предыдущей строки ((...) || \ трейлинг —
легальное продолжение, в отличие от ведущего).
Область. Гейт — именно СКАЛЯР (bool/int-family/float/char); str/
Any/произвольный Named-тип в скоуп ЭТОГО амендмента намеренно не входят
(дыра шире, но подтверждённый репро и зафиксированный P2-маркер —
[M-closure-trailing-scalar-coercion-no-typecheck] — именно про скаляр;
расширение до общего closure-vs-non-fn-type mismatch — отдельный follow-up
при необходимости).
Побочный улов. Тот же класс бага (ведущий || на continuation-строке)
обнаружился этой проверкой ещё в двух местах std — std/data/semver.nv
is_ascii_ident_char и std/encoding/csv.nv needs_quoting — обе функции
ДО фикса возвращали true для ЛЮБОГО входа (closure-указатель, коэрснутый в
bool, always-truthy). Обе мигрированы на трейлинг-|| (тот же канон, что
toml.nv’s is_bare_key_char) в той же волне.
D419. Fmt protocol — format-spec context для @display_fmt (Plan 152.7.2, 2026-07-13)
AMEND (Plan 208 Ф.0, 2026-07-15) — RETRACT/SUPERSEDED целиком → D422. Два опциональных метода (
@display(mut w Write)+ отдельный@display_fmt(mut f Fmt)) сворачиваются в ОДИН required-примитив@display(mut f Fmt);@display_fmt-путь удаляется целиком (не остаётся вторым методом рядом).Fmtиз D419 (@write(s str)+alternate/precision— «Fmt поверх Write», два метода) заменена на D422-Fmt(use Writeprotocol-embed, D145, + полный набор осейwidth/align/fill/sign/alternate/kind/pad). Диспетч «@display_fmtесть → зовём его, иначе прежний@display+внешний pad» (§«Правило диспетча» ниже) заменяется единым «@display(f)ВСЕГДА диспетчится,pad_consumedрешает — внешний pad или нет» (D422 §«Инвариант»). Статус: целевая модель (Ф.1-4 pending, см. D422 §Статус) — текст ниже читать как ИСТОРИЮ (что было ДО D422), не текущее/будущее поведение.
Закрывает Q-format-spec-to-display
и обновляет протухший followup-маркер [M-91.14-format-dsl-extensions]
(перечислял уже реализованное — 152.7-B закрыл :hex/:pad-N/:.3 до этого
амендмента; единственный реальный остаток был «типу не передаётся спек»,
что и закрывает это решение).
Проблема
До D419 встроенный rich format-spec (${x:[[fill]align][sign][#][0][width] [.precision][type]}, D258/152.7-B) применялся СНАРУЖИ к выводу
Display.@display — тип не видел собственный спек. # (alternate)
существовал только для integer radix-префиксов (0x/0o/0b); для
user-типов флаг молча игнорировался. Pretty-печать (JsonValue.to_str_pretty)
жила отдельным методом, не связанным с интерполяцией.
Решение — три развилки (см. Q-блок), решены так:
(а) Форма контекста. Второй ОПЦИОНАЛЬНЫЙ метод, не расширение сигнатуры
@display: @display_fmt(mut f Fmt). Fmt — protocol ПОВЕРХ Write
(структурно расширяет его: тот же метод @write(s str), НЕ переименованный
@write_str — это сохраняет «Fmt поверх Write» буквально верным: любой Fmt
структурно удовлетворяет и Write) плюс две новые оси:
export type Fmt protocol {
mut @write(s str) -> ()
@alternate() -> bool
@precision() -> Option[int]
}
Единственный production-implementor V1 — FmtCtx (конкретный record,
std/prelude/protocols.nv), который компилятор конструирует НА КАЖДОМ
call-site ${x:SPEC}, диспетчащем в @display_fmt:
export type FmtCtx {
sink Write
alt bool
has_prec bool
prec int
}
export fn FmtCtx.new(sink Write, alt bool, has_prec bool, prec int) -> Self => …
export fn FmtCtx mut @write(s str) -> () { @sink.write(s) }
export fn FmtCtx @alternate() -> bool => @alt
export fn FmtCtx @precision() -> Option[int] =>
if @has_prec { Some(@prec) } else { None }
ABI: Fmt эрайзится к конкретному C-типу Nova_FmtCtx* ТЕМ ЖЕ приёмом, что
Write уже эрайзится к Nova_StringBuilder* (152.7.1 D374 AMEND) — не
generic protocol-boxing (NovaBox_<proto>/vtable), явный V1-компромисс
(апгрейд до реального vtable — future followup, как и у Write).
(б) Без ломки. Сигнатуры @display(mut w Write) / @debug(mut w Write)
НЕ меняются — D374 не переламывается второй раз. @display_fmt — целиком
опциональный второй метод; тип без него получает ровно ПРЕЖНЕЕ поведение
(auto-делегация в @display/D410-to_str-fallback + внешняя пост-обработка
width/align/fill/precision).
(в) Какие оси передаются типу. Только alternate (# — канонический
pretty-флаг) и precision (.N). width/fill/align ОСТАЮТСЯ
пост-обработкой у вызывающего — тип не должен уметь паддить себя сам (тот же
nova_fmt_pad внешний проход, что и раньше). radix (x/X/b/o) —
без изменений: встроенный числовой путь, к user-типам неприменим
(E_BAD_FORMAT_SPEC как раньше).
Правило диспетча (${expr:SPEC}, ТОЛЬКО non-? rich-spec ветка)
- Если
typeof(expr)объявляет@display_fmt— вызывается ОН, сFmtCtx, сконструированным из разобранного спека (alt/has_prec/prec— изFormatSpecParsed, compiler-codegen/src/ast/format_spec.rs). - Иначе — прежний путь:
Display.@display(или D410to_str-fallback) в свежийStringBuilder, затем внешняя пост-обработка (precision-truncation +nova_fmt_pad) — байт-в-байт как до D419. ${expr:?}(Debug) — ВНЕ этого правила, не затронут;@display_fmtзеркалит толькоDisplay.E_FORMAT_SPEC_UNKNOWN(парсер, syntax-level — неизвестный type-char типа${x:foo}) — БЕЗ изменений для всех типов (парсер типа не знает); «смягчение только для типов с@display_fmt» относится к СЕМАНТИКЕ#/.N, не к синтаксической диагностике: до D419#/.Nна user-типе БЕЗ ошибки, но МОЛЧА игнорировались (#не имел эффекта); после D419 — типы С@display_fmtполучают РЕАЛЬНЫЙ эффект этих осей, типы БЕЗ — прежнее молчаливое игнорирование (не регрессия, задокументированный status quo).
Первый потребитель
JsonValue @display_fmt (std/src/encoding/json.nv): f.alternate() →
@to_str_pretty() (существующий pretty_at), иначе @to_str() (compact).
"${v:#}" == v.to_str_pretty(); "${v}" == v.to_str() (unaffected).
Codegen
compiler-codegen/src/codegen/emit_c.rs::emit_format_spec_value — user-type
branch: lookup (arg_type, "display_fmt") в all_methods (тот же реестр,
что и @display/@debug-lookup рядом); при hit — constructs FmtCtx inline
(Nova_FmtCtx_static_new(sink, alt, has_prec, prec)) и зовёт
Nova_<T>_method_display_fmt(v, fmtctx); при miss — байт-в-байт прежний
@display/@debug путь. ABI-эрейзинг Fmt→Nova_FmtCtx* — три
hardcode-точки, зеркалящие существующие Write→Nova_StringBuilder*
(resolved_named_to_c, debt_lowered_is_stub, extract_protocol_type_name
E7201-exclusion).
Прямо-в-sink для примитивов (Q-блок п.4) — НЕ в этом амендменте
Q-блок поднимал отдельно движок интерполяции «прямо-в-sink» (${x} для
ЛЮБОГО типа лоуэрится в @display/@debug без промежуточной str,
str.from перестаёт быть движком интерполяции). D419 НЕ трогает эту часть —
value-record-путь (${d}/${d:?} для value-records) уже пишет напрямую в
sink (Plan 175 Ф.3(d)); примитивы/fallback-путь (nova_int_to_str и т.п.
внутри emit_interpolated_str) остаются как есть. Остаток —
[M-152.7.2-interp-direct-primitives].
Cross-refs
- D374
—
Writesink protocol (Fmt расширяет структурно, тот же erasure-приём). - D229 — Debug, sibling, НЕ затронут этим амендментом.
- D258/152.7-B (
spec/decisions/03-syntax.md) — rich format-spec grammar (parser,FormatSpecParsed), source ofalternate/precisionvalues. - [M-91.14-format-dsl-extensions] — CLOSED by this decision (протухший followup обновлён; реальный остаток формализован здесь).
- Q-format-spec-to-display (
spec/open-questions.ru.md) — RESOLVED → D419. - Plan 152.7.2 (докладной подплан 152.7 — интерполяция и форматирование; this D-block’s home plan).
D422. Unified Formatter — единый @display(mut f Fmt) + @debug, байтовый Write, zero-alloc pad (Plan 208, 2026-07-15)
Keystone. Решение владельца 2026-07-15 (все развилки закрыты, полная карта —
docs/plans/208-unified-formatter.md). Сворачивает
D419’s двух-методную схему (@display/@display_fmt) в ОДИН required-примитив на тип, унифицирует
Write с байтовым io.Write (Plan 176), убирает C-поверхность форматирования до одного
float-extern.
Мотивация
Одна поверхность форматирования вместо дублей: один метод @display(mut f Fmt) (не пара
@display+@display_fmt, как в D419); Write пишет []u8, не str — фундаментальнее,
унифицирует с io.Write; буфер-примитивы (int/bool/char/радикс/pad) переезжают в .nv,
C остаётся ТОЛЬКО на float-body (dtoa/Ryu-класс непортируем). Максимизирует nv-sourcing (§3
compiler-conventions).
Правило
1. Write — байтовый sink, минимальный, инфаллибельный
export type Write protocol {
mut @write(bytes []u8) -> () // параметры ro по умолчанию
}
Единственный метод. Никакого @write(str)-overload’а: str-значение (литерал w.write("...") и переменная
w.write(s)) → коэрсия str в ro []u8 по #coerce (D429; явный
.bytes(), D176, допустим, но избыточен) (амендмент D55,
§2 ниже). @reserve/@advance НЕ в протоколе (были рассмотрены и отклонены — сырой
*mut u8 не течёт в общий протокол, io-sink не может дать указатель на будущие байты) — эти
два метода остаются конкретными методами StringBuilder (см. §5); компилятор, зная что
sink конкретно StringBuilder, использует их напрямую для zero-copy top-level рендера;
generic-@display/@debug (sink видим только как Fmt/Write) рендерят примитив в
стек-буфер + один @write(slice) → zero-alloc (без кучи), без copy на sink-стороне, как
fmt::Write у Rust.
io-Write (Plan 176) — ОТДЕЛЬНЫЙ протокол-РОДСТВЕННИК: тот же байтовый шейп @write([]u8),
но фаллибельная сигнатура -> Result[(), IoError] (std=Result, не Fail-эффект, конвенция
D325). Направления Read/Write НЕ сливаются. Мост fmt→io: собрать в
StringBuilder (инфаллибельно) → file.write(sb.bytes())?.
2. Fmt — sink + оси спека, embeds Write через use (D145 protocol-embed)
export type Fmt protocol {
use Write // компонует @write([]u8) из Write (D145 protocol composition)
@width() -> Option[int]
@precision() -> Option[int]
@align() -> Option[Align]
@fill() -> char
@sign() -> Sign
@alternate() -> bool
@kind() -> FmtKind
mut @pad(bytes []u8) -> () // тип-управляемый паддинг → ставит pad_consumed
}
use Write — та же протокольная композиция, что D145
§«Protocol composition» (use A, B внутри protocol {}, парсер parse_protocol_body,
type-check flatten): Fmt получает @write([]u8) из Write в свой method-set, а не
переопределяет его отдельным именем (@write_str, как в до-D422 D419-версии) — любой Fmt
структурно satisfies и Write.
FmtCtx — конкретный реализатор, компилятор строит его на каждом ${x:SPEC}:
export type FmtCtx {
sink Write // главный StringBuilder (или под-регион при pad_in_place)
mark int // старт тела в sink (для pad_in_place)
spec FormatSpec // width/align/fill/sign/alternate/precision/kind
mut pad_consumed bool
mut prec_consumed bool
}
3. Display/Debug — REQUIRED-примитив, без to_str-дефолта, без цикла
export type Display protocol { @display(mut f Fmt) -> () } // REQUIRED — нет дефолта
export type Debug protocol { @debug(mut f Fmt) -> () } // REQUIRED
Инвариант «нет циклической ловушки»:
@display/@debug— required-примитив; дефолта, который бы звал@to_str, НЕТ (это и была ловушка D419-эпохи: to_str-дефолт мог рекурсивно звать себя).@to_str— бланкет bare-T (fn[T] T @to_str() -> str) — вызывается ТОЛЬКО на типе, уже реализующемDisplay(уже имеющем реальный@display) → зовёт настоящий примитив, не себя. Цикл структурно невозможен.- Auto-derive структурных типов (record/sum/tuple): компилятор синтезирует реальный
@display/@debugпо требованию → структурный тип Display-способен без ручного impl. - Тип без
@displayи не-деривируемый (опак без полей) → не Display →${x}= compile-error «type X не реализует Display», не бесконечная рекурсия.
АМЕНДМЕНТ 2026-08-17 (аудит самосогласованности, раздел 4, пункт 16). Здесь стояло «бланкет bounded
Display(fn[T Display] T @to_str())», что расходилось с амендментом D410 и с реализацией. Правда — bare-T:std/src/runtime/string/core.nv:293объявляетexport fn[T] T @to_str() -> str => "${@}"без всякого bound’а, а комментарииstd/src/runtime/char.nv:26иchar_test.nv:9прямо говорят, что методы резолвятся ТОЛЬКО через bare-T blanket. Разница не косметическая: bounded-версия обещает, что@to_strдоступен лишь типам, реализующимDisplay, — читатель на этом строит вывод «значит есть проверка», которой нет.
4. Derived Display vs Debug — различаются формой
Auto-derive синтезирует ОБА метода, но с разной формой (владелец 2026-07-15, намеренный отход
от Rust — Rust деривит только Debug):
- derived
@debug(${x:?}) — техдамп С ИМЕНАМИ полей:Point { x: 1, y: 2 }; sum:Some(5)/Err(IoError { kind: NotFound, ... }); pretty (:#?) — многострочно черезf.alternate(). - derived
@display(${x}) — компактная «значенческая» форма БЕЗ имён полей:Point(1, 2); sum:Some(5)(payload как значение). - Примитивы: Display и Debug совпадают (
42,true) — различие только для структурных типов. Кастомный@displayперекрывает derived-форму.
5. Буфер-примитивы — внутренние (.nv, не публичные), zero-alloc
Канон-сигнатуры ниже — value-first + type-first имена + БЕЗ суффикса _into
(§10R-Д1-Д3,
владелец 2026-07-21, реализовано Ф.4R Ш4 — заменяет доредизайновый набросок этой подсекции):
export unsafe fn int_fmt(v int, buf *mut u8, cap int, spec FmtSpec = FmtSpec.new()) -> int requires cap >= 0
export unsafe fn bool_fmt(v bool, buf *mut u8, cap int) -> int
export unsafe fn char_fmt(v char, buf *mut u8, cap int) -> int // UTF-8 encode
// float — ЕДИНСТВЕННЫЙ C-extern (dtoa непортируем); "простой рендер" = дефолт-арги, НЕ отдельный
// `_into`-мост (Ф.4R Д3):
extern "C" fn nova_f64_fmt(v f64, buf *mut u8, cap int, kind int, prec int) -> int
export unsafe fn f64_fmt(v f64, buf *mut u8, cap int, kind FloatKind = FloatKind.Shortest, prec int = -1) -> int requires cap >= 0
export unsafe fn f32_fmt(v f32, buf *mut u8, cap int) -> int requires cap >= 0
extern "C" fn + литеральное имя (без nova_-префикса) — по
D282
(extern "nova" fn добавляет nova_fn_-prefix; extern "C" fn — нет, литеральный C-symbol).
FloatKind пересекает C-ABI как int (0=Shortest/1=Fixed/2=Sci), .nv-wrapper конвертит
enum→int на границе.
6. Энумы (D406 enum-маркер)
type Align enum Left | Right | Center
type Sign enum Minus | Plus
type FmtKind enum Display | Debug | Hex | Oct | Bin | Exp
type FloatKind enum Shortest | Fixed | Sci // для f64_fmt (C-ABI int, не пересекает границу как enum)
Все — enum-маркер синтаксис (D406), не
leading-|.
7. StringBuilder — аменд API (см. D179)
fn StringBuilder mut @reserve(n int) -> *mut u8
fn StringBuilder mut @advance(n int) -> ()
fn StringBuilder @len() -> int
fn StringBuilder mut @pad_in_place(mark int, width int, fill char, align Align) -> ()
fn StringBuilder mut @write_padded(bytes []u8, width int, fill char, align Align) -> ()
fn StringBuilder consume @into_str() -> str // assume-valid UTF-8
fn StringBuilder consume @into_str_checked() -> Result[str, Utf8Error]
@reserve/@advance — компилятор-приватный zero-copy путь (§1); @pad_in_place — width-
композит без известной длины заранее (streaming-композит: record/tuple/Vec/sum), memmove
тела + fill-вставка (right/center), left — без сдвига; @write_padded — известная-длина
примитивы (int/float/bool/char/str), рендер в стек-буфер/расчёт, потом padded-запись без
сдвига. pad_consumed: если тип сам вызвал f.pad(...) внутри своего @display/@debug,
компилятор внешний pad НЕ навешивает (зеркало прежнего precision_consumed).
Статус реализации
Дизайн финализирован 2026-07-15 (все развилки закрыты — Ф.0-4 карта исполнения, гейты, риски — см. docs/plans/208-unified-formatter.md §9-§11).
| Фаза | Что | Статус |
|---|---|---|
| Ф.0 | Эта спека (D422 keystone + amend-пометки D419/D374/D237/D229/D179 + D55-аменд) | ✅ 2026-07-15 |
| Ф.1 | Буфер-примитивы .nv (аддитивно, рядом с conv.h); fmt_f64_into C-extern; StringBuilder-аменд | ✅ 2026-07-15 (std/src/runtime/fmt_buf.nv, string_builder.nv — merge b6ee6f40a) |
| Ф.2 | Когерентная волна: Write/Fmt/FmtCtx/энумы в std; компилятор — переписка emit_interpolated_str/emit_format_spec_value на @display(f)/@debug(f); удаление @display_fmt-пути; ретракт str.from_debug | ✅ 2026-07-16 (ветка p208-impl) — с тремя V1-упрощениями, см. подсекцию ниже |
| Ф.3 | Дженерики .nv ([]T/Vec[T]/Option/Result Display/Debug) + auto-derive record/sum/tuple (компактная TypeName(a, b) форма Display, отличная от именованной Debug-формы) | ✅ 2026-07-16 (ветка p208-impl, волна 2) — см. docs/plans/wip/208-impl-progress.md §“Ф.3 — генерики .nv + auto-derive” |
| Ф.4 | Зачистка: оставшийся conv.h → .nv; удаление мёртвого nova_fmt_* | ⏳ pending — заблокирована (разведка волны 2 подтвердила и УГЛУБИЛА блокер Ф.2’s V1-упрощения #1, см. ниже): примитивный форматный путь (bare + rich-spec, emit_interpolated_str/emit_format_spec_value) сознательно НЕ перевязан на буфер-примитивы Ф.1 — conv.h’s nova_fmt_*/nova_*_to_str/nova_*_to_debug_str остаются ЖИВЫМИ (не мёртвыми), так что «удалить мёртвый nova_fmt_*» пока буквально нечего удалять. Волна 2 нашла ДОПОЛНИТЕЛЬНЫЙ блокер: буфер-примитивы Ф.1 не имеют quote/escape-логики для Debug str/char (нужна с нуля) — см. wip/208-impl-progress.md §“Ф.4 — статус: РАЗВЕДКА” |
| Ф.4R | Редизайн зачистки (owner 2026-07-20) + §10R-Д1-Д3 нормы-дополнения (owner 2026-07-21): value-first порядок аргументов везде (вкл. extern-границу), type-first имена (fmt_f64→f64_fmt), суффикс _into упразднён (int_fmt_into/f64_fmt_shortest_into/f32_fmt_shortest_into мосты retired — “простой рендер” = та же функция с default-аргами: int_fmt(v,buf,cap,spec=FmtSpec.new()), f64_fmt(v,buf,cap,kind=FloatKind.Shortest,prec=-1), f32_fmt(v,buf,cap); C-extern’ы → nova_f64_fmt/nova_f32_fmt, D282 литеральные-но-nova_-префиксные имена) | ✅ Ш0-Ш1/Ш3-Ш4 DONE, §10R-Д1-Д3 done (§5 code-примеры выше в ЭТОМ разделе переписаны на канон Ш4; норма семьи — docs/plans/208-unified-formatter.md §10R-Д, source of truth); Ш4 (снос conv.h nova_fmt_*/nova_*_to_str/nova_*_to_debug_str + kill-switch NOVA_FMT_LEGACY + str/char/bool rich-spec и int/float Debug rich переведены на *_display_spec) — ЗАКРЫТА: для ВСЕХ шести примитивных видов (int/f64/f32/char/bool/str), И bare, И rich-spec, И Display, И Debug — единственный источник рендер-семантики теперь std/src/runtime/{fmt_buf,string_builder}.nv (*_display_spec-семейство); conv.h остаток = nova_fmt_pad+nova_fmt_encode_fill+nova_fmt_char_count (ТОЛЬКО композитный/user-type rich-spec pad — нет *_display_spec-аналога для произвольных типов) и nova_ptr_to_debug_str (pointer ${p:?}, нет .nv-порта) — оба живые, не мёртвые. V1-упрощение #3 (см. подсекцию ниже) закрыто В ЧАСТИ «рендер хардкожен параллельно в Rust-эмиттере, int_fmt мёртв» (обе посылки теперь ложны). Ш2 (2026-07-21, worktree nova-sh2, ветка p208-sh2-bodies, sonnet) — ЗАКРЫТА, закрывает V1-упрощение #3 ПОЛНОСТЬЮ: блокер [M-fmt-write-protocol-collision-cycle-adjacent] снят (фикс влит в main, compiler-codegen/src/types/mod.rs); примитивные @display/@debug-тела (int/f64/f32/bool/char/str-@debug) в prelude/protocols.nv переписаны с циркулярной заглушки (f.write("${@}".bytes())) на прямые вызовы *_display_spec-семейства (runtime.string_builder) — физически тела ОСТАЛИСЬ в protocols.nv (не переехали в fmt_buf.nv, как исходно намечено картой — тот перенос потребовал бы protocols.nv ↔ fmt_buf.nv цикл; вместо этого — однонаправленный импорт protocols.nv → string_builder.nv, string_builder.nv не импортирует prelude.protocols обратно, поэтому нового цикла НЕТ), но теперь f.kind()/f.width() больше не остаются непрочитанными в теле «просто потому что» — тело зовёт РЕАЛЬНЫЙ рендер-движок (*_display_spec, тот же, что fast-path Ш3 девиртуализует), не самоссылающуюся интерполяцию. V1-упрощения #1 (композитный/user-type rich-spec не стримит в главный sb, рендерится во FRESH builder + внешний nova_fmt_pad) и #2 (precision для composite дропается) — ВНЕ scope Ш2/Ш4 (описывают ТОЛЬКО composite/user-type путь, который ни одна из этих волн не трогала — §10R предписывает закрыть ТОЛЬКО примитивную семью) — остаются как есть, НЕ регрессия. |
Ф.2 реализована на ветке p208-impl (3 шага: std-сигнатуры, emit_c.rs-диспатч,
миграция потребителей — json.nv + spec_tests/conformance/d374_*/d229_*/бывшие
d419_*→d422_*). Полный разбор по шагам, файлам, коммитам — см.
docs/plans/wip/208-impl-progress.md. Три
намеренных V1-упрощения относительно этого нормативного текста (НЕ противоречат
D422 — заполняют места, где D422 либо молчит, либо описывает алгоритм, для
которого V1 выбрал более простую/менее рискованную реализацию с идентичным
наблюдаемым поведением на всех текущих тестах):
-
Композитный/user-type rich-spec путь НЕ стримит в главный interpolation
sb. §4 описывает “mark в главном sb +@pad_in_placeпосле стрима” как целевой алгоритм для width-композитов. V1 (emit_format_spec_value) вместо этого сохраняет ДО-D422 архитектуру: рендер во FRESHStringBuilder,nova_fmt_padпост-обработка снаружи (тот же C-хелпер, что примитивы) — тип получает настоящийFmtCtx(может читать любую ось), но@pad/pad_consumedНЕ читается обратно компилятором (внешний pad применяется БЕЗУСЛОВНО). Наблюдаемо идентично для любого типа, который сам не зовёт@pad(ни один существующий тип этого не делает) — но алгоритм §4 в буквальном смысле не реализован. Полная mark+pad_in_place-перевязка — явный follow-up. Статус (Ф.4R Ш4, 2026-07-21): ВНЕ scope этой волны — §10R предписывает закрыть ТОЛЬКО примитивную семью (int/f64/f32/char/bool/str); composite/user-type rich-spec путь Ш4 намеренно не трогала, стоит как было. -
Precision auto-truncate для composite/user-типов — ДРОПНУТ, не “пропущен через
prec_consumed”. D422 §2 даётFmtтолько@precision() -> Option[int](иммутабельный getter) — нет протокольного способа для типа сигнализировать «я учёл precision», в отличие отpad_consumed/mut @pad. V1 решение (owner-approved 2026-07-16): для composite-путиprecision_consumedдержится БЕЗУСЛОВНОtrue(внешняя обрезка никогда не применяется) — Rust-паритет (Debug/derive не обрезаются по precision), не молчаливое навязывание типу поведения, которое он не запрашивал. Это меняет один D419-эры assert (был:${p:.3}обрезает извне; стало: не обрезает) — задокументировано как легитимная миграция ретрактированной семантики, не ослабление теста (spec_tests/conformance/d422_unified_display_dispatch.nv). Статус (Ф.4R Ш4, 2026-07-21): ВНЕ scope этой волны — тот же composite-путь, что #1, не тронут. -
Примитивные
@display/@debug(int/f64/f32/bool/char/str) не читаютf.kind()/f.width(). Тела —f.write("${@}".bytes())(интерп-стринг шорткат, byte-identical существующемуconv.h-пути). Верно ТОЛЬКО потому, что компилятор НИКОГДА не диспетчит эти методы напрямую для примитива ни в голой, ни в rich-spec интерполяции (свой прямойconv.h-fast-path остаётся для ОБОИХ случаев, byte-parity подтверждён) — единственный реальный вызыватель этих тел — generic-диспетч (Option[T Debug]/Result[...], будущий Ф.3Vec[T]), где radix/width на элементах пока не тестируется. Полная перевязка наint_fmt/bool_fmt/char_fmt(Ф.1) — Ф.4 (и часть того, что делает Ф.4 сейчас “нечего удалять” — см. таблицу выше). Волна 2 (2026-07-16) находка, углубляющая этот блокер: буфер-примитивы Ф.1 (fmt_buf.nv) реализуют ТОЛЬКО display-формуint/bool/char— НЕТ quote/escape-логики для Debugstr/char(nova_char_to_debug_str/nova_str_to_debug_strвconv.hдают'c'/"a\nb"с escaping;fmt_buf.nvничего эквивалентного не содержит). Полная Ф.4-перевязка требует ЭТУ логику написать с нуля (не просто “подключить существующее”) — см.docs/plans/wip/208-impl-progress.md§“Ф.4 — статус: РАЗВЕДКА” (волна 2) для полного разбора (там же — вторая находка:int_fmt/bool_fmt/char_fmtmodule-private по D422 §5 → единственный корректный способ их звать из hand-synth C — через method-dispatch на переписанных примитивных@display(f)/@debug(f)телах, не прямой C-вызов; это делает “перевязку” ОДНОЙ когерентной big-bang волной, не серией мелких безопасных шагов). Статус (Ф.4R Ш4, 2026-07-21): ЧАСТИЧНО ЗАКРЫТО (промежуточная запись, ниже — финал Ш2). Обе посылки этого пункта, КАК ДИАГНОСТИРОВАНО (компилятор хардкодит рендер параллельно.nv-движку;int_fmtмёртв), теперь ЛОЖНЫ: interp fast-path (bare И rich-spec, ОБА kind) девиртуализованно зовёт*_display_spec(std/src/runtime/string_builder.nv), который зовётint_fmt/f64_fmt/f32_fmt/bool_fmt/char_fmt/str_debug_fmt/char_debug_fmt(fmt_buf.nv) — тот же движок, что использовал бы настоящий@display-body.conv.h’snova_fmt_*/nova_*_to_str/nova_*_to_debug_strцепочка СНЕСЕНА (Ф.4R Ш4). Но САМИ примитивные@display/@debug-ТЕЛА (вprelude/protocols.nv) на тот момент оставались циркулярной заглушкой (f.write("${@}".bytes())) — компилятор их по-прежнему НЕ зовёт напрямую (обходит девиртуализацией), значит “примитивы не читаютf.kind()/f.width()В СВОИХ ТЕЛАХ” оставалось буквально верным до Ш2, заблокированного[M-fmt-write-protocol-collision- cycle-adjacent].Статус (Ф.4R Ш2, 2026-07-21, worktree
nova-sh2, веткаp208-sh2-bodies, sonnet): ПОЛНОСТЬЮ ЗАКРЫТО. Блокер снят —[M-fmt-write-protocol- collision-cycle-adjacent](Write-протокол name-коллизия по голому имени вTypeCheckCtx::build,compiler-codegen/src/types/mod.rs) исправлен и влит в main отдельной волной (per-file type overlay +types_get_for_file), равно как соседний[M-imports-order-dependent- cycle]. Примитивные@display/@debug-тела (int/f64/f32/bool/char/ str’s@debug; str’s@displayуже была некруговой identity-копией, не тронута) вprelude/protocols.nvпереписаны на прямые вызовы*_display_spec-семейства:#impl(Display) fn int @display(mut f Fmt) -> () { consume sb = StringBuilder.new() int_display_spec(sb, @, 0, 10, false, false, false, false, Align.Left, ' ') f.write(sb.into_str().bytes()) }(аналогично для f64/f32/bool/char displaying/debug-вариантов, зовущих
f64_display_spec/f32_display_spec/bool_display_spec/char_display_spec/char_debug_display_spec/str_debug_display_specсоответственно — bare-параметрыwidth:0, align:Left, fill:' '— no-op паддинг-оси, byte-identical старому bare-поведению).Тела физически ОСТАЛИСЬ в
protocols.nv(НЕ переехали вfmt_buf.nv, как исходно намечено §10R-картой — тот перенос потребовал бы завести циклprotocols.nv ↔ fmt_buf.nvзаново поверх уже разрешённого; вместо этого — ОДНОНАПРАВЛЕННЫЙ импортstd.runtime.string_builder.{...}вprotocols.nv, где*_display_spec-семейство физически живёт с Ш1 architecture-v2 —string_builder.nv#no_prelude, НЕ импортируетprelude.protocolsобратно, значит нового цикла нет; тот же однонаправленный shape, что уже существующийfmt_buf-импорт строкой выше в том же файле). Каждое тело рендерит в свежийconsume sb = StringBuilder.new()(т.к.*_display_specтребует КОНКРЕТНЫЙStringBuilder, а@display(mut f Fmt)получает АБСТРАКТНЫЙFmt), затем ОДИНf.write(sb.into_str().bytes())копирует итог в реальный sink — не zero-alloc (в отличие от компиляторного fast-path, который пишет прямо в целевойStringBuilderбез промежуточного), но единственный источник рендер-семантики: полиморфный путь (v.display(f)/v.debug(f)через абстрактныйFmt—Option[T Debug]/Result[T Debug, E Debug]и genericVec[T]/[]TDisplay) теперь зовёт ТОТ ЖЕ*_display_spec-движок, что компиляторный fast-path девиртуализует, а не отдельную циркулярную дорожку. V1-упрощение #3 (эта секция целиком) ЗАКРЫТО ПОЛНОСТЬЮ — обе половины («рендер хардкожен параллельно» И «примитивные тела — циркулярная заглушка») теперь ложны. zero-alloc для ЭТОГО (generic-диспетч) пути — задокументированный follow-up вне scope Ш2 (примитив тут рендерится редко, только через абстрактныйFmt; горячий top-level путь остаётся zero-copy через Ш3 fast-path, не задет).Правка 2026-09-18 (согласована с интегратором): выше стояло «future generic
Vec[T]/[]TDisplay». Слово снято: телоVec[T Display] @display(mut f Fmt)живёт вstd/src/collections/vec/protocols.nv:192и покрыто тестом (печатает «PASS: Vec[T] Display: nested»); док-пример там же утверждает"Vec[1, 2, 3]".[]Tотдельного тела не требует — это алиасVec[T].Почему записано, а не просто исправлено: в тот же день нашлось зеркальное — обзорные
syntax.*отставали от этого же D422, а здесь сам D422 отстал от дерева. Источник, оставленный неправленным, учит неверному в обе стороны, и «not yet» в нормативном тексте опаснее устаревшего описания: по нему планируют работу, которая уже сделана.Верификация:
nova check(checker-only — обходит НЕСВЯЗАННЫЙ pre-existing[M-d216-write-at-return-type-unknown-cc-panic], который ловит ЛЮБОЙ full-pipeline прогон папкиspec_tests/conformance) на трёх эталонах Ш0 (d422_f4r_baseline_{int,float,strcharboolu64}.nv) — PASS 3/0 (216 WARN, все предсуществующие unused-import/W_FFI_CANCEL_UNSAFE); изолированный standalone-репро (nova build+run вне conformance-папки, обходит write_at ICE полностью) — bare Display/Debug на ВСЕХ шести примитивах PASS, ПЛЮС полиморфный путь черезOption[T Debug]/Option[T Display]/Result[T Debug, E Debug](единственный реальный вызыватель перенесённых тел до будущего generic-Display наVec/[]T) — PASS;fmt_buf/core_test1/0,string_builder_test1/0,checksums3/0 (fnv/adler32/crc32);d374_write_sink_decouple(checker) PASS 1/0; флагманexamples/flagship/aggregator/src/main.nv --strict-effectsbuilt чисто (70.34s, только предсущ. warnings). Полный мега-CU НЕ гонялся — заблокирован тем же НЕСВЯЗАННЫМ write_at ICE, за интегратором (docs/plans/backlog-followups.md).
Координация с Plan 152.7.2 (docs/plans/152.7.2-format-context.md, отдельный
план, СВОЁ решение по статусу — не закрыт этим коммитом): его “interp-direct-
to-sink” наработка ([M-152.7.2]/[M-d419-interp-direct-primitives]) частично
реализована этой волной — bare-диспетч (emit_interpolated_str) действительно
пишет user-type @display/@debug ПРЯМО в главный interpolation sink (без
промежуточной строки); rich-spec композитный путь — ещё нет (V1-упрощение #1
выше). Маркер НЕ закрывается этим коммитом (only marking per coordination
note, 2026-07-16) — итоговое решение по статусу 152.7.2 (obsolete/superseded
vs remaining-tail) — за владельцем/интегратором.
Почему
- Один метод, не два — Rust-прецедент
Display::fmt(&self, f: &mut Formatter); D419’s@display+@display_fmtпара выглядела «некрасиво» (претензия владельца) и держала to_str-дефолт (ловушка цикла). - Байтовый
Write— фундаментальнее строкового; унифицирует сio.Write(Plan 176) без слияния самих типов направлений. use Writeprotocol-embed, не переименованный метод — сохраняет «FmtповерхWrite» буквально (тот же@write), убирая D419’s дублирующее «структурное расширение без embed».- required-примитив + bounded-
to_str— устраняет структурную возможность цикла раз и навсегда (не полагается на runtime cycle-guard, как в D419-эпохеstr.from_debug/default-body synthesis). - Derived Display ≠ Debug формой — иначе
${x}и${x:?}дают тождественный вывод, теряя смысл различия («значение» vs «для разработчика»).
Связь
- D419 —
RETRACT/SUPERSEDED целиком (двух-методная схема + узкая
Fmtс двумя осями). - D374 —
амендится ×2 (sink
Write→Fmt,@write_str(str)→@write([]u8)). - D237 —
амендится (сигнатура
@display/@debug→(mut f Fmt), имена протоколов без изменений). - D229 — амендится (диспетч
${expr:?}через@debug(f), радикс черезf.kind()). - D179 — амендится
(байтовый append +
@reserve/@spare/@advance/@len/@pad_in_place/@write_padded; 2026-07-18/20:@reserve -> @(fluent-канон D131; УКАЗАТЕЛЬ больше не возвращает), сырой хвост — отдельная дверь@spare() -> *mut u8;@advance -> @). - D55 —
амендится (
str-значение→ro []u8коэрсия по паре#coerceD429, общее правило для любой[]u8-позиции; с амендмента 2026-10-01, №1480 — для любой позиции только-для-чтения). - D145 — источник protocol-embed
механизма (
use WriteвнутриFmt), без изменений. - D406 —
enum-маркер дляAlign/Sign/FmtKind/FloatKind. - D282 —
extern "C" fn nova_f64_fmt/nova_f32_fmtлитеральные имена (§10R-Д3 канон;fmt_f64_into— доредизайновое имя, переименовано владельцем). - D176 —
.bytes()на str-переменной =ro []u8zero-copy view. - str.from_debug/str.from ретракция (Plan 174.2, D73)
—
str.from_debug(@)в Debug-протокола default-body (std/prelude/protocols.nv) остаётся мёртвым/нереализованным символом ДО D422 (174.2 явно оставил его вне scope — см.docs/plans/wip/174.2-scalar-to-str-notes.md); D422 Ф.2 удаляет этот default-body ПОЛНОСТЬЮ вместе с переходом на compiler-synthesized@debug(f)—str.from_debugокончательно устраняется, не остаётся даже мёртвым текстом. - Plan 208 (докладной план — docs/plans/208-unified-formatter.md, this D-block’s home plan).
- Plan 176 (
io.Write— координация байтового шейпа, направления раздельны). - Plan 196 — НЕ пересекается (замороженная зона
infer_call_ret_cвне scope форматирования).
D429. #coerce — декларативные неявные zero-cost конверсии (view + finalize) (Plan 214, 2026-07-18) {#d429}
Уточнено D488 (2026-10-01): размещение и передача значения — там единственный дом правила (место решает убегание, передачу
ro— размер, порог три слова;mut— указатель). Где этот блок расходится с D488, действует D488.
Контекст. Nova прячет только безопасные, очевидные, zero-cost конверсии (доктрина D55:
single-wrapper вкл. переменные, int→i64 widening «невидимо для автора» — снято 2026-09-10 (решение владельца): int ≠ i64, uint ≠ u64, неявного числового расширения нет (D405 амендмент 2026-09-04, E_MIXED_WIDTH_ARITH; для всех мест — D491, 2026-10-02); R11 ниже говорит о newtype-wrap и sum-lift, не о числах). Пары вида
str → []u8 (вид) и StringBuilder → str (финализация) в эту доктрину входят по стоимости,
но не имели механизма: реализованный компромисс (str-литерал в методе, названном write) был
name-keyed хардкодом — нарушение
compiler-conventions §3. D429 вводит ОБЩИЙ
декларативный механизм: пара объявляется атрибутом в .nv-исходнике; в Rust-компиляторе нет
ни одной захардкоженной пары.
Форма
Один атрибут #coerce на функции. Функция обязана быть унарной по входному типу и
объявляет неявную коэрсию I → O:
// V1: форма-ИСТОЧНИК — метод без параметров; I = тип-приёмник, O = возврат
#coerce fn str @bytes() -> ro []u8 => ... // str → ro []u8 (view)
#coerce fn StringBuilder consume @into_str() -> str { .. } // StringBuilder → str (finalize)
Форма-ПРИЁМНИК (статик .new с одним параметром: #coerce fn Matrix.new(v []f64) -> ro Self) — ОТЛОЖЕНА из V1 (решение владельца 2026-07-18, ревью-2 п.19): у неё ноль
носителей среди первых деклараций, а R2-проверка формы (ro Self) НЕ гарантирует
реальную zero-cost (конструктор может клонировать внутри — форм-инвариант без
стоимость-инварианта). Возврат — отдельным амендментом при первом реальном носителе
ВМЕСТЕ с правилом проверяемости (кандидат: ограничение newtype/однополевой обёрткой,
которую компилятор верифицирует). В V1 приёмник-форма — явная ошибка (см. R1), не тихое
игнорирование.
Правила (нормативные)
-
R1 Унарность; V1 — только форма-источник. Метод-без-параметров на конкретном типе; иначе
E_COERCE_NOT_UNARY. Статик-с-одним-параметром (форма-приёмник) в V1 —E_COERCE_RECEIVER_FORM_DEFERREDс подсказкой «объявите пару методом на типе-источнике» (см. §Форма — отложена решением владельца 2026-07-18). -
R2 Zero-cost, две полосы. Неявная вставка разрешена только для форм без скрытой аллокации:
- view — не-
consumeформа сro-возвратом (zero-cost вид); - finalize —
consume-метод с владеющим возвратом (zero-cost MOVE: передача владения, не копия). Ресивер разряжается в точке вставки; линейность (D133) отслеживается как при явном вызове — use-after = немедленный compile error (см. R7). Решение владельца 2026-07-17: позиции finalize НЕ сужаются (return-only-срез отвергнут — рантайм-опасности нет ни в одной ветке, нецелевое использование громко ловится компиляцией).
Форма вне полос (не-
consumeс владеющим возвратом = скрытая аллокация) —E_COERCE_NOT_ZERO_COST. Атрибут «без эффекта» не существует — либо работает, либо ошибка. - view — не-
-
R3 Одна декларация на пару. Не более одного
#coerceна пару(I, O)во всей программе (обе формы записи считаются; дубль с двух сторон — тоже дубль):E_COERCE_DUPLICATE_PAIRс указанием обеих деклараций.R3’ (Plan 214.1, «один образец на пару-форму»): для GENERIC-образцов у пары нет decl-time идентичности — конкретная пара кристаллизуется только когда
?Tсвязан на конкретном сайте вызова, поэтому R3-дедуп поseen_pairs(decl-time) на образцы не переносится буквально. Вместо этого: если ≥2 образца (при промахе конкретных пар) унифицируются с ОДНОЙ и той же конкретной парой в данной позиции —E_COERCE_DUPLICATE_PAIRВ ТОЧКЕ ПРИМЕНЕНИЯ, перечисляя обе декларации. Смотри «Generic-образцы» ниже за деталями матчера. -
R4 Один уровень; механизмы НЕ компонуются (расширено ревью-5 2026-07-18). Цепочки не разворачиваются:
I→OиO→Pобъявлены —IвP-позиции НЕ коэрсится (паритет single-wrapper «ровно один уровень»). Это включает КРОСС-механизменную композицию: позиция ждётW(newtype над[]u8), значениеstr— путьstr →(#coerce)→ []u8 →(single-wrapper)→ WЗАПРЕЩЁН. Один шаг неявности ВСЕГО на позицию, каким бы механизмом он ни делался — иначе «один уровень» обходится смешиванием, а поиск становится транзитивным (непредсказуемость + перф). -
R5 Exact > coercion; однозначность. Перегрузка/позиция с точным типом всегда побеждает; коэрсия пробуется только когда позиция ждёт
Oи точного совпадения нет. Если к позиции применимы ≥2 пары —E_COERCE_AMBIGUOUS(перечислить кандидатов), никакого tie-break.R5’ (Plan 214.1, генерализация на образцы): сначала конкретные пары (
coerce_pairs), потом образцы (generic_coerce_patterns) — двухступенчатый lookup, генерик-матчер даже не запускается при попадании в конкретный реестр (горячий str→[]u8-путь не замедляется). ≥2 ПОДХОДЯЩИХ ОБРАЗЦА к ОДНОЙ и той же позиции с РАЗНЫМИ (I,O) (напримерJson[T] -> TиJson[T] -> strпри overload-перегрузкеdump(v User)/dump(v str)) —E_COERCE_AMBIGUOUS. Честная пометка: этот overload-уровневый сценарий разделяет нереализованность с R5 для КОНКРЕТНЫХ пар —E_COERCE_AMBIGUOUSне встречается нигде в текущем коде компилятора (ни для конкретных пар, ни для образцов); механизм, реально реализованный и протестированный Plan 214.1, — это R3’ (≥2 образца унифицируются к ОДНОЙ и той же (I,O)-паре В ОДНОЙ позиции,E_COERCE_DUPLICATE_PAIR, см. R3’). Снятие этого пробела для overload-уровневой R5/R5’-неоднозначности (обеих полос, конкретной и generic) — отдельный, не начатый пункт; см.[M-coerce-r5-ambiguous-overload-unimplemented]. -
R6 Позиции. Таблица «явно ожидаемого типа» D55 (case-таблица выше, БЕЗ исключений): call-arg на резолвленный параметр,
let/const/ro/mutс явной аннотацией типа, return-позиция (вкл. last-expr), element-позиция коллекции.ro Oне матчитmut/consume-позицию (там — явный жест:.bytes().clone()и т.п.).Амендмент 2026-10-01 (решение владельца, №1480, вариант (а)). Для VIEW-полосы (R2) «
mut/consume-позиция» расширена до любой СОБСТВЕННОЙ позиции: возврат, элемент коллекции и поле записи, чей типO, а неro O, держат собственное значение, котороеmut-связь результата может изменить, — там вид не матчит (E_READONLY_COERCE, явный жест.bytes().clone()). Вид идёт в позицию типаro O(-> ro O,[]ro O,f ro O) и в позицию, только-для-чтения по режиму (параметр по умолчанию,ro-связь с аннотациейO). Finalize-полоса не затронута: её результат — собственное значение. Подробно — D55 «Str →ro []u8coercion», амендмент №1480.Уточнение (ревью 2026-07-18, Plan 214 pre-Ф.1 ревью): «без исключений» означает — набор позиций #coerce НЕ ýже перечисленного списка выше (сверх явно оговорённой mut/consume-оговорки), НЕ обещание достать позиции, которых сама D55-таблица ещё не достигла. Перечисленный список (call-arg /
let-const-ro-mut/ return / element) — это в точности ✅-строки D55-таблицы. Строки D55, помеченные ⛔ (generic-параметр record/sum, match-arm, record-элемент коллекции), и позиция «значение record-литерала» (не имеющая отдельной строки в D55-таблице вовсе, см. case 4) — вне охвата Ф.1: #coerce переписывается ТОЙ ЖЕ машинерией expected-type-propagation (try_wrap_leaf-семья), что и сам D55, и физически не может достать позицию, которую не достаёт D55. Как только D55 доводит ⛔-строку до ✅ (или заводит строку для record-литерал-поля), #coerce наследует её автоматически тем же проходом — отдельного D429-амендмента не требуется. -
R7 Вставка = именованный вызов + диагностика-контракт. Компилятор АСТ-переписывает значение в вызов объявленной функции (
s→s.bytes()); неявная форма байт-в-байт эквивалентна явной, нового лоуэринга нет. Для finalize-полосы диагностика use-after-consume ОБЯЗАНА указывать точку неявной вставки: «потреблён неявной #coerce-финализациейinto_str()в вызове … (строка N); для чтения без потребления — явный view-метод». -
R8 Охват — все типы, включая пользовательские, с первого дня (решение владельца 2026-07-18: без std-only-гейта). Orphan-развязка — extension-форма: автор
MyIdобъявляет#coerce fn str @to_myid() -> ro MyIdextension’ом в своём модуле. R3 глобально предотвращает конфликт деклараций. Видимость (ревью 2026-07-18): пара активна ТОЛЬКО там, где объявляющая функция ВИДИМА обычными правилами (импорт/prelude/extension-видимость) — вставленный вызов резолвится по построению; невидимая пара = коэрсии нет (обычный type mismatch), никакого action-at-a-distance и нерезолвящихся синтез-вызовов. -
R9 Канон call-сайта (конвенция + линт). В коэрсибельной позиции канон — ГОЛОЕ значение (
w.write(s),return sb), НЕ явный вызов. ЛинтW_COERCE_EXPLICIT_REDUNDANT: явный вызов#coerce-функции там, где голое значение скоэрсилось бы той же парой → «уберите явный вызов — действует #coerce (D429)». Линт читает ТОТ ЖЕ реестр пар, что и вставка (одно окно — расхождение линта и коэрсии невозможно по построению). Не флагаются: позиции без явного ожидаемого типа (ro b = s.bytes()), mut/clone-сайты, пары вне реестра. nv-coding-style амендится этим правилом в слиянии реализации (Plan 214 Ф.5).Уточнение (ревью 2026-07-18, Plan 214 pre-Ф.1 ревью): «тот же реестр пар, что и вставка» — про ОДНО ОКНО данных (не два независимых списка, которые могли бы разойтись), не про то, что голого членства пары в реестре ДОСТАТОЧНО для флага. Формальный критерий — предикат ВСТАВКИ ЦЕЛИКОМ (та же функция, что решает, вставлять ли коэрсию на этом сайте), включая exact/generic-wins (R5/R16): при catch-all
f[T](T)пара(I,O)в реестре ЕСТЬ, но R16 её не вставляет здесь (успешно инстанцируемый generic-кандидат = точное совпадение) — явный.bytes()на таком сайте НЕ избыточен и флагаться не должен. Формула: варнинг ⇔ коэрсия РЕАЛЬНО вставилась бы на этом сайте при голой форме значения. -
R10
asНЕ вызывает#coerce(вопрос владельца 2026-07-18, закреплено; rationale уточнён 2026-07-18 — поправка владельца: «кода не исполняет» было неверно).as— ЗАКРЫТОЕ спекой множество конверсий (численное сужение/расширение D54, newtype-relabel D52, ptr-каст в unsafe); часть пар исполняет вшитый спека-заданный код (насыщениеint as uintneg→0 D54, см. §вышеnova_int_to_uint) — но семантика КАЖДОЙ пары задана таблицей D-блоков и известна читателю из спеки, не из пользовательских деклараций (тот же контракт у Rustas: насыщающий float→int с 1.45 — и всё равно не зовёт From/Into).#coerce— ОТКРЫТЫЙ реестр произвольных пользовательских функций — другой класс; смешение сделало бы семантикуasзависимой от атрибутов в чужих модулях (C++-ловушка conversion-operator у static_cast) и дало бы ТРЕТЬЮ дверь к паре (голое значение / именованный метод / as) — D9. Явная форма пары ровно одна — именованный вызов (s.bytes(),sb.into_str()); finalize черезasнемыслим (каст не потребляет операнд). Диагностика:s as Oпри объявленной паре(I,O)и невалидном repr-касте → обычная cast-ошибка + подсказка «пара #coerce объявлена: голое значение в типизированной позиции или явныйs.bytes()». Форма"lit" as []u8из ретрактированной D55-подсекции — мертворожденная (0 использований в дереве), Ф.2 её не поддерживает; подсказка выше покрывает миграцию. -
R11 Вшитая single-wrapper-неявность первична (решение владельца 2026-07-18). Неявных механизмов ТРИ на одной таблице позиций: вшитый newtype-wrap (
expr as W) и вшитый sum-lift (W.C(expr), ровно один unary-вариант «рода» — §Obvious single-wrapper выше) + декларативный#coerce. Правило первенства: если пара (I, O) УЖЕ покрыта single-wrapper’ом (O — newtype над родом I, ЛИБО sum с ровно одним unary-вариантом I-рода) —#coerce-декларация на эту пару = ошибкаE_COERCE_DUPLICATE_PAIRс указанием покрывающей newtype/sum-декларации (симметрично дублю двух#coerce, R3). Rationale: два механизма на одной паре = недетерминированная для читателя вставка и рассадник диффузных багов; вшитая форма дешевле (no-op repr / прямой конструктор) и первична по старшинству. Ф.4 Plan 214: две neg-фикстуры (newtype-цель, sum-цель). -
R12 Эффект-свобода (ревью 2026-07-18).
#coerce-функция обязана иметь ПУСТОЙ effect-row — неявная вставка эффектного вызова была бы скрытым эффектом в голой позиции, что ломает контракт strict-effects («эффект виден в сигнатуре и на call-сайте»). Нарушение —E_COERCE_EFFECTFUL. Паника эффектом не является и не запрещается (doc-рекомендация: тело желательно total). -
R13 Само-применение исключено (ревью 2026-07-18). Внутри тела
#coerce-функции ЕЁ СОБСТВЕННАЯ пара не применяется:#coerce fn str @bytes() -> ro []u8 => @иначе переписался бы в@.bytes()— бесконечная саморекурсия. С R13 голое@в такой позиции — честный type mismatch. Чужие пары в теле применяются как обычно.R13’ (Plan 214.1, генерализация на образцы): тот же запрет действует внутри тела GENERIC
#coerce-образца —Json[T] @data() -> T => @НЕ переписался бы в@.data()(иначе бесконечная саморекурсия на ЛЮБОМT). Реализовано публикацией span’а текущей#coerce-декларации на время проверки её тела (current_coerce_decl_spanв чекере,current_fn_spanв rewrite-проходе) и исключением образца с тем жеdecl_spanиз кандидатовgeneric_coerce_lookup. Чужие образцы и чужие конкретные пары в теле применяются как обычно — исключение строго self-only. -
R14 (RETRACTED → см. «Generic-образцы» ниже, Plan 214.1, 2026-07-24). Текст ниже — история (было нормативно до 214.1):
Generic-формы запрещены (V1, ревью-5 2026-07-18).Владелец 2026-07-23: «это было упущение, надо исправить сейчас» —#coerceна функции с method-level type-параметрами (fn[T] …) или на generic-инстансной паре —E_COERCE_GENERIC_UNSUPPORTED(явная ошибка, не тихое игнорирование): реестр пар — конкретный lookup, не унификация; параметрические пары взорвали бы R5-однозначность. Все текущие декларации (str/StringBuilder/WriteBuffer) конкретны. Снятие — отдельным амендментом при реальном кейсе.Json[T] @data() -> Tесть ИДЕАЛЬНЫЙ кандидат view-полосы (zero-cost: чтение поля; один уровень; exact-wins сохраняется), запрет упирался только в форму РЕЕСТРА (словарь-lookup по конкретному имени), не в семантику.E_COERCE_GENERIC_UNSUPPORTEDушёл из набора кодов; бланкет-реджект заменён узким матчером образцов — см. «Generic- образцы (Plan 214.1)» ниже. Родственный, НЕ путать: R16 (generic-ОВЕРЛОАДЫ на сайте вызова) остаётся в силе без изменений. -
R15 На protocol-требовании запрещён (ревью-5 2026-07-18).
#coerceвнутриtype P protocol { … }—E_COERCE_ON_PROTOCOL: атрибут на требовании раздавал бы пару КАЖДОМУ имплементору (массовая генерация пар + гарантированные R5-конфликты). Легален только на конкретной fn-декларации: метод или extension-метод (V1; статик.new— вместе с приёмник-формой, см. R1). -
R16 Generic-унификация = точное совпадение (решение владельца 2026-07-18, ревью-2 п.18; НЕ путать с R14 — тот про generic-ДЕКЛАРАЦИИ #coerce, этот про generic-ОВЕРЛОАДЫ на сайте вызова). При overload-резолве успешно инстанцируемый generic-кандидат (
f[T](T)дляf(s)) считается ТОЧНЫМ совпадением в смысле R5 — коэрсия НЕ пробуется. Коэрсия рассматривается только при НУЛЕ применимых кандидатов (конкретных и generic). Следствие (норма, не баг): в generic-насыщенных API (f[T](T)catch-all) пара #coerce в этой позиции не сработает никогда — предсказуемость диспатча дороже эргономики (урок семьи 196.7/196.8: неоднозначность «унификация vs спец-путь» = источник тихих мис-диспатчей). Фикстура Plan 214 Ф.4 пинует: при наличииf[T](T)и объявленной пары str→[]u8 вызовf(s)берёт generic-инстанс, НЕ коэрсию.
Generic-образцы (Plan 214.1, снятие R14, 2026-07-24)
Что снято. R14 (было: любой #coerce на receiver’е с собственными generic’ами или на
функции с method-level type-параметрами — бланкет-E_COERCE_GENERIC_UNSUPPORTED) заменена
узким, но реально исполняемым матчером образцов:
#coerce
export fn Json[T] @data() -> T => @data
Значение Json[User] в позиции, ожидающей User, разворачивается автоматически (в
j.data()), симметрично тому, как str разворачивается в []u8 через s.bytes().
Дизайн — ДВА реестра, не один. Конкретные пары (coerce_pairs, R1-R13/R15) остаются
байт-в-байт как есть — ноль регрессий, ноль замедления горячего str→[]u8-пути. Generic-формы
идут во ВТОРОЙ, отдельный реестр образцов (generic_coerce_patterns, ключ — БАЗОВОЕ имя типа
receiver’а, напр. Json), проверяемый ТОЛЬКО при промахе конкретного lookup’а:
lookup(I, O):
1. coerce_pairs[(I,O)] → hit? вернуть (текущий путь, байт-в-байт)
2. generic_coerce_patterns → унификация образца с (I,O); ноль/один/много (R3'/R5')
Какие формы поддержаны. Пара-переменные — ИСКЛЮЧИТЕЛЬНО собственные carrier-generic’и
receiver’а (Json[T], Pair[K, V]) в ГОЛОМ виде (bare single-segment reference на позиции
generic-аргумента) — ЛЮБОЙ арности. Унификация ОДНОСТОРОННЯЯ и БЕЗ РЕКУРСИИ ВГЛУБЬ: пара
Json[?T] → ?T сопоставляется с конкретным (Json[User], User), связывая ?T := User —
слот receiver’а связывается ЦЕЛИКОМ (Json[Vec[T]] НЕ разбирается на Vec/T отдельно; это
уже нарушило бы форму «bare reference»). Подстановка связанных ?T в ret_shape (не сам
матчинг, а применение уже НАЙДЕННЫХ связок) — рекурсивная, без ограничения глубины: это
чисто механическая замена, а не поиск, так что образец Json[T] -> Vec[T] корректно даёт
Vec[User].
Явно отклоняется (ошибка E_COERCE_GENERIC_PATTERN_UNSUPPORTED, не тихий пропуск —
та же поза «атрибут без эффекта не существует», что и у R2/R14):
- метод-уровневый type-параметр (
fn[U] Type @method()) — ничего в receiver’е не определяетUна сайте НЕЯВНОЙ вставки (нет turbofish на неявной вставке); - generic-аргумент receiver’а, который НЕ является голой carrier-переменной (вложенная форма
Json[[]T], или конкретный аргументJson[int]) — шаблон-матчер связывает только голые верхнеуровневые слоты.
R4 (без изменений, критично для образцов). Один уровень — Json[Json[T]] в позиции,
ожидающей User, НЕ разворачивается дважды: единственный проверяемый образец Json[?T] → ?T при унификации с (Json[Json[User]], User) связывает ?T := Json[User], ключ результата
— Json[User], НЕ равен ожидаемому User → честный type mismatch (нет цепного повторного
поиска — lookup вызывается РОВНО один раз на позицию, как и для конкретных пар).
R13’ само-применение (детали в амендменте R13 выше). Реализовано публикацией span’а
текущей #coerce-декларации на время проверки/переписывания ЕЁ ЖЕ тела
(current_coerce_decl_span в чекере — RAII-guard CoerceSelfGuard; current_fn_span в
mutable AST-rewrite проходе, независимая копия по той же R9-логике «одно окно данных») и
исключением образца с СОВПАДАЮЩИМ decl_span из кандидатов generic_coerce_lookup /
rewrite-поиска. Чужие образцы и конкретные пары в теле работают как обычно — исключение
строго self-only, не «не коэрситься вообще внутри #coerce-тел».
R3’/R5’ — см. амендменты у R3/R5 выше. Реализовано: R3’ (E_COERCE_DUPLICATE_PAIR в
точке применения при ≥2 образцах, унифицирующихся к одной и той же (I,O)-паре в ОДНОЙ
позиции — единственный сценарий, различающий образцы от конкретных пар: там decl-time
seen_pairs физически не может это поймать, потому что конкретная пара кристаллизуется
только после связывания ?T). НЕ реализовано (честная пометка, разделяет пробел с
R5 конкретных пар): overload-уровневая R5’/E_COERCE_AMBIGUOUS (два образца с РАЗНЫМИ O,
оба применимые к разным перегрузкам одного call-arg) — маркер
[M-coerce-r5-ambiguous-overload-unimplemented].
Реализация: compiler-codegen/src/types/mod.rs — GenericCoercePattern (структура
образца), named_base_and_args/unify_coerce_receiver/substitute_coerce_shape (шаблон-
матчер), TypeCheckCtx::generic_coerce_lookup (accept-путь, вызывается ПОСЛЕ конкретного
coerce_pairs-фоллбэка в assignable), MapLitAnnotator::try_coerce_leaf (AST-rewrite,
generic-ветка после конкретной). Один и тот же collect_coerce_pairs строит ОБА реестра за
один проход (R9 «одно окно» — расширено на образцы теми же гарантиями).
Ретракт
Подсекция D55 «Str-литерал → []u8 coercion» (2026-07-15): литерал-правило снято в пользу
D429: литерал — частный случай str-значения, отдельного литерал-правила не остаётся; общее
правило распространяется на литералы автоматически. Name-gated реализация
(synthesize_write_str_lit_bytes_coercion, метод буквально write) снесена в Plan 214 Ф.2;
поведение сайтов w.write("...") сохранено новым механизмом байт-в-байт. Уточнение
2026-10-01 (владелец): подсекция D55 переписана под это правило (str-значение любой формы
→ ro []u8 по паре #coerce str @bytes(); позиции — в D55, механизм — здесь); текст «w.write(s)
— ошибка» и «Статус реализации (Plan 208 Ф.3)» в ней сняты.
R14 «Generic-формы запрещены» (ревью-5 2026-07-18) — RETRACTED → см. «Generic-образцы»
выше (Plan 214.1, 2026-07-24): бланкет-реджект был упущением (владелец), не осознанным
ограничением — заменён узким матчером образцов; E_COERCE_GENERIC_UNSUPPORTED ушёл из
набора кодов, его место занял E_COERCE_GENERIC_PATTERN_UNSUPPORTED (другая семантика —
не «generic вообще запрещён», а «эта КОНКРЕТНАЯ generic-форма не представима шаблон-
матчером»).
Первые декларации (std, Plan 214 Ф.3)
#coerce fn str @bytes() -> ro []u8 · #coerce fn StringBuilder consume @into_str() -> str ·
#coerce fn WriteBuffer consume @into_bytes() -> []u8.
Отклонённые альтернативы (ревью 2026-07-17)
- Универсальная вшитая str→[]u8 (хардкод пары в Rust) — §3-нарушение.
- Безымянная call-форма
fn Vec[T](s str)— новый синтаксис + реинкарнация ретрактированногоfrom(§1а) + всё равно требует opt-in атрибута. - Два атрибута
#view/#coerce— унарность делает пару однозначной, атрибут один. - return-only-срез finalize-полосы — перестраховка (линейность ловит всё компиляцией).
Амендмент (№520, 2026-08-09, владелец): неявная finalize-коэрсия — сама потребление
Контекст. R2 уже нормативно требовал этого («ресивер разряжается в точке вставки;
линейность (D133) отслеживается как при явном вызове»), но реализация не соответствовала —
spec_tests/conformance/lint/conv_clean.nv:46 (sb.into_str(), sb consume = StringBuilder.new()) с голым sb вместо явного вызова давало E_VIEW_BINDING_FORBIDDEN/
D133-not-consumed/E_CONSUME_KEYWORD_MISSING в зависимости от позиции — линт
W_COERCE_EXPLICIT_REDUNDANT (R9) советовал код, который не собирался. Найдено интегратором
2026-08-09 (реестр 221.1 №519/№520). Разбор владельца отверг оба предложенных интегратором
варианта (не срабатывать на #coerce-функциях с consume-ресивером / завести
форму-выражение для потребления) в пользу третьего: дефект в чекере, не в линте и не в
синтаксисе.
Решение владельца, дословно: «автовызов через #coerce должен учитываться при расчёте
потребления автоматически во всех ситуациях, автодисарм, например».
Нормативно (уточняет R2/R6, не меняет их): неявная вставка finalize-вызова (consume-
ресивер) в ЛЮБОЙ позиции из R6-таблицы («без исключений») ЕСТЬ потребляющее использование
ресивера — гасит consume-обязательство/drop-флаг (D432 §4/§5 — тип с @cleanup не получает
повторный авто-вызов на выходе из scope) и запрещает повторное использование значения (D131),
байт-в-байт как явный вызов (R7 уже требовал этого от диагностики; теперь требует и от
самого расчёта потребления, а не только от текста сообщения).
Реализация (compiler-codegen/src/types/mod.rs, ConsumeCtx): до амендмента расчёт
потребления (отдельный проход по ДО-переписанному AST, до MapLitAnnotator::try_coerce_leaf)
корректно кредитовал только return-позицию (существовавшее ДО D429 типонезависимое правило
«голый Ident в хвосте блока — вынос владения») и call-arg свободной функции
(fn_param_output_keys/coerce_finalize_output_keys, тот же Plan 214). Остальные позиции
R6-таблицы падали в старые D180/D133-диагностики. Новый метод
ConsumeCtx::credit_coerce_finalize(_by_key) (общий с уже существующим call-arg кредитом)
подключён к: let/ro/mut-биндингу с явной аннотацией типа, аргументу МЕТОДА (новый
реестр ConsumeRegistry::method_param_output_keys, зеркало fn_param_output_keys), полю
record-литерала (record_field_types) и элементу array-литерала под annotated let.
Return/free-fn-call-arg/if-match-ветка (та же типонезависимая «голый Ident в хвосте блока»)
уже работали корректно — проверено пробой ДО правки, не принято на веру из формулировки
находки.
Найденный побочный пробел (НЕ входит в этот амендмент, честная пометка):
resolve_call_params’s резолв ожидаемого типа для АРГУМЕНТА МЕТОДА матчит callee по ГОЛОМУ
ИМЕНИ метода глобально («определён ровно на одном типе без overload»,
unique_method_param_types) — коллизия имени с ЛЮБЫМ другим методом в скомпилированном
корпусе (напр. accept, разделяемое с TcpListener.accept() из std) молча гасит
expected-type propagation, и try_coerce_leaf не переписывает AST, хотя новый чекер-кредит
(точный по (тип, метод) через method_param_output_keys) всё равно срабатывает — расхождение
ловится ГРОМКО на C-компиляции (несовпадение типов), не тихим неверным значением. Отдельный,
не начатый пункт — маркер [M-520-method-arg-name-collision].
Связь
- D55 —
сиблинг-механизм (wrap-in: структурно выведенная обёртка); D429 = view/finalize-out
(объявленная функцией); таблица позиций общая; подсекция «Str →
ro []u8» в D55 переписана под D429 (уточнение 2026-10-01). - D176 —
ro-возврат = носитель view-полосы. - D133 (01-basics.md) — линейность consume; finalize-полоса разряжает ресивер в точке вставки.
- D372 — форма-приёмник живёт на
.new-перегрузке. - compiler-conventions §3 — механизм декларативен, пары в Rust не хардкодятся.
Статус: спека нормативна с 2026-07-18 (прод-решение, без V1-упрощений — решение владельца). Реализация конкретных пар — Plan 214 (Ф.0-Ф.5 слиты; std-пары str→[]u8/StringBuilder→str/WriteBuffer→[]u8 действуют, write-костыль снесён). R14 снят, generic-образцы реализованы — Plan 214.1 (2026-07-24, см. «Generic-образцы» выше); Ф.3 (снятие ограничения у extractors, Plan 222.3) — отдельно, не в этом амендменте.
D433. Match-арм int-width unify + E_MATCH_ARM_WIDTH_MISMATCH ([M-match-arm-mixed-int-width-sentinel-coerce], Plan 172.2 followup, 2026-07-21)
Уточнено D491 (2026-10-02, решение владельца): R1 (сводка рукавов к более широкому типу) отозван — значение числового типа неявно не расширяется нигде; R2 остаётся и покрывает любое расхождение.
Уточнено D489 (2026-10-02): литерал-сентинел в рукаве — по единому правилу литерала: без внешнего типа рукава сводятся по R1 ниже, литерал берёт
int; при аннотированном результате литерал берёт его тип и проверяется по диапазону (ro r u32 = match … None => -1—E_LIT_OUT_OF_RANGE).
Статус: закреплён 2026-07-21 (bugfix + enforcement-gap closure, P1 — старейший живой пункт
backlog-followups.md, семья Plan 172.2). Behavior-changing (меняет наблюдаемое принятие/отклонение
части match-выражений) → D-амендмент в том же слиянии, что и код.
Что
Мотив. match o { Some(v) => v, None => -1 } в контексте Option[u32] (значит v типа
u32) и None-арм с сентинелом-литералом -1 (типа int) молча компилировался в C-код,
типизированный uint32_t, — -1 тихо реинтерпретировался как 4294967295 (bit pattern),
КОГДА не было внешнего ожидаемого типа, форсирующего верную ширину (например,
неаннотированный ro r = match {...}; аннотированный ro r int = match {...} или прямой
-> int-возврат matcha уже работали верно — там ширина брался из аннотации/return-типа, а не
из самого match). Причина: infer_match_common_primitive (единственный производитель
согласованного типа арм matcha) бейлил в None при ЛЮБОМ расхождении типов арм, и codegen’ный
legacy-фоллбек (infer_expr_c_type’s Match-арм / emit_match’s собственный arm-type-цикл)
подбирал тип ПЕРВОГО non-nova_int арма — произвольно, безотносительно к типу ДРУГОГО арма.
Триггер исчез из std.unicode после миграции compose_pair → Option[u32] (D327), но остался
в компиляторе — тихая дыра (§1 + §4 compiler-conventions.md), P1 с 2026-06-26.
Правило
R1 ОТОЗВАН 2026-10-02 (решение владельца) → D491: рукава без записанного типа результата обязаны быть одного типа; «более широкая сторона» больше не выбирается. Текст R1 ниже — история; R2 (
E_MATCH_ARM_WIDTH_MISMATCH) теперь покрывает ЛЮБОЕ расхождение числовых типов рукавов.
(R1, ОТОЗВАН) Safe-widening unify. Если арм’ы matcha — int-family (Scalar) с РАЗНЫМИ типами, но
ОДНА сторона безопасно расширяется в другую (D54 would_narrow_into — тот же критерий, что
уже разрешает неявное присваивание u32-значения в int-локаль без as; ключевое правило:
unsigned → строго более широкий signed безопасно, signed → unsigned — НИКОГДА неявно),
общий тип matcha = БОЛЕЕ ШИРОКАЯ сторона — а не тип первого/произвольного арма. Закрывает
описанный выше баг: u32-payload-арм + int-сентинел-арм теперь детерминированно унифицируются
в int, -1 остаётся -1. Итеративно (N>2 арм): при переходе к каждому следующему арму
common сравнивается с ТЕКУЩИМ (уже расширенным) common, не с первым исходным армом.
(R2) Genuine mismatch → E_MATCH_ARM_WIDTH_MISMATCH. Если НИ ОДНА сторона не расширяется
безопасно в другую (оба направления — narrowing; типовой случай: одинаковая ширина, разная
знаковость, i32 vs u32 — signed → unsigned запрещено правилом D54 категорически, ширина
тут не помогает) — раньше это ТОЖЕ тихо бейлилось в None и ловило тот же произвольный-arm-
codegen-баг. Теперь — hard compile error ДО кодогена, с точкой на несовместимом арме и
заметкой на первом установленном типе; требует явного ... as <T> на одном из арм, ровно как
уже требуется для несовместимого присваивания (D54). Это НОВОЕ отклонение ранее молча
принимавшихся программ — источник D-амендмента.
(R3) Область: int-family (Scalar) ТОЛЬКО. Расхождение НЕ-числовых типов арм
(Float vs Float, record vs record, …) не тронуто — infer_match_common_primitive
по-прежнему бейлит в None МОЛЧА для этих категорий (unchanged pre-existing behavior, вне
периметра этого маркера; отдельный follow-up при реальном триггере).
(R4) Literal-fit amend (2026-07-21, найдено мега-CU гейтом на d407_enum_payload_width.nv
— ложное срабатывание R2 на УЖЕ ЖИВОЙ фикстуре). БЕЗ типового суффикса/каста литеральный арм
(d407W(_) => 1) не несёт СОБСТВЕННОГО фиксированного типа — в отличие от pattern-bound
переменной (чей тип ФИКСИРОВАН типом payload’а, из которого её извлекли), голый int-литерал
гибок до помещения в контекст (D54 literal-fit; rustc-эталон: unsuffixed integer literal
унифицируется с контекстом, а не навязывает свой дефолт). ПЕРЕД R1/R2-сравнением ResolvedType’ов
чекер теперь проверяет: если один из двух конфликтующих арм — голый литерал (IntLit или
Unary{Neg, IntLit}, -1), тот же самый critical shape, что уже матчит D227 Rule 6 в
assignable), впишется ли его СЫРОЕ значение в тип ДРУГОГО арма (те же правила, что литерал в
аннотированной позиции: D227 Rule 3 sized range-check, Rule 1 no-upper-check для wide-default
int/uint, Rule 6 negative-floor для unsigned) — если да, литеральный арм ПРОСТО ПРИНИМАЕТ
тип другого арма (никакого widen, никакой ошибки); НЕ имеет значения, какой арм физически
первый (порядко-независимо: common/common_lit отслеживают, был ли текущий «общий» тип сам
литералом, чтобы ПОЗЖЕ пришедший конкретный арм тоже мог быть усыновлён). Floor НЕ ослаблен:
отрицательный литерал (-1), который НЕ вписывается в unsigned-таргет (напр. uint, 64-бит
безнаковый — САМ int тоже не расширяется безопасно в uint той же ширины), падает через это
исключение и остаётся genuine-mismatch → E_MATCH_ARM_WIDTH_MISMATCH, как и раньше (R2
неизменён для этого случая — Rule 6 категоричен, ширина не спасает). Область — та же, что R1/R2
(int-family/Scalar only); нечисловые литералы (str/char/…) не тронуты.
Реализация
compiler-codegen/src/types/mod.rs: infer_match_common_primitive (unify через
would_narrow_into, R1) + новая check_match_arm_width_mismatch (диагностика, R2), обе
построены над общим match_arm_value_types (per-arm (Span, ResolvedType, Option<i128>)
экстракция — третий элемент несёт RAW-значение, если арм — голый литерал; единый источник для
канала И диагностики — §0/§3). Литерал-fit (R4) — bare_int_literal_value (распознаёт
IntLit/Unary{Neg,IntLit}) + literal_fits_scalar (те же D227-правила, что assignable’s
IntLit-ветка уже применяет к аннотированной позиции), проверяется ПЕРВЫМ в обеих функциях,
до R1-widen/R2-mismatch-ветки. Вызывается из f1_expr’s ExprKind::Match-ветки ДО
материализации канала.
Тесты
detect172/u172_2_match_arm_width_pos.nv (R1: широкий/узкий unify, three-arm progressive,
explicit-as-unify; R4: bare-литерал усыновляет uint-сиблинга, порядко-независимо) +
detect172/neg/n_match_arm_width_mismatch.nv (R2, EXPECT_COMPILE_ERROR E_MATCH_ARM_WIDTH_MISMATCH) + detect172/neg/n_match_arm_width_negative_literal_uint.nv (R4
floor: отрицательный литерал НЕ усыновляет uint, mismatch остаётся) ·
spec_tests/conformance/d129_match_arm_width_widen.nv (R1+R4, конформанс-CU) +
spec_tests/conformance/neg/n_match_arm_width_mismatch.nv (R2) +
spec_tests/conformance/neg/n_match_arm_width_negative_literal_uint.nv (R4 floor). Полный
мега-CU spec_tests/conformance (1004+ файлов, NOVA_CACHE=0) — PASS 519 / FAIL 0 / SKIP
19 (включает d407_enum_payload_width.nv, чей ложный R2-mismatch и был найден гейтом).
Связь
Использует D54 (would_narrow_into/narrowing-критерий —
единственный источник, тот же, что метод-arg enforcement Plan 172.2) · D129
(int≡i64-alias — сентинел-литерал -1 типизируется int, широкая сторона unify) ·
D327 (Codepoint=u32 — миграция, убравшая исходный триггер из std.unicode, не из
компилятора) · Backlog [M-match-arm-mixed-int-width-sentinel-coerce]
(docs/plans/backlog-followups.md, Plan 172.2 followup, P1).
D447. #no_copy — аффинность на уровне ТИПА (нельзя копировать, забыть можно) (План 248, 2026-08-05)
Принято владельцем 2026-08-05 по итогам разбора разделяемых ручек. Закрывает пустую клетку таблицы D131 / D133.
Что
Атрибут #no_copy на объявлении типа. Значение такого типа нельзя связать
вторым именем; забыть его — можно, обязанности потребления НЕТ.
#no_copy type AtomicU32 value priv { v u32 }
Зачем — пустая клетка, а не дубль consume
D131 consume | D133 type X consume | D447 #no_copy | |
|---|---|---|---|
| потребить ≤1 раз (нельзя копировать) | да | да | да |
| потребить ≥1 раз (обязан израсходовать) | нет, забыть OK | да | нет, забыть OK |
| ставится на | получатель/параметр метода | тип | тип |
Аффинность в языке была, но только на получателе метода. На типе имелась одна лишь must-consume-форма — и она для этого случая непригодна по двум причинам сразу (проверено на release-компиляторе 2026-08-05):
[D133-empty-consume] type `AtomicU32` помечен `consume` но не имеет ни
consume-полей, ни consume-методов — добавь хотя бы один consume-method
либо убери `consume` с type-decl.
[E_CONSUME_KEYWORD_MISSING] binding `a` держит consume-обязательную инстансу
типа `AtomicU32` — требуется keyword `consume` (D180).
Первая требует придумать метод-расход, которого у счётчика со значением
внутри нет и быть не может: память не выделяется, закрывать нечего. Вторая
требует писать consume a = … при каждом объявлении переменной. Обязанность
расхода существует, чтобы не забыли закрыть ресурс; там, где ресурса нет, она
вырождается в ритуал.
Почему признак ОБЪЯВЛЯЕТСЯ, а не выводится
Структурная проверка «полностью стековое значение» (та же, что стоит за
запретом отмывания ro→mut) решает по составу полей. Она посмотрит на
AtomicU32 { v u32 }, увидит внутри обычное число и заключит «копировать
безопасно». Семантика противоречит структуре — атомарность ячейки не
следует из её состава, — поэтому вывести запрет нельзя, он объявляется.
Это ровно тот случай, который план 248 при закрытии ветки «признак не нужен» называл возвращающим вопрос: тип не разделяемый, не стековый по смыслу, и не копируется по причине, невыводимой из полей.
Форма записи
#no_copy, с подчёркиванием — по строю соседних атрибутов типа
(#from_fields, #from_pairs, #zero_on_move, #pub_to, #serde).
Ближайший образец — #zero_on_move: тоже атрибут типа, влияющий на семантику
перемещения.
Отвергнутая альтернатива — #copy (пометка наоборот, «этому типу копирование
разрешено»). Она меняет умолчание всего языка: пометку пришлось бы
расставить примерно по 317 декларациям корпуса ради 21 случая. Измерено
окном p248-copyflip 2026-08-05.
Механизм
Признак чисто чекерный, кодоген не затрагивается. Переиспользуется
существующий flow-sensitive проход (check_consume, состояния
Live/Consumed/MaybeConsumed) с одним изъятием: для #no_copy-типа НЕ
выполняется финальная проверка «на выходе из области не осталось живого» —
именно она и есть разница между «ровно один раз» и «не больше одного раза».
Реестр типов ведётся с уровнем строгости, а не двумя списками; обход
type_is_consume_v, тянущий свойство через поля записи, варианты суммы,
обёртки-дженерики и кортежи, тянет и уровень — не раздваиваясь.
Диагностики — свои, не переиспользовать тексты consume: там речь про
«обязан потребить», здесь про «нельзя завести второе имя».
Энфорс — волна 2 (план 248, 2026-08-05)
Правило второго имени. Значение Affine-типа (#no_copy) не может
получить второе имя. Проверяются четыре формы:
- Голое связывание:
ro b = a. - Чтение поля в локальную:
x = obj.field. - Передача аргументом получателю, который его НЕ заимствует (см. ниже).
- Встраивание в литерал записи/кортежа:
Type { field: a },(a, b).
Диагностика — E_NO_COPY_SECOND_NAME. Свежая конструкция
(Type{field: Handle{...}}, вызов функции, бинарная операция) НЕ
считается вторым именем — второе имя относится только к уже
СУЩЕСТВУЮЩЕМУ значению (bare identifier / @self / .field-путь).
Заимствование. Передача Affine-значения в параметр — не копия,
если параметр получателя ro ИЛИ mut (амендмент волны 3, 2026-08-06:
in-out mut-параметр по D246/Plan 184 P10 не создаёт второго имени —
значение возвращается владельцу; исключён только consume) И тело получателя
НЕ сохраняет его: не пишет в поле, не возвращает, не встраивает в
литерал, не захватывает в замыкание/spawn/detach/blocking/
supervised, не передаёт дальше аргументом. Такая передача — заём, не
перевязка, и остаётся законной.
consume и #no_copy на одном типе — ошибка
(E_NO_COPY_CONSUME_CONFLICT): два взаимоисключающих уровня строгости
(«обязан израсходовать» против «расходовать не обязан»).
#no_copy применим к видам объявления с собственным хранилищем —
record, sum, named tuple, newtype, external opaque (то же множество, что
у #share, D415 §1). На alias/type-set/effect/protocol —
E_NO_COPY_INVALID_KIND.
Связь
- D131 — аффинный
consumeна получателе/параметре, исходная половина таблицы. - D133 — must-consume на типе, вторая половина.
- D415 —
#share: ортогонален.#shareразрешает разделение между файберами,#no_copyзапрещает второе имя; на одном типе не конфликтуют (проверено пробой 2026-08-05). - D246 — три оси изменяемости; запрет отмывания
ro→mutдля не-полностью-стековых типов остаётся в силе и работает независимо. - План 248 — разбор разделяемых ручек, откуда решение выросло.
D450. Контракт #from_fields/#from_pairs — один конструктор new(cap:)
Решение владельца 2026-08-09: «по нашей текущей концепции мы вызываем
new(cap:)одной функцией, предлагаю это перенести в#from_fields/#from_pairs». Уточняет D55 и D108. Записано ДО реализации (нормаdev-workflow.md: спека первой).
Что меняется
Тип, помеченный #from_fields либо #from_pairs, обязан предоставить один
статический конструктор с необязательным именованным параметром вместимости:
export fn T[K, V].new(cap int = <по умолчанию>) -> Self
Десугаринг литерала ([k: v, …] и {field: val}) вызывает его одного:
T.new(cap: <число элементов литерала>), затем наполняет обычным @insert.
Что отменяется, а что остаётся
Прежний контракт требовал трёх методов — new, mut @cap(n), insert_new,
— и проверялся условием has_new && has_cap && has_insert_new. Обязательным
остаётся один конструктор; остальное переходит в разряд необязательного.
| было | стало |
|---|---|
new + mut @cap(n) + insert_new — все обязательны | new(cap:) обязателен |
| — | insert_new — необязательная оптимизация: если тип её имеет, десугаринг использует её; иначе обычный @insert |
insert_new может быть приватной (решение владельца 2026-08-09: «её можно
кстати делать не публичным методом, но доступным для компилятора»). Компилятор
видит все методы типа, поэтому priv контракту не мешает — и тогда оптимизация
не попадает в публичный договор типа.
Её предусловия («ключа нет в карте», «вместимость не меньше итогового числа»)
выполнимы для литерала: дубликаты ключей запрещены линтом
(lint_duplicate_str_key/lint_duplicate_int_key), а вместимость задаёт сам
десугаринг через cap:.
Две поправки 2026-08-09, обе от владельца. Первая редакция обосновывала отмену тем, что (а) «
capзначит не то, что в коде» и (б) «insert_new— деталь реализации, требовать её нельзя». Оба довода сняты: сеттерmut @cap(n int) -> @существует и жив (hash_map/core.nv:186; ретрактирован былwith_capacity), а приватность решает вопрос с деталью реализации. Решение держится на третьем, исходном доводе владельца — единообразии с текущей концепцией конструктора, — и он самодостаточен:new(cap:)уже канон (HashMap[K, V].new(cap int = 16), вызовSelf.new(cap: pairs.len() * 2)), а контракт атрибута от него отстал.Отдельно уцелело наблюдение, не зависящее от снятых доводов: проверка «есть метод по имени
cap» не отличает сеттер от геттера — тип с одним лишь@cap() -> intпроходил её так же. Контракт проверял имя, а не способность.
Обязательная диагностика
Молчаливое игнорирование пометки запрещается (№503 (реестр 221.1)). Если тип помечен, но конструктора нет — ошибка компиляции с указанием, чего именно не хватает:
error: тип `IndexMap` помечен `#from_pairs`, но не имеет конструктора
`IndexMap[K, V].new(cap int = …)`
Сегодня такой тип просто выпадает из множества from_pairs_types, литералы в
него не коэрсятся, и пользователь не отличает «атрибут работает» от «атрибут
проигнорирован» — тот же класс, что #zero_on_move (№465) и #debug invariant
(№466).
Следствие для std
IndexMap получает new(cap int = …) и обе пометки — insert_new ему не
нужен, десугаринг обойдётся @insert. HashMap уже соответствует и правки не
требует: его mut @cap(n) и insert_new остаются как есть, просто перестают
быть ТРЕБОВАНИЯМИ атрибута — первый полезен сам по себе, второй продолжит
использоваться десугарингом как оптимизация.
D457. Видимость уровня пакета — priv(package) (план 269, владелец 2026-08-11)
Status: ACCEPTED (2026-08-11). Продолжает: D281 (module-private), D307 (
priv(file)). Реализация: план 269, ДО тега v0.1 — см. «Почему до тега» ниже.
Мотивация — ступень, которой не хватало
Шкала видимости была такой: priv(file) — только этот файл; без модификатора
(или priv) — модуль, то есть все peer-файлы папки; export — снаружи модуля,
то есть ВСЕМ, включая пользователя пакета.
Все существующие уровни уже модуля или ровно модуль. Не хватало ровно одного шага шире: «видно всем модулям МОЕГО ПАКЕТА, наружу — нет».
Нехватка не гипотетическая, у неё измеренная цена. std/src/runtime/**
объявляет интринсики рантайма (nova_rt_*), и зовут их std.concurrency,
std.net, std.io — другие модули ТОГО ЖЕ пакета. Дать соседнему модулю увидеть
объявление можно было только через export, а export означает «всем, включая
пользователя». Отсюда замер 2026-08-11: 425 export extern, из них 381 в
std/src/runtime/** (sync.nv — 327, math.nv — 54). Это не расхлябанность
авторов: промежуточной ступени в языке не было, а писать 380 сквозных обёрток —
работа без выгоды.
Что
priv(package) fn helper() -> int // виден всем модулям своего пакета
priv(package) extern "C" fn nova_rt_x() // но НЕ виден снаружи пакета
priv(package) type Slot { … }
type Job priv(package) { … } // и как type-modifier, симметрично D307
Правило: объявление с priv(package) разрешается использовать из любого модуля
того пакета, которому принадлежит файл (пакет = каталог с nova.toml, с учётом
[lib] src и сброса корня по D78 rev-6), и НЕ разрешается ни из
какого другого пакета — ни через import, ни через re-export фасада.
Шкала целиком, от узкого к широкому:
priv(file) → priv / без модификатора (модуль) → priv(package) →
export.
Почему именно такое написание
Семья priv(<область>) уже существует и читается буквально: «приватно на уровне
области». priv(package) продолжает её без нового ключевого слова. Rust решает
ту же задачу через pub(crate) — «публично, но до границы крейта»; мы говорим с
другой стороны — «приватно, но до границы пакета». УРОВНЯ видимости pub(…) в
языке нет и не вводится: export уже занят и означает ровно «наружу».
Уточнение 2026-10-02 (владелец; охота по спеке, реестр №1581). Прежде здесь стояло «слово
pubв язык не вводится: у нас его нет» — это неверно. Словоpubв языке ЕСТЬ, но только в одной роли: модификатор ПОЛЯ, открывающий поле внутри типа с перевёрнутым умолчанием,type X priv { pub id int }(D220, D281). Уровнем видимости объявлений оно не служит: для них шкала выше, иpub fn/pub typeне пишутся.
Почему ДО тега, а не после
Добавить уровень видимости после тега можно — это аддитивно. Но сегодняшние
425 export extern УЖЕ попадут в публичную поверхность v0.1, и пользователь
вправе на них опереться. Спрятать их потом — ЛОМАЮЩЕЕ изменение. Откладывание
меняет цену с «час работы» на «ждать v0.2».
Что это упрощает немедленно
Страж FFI (реестр 221.1 №569, план 268 Ф.4) перестаёт нуждаться в именованном
исключении для runtime/**: правило становится «export extern — НОЛЬ, жёстко,
без исключений», а интринсики переезжают на priv(package). Исключение, которого
нет, всегда лучше исключения, которое надо помнить.
Приёмка
Позитивная фикстура: priv(package)-объявление видно из другого модуля своего
пакета. Негативная: то же объявление из другого ПАКЕТА — ошибка компиляции с
собственным кодом диагностики, а не «undefined identifier». Плюс проба «подсунь
негодное»: снять проверку — негативная фикстура краснеет.
D458. Одна форма для методов и функций — @ как переменная типа получателя (план 273, владелец 2026-08-12)
Status: ACCEPTED (2026-08-12). Родня: план 196 («одна правда» — чекер пишет канал, кодоген лоуэрит), прецедент — 196.7 (method-dispatch через
resolved_callees, закрыт 2026-07-15). Реализация: план 273, фазы Ф.0–Ф.5, до тега v0.1 (Ф.0–Ф.1 обязательны, Ф.2 по факту критерия §4.2).
Мотивация — не синтаксис, а место в компиляторе
Метод сегодня — функция, у которой первый параметр помечен как получатель, но в компиляторе методы и функции живут в РАЗНЫХ местах: отдельная таблица имён, отдельный обход достижимости, отдельная мономорфизация. Каждый проход поэтому имеет про них ДВА случая, и расхождение между случаями — не гипотеза, а источник конкретных дефектов реестра 221.1:
- №576 — обход достижимости DCE (
compute_dead_decls_with) ходит по ИМЕНАМ; голый конструктор варианта суммы (Blue(7)) упоминает только имя варианта, никогда имя владеющего типа — метод типа вычищался как мёртвый. - №613 — то же самое пятно кода: значение метода (
Type.@method) не считалось использованием, метод вычищался, а переходник значения на него ссылался. - №536, №534, №514 — резолв по голому имени через границу модуля: таблица функций плоская и CU-широкая, метод-сет — другой механизм; чинили трижды, класс возвращался соседней дверью.
- №600 — прямой путь
[]T.method()не связывает метод-уровневые generic’и.
Единая внутренняя форма убирает не эти четыре записи, а МЕСТО, откуда они берутся: один резолв, один обход достижимости, одна мономорфизация.
Что
Метод — сахар над компиляторной формой, в которой получатель — обычный первый
параметр с явным маркером @:
fn Type mut @job(a int) -> @ // ИСХОДНАЯ форма (сахар, единственная в коде)
fn job(mut @Type, a int) -> @ // КОМПИЛЯТОРНАЯ форма — внутреннее представление
@ — переменная типа получателя, аналог Self: связывается ПЕРВЫМ
параметром объявления (@Type/mut @Type/consume @Type, позиция
фиксирована), и эта же переменная доступна в позиции возврата (-> @) и внутри
generic-конструкторов (-> Option[@]). @ — часть ТИПА, не пометка места
объявления: тип fn(mut @Purse, int) -> @ несовместим с
fn(mut Purse, int) -> Purse намеренно (@ может лечь в C иначе — метод
использует получателя как контекст диспетча, обычная функция нет).
-> @ — НЕ новая форма: она уже используется в std 37 раз (sort.nv,
queue.nv, fs.nv и родня, паттерн with_*-цепочек:
fn OpenOptions mut @read(v bool) -> @ { @rd = v }). D458 не изобретает
-> @, а называет, чем оно всегда было — теперь общим механизмом, а не
точечным правилом только для позиции возврата.
Получатель — первым параметром, без исключений на первом шаге. Разрешить получателя в середине списка — расширение, которое всегда можно сделать позже; сузить обратно нельзя.
Вызов: @ принуждает точку, без вариантов
Раз @ в типе — вызов возможен ТОЛЬКО через точку, и это касается и обычного
метода, и метод-ЗНАЧЕНИЯ:
x.job(5) // обычный вызов метода — как сегодня
ro f = Purse.@bump // значение метода, тип: fn(mut @Purse, int) -> @
ro r = x.(f)(5) // вызов ЗНАЧЕНИЯ: получатель слева, значение в скобках
Позиционная форма с получателем-аргументом (job(x, 5), f(x, 5)) не
появляется в языке НИГДЕ — ни у имени, ни у значения. x.(f) БЕЗ вызова —
ошибка компиляции (не частичное применение): @ принуждает вызов через точку,
и выражение, «связавшее получателя» и более ничего, было бы значением формы,
которую нельзя вызвать иначе, — тупик системы типов.
Компиляторная форма запрещена как ОБЪЯВЛЕНИЕ, но легальна как ТИП. Это разные грамматические позиции: объявление создаёт callable, аннотация лишь называет тип уже существующего значения.
fn job(mut @Type, a int) -> @ { … } // ЗАПРЕЩЕНО: объявление в компиляторной форме
// → E_D458_COMPILER_FORM_IN_SOURCE,
// fix-it на `fn Type mut @job(a int) -> @`
ro f fn(mut @Type, a int) -> @ = Type.@job // ЛЕГАЛЬНО: компиляторная форма как ТИП
Явное преобразование метод-значения в обычную функцию — запрещено в обе
стороны. Не заводится каста as fn(...), стирающего @: для кода высшего
порядка, которому нужна функция без получателя, используется обычная лямбда
(fn(mut q Type, k int) -> int => q.job(k)) — она уже в языке, видна на месте
вызова и не создаёт второй способ делать то, что делает первый.
Почему не разрешаем компиляторную форму нигде, кроме типа
Форма, легальная в одном контексте и запрещённая в другом БЕЗ названной
ошибки, становится источником вопросов «а почему там можно». Ошибка
E_D458_COMPILER_FORM_IN_SOURCE гейтится на грамматической позиции
ОБЪЯВЛЕНИЯ (fn <возможно-с-получателем>(...) { … } / => …), не на встрече
компиляторной формы вообще — иначе она ложно сработала бы на каждой аннотации
типа метод-значения, которая обязана оставаться легальной.
Родство с планом 196 — обязательное условие реализации
Единая форма — не переименование, а сведение К ОДНОМУ каналу: чекер резолвит
методы и функции ЕДИНЫМ резолвером в resolved_callees, кодоген лоуэрит
результат, а не переизобретает по имени. Реализация ОБЯЗАНА строиться на этом
канале (прецедент — 196.7, коллизия bare-T бланкета с фасадным методом уже
закрыта тем же приёмом); параллельный механизм резолва означал бы не одну
дверь, а третью — ровно то, что D458 существует закрыть.
Смежное, но не часть этого D-блока
Реестр 221.1 №625: резолв неоднозначного значения метода по аннотации
let-binding (в отличие от as fn(...)) сегодня не работает — известный
дефект, не введённый D458, но который D458 делает заметнее (метод-значения
становятся центральным механизмом языка). Чинится отдельно, тем же принципом
«один резолв, оба синтаксиса передают в него целевой тип», до начала Ф.2.
Приёмка
- Негативная фикстура: компиляторная форма как объявление вне типовой позиции
→
E_D458_COMPILER_FORM_IN_SOURCEс fix-it. - Позитивная: та же форма в позиции типа (аннотация связывания) — легальна.
- Побайтово идентичный СИ-выход десугаринга на корпусе (мега-CU +
std) до и после — критерий Ф.1, расхождение объясняется пофайлово, не списывается на «так тоже правильно». - Все 37 существующих мест
-> @вstd— то же поведение, не проверяются саботажем заново (уже покрыты собственными тестами), а лишь остаются собранными. - Каждая фаза удаления (Ф.2, Ф.3) называет СНЯТЫЙ метод-специфичный путь и показывает фикстуру, которая теперь идёт общим путём — приёмка формулируется как удаление, не как добавление.
D469. Анонимный тип с ИМЕНОВАННЫМИ компонентами: (file str, line int) (решение владельца 2026-09-04)
Status: active (spec, 2026-09-04). Заполняет пустую клетку решётки D215. Номер выдан интегратором; замер до решения — план 274.6 §4а.
Что
Тип, у которого нет имени, но есть имена у компонентов:
fn loc() -> (file str, line int) => ("main.nv", 42)
ro l = loc()
println(l.file) // доступ ПО ИМЕНИ, а не по индексу
ro { file, line } = l // разбор фигурной формой (D411)
Почему клетка была пуста, и почему её надо закрыть
Формы записи различаются по ДВУМ независимым осям — есть ли имя у ТИПА и есть ли имена у ПОЛЕЙ. До этого блока язык покрывал три клетки из четырёх:
| компоненты именованы | компоненты позиционны | |
|---|---|---|
| тип объявлен | type Pair(x int, y int) (D215) | type Point(f64, f64) (D215) |
| типа нет | D469 — эта клетка | (1, 2) (D123) |
Пустота была не решением, а пропуском: анонимный кортеж (1, 2) в языке есть,
именованные поля у объявленного типа есть, а их сочетание — нет. Практическая
цена видна на возврате нескольких значений: -> (str, int) заставляет
вызывающего помнить ПОРЯДОК, тогда как -> (file str, line int) называет
смысл на месте и переживает перестановку.
Правила — все выводятся из уже принятого, ни одно не новое
- Размещение — стек, семантика значения (D215, allocation contract:
скобочная форма всегда stack/value).
valueк ней неприменим: переводить с кучи нечего. - Тождество — СТРУКТУРНОЕ, как у анонимного кортежа (D123): два
вхождения
(file str, line int)суть один тип. Имя компонента входит в тождество —(file str, line int)и(path str, line int)РАЗНЫЕ типы; иначе имя не несло бы контракта, а лишь украшало. - Конструирование — позиционное (D215, амендмент 2026-08-05; D102 в
редакции 2026-09-04):
("main.nv", 42). Именованная форма — только для компонентов с умолчанием. - Доступ — по имени (
l.file), не по индексу: имена есть, и.0на именованных компонентах не вводится — одна форма доступа на одну сущность. - Разбор — фигурной формой
ro { file, line } = l(D411), как у любой формы с именованными полями; круглая(a, b)на ней ЗАПРЕЩЕНА по той же причине, что и на именованном кортеже: имена — часть контракта, и разбор по порядку молча сломался бы при законной перестановке.
Замер, стоящий за блоком (2026-09-03, до решения)
Формы в языке НЕ БЫЛО, и это проверено, а не предположено: nova build на
fn loc() -> (file str, line int) даёт expected ')', got identifier; грепом
по spec/decisions/ форма не описана. То есть блок ВВОДИТ форму, а не
описывает существующую, и до его реализации компилятор обязан её отказывать.
Что это стоит реализации
В novac цена названа заранее и мала: шестой вариант перечисления формы записи
плюс арм разбора — классификатор уже различает «имя у поля есть/нет», а форма
уже отделена от тождества (план 274.6, волны В1–В3-1). В оракуле — разбор
скобочного типа с именованными компонентами и структурное тождество по
(имя, тип) вместо одного типа.
Что отвергнуто
- Доступ
.0наравне с.file— две формы доступа к одному полю; язык выбирает одну, и раз имена есть, побеждает имя. - Считать
(file str, line int)тем же типом, что(str, int)— тогда имя компонента не несёт контракта и его нельзя проверить; отвергнуто той же логикой, по которой имена полей именованного кортежа входят в контракт.
D475. Амендмент к D422 §5: цифры float производит Nova, C-дверь форматтера закрывается (план 285, 2026-09-13)
Что говорил D422 §5. «float — ЕДИНСТВЕННЫЙ C-extern, dtoa непортируем». Всё
остальное форматирование — целые, bool, char, радикс, дополнение — ушло в .nv, а печать
чисел с плавающей точкой осталась за snprintf и strtod.
Что меняется. Утверждение «непортируем» больше не верно и снимается. Кратчайшая
round-trip-запись (FloatKind.Shortest) для f64 и f32 производится кодом на Nova
(std/src/runtime/fmt_buf/shortest.nv, порт Ryu), а точная десятичная арифметика живёт в
runtime.decimal и обслуживает ОБЕ стороны преобразования. C остаётся у Fixed и Sci до
завершения плана 285 Ф.4.
Почему это АМЕНДМЕНТ, а не новое решение. Поверхность не меняется ни на байт:
f64_fmt/f32_fmt, FloatKind, мини-язык ${x:.2} и протокол Fmt остаются теми же.
Меняется ТОЛЬКО то, кто производит цифры за ней.
ОДНО НАБЛЮДАЕМОЕ РАСХОЖДЕНИЕ, и оно НЕ регрессия. В субнормальной ленте
старый C-хелпер печатал НЕ кратчайшее: 4.94066e-324 там, где кратчайшая запись есть
5e-324 (замер плана 285: 0 расхождений на 200 000 случайных f64, 3871 из 4000 в нижней
субнормальной ленте; у f32 — 4 из 59 786, все субнормальные). Причина: хелпер берёт
ПЕРВУЮ точность, что читается обратно, начиная с %g — шести значащих цифр ТОЧНОГО
двоичного значения, — а у субнормали с малой мантиссой двоичная величина отстоит от
короткой десятичной на 1.2 %, и хвостовые нули не появляются. Shortest есть ИМЯ
ВИДА, то есть контракт, и функция, печатавшая там шестизначное, его не исполняла.
Новый производитель не отступает от поверхности — он начинает её исполнять.
Что ЕЩЁ НЕ ЗАКРЫТО на момент амендмента, чтобы читатель не счёл дело сделанным:
у печати ДВЕ двери. print(x) для float идёт мимо f64_fmt — кодоген выпускает
nova_print_f64, зовущий C-хелпер напрямую (реестр 221.1 №1072). До его закрытия
println(x) и println("${x}") могут разойтись на субнормалях — расхождение, которое план 180
однажды уже закрывал.
D477. Размещение суммы и маркер косвенности indirect (решение владельца 2026-09-13)
Уточнено D488 (2026-10-01): размещение и передача значения — там единственный дом правила (место решает убегание, передачу
ro— размер, порог три слова;mut— указатель). Где этот блок расходится с D488, действует D488.
Статус: ПРИНЯТО 2026-09-13 (решение владельца в разговоре; номер назначен интегратором). Реализации НЕТ ни у одного компилятора. Требование к Карине — план 274, Т2; для оракула работа не планируется (см. §«Область» ниже).
Амендирует: D406 (синтаксис
суммы — добавляется маркер), [D241] (value-запись — маркер применим и к её полям).
0. Чего в спеке НЕ БЫЛО, и почему это пробел, а не умолчание
Нормы о размещении суммы не существовало. Проверено поиском по заголовкам
D-блоков во всех файлах spec/decisions/ 2026-09-13: есть D406 (синтаксис
enum), есть D474 (размещение литерала обработчика), есть
D280 (ссылка на heap-тип по значению = размер указателя) —
про размещение самой суммы нет ничего.
Фактическое поведение жило комментарием в коде: check_infinite_type
(compiler-codegen/src/types/mod.rs) вырезает суммы из проверки на бесконечный
тип ДВАЖДЫ и намеренно — как субъект цикла и как ребро, — с комментарием «A Sum
is ALWAYS heap». Поведение, держащееся комментарием, не является нормой: его
нельзя ни процитировать, ни опровергнуть, ни унаследовать второму компилятору.
1. Норма
Сумма размещается ПО ЗНАЧЕНИЮ (на стеке, в поле, в регистре — где живёт объемлющее значение), а не за неявным указателем.
Рекурсия разрывается ЯВНО — маркером indirect на объявлении. Тип, размер
которого без косвенности бесконечен, обязан СКАЗАТЬ об этом; молчаливого спасения
кучей больше нет.
type SqlType enum
| TInt
| TText
| indirect TList(SqlType) // нагрузка варианта — за указателем
Помеченный вариант вносит в размер суммы РАЗМЕР УКАЗАТЕЛЯ. Косвенность невидима в
использовании: рукав match связывает внутреннее значение напрямую, без
развёртки, и unsafe не требуется нигде.
match t {
SqlType.TList(inner) => … // inner : SqlType, не коробка
_ => …
}
2. Где стоит маркер — ОДНО правило на две фигуры
Маркер сидит на той единице, чей размер вычисляется ОТДЕЛЬНО.
| фигура | как считается размер | место маркера | пример |
|---|---|---|---|
| сумма | МАКСИМУМ по вариантам | вариант | ` |
value-запись | СУММА полей | поле | job indirect Option[Job] |
Это не два правила, а одно, приложенное к двум фигурам. У записи вариантов нет, поэтому поле — единственное возможное место. Для суммы вариант выбран по трём причинам, и каждая проверяема:
-
Размер суммы есть максимум по вариантам — ручка, управляющая размером, принадлежит той единице, где размер и решается.
-
Одна аллокация на помеченный вариант, а не по одной на каждое помеченное поле:
TMap(SqlType, SqlType)даёт ОДНУ косвенность, а не две. -
Вариантная пометка лечит и ТОЛСТЫЙ вариант. Пополевая пометка оставила бы толстую часть внутри и раздула бы ВСЮ сумму до её размера — то есть отменила бы то, ради чего сумма размещается по значению. Пример целиком (добавлен 2026-09-20 по замечанию владельца: прежде здесь стояло одно имя
TDecorated(BigRec, SqlType)без объявлений, и довод читался только тем, кто его уже знал):type BigRec value { // 32 байта: четыре i64 a i64 b i64 c i64 d i64 } type Node enum | Leaf | indirect Decorated(BigRec, Node)Читается так:
Decorated— ИМЯ варианта, а(BigRec, Node)— его нагрузка из ДВУХ значений: сначалаBigRec, потом ещё одинNode. То есть какSome(T), только значений два. Второе и есть рекурсия — узел несёт соседний узел;BigRecздесь просто «что-то большое», чтобы было видно, за что платит сумма.Размер суммы — максимум по вариантам. БЕЗ маркера
DecoratedнесётBigRec(32 Б) плюс самNode, то есть размер бесконечен. С маркером НА ВАРИАНТЕ за указатель уезжает ВСЯ нагрузка — иBigRec, иNode, — поэтомуNodeвесит тег плюс указатель, и это тот же вес, что уLeaf.Если бы маркер стоял на рекурсивном ВХОЖДЕНИИ (гипотетическое
Decorated(BigRec, indirect Node)), рекурсия бы разорвалась, ноBigRecостался бы внутри: сумма весила бы 32 Б + указатель, и КАЖДЫЙLeafплатил бы эти 32 байта. Ради этого маркер и сидит на варианте.
Расширение с варианта на поле остаётся возможным, сужение — нет: если случай потребует пополевой точности внутри суммы, маркер на поле добавляется как уточнение, и написанный код не ломается.
3. Чего маркер НЕ делает
indirect отвечает на вопрос «сколько это занимает» и НЕ отвечает на
«где это кончается».
type Job value {
name str
job indirect Job // размер конечен — и тип НЕПОСТРОИМ
}
Чтобы построить Job, нужен готовый Job; первый взять неоткуда. Базовый случай
даёт Option либо вариант-лист:
type Job value {
name str
job indirect Option[Job] // { name: "a", job: None } строится
}
Диагностики РАЗНЫЕ, и это нормативное требование, а не пожелание:
E_RECURSIVE_NEEDS_INDIRECT— рекурсия без маркера. Сообщение называет ЦИКЛ (последовательность типов) и предлагает маркер в конкретной позиции.E_RECURSIVE_NO_BASE_CASE— маркер есть, базового случая нет. Сообщение предлагаетOptionлибо вариант без рекурсивной нагрузки.
Два отказа смешивать нельзя: правки у них разные, и автор, получивший первое сообщение на второй случай, поставит маркер и упрётся снова.
4. Где маркер НЕ нужен
Только там, где размещение по значению запрошено автором: value-записи и
суммы. Обычная запись (type X { … }) конечна уже сегодня — ссылка на
heap-тип по значению занимает размер указателя, а не размер объекта
(D280). Новая обязанность ложится ровно на тех, кто просил
размещение по значению, и ни на кого больше.
5. Область: Карина — да, оракул — нет
Карина — требование самохостинга (план 274, Т2), наравне с D473 и D474.
Оракул — работа НЕ планируется. Критерий владельца 2026-09-13: «причина менять
оракул одна — требования Карины, т.к. она без этого не может собраться». Сумма в
куче сборке Карины не мешает; перевод оракула на размещение по значению — широкий
радиус (замер плана
172.14 Ф.2: 4387 сайтов
nova_make_* в 73 единицах компиляции) и к тегу v0.1 не приближает.
Следствие, названное вслух: до реализации в Карине ни один компилятор не
РАЗМЕСТИТ сумму с indirect по значению. Положительная фикстура на РАЗМЕЩЕНИЕ
D477 покраснеет у оракула, поэтому в корпус до реализации идут только
ОТРИЦАТЕЛЬНЫЕ (рекурсия без маркера — отказ), а положительные приезжают тем же
слиянием, что и реализация. Обратный порядок кончается фикстурой, которую
придётся отключить. Уточнение §5б (2026-09-25): оракул сам токен indirect
с этой правки ПАРСИТ (и держит сумму в куче, как всегда) — сказанное выше про
РАЗМЕЩЕНИЕ не изменилось, речь только о синтаксисе.
6. Что обязан сделать реализующий
Детектора рекурсии для сумм НЕТ ни у одного компилятора — это установлено
разведкой 2026-08-22 (поправка в плане 172.14) и проверено по коду: единственный
настоящий детектор check_infinite_type суммы исключает. Работа начинается с
написания детектора, а не с его использования; каркас обхода (DFS с on_path,
ловящий и взаимную рекурсию) лежит рядом.
5а. AMEND (решение владельца 2026-09-20): маркер законен и без рекурсии
Рекурсия — повод ОБЯЗАТЕЛЬНЫЙ, но не единственный. §1 говорит, что бесконечный тип
ОБЯЗАН сказать о косвенности; он не говорит, что в остальных случаях маркер запрещён, — и
это молчание прочиталось двумя способами в первом же разговоре о блоке. Норма: маркер
законен на любом варианте и любом поле value-записи, независимо от рекурсии.
Случай, ради которого это названо вслух, уже лежит в §2 — довод 3 про толстый
вариант: у TDecorated(BigRec, SqlType) пометка убирает из размера суммы ВЕСЬ BigRec, а
не только рекурсивную часть. То есть добровольное применение не выдумано задним числом:
на нём стоит выбор места маркера.
Критерий не меняется: маркер по-прежнему отвечает на «сколько занимает». Значит никакого отдельного разрешения ему не нужно, а отказ вида «здесь нет рекурсии, маркер лишний» был бы дефектом.
И тот же довод закрывает вторую половину вопроса — КУЧЕВОЙ тип (решение владельца
2026-09-20). indirect T законен при любом классе хранения T, а действует по-разному:
Tстековый (value-запись, сумма, примитив) — под ним появляется ПРОЗРАЧНЫЙ указатель: в размер входит указатель, в использовании косвенности не видно;Tкучевой — не меняется НИЧЕГО: значение и так живёт по ссылке и занимает размер указателя (D280), так что маркер не добавляет ни аллокации, ни перехода.
Почему не отказ на кучевом, раз пользы ноль. Отказ заставил бы автора обобщённого
кода знать класс хранения параметра типа: job indirect Option[T] пришлось бы писать
по-разному для T = value и T = heap, а класс становится известен лишь с
инстанциацией. No-op снимает это по построению — пишется одинаково, стоит по-разному.
Та же логика, что у нормализации ref H ≡ H в Р1/Р6 ревизии D326.
Приёмка — четыре пункта, и каждый машинный:
- рекурсивная сумма БЕЗ маркера →
E_RECURSIVE_NEEDS_INDIRECT, а неstack overflowи не тихая куча; - она же С маркером → собирается и ИСПОЛНЯЕТСЯ (не «собирается»: предмет — проходит ли значение целиком);
- нерекурсивная сумма БЕЗ МАРКЕРА → в сгенерированном C НИ ОДНОГО
nova_allocпод неё (проверяется грепом по выпущенному C, а не по исходнику компилятора). Уточнение «без маркера» добавлено амендментом §5а: помеченный вариант выделяет память и в нерекурсивной сумме — это и есть его работа, а прежняя формулировка объявила бы такую фикстуру красной; - обратная проба: правило снято — фикстура (1) зеленеет.
Плюс негативная фикстура на E_RECURSIVE_NO_BASE_CASE с построчным
nova:expect, потому что это новый E_*.
5б. AMEND (решение владельца 2026-09-25): оракул ЧИТАЕТ маркер синтаксически, не РАЗМЕЩАЕТ по нему
§5 остаётся в силе без изменений: размещение по значению у оракула НЕ
планируется — критерий владельца 2026-09-13 не пересмотрен, радиус (4387
сайтов, план 172.14 Ф.2) тот же. Амендмент сужает не критерий, а СЛЕДСТВИЕ
из §5, которое оказалось шире факта: «ни один компилятор не соберёт код с
indirect» было верно до этой правки и перестало быть верным для ПАРСИНГА
конкретно.
Повод — не абстрактный. План 286
(потребитель, назван в «Связь» ниже) объявляет SqlType.TList(SqlType) с
маркером и сам документирует последствие (286:924-929): это ЕДИНСТВЕННАЯ
форма во всём плане, которую сегодня не собирает НИ ОДИН компилятор, при
том что остальные формы плана собирает хотя бы оракул. Пакет с этим типом
не мог даже дойти до типовой проверки — отказ был на токене indirect, до
всякой семантики размещения.
Норма. Парсер оракула принимает indirect как контекстное ключевое
слово перед именем варианта суммы (ровно позиция §2 — на варианте, не на
типе целиком), тем же приёмом, что и value у типа-модификатора (тот же
файл кода, тот же класс trade-off с обратной совместимостью для случайного
идентификатора indirect). Токен читается и немедленно отбрасывается:
в AST он нигде не сохраняется, чекер и кодоген его не видят. Сумма с
indirect-вариантом продолжает размещаться в куче — ровно так же, как без
маркера, — потому что решение о размещении у оракула как было безусловным
(«A Sum is ALWAYS heap»), так и осталось; амендмент этого решения не
трогает.
Чего это НЕ даёт, сказано прямо, чтобы не читалось шире, чем есть:
- не даёт размещения по значению — §5 в силе, работа не начата;
- не даёт диагностик D477 (
E_RECURSIVE_NEEDS_INDIRECT,E_RECURSIVE_NO_BASE_CASE) — сумма без маркера собирается точно так же, как и с ним, отказа НЕТ ни в одну сторону; - не является позитивной фикстурой D477 в смысле §5 — та фикстура доказывает размещение и по-прежнему приезжает одним слиянием с реализацией. Фикстура на эту правку доказывает другой, более узкий факт — что маркер не роняет парсинг и поведение не меняется — и названа так же узко.
Почему разрешено менять оракул при неизменном критерии §5. Критерий владельца — «причина менять оракул одна: то, без чего не собирается Карина». Пакетный код, нацеленный на будущий синтаксис Карины, тоже не может собраться без этой правки — не потому, что Карине НУЖНО что-то от оракула, а потому, что до её релиза ЕДИНСТВЕННЫЙ компилятор, строящий пакеты для пользователей, — оракул. Различие: критерий §5 отказывал в РАЗМЕЩЕНИИ (широкий радиус, к тегу не приближает); эта правка — в ПАРСИНГЕ (один контекстный токен, отбрасывается, радиус — один файл).
Связь
- D406 — синтаксис суммы, куда добавляется маркер.
- D280 — почему обычная запись в маркере не нуждается.
- План 274 Т2 — требование к Карине, тесты и порядок работ.
- План 286 — первый потребитель
(
SqlType.TList); там же разобрано, почему отвергнуты[]T,Box[T]и указатель.
D480. Тип-ссылка: запись ref T / ref mut T; возврат ссылки из обобщённой функции (решение владельца 2026-09-20)
Статус: ПРИНЯТО 2026-09-20 (решение владельца в разговоре; обсуждено с интегратором
до утверждения; номер выдан интегратором). Реализация — половина есть, половины нет,
и это разные половины: возврат ref T над неподтверждённым T нынешний компилятор
УЖЕ принимает (§2 — норма догоняет код), а диагностики избыточного ro при ссылке
(E_REDUNDANT_REF_RO) нет ни у кого.
Амендирует: ревизию D326 (Plan 184) —
снимает запрет Р1 РОВНО В ОДНОЙ позиции (возврат) и фиксирует запись изменяемости у
ссылки, которой Р-184 не задал. Соседи по смыслу:
D216 (та же пара
умолчаний у указателя), D176 (ro T как тип-модификатор),
D246 (три оси).
0. Откуда взялся вопрос
Разбиралась дверь доступа к сырому указателю. Замер показал тихую коллизию: у типа со
своим методом fn Pt @read() вызов p.read() через указатель *Pt отдаёт ВСТРОЕННЫЙ
примитив указателя, а не метод типа, и диагностики нет (проба 2026-09-20: на значении
p.read() даёт 777, через указатель — копию Pt). Обсуждался вынос примитивов в
свободные функции — и там немедленно потребовалась подпись вида
fn ptr_read[T](p *T) -> <ссылка на T>, то есть ответ на вопрос, КАК ссылка записывается
и законна ли она над параметром типа. Оба ответа в спеке отсутствовали.
1. Норма записи
| запись | смысл |
|---|---|
ref T | ссылка на цель; сквозь неё только ЧИТАЮТ |
ref mut T | ссылка на цель; сквозь неё пишут |
ro ref T / ref ro T | ошибка E_REDUNDANT_REF_RO — «избыточно, пишите ref T» |
Довод 1: у ссылки ОДНА ось, и вторую спека закрыла явно. Р2 ревизии D326: ссылка
«непереселяема (как C++ T&: связал — навсегда; переприсваивания ссылки на другой
сторадж нет)». Значит изменяемость может относиться только к ЦЕЛИ, и слово стоит рядом с
целью. mut ref T обещало бы изменяемость самой ссылки — не просто ось, которой нет, а
ось, ПРОТИВОРЕЧАЩУЮ норме.
Довод 2 (решающий, формулировка интегратора): одна грамматика умолчания на две соседние
формы. У указателя уже *T / *mut T — модификатор ПОСЛЕ маркера и относится к
указуемому, а *ro T — HARD ERROR E_REDUNDANT_POINTER_RO («избыточно: *T»). Обратный
порядок у ссылки завёл бы в языке две разные грамматики умолчания для двух соседних форм:
читатель, выучивший одну, будет ошибаться во второй ровно в тот момент, когда перестанет
сверяться со спекой.
Довод 3: написание ro ref / mut ref УЖЕ ЗАНЯТО — как образец СНЯТОЙ формы. Оно
живёт в дереве только отрицательными фикстурами про удалённую форму параметра
(spec_tests/conformance/neg/d326_param_form_mut_ref_neg.nv:7,
d326_param_form_ro_ref_neg.nv:7, обе под E_REF_PARAM_FORM_REMOVED, Р3/Р10). Назвать
тем же написанием ЗАКОННЫЙ тип значит сделать текст той диагностики двусмысленным: одна и
та же последовательность слов в параметре — ошибка, в возврате — норма. Это не эстетика,
а будущий ложный след для того, кто будет читать отказ.
Почему код диагностики ОТДЕЛЬНЫЙ, а не E_REDUNDANT_POINTER_RO: диагностика обязана
называть СВОЙ предмет. Пользователь написал ro ref T, а сообщение сказало бы ему про
указатель — он пойдёт искать указатель в своей строке и не найдёт. В документации оба
кода стоят рядом как один класс «модификатор, ничего не добавляющий».
Видимый контрпример mut ref Self из таблицы Р7 контрпримером НЕ является. По Р14
ресивер — нулевой параметр с осью {ro, mut, consume}, по Р3/Р10 изменяемость параметра
пишется РЕЖИМОМ (mut x T), а не словом в типе. В ячейке таблицы склеены столбец режима и
тип; отдельного ТИПА mut ref T в языке нет.
2. Возврат ссылки из обобщённой функции ЗАКОНЕН
Р1 ревизии D326 перечисляет запрещённые позиции и заканчивается словами «реджектится
только ref <value/generic/unknown>» — то есть ссылка над ПАРАМЕТРОМ ТИПА считалась
запрещённой везде, включая возврат.
Компилятор её принимает, и это замер, а не мнение: fn pick[T](mut x T) -> ref T => x
собирается и работает (проба 2026-09-20, nova-cli/target/release/nova build, печатает
a=1). При ro-источнике отказ приходит, но ПО ДРУГОМУ правилу — E_READONLY_COERCE
(ro-источник под не-ro возвратом), а не E_REF_TYPE_POSITION.
Норма выбирает сторону кода: возврат ref T законен, в том числе когда T —
параметр типа, чей класс хранения станет известен лишь с инстанциацией (проверка позиции и
так идёт ПОСЛЕ нормализации ref H ≡ H, Р1/Р6). Снимается РОВНО ОДИН случай.
РАБОТА, КОТОРУЮ ЭТА НОРМА СОЗДАЁТ, И ОНА НАЗВАНА ЗАМЕРОМ, А НЕ ПРЕДПОЛОЖЕНА. Проба 2026-09-20, две половины:
fn edit[T](mut x T) -> ref mut T => x— принимается (PASS: 1 FAIL: 0);fn peek[T](x T) -> ref T => x— отвергаетсяE_READONLY_COERCEс доводом «источник связан какro, но объявленный тип возврата НЕro».
Второй отказ ПРАВИЛЕН ПО СУЩЕСТВУ и неверен по формулировке (поправка владельца
2026-09-20, принята: «возврат ссылки на локальную переменную невозможен»). ro-параметр
по Р3 имеет представление, ВЫБИРАЕМОЕ КОМПИЛЯТОРОМ, — «копия ≤ порога R3, 24 Б либо скрытая
ссылка»; значит x внутри peek может быть КОПИЕЙ, и -> ref T вернул бы ссылку на
хранилище, умирающее с вызовом. У mut x T этого нет по Р3 же: mut-параметр — «in-out
ссылка ВСЕГДА», место принадлежит вызывающему, и -> ref mut T законен — потому вторая
половина пробы и зелена.
Отсюда правило, которое §2 НЕ снимает: возвращать ref можно только на место,
которое переживает вызов, — приёмник (-> @, Р7), его проекция, или место, названное
АРГУМЕНТОМ-УКАЗАТЕЛЕМ. Последнее и есть случай двери из §0: в ptr_read[T](p *T) -> ref T
ссылка указывает не на параметр, а на то, что адресует p, и потому не висячая.
Что здесь ДЕФЕКТ — не отказ, а его текст: E_READONLY_COERCE объясняет отказ
ro-отмыванием («источник связан как ro, а возврат не ro»), тогда как настоящая
причина — время жизни, и семья у неё другая (E_REF_ESCAPE, Р8). Автор, прочитав
подсказку, объявит возврат -> ro T и получит копию, так и не узнав, что ссылку здесь
нельзя было вернуть вовсе. Диагностика обязана называть свой предмет — тот же класс, что
E_REDUNDANT_REF_RO из §1.
Что остаётся запрещённым, без изменений: ref в полях record/value, в коллекциях
(Vec[ref T]), в суммах, в Option[ref T], в тип-аргументах дженериков (f[ref T],
size_of[ref T]) и в параметрах (формы сняты Р3/Р10). Диагностика прежняя —
E_REF_TYPE_POSITION.
Почему это безопасно без лайфтаймов — доводом, а не надеждой: вся страховка уже
написана для -> @ и работает. Р7 даёт -> @ конкретный тип (-> ref Self у стекового,
-> Self у кучевого); Р5 — автоконверсию ref T -> T при чтении, поэтому биндинг
результата КОПИРУЕТ (ro r = obj.method() в таблице D181); Р8 — запрет утечки в кучу,
escape-анализ, запрет захвата ссылок замыканиями/spawn/parallel, «только синхронный
lifetime вызова». Возврат из свободной функции отличается от -> @ лишь тем, что место
приезжает из аргумента, а не из приёмника.
2а. Локальная переменная-ссылка ЗАПРЕЩЕНА (решение владельца 2026-09-20)
Р1 ревизии D326 разрешал локал-алиас (ro y ref T = x.a.b / mut y ref T = …). D480
это снимает: объявить локальную переменную типа ref T нельзя. Ссылка остаётся типом
возврата и типом приёмника (@ у стекового типа), но именем её не связывают.
Почему сняли, и довод не эстетический. Форма не нужна ни одному живому потребителю:
у кучевого типа обычная переменная и так псевдоним, а у value-типа остаётся запись пути
целиком. Замер 2026-09-20 по std/src, novac/src, spec_tests, examples: носителей
ТРИ строки, все в spec_tests/conformance/d326_reflocal.nv. Цена запрета — одна фикстура,
и дешевле она уже не будет.
Запрет снимает и спор о том, ГДЕ пишется изменяемость ссылки. Пока локал существовал,
одно свойство писалось в двух местах — биндингом у локала (mut y ref T) и типом в
возврате (-> ref mut T), отчего ref T значил разное по позициям. Без локала место
остаётся одно: ТИП. Никакого правила про приоритет биндинга не нужно.
ЗАПИСЬ, ЕСЛИ ФОРМА КОГДА-НИБУДЬ ВЕРНЁТСЯ — предложение владельца, зафиксировано здесь,
чтобы не изобретать заново: ref x = a.b.c — чтение, ref mut x = a.b.c — запись
сквозь. Слово ref встаёт НА МЕСТО ro/mut, а не рядом с ними: пара ro y ref mut T
читается противоречиво, а ref x — нет. Это предложение, НЕ норма: сегодня форма
запрещена целиком.
ВТОРОЕ ПРЕДЛОЖЕНИЕ НА БУДУЩЕЕ, того же дня и того же автора: писать ссылку в семье
указателя — *ref T и *ref mut T вместо ref T / ref mut T. Тогда у косвенности
один маркер * и одно правило умолчаний на все формы. Не принято сегодня по одному
доводу: * в нынешнем языке читается как «сырой указатель», и отдавать тот же знак
безопасной форме — стирать различие, которое эта же пара блоков только что укрепила.
Записано, чтобы решение можно было пересмотреть с доводом, а не начинать спор заново.
3. Решение принято ДО носителей — и это часть решения, а не примечание
Замер 2026-09-20 (греп *.nv по std/src, spec_tests, examples,
nova-p274/novac/src): живых носителей ro ref T / mut ref T В ТИПЕ — НОЛЬ; голого
типа ref T в std/src — НОЛЬ; единственные вхождения — две отрицательные фикстуры из
довода 3 выше.
Отсюда две вещи, и вторая важнее. Первая: цена выбора сегодня НОЛЬ, мигрировать нечего. Вторая: выбор НЕ ПОДТВЕРЖДЁН ПРАКТИКОЙ — решение принято на чистом поле, и первый же реальный носитель может показать неудобство, которого мы не предвидели. Это не довод против; это записано здесь, рядом с самим правилом, чтобы через месяц никто не читал его как выстраданное практикой.
4. Связь
- Ревизия D326 (Plan 184) — амендируется (§2); Р2/Р5/Р7/Р8/Р14 цитируются и НЕ меняются.
- D216 — пара умолчаний
*T/*mut TиE_REDUNDANT_POINTER_RO, с которой §1 приводится к симметрии. - D176 —
ro Tостаётся тип-модификатором и в возврате (живой носитель —str @bytes() -> ro []u8,std/src/runtime/string/core.nv:76); запрещается неroв типе, аroПРИ ССЫЛКЕ, где он ничего не добавляет. - D181 —
-> @, чья страховка переиспользуется §2.
D483. Сырой указатель: авторазыменование снято, доступ к цели постфиксом p*. (решение владельца 2026-09-20)
Статус: ПРИНЯТО 2026-09-20 (решение владельца в разговоре; номер выдан интегратором,
бронь записана в spec/decisions/ISSUED-NUMBERS.md). Реализации нет.
Амендирует: D216 §5 «Auto-deref» — раздел заменяется целиком. Соседи: D480 (та же семья косвенности), реестр 221.1 №1177 (тот же §5 показывает ретрактированные формы).
1. Что меняется
| было (D216 §5) | стало |
|---|---|
p.field — авторазыменование на один уровень | p*.field |
p.method() — авторазыменование | p*.method() |
p.read() — примитив указателя | без изменений |
Методы самого указателя остаются на p. — read, write, read_at, offset,
dist, copy_from и прочие из таблицы амендмента 174.5. Правила ro/mut тоже не
меняются: p*.field = v требует *mut T, вызов mut-метода цели — тоже
(E_POINTER_RO_ASSIGN, E_POINTER_RO_MUT_METHOD).
2. Почему — замер, а не вкус
Авторазыменование и методы на указателе вместе дают ОДНОЙ точке два разных смысла: член цели и операция над указателем. Пока имена не пересекаются, это незаметно; как только пересеклись — выбор делается молча и в пользу указателя.
Замер 2026-09-20. Тип Pt со своим fn Pt @read() -> int => 777: на значении
p.read() даёт 777, через указатель ptr.read() — копию Pt из примитива. Ни
ошибки, ни предупреждения. То есть добавление метода СВОЕМУ типу молча меняло смысл
далёкого чужого вызова.
После правки выбирать нечего: p.m() — всегда метод указателя, p*.m() — всегда
метод цели. Класс закрыт формой, а не бдительностью.
Почему не «сделать совпадение ошибкой» (вариант, который рассматривали и отвергли): он ловит столкновение, но оставляет действие на расстоянии — автор, добавив метод своему типу, краснит чужой сайт вызова, который про этот тип ничего не знает. Постфикс убирает саму возможность столкновения.
Прецедент — Zig (p.*), и по той же причине: там у указателя тоже есть операции, и
коллизии не возникает именно потому, что разыменование пишется явно. В Go и C# обратный
путь — авторазыменование есть, но у указателя НЕТ методов; оба решения рабочие, наш
случай — первый из них, потому что примитивы указателя нам нужны.
3. Парсер: неоднозначности с умножением НЕТ, и это проверено
Возражение очевидное: * уже занят префиксом типа (*T, *mut T) и бинарным
умножением, а p* в позиции выражения похоже на начало p * x.
Постфикс — ЦЕЛЬНАЯ ЛЕКСЕМА, пишется слитно (уточнение владельца 2026-09-20). Её
форма: одна или несколько звёзд подряд и точка — *., **., ***., — без пробелов
внутри. Формы p * .field и p* .field невалидны: это не другая запись того же, а
синтаксическая ошибка.
Сказать «*. — один токен» было бы неточно (поправка владельца того же дня), и
неточность не косметическая: звёзд может быть больше одной, см. §3а. Точное правило —
«серия * длиной N, сразу за ней .»; лексер берёт её целиком.
Отсюда неоднозначности нет уже на уровне лексера: p * x содержит одиночный * с
операндом справа, а p*.field — цельную лексему *.. Разбор не зависит ни от одного
правила вида «посмотри, есть ли пробел».
3а. Несколько уровней: одна звезда на уровень
**T — законный тип (указатель на указатель; проверено 2026-09-20: &p от указателя
собирается). Доступ к члену цели через N уровней пишется N звёздами:
p*.field // p : *T
p**.field // p : **T
Почему не промежуточный шаг (p.read()*.field, который тоже работает и копирует
указатель, а это дёшево): он заставляет писать РАЗНЫЙ код для одного и двух уровней,
хотя действие одно. Счёт звёзд читается ровно как счёт уровней, и это единственное, что
здесь надо помнить.
Прежнее авторазыменование снимало РОВНО ОДИН уровень и для **T не помогало вовсе —
то есть многоуровневый случай не «усложнился», а впервые получил запись.
Вторая страховка, проверенная на нынешнем компиляторе 2026-09-20: даже если бы токены
делились по-старому, точка не может начать операнд — литерала с ведущей точкой в языке
нет, x * .5 отвергается как unexpected '.' in expression (грамматика требует цифру
перед точкой: 1.5, 1_234.567_89).
Голый постфикс p* без точки формой НЕ является. *p остаётся ретрактированным
(E_POINTER_OP_USE_METHOD, амендмент 174.5), и D483 его не воскрешает: значение целиком
берут методом p.read(), а p*. существует только как начало доступа к члену.
4. Что обязан сделать реализующий
- Снять авторазыменование:
p.fieldна указателе — ошибка с подсказкойp*.field(новый код, имя предлагаетсяE_POINTER_AUTODEREF_REMOVED). - Завести в лексере цельную лексему «N звёзд подряд и точка» (
*.,**., …) и ввести постфикс в грамматику выражения;p * .fieldпри этом остаётся ошибкой разбора, а не альтернативной записью. Число звёзд обязано сверяться с числом уровней указателя — лишняя звезда на*Tэто ошибка, а не молчаливый no-op. - Переписать D216 §5 целиком (там же живут три ретрактированные формы — реестр №1177, чинится тем же слиянием).
- Мигрировать носителей. Число не измерено: места авторазыменования грепом не отличить от обычного доступа к полю, признак — тип приёмника, поэтому счёт снимает компилятор после шага 1, а не человек заранее.
Приёмка: фикстура с типом, у которого есть метод read, доказывает, что p.read() и
p*.read() — РАЗНЫЕ вызовы; негатив на p.field с точным кодом; проба в обе стороны;
корпусный диф C пуст на местах, переписанных механически.
D482 (ex-D478, renumber 2026-09-20). Умолчания только в хвосте (решение владельца 2026-09-14)
Статус: ПРИНЯТО 2026-09-14 (решение владельца в разговоре; номер назначен интегратором). Реализации НЕТ ни у одного компилятора: сегодня обе формы принимаются молча.
Отменяет: правило о порядке полей в амендменте D215 (раздел «Семантика», до этого блока — строки 13780–13781). Это именно отмена явного разрешения, а не заполнение пробела: спека о порядке не молчала, она говорила обратное.
Что
Поле или параметр со значением по умолчанию стоит только ПОСЛЕ всех обязательных — одинаково у функций, методов и именованных кортежей.
type GoodOrder(a int, b int = 3) // OK
type BadOrder(a int = 7, b int) // E_TUPLE_DEFAULT_BEFORE_REQUIRED
fn f(x int, y int = 0) // OK
fn g(x int = 0, y int) // compile error (D102 «Правило — объявление» п.1)
Для функций правило уже нормативно с самого
D102:
«Параметры с дефолтом идут после параметров без дефолта. fn f(x int = 0, y int)
— compile error». D482 распространяет его на поля именованного кортежа, где амендмент
D215 разрешал чередование.
Диагностика — E_TUPLE_DEFAULT_BEFORE_REQUIRED. Негативную фикстуру пишет тот, кто
чинит, по общему правилу (новый E_* требует фикстуры с line-pinned nova:expect).
Почему
1. Это не новое ограничение, а инвариант, на который проверка уже молча
рассчитывала. Проверка арности амендмента D215 судит КОЛИЧЕСТВО аргументов:
min_arity — число обязательных полей, max_arity — всего полей, выход за окно —
E_TUPLE_CONSTRUCT_ARITY_MISMATCH. Счёт отличает связанные поля от несвязанных только
пока умолчания идут хвостом. При чередовании вызов BadOrder(5) даёт
1 ∈ [min_arity=1, max_arity=2] — счёт сходится, позиционный аргумент уходит в поле
С УМОЛЧАНИЕМ, а ОБЯЗАТЕЛЬНОЕ остаётся несвязанным и молча зануляется
(BadOrder(5) → a=5, b=0; реестр 221.1 №1096).
2. Одно правило на все три формы. Разный порядок у функций и у кортежей пришлось бы запоминать отдельно, а причина запрета у них общая.
Цена отмены — ноль, и вот как это измерено
На день принятия чередованием не пользовалось ни одно из 141 скобочных
объявлений в novac/src, std/src, spec_tests, examples (29 из них несут
умолчания). То есть отмена не ломает ничего из написанного.
История мерки приведена намеренно, потому что число «ноль» дорого стоит ровно в той мере, в какой известно, что считали весь предмет. Мерка врала трижды и каждый раз МОЛЧА:
)внутри комментария обрубал тело объявления — одно ложное попадание (type FloatSpec(...));- имя типа искалось глобально, и вызовы одного
Pairприписывались объявлению другогоPairиз чужого модуля; - якорь
^typeне виделexport type— а именно так объявленstd/src/math/complex.nv:50.
Прежние числа этой же мерки — 97 и 112 — были взяты по НЕПОЛНОЙ выборке и
сняты. Замечание общего свойства: первоначальное утверждение «спека о порядке
МОЛЧИТ» тоже было результатом односоставного поиска: искали РУССКИЕ слова
(«умолч», «порядок»), а абзац говорит required/optional по-английски.
Утверждение об ОТСУТСТВИИ правила нельзя доказать поиском по ОДНОМУ
написанию предмета.
Область
Правило нормативно для обоих компиляторов. Порядок работ назначает интегратор: ни одно объявление в деревьях правила не нарушает, поэтому отсутствие проверки ничего не ломает сегодня и не требует срочной работы.
Чего этот блок НЕ решает
Отдельный вопрос — можно ли передавать поле С УМОЛЧАНИЕМ ПОЗИЦИОННО. Здесь расходятся ТРИ стороны, и вопрос ждёт решения владельца:
- D102 «Правило — вызов» п.1: параметр с дефолтом — keyword-only, позиционно — compile error; и этот файл (амендмент D215) прямо называет D102 адресом конструирования именованного кортежа;
stdделает обратное в 126 вызовах из 14 файлов (std/src/math/int128.nv— 18,complex_test.nv— 22,float_parse/convert.nv— 12);- в корпусе лежит ЗЕЛЁНАЯ фикстура
spec_tests/conformance/m_ice_named_tuple_positional_defaults_pos.nv, пинающая позиционную передачу как «verified-non-bug».
Третья сторона важнее первых двух: кто-то уже принял решение и закрепил его фикстурой, не изменив спеку.
Связь
- D102 — то же правило для функций, источник формулировки.
- D52 — «Construction всегда требует все обязательные поля»: правило вызова, которое здесь нарушается молча.
- Реестр 221.1 №1096 — дефект, на котором это вышло наружу, и обе его двери.
D488. Размещение и передача значения — три правила (2026-10-01) {#d488}
Решение владельца 2026-10-01 (вопросы облачной сессии
p274-carina-sweepи ход интегратора; порог — решение владельца 2026-08-08, план 172.15 п.4). Это ЕДИНСТВЕННЫЙ дом правила. Блоки D32, D55 (Str →ro []u8), D176, D215, D228 (и его амендмент «escape & auto-promote»), D326 (R3/R5), D429 R2, D473, D477 несут указатель сюда: где их текст расходится с этим блоком, действует этот блок.
Что
Где живёт значение и как оно передаётся — три правила, без исключений по виду значения:
- Где живёт — решает убегание, а не размер. Значение размещается на стеке, если оно не переживает кадр, в котором создано: не кладётся в поле кучевого объекта, не захватывается замыканием, не возвращается дальше владельцу. Иначе — на куче. Большое неубегающее значение живёт на стеке вызывающего (возврат в его буфер, D473); куча — только для убегающего.
- Как передаётся
ro— решает размер.ro-параметр, приёмникro @и результат-> ro T: до трёх машинных слов включительно (24 байта на 64-bit) — копией; больше — указателем на место у вызывающего. Программа разницы не видит. mut— всегда указателем, любого размера:mut-параметр, приёмникmut @. Иначе изменение не увидит вызывающий.
Следствие для вида без копии (str @bytes() -> ro []u8, полоса view D429 R2, D55 «Str → ro []u8»):
заголовок вида не больше трёх слов и не убегает — он живёт на стеке вызывающего и не аллоцируется ни в одной
позиции, включая аргумент (write(f, s.bytes())) и промежуточное значение; данные остаются в буфере строки.
Временный слот под аргумент и промежуточное значение компилятор заводит сам (периметр D473 §3 расширен).
Правило
- Порог — ОДНА именованная константа компилятора на все места, где решается передача (параметр,
ro @,-> ro T); замер может её повысить, но не понизить (план 172.15 Ф.1-бис). Числом в выражении порог не пишется. - Настройка порога (ключ сборки), если появится, — одна на всю программу: разные пороги у частей одной программы дают несовместимую передачу значений между ними; оракул и Карина применяют одно значение (Карина вызывает функции оболочки, которую выпускает оракул).
- Размер не решает место (правило 1): «порог размера как признак размещения» запрещён (D473).
Почему
- Правило 1 — анализ убегания Go: куча нужна только тому, что переживает кадр; большое неубегающее значение дешевле держать у вызывающего, чем аллоцировать и отдавать сборщику мусора.
- Правило 2 — косвенная передача больших значений Swift: копия до трёх слов дешевле указателя, больше — дороже. В Go значение копируется всегда, а указатель пишет программист — мы ближе к Swift.
- Правило 3 —
inoutSwift,&mutRust: изменение обязано быть видно вызывающему. - Порог «три слова» — выбор владельца «как в Swift»; у Swift граница зависит от платформы (обычно около четырёх слов).
Что заменено
- D326 R3: порог «
~2*sizeof(ptr)(≈16B)» → три слова; R3/R5 читаются через этот блок. - D228 «Method receiver
@: pointer на stack-slot» — оmut @;ro @— по правилу 2. - D473 §3: пометка «не дефект» для позиции аргумента (
write(f, bytes(lit))зовёт аллоцирующую обёртку) снята — это дефект, ведущий к реализации следствия выше. - Открытый вопрос
Q-value-abi-auto-placement— решён этим блоком. - Уточнены (указатель под заголовком, правило читается через этот блок): D32 (передача параметров), D55 (Str →
ro []u8), D176 (ro T), D215 (приёмник именованного кортежа), D429 (полоса view), D477 (размещение суммы).
Реализация
План 172.15: Ф.1-бис (одна константа, 24 Б — оракул), следствие для вида без копии (D473 §3), Карина — R3/R5 тем же порогом.
D489. Единое правило литерала — тип позиции, если значение представимо точно (2026-10-02) {#d489}
Уточнено D491 (2026-10-02): это правило — о ЛИТЕРАЛАХ; значение с числовым типом не меняет тип неявно нигде (D491), рукава без записанного типа — одного типа.
Решение владельца 2026-10-02, уточнение того же дня: «
2.0— это не литерал без типа, это литерал с типом». Это ЕДИНСТВЕННЫЙ дом правила. Блоки D44, D54 (символьный литерал), D55 (пункт 4 и амендмент 2026-09-04), D227, D405 (литерал-операнд) и D486 §2 (литерал в образце) несут указатель сюда: где их текст расходится с этим блоком, действует этот блок.
Правило
Литерал — и нетипизированное константное выражение из литералов (40 + 60, -1) — не имеет типа, пока
его не даст позиция. В позиции типа T он принимает T, если его ТОЧНОЕ значение представимо в T; иначе —
ошибка компиляции, называющая литерал, тип и ближайшую законную запись. Неявного сужения, обрезания и
округления, меняющего ЗАПИСАННОЕ число, нет. Без позиции — int для целого и f64 для дробного (D44).
Род литерала задаёт его ФОРМА. Внутри рода тип выбирает позиция; между родами литерал не переходит:
| форма литерала | куда идёт без as | иначе |
|---|---|---|
целый (без точки и экспоненты: 1, -1, 0x0A, 1_000) | любой целый тип, если в диапазоне; f32/f64, если представим ТОЧНО | E_LIT_OUT_OF_RANGE; во float — E_LIT_INEXACT (16777217 в f32, 9007199254740993 в f64) |
дробный (с точкой или экспонентой: 1.5, 2.0, 1e10) | только f32/f64; округление к ближайшему законно — это природа десятичной дроби (0.1 неточен и в f64) | в целом типе — ошибка типов (E7301): 2.0 в int — нет; переполнение до бесконечности — E_LIT_OUT_OF_RANGE (1e40 в f32) |
символьный ('a') | только char | в целое — только явно, 'a' as u8, если кодовая точка влезает (D54, амендмент 2026-10-01 п.2) |
строковый, true/false | str, bool | число из bool — только as (D54) |
Где действует
Везде, где позиция знает тип: объявление с типом, аргумент, возврат, поле записи, элемент вектора, константа
const C T = …, newtype над числом (по правилу базового типа, D55 амендмент 2026-08-21), ОБРАЗЕЦ match
(тип даёт скрутини: 1 => над f64 законно, 'a' => над u8 — нет) и второй операнд сравнения или
арифметики (x == 1, x + 1 при x u8; x == 300 при x u8 — E_LIT_OUT_OF_RANGE, а не вечное false).
Литерал в позиции суммы с несколькими числовыми вариантами — тип по умолчанию D44, см. D55
«Obvious single-wrapper coercion» (реестр 221.1 №1637).
Составной литерал — тоже без типа до места, рекурсивно (решение владельца 2026-10-02: «даже для таких: {a: 4, b: 10}, [3, 10, 5], [a: 9, b: 4] — всё разворачивается в нужный тип при присваивании»). Запись или вариант берут тип места (D55), вектор — тип элемента для каждого элемента (D44), словарь — типы ключа и значения (D108); каждый вложенный литерал — по этому же правилу: [1, 300] в []u8 — E_LIT_OUT_OF_RANGE на 300.
Рукав match и ветвь if. Если тип результата записан (ro r int = match …, возврат, аргумент), каждый
рукав — позиция этого типа, и литерал в нём берёт этот тип. Если не записан, рукава обязаны быть одного типа
(D491): литерал-рукав берёт тип соседа-значения и проверяется по диапазону —
ro r = match o { Some(v) => v None => -1 } при o Option[u32] есть E_LIT_OUT_OF_RANGE (пишется
ro r int = match o { Some(v) => v as int None => -1 }). Сводка рукавов к «более широкой стороне» (D433 R1)
отозвана 2026-10-02.
Почему
Целое число, записанное в коде, точно; молча заменить его соседним float-значением значит выполнить другую
программу, чем написана. Десятичная дробь точной не бывает по природе записи, поэтому для неё мерой служит
конечность, а не точность. Форма литерала — слово автора: точка говорит «дробное», кавычки — «символ», и
позиция их не переписывает. Доноры: Go (нетипизированная константа, точное значение сверяется с целью;
отличие — Go молча округляет целую константу во float32 и пускает 2.0 в целое), Rust и Swift (2.0 в
целое — ошибка; символ в число — только явно).
Что заменено
- D55 пункт 4 и амендмент 2026-09-04 — целое во float шло без границы точности; граница добавлена.
- D486 §2 (амендмент 2026-10-01) — «целый — только над целым типом» заменено строкой таблицы: целый литерал
над
f32/f64законен, если точен. - Уточнены (указатель под заголовком, правило читается через этот блок): D44 (числовые литералы), D54
(символьный литерал в число — по-прежнему
as), D227 (вывод и диапазон числового литерала), D405 (литерал-операнд), D433 (литерал-сентинел в рукавеmatch).
Статус на 2026-10-02 (28 проб на оракуле main)
Совпадают: целое в целый тип во всех позициях; E_LIT_OUT_OF_RANGE у 300/-1 в u8 и в элементе вектора;
2.0 в int и 'a' в u8 отвергаются; 1 => над f64 принят. Расходятся: 16777217 в f32 даёт
16777216, 9007199254740993 в f64 даёт …992, 1e40 в f32 даёт inf — все без ошибки (реестр 221.1
№1593). Карина — по дифференциалу с оракулом после его фикса.
D491. Значение числового типа не меняет тип неявно — нигде (2026-10-02) {#d491}
Решение владельца 2026-10-02. «Может нам просто запретить расширение типа чисел?» — да: подтверждает решение 2026-09-04 («везде одинаковый тип», D405) и распространяет его со всех операторов на все места. Это ЕДИНСТВЕННЫЙ дом правила о ЗНАЧЕНИЯХ. D405, D54, D55, D433 и D489 несут указатель сюда. Литералы — другое правило: у литерала нет типа, пока его не даст место (D489).
Правило
Значение, у которого ЕСТЬ числовой тип (переменная, поле, результат вызова, выражение над такими), никогда не
меняет этот тип само — ни расширением (u8 → u16, u32 → int, i32 → i64, f32 → f64), ни сужением,
ни сменой рода (целое ↔ float). Смена типа пишется явно: as (D54) или проверяемая форма (D430).
| где | что требуется |
|---|---|
| позиция с записанным типом — объявление, аргумент вызова, возврат, поле записи, элемент коллекции | тип значения совпадает с типом позиции; иначе ошибка, подсказка — форма с as |
бинарный оператор (+ - * / %, битовые, сравнения, ==) | оба операнда одного типа (D405 и амендмент 2026-09-04, E_MIXED_WIDTH_ARITH) |
рукава match, ветви if | с записанным типом результата — каждый рукав позиция; без него — один тип у всех (E_MATCH_ARM_WIDTH_MISMATCH); сводка к «более широкой стороне» (D433 R1) отозвана |
| обёртка значения в вариант суммы / newtype (D55) | числовой тип payload совпадает точно |
Чего правило НЕ касается
- Литералов — скалярных и составных (
{a: 4, b: 10},[3, 10, 5],[a: 9, b: 4]): у литерала нет типа, он берёт тип места, если точное значение представимо (D489, D55, D44, D108). - Пар
#coerce(D429): механизм открыт для любых типов (R8), и числовые пары МОГУТ быть объявлены в std; на 2026-10-02 их нет. Объявленные — действуют только по правилам D429: в позициях (R6), точное совпадение важнее (R5), без обобщений (R16) и цепочек (R4), никогда в операторах. Это решение о библиотеке, не о языке: встроенного неявного расширения в компиляторе нет. as— явное приведение, его таблица — D54.
Почему
Одно правило без исключений: число никогда не меняет тип само, а литерал — не число с типом. Ловушка C
(u32(4294967295) == i32(-1) — истина) закрыта для всех мест, а не только для операторов; Карине не нужна
логика расширения. Цена — as на границах (f(x as int) при x u32); её объём замеряется (реестр 221.1 №1608),
и если он велик — ответ библиотечный (пары #coerce в std), а не языковой. Доноры — Rust, Go, Swift: значения
разных числовых типов не смешиваются нигде без явного приведения.
Что заменено
- D433 R1 — сводка рукавов к более широкой стороне: отозвана (R2 остаётся и покрывает любое расхождение).
- D55 — «int-семья взаимозаменяема при обёртке в вариант»: снята, числовой тип сверяется точно.
- D489 — фраза о рукавах без записанного типа: переписана по этому блоку.
- Уточнены (указатель под заголовком): D405 (правило «один тип» распространено с операторов на все места), D54 («неявных числовых конверсий нет нигде» — подтверждено и получило дом).
Статус на 2026-10-02 (замер D:\Temp\litprobe\run1597.py и помощник nova-88)
Оракул расходится: принимает без as u32 → int, u8 → u16, i32 → i64 в присваивании, аргументе, возврате;
u32 + int в операторе; сводит рукава по D433 R1 (с №1595 — по общему типу); и молча усекает значение f64 в
целую позицию (№1611, К1). Перевод оракула и корпусов — №1608; Карина — №1609.