feat: add content targets and loop expansion for target templates
All checks were successful
Continuous Integration / Build Package (push) Successful in 25s
Continuous Integration / Lint, Check & Test (push) Successful in 44s

Content targets write literal text to files via 'content:' field,
without requiring an AI provider or API keys. They are not archived
when overwritten.

Loop expansion allows defining 'loops:' at the top level with named
lists of values. Targets with [var] in their name are expanded via
cartesian product. Variables are substituted in all string fields.
Explicit targets override expanded ones. Escaping: \[var] -> [var].
Expansion happens at config load time so the rest of the system
(builder, graph, state) sees only expanded targets.
This commit is contained in:
Konstantin Fickel 2026-02-21 18:39:13 +01:00
parent bb03975ece
commit 7503672942
Signed by: kfickel
GPG key ID: A793722F9933C1A5
7 changed files with 581 additions and 2 deletions

View file

@ -709,6 +709,68 @@ class TestContentTarget:
assert result.failed == {}
class TestLoopExpansion:
"""End-to-end tests for loop-expanded targets in builds."""
async def test_loop_content_targets_build(
self, project_dir: Path, write_config: WriteConfig
) -> None:
config = write_config(
{
"loops": {"n": ["1", "2", "3"]},
"targets": {"file-[n].txt": {"content": "Value [n]"}},
}
)
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
result = await run_build(config, project_dir, _PROJECT)
assert set(result.built) == {"file-1.txt", "file-2.txt", "file-3.txt"}
assert (project_dir / "file-1.txt").read_text() == "Value 1"
assert (project_dir / "file-2.txt").read_text() == "Value 2"
assert (project_dir / "file-3.txt").read_text() == "Value 3"
async def test_loop_incremental_skip(
self, project_dir: Path, write_config: WriteConfig
) -> None:
config = write_config(
{
"loops": {"n": ["1", "2"]},
"targets": {"file-[n].txt": {"content": "Value [n]"}},
}
)
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
r1 = await run_build(config, project_dir, _PROJECT)
assert len(r1.built) == 2
r2 = await run_build(config, project_dir, _PROJECT)
assert r2.built == []
assert set(r2.skipped) == {"file-1.txt", "file-2.txt"}
async def test_loop_with_dependency_chain(
self, project_dir: Path, write_config: WriteConfig
) -> None:
config = write_config(
{
"loops": {"id": ["a", "b"]},
"targets": {
"data-[id].txt": {"content": "Data for [id]"},
"summary-[id].txt": {
"prompt": "Summarize [id]",
"inputs": ["data-[id].txt"],
},
},
}
)
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
result = await run_build(config, project_dir, _PROJECT)
assert "data-a.txt" in result.built
assert "data-b.txt" in result.built
assert "summary-a.txt" in result.built
assert "summary-b.txt" in result.built
assert result.failed == {}
class TestPlaceholderPrompts:
"""Tests for prompt placeholder substitution in builds."""