Declarative Build Orchestration: Dependency Graphs via Annotations
The Problem: Hardcoded Build Orchestration
The Koru compiler was doing something crude. When you compiled a Koru program, the backend would:
- Compile your Koru code to Zig
- Hardcode a call to
zig build-exe - Hope that was enough
No orchestration. No dependency management. No flexibility. Just “compile this file and pray.”
// Inside main.zig - HARDCODED
try std.process.Child.run(.{
.allocator = allocator,
.argv = &[_][]const u8{
"zig", "build-exe",
"backend_output_emitted.zig",
"-femit-bin=backend"
},
}); This worked for toy examples. But what about:
- Running tests before packaging?
- Compiling multiple artifacts?
- Managing build dependencies?
- Custom build steps?
Impossible. The build was baked into the compiler.
The Insight: Build Steps as Events
During Orisha (web framework) integration, we hit a wall. The hardcoded backend couldn’t handle the complexity. That’s when the breakthrough happened:
“Hold on. We shouldn’t use the backend to do that. Shouldn’t we add something like a
~std.build:step(name: "Zig build") {? Where we could possibly add~[depends_on("Zig build")]std.build:step(name: "Zig Link"), something like that?”
This changed everything.
Build steps aren’t infrastructure. They’re just events that happen at compile time. And events can have dependencies expressed via annotations.
The vision:
~import "$std/build"
// Compile step
~std.build:step(name: "compile") {
zig build-exe backend.zig
}
// Test step - depends on compile
~[depends_on("compile")]
std.build:step(name: "test") {
./backend --test
}
// Package step - depends on both
~[depends_on("compile", "test")]
std.build:step(name: "package") {
tar czf app.tar.gz backend
} Execute koruc main.kz build package and the compiler:
- Topologically sorts the dependency graph
- Executes steps in correct order:
compile→test→package - Detects circular dependencies with helpful errors
Declarative. Testable. Extensible.
The Architecture: Three Layers
1. Parametrized Annotation Parser
First problem: annotations were opaque strings. We needed to parse depends_on("a", "b", "c").
Solution: A reusable annotation parser library that works in both frontend and backend:
// annotation_parser.zig
pub const AnnotationCall = struct {
name: []const u8,
args: [][]const u8,
};
// Parse "depends_on("compile", "test")"
pub fn parseCall(
allocator: std.mem.Allocator,
annotation: []const u8
) !?AnnotationCall {
// Extract function name
const paren = std.mem.indexOf(u8, annotation, "(") orelse return null;
const name = std.mem.trim(u8, annotation[0..paren], &std.ascii.whitespace);
// Parse arguments with proper quote handling
// Returns: AnnotationCall{ .name = "depends_on", .args = ["compile", "test"] }
} Key design: The parser treats annotations as opaque strings during AST parsing, then interprets them semantically when needed. Clean separation of syntax and semantics.
Tests: 10/10 passing, handling quotes, escapes, whitespace, variadic arguments.
2. Build Step Event
Define build steps as compile-time events:
~[comptime|norun]pub event step {
name: []const u8,
source: Source
} Notice: No dependencies parameter. Dependencies are expressed via annotations, not parameters. This keeps the event signature clean and moves dependency information to metadata.
3. Dependency Graph Execution
Collect all build:step invocations, extract dependencies from annotations, build a DAG, and execute via topological sort:
fn collectBuildSteps(allocator: std.mem.Allocator, program: *const ast.Program) ![]BuildStep {
var steps = try std.ArrayList(BuildStep).initCapacity(allocator, 8);
for (program.items) |item| {
if (item == .flow) {
const flow = item.flow;
// Check if this is a build:step invocation
if (isBuildStep(flow)) {
// Extract name and script from parameters
const name = extractName(flow);
const script = extractScript(flow);
// Extract dependencies from annotations
const deps = try extractDependenciesFromAnnotations(
allocator,
flow.annotations // NEW: Flow now has annotations field!
);
try steps.append(allocator, BuildStep{
.name = name,
.script = script,
.dependencies = deps,
});
}
}
}
return try steps.toOwnedSlice(allocator);
} Then execute with Kahn’s algorithm for topological sort:
fn executeBuildSteps(allocator: std.mem.Allocator, steps: []const BuildStep) !void {
// Build in-degree map
var in_degree = std.StringHashMap(usize).init(allocator);
for (steps) |step| {
for (step.dependencies) |dep| {
const entry = try in_degree.getOrPut(step.name);
if (!entry.found_existing) entry.value_ptr.* = 0;
entry.value_ptr.* += 1;
}
}
// Find steps with no dependencies
var queue = std.ArrayList([]const u8).init(allocator);
for (steps) |step| {
if (!in_degree.contains(step.name) or in_degree.get(step.name).? == 0) {
try queue.append(allocator, step.name);
}
}
// Execute in topological order
var executed: usize = 0;
while (queue.items.len > 0) {
const current_name = queue.orderedRemove(0);
// Find and execute the step
for (steps) |step| {
if (std.mem.eql(u8, step.name, current_name)) {
std.debug.print("🔨 Executing step: {s}
", .{step.name});
try executeShellScript(step.script);
executed += 1;
// Reduce in-degree for dependent steps
for (steps) |dep_step| {
for (dep_step.dependencies) |dep| {
if (std.mem.eql(u8, dep, current_name)) {
const new_degree = in_degree.get(dep_step.name).? - 1;
try in_degree.put(dep_step.name, new_degree);
if (new_degree == 0) {
try queue.append(allocator, dep_step.name);
}
}
}
}
}
}
}
// Detect circular dependencies
if (executed < steps.len) {
return error.CircularDependency;
}
} Result: Steps execute in correct dependency order, with helpful errors for cycles.
Flow Annotations: The Missing Piece
To make this work, we needed to add annotations to Flow invocations in the AST:
// ast.zig
pub const Flow = struct {
invocation: Invocation,
continuations: []const Continuation,
annotations: []const []const u8 = &[_][]const u8{}, // NEW!
// ... rest of fields
}; And update the parser to capture them:
~[depends_on("compile", "test")]
std.build:step(name: "package") {
tar czf app.tar.gz backend
} The parser already handled ~[annotation] syntax for events. We extended it to work on flows too.
Critical bug fix: The parser was including annotations in event name lookup, causing:
error[PARSE001]: Event '[pure]std.build:step' not found Fix: Strip annotations before looking up the event:
fn parseFlow(self: *Parser, annotations: [][]const u8) !ast.Flow {
const line = self.lines[self.current];
const after_tilde = trimmed[1..]; // Skip ~
// Strip past annotations if present
var remaining = after_tilde;
if (std.mem.startsWith(u8, after_tilde, "[")) {
if (std.mem.indexOf(u8, after_tilde, "]")) |close_pos| {
remaining = lexer.trim(after_tilde[close_pos + 1..]);
}
}
// Now use 'remaining' for event lookup
const event_name = parseEventName(remaining);
// ...
} Now the parser correctly separates annotations (metadata) from event names (lookup keys).
Test 641: See It Working
Create tests/regression/600_COMPTIME/641_flow_annotations/input.kz:
~import "$std/build"
// Simple step with no dependencies
~std.build:step(name: "compile") {
echo "Compiling..."
}
// Single dependency
~[depends_on("compile")]
std.build:step(name: "test") {
echo "Testing..."
}
// Multiple dependencies (variadic)
~[depends_on("compile", "test")]
std.build:step(name: "package") {
echo "Packaging..."
} Run it:
$ koruc input.kz
🔨 Executing step: compile
Compiling...
🔨 Executing step: test
Testing...
🔨 Executing step: package
Packaging...
✅ All build steps completed successfully! Test passes! Steps execute in correct dependency order: compile → test → package.
Circular Dependency Detection
What if you create a cycle?
~[depends_on("test")]
std.build:step(name: "compile") {
echo "Compile"
}
~[depends_on("compile")]
std.build:step(name: "test") {
echo "Test"
} The topological sort detects it:
error: Circular dependency detected in build steps!
Step 'compile' depends on 'test'
Step 'test' depends on 'compile'
This creates a cycle - build cannot proceed. Helpful errors that show you exactly what’s wrong.
The Evolution: Before and After
Before: Hardcoded
// main.zig - NO FLEXIBILITY
try executeBackend(allocator, "backend.zig"); After: Declarative
~import "$std/build"
~std.build:step(name: "compile") {
zig build-exe backend.zig
}
~[depends_on("compile")]
std.build:step(name: "test") {
./backend --test
}
~[depends_on("compile", "test")]
std.build:step(name: "package") {
tar czf app.tar.gz backend
} Result:
- ✅ Declarative dependency graph
- ✅ Automatic topological sort
- ✅ Circular dependency detection
- ✅ Clean, testable architecture
- ✅ Foundation for future extensions
The Future: command.zig Metaprogramming
Build steps using shell scripts are useful, but limited. The real power comes from Zig commands with full compiler access:
~import "$std/build"
~[comptime|norun]pub event command.zig {
name: []const u8,
source: Source
}
// Define a Zig command that can access the AST
~std.build:command.zig(name: "build") {
pub fn execute(
allocator: std.mem.Allocator,
argv: [][]const u8,
ast: *const ProgramAST
) !void {
// Full access to the compiler AST!
// Can inspect events, find dependencies, optimize, etc.
// Find the step to run
const step_name = argv[0];
const steps = collectBuildSteps(allocator, ast);
for (steps) |step| {
if (std.mem.eql(u8, step.name, step_name)) {
try executeBuildStep(allocator, step);
return;
}
}
return error.StepNotFound;
}
} Then use it:
$ koruc main.kz build compile
🔨 Executing step: compile The build command is written in Koru, compiled to Zig, and has full access to the compiler’s AST. This is metacircular metaprogramming.
Status: Collection works (finds command.zig invocations), execution stub exists, full implementation pending.
Comparison: Build Systems
Make
Declarative targets with dependencies:
compile:
zig build-exe backend.zig
test: compile
./backend --test
package: compile test
tar czf app.tar.gz backend Problems:
- Separate file (not in your source code)
- Shell-only (no type safety)
- Tab-sensitive syntax (ugh)
- No compiler AST access
Cargo (Rust)
Build scripts with limited access:
// build.rs (separate file)
fn main() {
println!("cargo:rerun-if-changed=src/");
// Limited build.rs API
} Problems:
- Separate file again
- Limited to predefined build.rs capabilities
- No access to Rust’s AST
- Complex multi-stage build system
Zig build.zig
Programmatic builds:
// build.zig
pub fn build(b: *std.Build) void {
const exe = b.addExecutable(...);
const tests = b.addTest(...);
tests.step.dependOn(&exe.step);
} Better! But still:
- Separate file
- Manual dependency wiring
- No self-describing modules
Koru
Build steps as events in source:
~import "$std/build"
~std.build:step(name: "compile") { ... }
~[depends_on("compile")]
std.build:step(name: "test") { ... } Advantages:
- ✅ In-source (delete module = delete build steps)
- ✅ Declarative dependencies via annotations
- ✅ Automatic topological sort
- ✅ Full compiler AST access (with command.zig)
- ✅ Self-describing modules
- ✅ Type-safe Zig metaprogramming
Implementation: What We Built
Files Created/Modified
annotation_parser.zig(new) - 200 lines- Parametrized annotation parser
parseCall(),hasSimple(),getCall()- 10/10 tests passing
ast.zig(modified)- Added
annotations: []const []const u8toFlowstruct - Updated
deinit()to free annotations
- Added
parser.zig(modified)- Fixed annotation stripping in
parseFlow() - Prevents annotations from polluting event lookup
- Fixed annotation stripping in
main.zig(modified)extractDependenciesFromAnnotations()using annotation parsercollectBuildSteps()extracts steps from AST- Topological sort with Kahn’s algorithm
- Circular dependency detection
build.kz(modified)- Refactored
build:stepfrom parameters to annotations - Added
command.zigevent signature
- Refactored
Test 641 (new)
- Flow annotations test
- Dependency graph execution
- Passing!
The Numbers
- 7 hours of human-AI collaboration
- 200+ lines of annotation parser (reusable library)
- ~150 lines of build orchestration code
- 10/10 tests passing for annotation parser
- 1 new regression test (641) validating the whole system
- Zero technical debt - clean, tested, documented
What This Enables
1. Complex Build Pipelines
~std.build:step(name: "compile_backend") { ... }
~std.build:step(name: "compile_frontend") { ... }
~[depends_on("compile_backend", "compile_frontend")]
std.build:step(name: "integration_test") { ... }
~[depends_on("integration_test")]
std.build:step(name: "deploy") { ... } 2. Conditional Execution
~[depends_on("compile")]
std.build:step(name: "test") when cfg.run_tests {
./backend --test
} 3. Metaprogramming with command.zig
~std.build:command.zig(name: "analyze") {
pub fn execute(allocator: std.mem.Allocator, argv: [][]const u8, ast: *const ProgramAST) !void {
// Analyze the AST, generate reports, optimize, etc.
for (ast.events) |event| {
if (event.is_pure) {
std.debug.print("Pure event: {s}
", .{event.name});
}
}
}
} Full compiler power at build time.
Design Philosophy
Annotations as Metadata, Not Parameters
Bad (what we had):
~build:step(name: "package", dependencies: "compile,test") { } Good (what we have now):
~[depends_on("compile", "test")]
build:step(name: "package") { } Why?
- Annotations are metadata about the invocation
- Parameters are data for the event handler
- Separating these keeps concerns clean
Parser Treats Annotations as Opaque
The parser doesn’t interpret depends_on("a", "b"). It just captures ["depends_on(\"a\", \"b\")"] as a string.
Interpretation happens later via annotation_parser.zig.
This separation means:
- Parser stays simple
- Annotation semantics can evolve
- Same parser works for all annotation types
Dependency Graph vs Execution Order
We don’t hardcode execution order. We declare dependencies, then derive the order via topological sort.
This means:
- Add new steps without worrying about order
- Circular dependencies get caught automatically
- Parallel execution possible in the future
Current Status
Shipping now:
- ✅ Annotation parser library
- ✅ Flow annotations in AST
- ✅ Build step collection
- ✅ Topological sort execution
- ✅ Circular dependency detection
- ✅ Test 641 passing
Coming soon:
- Zig command compilation and execution
- Parallel step execution
- Build step utilities library
- Canonical
buildcommand in stdlib
Try It
Clone the repo:
git clone https://github.com/korulang/koru
cd koru
zig build Run test 641:
./run_regression.sh 641 Check the code:
The Collaboration Story
This feature emerged from a single insight during human-AI collaboration:
“Shouldn’t we add something like a
~std.build:step(name: 'Zig build')?”
From that insight to working implementation took 7 hours, involving:
- Architecture discussion - Parameters vs annotations
- Library design - Reusable annotation parser
- AST modification - Adding annotations to flows
- Bug fixes - Parser annotation stripping
- Algorithm implementation - Topological sort
- Testing - Test 641 validation
- Documentation - This blog post!
This is what AI-first compiler development looks like: Rapid iteration, transparent failures, collaborative problem-solving, and code that actually works.
What’s Next
With declarative build orchestration in place, we can now tackle Orisha integration - the web framework that sparked this whole refactoring.
Instead of hardcoded backend compilation, Orisha will declare:
~[depends_on("compile_backend")]
std.build:step(name: "run_server") {
./backend --port 3000
} Self-describing. Declarative. Testable.
The toolchain is getting stronger. The tests are honest. The progress is real.
Follow along: GitHub • Docs • Contributing
This is an AI-first project. Every feature emerges from human-AI collaboration. We’re proving that the future of compiler development is cooperative, not solitary.