Builtin Developer
Guides development of AILANG builtin functions. Use when user wants to add a builtin function, register new builtins, or understand the builtin system. Reduces development time from 7.5h to 2.5h (-67%).
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install sunholo-data-ailang-builtin-developer
Repository
Skill path: .claude/skills/builtin-developer
Guides development of AILANG builtin functions. Use when user wants to add a builtin function, register new builtins, or understand the builtin system. Reduces development time from 7.5h to 2.5h (-67%).
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: sunholo-data.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install Builtin Developer into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/sunholo-data/ailang before adding Builtin Developer to shared team environments
- Use Builtin Developer for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: Builtin Developer
description: Guides development of AILANG builtin functions. Use when user wants to add a builtin function, register new builtins, or understand the builtin system. Reduces development time from 7.5h to 2.5h (-67%).
---
# Builtin Developer
Add new builtin functions to AILANG using the modern registry system (M-DX1).
## Quick Start
**Development time: ~2.5 hours** (down from 7.5h with legacy system)
**Most common workflow:**
1. Register builtin with metadata (~30 min)
2. Write hermetic tests (~1 hour)
3. Validate and inspect (~30 min)
4. Runtime auto-wires (already done!)
## When to Use This Skill
Invoke this skill when:
- User wants to add a new builtin function
- User asks about builtin registration process
- User needs to understand the builtin system
- User wants to validate or inspect builtins
- User asks "how do I add a builtin?"
## System Overview
**Status**: 🎉 **M-DX1 COMPLETE (Oct 2025)** - 52 builtins migrated, fully documented
**Key Benefits:**
- **67% faster**: 2.5h instead of 7.5h
- **1 file instead of 4**: Single-point registration
- **71% less code**: Type Builder DSL reduces boilerplate
- **Automatic wiring**: Registry connects to runtime/link
- **Hermetic tests**: MockEffContext with HTTP/FS mocking
**Components:**
- **Central Registry** (`internal/builtins/spec.go`) - Single registration point
- **Type Builder DSL** (`internal/types/builder.go`) - Fluent type construction
- **Test Harness** (`internal/effects/testctx/`) - Hermetic testing helpers
- **CLI Commands** - Validation and inspection tools
## Available Scripts
### `scripts/validate_builtins.sh`
Validate all builtins in the registry.
**Usage:**
```bash
.claude/skills/builtin-developer/scripts/validate_builtins.sh
```
**What it checks:**
- All builtins are registered
- Proper metadata structure
- Type signatures valid
- Test coverage exists
### `scripts/check_builtin_health.sh`
Run ailang doctor and list commands.
**Usage:**
```bash
.claude/skills/builtin-developer/scripts/check_builtin_health.sh
```
## Workflow
### Step 1: Register the Builtin (~30 min)
**Create or edit module file** (e.g., `internal/builtins/string.go`):
```go
// internal/builtins/string.go
func init() {
registerMyBuiltin()
}
func registerMyBuiltin() {
RegisterEffectBuiltin(BuiltinSpec{
Module: "std/string",
Name: "_str_reverse",
NumArgs: 1,
IsPure: true, // or false with Effect: "IO"
Type: makeReverseType,
Impl: strReverseImpl,
Metadata: &BuiltinMetadata{
Description: "Reverse a string (Unicode-aware)",
Params: []ParamDoc{
{Name: "s", Description: "String to reverse"},
},
Returns: "Reversed string",
Examples: []Example{
{Code: `_str_reverse("hello")`, Description: "Returns \"olleh\""},
},
Since: "v0.3.15",
Stability: StabilityStable,
Tags: []string{"string", "reverse", "unicode"},
Category: "string",
},
})
}
func makeReverseType() types.Type {
T := types.NewBuilder()
return T.Func(T.String()).Returns(T.String())
}
func strReverseImpl(ctx *effects.EffContext, args []eval.Value) (eval.Value, error) {
str := args[0].(*eval.StringValue).Value
runes := []rune(str)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return &eval.StringValue{Value: string(runes)}, nil
}
```
**Key points:**
- Use `RegisterEffectBuiltin()` in `init()`
- Use Type Builder DSL for type construction
- Include complete metadata (description, params, examples, tags)
- Set `IsPure: true` for pure functions, or `Effect: "IO"` for effects
**See:** [resources/type_builder_examples.md](resources/type_builder_examples.md) for type patterns
### Step 2: Write Hermetic Tests (~1 hour)
**Add tests in** `internal/builtins/register_test.go`:
```go
func TestStrReverse(t *testing.T) {
ctx := testctx.NewMockEffContext()
tests := []struct {
input string
expected string
}{
{"hello", "olleh"},
{"", ""},
{"🎉", "🎉"},
}
for _, tt := range tests {
result, err := strReverseImpl(ctx, []eval.Value{
testctx.MakeString(tt.input),
})
assert.NoError(t, err)
assert.Equal(t, tt.expected, testctx.GetString(result))
}
}
```
**Test Harness helpers:**
- `testctx.NewMockEffContext()` - Create test context
- `testctx.MakeString()`, `MakeInt()`, `MakeRecord()` - Create test values
- `testctx.GetString()`, `GetInt()`, `GetRecord()` - Extract values
**For HTTP/FS effects:**
```go
ctx := testctx.NewMockEffContext()
ctx.GrantAll("Net") // Grant Net capability
ctx.SetHTTPClient(server.Client()) // Use test server
```
**See:** [resources/testing_patterns.md](resources/testing_patterns.md) for more examples
### Step 3: Validate and Inspect (~30 min)
**Run validation:**
```bash
# Validate all builtins
ailang doctor builtins
# List all builtins by module
ailang builtins list --by-module
# Check for orphaned builtins (migration safety)
ailang builtins check-migration
```
**Run tests:**
```bash
# Test specific builtin
go test -v internal/builtins -run TestStrReverse
# Test all builtins
make test
```
**Test in REPL:**
```bash
ailang repl
> :type _str_reverse
string -> string
> _str_reverse("hello")
"olleh" : string
```
### Step 4: Runtime Wiring (Automatic!)
No manual wiring needed! The registry automatically:
- ✅ Registers with evaluator (`internal/eval`)
- ✅ Adds to type environment (`internal/types`)
- ✅ Links to runtime (`internal/runtime`)
- ✅ Generates module interface (`internal/link`)
**Feature flag removed in v0.3.10** - New registry is now the default.
## Key Components
### Central Registry
**Location:** `internal/builtins/spec.go`
**Features:**
- Single registration point with `RegisterEffectBuiltin()`
- Compile-time validation (arity, types, impl, effects)
- Freeze-safe (no registration after init)
- 52 builtins migrated
### Type Builder DSL
**Location:** `internal/types/builder.go`
**Reduces type construction from 35 lines → 10 lines (-71%)**
**Available methods:**
```go
T := types.NewBuilder()
// Primitive types
T.String()
T.Int()
T.Float()
T.Bool()
T.Unit()
// Complex types
T.List(elementType)
T.Record(fields...)
T.Tuple(types...)
T.Con("TypeName")
T.Var("α")
// Function types
T.Func(arg1, arg2, ...).Returns(returnType).Effects("IO", "FS")
// Type applications
T.App("Result", okType, errType)
```
**See:** [resources/type_builder_examples.md](resources/type_builder_examples.md)
### Test Harness
**Location:** `internal/effects/testctx/`
**Value constructors:**
- `MakeString(s string)` → StringValue
- `MakeInt(i int)` → IntValue
- `MakeFloat(f float64)` → FloatValue
- `MakeBool(b bool)` → BoolValue
- `MakeList([]Value)` → ListValue
- `MakeRecord(map[string]Value)` → RecordValue
**Value extractors:**
- `GetString(Value)` → string
- `GetInt(Value)` → int
- `GetFloat(Value)` → float64
- `GetBool(Value)` → bool
- `GetList(Value)` → []Value
- `GetRecord(Value)` → map[string]Value
**Effect mocking:**
- `ctx.GrantAll(effect)` - Grant capabilities
- `ctx.SetHTTPClient(client)` - Mock HTTP
- `ctx.SetFS(fs)` - Mock filesystem
### Validation & Inspection
**Commands:**
```bash
ailang doctor builtins # Health checks
ailang builtins list # List all builtins
ailang builtins list --by-module # Group by module
ailang builtins list --by-effect # Group by effect
ailang builtins check-migration # Check for orphaned builtins
```
**6 validation rules:**
1. Type function exists and valid
2. Implementation function exists
3. Arity matches NumArgs
4. Effect consistency (IsPure vs Effect field)
5. Module name valid
6. Metadata complete
## Metrics
**Development time:**
| Task | Before | After | Savings |
|------|--------|-------|---------|
| Files to edit | 4 | 1 | -75% |
| Type construction | 35 LOC | 10 LOC | -71% |
| Total time | 7.5h | 2.5h | -67% |
| Test setup | ~50 LOC | ~15 LOC | -70% |
## Resources
### Detailed Examples
**For type patterns:**
- [resources/type_builder_examples.md](resources/type_builder_examples.md) - All type patterns
**For testing patterns:**
- [resources/testing_patterns.md](resources/testing_patterns.md) - Hermetic test examples
**For comprehensive guide:**
- [M-DX1-FINAL-SUMMARY.md](../../../M-DX1-FINAL-SUMMARY.md) - Complete M-DX1 summary
- `design_docs/planned/easier-ailang-dev.md` - Design rationale
- `CHANGELOG.md` (v0.3.10+) - Version history
### File Locations
**Builtin files by module:**
- `internal/builtins/string.go` - String functions (9 builtins)
- `internal/builtins/math.go` - Math functions (37 builtins)
- `internal/builtins/io.go` - IO functions (3 builtins)
- `internal/builtins/net.go` - Network functions (1 builtin)
- `internal/builtins/show.go` - Show typeclass (1 builtin)
- `internal/builtins/json_decode.go` - JSON decoding (1 builtin)
**Test files:**
- `internal/builtins/*_test.go` - Builtin tests
- `internal/effects/testctx/*_test.go` - Test harness tests
## Common Patterns
### Pattern 0: "Zero-Arg" Builtins (M-DX10 Unit-Argument Model)
**⚠️ AILANG has no true nullary functions!** All functions that appear to take no arguments must take a `unit` parameter.
```go
// ✅ CORRECT - Unit-argument model
RegisterEffectBuiltin(BuiltinSpec{
Module: "std/sharedmem",
Name: "_sharedmem_keys",
NumArgs: 1, // Takes unit parameter!
Effect: "SharedMem",
Type: func() types.Type {
T := types.NewBuilder()
return T.Func(T.Unit()).Returns(T.List(T.String())).Effects("SharedMem")
},
Impl: func(ctx *effects.EffContext, args []eval.Value) (eval.Value, error) {
// args[0] is unit, ignored (validates arity)
keys := ctx.SharedMem.Cache.Keys()
// ...
},
})
// ❌ WRONG - True nullary (causes "arity mismatch: 0 vs 1")
RegisterEffectBuiltin(BuiltinSpec{
Name: "_sharedmem_keys",
NumArgs: 0, // BUG!
})
```
**Stdlib wrapper:**
```ailang
export func keys(u: unit) -> list[string] ! {SharedMem} {
_sharedmem_keys(u)
}
-- Or expression body:
export func now() -> int ! {Clock} = _clock_now()
```
**Go tests must pass unit:**
```go
// ✅ Correct
result, err := impl(ctx, []eval.Value{&eval.UnitValue{}})
// ❌ Wrong - causes arity mismatch
result, err := impl(ctx, []eval.Value{})
```
**Reference:** [M-DX10 design doc](../../../design_docs/implemented/v0_4_6/m-dx10-nullary-function-calls.md)
### Pattern 1: Pure String Function
```go
RegisterEffectBuiltin(BuiltinSpec{
Module: "std/string",
Name: "_str_len",
NumArgs: 1,
IsPure: true,
Type: func() types.Type {
T := types.NewBuilder()
return T.Func(T.String()).Returns(T.Int())
},
Impl: func(ctx *effects.EffContext, args []eval.Value) (eval.Value, error) {
s := args[0].(*eval.StringValue).Value
return &eval.IntValue{Value: len([]rune(s))}, nil
},
})
```
### Pattern 2: Effect Function with HTTP
```go
RegisterEffectBuiltin(BuiltinSpec{
Module: "std/net",
Name: "_net_httpRequest",
NumArgs: 4,
Effect: "Net",
Type: makeHTTPRequestType,
Impl: effects.NetHTTPRequest, // Uses ctx.GetHTTPClient()
})
```
### Pattern 3: Complex Record Types
**See:** [resources/type_builder_examples.md](resources/type_builder_examples.md) for full example with nested records.
## Progressive Disclosure
This skill loads information progressively:
1. **Always loaded**: This SKILL.md file (workflow overview)
2. **Execute as needed**: Scripts in `scripts/` (validation)
3. **Load on demand**: Detailed type examples in `resources/`
## Notes
- No feature flag needed (default since v0.3.10)
- Registry auto-wires to all subsystems
- Test harness prevents network/FS side effects
- Metadata enables future tooling (docs, LSP)
- Type Builder DSL is type-safe at compile time
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### resources/type_builder_examples.md
```markdown
# Type Builder Examples
Complete examples of using the Type Builder DSL for AILANG builtins.
## Basic Types
### Pure String Function
```go
func makeStrLenType() types.Type {
T := types.NewBuilder()
return T.Func(T.String()).Returns(T.Int())
}
```
### Multi-Argument Function
```go
func makeStrSliceType() types.Type {
T := types.NewBuilder()
return T.Func(
T.String(), // string to slice
T.Int(), // start index
T.Int(), // end index
).Returns(T.String())
}
```
## Complex Types with Records
### HTTP Request Function
```go
func makeHTTPRequestType() types.Type {
T := types.NewBuilder()
// Define header type: {name: string, value: string}
headerType := T.Record(
types.Field("name", T.String()),
types.Field("value", T.String()),
)
// Define response type: {status: int, headers: [Header], body: string}
responseType := T.Record(
types.Field("status", T.Int()),
types.Field("headers", T.List(headerType)),
types.Field("body", T.String()),
)
// Function signature with Result type
return T.Func(
T.String(), // url
T.String(), // method
T.List(headerType), // headers
T.String(), // body
).Returns(
T.App("Result", responseType, T.Con("NetError")),
).Effects("Net")
}
```
### JSON Decode Function
```go
func makeJSONDecodeType() types.Type {
T := types.NewBuilder()
// Use type variable for polymorphic return
alpha := T.Var("α")
// Result[α, JSONError]
resultType := T.App("Result", alpha, T.Con("JSONError"))
return T.Func(T.String()).Returns(resultType)
}
```
## List and Tuple Types
### List Operations
```go
func makeListMapType() types.Type {
T := types.NewBuilder()
alpha := T.Var("α")
beta := T.Var("β")
// (α -> β) -> [α] -> [β]
return T.Func(
T.Func(alpha).Returns(beta), // mapper function
T.List(alpha), // input list
).Returns(T.List(beta))
}
```
### Tuple Constructor
```go
func makeTupleType() types.Type {
T := types.NewBuilder()
alpha := T.Var("α")
beta := T.Var("β")
// α -> β -> (α, β)
return T.Func(alpha, beta).Returns(
T.Tuple(alpha, beta),
)
}
```
## Effect Functions
### File System Read
```go
func makeFSReadFileType() types.Type {
T := types.NewBuilder()
// Result[string, FSError]
resultType := T.App("Result", T.String(), T.Con("FSError"))
return T.Func(T.String()).Returns(resultType).Effects("FS")
}
```
### Multiple Effects
```go
func makeHTTPWithLoggingType() types.Type {
T := types.NewBuilder()
headerType := T.Record(
types.Field("name", T.String()),
types.Field("value", T.String()),
)
responseType := T.Record(
types.Field("status", T.Int()),
types.Field("body", T.String()),
)
return T.Func(
T.String(),
T.String(),
T.List(headerType),
).Returns(
T.App("Result", responseType, T.Con("NetError")),
).Effects("Net", "IO") // Multiple effects
}
```
## Advanced Patterns
### Option/Maybe Types
```go
func makeFindType() types.Type {
T := types.NewBuilder()
alpha := T.Var("α")
// (α -> bool) -> [α] -> Option[α]
return T.Func(
T.Func(alpha).Returns(T.Bool()), // predicate
T.List(alpha), // list
).Returns(
T.App("Option", alpha), // Option[α]
)
}
```
### Nested Records
```go
func makeNestedRecordType() types.Type {
T := types.NewBuilder()
// Inner record: {x: int, y: int}
pointType := T.Record(
types.Field("x", T.Int()),
types.Field("y", T.Int()),
)
// Outer record: {topleft: Point, bottomright: Point}
rectType := T.Record(
types.Field("topleft", pointType),
types.Field("bottomright", pointType),
)
return T.Func().Returns(rectType)
}
```
### Higher-Order Functions
```go
func makeComposeType() types.Type {
T := types.NewBuilder()
alpha := T.Var("α")
beta := T.Var("β")
gamma := T.Var("γ")
// (β -> γ) -> (α -> β) -> α -> γ
return T.Func(
T.Func(beta).Returns(gamma), // f
T.Func(alpha).Returns(beta), // g
alpha, // x
).Returns(gamma)
}
```
## Type Builder API Quick Reference
### Primitive Types
- `T.String()` → `string`
- `T.Int()` → `int`
- `T.Float()` → `float`
- `T.Bool()` → `bool`
- `T.Unit()` → `()`
### Compound Types
- `T.List(elementType)` → `[T]`
- `T.Tuple(t1, t2, ...)` → `(T1, T2, ...)`
- `T.Record(fields...)` → `{field1: T1, field2: T2, ...}`
### Type Constructors
- `T.Con("TypeName")` → Named type constructor
- `T.Var("α")` → Type variable (polymorphism)
- `T.App("Con", arg1, arg2)` → Type application (e.g., `Result[T, E]`)
### Function Types
- `T.Func(arg1, arg2, ...).Returns(retType)` → `arg1 -> arg2 -> retType`
- `.Effects("IO", "FS")` → Add effect annotation
### Field Construction (for Records)
```go
types.Field("fieldName", fieldType)
```
## Common Mistakes
### ❌ Wrong: Forgetting Returns()
```go
// This doesn't compile!
T.Func(T.String()) // Missing .Returns()
```
### ✅ Correct: Always use Returns()
```go
T.Func(T.String()).Returns(T.Int())
```
### ❌ Wrong: Effects without Returns()
```go
// This doesn't work!
T.Func(T.String()).Effects("IO")
```
### ✅ Correct: Returns() before Effects()
```go
T.Func(T.String()).Returns(T.Unit()).Effects("IO")
```
### ❌ Wrong: Creating Record fields directly
```go
// Don't do this!
T.Record(map[string]types.Type{
"name": T.String(),
})
```
### ✅ Correct: Use types.Field()
```go
T.Record(
types.Field("name", T.String()),
types.Field("age", T.Int()),
)
```
## See Also
- **Testing**: [testing_patterns.md](testing_patterns.md) - How to test these builtins
- **Registry**: `internal/builtins/spec.go` - Registration system
- **Examples**: `internal/builtins/*.go` - Real builtin implementations
```
### resources/testing_patterns.md
```markdown
# Testing Patterns for AILANG Builtins
Complete guide to hermetic testing using the Test Harness (`internal/effects/testctx/`).
## Basic Test Structure
### Pure Function Test
```go
func TestStrReverse(t *testing.T) {
ctx := testctx.NewMockEffContext()
tests := []struct {
input string
expected string
}{
{"hello", "olleh"},
{"", ""},
{"🎉", "🎉"},
{"abc", "cba"},
}
for _, tt := range tests {
result, err := strReverseImpl(ctx, []eval.Value{
testctx.MakeString(tt.input),
})
assert.NoError(t, err)
assert.Equal(t, tt.expected, testctx.GetString(result))
}
}
```
### Multi-Argument Function Test
```go
func TestStrSlice(t *testing.T) {
ctx := testctx.NewMockEffContext()
tests := []struct {
str string
start int
end int
expected string
}{
{"hello", 0, 3, "hel"},
{"world", 1, 4, "orl"},
{"test", 0, 4, "test"},
}
for _, tt := range tests {
result, err := strSliceImpl(ctx, []eval.Value{
testctx.MakeString(tt.str),
testctx.MakeInt(tt.start),
testctx.MakeInt(tt.end),
})
assert.NoError(t, err)
assert.Equal(t, tt.expected, testctx.GetString(result))
}
}
```
## Testing with Records
### Simple Record
```go
func TestRecordBuiltin(t *testing.T) {
ctx := testctx.NewMockEffContext()
// Create input record
input := testctx.MakeRecord(map[string]eval.Value{
"name": testctx.MakeString("Alice"),
"age": testctx.MakeInt(30),
})
result, err := myRecordBuiltin(ctx, []eval.Value{input})
assert.NoError(t, err)
// Extract and verify output record
output := testctx.GetRecord(result)
assert.Equal(t, "Alice", testctx.GetString(output["name"]))
assert.Equal(t, 31, testctx.GetInt(output["age"]))
}
```
### Nested Records
```go
func TestNestedRecord(t *testing.T) {
ctx := testctx.NewMockEffContext()
// Create nested record
point := testctx.MakeRecord(map[string]eval.Value{
"x": testctx.MakeInt(10),
"y": testctx.MakeInt(20),
})
rect := testctx.MakeRecord(map[string]eval.Value{
"topleft": point,
"bottomright": testctx.MakeRecord(map[string]eval.Value{
"x": testctx.MakeInt(100),
"y": testctx.MakeInt(200),
}),
})
result, err := processRect(ctx, []eval.Value{rect})
assert.NoError(t, err)
// Extract nested values
output := testctx.GetRecord(result)
topleft := testctx.GetRecord(output["topleft"])
assert.Equal(t, 10, testctx.GetInt(topleft["x"]))
}
```
## Testing with Lists
### Simple List
```go
func TestListOperation(t *testing.T) {
ctx := testctx.NewMockEffContext()
// Create list of integers
input := testctx.MakeList([]eval.Value{
testctx.MakeInt(1),
testctx.MakeInt(2),
testctx.MakeInt(3),
})
result, err := sumList(ctx, []eval.Value{input})
assert.NoError(t, err)
assert.Equal(t, 6, testctx.GetInt(result))
}
```
### List of Records
```go
func TestListOfRecords(t *testing.T) {
ctx := testctx.NewMockEffContext()
people := testctx.MakeList([]eval.Value{
testctx.MakeRecord(map[string]eval.Value{
"name": testctx.MakeString("Alice"),
"age": testctx.MakeInt(30),
}),
testctx.MakeRecord(map[string]eval.Value{
"name": testctx.MakeString("Bob"),
"age": testctx.MakeInt(25),
}),
})
result, err := getNames(ctx, []eval.Value{people})
assert.NoError(t, err)
names := testctx.GetList(result)
assert.Equal(t, 2, len(names))
assert.Equal(t, "Alice", testctx.GetString(names[0]))
assert.Equal(t, "Bob", testctx.GetString(names[1]))
}
```
## Testing Effect Functions
### HTTP Request (Hermetic)
```go
func TestNetHTTPRequest(t *testing.T) {
// Create test HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "/api/test", r.URL.Path)
// Return response
w.WriteHeader(200)
w.Write([]byte(`{"status": "ok"}`))
}))
defer server.Close()
// Create mock context with HTTP capability
ctx := testctx.NewMockEffContext()
ctx.GrantAll("Net")
ctx.SetHTTPClient(server.Client())
// Create request arguments
url := testctx.MakeString(server.URL + "/api/test")
method := testctx.MakeString("GET")
headers := testctx.MakeList([]eval.Value{})
body := testctx.MakeString("")
// Execute builtin
result, err := effects.NetHTTPRequest(ctx,
url,
method,
headers,
body,
)
// Verify result
assert.NoError(t, err)
resp := testctx.GetRecord(result)
assert.Equal(t, 200, testctx.GetInt(resp["status"]))
assert.Contains(t, testctx.GetString(resp["body"]), "ok")
}
```
### File System Operations (Mock)
```go
func TestFSReadFile(t *testing.T) {
ctx := testctx.NewMockEffContext()
ctx.GrantAll("FS")
// Note: In real tests, you'd mock the filesystem
// For now, this shows the pattern
path := testctx.MakeString("/tmp/test.txt")
result, err := fsReadFile(ctx, []eval.Value{path})
// Handle Result[string, FSError]
assert.NoError(t, err)
resultVariant := testctx.GetVariant(result)
if resultVariant.Tag == "Ok" {
content := testctx.GetString(resultVariant.Value)
assert.NotEmpty(t, content)
}
}
```
### Testing Capability Denial
```go
func TestCapabilityDenied(t *testing.T) {
ctx := testctx.NewMockEffContext()
// Don't grant Net capability
url := testctx.MakeString("http://example.com")
method := testctx.MakeString("GET")
headers := testctx.MakeList([]eval.Value{})
body := testctx.MakeString("")
result, err := effects.NetHTTPRequest(ctx, url, method, headers, body)
// Should fail with capability error
assert.Error(t, err)
assert.Contains(t, err.Error(), "capability")
}
```
## Testing Result Types
### Ok Variant
```go
func TestResultOk(t *testing.T) {
ctx := testctx.NewMockEffContext()
result, err := myBuiltin(ctx, []eval.Value{
testctx.MakeString("valid-input"),
})
assert.NoError(t, err)
// Extract Result variant
variant := testctx.GetVariant(result)
assert.Equal(t, "Ok", variant.Tag)
// Get the Ok value
value := testctx.GetString(variant.Value)
assert.Equal(t, "expected", value)
}
```
### Err Variant
```go
func TestResultErr(t *testing.T) {
ctx := testctx.NewMockEffContext()
result, err := myBuiltin(ctx, []eval.Value{
testctx.MakeString("invalid-input"),
})
assert.NoError(t, err) // Builtin executed successfully
// Extract Result variant
variant := testctx.GetVariant(result)
assert.Equal(t, "Err", variant.Tag)
// Get the Err value
errValue := testctx.GetString(variant.Value)
assert.Contains(t, errValue, "invalid")
}
```
## Testing Option Types
### Some Variant
```go
func TestOptionSome(t *testing.T) {
ctx := testctx.NewMockEffContext()
list := testctx.MakeList([]eval.Value{
testctx.MakeInt(1),
testctx.MakeInt(2),
testctx.MakeInt(3),
})
result, err := findInList(ctx, []eval.Value{
testctx.MakeInt(2),
list,
})
assert.NoError(t, err)
variant := testctx.GetVariant(result)
assert.Equal(t, "Some", variant.Tag)
assert.Equal(t, 2, testctx.GetInt(variant.Value))
}
```
### None Variant
```go
func TestOptionNone(t *testing.T) {
ctx := testctx.NewMockEffContext()
list := testctx.MakeList([]eval.Value{
testctx.MakeInt(1),
testctx.MakeInt(2),
})
result, err := findInList(ctx, []eval.Value{
testctx.MakeInt(99), // Not in list
list,
})
assert.NoError(t, err)
variant := testctx.GetVariant(result)
assert.Equal(t, "None", variant.Tag)
}
```
## Test Harness API Reference
### Value Constructors
```go
// Primitives
testctx.MakeString(s string) *eval.StringValue
testctx.MakeInt(i int) *eval.IntValue
testctx.MakeFloat(f float64) *eval.FloatValue
testctx.MakeBool(b bool) *eval.BoolValue
testctx.MakeUnit() *eval.UnitValue
// Compound types
testctx.MakeList(items []eval.Value) *eval.ListValue
testctx.MakeRecord(fields map[string]eval.Value) *eval.RecordValue
testctx.MakeTuple(items []eval.Value) *eval.TupleValue
// Variants (ADTs)
testctx.MakeVariant(tag string, value eval.Value) *eval.VariantValue
```
### Value Extractors
```go
// Primitives
testctx.GetString(v eval.Value) string
testctx.GetInt(v eval.Value) int
testctx.GetFloat(v eval.Value) float64
testctx.GetBool(v eval.Value) bool
// Compound types
testctx.GetList(v eval.Value) []eval.Value
testctx.GetRecord(v eval.Value) map[string]eval.Value
testctx.GetTuple(v eval.Value) []eval.Value
// Variants
testctx.GetVariant(v eval.Value) *eval.VariantValue
// Then access variant.Tag and variant.Value
```
### Mock Context Methods
```go
// Create context
ctx := testctx.NewMockEffContext()
// Grant capabilities
ctx.GrantAll(effect string)
ctx.RevokeAll(effect string)
// Mock HTTP
ctx.SetHTTPClient(client *http.Client)
// Mock Filesystem (future)
ctx.SetFS(fs FS)
```
## Common Patterns
### Table-Driven Tests
```go
func TestMathOperations(t *testing.T) {
ctx := testctx.NewMockEffContext()
tests := []struct {
name string
a int
b int
expected int
}{
{"positive", 5, 3, 8},
{"negative", -5, -3, -8},
{"zero", 0, 0, 0},
{"mixed", -5, 10, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := addInts(ctx, []eval.Value{
testctx.MakeInt(tt.a),
testctx.MakeInt(tt.b),
})
assert.NoError(t, err)
assert.Equal(t, tt.expected, testctx.GetInt(result))
})
}
}
```
### Error Testing
```go
func TestErrorHandling(t *testing.T) {
ctx := testctx.NewMockEffContext()
tests := []struct {
name string
input string
expectError bool
errorMsg string
}{
{"valid", "valid", false, ""},
{"invalid", "", true, "empty string"},
{"too long", strings.Repeat("a", 1000), true, "too long"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := validateString(ctx, []eval.Value{
testctx.MakeString(tt.input),
})
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg)
} else {
assert.NoError(t, err)
assert.NotNil(t, result)
}
})
}
}
```
### Benchmark Tests
```go
func BenchmarkStrReverse(b *testing.B) {
ctx := testctx.NewMockEffContext()
input := testctx.MakeString("hello world")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := strReverseImpl(ctx, []eval.Value{input})
if err != nil {
b.Fatal(err)
}
}
}
```
## Best Practices
1. **Always use MockEffContext** - Never use real effects in tests
2. **Test edge cases** - Empty strings, zero, negative numbers, etc.
3. **Use table-driven tests** - Makes adding test cases easy
4. **Test error conditions** - Not just happy path
5. **Use subtests** - For better test organization
6. **Hermetic HTTP tests** - Use httptest.NewServer()
7. **Don't test the harness** - Test your builtin logic
8. **Keep tests fast** - No network, no filesystem I/O
9. **Clear assertions** - Use descriptive error messages
10. **One assertion per concept** - Don't overload tests
## Common Mistakes
### ❌ Wrong: Using real HTTP
```go
// Don't do this!
resp, err := http.Get("https://api.example.com")
```
### ✅ Correct: Use httptest
```go
server := httptest.NewServer(handler)
defer server.Close()
ctx.SetHTTPClient(server.Client())
```
### ❌ Wrong: Type assertion without checking
```go
// This can panic!
str := result.(*eval.StringValue).Value
```
### ✅ Correct: Use testctx extractors
```go
str := testctx.GetString(result)
```
### ❌ Wrong: Testing without EffContext
```go
// Don't skip the context!
result := myFunction(args)
```
### ✅ Correct: Always use EffContext
```go
ctx := testctx.NewMockEffContext()
result, err := myFunction(ctx, args)
```
## See Also
- **Type Builder**: [type_builder_examples.md](type_builder_examples.md) - How to define types
- **Test Harness**: `internal/effects/testctx/` - Implementation
- **Examples**: `internal/builtins/*_test.go` - Real test examples
```
### ../../../design_docs/implemented/v0_4_6/m-dx10-nullary-function-calls.md
```markdown
# M-DX10: Complete S-CALL0 and Unit-Argument Model
**Status**: ✅ Implemented
**Target**: v0.4.6
**Completed**: v0.4.6
**Priority**: P0 - High (blocks CLI args feature)
**Estimated**: 2-3 days
**Dependencies**: S-CALL0 (partial implementation exists)
## Implementation Summary
**Implemented in v0.4.6** - All phases complete:
1. **Phase 1: Builtins aligned** - All "zero-arg" builtins updated to `NumArgs: 1` with unit parameter:
- `_clock_now`: `internal/builtins/clock.go` - Type: `() -> int ! {Clock}`
- `_env_getArgs`: `internal/builtins/env.go` - Type: `() -> [string] ! {Env}`
- `_io_readLine`: `internal/builtins/io.go` - Type: `() -> string ! {IO}`
- All implementations validate unit argument (defense against type system bugs)
2. **Phase 1.5: Entry invocation** - Runtime invokes `main()` with unit argument (`cmd/ailang/run_helpers.go:258`)
3. **Phase 2: S-CALL0 complete** - `f()` → `f(())` desugaring works in all contexts (expression, statement, lambda, match)
4. **Phase 3: Stdlib fixed** - All wrappers call builtins with `()`:
- `std/clock.ail`: `now() = _clock_now()`
- `std/env.ail`: `getArgs() = _env_getArgs()`
- `std/io.ail`: `readLine() = _io_readLine()`
5. **Phase 4: Documentation** - Teaching prompts updated with unit-argument model explanation
**Verification**: All success criteria met. New builtins (e.g., `_sharedmem_keys` in v0.5.11) follow this pattern.
## AI-First Alignment Check
**Score this feature against AILANG's core principles:**
| Principle | Impact | Score | Notes |
|-----------|--------|-------|-------|
| Reduce Syntactic Noise | + | +1 | Enables clean `getArgs()` syntax without special cases |
| Preserve Semantic Clarity | + | +1 | Unifies all "zero-arg" functions under unit-argument model |
| Increase Determinism | + | +1 | Single, consistent desugaring rule: `f()` → `f(())` everywhere |
| Lower Token Cost | + | +1 | No arity-dependent rules for AI to learn; `f()` always works |
| **Net Score** | | **+4** | **Decision: Move forward (complete existing feature)** |
**Decision rule:** Net score > +1 → Move forward | ≤ 0 → Reject or redesign
**Reference:** See [AI-first DX philosophy](../v0_3_15/example-parity-vision-alignment.md#-design-principle-ai-first-dx)
## Problem Statement
**Discovered during M-LANG-CLI-ARGS implementation (v0.4.6):**
"Zero-argument" functions (`_env_getArgs`, `_clock_now`, `_io_readLine`) cannot be called correctly from AILANG code, blocking the CLI args feature and causing AI confusion.
**Current State:**
- `_env_getArgs` returns `<*eval.BuiltinFunction>` (function object) instead of `["arg1", "arg2"]`
- `_clock_now()` causes type error: "function arity mismatch: 0 vs 1"
- `_io_readLine()` has same issue
- Stdlib wrappers don't work: `export func getArgs() -> [string] ! {Env} = _env_getArgs`
- Only workaround: Call builtin implementation directly via `effects.Call()` (Go tests only)
**Impact:**
- **Blocks v0.4.6 CLI args feature** - cannot call `getArgs()` from AILANG code
- Affects existing builtins: `clock.now()`, `io.readLine()` (dormant bugs)
- AI models don't know whether to use `f`, `f()`, or `f ()`
- Inconsistent with AILANG's ML-style function application semantics
**Root Cause:**
AILANG **already chose the unit-argument model** with S-CALL0, but the implementation is incomplete:
1. **S-CALL0 sugar exists**: `f()` desugars to `f(())` in expression contexts
2. **Builtins are misaligned**: Registered as "zero-arg" in Go, expecting empty argument lists
3. **Stdlib wrappers are broken**: Try to call builtins without passing unit
4. **Teaching prompt is silent**: Doesn't explain the unit-argument model
This isn't a "missing feature" - it's an **incomplete feature rollout**.
## Semantic Model: No True Nullaries
**AILANG's Design Decision** (implicit in S-CALL0):
> **There are no semantically distinct nullary functions in AILANG.**
>
> All `func f() -> T` are surface syntax sugar for `func f(_ : ()) -> T`.
> All "zero-arg" calls are just applications to the unit value `()`.
**Why this model?**
- ✅ Consistent with ML tradition (`unit` as a proper type)
- ✅ Simplifies elaboration (no arity-dependent transformations)
- ✅ Preserves function application by juxtaposition (`f x`)
- ✅ Already implemented partially (S-CALL0)
- ✅ No special "nullary call" construct needed in core
**Type system:**
```
Surface: func getArgs() -> [string] ! {Env}
Core: () -> [string] ! {Env} (single unit parameter)
```
**Call sites:**
```ailang
-- Surface syntax (S-CALL0 sugar)
let args = getArgs()
-- Desugars to
let args = getArgs (())
-- Both are valid; sugar is for ergonomics
```
## Goals
**Primary Goal:** Complete the unit-argument model by aligning builtins, stdlib, and documentation with S-CALL0
**Success Metrics:**
- All 3 affected builtins work: `_env_getArgs()`, `_clock_now()`, `_io_readLine()`
- Stdlib wrappers work correctly
- `cli_args` benchmark passes (currently 0%)
- No new language constructs (complete existing S-CALL0)
- AI models can reliably call "zero-arg" functions (measured via eval)
- Teaching prompt explicitly documents unit-argument model
## Solution Design
### Overview
**Complete the S-CALL0 feature by:**
1. Aligning builtin types to expect unit parameter
2. Ensuring S-CALL0 desugaring works everywhere
3. Fixing stdlib wrappers to pass unit
4. Documenting the unit-argument model
**No new semantics**. This is housekeeping.
### Architecture
**Components:**
1. **Builtins**: Update "zero-arg" builtins to have type `() -> T` (single unit param)
2. **S-CALL0**: Ensure `f()` → `f(())` transformation is complete
3. **Stdlib**: Update wrappers to call with `_builtin()`
4. **Teaching Prompt**: Document unit-argument model explicitly
**Invariant to enforce:**
> Every function that appears to take "no arguments" actually takes a single parameter of type `()` in core.
### Implementation Plan
**Phase 1: Align Builtins** (~1 day, 4 hours)
Audit all "zero-arg" builtins and update their Go implementations:
- [ ] **`_clock_now`** (`internal/builtins/clock.go`)
- Current: Registered as `NumArgs: 0`
- Fix: Change to `NumArgs: 1`, type `() -> int ! {Clock}`
- Update `clockNowImpl` to expect 1 unit argument
- [ ] **`_env_getArgs`** (`internal/builtins/env.go`)
- Current: Registered as `NumArgs: 0`
- Fix: Change to `NumArgs: 1`, type `() -> [string] ! {Env}`
- Update `envGetArgs` to expect 1 unit argument
- [ ] **`_io_readLine`** (`internal/builtins/io.go`)
- Current: Registered as `NumArgs: 0` (assumed)
- Fix: Change to `NumArgs: 1`, type `() -> string ! {IO}`
- Update `readLineImpl` to expect 1 unit argument
- [ ] **Update golden snapshots**
- Regenerate `internal/pipeline/testdata/builtin_types.golden`
- Verify types show `() -> T` not `T`
**Phase 1.5: Entry Invocation** (~0.5 days, 2 hours)
Update runtime to invoke `main()` with unit argument:
- [ ] **Runtime invocation** (`cmd/ailang/main.go`, `internal/runtime/`)
- Current: Assumes `main` has arity 0, calls with empty args
- Fix: Call `main` with unit value: `main(())`
- Consistent with `() -> () ! {E}` type
- [ ] **Entry detection**
- Verify entry modules with `main()` still work
- Test with various effect combinations: `! {IO}`, `! {IO, Env}`, etc.
- [ ] **Regression tests**
- Entry modules with different effect signatures
- Ensure no "arity mismatch" errors at runtime
**Phase 2: Complete S-CALL0** (~0.5 days, 2 hours)
Ensure `f()` → `f(())` desugaring works in **all** contexts:
**Where S-CALL0 must work:**
- [ ] **Expression contexts**:
- `let x = f()` ✅ (already works)
- `if cond then f() else g()`
- `f() ++ g()` (binary operators)
- Right-hand side of bindings
- [ ] **Statement contexts**:
- Top of block: `{ f(); g(); }` ✅ (already works)
- Sequence expressions
- Effect statements
- [ ] **Lambda bodies**:
- `\x. f()` (important for HOF)
- Nested lambdas
- [ ] **Match arms** (expression position):
- `match x { _ => f() }` (this bit us before!)
- Guard expressions if implemented
- [ ] **Top-level in entry modules**:
- If top-level `f()` is allowed (currently not, but future-proof)
**Where () must NOT appear:**
- ❌ **Patterns**: `match f() { ... }` (invalid - `f()` is expr, not pattern)
- ❌ **Type syntax**: `() -> T` is a type, not `f()`
- [ ] **Regression tests** (`internal/elaborate/elaborator_test.go`):
- One test per context listed above
- Verify AST transformation: `f()` → `App(f, UnitLit)`
- Verify error messages for invalid positions
**Phase 3: Fix Stdlib Wrappers** (~0.5 days, 2 hours)
Update stdlib to use S-CALL0 sugar:
- [ ] **`std/clock.ail`**:
```ailang
-- Before (broken)
export func now() -> int ! {Clock} = _clock_now
-- After (fixed)
export func now() -> int ! {Clock} = _clock_now()
```
- [ ] **`std/env.ail`**:
```ailang
-- Before (broken)
export func getArgs() -> [string] ! {Env} = _env_getArgs
-- After (fixed)
export func getArgs() -> [string] ! {Env} = _env_getArgs()
```
- [ ] **`std/io.ail`**:
```ailang
-- Before (broken)
export func readLine() -> string ! {IO} = _io_readLine
-- After (fixed)
export func readLine() -> string ! {IO} = _io_readLine()
```
**Phase 4: Documentation & Testing** (~0.5 days, 2 hours)
- [ ] **Teaching prompt updates** (`prompts/v0.4.6.md`):
- Add "Zero-Argument Functions" section
- Explain unit-argument model
- Show `f()` syntax and desugaring
- Clarify that `()` is unit value, not "empty args"
- [ ] **Integration tests**:
- Fix `tests/cli_args_test.ail` to use `getArgs()`
- Test `clock.now()` in examples
- Test `io.readLine()` if examples exist
- [ ] **Benchmark**:
- Run `cli_args` benchmark
- Target: 0% → 80%+ success rate
### Files to Modify/Create
**Modified files:**
**Phase 1: Builtins**
- `internal/builtins/clock.go` - Change `NumArgs: 0` → `1`, add unit validation (~15 LOC)
- `internal/builtins/env.go` - Change `NumArgs: 0` → `1`, add unit validation (~15 LOC)
- `internal/builtins/io.go` - Change `NumArgs: 0` → `1`, add unit validation (~15 LOC)
- `internal/pipeline/testdata/builtin_types.golden` - Update types (~3 LOC)
**Phase 1.5: Entry Invocation**
- `cmd/ailang/main.go` - Update `main()` invocation to pass unit (~10 LOC)
- `internal/runtime/runtime.go` - Ensure entry calls use unit argument (~10 LOC)
- `internal/runtime/runtime_test.go` - Test entry with various effect signatures (~30 LOC)
**Phase 2: S-CALL0**
- `internal/elaborate/elaborator_test.go` - Comprehensive S-CALL0 regression tests (~60 LOC)
- `internal/parser/parser_test.go` - Parser tests for `f()` contexts (~20 LOC)
**Phase 3: Stdlib**
- `std/clock.ail` - Add `()` to call: `_clock_now()` (~1 LOC)
- `std/env.ail` - Add `()` to call: `_env_getArgs()` (~1 LOC)
- `std/io.ail` - Add `()` to call: `_io_readLine()` (~1 LOC)
- `tests/cli_args_test.ail` - Use `getArgs()` instead of direct builtin (~2 LOC)
**Phase 4: Documentation**
- `prompts/v0.4.6.md` - Document unit-argument model with examples (~50 LOC)
**New files:**
- `design_docs/planned/v0_4_6/NULLARY-FUNCTIONS-SPEC.md` - Canonical spec (~100 LOC)
**Total: ~193 LOC (45 impl + 110 tests + 38 docs + stdlib)**
## Examples
### Example 1: CLI Arguments (The Blocker)
**Before (broken):**
```ailang
import std/env (getArgs)
export func main() -> () ! {IO, Env} {
let args = getArgs; -- ERROR: Returns function object, not list
println(show(args)) -- Prints: <*eval.BuiltinFunction>
}
```
**After (fixed):**
```ailang
import std/env (getArgs)
export func main() -> () ! {IO, Env} {
let args = getArgs(); -- ✓ Desugars to getArgs(()), returns ["arg1", "arg2"]
println(show(args)) -- Prints: ["arg1", "arg2"]
}
```
### Example 2: Clock Operations
**Before (broken):**
```ailang
import std/clock (now)
export func main() -> () ! {Clock} {
let ts = now(); -- ERROR: arity mismatch (builtin expects 0, got 1)
...
}
```
**After (fixed):**
```ailang
import std/clock (now)
export func main() -> () ! {Clock} {
let ts = now(); -- ✓ Desugars to now(()), builtin expects unit, all good
...
}
```
### Example 3: Builtin Type Signature
**Before (broken):**
```
_env_getArgs : [string] ! {Env} -- Wrong: looks like a value, not a function
```
**After (fixed):**
```
_env_getArgs : () -> [string] ! {Env} -- Correct: single unit parameter
```
### Example 4: Higher-Order Functions (Still Work)
```ailang
-- Passing "zero-arg" function as value
let f = now -- f has type (() -> int ! {Clock})
-- Calling through higher-order function
let callTwice[a](g: () -> a) -> (a, a) ! {Clock} = (g(), g())
let (t1, t2) = callTwice(now) -- ✓ Both calls desugar to now(())
```
## Semantic Specification: "Nullary Functions" in AILANG
**Canonical rule:**
> AILANG does not have nullary functions at the semantic level.
>
> Surface syntax `func f() -> T` is sugar for `func f(_ : ()) -> T`.
> Surface syntax `f()` is sugar for `f(())` (application to unit value).
**Implications:**
1. **Type system**:
- "Zero-arg function" types in core are always `() -> T`.
- The parameter list contains exactly one element: the unit type.
2. **Elaboration**:
- Parser recognizes `f()` as syntactic sugar.
- Elaborator transforms to `App(f, UnitLit)`.
- No arity-dependent logic; this is universal syntax sugar.
3. **Builtins**:
- Every "zero-arg" builtin must be registered with `NumArgs: 1`.
- Go implementation must expect one `eval.Value` (unit value).
- Type builder must include unit in parameter list: `T.Func().Returns(...)`
- **Validation invariant**: Builtin impl should validate the argument is unit (or safely ignore it).
- Defense against type system bugs (e.g., annotation threading unsoundness).
- If non-unit value is passed, panic with clear error: "internal invariant violation: expected unit".
4. **Stdlib**:
- Wrapper functions use `f()` syntax (sugar).
- Example: `export func now() -> int ! {Clock} = _clock_now()`
5. **Higher-order functions**:
- "Zero-arg" functions can be passed as values: `let f = now`.
- Type: `f : () -> int ! {Clock}`.
- Calling: `f()` desugars to `f(())`.
6. **AI teaching**:
- Teach: "Use `f()` to call functions with no parameters."
- Don't teach: "Zero-arg functions are special."
- Rationale: Reduces cognitive load; one rule covers all cases.
- **Power-user note** (for teaching prompt):
```ailang
-- These functions have type () -> T. You can pass them as values:
let f = getArgs; -- f : () -> [string] ! {Env}
let xs = f(); -- calls getArgs(()), returns [string]
-- Works with higher-order functions:
let callTwice[a](g: () -> a) -> (a, a) = (g(), g())
let (t1, t2) = callTwice(now) -- both calls work
```
## Success Criteria
- [ ] `_env_getArgs()` returns `["arg1", "arg2"]` (not function object)
- [ ] `_clock_now()` returns Unix timestamp (no arity mismatch)
- [ ] `_io_readLine()` reads a line from stdin
- [ ] Stdlib wrappers work: `getArgs()`, `now()`, `readLine()`
- [ ] **Runtime invokes `main()` as `main(())` (or equivalent) and no entry module breaks**
- [ ] `cli_args` benchmark: 0% → 80%+ success rate
- [ ] All existing tests still pass (no regressions)
- [ ] Teaching prompt documents unit-argument model with examples
- [ ] Can pass "zero-arg" function as value: `let f = now` (type `() -> int ! {Clock}`)
- [ ] Golden snapshot shows `() -> T` types (not bare `T`)
- [ ] No new core AST nodes or arity-dependent elaboration
- [ ] S-CALL0 works in all listed contexts (expr, stmt, lambda, match)
## Testing Strategy
**Unit tests:**
- Builtins: Each "zero-arg" builtin accepts unit value, returns correct result
- S-CALL0: `f()` desugars to `f(())` in all contexts (expr, stmt, match, lambda)
- Type checker: Infers `() -> T` for "zero-arg" functions
- Higher-order: Can pass `now` as value, call via `g()`
**Integration tests:**
- `tests/cli_args_test.ail` - CLI arguments work end-to-end
- `examples/tests/micro_clock_measure.ail` - Clock operations work
- REPL: `now()` works interactively
**Manual testing:**
```bash
# Test 1: CLI args (the blocker)
ailang run --caps IO,Env --entry main tests/cli_args_test.ail arg1 arg2
# Expected: Prints "argc: 2", "arg: arg1", "arg: arg2"
# Test 2: Clock
ailang run --caps Clock examples/tests/micro_clock_measure.ail
# Expected: No errors, prints timestamps
# Test 3: REPL
ailang repl --caps Clock
> now()
# Expected: Returns int (Unix timestamp)
# Test 4: Stdlib wrappers
ailang repl --caps Env
> import std/env (getArgs)
> getArgs()
# Expected: Returns list of args
```
## Non-Goals
**Not in this feature:**
- ❌ True nullary functions (distinct semantic entity)
- ❌ New call syntax (e.g., `f!` operator)
- ❌ Auto-calling on reference (`f` → auto-call)
- ❌ Arity-dependent elaboration hacks
- ❌ Special "nullary call" core AST node
**Why not?**
- AILANG already chose the unit-argument model (S-CALL0)
- Adding true nullaries would require deep semantic surgery
- Current model is simpler, more consistent, and AI-friendly
## Timeline
**Day 1** (4 hours):
- Phase 1: Align builtins (change NumArgs, update impls)
- Update golden snapshots
- Unit tests for builtin changes
**Day 2** (4 hours):
- Phase 2: S-CALL0 regression tests
- Phase 3: Fix stdlib wrappers
- Integration tests
**Day 3** (2 hours):
- Phase 4: Teaching prompt updates
- Run benchmarks
- Verify no regressions
- Write canonical spec document
**Total: ~10 hours across 3 days**
## Risks & Mitigations
| Risk | Impact | Mitigation |
|------|--------|-----------|
| Breaks existing code using "zero-arg" functions | High | Test all examples; expect breakage (dormant bugs) |
| **Entry runtime still assuming arity-0 `main`** | **High** | **Phase 1.5: Update runtime invocation explicitly** |
| S-CALL0 doesn't work in some contexts | Medium | Comprehensive regression tests; audit elaborator per context list |
| Golden snapshot changes cause test failures | Low | Regenerate with `UPDATE_GOLDEN=1` |
| AI models still confused by `f` vs `f()` | Medium | Clear teaching prompt; emphasize `f()` syntax |
| Type annotation threading interactions | Medium | Re-run concat inference & annotation tests after builtin changes |
## References
- **Triggered by**: M-LANG-CLI-ARGS implementation (v0.4.6)
- **Related features**:
- S-CALL0 (partial implementation)
- M-DX1 Builtin System (uses NumArgs)
- **Affected builtins**:
- `internal/builtins/clock.go` - `_clock_now`
- `internal/builtins/env.go` - `_env_getArgs`
- `internal/builtins/io.go` - `_io_readLine`
- **Stdlib wrappers**:
- `std/clock.ail` - `now()`
- `std/env.ail` - `getArgs()`
- `std/io.ail` - `readLine()`
- **Teaching materials**:
- `prompts/v0.4.6.md` - Needs unit-argument model section
- **Type system**:
- Hindley-Milner with row polymorphism
- Unit type `()` as first-class value
## Cross-References to Other M-Docs
**Dependencies & Interactions:**
1. **M-BUG-CONCAT-INFERENCE** (String concatenation type inference)
- **Impact**: Changing builtin types to `() -> T` adds more explicit function types
- **Action**: Re-run concat inference tests after M-DX10 implementation
- **Why**: Typechecker will see more `() -> T` patterns; verify no surprise interactions
2. **Type Annotation Threading** (Expected-type propagation)
- **Impact**: Users will write explicit types like `() -> [string] ! {Env}` in code
- **Action**: Ensure annotation threading doesn't silently ignore these types for builtins
- **Why**: Runtime must not explode when annotations differ from actual types
- **Critical**: This composes with M-DX10 - both touch typechecker paths
3. **M-LANG-CLI-ARGS** (CLI arguments feature)
- **Blocks**: This feature is the primary blocker
- **Unblocks**: After M-DX10, `getArgs()` will be usable from AILANG code
- **Testing**: Use cli_args benchmark as success metric
**Suggested Implementation Order:**
1. M-DX10 (this doc) - Complete unit-argument model
2. M-LANG-CLI-ARGS - Unblocked by M-DX10
3. Type annotation threading - Builds on stable builtin types
4. M-BUG-CONCAT-INFERENCE - Can now assume `() -> T` is stable
**Note**: M-DX10 and annotation threading compose. Expect to touch both in same release.
## Future Work
**If true nullaries are ever needed** (defer to v0.5.0+):
- Introduce distinct nullary function kind at type level
- Add nullary call construct to core AST
- Define interaction with higher-order functions, partial application
- Update teaching prompt to explain both models
**For now**: The unit-argument model is sufficient, consistent, and AI-friendly.
---
## Appendix: Why the Unit-Argument Model?
**Advantages over true nullaries:**
1. **Simpler elaboration**: No arity-dependent transformations.
2. **Uniform function application**: `f x` covers all cases; `f ()` is just `f` applied to unit.
3. **ML tradition**: Follows OCaml/SML where `()` is the canonical "no meaningful value".
4. **Type system**: Unit is a proper type, not special syntax.
5. **Higher-order functions**: Can pass "zero-arg" functions as values without special cases.
6. **Already implemented**: S-CALL0 exists; this just completes it.
**Disadvantages:**
- Slightly verbose: `f()` instead of `f` (but sugar mitigates this).
- Not obvious from docs: Requires explicit teaching (this doc fixes that).
**Decision rationale**: AILANG prioritizes machine decidability and determinism. The unit-argument model is more compositional and requires less context-dependent reasoning.
---
**Document created**: 2025-11-18
**Last updated**: 2025-11-18 (revised with architectural feedback)
**Replaces**: Previous M-DX10 draft (semantic model changed from true nullaries to unit-argument)
**Discovered by**: M-LANG-CLI-ARGS sprint (Milestone 2)
**Blocks**: CLI arguments feature (v0.4.6)
**Key revisions:**
- Added Phase 1.5: Entry invocation handling (prevents repeat of S-CALL0 failure pattern)
- Expanded S-CALL0 contexts list with explicit inclusions/exclusions
- Added builtin validation invariant (defense against type system bugs)
- Enhanced teaching section with power-user HOF examples
- Added cross-references to type annotation threading and concat inference
- Updated risks to highlight entry runtime assumption
```
### scripts/validate_builtins.sh
```bash
#!/usr/bin/env bash
set -euo pipefail
# Validate AILANG builtins registry
echo "Validating AILANG builtins..."
# Check if ailang command exists
if ! command -v ailang &> /dev/null; then
echo "✗ ailang command not found"
echo " Run: make install"
exit 1
fi
# Run doctor builtins
echo ""
echo "Running ailang doctor builtins..."
if ailang doctor builtins 2>&1 | tee /tmp/builtin_doctor.log; then
echo "✓ All builtins are valid"
else
echo "✗ Builtin validation failed"
echo " See /tmp/builtin_doctor.log for details"
exit 1
fi
# Count builtins
echo ""
echo "Counting registered builtins..."
BUILTIN_COUNT=$(ailang builtins list | grep -c "^\s*_" || true)
echo "✓ Found $BUILTIN_COUNT builtins"
# Check for orphaned builtins
echo ""
echo "Checking for orphaned builtins..."
if ailang builtins check-migration 2>&1 | tee /tmp/builtin_migration.log | grep -q "No orphaned"; then
echo "✓ No orphaned builtins found"
else
echo "⚠ Found orphaned builtins - see /tmp/builtin_migration.log"
fi
echo ""
echo "✓ Builtin validation complete"
echo ""
echo "Summary:"
echo " Total builtins: $BUILTIN_COUNT"
echo " Status: All valid"
echo ""
echo "Run ./scripts/check_builtin_health.sh for detailed listing"
```
### scripts/check_builtin_health.sh
```bash
#!/usr/bin/env bash
set -euo pipefail
# Check AILANG builtin health and list all builtins
echo "AILANG Builtin Health Check"
echo "============================"
echo ""
# Check if ailang command exists
if ! command -v ailang &> /dev/null; then
echo "✗ ailang command not found"
echo " Run: make install"
exit 1
fi
# Run doctor
echo "1. Running ailang doctor builtins..."
echo "-----------------------------------"
if ailang doctor builtins; then
echo ""
echo "✓ All builtins are valid"
else
echo ""
echo "✗ Builtin validation failed"
exit 1
fi
echo ""
echo "2. Listing builtins by module..."
echo "--------------------------------"
ailang builtins list --by-module
echo ""
echo "3. Builtin statistics..."
echo "------------------------"
TOTAL=$(ailang builtins list | grep -c "^\s*_" || true)
PURE=$(ailang builtins list | grep -c "\[pure\]" || true)
EFFECTS=$((TOTAL - PURE))
echo "Total builtins: $TOTAL"
echo "Pure functions: $PURE"
echo "Effect functions: $EFFECTS"
echo ""
echo "✓ Health check complete"
```