All 176 files in the four accumulating directories now use a zero-padded 4-digit counter prefix that reflects creation order (`NNNN-slug.md`). The counter is assigned per directory in strict git-log creation order; ties broken alphabetically by original name. The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is dropped — the date is recoverable from git log and the counter carries the ordering. A file's counter is stable for the life of the file: never reassigned, never reused, never compacted. Deleted files retire their counter; subsequent files do not fill the gap. This is the property that lets cross-references stay literal — refs use the full filename including the counter (`design/contracts/0007-honesty-rule.md`) so they grep cleanly and resolve directly without a glob step. 313 cross-references updated across .md/.rs/.toml/.c/.json files (test pins, include_str! paths, design-INDEX entries, baseline notes, runtime C comments, inter-contract markdown links incl. bare basename and `../models/foo.md` forms). CLAUDE.md gets a new "File-naming convention" section spelling out the rule and rationale. skills/brainstorm/SKILL.md and skills/planner/SKILL.md updated so new spec/plan creation produces counter-prefixed names from the start. The full test suite (cargo test --workspace) passes.
15 KiB
nullary-app.1 — Accept (app f) as the canonical zero-arg call form
No parent spec. Gitea issue #12 carries the resolved design fork verbatim; two prior fieldtest specs (
docs/specs/0030-fieldtest-mut-local.mdF3,docs/specs/0035-fieldtest-loop-recur.mdspec_gap) are the LLM-natural-shape evidence. User accepted(app f)+ Ctor-style serde convention on 2026-05-20.For agentic workers: REQUIRED SUB-SKILL: use
skills/implementto run this plan.
Goal: Lift the surface parser's "at least one argument" rule on
app-term (and tail-app-term) so a niladic function can be called
as (app f). Round-trip and downstream stages already accept empty
args mechanically — typechecker's arity check is args.len() == params.len() (passes for 0 == 0), codegen's args.iter().zip(...)
loop is a no-op for empty args, surface printer's arg-emit loop
prints nothing for empty args. The fix is the parser guard plus one
serde-attribute edit on Term::App.args to mirror Term::Ctor.args
(read-tolerant when args is absent in canonical JSON).
Doc-honesty side-fix: the existing comment on Term::Ctor in
design/contracts/0002-data-model.md:130-131 claims "args omitted when
empty", which is factually false for the current code — Ctor.args
has #[serde(default)] (read-tolerant) but NO skip_serializing_if,
so writes always emit "args":[]. Fix the comment to describe actual
behaviour. This is design/contracts/0007-honesty-rule.md enforcement
folded into the same iter (the discovery happened while researching
this iter's serde change, and a stale comment in the same jsonc
block we're modifying must not be left behind).
Architecture: Three surface-layer edits + one schema-doc edit + one
new E2E test (which is also the RED-then-GREEN pin for the parser
change). No typechecker, codegen, or runtime changes — every layer
below the parser already handles empty args correctly today.
Tech Stack: ailang-surface (parser), ailang-core (AST serde),
design/contracts/0002-data-model.md (schema doc), crates/ail (E2E
test).
Files this plan creates or modifies:
- Create:
examples/nullary_app_smoke.ail— single nullary user fngreet : fn() -> Unit !IOcalled as(app greet). - Create:
crates/ail/tests/nullary_app_e2e.rs— builds and runs the fixture; asserts"hello\n"stdout. - Modify:
crates/ailang-surface/src/parse.rs:1322-1342— drop the 4-line empty-args guard fromparse_app_body; the doc comment onparse_app_bodychanges from "callee + 1+ args + closing)" to "callee + 0+ args + closing)". The grammar comment near the top of the file (parse.rs:48) changesterm+toterm*. - Modify:
crates/ailang-core/src/ast.rs:419-425— annotateTerm::App.argswith#[serde(default)], mirroringTerm::Ctor.argsat line 472-477.skip_serializing_ifis NOT added (mirror Ctor's actual attrs, not the doc claim). - Modify:
design/contracts/0002-data-model.md:112— add an inline comment to theappjsonc shape thatargsMAY be empty (e.g. nullary call(app f)). - Modify:
design/contracts/0002-data-model.md:130-131— rewrite the misleading "args omitted when empty" comment on thectorjsonc shape so it describes the actual write/read behaviour: write always emits"args":[]; read tolerates absentargs.
Task 1: RED — fixture + E2E test for nullary (app f)
Files:
- Create:
examples/nullary_app_smoke.ail - Create:
crates/ail/tests/nullary_app_e2e.rs
The RED test compile-blocks NOTHING (it is a new file using public
CLI surface). It fails at runtime because ail build returns a parse
error from parse_app_body's "expected at least one argument" guard
on the (app greet) call. Watch the failure carry that exact error
string; that confirms the RED hits the targeted symptom.
-
Step 1.1: Write the example fixture
Create
examples/nullary_app_smoke.ail:(module nullary_app_smoke (fn greet (doc "Nullary user function — no params, prints a fixed line.") (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body (do io/print_str "hello\n"))) (fn main (doc "Calls the nullary `greet` as `(app greet)` — the zero-arg surface form accepted under Gitea #12.") (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body (app greet))))Confirm
greetandmainboth have empty(params)and both type tofn() !IO -> Unit. The fixture exercises BOTH a nullary fn definition AND a nullary call to it. -
Step 1.2: Write the RED E2E test
Create
crates/ail/tests/nullary_app_e2e.rs://! Gitea #12 RED→GREEN pin — nullary `(app f)` surface acceptance. //! //! Pre-fix, `ail build` rejects `examples/nullary_app_smoke.ail` //! at parse with `parse_app_body`'s "expected at least one //! argument" error. Post-fix (parse guard dropped), the file //! builds, runs, and prints `"hello\n"` from the nullary `greet` //! invoked as `(app greet)`. //! //! Two independent fieldtest specs flagged the same gap: //! `docs/specs/0030-fieldtest-mut-local.md` F3 and //! `docs/specs/0035-fieldtest-loop-recur.md` spec_gap. The //! Form-A surface refusing nullary calls had no schema backing — //! `Term::App.args` is `[Term...]` (zero-or-more), so the parser //! guard was an unbacked rule. use std::path::Path; use std::process::Command; fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") } fn build_and_run(example: &str) -> String { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace.join("examples").join(example); let tmp = std::env::temp_dir().join(format!( "ailang_e2e_{}_{}", example.replace('.', "_"), std::process::id() )); std::fs::create_dir_all(&tmp).unwrap(); let out = tmp.join("bin"); let status = Command::new(ail_bin()) .args(["build", src.to_str().unwrap(), "-o"]) .arg(&out) .status() .expect("ail build failed to run"); assert!(status.success(), "ail build failed for {example}"); let output = Command::new(&out).output().expect("execute binary"); assert!( output.status.success(), "binary {} exited non-zero", out.display() ); String::from_utf8(output.stdout).expect("stdout utf8") } /// A nullary user fn called as `(app greet)` builds and runs; the /// nullary application surface form is the canonical zero-arg /// shape under the resolution of Gitea #12. #[test] fn nullary_app_builds_and_runs() { let out = build_and_run("nullary_app_smoke.ail"); assert_eq!( out, "hello\n", "nullary `(app greet)` must build, run, and emit `hello\\n`; \ got `{out}` — pre-fix surface parser rejects empty-args at \ `parse_app_body` with `expected at least one argument` (see \ Gitea #12).", ); } -
Step 1.3: Confirm RED
Run:
cargo test --quiet -p ail --test nullary_app_e2eExpected: FAIL. The failure must mention "expected at least one argument" in the
ail buildstderr (visible becausebuild_and_runassertsstatus.success()and the build failure propagates the parser diagnostic). If the failure mode is anything else — wrong stdout instead of build failure, panic from a missing file, etc. — STOP and re-read; the RED is not pinning the targeted symptom.
Task 2: GREEN — drop the empty-args guard in parse_app_body
Files:
-
Modify:
crates/ailang-surface/src/parse.rs:48 -
Modify:
crates/ailang-surface/src/parse.rs:1315-1342 -
Step 2.1: Update the grammar comment near the file top
crates/ailang-surface/src/parse.rs:48currently reads://! app-term ::= "(" "app" term term+ ")"Change
term+toterm*. Also update the next line (thetail-app-termgrammar) to match — that line currently mirrors the sameterm+.Re-verify by re-reading lines 47-50 to ensure both productions now read
term*(zero or more args). -
Step 2.2: Update the
parse_app_bodydoc commentcrates/ailang-surface/src/parse.rs:1315-1316reads:/// Body shared by [`Self::parse_app`] and [`Self::parse_tail_app`]: /// callee + 1+ args + closing `)`.Change
1+ argsto0+ args. -
Step 2.3: Drop the empty-args guard
Delete
crates/ailang-surface/src/parse.rs:1323-1331— the entire guard block:let callee = self.parse_term()?; // 1+ args if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) { let pos = self.peek().map(|t| t.span.start).unwrap_or(0); return Err(ParseError::Production { production, message: "expected at least one argument".into(), pos, }); } let mut args = Vec::new();becomes:
let callee = self.parse_term()?; // 0+ args — empty arg list is the nullary call shape `(app f)`, // resolution of Gitea #12. The schema permits empty `args` on // `Term::App` (data-model.md: `args: [Term...]` is zero-or-more); // typechecker, codegen, and surface printer already handle // empty `args` cleanly today. let mut args = Vec::new(); -
Step 2.4: Confirm Task 1's RED now flips to GREEN
Run:
cargo test --quiet -p ail --test nullary_app_e2eExpected: PASS with the
"hello\n"stdout assertion.
Task 3: GREEN — serde default on Term::App.args
Files:
-
Modify:
crates/ailang-core/src/ast.rs:419-425 -
Step 3.1: Annotate
App.argswith#[serde(default)]crates/ailang-core/src/ast.rs:419-425currently reads:App { #[serde(rename = "fn")] callee: Box<Term>, args: Vec<Term>, #[serde(default, skip_serializing_if = "is_false")] tail: bool, },Add
#[serde(default)]toargsso a canonical JSON document that omits theargskey parses to an emptyVec. Do NOT addskip_serializing_if = "Vec::is_empty"— Ctor's actual write behaviour always emits"args":[]; this iter mirrors that exactly:App { #[serde(rename = "fn")] callee: Box<Term>, #[serde(default)] args: Vec<Term>, #[serde(default, skip_serializing_if = "is_false")] tail: bool, },The doc comment on
App(lines 411-418) does not need an edit — the variant docstring is about semantics, not serialisation, andtail's comment already covers the "additive variants must hash-stably" pattern. -
Step 3.2: Verify the existing corpus still serialises identically
Run:
cargo test --workspace --quietExpected: every existing test still PASS. The new
#[serde(default)]is read-tolerance-only — it changes deserialisation behaviour for documents missingargs, but every existing.ail.jsonfixture has a non-emptyargs(verified at plan-write time:grep -rn '"args":\[\]' examples/*.ail.jsonreturns zero matches). Write behaviour is unchanged. Canonical-byte hashes of every existing fixture are therefore unchanged.
Task 4: Doc-honesty fix — data-model.md ctor and app comments
Files:
- Modify:
design/contracts/0002-data-model.md:110-131
This task is design/contracts/0007-honesty-rule.md enforcement — the
prose-authoritative contract must describe present-state behaviour,
not aspirational behaviour. The current Ctor comment is
aspirational (the omit-when-empty rule is not implemented), and the
App jsonc shape benefits from an explicit note now that nullary
calls are legal.
-
Step 4.1: Add an empty-args note to the
appshapedesign/contracts/0002-data-model.md:110-112currently reads:// fn application; tail flag triggers musttail under codegen. // `tail` is omitted when false (hash-stable when omitted). { "t": "app", "fn": Term, "args": [Term...], "tail": false }Extend the comment block above the jsonc to mention that
argsmay be empty (nullary call). New text:// fn application; tail flag triggers musttail under codegen. // `tail` is omitted when false (hash-stable when omitted). // `args` may be empty: a nullary call is the surface form // `(app f)` (resolution of Gitea #12). Read-tolerant: a JSON // document omitting the `args` key deserialises to `[]`. { "t": "app", "fn": Term, "args": [Term...], "tail": false } -
Step 4.2: Rewrite the misleading
ctorcommentdesign/contracts/0002-data-model.md:130-131currently reads:// Ctor application; `args` omitted when empty. { "t": "ctor", "type": "<id>", "ctor": "<id>", "args": [Term...] }Replace with the actual behaviour:
// Ctor application. `args` is always emitted on write (including // as `"args": []` for niladic ctors); reads tolerate the key being // absent and treat it as `[]`. This mirrors the read/write // asymmetry on `Term::App.args` (see above). { "t": "ctor", "type": "<id>", "ctor": "<id>", "args": [Term...] } -
Step 4.3: Verify drift-pin still green
Run:
cargo test --quiet -p ailang-core --test design_schema_driftExpected: PASS. The drift test scans only jsonc-fenced blocks (per bug #10's GREEN fix); the anchor strings
"t": "app"and"t": "ctor"remain inside the same jsonc block and are still found. The comment changes do NOT move any schema anchor.
Task 5: Workspace-wide sanity
Files: none modified.
-
Step 5.1: Full workspace test
Run:
cargo test --workspace --quietExpected: PASS across all crates. No regressions; new
nullary_app_e2etest in green state from Task 2.4. -
Step 5.2: Drift-pin re-run for honesty-rule compliance
Run:
cargo test --quiet -p ailang-core --test design_index_pin --test design_schema_driftExpected: PASS. Both drift tests are jsonc-fenced-only after bug #10's GREEN; the present iter touches doc-comments inside an existing jsonc block but does not move any anchor.
-
Step 5.3: Round-trip invariant
Run:
cargo test --quiet -p ailang-surface --test round_tripExpected: PASS. The new fixture
examples/nullary_app_smoke.ailhas no.ail.jsoncounterpart (none authored), so it falls under the test's "parse-only" branch. This is correct: the canonical form a Form-A author would produce for nullary(app f)is{"t":"app","fn":{"t":"var","name":"f"}, "args":[]}— write-emit always carries"args":[]. A future iter may author the JSON counterpart explicitly if a hash-stable canonical fixture is wanted; not required for this iter.