Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Введение

Aura — конфигурационный язык, который компилируется в JSON/YAML/TOML. Он создан вокруг трёх обещаний:

  1. Детерминизм по построению. Два запуска дают побайтно одинаковый результат. В языке нет now(), случайности и неявного доступа к окружению — всё внешнее (файлы, переменные среды) приходит только через явные права, выданные при запуске.
  2. Безопасность цепочки поставок. Импортированный пакет физически не может читать ваши файлы и переменные окружения — capability-модель в стиле Deno, но строже: даже вызов экспортированной функции пакета выполняется с правами пакета, а не вашими.
  3. Валидация до деплоя. Схемы с типами, assert-инварианты, статический анализ мёртвого кода — ошибки конфигурации ловятся при сборке, а не в проде.

Как это выглядит

type Service
  name: String
  port: Int
end

base_port = 8000 # приватное вычисление
is_prod   = env("APP_ENV", "dev") == "production"

api: new Service # свойство — попадает в вывод
  name: "api"
  port: base_port + 1
end

replicas: is_prod ? 3 : 1
assert base_port > 1024, "privileged ports are not allowed"
aura eval app.aura --allow-env=APP_ENV

Кому это нужно

  • DevOps/SRE: манифесты k8s, CI-матрицы, конфиги сервисов — без копипасты YAML и с проверкой схем до kubectl apply.
  • Платформенным командам: публикуемые пакеты стандартов (pub def/pub type) с версиями и integrity-локом.
  • Разработчикам приложений: одна .aura-декларация → JSON/YAML/TOML для любого потребителя; для Rust — прямое встраивание библиотекой.

Где что лежать

  • Эта книга — учебник и справочник для пользователя языка.
  • SPEC.ru.md — формальная спецификация для разработчиков реализации.
  • examples/ — работающие примеры с ожидаемыми выводами.

Основная версия книги — английская. Русская публикуется под /book/ru/ и является полным переводом; при расхождении верна английская.

Быстрый старт

Установка

Быстрее всего попробовать язык, ничего не устанавливая: playground запускает настоящий компилятор прямо в браузере.

Бинарник:

cargo install aura-lang

Либо готовая сборка со страницы Releases — Linux (gnu и статический musl), macOS (Intel и Apple silicon), Windows, к каждой .sha256. На x86_64 Linux предпочтительна musl-сборка: она статическая, порога glibc нет.

В GitHub Actions:

- uses: aura-config/setup-aura@v1
- run: aura check deploy.aura --strict

Сборка из исходников:

git clone https://github.com/aura-config/aura-lang && cd aura-lang
cargo build --release
# бинарник: target/release/aura

Первый манифест

Создайте hello.aura:

app_name = "hello"
port     = 8080

service:
  name:     app_name
  url:      "http://localhost:#{port}"
  replicas: 2
end
$ aura eval hello.aura
{
  "service": {
    "name": "hello",
    "url": "http://localhost:8080",
    "replicas": 2
  }
}

Обратите внимание: app_name и port (объявленные через =) в JSON не попали — это приватные вычисления. В вывод идут только свойства (имя:) и блоки. Подробнее — в следующей главе.

Три команды, которые нужны каждый день

aura eval app.aura            # вычислить → JSON в stdout
aura check app.aura --strict  # проверить без вычисления (линтер; для CI)
aura fmt app.aura             # канонизировать отступы

Структура без боли

Aura не использует значимые отступы (травмы YAML лечатся здесь). Структуру задают переносы строк и явное end:

server:
  http:
    port: 8080
    timeouts:
      read: "30s".parse_duration()
    end
  end
end

Отступы — только для людей; aura fmt приводит их к канону автоматически и гарантированно не меняет смысл (поток токенов до/после идентичен).

Вычисления и вывод: = против :

Главное правило Aura, отличающее её от YAML/JSON (аналог locals vs outputs в Terraform):

ФормаСмыслВ выводе?
x = exprприватная переменнаянет
key: exprсвойствода
domain "name" ... endблокда (ключ = имя)
base = 8000     # вычисление
port: base + 80 # вывод: {"port": 8080}

Благодаря этому анализ мёртвого кода точен: неиспользуемая =-переменная — всегда настоящий мусор (W0501), а не «может, это чей-то вывод».

Иммутабельность

Повторное присваивание в одной области — ошибка:

x = 1
x = 2 # E0301: variable 'x' is immutable

Явное затенение

Переопределить внешнюю переменную во вложенном блоке можно только явно:

log_path = "/var/log/app.log"

domain "debug"
  log_path        = "/tmp/debug.log" # E0302: требуется shadow
  shadow log_path = "/tmp/debug.log" # ок — намерение видно в diff'е
  path: log_path                     # → "/tmp/debug.log"
end

«Почему в проде другой путь?» больше не тихий сюрприз — затенение всегда видно по слову shadow.

Длинные числа

Разделяйте разряды через _ там, где это помогает глазу:

max_bytes: 10_000_000
retention: 2_592_000
port:      8_080
rate:      0.000_001 # в дробной части группы отсчитываются от точки

Это полностью необязательно: 2371933 и 2_371_933 — одно и то же число, и никто не требует что-либо группировать.

Но если вы группируете, разделитель обязан разбивать по три, считая от десятичной точки. Поэтому короткая группа оказывается в начале целой части и в конце дробной: 8_080, 1.123_45.

Это строже, чем в Go, Ruby или Rust, где 2_3_7_1_9_3_3 допустимо. Причины две. Разделитель, который может стоять где угодно, — это второй способ записать одно и то же число, а язык тратит свои правила ровно на то, чтобы таких способов не было. И он вводит в заблуждение: 1_0000_000 — это десять миллионов, но всякий, кто читает по три, увидит куда большее число.

100_, 1__0 и 1_.5 — тоже ошибки: разделять там нечего.

Всё это существует потому, что длинные числа живут именно в конфигурации, а просчитанный ноль — та ошибка, которую больше ничто не поймает: значение корректно, тип верен, и сервис тихо получает в десять раз больше, чем должен.

Строки и интерполяция

name = "auth"
image: "company/#{name}:v#{1 + 1}" # → "company/auth:v2"

Внутри #{...} — обычный синтаксис выражений: кавычки вложенных строк не экранируются ("#{list.join(", ")}" — валидно).

У null нет текстовой формы, поэтому его интерполяция — это E0324, а не слово null:

host = env("HOST", null)
url: "https://#{host == null ? "localhost" : host}/api"

Проверка написана явно, потому что какой текст подставить вместо отсутствующего значения — это решение о конфигурации. Если бы печаталось null, эти четыре буквы попали бы в вывод, и тот, кто читает его дальше, увидел бы настройку, а не её отсутствие. А обычная причина, по которой null оказывается в строке, — это значение, потерянное выше по цепочке.

Доступ к данным

Точка — для полей, скобки — только для индексов списков:

version:  cfg.package.version         # обычные ключи
special:  cfg."ключ с пробелом"       # любой ключ — строкой
dynamic:  cfg.envs."#{region}"        # динамический ключ
first:    apps[0].name                # индекс (за границами — E0317)
optional: cfg.get("maybe", "default") # промах → default, не ошибка

Опечатка в имени поля — ошибка E0308 с точной позицией, а не молчаливый null.

Блоки и области видимости

Конфиг редко бывает плоским. В Aura есть две формы вложенности, и различаются они не выводом, а тем, что разрешено писать внутри.

ФормаЧто внутриВ JSON
key: … endтолько свойства — данныевложенный объект
domain "имя" … endсвойства и вычисления (=, shadow, assert, def)объект под ключом-меткой

Литерал объекта: просто данные

security:
  tls:         true
  min_version: "1.3"
end
{ "security": { "tls": true, "min_version": "1.3" } }

Внутри допустимы только свойства. Нужны вычисления — берите domain.

В одну строку

Короткий объект помещается в одну строку: тот же блок, только с запятыми там, где были переводы строк (D38).

resources:
  requests: cpu: "250m", memory: "512Mi" end
  limits:   cpu: "500m", memory: "1Gi" end
end

Запятая разделяет два поля и ничего больше - пропущенная или стоящая перед end даёт E0205, и сообщение говорит, какая именно. aura fmt сохраняет ту форму, которую вы выбрали. Два end в строке (a: b: 1 end end) допустимы, но читаются тяжело: внешний уровень лучше записать блоком.

Внутри списка однострочной форме нужна схема, new Port name: "http", port: 80 end - там запятая разделяет ещё и элементы, а схема заодно проверит поля.

Через =

= привязывает объект ровно так же, как : его выводит (D10): блок тот же, но в результат он не попадает.

defaults =
  port:    8080
  timeout: "30s"
end

limits = cpu: "500m", memory: "1Gi" end

server:
  port:    defaults.port
  timeout: defaults.timeout
end
{ "server": { "port": 8080, "timeout": "30s" } }

Замените defaults = на defaults: - и объект попадёт в вывод; больше в нём ничего не изменится. Если у блока после = забыт end, ошибка укажет на строку с =.

domain — секция со своей областью видимости

Метка становится ключом, а тело — полноценная область: =, shadow, assert работают:

domain "prod"
  replicas: 3
  region:   "eu-central"
end
{ "prod": { "replicas": 3, "region": "eu-central" } }

Метка может быть выражением, не только строковым литералом.

Область видимости и shadow

Внешние переменные видны, но их перекрытие требует явного shadow (D7):

port = 80

domain "prod"
  shadow port = 443 # без `shadow` — E0302
  listen: port      # 443
end

fallback: port # 80 — снаружи ничего не изменилось

Это прямой ответ на вопрос «почему в проде другой порт»: перекрытие видно в коде.

Списки объектов: map без лишних ключевых слов

Тело лямбды — тоже область видимости (D17), поэтому элементы списка описываются напрямую, включая приватные вычисления:

services: ["api", "web"].map (n, i) ->
  base = 8000 # приватно: в JSON не попадёт
  name: n
  port: base + i
end
{
  "services": [
    { "name": "api", "port": 8000 },
    { "name": "web", "port": 8001 }
  ]
}

Поле name здесь — обычное свойство, которое вы пишете сами. Раньше эту строку неявно вставляло ключевое слово component; оно убрано в D17 — язык не добавляет полей за вашей спиной.

Что выбрать

  • Только данные → key: … end.
  • Данные плюс вычисления → domain.
  • Список объектов → map с телом-лямбдой; блок не нужен.

Общее правило: тела кода (def, лямбда, domain) — это области видимости; литерал объекта — это данные.

Схемы и валидация

Схемы

type объявляет схему, new — инстанцирует с проверкой:

type Container
  name:  String
  image: String
  port:  Int
end

main: new Container
  name:  "gateway"
  image: "company/gateway:2.4.1"
  port:  8080
end

Короткий экземпляр помещается в одну строку, с запятыми между полями (D38). Проверяется он ровно так же, как блочная форма:

sidecar: new Container name: "proxy", image: "envoy:1.30", port: 9901 end

Проверки при инстанцировании:

СитуацияКодКогда
Отсутствует поле схемыE0511всегда
Неверный тип (Int vs String, Int vs Float)E0512всегда
Лишнее поле, не объявленное в схемеE0513только --strict

Значение по умолчанию проверяется там, где схема объявлена, а не только там, где используется. q: Int = "not an int" — это E0516 прямо на type, потому что поле, которое вызывающие всегда задают явно, иначе никогда не дошло бы до new со своим умолчанием, и объявление продолжало бы утверждать невозможное.

Судятся только литералы: что даст произвольное выражение, до запуска неизвестно, а результат всё равно проверит new.

type Quota
  # `Int? = null` — маркер допускает null, а умолчание делает поле необязательным.
  # Один только маркер по-прежнему требовал бы поле при `new`.
  limit: Int? = null
end

Типы полей: String, Int, Float, Bool, List, Object. Int и Float — раздельные типы: лимиты памяти в байтах и 64-битные идентификаторы не теряют точность (E0304 на переполнении, а не тихий wrap).

Поля, у которых может не быть значения

Поле хранит значение своего типа и ничего больше. Когда значения действительно нет - безлимитная квота, срок, который никто не назначил, - пометьте поле ?:

type Plan
  id:    String
  quota: Int?
end

unlimited: new Plan
  id:    "enterprise"
  quota: null
end

Это нужно, чтобы покончить с сигнальными значениями. Без ? «безлимит» пишут как -1 или 2147483647, и каждому приходится помнить, какое число что значит и где. Сигнальное значение - законное значение своего типа, поэтому неверное никто никогда не поймает.

? отвечает только на один вопрос: может ли значения не быть. Можно ли поле опустить - отдельная задача = default, и они сочетаются:

ОбъявлениеЕсли опуститьЕсли написать null
quota: IntE0511E0512
quota: Int?E0511можно
quota: Int = 100будет 100E0512
quota: Int? = nullбудет nullможно

Nullable-поле по-прежнему проверяется по типу, когда значение в нём есть, а aura types выпускает Option<i64>, number | null и *int64.

Единственное место, где ? может навредить

? расширяет то, что поле принимает, и именно поэтому на него стоит посмотреть ещё раз, когда значение приходит извне:

quota: data.get("quota") # без запасного значения

Без ? отсутствующий ключ - это E0512, и прогон останавливается, так что двое людей с разными входными файлами узнают об этом сразу. С ? та же разница проходит как null, и они молча выкатывают разные конфигурации.

Это сочетание - W0513, а под --strict - ошибка:

[W0513] Warning: 'quota' is nullable and is filled from `get(key)`, so a
        missing value becomes null instead of an error
          Help: name the value that stands for absence: `get(key, fallback)`
                — or drop the `?` so a missing one is reported

env(name) без запасного значения - тот же случай. Предупреждение просит только назвать запасное значение: data.get("quota", 0) говорит, какое значение означает отсутствие, и говорит это в манифесте, а не в чьей-то голове.

Списки с типом элементов

List сам по себе принимает любые элементы. Чтобы сказать, что в нём лежит, пишите [T]:

type Service
  name:      String
  tags:      [String]
  endpoints: [Endpoint]
end

Тип повторяет значение: [1, 2] - список, значит, его тип [Int]. Вложенность получается без нового правила, [[Int]], и enum тоже годится как тип элемента.

Неверный элемент называется с индексом, потому что «один из них неверный» - не ответ в списке из сорока:

[E0512] Error: endpoints[0] of schema Service expects Endpoint, got String

Это доходит и до хоста: aura types выпускает Vec<Endpoint>, Endpoint[] или []Endpoint, а не нетипизированный массив.

Голый List сохраняет прежний смысл, так что ничего написанного раньше менять не нужно.

Поле с типом-схемой хранит то, что принял бы new

Поле с типом Port, [Port] или [[Port]] хранит значения, которые принял бы new Port, откуда бы они ни пришли (D41). Элемент из JSON, YAML или от хоста проверяется по тем же правилам, что и написанный через new: обязательные поля, типы, enum и инварианты.

type Port
  number: Int
  protocol: String = "tcp"

  assert number > 0, "port must be positive"
end

type Service
  ports: [Port]
end

raw = read_file("ports.json").parse_json()
svc: new Service ports: raw end

Ошибка называет путь до неверного значения:

[E0515] Error: Port: port must be positive (in ports[2])
[E0511] Error: missing field 'port' required by schema Endpoint (in routes[1].target)

Как это работает и почему

  • Структурно, а не номинально. Значение не помнит, какая схема его создала, поэтому вопрос звучит «принял бы это new Port?», а не «создано ли это через new Port?». Данные извне проходят, если они правильные, и их не нужно пересобирать поле за полем через .map.
  • Умолчания подставляются. {"number": 80} в поле [Port] становится {"number": 80, "protocol": "tcp"}, в порядке полей схемы, ровно как сделал бы new. Одна схема даёт одну форму вывода, каким бы ни был источник, - на это опираются aura types и хост, читающий результат.
  • Экземпляр, созданный через new, проверяется ещё раз. Проверки чистые, а умолчания уже на месте, так что ничего не меняется; альтернатива - скрытая метка «уже проверено» - была бы состоянием, которого читатель не видит.
  • Лишние поля - как у new: сохраняются, а под --strict дают E0513.
  • Схема может содержать саму себя - children: [Node] - на глубину до 32 уровней; глубже - E0208, а не падение, потому что данные, в отличие от кода, можно построить любой глубины.

До D41 такое поле принимало любой объект: без number или с number, который нарушает инвариант. Манифест, который на это опирался, теперь остановится с ошибкой, называющей неверный элемент.

Правила, которые схема утверждает о себе

Тип говорит, какой формы значения. Он может сказать и то, какими они должны быть:

type Plan
  price_monthly: Int
  price_yearly:  Int
  max_channels:  Int

  assert price_monthly >= 0, "price must not be negative"
  assert max_channels > 0, "a plan needs at least one channel"
  assert price_yearly <= price_monthly * 12, "yearly must not exceed twelve months"
end

Они выполняются при каждом new Plan, а провал - это E0515 с сообщением, которое написала схема:

[E0515] Error: Plan: yearly must not exceed twelve months

Ради третьего это и стоит иметь: оно связывает два поля, чего не выразит никакое ограничение на отдельное поле. И правило порядка ему не нужно, потому что каждое поле заполнено и проверено по типу до того, как выполнится любой инвариант.

Почему бы просто не написать assert рядом с new

Потому что тогда это не часть типа. Он не переезжает вместе со схемой, так что импортёр получает поля без правил; инструменты его не видят; а следующий, кто создаст экземпляр схемы, просто его не напишет.

Поместите его в схему - и он действует везде, включая манифест автора, который вашего пакета никогда не читал:

import "./lib.aura" as lib

p: new lib.Port
  number: 80 # E0515: Port: ports below 1025 are privileged
end

Что видит инвариант

Свои поля и ничего больше. Ни модуль, где объявлена схема, ни тот, где написан new:

[E0504] Error: use of undefined variable 'limit'
          Help: an invariant sees only this schema's fields (q), so it means the
                same in every manifest that uses the schema; pass anything else
                in as a field

Это сделано намеренно. Правило, которое могло бы читать переменную модуля, значило бы разное в каждом манифесте, импортирующем схему, - а инварианты существуют как раз чтобы решить эту проблему. Заодно из него недостижимо ничего эффектного, так что экземпляр валиден или невалиден одинаково для всех.

assert

Произвольные инварианты — statement assert:

admins = [187650342, 244179081]
assert admins.uniq().len() == admins.len(), "Duplicate admin ids"
assert admins.len() >= 1

Провал — E0530 с вашим сообщением и точной позицией. assert работает и в --dry-run — тестовый прогон обязан находить отказ валидации.

fail в выражениях

Для валидации внутри выражения есть fail:

port: p > 0 && p < 65536 ? p : fail("invalid port #{p}")

Закрытые наборы: enum

Поле String принимает любую строку — опечатка в значении доезжает до прода. enum задаёт закрытый набор допустимых строк:

enum Tier
  "frontend"
  "backend"
end

type Service
  tier: Tier
end

svc: new Service
  tier: "backand" # E0514 + подсказка «did you mean "backend"?»
end

Член enum — обычная строка: в JSON попадёт "backend", никакой обёртки не появится. Это ограничение на значение, а не новый тип данных.

pub enum экспортируется importer’ам (D12). Члены резолвятся там, где объявлена схема, поэтому импортированная схема проверяется против enum своего модуля — даже если у вас в области видимости есть одноимённый.

Статический анализ

aura check (и автоматически перед каждым eval) находит проблемы до запуска:

  • E0504 — использование необъявленной переменной (в том числе внутри #{...});
  • W0501/W0502/W0503 — неиспользуемые переменные / импорты / функции и типы;
  • W0512 — эффектный вызов (env, read_file) в импортированном модуле.

В --strict предупреждения становятся ошибками — режим для CI.

Функции и методы

def — функции-конструкторы

def возвращает объект, когда тело - свойства (и значение, когда тело им кончается, - ниже):

def make_env(name, replicas, debug)
  replicas:  replicas
  log_level: debug ? "debug" : "warn"
  db_url:    "postgres://db.#{name}.internal:5432/app"
end

environments:
  dev:  make_env("dev", 1, true)
  prod: make_env("prod", 6, false)
end

Один def вместо трёх скопированных YAML-файлов — главный анти-копипаст инструмент языка.

Функция, которая возвращает значение

Функция, тело которой кончается выражением, возвращает это выражение (D40). Перед ним можно писать привязки и assert, так что шаг можно назвать:

def ports_of(services)
  all = services.map (s, i) -> s.port end
  assert all.len() > 0, "no services"
  all.uniq().sort()
end

ports: ports_of(catalogue)

Многострочная лямбда подчиняется тому же правилу; однострочная, (x) -> x * 2 end, и так была выражением.

Три случая и почему они такие

  • Свойства или последнее значение - не то и другое сразу. Тело со свойствами key:, которое ещё и кончается выражением, - это E0215. Вернуть значение значило бы потерять свойства, вернуть свойства - потерять значение, и в обоих случаях что-то пропадало бы бесследно. Сделайте значение свойством (value: ...) или уберите свойства.
  • Значение, которое никто не использует, - ошибка. Голое выражение в любом другом месте - в начале файла, в domain, до последней строки функции - это E0214. В Aura нет побочных эффектов, так что значение, которое никуда не идёт, ничего не сделало: автор почти всегда имел в виду key: (вывести), name = (использовать дальше) или assert (проверить), и подсказка называет все три. До D40 такая строка вычислялась и молча выбрасывалась.
  • Только привязки и проверки - по-прежнему пустой объект. Тело из одних = и assert возвращает {}, как и раньше: ничто в нём не задумывалось результатом, значит, ничего и не выбрасывается.

Обе ошибки выдаёт aura check, поэтому редактор показывает их сразу, пока вы печатаете.

Лямбды

double = (x) -> x * 2 end
up: ["a", "b"].map (s, i) -> s.upper() end # trailing-лямбда

Колбэк map/filter получает элемент и индекс; лишние параметры можно не объявлять.

Методы

Вызываются через точку, образуют цепочки:

active: services.compact().uniq().map (s, i) -> s.upper() end

Полный список — в справочнике методов. Часто используемые:

  • списки: map filter compact (убрать null) uniq first last join(sep) contains(x) get(i, default)
  • объекты: merge (правый перекрывает) keys values contains(key) get(key, default)
  • строки: upper lower len contains(sub) + парсеры форматов

Тернарный оператор

Единственная ветвящаяся конструкция языка:

mode: is_prod ? "webhook" : "long_polling"

Условие обязано быть Bool — «правдивости» (truthiness) в Aura нет, E0306 на любом другом типе.

Модули и пакеты

Импорты

import "templates/defaults.aura" as defaults # файл, относительно текущего
import github/acme/aura-k8s@v1.2 as k8s      # пакет из registry, версия обязательна

Циклические импорты обнаруживаются с полной цепочкой: E0401: cyclic import: a.aura -> b.aura -> a.aura.

Что экспортирует модуль

Модуль отдаёт импортёру объект: свойства, блоки и pub-элементы:

# validators.aura
pub type Service # схема — часть API
  name: String
  port: Int
end

def valid_port(p) # приватный хелпер: импортёру невидим
  ok: p > 0 && p < 65536
end

pub def service(name, port) # функция — часть API
  name: name
  port: valid_port(port).ok ? port : fail("invalid port #{port}")
end
# использование
import "validators.aura" as v

api:    v.service("api", 8080)
worker: new v.Service
  name: "worker"
  port: 9000
end

Ключевая гарантия безопасности: экспортированная функция выполняется с правами своего модуля, а не вызывающего. Пакет не может «одолжить» ваши права на чтение файлов, заставив вас вызвать его функцию.

Установка пакетов: aura add

aura add github/acme/aura-k8s@v1.2.3        # из сети (точная версия)
aura add pkg/internal@v1.0.0 --from ./pkg.aura   # из локального файла

aura add — единственное место, где Aura обращается к сети: пакет скачивается, валидируется, кладётся в локальный кэш (~/.aura/registry) и фиксируется в aura.lock с хешем целостности. eval всегда работает оффлайн.

aura.lock и –frozen

  • лок хранит точную версию + integrity каждого пакета;
  • хеш считается по потоку токенов, а не по байтам: переформатирование пакета или исправление опечатки в комментарии проверку не срабатывают, а переименование поля или изменение числа — срабатывает. Проверка целостности, которая срабатывает на безобидных правках, приучает обновлять лок не глядя, что её и обесценивает;
  • запись sha256- от старой версии по-прежнему проверяется и обновляется при первой возможности записи;
  • подмена содержимого пакета → E0402 integrity mismatch;
  • в CI запускайте с --frozen: резолв строго по локу, отсутствие записи — ошибка E0403, лок не переписывается.

Диапазоны версий (@v1, @v1.2) резолвятся по локальному кэшу в максимальную подходящую (v1.2 → 1.2.*).

Время без сюрпризов

now() не существует

Вызов now() или timestamp() — ошибка E0533. Это не пробел, а дизайн-решение (D13): текущее время сделало бы конфиг невоспроизводимым — сломались бы --dry-run-сравнения, кэши и сама идея «два прогона дают одинаковый результат».

Если время сборки действительно нужно — его передаёт хост:

built_at: env("BUILD_TIME", "unknown")
BUILD_TIME=$(date -u +%FT%TZ) aura eval app.aura --allow-env=BUILD_TIME

Решение о времени принимает и фиксирует запускающая сторона, конфиг остаётся чистой функцией от входов.

Длительности

Таймауты и TTL — строки с единицами d/h/m/s, парсятся в секунды (Int):

ttl = "1h30m".parse_duration() # → 5400
cache:
  ttl_seconds:   ttl
  refresh_every: (ttl / 3).format_duration() # → "30m"
end

Дальше — обычная целочисленная арифметика: сложение, вычитание, «перевести сутки в часы» и т.п. Некорректная строка — E0319.

Даты

RFC3339 ↔ epoch-секунды (UTC):

start = "2026-07-18T22:00:00Z".parse_datetime()      # → epoch Int
window:
  start: start
  end: (start + "4h".parse_duration()).format_datetime()
  # → "2026-07-19T02:00:00Z" — переход через полночь корректен
end

Поддерживаются смещения (+02:00 указывает на тот же момент времени) и короткая форма даты ("2026-07-18" — полночь UTC). Некорректная дата — E0320.

Календарная арифметика высокого уровня («+3 месяца», рабочие дни, таймзоны) — намеренно не в ядре: это территория будущих пакетов сообщества поверх epoch-примитивов.

Модель безопасности

Aura исходит из того, что конфиги собирают из чужого кода (пакеты, шаблоны коллег, скопированные сниппеты), и строит защиту по построению, а не по договорённости.

Capability-модель

По умолчанию манифест не может ничего: ни читать файлы, ни переменные окружения. Права выдаются флагами запуска и не «протекают»:

ФлагРазрешает
--allow-read=<dir>read_file() внутри каталога; пути канонизируются, .. не сбегает (E0311). Относительный путь читается от папки самого манифеста (D37)
--allow-env[=A,B]env() для перечисленных переменных (без списка — все)
--hermeticI/O нет вовсе: env() и read_file() — E0505 в любом модуле; несовместим с --allow-*
--allow-imports-ioраспространить права корня на импортированные модули

Вызов без прав — E0310 с подсказкой, какой флаг добавить.

Герметичный режим

--hermetic — та же шкала с другого конца: вместо выдачи прав он объявляет, что прав нет вовсе. env() и read_file() становятся E0505 где угодно — и в корне, и в импорте, — а сам флаг несовместим с любым --allow-*.

Отличие от простого «не выдавать права» — в том, когда узнаёшь. Запрещённый вызов — это E0310 во время выполнения, на той ветке, которая исполнилась; E0505 — ошибка анализа, поэтому aura check --hermetic доказывает свойство для всего манифеста, ничего не вычисляя. Это и делает его пригодным для гейта в CI:

$ aura check --hermetic manifests/*.aura

CUE, KCL и Starlark герметичны по построению. В Aura это режим — так один репозиторий может держать и манифесты, которым нужно читать окружение, и те, которым нельзя, а CI может требовать строгий вид по каталогам.

Ограниченное вычисление

Герметичность доказывает, что манифест ничего не может взять. Но не доказывает, что он закончится: три вложенных range(1000).map умещаются в 95 байт, проходят check --hermetic и работали бы больше минуты. Поэтому у каждого вычисления есть бюджет — у CLI, eval_file, песочницы и Script одинаково:

БюджетИсчерпанПо умолчаниюФлаг
шаги — вычисленные выраженияE032510 млн--max-steps=N
байты — каждое созданное значение и кадрE0326256 МиБ--max-memory=MB

Оба считаются, а не замеряются по времени, поэтому один и тот же манифест останавливается в одной и той же точке на любой машине. Значения по умолчанию примерно в тридцать тысяч раз больше, чем тратит самый тяжёлый пример в репозитории; --stats печатает расход, чтобы бюджет выбирался по замеру.

У глубины свой предел, потому что шаги её не ограничивают: функция, вызывающая себя несколько сотен раз, тратит мало шагов и очень много стека. Одно выражение вкладывается не глубже 256 уровней - длинная цепочка вроде a + b + c + ... засчитывает свою длину, - а вычисление во время работы вкладывается не глубже 256 уровней, причём рекурсивный вызов засчитывает выражения, через которые проходит. За любым из пределов - ошибка, E0208 или E0399, а не переполнение стека (D42). Как и бюджет, предел - это счёт, поэтому манифест останавливается в одной и той же точке везде. Release-сборке на него хватает 512 КиБ стека, так что хост может вызывать Script с любого потока, где стека не меньше, - у вторичного потока iOS ровно 512 КиБ.

У значений тот же предел (D43). Цикл может построить глубокое значение без всякой собственной вложенности - reduce, заворачивающий аккумулятор, добавляет уровень на каждом шаге, - а любой обход значения, от вывода до освобождения памяти, рекурсивен по уровням. Поэтому список или объект глубже 256 - это E0328 там, где его строят, а значение от хоста такой глубины отвергается на границе. Разобранные JSON и YAML и так останавливаются на 128.

Изоляция импортов

Права принадлежат корневому манифесту. Импортированный модуль:

  • не может вызывать env()/read_file() — даже если у корня права есть;
  • не получает права через свои экспортированные функции: тело pub def выполняется с capability модуля-происхождения, а не вызывающего;
  • статически помечается предупреждением W0512, если содержит эффектные вызовы.
$ aura eval main.aura --allow-read=.
[E0310] Error: imported module has no capability to call read_file()
   ╭─[ evil_dependency.aura:2:7 ]

Живой пример — examples/security_demo/.

Функции хоста и перезагрузка

Приложение, встраивающее Aura, может дать скрипту свои функции (D35) и перезагружать его на ходу (D36). Оба механизма подчинены той же модели:

  • Функция хоста - это разрешение, которое приложение выдаёт по имени, по одной. pure - обещание хоста, что функция детерминирована, - оставляет скрипт герметичным; effect - разрешение, как --allow-env.
  • Они доступны только корневому модулю. Импортированный пакет не может их вызвать, если приложение не открыло ему эту функцию по имени (E0310).
  • Каждое использование проверяется при загрузке, до любого вычисления: непредоставленная функция - E0007. Функция хоста не может вызвать скрипт обратно (E0010).
  • Перезагрузка - это полная загрузка с разрешениями первой и её aura.lock, замороженным: правка скрипта не расширит его права, не подтянет незакреплённый пакет и не уберёт экспорт, к которому привязалось приложение (E0011). Недописанный файл не загружается, пока его не оставят в покое на период тишины.

Supply chain

  • Версия пакета обязательна в самом импорте: import github/acme/pkg@v1.2 as p.
  • aura.lock фиксирует точную версию и хеш потока токенов модуля; подмена — E0402. Хеш по токенам, а не по байтам, означает, что переформатирование или правка комментария проверку не срабатывают, а любое изменение, способное повлиять на поведение, — срабатывает. Lock, записанный старой версией, содержит байтовый sha256-; он по-прежнему проверяется и обновляется на первом же запуске без --frozen.
  • --frozen (режим CI) запрещает резолв мимо лока (E0403).
  • Сеть существует только в aura add; вычисление всегда оффлайн.

Детерминизм

  • now() не существует (E0533) — время передаёт хост через env.
  • Порядок ключей вывода = порядок объявления; два прогона побайтно идентичны.
  • --dry-run выполняет всё вычисление, но не пишет ни JSON, ни лок, и отчитывается о каждом чтении: [dry-run] read: ./Cargo.toml.

Форматы: TOML, JSON, YAML

Чтение

Три парсера — методы строки; результат — обычные объекты/списки Aura:

cargo = read_file("./Cargo.toml").parse_toml()
team  = read_file("./team.json").parse_json()
lint  = read_file("./.rules.yaml").parse_yaml()

service:
  name:  cargo.package.name
  owner: team.teams[0].lead
end

Целые числа всех форматов приходят как Int (без потери точности), ошибки парсинга — E0314 с сообщением исходной библиотеки.

Запись

Из CLI — флагом:

aura eval app.aura --format json       # по умолчанию, pretty
aura eval app.aura --format json-flat  # плоские ключи: a.b.c = 1
aura eval app.aura --format yaml
aura eval app.aura --format toml       # требует объект на верхнем уровне

Изнутри языка — методами. У каждого формата их два, и выбор — про то, кто будет читать результат:

configmap:
  # Читает программа: компактная форма, потому что переносы строк всё равно
  # были бы экранированы на входе.
  "app-config.yaml": settings.to_yaml()
  # Читает человек — в `kubectl get cm -o yaml` или в diff: развёрнутая.
  "reference.yaml": settings.to_yaml_pretty()
end

to_json / to_yaml / to_toml дают самую компактную форму, которую допускает формат; to_json_pretty / to_yaml_pretty / to_toml_pretty — форму для человека. Компактный YAML — это flow-стиль, {a: 1, xs: [1, 2]}, обычный YAML, а не диалект. Компактный TOML держит массивы в одну строку.

У CLI наоборот: --format всегда пишет развёрнутую форму, потому что производит документ, а не строку, которая пойдёт внутрь документа.

json-flat склеивает вложенные ключи точкой, и это однозначно лишь пока сама точка не встречается внутри ключа. Ключи, написанные на Aura, — идентификаторы, поэтому не встречается; но parse_json и родственные возвращают то, что было во входных данных, и тогда {"a": {"b": 1}} и {"a.b": 2} оба претендуют на ключ a.b. Это E0604, а не молчаливая перезапись одного значения другим. Пустой объект сплющивается в себя (a.b: {}) — он лист, как и пустой список.

Ограничения TOML честно превращаются в ошибки E0603: нет null, на верхнем уровне обязан быть объект.

Aura как конвертер

Поскольку язык читает и пишет все три формата, миграция — однострочник:

# convert.aura
config: read_file("./legacy.toml").parse_toml()
aura eval convert.aura --allow-read=. --format yaml

В отличие от yq/jq, по пути доступны схемы, assert и merge нескольких источников — конвертация с гарантиями.

Встраивание и вызов из других языков

Rust: библиотека

[dependencies]
# `default-features = false` отключает фичу `cli`, а с ней clap, ariadne и ureq —
# библиотеке они не нужны. Замер на пустом проекте: 38 пакетов вместо 96. Фича
# включена по умолчанию только чтобы `cargo install aura-lang` давал бинарник
# `aura`.
aura-lang = { version = "0.4", default-features = false }
#![allow(unused)]
fn main() {
use aura_lang::facade::{eval_file, EvalOptions};

let opts = EvalOptions {
    strict: true,
    allow_read: vec!["config/".into()],
    ..Default::default()
};
let out = eval_file("config/app.aura".as_ref(), &opts)?;
let cfg: MyConfig = serde_json::from_value(out.json)?;      // сразу в свои структуры
for w in &out.warnings {
    log::warn!("{w}");                                       // Display: warning[W..]: ... at file:line:col
}
if let Some(lock) = out.updated_lockfile {
    std::fs::write("config/aura.lock", lock)?;               // писать или нет — решает хост
}
}

Права задаёт хост-приложение — конфиг не получает больше, чем вы разрешили. Диагностики приходят структурированными (code, severity, file, line, column, help) — рендерите в свой лог как удобно.

В async-контексте заворачивайте eval_file в spawn_blocking: он читает файлы и вычисляет синхронно.

Чтение всего манифеста в свои типы

Когда манифест и есть конфигурация, читайте его целиком, сразу в свои структуры - без JSON посередине и с тем же результатом, что печатает aura eval:

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct Device { board: Board, sensors: Vec<Sensor>, /* ... */ }

let device: Device = Script::builder()
    .file("device.aura")
    .allow_read("config")                      // файлы, которые он читает, рядом с ним
    .env_values([("APP_ENV", "production")])   // ровно эти, и никакие другие
    .load()?
    .output()?;
}

env_values разрешает названные переменные с заданными значениями; env() берёт их оттуда и никогда - из процесса, поэтому окружение самого сервиса не утечёт в манифест, а тест может дать ему любое окружение, не трогая окружение процесса. aura types --lang rust генерирует структуры для схем, объявленных в манифесте. Каждый пример в репозитории читается так в тесте и сравнивается с выводом, на котором зафиксирован CLI; cargo run --example read_device (а также read_k8s_deploy, read_service_catalog) показывает это целиком.

Вызов скрипта: Script

eval_file возвращает JSON, а JSON не умеет хранить функции. Но pub def в Aura — это значение, поэтому правила могут лежать в .aura-файле, а приложение — вызывать их на каждый запрос, событие или порцию, меняя поведение правкой файла, а не пересборкой. Для этого есть тип Script (D33).

#![allow(unused)]
fn main() {
use aura_lang::{Budget, Script};

#[derive(serde::Serialize)]
struct Event { device: String, temp_c: f64 }

#[derive(serde::Deserialize)]
struct Summary { total: u64, alerts: u64 }

let rules = Script::builder()
    .file("telemetry.aura")
    .budget(Budget::per_call().steps(5_000_000).memory_mb(32))
    .load()?;                                                // разбор, анализ, доказательство герметичности — один раз

let summarize = rules.function::<(&[Event],), Summary>("summarize")?;  // имя, тип, арность — проверены сейчас

for chunk in events.chunks(10_000) {
    let s = summarize.call((chunk,))?;                       // один вызов, один бюджет, ничего не остаётся в памяти
}
}

Что гарантируется — в том порядке, в каком решение ставит приоритеты:

  • Герметичность по умолчанию. Без выданных прав load статически доказывает, что скрипт не читает ни файлов, ни переменных, ни часов, и падает с E0505, если мог бы, — в том числе на ветке, которая никогда не выполнится. allow_env и allow_read выдают ровно то же, что --allow-env и --allow-read, и только корневому модулю.
  • У каждого вызова бюджет — в шагах и байтах, но не во времени: иначе результат зависел бы от машины. Убежавший вызов заканчивается с E0325 или E0326, а не вешает хост. Значение по умолчанию конечно.
  • Память на вызов постоянна. Каждый вызов идёт в своём интерпретаторе и освобождает созданное, когда возвращается.
  • Граница проверяется. Аргументы проходят через serde; целое за пределами i64 или NaN — это E0005, а не молча другой вход, а результат, который не подходит под ваш тип, — E0006 с путём внутри значения.
  • Script — Send + Sync. Один экземпляр обслуживает все потоки.

Замерено на одном ядре: вызов стоит около 1,2 мкс, а правило над событиями обрабатывает около 0,9 млн событий в секунду при вызове на каждое событие и 1,3 млн — порциями (cargo bench --bench script). cargo run --example embedding запускает пример выше целиком.

Перезагрузка правил без перезапуска сервиса

Правила меняются чаще, чем сервис, который их исполняет. Скрипт, загруженный из файла, можно загрузить заново без перезапуска (D36):

#![allow(unused)]
fn main() {
use std::time::Duration;

let rules = Script::builder()
    .file("routing.aura")
    .watch(Duration::from_secs(2), |result| match result {
        Ok(v) => log::info!("правила: поколение {} ({})", v.generation, v.hash),
        Err(e) => log::error!("правила не перезагружены, работают старые: {e}"),
    })
    .load()?;

rules.reload()?;     // или перезагрузить сейчас, с watch или без него
}
  • Перезагрузка - это загрузка. Она проходит все проверки первой загрузки с теми же разрешениями: скрипт не станет менее герметичным, не дотянется до функции хоста, которую вы не дали, и не подтянет пакет, не закреплённый в aura.lock, - lock читается один раз, а перезагрузка разрешает импорты как --frozen.
  • Экспорт - это контракт. Каждый экспорт сохраняет вид и число аргументов, иначе перезагрузка - E0011, и продолжает работать старая версия. Новые экспорты разрешены. Привязанная Function следует за своим именем.
  • Никогда не половина версии. Вызов заканчивается на той версии, на которой начался; старая версия освобождается, когда вернулся последний такой вызов.
  • Решает содержимое. touch ничего не загружает. При слежении изменение загружается, только когда файлы не трогали в течение периода тишины - двух интервалов и не меньше секунды, или .settle(d), - поэтому недописанный файл не загрузится наполовину. Писатель, застрявший посреди записи дольше этого, всё же может быть пойман: выкладывайте новый файл и переименовывайте его поверх старого, как делают редакторы, а для медленного сетевого диска увеличьте .settle.
  • Без тихих отказов. Обработчик, который требует watch, узнаёт о каждой автоматической перезагрузке - новая версия или ошибка, - а об одном и том же сломанном содержимом один раз.
  • version() называет поколение и хэш всех файлов - для ваших логов.

cargo run --example checkout собирает всё вместе: правила риска интернет-магазина, которым сервис даёт таблицу GeoIP, курсы валют и базу споров, перезагрузка по правке - и отказ в правке, которая переименовывает то, что вызывает сервис, или тянется к секрету, которого ей не давали.

Продвинутое: скрипт вызывает хост

Некоторым правилам нужны данные, которые есть только у приложения: в каком регионе адрес, включена ли функция для клиента. Вместо того чтобы вычислять все такие значения заранее, хост может дать скрипту именованные функции (D35). Это продвинутое использование: прочитайте гарантии ниже, прежде чем на него опираться.

import "host:geo" as geo

pub def route(ip)
  region = geo.region(ip)
  backend: region == null ? "global" : "#{region}-cluster"
end
#![allow(unused)]
fn main() {
let rules = Script::builder()
    .file("routing.aura")
    .pure("geo.region", move |ip: String| geoip.get(&ip).cloned())   // Option: None - это null
    .effect("flags.enabled", move |flag: String, customer: String| {
        flags.enabled(&flag, &customer)                               // Result: Err - это E0008
    })
    .load()?;
}

pure и effect - методы Rust API, а не слова языка. Часть имени до точки - модуль, который импортирует скрипт.

  • pure - ваше обещание, и язык не может его проверить. Чистая функция должна давать один ответ на одни аргументы: таблица, поставляемая с бинарником, формула. Загрузка остаётся герметичной. Всё, что читает внешний мир - база, сервис флагов, часы, - это effect: его регистрация - разрешение, как allow_env, и загрузка перестаёт быть герметичной.
  • Только корневой модуль. Пакет, который импортирует скрипт, не может вызывать функции хоста; grant_to_imports("geo.region") открывает импортам одну функцию.
  • Проверка при загрузке. Непредоставленный модуль или функция - E0007, вызов с неверным числом аргументов - E0009, в том числе в ветках, которые не выполнятся. Типы аргументов проверяются при вызове, тоже как E0009.
  • Бюджет. Вызов хоста стоит 100 шагов (host_call_steps меняет это). Время внутри вашей функции бюджету не видно: ограничивайте его сами.
  • Без обратного вызова. Функция хоста, вызвавшая скрипт, получает E0010.

У aura eval хоста нет, поэтому импорт host: там останавливается с E0007; aura check перечисляет, что нужно скрипту от приложения, которое его запустит. cargo run --example host_functions запускает полный пример.

Любой язык: subprocess

CLI-контракт стабилен и является API: JSON в stdout, диагностики с кодами E0xxx/W0xxx в stderr, exit-коды 0 (успех) / 1 (диагностики) / 2 (I/O, аргументы).

import json, subprocess
r = subprocess.run(["aura", "eval", "app.aura", "--frozen"],
                   capture_output=True, text=True)
if r.returncode != 0:
    raise RuntimeError(r.stderr)
config = json.loads(r.stdout)

Рекомендации для прода:

  • --frozen — зависимости строго по aura.lock;
  • права минимальные и явные (--allow-read, --allow-env=ИМЕНА);
  • --format yaml|toml, если потребителю удобнее.

Мобильные и браузерные приложения

Правильный паттерн — вычисление на сервере/CI:

configs.aura ──aura eval──▶ config.json ──▶ CDN / бандл
устройство читает готовый JSON штатным парсером платформы

Приложения — потребители результата Aura; валидация уже прошла в CI.

Почему обёртки, а не нативные реализации

Для YAML в каждом языке есть свой парсер, и возникает вопрос, почему у Aura не так. Потому что YAML — формат данных: его порт это парсер, байты → данные. Aura — вычисляемый язык (функции, импорты, схемы, capability, детерминизм): её порт это интерпретатор, который обязан побайтово совпадать с этим. Иначе один и тот же манифест даст разные значения в CI и в сервисе — то есть в проде. Расхождение возникает не на экзотике: наивная реализация на encoding/json в Go выдаёт 1 там, где Aura выдаёт 1.0, и 1000000000000000000 там, где 1e+18.

Поэтому реализация одна, а языки получают её через:

  1. Вычисление на этапе сборки — основной путь. Рантайм Aura внутри вашего языка не нужен вовсе, а aura types вдобавок сгенерирует типы под ваш JSON.
  2. Обёртки вокруг того же ядра — в роадмапе, по приоритету: WASM/npm (закрывает Node, браузер и playground одним артефактом), затем wazero для Go (чистый Go, без cgo) и wasmtime для Python; PyO3/UniFFI — по мере спроса.

Ядро уже собирается под wasm32-unknown-unknown; это проверяется в CI на каждом коммите.

CLI

aura eval <file.aura>  [--strict] [--dry-run] [--frozen]
                       [--allow-read=<dir>]... [--allow-env[=A,B]] [--allow-imports-io]
                       [--hermetic] [--max-steps=N] [--max-memory=MB] [--stats]
                       [--format json|json-flat|yaml|toml] [-o out] [--registry-dir=<dir>]
aura check <file.aura> [--strict] [--hermetic]
aura docs --agent [-o <file>]
aura fmt <files...> [--check]
aura types <file.aura> --lang rust|ts|go [--out <file>]
aura add <path>@vX.Y.Z [--from <file>] [--registry-dir=<dir>]
aura --version

eval

Вычисляет манифест со всеми импортами и печатает результат.

ФлагДействие
--strictпредупреждения анализа (всех модулей) блокируют; лишние поля схем — ошибка E0513
--dry-runполное вычисление, но ни вывод, ни aura.lock не записываются; отчёт [dry-run] read/would write
--frozenзависимости строго по aura.lock (E0403 при расхождении), лок не переписывается — режим CI
--allow-read=<dir>право на read_file() внутри каталога (повторяемый). Относительный путь в read_file() читается от папки самого манифеста, поэтому вывод не зависит от того, откуда запущен aura (D37)
--allow-env[=A,B]право на env(): список имён либо все
--hermeticникакого I/O: env() и read_file() — это E0505 где угодно. Несовместим с флагами --allow-*
--allow-imports-ioвыдать импортам права корня
--max-steps=Nсколько выражений вычисление может выполнить, прежде чем остановится с E0325. По умолчанию десять миллионов
--max-memory=MBсколько мебибайт значений оно может создать, прежде чем остановится с E0326. По умолчанию 256
--statsнапечатать в stderr, сколько шагов и байтов израсходовано
--formatjson (по умолчанию) / json-flat / yaml / toml
-o, --output <file>записать в файл вместо stdout
--registry-dir=<dir>каталог кэша пакетов (по умолчанию ~/.aura/registry)

check

Только lex + parse + статический анализ — быстрый гейт для pre-commit и CI.

--hermetic дополнительно требует, чтобы манифест не выполнял ввода-вывода: env() и read_file() где угодно в нём или в том, что он импортирует, - это E0505. Решается статически, поэтому check отвечает без вычисления - в том числе для веток, которые этот запуск не прошёл бы:

$ aura check --hermetic deploy.aura
[E0505] Error: env() is not allowed in hermetic mode
   ╭─[ deploy.aura:4:11 ]

Он нужен, когда вывод манифеста не должен зависеть ни от чего, кроме его текста: гейт в CI на каталог манифестов или сборка, которую хочется воспроизвести на другой машине. eval --hermetic требует того же и подходит, когда манифесту нет доверия.

Скрипт, написанный для приложения с функциями хоста (D35), импортирует их как import "host:geo" as geo. У CLI хоста нет, поэтому eval останавливается с E0007, а check перечисляет, что скрипту нужно от приложения, которое его запустит, и завершается с кодом 0:

$ aura check routing.aura
note: provided by the application at run time, this script calls:
  geo.region (1 argument)
  flags.enabled (2 arguments)
ok: routing.aura

docs

aura docs --agent печатает полный справочник по языку — синтаксис, стандартную библиотеку, все коды диагностик, — собранный из определений самого компилятора. Это около четырёх тысяч токенов: достаточно мало, чтобы отдать ИИ-ассистенту целиком.

Смысл печатать его из бинарника, а не давать ссылку на страницу, — в версиях: вывод описывает тот aura, который его произвёл, и не может разойтись с ним, как это бывает с размещённым в сети документом.

Полезнее всего дать ассистенту забрать справку самому. Одна строка в вашем AGENTS.md, CLAUDE.md или аналоге:

Before writing or editing .aura files, run `aura docs --agent` for the complete
language reference.

Либо закоммитить — для ассистентов, которые читают файлы, но не запускают команды:

$ aura docs --agent -o AURA.md

Тот же текст публикуется по адресу /llms.txt для инструментов, которые ходят по сети.

fmt

Канонический форматтер: отступы (2 пробела/уровень по токен-глубине), внутристрочные пробелы, колоночное выравнивание прогонов name = value, key: value и арок cond вместе с хвостовыми комментариями. Строки и содержимое block strings не трогаются; поток токенов гарантированно не меняется, форматирование идемпотентно. --check — только проверить (exit 1 при отличиях).

types

Генерация типов host-языка из объявлений type и enum:

aura types config.aura --lang ts

Шесть встроенных типов полей отображаются так:

AuraRustTypeScriptGo
StringStringstringstring
Inti64numberint64
Floatf64numberfloat64
Boolboolbooleanbool
ListVec<serde_json::Value>unknown[][]any
Objectserde_json::Map<String, serde_json::Value>Record<string, unknown>map[string]any

String — единственное имя, которое язык принимает для текстового поля; Str не существует.

Обратите внимание на две последние строки. List и Object в схеме не несут типа элемента, поэтому и сгенерированный код не может: получается последовательность или отображение нетипизированных значений. Где форма важна, объявите type для элемента и держите в поле его.

Одна схема и валидирует конфиг, и типизирует сервис, который читает JSON. Enum превращается в юнион строковых литералов (TS), enum с #[serde(rename)] (Rust) или тип-строку с типизированными константами (Go). Опциональные поля (D15) — обязательные в выводе: вычисление всегда вставляет дефолт. Только парсинг — манифест не вычисляется, capability не задействованы. --out пишет в файл.

Вывод сразу канонический для форматтера host-языка: rustfmt --check, gofmt -l и prettier --check на сгенерированных файлах молчат. Сгенерированное можно коммитить в репозиторий сервиса без форматного шума в диффах.

add

Установка пакета: скачивание (github/<owner>/<repo> → package.aura тега vX.Y.Z; точная версия обязательна), валидация, запись в кэш, фиксация в ./aura.lock с хешем целостности. --from <file> — установка из локального файла. Единственная сетевая команда.

Exit-коды

КодСмысл
0успех
1диагностики (ошибки языка, провал assert, блокирующий strict)
2ошибки I/O и аргументов

Ключевые слова

В Aura шестнадцать зарезервированных слов. Ни одним из них нельзя назвать привязку, функцию, схему или ключ без кавычек: type = 1 - синтаксическая ошибка. Ключ, который должен быть одним из этих слов, пишется строкой и так же читается (D11): "type": "service", затем o."type".

СловоЧто делаетПримерРешение
importПодключает другой модуль, пакет из реестра с закреплённой версией или модуль, который даёт хостimport "./lib.aura" as libD8, D12, D35
asДаёт имя тому, что подключил import; только после импортаimport org/net@v1.2 as netD12
typeОбъявляет схему: поля с типами, умолчаниями и инвариантами. Поле с типом-схемой хранит только то, что принял бы её newtype Port … endD15, D26, D27, D28, D41
enumОбъявляет закрытый набор строк для поля схемыenum Tier … endD18
defОбъявляет именованную функцию. Возвращает объект из своих свойств или последнее выражение, если тело им кончаетсяdef labels(team) … endD17, D40
endЗакрывает любой блок: объекты, def, type, enum, domain, cond, new, многострочную лямбду и строку texttls: enabled: true endD3, D38
domainИменованный раздел со своей областью видимости: метка становится ключом, а в теле можно вычислятьdomain "production" … endD17
newСоздаёт экземпляр схемы и проверяет егоnew Port number: 443 endD4, D38
assertПроверка, которая останавливает вычисление с сообщением; внутри type - инвариант, который переезжает вместе со схемойassert replicas > 0, "need one"D5, D28
shadowЗаново привязывает имя из внешней области - без него это E0302shadow port = 9090D7
pubДелает def, type или enum доступным для импорта; свойства видны всегдаpub def service(name)D12
condВыбор из нескольких значений, побеждает первая истинная веткаcond … else -> "dev" … endD14
elseПоследняя ветка cond, обязательна, чтобы у каждого пути было значениеelse -> "dev"D14
trueЛогическая истина. «Истинности» нет: условие, которое не Bool, - это E0306tls: true-
falseЛогическая ложьdebug: false-
nullОтсутствие значения. Поле схемы принимает его, только если тип помечен ?, и оно никогда не выводится текстомquota: Int? = nullD27, D31

Не зарезервировано

СловоЧто делаетПримерРешение
textОткрывает блочную строку, но только там, где ожидается значение, - key: text или x = text в конце строки. В остальных местах это обычное имя, так что поле с именем text продолжает работатьbody: text … endD16

Имена типов - String, Int, Float, Bool, List, Object - тоже не ключевые слова: это имена, которые знает проверка схем. Решения из последнего столбца - в журнале решений спецификации.

Встроенные методы

Методы вызываются через точку и составляются в цепочки. Каждая сигнатура ниже сверена с реализацией, а не переписана по памяти.

String

МетодРезультатПримечания
upper() / lower()String"auth".upper() → "AUTH"
len()Intсчитает символы: "héllo".len() → 5
trim()Stringубирает пробельные символы по краям
split(sep)List"a,b".split(",") → ["a", "b"]
replace(from, to)Stringвсе вхождения
starts_with(prefix) / ends_with(suffix)Bool
contains(substr)Bool"hello".contains("ell") → true
to_int() / to_float()Int / Floatнекорректное число — ошибка, а не null
slice(start, end)Stringподстрока по индексу символа, end не включается, индексы зажимаются
to_str()Stringтождество, чтобы не разрывать цепочку
parse_json() / parse_yaml() / parse_toml()Objectцелые становятся Int; сбой — E0314
↳ точностьпорядок ключей — как в документе; YAML << применяется; дата TOML становится String
parse_duration()Int (секунды)"1h30m" → 5400; единицы d/h/m/s; E0319
parse_datetime()Int (epoch UTC)RFC 3339 или YYYY-MM-DD; смещения ±HH:MM; E0320
sha256()Stringhex-дайджест в нижнем регистре
base64() / base64_decode()Stringстандартный алфавит с дополнением; неверный вход — E0321

Int

МетодРезультатПримечания
abs()Int
to_float()Floatрасширение; Float.to_int() нет — ему пришлось бы округлять
to_str()String
format_duration()String5400 → "1h30m"; 0 → "0s"
format_datetime()StringRFC 3339 UTC: 946684800 → "2000-01-01T00:00:00Z"

Float

МетодРезультат
abs()Float
round(digits)Float
to_str()String

Bool

МетодРезультат
to_str()String

List

МетодРезультатПримечания
len()Int
first() / last()элементпустой список — E0317
get(i) / get(i, default)элементпромах даёт null или указанный default
contains(item)Boolструктурное равенство
compact()Listубирает null
uniq()Listдедупликация, порядок первых вхождений
map (x, i) -> ... endListколбэк получает элемент и его индекс
filter (x, i) -> Bool endListне-Bool из колбэка — E0306
join(sep)Stringтолько скаляры; контейнеры — E0307
sort()List
reverse()List
sum()Int
min() / max()элемент
flatten()Listна один уровень: [[1], [2, 3]] → [1, 2, 3]
to_object()Objectсобирает объект из пар { key, value }; повтор ключа — E0323
reduce(init) (acc, e) -> … endAnyлевая свёртка; начальное значение обязательно
any (e, i) -> Bool endBoolхотя бы один подходит; для пустого списка — false
all (e, i) -> Bool endBoolподходят все; для пустого списка — true
find(default) (e, i) -> Bool endAnyпервое совпадение или default
index_of(value, default)Intиндекс первого равного элемента или default; default обязан быть Int
group_by (e, i) -> key endObjectэлементы по ключу-строке, ключи в порядке первого появления (D39)
sort_by (e, i) -> key endListпо возрастанию и устойчиво; ключи как у sort() (D39)
flat_map (e, i) -> [...] endListmap, затем списки склеиваются на один уровень; не-список из лямбды - E0306 (D39)
zip(other)Listпары { left, right }; списки разной длины - E0327 (D39)
slice(start, end)Listend не включается: [1, 2, 3].slice(1, 3) → [2, 3]
to_json() / to_yaml() / to_toml()Stringкомпактная форма, для встраивания; to_toml на списке — E0603
to_json_pretty() / to_yaml_pretty() / to_toml_pretty()Stringформа для человека, для файла, который попадает в diff

Object

МетодРезультатПримечания
len()Int
keys() / values()Listпорядок объявления
contains(key)Bool
get(key, default)значениепромах даёт default
entries()Listобъекты { key, value } в порядке объявления; обратное к to_object()
merge(other)Objectключи правой стороны побеждают
to_json() / to_yaml() / to_toml()Stringкомпактная форма, для встраивания
to_json_pretty() / to_yaml_pretty() / to_toml_pretty()Stringформа для человека, для файла, который попадает в diff

Операторы, которые строят значения

Почти все операторы числовые. Два — нет, и только у них результат может быть больше того, что пришло на вход.

ВыражениеРезультатПримечания
"a" + "b"Stringсклеивает две строки; интерполяция "#{a}#{b}" делает то же самое
[1] + [2, 3]Listсклеивает два списка; операнды не меняются

+ никогда не преобразует. "port " + 8080 — это E0306, и диагностика называет сторону, на которой нужно вызвать .to_str(). Склейка ограничена 1 000 000 элементов списка и 16 МиБ строки, сообщается как E0322.

Глобальные функции

ФункцияТребуемое правоПоведение
range(n)—[0, 1, … n-1]
env(name, default)--allow-envпеременная окружения; отсутствует → default
read_file(path)--allow-readсодержимое файла строкой; относительный путь - от папки манифеста (D37)
fail(message)—останавливает выполнение с E0531 и вашим сообщением

Вызов метода имеет приоритет над одноимённым полем объекта, в котором лежит функция. Экспортированные функции пакета вызываются тем же синтаксисом: pkg.fn(...).

Коды ошибок и предупреждений

Каждая диагностика Aura имеет стабильный код — по нему удобно искать в CI-логах и ссылаться в обсуждениях. E — ошибка, W — предупреждение (в --strict предупреждения блокируют).

Хост (E00xx)

Возникают на границе между манифестом и приложением, которое его встраивает. У E0001-E0006 нет позиции в исходнике, потому что разбор ещё не начинался; E0007-E0009 указывают на вызов в скрипте (D35).

КодЗначениеКак исправить
E0001хост не смог открыть файл либо aura.lock нечитаемв сообщении — исходная ошибка ввода-вывода
E0002скрипт ничего не экспортирует под этим именемпроверьте имя; экспортом являются только свойства : и pub def
E0003экспорт — не функцияполучите его через get, а не function или call
E0004у функции другое число аргументовпроверяется при привязке функции, до любого вызова
E0005у аргумента нет точного значения в Auraцелое за пределами i64, NaN или бесконечность, байтовая строка; отвергается, а не приводится
E0006результат не подходит под тип хостасообщение называет путь внутри значения, где не совпало
E0007скрипт импортирует или вызывает функцию хоста, которую хост не предоставилмодуль host: предоставляет приложение во время выполнения, поэтому aura eval останавливается здесь; aura check перечисляет, что нужно скрипту
E0008функция хоста вернула ошибкусообщение - от приложения; скрипт не может её обработать
E0009функция хоста вызвана с неверным числом аргументов (при загрузке) или типами (при вызове) либо вернула значение, которого нет в Auraсообщение называет функцию
E0010скрипт вызван изнутри одной из его же функций хостазапрещено (D35): верните значение скрипту
E0011перезагрузка изменила бы экспорт скрипта: экспорт удалён, либо изменился его вид или число аргументовхост мог его привязать, поэтому продолжает работать старая версия (D36); чтобы изменить экспорт, перезапустите приложение

Лексика (E01xx)

КодСмыслКак чинить
E0101после десятичной точки нет цифры (12.)12.0 либо 12
E0102незакрытая строка до конца строкистроки в Aura однострочные; закройте "
E0103целочисленный литерал не помещается в i64
E0104registry-импорт без версииimport a/b@v1 as x — версия обязательна (D8)
E0105неожиданный символ
E0106незакрытая интерполяция #{
E0107незакрытая блочная строказакрыть end на отступе открывающей строки (D16)
E0108разделитель разрядов стоит неверно_ группирует по три от точки: 10_000_000, 0.000_001 — но не 100_, 1__0 и не 2_3_7_1_9_3_3

Синтаксис (E02xx)

КодСмыслКак чинить
E0200ожидался конкретный токентекст ошибки называет какой
E0201инлайн-блок из v1.1 (metrics port: 9090)объектный блок: metrics: … end (D3)
E0203не хватает end / ]
E0204ожидалось выражение
E0205statement’ы разделяются переносами строк
E0206pub не перед def/type/enumсвойства экспортируются сами; = всегда приватен
E0207у cond нет ветки elseдобавить else -> …: каждая ветвь обязана дать значение (D14)
E0208слишком глубокая вложенностьпредел рекурсии, из-за которого глубоко вложенный ввод — ошибка, а не переполнение стека; цепочка вида a + b + c засчитывает свою длину (D42)
E0211член enum — не строка в кавычкахчлены перечисляются строками, по одной на строку (D18)
E0212дубликат члена enumкаждый член указывается один раз
E0213enum без членовпустой набор нельзя удовлетворить
E0214значение вычислено и выброшеноголое выражение не в конце функции никуда не идёт; напишите key:, name = или assert (D40)
E0215в теле есть свойства, и оно же кончается значениемфункция возвращает одно или другое; сделайте значение свойством или уберите свойства (D40)

Выполнение (E03xx)

КодСмыслКак чинить
E0301повторное присваивание — значения иммутабельныновое имя либо вложенный scope
E0302затенение внешней переменной без маркераshadow x = ... (D7)
W0303shadow ничего не затеняетуберите shadow
E0304переполнение целочисленной арифметикиFloat либо пересмотрите величины
E0305деление на ноль
E0306неподходящий тип операндаусловия — строго Bool; «правдивости» нет
E0307в интерполяции/join не скалярсериализуйте явно: .to_json()
E0308неизвестное поле объектаопечатка? .get(key, default) для опциональных
E0309неизвестный методсписок — в справочнике методов; поля-функции пакетов вызываются так же
E0310нет прав (capability)подсказка в ошибке называет нужный флаг (D1)
E0311путь вне разрешённых --allow-read каталогов
E0312функции передано меньше аргументов, чем параметров
E0313ошибка чтения файла (I/O)
E0314некорректный TOML/JSON/YAML при парсинге
E0315методу (map/filter) не передана лямбда
E0316некорректное выражение внутри #{...}
E0317индекс вне границ списка / first()/last() на пустом.get(i, default)
E0318obj["key"] — скобки на объектах запрещеныточечная форма: obj."key" (D11)
E0319некорректная длительностьформат "1h30m", единицы d/h/m/s
E0320некорректная датаRFC3339: "2026-07-18T12:00:00Z"
E0321некорректный base64base64_decode() на входе, который не является base64
E0322склеенный список или строка превышает предел размера+ удваивает: потолок 1M элементов / 16 MiB
E0323to_object() получил один и тот же ключ дваждымолчаливая перезапись зависела бы от порядка элементов; уберите дубли заранее
E0324null выводится как текстинтерполяция и to_str() обязаны совпадать; скажите, что печатать в пустом случае (D31)
E0325вычисление исчерпало бюджет шагов--max-steps его увеличивает, --stats показывает расход (D34); в Script бюджет — на вызов (D33)
E0326вычисление исчерпало бюджет памяти--max-memory его увеличивает; считается каждое созданное значение и кадр (D34)
E0327zip() получил списки разной длинымолча отброшенный хвост унёс бы часть конфигурации; сначала обрежьте длинный через slice (D39)
E0328значение вложено глубже 256 списков и объектовпочти всегда цикл, который на каждом шаге заворачивает результат; стройте уровни рядом, а не друг в друге (D43)
E0398внутреннее: окружение замыкания было освобожденоошибка в Aura — пришлите манифест в issue
E0399вычисление вложено глубже 256 уровнейпочти всегда рекурсивная функция без условия остановки; map, filter и reduce обходят список без вложенности (D42)
E0533now()/timestamp() не существуютвремя передаёт хост: env("BUILD_TIME", ...) (D13)

Модули и пакеты (E04xx)

КодСмыслКак чинить
E0401циклический импорт (цепочка в сообщении)разорвите цикл: общий код — в третий модуль
E0402integrity-хэш пакета не совпал с aura.lockсодержимое пакета изменилось — разберитесь почему
E0403--frozen: записи нет в aura.lockaura add локально, лок — в коммит
E0404модуль не найден / не резолвитсяпуть, версия, --registry-dir
E0410импорт передан интерпретатору без загрузчика модулейвстречается только при прямом встраивании вычислителя; используйте facade::eval_file/eval_source, которые грузят модули

Анализ и валидация (E05xx / W05xx)

КодСмыслКак чинить
W0501неиспользуемая переменнаямёртвый код — удалите
W0502неиспользуемый импорт
W0503неиспользуемая функция/типдля API пакета — пометьте pub
E0504необъявленная переменнаяобъявление должно предшествовать использованию
E0505env() или read_file() при --hermeticпередать значение снаружи либо снять флаг и выдать право
E0511отсутствует поле, требуемое схемой
E0512тип поля не совпал со схемойInt и Float — разные типы
E0513лишнее поле (только --strict)уберите поле либо добавьте в схему
E0514значение не входит в enumподсказка предлагает ближайший член и перечисляет набор (D18)
E0515инвариант схемы не выполняется для этого экземплярасобственный assert схемы; показано его же сообщение
E0516значение по умолчанию, которое поле принять не можетпроверяется там, где схема объявлена, а не там, где используется
W0512эффектный вызов в импортированном модулеу импортов нет I/O-прав; пересмотрите дизайн пакета
W0513нулевое поле заполняется из get(key) или env(name) без значения по умолчаниюотсутствие тихо станет null; задайте значение по умолчанию или уберите ?
E0530провал assertсообщение — ваше собственное
E0531вызван fail(...)

Сериализация (E06xx)

КодСмыслКак чинить
E0601функция/схема внутри выводимого деревапуть ключа — в сообщении; на верхнем уровне pub-элементы исключаются сами
E0602не-конечный Float (NaN/Inf)
E0603ограничение формата (TOML: нет null, корень — объект)
E0604два ключа сплющиваются в один ключ json-flatключ с точкой неотличим от вложенного пути; взять --format json либо переименовать ключ