plan: nullary-app.1 — accept (app f) as canonical zero-arg call form
Resolves the design fork in Gitea #12 (the bounce-back from the 2026-05-20 /boss session). User accepted both half-decisions: (a) the surface form `(app f)` becomes the canonical nullary call shape, dropping the "expected at least one argument" parser guard in `parse_app_body`; (b) `Term::App.args` mirrors the *actual* serde attrs of `Term::Ctor.args` — `#[serde(default)]` for read-tolerance, no `skip_serializing_if`, so writes always emit `"args":[]`. The plan covers five small tasks: a RED→GREEN E2E (nullary user fn called as `(app greet)`), the four-line parser-guard removal, the one-attribute serde edit, and a doc-honesty fix on `design/contracts/data-model.md` — the current "args omitted when empty" comment on `Term::Ctor` is factually false relative to the code (verified by a serde probe at plan time) and gets rewritten to describe actual write/read behaviour while we are in the same jsonc block. `design/contracts/honesty-rule.md` enforcement folded into the same iter because leaving a stale comment behind in the block we're modifying would be the exact failure mode the rule names. Hash-impact verified at plan time: `grep -rn '"args":\[\]' examples/*.ail.json` returns zero matches, so the new `#[serde(default)]` on `App.args` is read-only behaviour change — write side is unchanged, no existing fixture hash mutates. The plan documents this explicitly under Task 3.2. Two prior fieldtest specs already supplied the LLM-natural shape evidence — `docs/specs/2026-05-15-fieldtest-mut-local.md` F3 (mut-local) and `docs/specs/2026-05-18-fieldtest-loop-recur.md` spec_gap (`run_forever`) — so the feature-acceptance gate in `design/contracts/feature-acceptance.md` is already passed; no brainstorm phase needed for this iter. refs #12
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
# 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/2026-05-15-fieldtest-mut-local.md` F3,
|
||||
> `docs/specs/2026-05-18-fieldtest-loop-recur.md` spec_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/implement`
|
||||
> to 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/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/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/data-model.md` (schema doc), `crates/ail` (E2E
|
||||
test).
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Create: `examples/nullary_app_smoke.ail` — single nullary user fn
|
||||
`greet : fn() -> Unit !IO` called 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 from `parse_app_body`; the doc comment on
|
||||
`parse_app_body` changes from "callee + 1+ args + closing `)`" to
|
||||
"callee + 0+ args + closing `)`". The grammar comment near the top
|
||||
of the file (`parse.rs:48`) changes `term+` to `term*`.
|
||||
- Modify: `crates/ailang-core/src/ast.rs:419-425` — annotate
|
||||
`Term::App.args` with `#[serde(default)]`, mirroring
|
||||
`Term::Ctor.args` at line 472-477. `skip_serializing_if` is NOT
|
||||
added (mirror Ctor's *actual* attrs, not the doc claim).
|
||||
- Modify: `design/contracts/data-model.md:112` — add an inline comment
|
||||
to the `app` jsonc shape that `args` MAY be empty (e.g. nullary
|
||||
call `(app f)`).
|
||||
- Modify: `design/contracts/data-model.md:130-131` — rewrite the
|
||||
misleading "args omitted when empty" comment on the `ctor` jsonc
|
||||
shape so it describes the actual write/read behaviour: write
|
||||
always emits `"args":[]`; read tolerates absent `args`.
|
||||
|
||||
---
|
||||
|
||||
## 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`:
|
||||
|
||||
```lisp
|
||||
(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 `greet` and `main` both have empty `(params)` and both type
|
||||
to `fn() !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`:
|
||||
|
||||
```rust
|
||||
//! 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/2026-05-15-fieldtest-mut-local.md` F3 and
|
||||
//! `docs/specs/2026-05-18-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_e2e`
|
||||
|
||||
Expected: FAIL. The failure must mention "expected at least one
|
||||
argument" in the `ail build` stderr (visible because `build_and_run`
|
||||
asserts `status.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:48` currently reads:
|
||||
|
||||
```
|
||||
//! app-term ::= "(" "app" term term+ ")"
|
||||
```
|
||||
|
||||
Change `term+` to `term*`. Also update the next line (the
|
||||
`tail-app-term` grammar) to match — that line currently mirrors the
|
||||
same `term+`.
|
||||
|
||||
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_body` doc comment**
|
||||
|
||||
`crates/ailang-surface/src/parse.rs:1315-1316` reads:
|
||||
|
||||
```rust
|
||||
/// Body shared by [`Self::parse_app`] and [`Self::parse_tail_app`]:
|
||||
/// callee + 1+ args + closing `)`.
|
||||
```
|
||||
|
||||
Change `1+ args` to `0+ args`.
|
||||
|
||||
- [ ] **Step 2.3: Drop the empty-args guard**
|
||||
|
||||
Delete `crates/ailang-surface/src/parse.rs:1323-1331` — the entire
|
||||
guard block:
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
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_e2e`
|
||||
|
||||
Expected: 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.args` with `#[serde(default)]`**
|
||||
|
||||
`crates/ailang-core/src/ast.rs:419-425` currently reads:
|
||||
|
||||
```rust
|
||||
App {
|
||||
#[serde(rename = "fn")]
|
||||
callee: Box<Term>,
|
||||
args: Vec<Term>,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
tail: bool,
|
||||
},
|
||||
```
|
||||
|
||||
Add `#[serde(default)]` to `args` so a canonical JSON document that
|
||||
omits the `args` key parses to an empty `Vec`. Do NOT add
|
||||
`skip_serializing_if = "Vec::is_empty"` — Ctor's actual write
|
||||
behaviour always emits `"args":[]`; this iter mirrors that exactly:
|
||||
|
||||
```rust
|
||||
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, and
|
||||
`tail`'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 --quiet`
|
||||
|
||||
Expected: every existing test still PASS. The new `#[serde(default)]`
|
||||
is read-tolerance-only — it changes deserialisation behaviour for
|
||||
documents missing `args`, but every existing `.ail.json` fixture
|
||||
has a non-empty `args` (verified at plan-write time:
|
||||
`grep -rn '"args":\[\]' examples/*.ail.json` returns 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/data-model.md:110-131`
|
||||
|
||||
This task is `design/contracts/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 `app` shape**
|
||||
|
||||
`design/contracts/data-model.md:110-112` currently reads:
|
||||
|
||||
```jsonc
|
||||
// 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 `args` may
|
||||
be empty (nullary call). New text:
|
||||
|
||||
```jsonc
|
||||
// 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 `ctor` comment**
|
||||
|
||||
`design/contracts/data-model.md:130-131` currently reads:
|
||||
|
||||
```jsonc
|
||||
// Ctor application; `args` omitted when empty.
|
||||
{ "t": "ctor", "type": "<id>", "ctor": "<id>", "args": [Term...] }
|
||||
```
|
||||
|
||||
Replace with the actual behaviour:
|
||||
|
||||
```jsonc
|
||||
// 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_drift`
|
||||
|
||||
Expected: 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 --quiet`
|
||||
|
||||
Expected: PASS across all crates. No regressions; new
|
||||
`nullary_app_e2e` test 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_drift`
|
||||
|
||||
Expected: 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_trip`
|
||||
|
||||
Expected: PASS. The new fixture
|
||||
`examples/nullary_app_smoke.ail` has no `.ail.json` counterpart
|
||||
(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.
|
||||
Reference in New Issue
Block a user