Skip to content

Appendix C — Rationale

Appendix C. Rationale (informative)

Informative. This appendix records the reasoning behind design decisions in the Standard. It is non-binding.

C.0. On § 0 Introduction

C.0.1. Versioning posture pre-1.0

The § 0.5 model — MAJOR.MINOR pre-1.0, batched cuts, convention-only conformance claim, git-tagged corpus — embeds four design decisions worth recording.

Why MAJOR.MINOR not strict SemVer pre-1.0. Breaking changes are normal pre-1.0; the discipline of distinguishing MAJOR from MINOR is performative when every cut may contain a breakage. PATCH buys nothing — there is no installed base whose conformance is broken by a typo fix that nobody has to back-port. Strict SemVer arrives at 1.0 along with an installed base for which the discipline starts to matter.

Why version cuts MAY batch CSes. Authoring (one CS per merge) and publication (one cut per N CSes) are different concerns. Batching keeps the cut cadence at “when an implementation needs to claim a new version” rather than “after every spec edit,” which would inflate the version number into the dozens with little reader benefit.

Why the conformance-claim mechanism is convention, not a normative requirement. No consumer pre-1.0; § 0.3 excludes the CLI surface; no automated check enforces a MUST without an ecosystem to enforce against. The convention is documented in CONTRIBUTING.md, where it is amendable without a CS.

Why the corpus is identified by git tag rather than versioned subdirectory. Git already snapshots prose and corpus together at the tag. Versioned subdirectories duplicate that snapshot in a place where it must be manually maintained; they also imply a back-port story that pre-1.0 explicitly disclaims.

C.2. On § 2 Notation and conventions

To be filled in.

C.3. On § 3 Lexical structure

Chapter 2 is deliberately small. Cookfiles are tokenized line by line, each line classifies into exactly one token, and the step-dispatch cascade in App. A.4 handles every syntactic distinction the lexer declines to make. This pushes complexity out of the lexer and into a single prose cascade (§ 3.10) that mirrors the implementation’s if/else if ladder. The rationale entries below record the places where a more sophisticated lexical design was considered and rejected; the unadorned sections are either mechanical (§ 3.1, § 3.6, § 3.8) or fall out of constraints explained elsewhere (§ 3.5 follows from App. A.5, § 3.9 follows from the embedded-Lua design in § 21).

C.3.2. Why one token per line

The Cookfile grammar is strictly line-oriented: every source line produces exactly one token carrying that line’s 1-indexed number. The alternative — a conventional multi-token-per-line lexer — was rejected on three grounds.

First, error locality. Nearly every diagnostic in the Rust parser reports a 1-indexed line number, and authors of Cookfiles edit them in line-oriented tools (editors, diffs, code review). A one-token-per-line scheme makes “line N” unambiguous; a multi-token scheme would need column offsets and a column-aware diagnostic renderer for the same quality of error.

Second, block locality. Brace-balanced blocks (§ 3.9) are collected by reading subsequent source lines and counting braces, not by reading subsequent tokens. A line-oriented lexer composes cleanly with that design: the block-collector asks the line iterator for the next N raw lines and the lexer asks for a single classification per line, and neither layer needs to hand the other a partial line.

Third, authoring model. Cookfile authors think in steps, not in expressions, and one step per line is the idiom every existing example reinforces. There is no authoring convenience the multi-token model would unlock.

The cost is that a Content token must be re-examined by the step-dispatch cascade (App. A.4) to distinguish gather, cook, test, module calls, and — in a chore body — shell commands (CS-0134 removed the @ interactive-command kind and rejects loose shell in recipe bodies). That re-examination is a small, linear, keyword-prefix test — cheaper than a general tokenizer would be and easier to reason about.

C.3.3. Why BARE_IDENTIFIER is ASCII-only

The BARE_IDENTIFIER character class in App. A.5 is ASCII — no Unicode letters, no Unicode digits, no confusables. The reason is interoperability with the shell.

Most BARE_IDENTIFIER values end up as filesystem-visible artefacts: recipe names become command-line arguments to the cook CLI, and module-call first-segments correspond to on-disk module roots. ASCII-only identifiers guarantee those names survive copy-paste between a Cookfile, a terminal, a CI log, a URL, and a filesystem without encoding surprises. Unicode identifiers would force the implementation to take a stance on normalisation, case-folding, and confusable-detection, none of which is justified by the expected author population.

The cost is paid by authors working in non-Latin scripts, who can still use a STRING-form recipe name (App. A.5 allows name ::= BARE_IDENTIFIER | STRING) at the price of double quoting. The escape hatch keeps the simple case simple and charges the complexity only to the users who actually need it.

C.3.4. Why keywords are reserved contextually, not globally

The Cookfile language defines step keywords (recipe, config, cook, gather, using, use, import, end, test) and reserved recipe segments (stem, name, ext, dir, in, out); neither set is reserved globally. A recipe named backend.build is accepted because build is not on the reserved-segment list; backend.stem is rejected because stem is. A Content line whose first word is recipes_cleanup or configure is dispatched as a shell_command, not as a declaration, because the step-keyword rule requires a separator after the keyword.

Global reservation would have been simpler to specify but would have broken the most common shell idioms. Cookfiles frequently call tools named configure, cook-book, tester, using-* — forbidding those as shell commands to protect a handful of language keywords would be a poor trade. The contextual rule instead places the reservation only where it is needed: the final segment of a recipe name, where the $<stem>-style substitution rule would otherwise collide with a literal segment of the same name.

The cost is that the step-dispatch cascade in App. A.4 must insist on a separator after each step keyword (recipe, config, cook, gather, using, use, import, end, test) so that a Content line whose first word is recipes_cleanup or configure is not misclassified as a declaration. That cost is borne by the Standard and the implementation, not by the author.

C.3.7. Why the @ interactive prefix was removed

A natural reading of pre-CS-0134 Cookfiles suggested that @ was a third line prefix alongside > and >{, marking a shell command as interactive. The Standard never defined it as a lexical prefix — and CS-0134 removed it from the language altogether.

Two facts made @ redundant. First, a recipe body is now purely declarative (§ C.6.10): it holds no shell steps at all, interactive or otherwise, so it has no use for an interactivity marker. Second, every chore-body shell_command is interactive by the chore default (§ 7.3) — the @ marker was a no-op on the only construct that still admitted shell commands. A marker that is illegal in one body kind and a no-op in the other carries no information, so CS-0134 deleted it: a line beginning with @ is now invalid syntax everywhere.

Keeping @ would also have forced a context-sensitive lexer: @cmd at the top level is a parse error while @cmd in a body was an interactive step, a distinction a line-oriented, context-free lexer cannot make. Removing the marker outright is simpler than either a context-aware lexer or a syntactic-layer strip, and costs the author nothing — interactivity is now implied by the chore form itself.

C.4. On § 4.1 Syntactic grammar

Chapter 3 walks through a grammar that is almost entirely keyword-led at the outer layer. Most of the design decisions in § 4.1 are mechanical consequences of the lexical structure defined in § 3: each production is selected by a single leading token, and no backtracking is required. The rationale entries below address the places where § 4.1 imposes a constraint that goes beyond “pick the production the leading token names” — places where the grammar is deliberately narrower than a permissive reading would allow.

C.4.2. Why the top-level prefix is ordered

Cookfiles allow declarations of four kinds — variables, use, import, and config — to precede recipes, and the Standard requires that they form a contiguous prefix: once the first recipe has been parsed, no further declaration of any of the four kinds is accepted.

The ordering is not necessary for parsing — a context-free grammar that accepted interleaved declarations and recipes would parse unambiguously. It is a readability and configuration-layering choice. Config blocks and variable declarations are meant to be read as a header that frames the recipes that follow; scattering them between recipes defeats that reading, and permits confusing patterns where a use resolved by one recipe is not yet in scope for a sibling recipe that was parsed earlier in the same file. Requiring the prefix to be contiguous removes the question entirely: every declaration is in scope for every recipe.

The rule is trivially removable if it ever proves onerous; nothing downstream depends on it. But while it is in place, the diagnostic is actionable — the parser names the offending item’s line — and authors get a clean separation between file-level configuration and recipe definitions.

C.4.9. Config blocks as the sole variable surface

A previous revision of the Standard admitted a top-level variable_declaration form (NAME "value") that fed values into the variable namespace independently of any config_block. This amendment removes that form. The remaining surface is the config_block Lua body, which writes the var sink through ordinary Lua assignment.

Three forces motivate the consolidation. First, named overlay configs cannot override values set outside any config block without ad-hoc precedence wording; folding the surface into config blocks gives a single layered model with well-defined override semantics (§ 5.3.1). Second, the bare top-level form is sugar for one specific case of what config block bodies already express; removing it deletes a duplicate surface, the Token::VarDecl lexical class, the contextual blocking-keyword reservation, and the recipe-body reclassification rule that the form required. Third, the consolidation moves the choice of when a value is set (always, vs. only-when-an-overlay-is-active) into the same surface as the value itself, which keeps the source of truth localised.

Layered single-select was chosen over multi-select for this revision: at most one overlay can be active at a time. Multi-select would force the Standard to specify ordering across multiple selected overlays and to define conflict resolution for keys written by more than one of them. Single-select avoids both questions and is non-breaking to relax in a future revision if real use cases warrant it.

The selection mechanism for the active overlay is implementation-defined. Config selection is a CLI affordance, and the Standard’s general posture (§ 0) is to specify Cookfile-language behaviour rather than tool invocation surfaces. Implementations are free to expose a flag, an environment variable, a file-level default, or any combination; the validation rule for the selected name is normatively specified in § 5.3.1.

C.4.10. Implicit termination of recipe and config bodies

Each recipe and config block in v0.3 closed with an explicit end line. The closing keyword was ceremony — every author wrote it without thinking, and the parser had no need for it that the next top-level construct or EOF could not serve. A Cookfile with ten recipes had ten lines that existed only to satisfy the grammar. CS-0019 / v0.4 drops the keyword and replaces it with the implicit-termination rule of § 4.3.

Three forces motivate the change. First, the brace-block-as-opaque-source-lines model already cleanly separates Lua- and shell-block boundaries from recipe boundaries: a recipe keyword appearing inside a Lua block’s source is never seen by the recipe-body parser, because the brace-counter skips the token stream over the block (§ 3.9). Implicit termination by next top-level keyword does not interfere with embedded blocks. Second, the lexer’s column-0 enforcement on top-level keywords (§ 3.4) prevents an indented recipe foo line inside a recipe body from silently terminating the outer recipe — the column-0 requirement is the exact discriminator the parser needs. Third, the diagnostic surface improves: the prior “you forgot to close this recipe” diagnostic is replaced by a more specific diagnostic when an embedded keyword token violates region rules, since there is no missing end to forget.

The cost is that a deliberately empty recipe at the head of a Cookfile (no body, no immediately-following next-recipe header) is now indistinguishable from “header followed by the rest of the file as body until EOF” — but that ambiguity does not actually exist, because EOF is a valid terminator. The recipe recipe empty\n followed by EOF is a valid empty recipe.

A bare end line is a Content token (§ 3.10). At top level it is rejected by § 4.1. Inside a recipe body it dispatches as a shell_command running the literal command end, which the user’s shell will interpret per its own rules. This is a deliberate non-special-case: there is no end keyword in the recipe-body cascade; the line gets the same treatment as any other Content line.

Migration: in this commit, every spec example (chapters 2 through 8 and App. C) drops its trailing end lines so the spec consistently teaches v0.4 surface form. The conformance-corpus migration (every fixture under standard/conformance/) and the project-Cookfile migration (root, cli/, standard/, tree-sitter-cook/, examples/) follow in subsequent commits of the same v0.4 cut. The Standard cuts at v0.4 with cs-standard/v0.4 tagged at the merge commit per the parse-format-altering rule in CONTRIBUTING.md.

C.6. On § 6 Recipes and step kinds

Chapter 4 defines the largest single surface area of the Cookfile language: the step kinds available inside a recipe body and the dispatch rules that separate them. The design is pragmatic rather than minimalist. The Cook authors had to balance three competing forces: (a) make the common cases (a shell command, a one-to-one build, an interactive prompt) trivially expressible; (b) keep the lexer and parser context-free and line-oriented so that editor tooling remains simple; (c) allow Lua to extend the language without requiring it to do so for basic use. The sections below record the non-obvious choices that follow from those forces.

C.6.6. Why cook "a" "b" using "cmd" is a parse error

The single-string using form is convenient in the one-output case because the command template can reference $<out> and the runtime knows exactly what to substitute. In the multi-output case that determinism evaporates: a single "cmd" template has no way to describe which of several outputs is produced by which sub-command, nor which sub-command produces any at all.

The Standard could have papered over this by, for example, running the same "cmd" multiple times with $<out> bound in turn to each output. That semantics is surprising — the user typically wants a single invocation of a generator that happens to produce several artifacts, not N invocations. It would also hide common bugs, because a "cmd" that produces only the first output would silently “succeed” on the others.

Requiring a block body — either { ... } or >{ ... } — forces the author to state exactly how the outputs are produced: either as a literal sequence of shell commands (whose joint effect produces all outputs) or as a Lua block (which has full access to the outputs array and can write each entry deliberately). The parser’s rejection of the ambiguous single-string form is therefore normative (§ A.4, “Multi-output cook-step rule”) and produces a diagnostic that names the block-body remedy directly.

C.6.8. Why > is execute-phase and >> was removed

The Cookfile language once carried two Lua-prefix surfaces: the single-> family (>, >{ … }) for execute-phase Lua, and a doubled-> family (>>, >>{ … }) for register-phase Lua. CS-0134 removes the doubled-> family from the language entirely and, with it, the standalone single-> step from recipe bodies. The single-> syntax survives only as a block-body opener — the >{ … } body of a cook / test / probe step, and standalone > / >{ … } steps inside a chore body. This entry records why the split existed and why it was retired.

The teachable meaning of the single-> form is unchanged: it means “execute-phase Lua.” A reader who encounters > print("hi") in a chore body, or a cook "out" >{ … } body, expects the code to run when the work runs, not when Cook plans it. That intuition aligns with the rest of execute — a chore shell_command runs at execute, and so does a > line.

The doubled prefix existed to reverse the timing for register-phase orchestration, where the Lua body’s job is to call Cook Lua API helpers (cook.add_unit, cook.export, cook.step_group) that record work for execute. That role has not disappeared, but its surface has: CS-0134 routes register-phase Lua through a bare module-call line (§ C.6.9) inside a recipe body, and through a top-level register block (§ 5.4) or top-level module_call (§ 4.4) everywhere else. A dedicated >> sigil for the same effect was redundant visual weight once module calls carry the register-phase default; retiring it collapses two ways of writing register-phase Lua into one and removes the “how many arrows?” question from the language.

The cook-body Lua block (cook STR >{ … }) keeps the single-> prefix. The >{ … } syntax means “execute-phase Lua chunk” wherever it appears; the cook-step body position adds bindings (input, output, inputs, outputs, input_N per § 23.1) but does not change the phase. A >>{ … } body is not merely rejected in that position — the >>{ prefix no longer exists (§ 3.7, Note 3.7.1).

C.6.9. Why a recipe-body module_call is register-phase

A bare ns.fn(...) line in a recipe body is auto-classified as a register-phase module_call (§ 8.1, rule 7). CS-0134 reinstates this classification, reversing the CS-0072 removal that this entry originally justified: for two revisions authors had to spell register-phase module calls with an explicit >> prefix, and CS-0134’s removal of >> from the language made a bare, register-phase module-call line the natural home again. The alternative — treating a recipe-body module call as execute-phase — was considered and rejected.

Module functions in the wild record work: they call cook.add_unit, cook.step_group, cook.export, and similar register-only helpers. A function like cpp.static_library("widget", { … }) exists to enumerate sources, register one compile unit per source, and record a final archive unit. None of that is meaningful at execute time — the DAG is already built, and cook.add_unit raises by § C.6.12. Defaulting the bare call to register-phase fits the overwhelmingly common case without ceremony.

A user who wants to do execute-phase work via a module function does so where execute-phase Lua now lives: a >{ … } block body of a cook / test step, or a > / >{ … } step inside a chore body. There the module function’s body runs at execute with the execute-phase API surface (§ C.6.12). The split is now carried by body position rather than by an arrow count: register-phase at the recipe-body top level, execute-phase inside a block body or chore.

C.6.10. Why the recipe body is purely declarative

A recipe_body is purely declarative (§ 6.3): it carries only register-phase items — gather / cook / test steps and bare module_call lines — and its execute-phase work lives exclusively inside the block body of a cook or test step (>{ … } / { … }). CS-0134 arrived at this shape after an intermediate design that partitioned the body into a declarative region followed by an imperative region, enforced by a parse-time out-of-order error. This entry records why the whole intra-body ordering apparatus was dropped in favour of body position.

The two earlier alternatives both failed. Free interleaving of register and execute steps was tried first, and produced two footguns. Body-unit diagnostics became fragile: a > print(x) after a cook step coalesced into one body unit, a later cook recorded another, a third > print(x) yet another, scattering a recipe’s units so failures cited line numbers without context. And scope-sharing across the register/execute boundary became impossible to reason about — a user who wrote >> local x = … then > print(x) expected the local to print, but x was a register-phase Lua local invisible to the execute worker VM. The region-ordering rule fixed interleaving but kept both surfaces in the same body, so the reader still had to track a mid-body phase transition and the scope discontinuity that rode along with it.

CS-0134 removes the footgun at its root: the register/execute split now maps 1:1 to body position, not to intra-body ordering. Register-phase work is the recipe-body top level (declarative); execute-phase work is a cook / test block body; and imperative command-runner work is a chore (§ 7). There is no in-body ordering rule to violate because there is no imperative region in a recipe body, and there is no cross-boundary scope surprise because the two phases no longer share one body. A reader sees a recipe body that is uniformly a plan, and a block body that is uniformly the doing.

The cost is that a recipe author who wants ad-hoc execute-phase glue reaches for a cook/test block body or a chore rather than dropping a loose >/shell line into the recipe. The tradeoff is deliberate: a recipe body that mixed register and execute work in alternating slabs was, almost always, a plan and a chore pretending to be one recipe.

C.6.11. Why recipe execute-work is now one unit per block body

Because a recipe body is purely declarative (§ C.6.10), a recipe’s execute-phase work comes exclusively from the block body of a cook or test step, and each block body is its own work unit (§ 15.2 is vacuous under CS-0134). This entry records how that shape settled a question the intermediate design fought over — how loose recipe-body imperative steps should have been grouped into units.

The intermediate design admitted standalone > / >{ … } / shell / @ steps in a recipe body’s imperative region and had to decide how a run of them bundled. Per-line units — one unit per step — were rejected because they made “Make-.ONESHELL” semantics impossible: a cd bin followed by ./app ran in two shell processes, the cd did not persist, and the most basic intuition for a sequence of body lines broke. One unit per region was rejected because an interactive step has draining semantics (§ 16.2) that cannot live inside a shared Lua chunk. The compromise — bundle consecutive steps until an interactive step interrupts — worked, but it was a subtle rule with a source-line-range progress label papering over the fact that one surface region produced several hidden units.

CS-0134 dissolves the question. There is no loose imperative region to bundle: the unit boundary is now syntactically explicit — it is the { … } / >{ … } block the author wrote. A multi-command sequence that wants one-shell persistence goes inside one { … } shell body (whose lines join into a single set -e shell process, § 8.4); Lua glue that wants shared locals goes inside one >{ … } block. Interactive command-runner work moves to a chore (§ 7), where every shell step is interactive by default and the single-drain rule (§ 7.3) — not body bundling — governs execution. The user sees exactly as many units as blocks they wrote, and the progress UI needs no line-range heuristic.

C.6.12. Why register-only Cook Lua API helpers raise from execute-phase Lua

cook.add_unit, cook.exec, cook.interactive, cook.step_group, and cook.recipe are register-only by § 24.1–§ 22.3 and § 22.1. A conforming implementation MUST raise a Lua runtime error when any of them is called from execute-phase Lua (a lua_line, a lua_block, or a >{ … } payload). The alternatives — silently doing nothing, returning a stub value, or re-recording a unit that runs after the DAG closed — were rejected.

The register/execute split makes a sharp claim: the DAG is fully known before execution begins. Allowing register-only helpers to “soft fail” inside an execute-phase chunk would erode that claim. A user who calls cook.add_unit inside a >{ … } body unit is, by the structure of the language, doing something incoherent: the unit being added would have no scheduling context. An error caught at the offending call is the right diagnostic — it identifies the function name and the calling step kind, and points the user at the corrective form (move the call to a bare module.call() line in the recipe body, a top-level register block, or a module function).

cook.sh is the one helper exposed in both phases (§ 24.1). The user-visible behaviour — “run this command, give me its stdout, error on non-zero” — is the same in both contexts; only the surrounding scheduling differs. Promoting it to both phases keeps execute-phase Lua — a cook/test block body or a chore body — usable for shell-out work without inventing a parallel cook.run-style helper.

C.6.13. Why module-call dispatch is a first-two-tokens heuristic

The module-call pattern of § A.4 inspects only the first identifier, a literal ., and the first character after the dot. It does not attempt to parse the rest of the line as Lua. This is deliberate.

A full Lua parse at dispatch time would couple the Cook parser to the Lua grammar, making the Cook parser’s correctness depend on a Lua front-end and making its diagnostics worse: a Lua syntax error inside a module-call line would have to be reported as a Cook error at a Cook source position. It would also require the Cook parser to make an early commit between “this is a shell command” and “this is Lua”, and a wrong early commit in either direction produces terrible error messages.

The two-token heuristic is cheap, lexer-local, and has no false positives in practice. A line that looks like identifier.identifier and is not a valid Lua expression is virtually never a valid shell command either (hyphens in the first segment are excluded precisely to protect cargo-build ... and similar shell forms). When the heuristic matches, the Lua runtime is the right place to report a Lua syntax error: it has the full Lua grammar and a dedicated error reporter. When the heuristic does not match, the fall-through to shell_command is lossless — the line is passed verbatim to the shell, which will report its own error if the line is malformed there.

The multi-line extension (continuing across source lines while braces are unbalanced) is the same brace-balance algorithm used for lua_block (§ 3.9). Reusing it keeps the lexer consistent: the Cookfile parser has exactly one way to recognise a brace-balanced region, and that region’s body is always handed to Lua untouched.

C.6.14. Why chore is a separate form rather than a flag on recipe

The chore-vs-recipe distinction could have been spelled recipe foo (chore), recipe foo @chore, recipe! foo, or any number of recipe-with-modifier shapes. CS-0020 instead introduces chore as a peer top-level keyword. Three forces drive the choice.

First, the keyword is the carrier of intent. A reader who scans a Cookfile reads recipe build and chore deploy differently — the keyword tells the eye whether the body is a build target or a command runner. A modifier on recipe is read after the keyword, by which time the reader has already committed to “build target”. The split keyword is a pure UX win for navigation.

Second, the parse-time step-kind ban is naturally expressed at the production level. A chore production has a smaller chore_body alternation than a recipe production has for recipe_body, and the ban falls out of the grammar rather than being a separate rule the parser checks after the fact. The implementation is less code than a recipe (chore) modifier whose validation runs after step-kind dispatch.

Third, the syntactic peerage models the semantic peerage. Chores and recipes are equally first-class — a recipe may depend on a chore, a chore on a recipe — and giving them peer keywords is an accurate model of that. A modifier shape would suggest one is a special case of the other.

The cost is a new keyword to remember. Cook’s keyword surface remains small (recipe, chore, config, use, import, gather, cook, test, using); even with chore added, the set is well within “memorable in one sitting” range.

C.6.15. Why chore bans gather, cook, test

The three banned step kinds — gather, cook, test — share a common property: each declares part of a build-graph node (inputs, outputs, validation). A chore is by definition a body that does NOT participate in the build graph: it always runs, it produces no cacheable output, and its outputs are not consumed by other recipes via the cross-recipe-reference mechanism (§ 10).

Allowing a cook step inside a chore body would be incoherent: either (a) the engine ignores the cook step’s output declaration, in which case it serves no purpose, or (b) the engine treats it as a build target despite the chore’s no-cache rule, in which case the rule is violated. The same argument applies to gather (declares inputs, but a chore has no cache to invalidate against) and test (validates a cook step’s output, but no cook step).

Banning module calls was considered and rejected. A module call typically expands into a sequence of register-phase declarative steps, some of which might be cook steps and would be caught by the recipe-body region rule’s existing diagnostics. A flat ban would be over-strict — a module that exposes a cleanup() function returning no cook.add_unit calls is a perfectly valid chore body call. The conservative choice is therefore to allow module_call in chore bodies; module authors who emit cook units inside a chore-context call see a diagnostic at the cook.add_unit site (per § 7.4) rather than at parse time.

C.6.16. Why default-interactive

A chore is by intent a thing the user runs at a terminal. The @ marker (§ 8.8) on every shell line of a recipe-shaped command-runner is ceremony — the author already chose chore over recipe because the body is interactive. The default-interactive rule deletes the ceremony.

The @ marker is preserved as a no-op inside chore bodies for symmetry: a recipe body author migrating to chore does not need to strip every @, and a Cookfile that is moved between forms during refactoring continues to parse. The non-symmetric direction (chore → recipe) requires the author to add @ markers where they want interactivity preserved; this is an authoring choice and is the expected path for “this used to be a chore but I want to add cook steps now”.

C.6.17. Why test is a cook step with no declared outputs

Once cook gained block bodies and three iteration modes (CS-0022), the only structural difference between a cook step and a test step was the presence of an output declaration. Modeling test as “cook with no outputs” makes the body grammar uniform, the placeholder vocabulary uniform, the cross-recipe substitution rules uniform, and the substitution timing uniform. The author’s mental model stays a single body surface across cook and test. (CS-0135 removed the former plate step, which was the other “cook with no outputs” kind; its distinguishing traits — no declared output, un-sandboxed body — are exactly what a chore provides.)

The test keyword survives as its own step kind rather than folding into a generic “cook with no outputs” form because it signals author intent a reader relies on: scanning a recipe and seeing test says “this is a validation gate,” and the cook test runner reports its verdict — a DAG-positioned pass/fail node that a bare output-less cook step would not name.

The using keyword does not appear on a test line. Cook once earned it because a cook line carried two things — output pattern list and body — that needed a separator (the keyword was itself later removed, CS-0099). A test line carries only a body, so no such separator was ever warranted.

C.6.18. Why iteration mode is deduced from the body for test

Cook owns iteration mode via the output pattern (CS-0022) because the output pattern is declarative: it states the unit’s filenames and falls out the iteration shape. A test has no outputs to declare, so the body’s placeholder content is the only available signal. The CS-0022 footgun — an output-pattern signal contradicting a body signal — cannot occur in a step that has no output pattern. Deducing mode from body content is therefore sound for test, even though it would be incoherent for cook.

The deduction is purely syntactic: it scans the body’s text (shell) or its free-identifier references (Lua) at register time. It does not require Lua evaluation. The alternative — letting the runtime decide which binding to populate based on which one was named — would require dynamic mode selection, breaking the static-shape DAG cook.add_unit produces.

C.6.19. Why $<out> is rejected in test bodies

CS-0022 fixed $<out> to mean the unit’s declared output. A test declares no outputs, so $<out> has no referent. An earlier (pre-CS-0024) surface used $<out> to mean the iteration item — but that is what $<in> means everywhere else in the language. A per-step-kind name for “the iteration item” would re-introduce the position-dependent rule that CS-0022 paid to remove.

C.6.20. Why $<lib.ACCESSOR> is rejected in test bodies

A test has no output pattern and therefore cannot declare a lib-driven iteration. § 10.4‘s firewall — $<lib.ACCESSOR> rejected in any cook-step body — applies trivially to a test body: there is never a position in such a body where $<lib.ACCESSOR> could be valid. A bare $<lib> reference is admitted and substitutes per § 10.5.

C.6.21. Why a static Lua identifier scan, not a runtime decision

A Lua body that references both input and inputs is making contradictory claims about iteration shape. Detecting that at register time produces a sharp diagnostic with a line number; deferring it to “the runtime decides which binding is bound based on which one was named” would either require dynamic mode selection (incompatible with the static-shape DAG) or two distinct mode-resolution passes (one syntactic for shell, one runtime for Lua). One pass, applied uniformly, is the better trade.

The scan is whole-word, identifier-position. A property-name suffix (cook.input, obj:input(...)) is not a reference to the binding because the Lua grammar resolves the name as a field access. The scan respects the existing string/comment/long-string ignore rules of § 3.9.

C.6.22. Why as STRING and not name STRING

as reads as natural English (“this test, as ‘parses-config’”) and aligns with SQL’s AS aliasing posture. name would collide with the table-field name in cook.add_test (§ 22.4), creating two ways to spell the same concept with subtly different timing — the modifier substitutes at register time, whereas the name field is constructed when the table is assembled at execute time. Choosing distinct surface tokens for the two surfaces makes the timing difference legible.

C.6.23. Why the modifier order is fixed

LL(1) parsability is a hard constraint for the Cook parser (cook-lang’s recursive-descent shape). Free-order modifiers either require backtracking or a peek/look-ahead that the parser does not currently support. The fixed order also produces canonical reading order in source: identity (as) → constraint (timeout) → interpretation (should_fail). Authors who reach for a different order are flagged immediately rather than silently parsed differently.

C.6.24. Why test caching is universal in the Standard

The alternative was caching as an implementation choice with no Standard contract. That posture works for v0.x but creates a portability hazard at v1.0: a Cookfile that passes on cloud-cache machine A and fails on machine B — because A cached a test and B did not — is a contract violation only if the Standard says so. Specifying the cache key inputs and the recordable-outcomes rule (§ 17.4) means cross-machine cache replay is correct or it is a Standard violation. Without a contract, the cloud-cache feature degrades to “works on machines configured the same way,” which is the wrong promise for a build-system test runner.

C.6.25. Why no per-step volatile modifier

A volatile modifier teaches authors that hidden inputs are normal. The right answer for tests with filesystem inputs Cook cannot see (vitest reading .test.ts, pytest reading .py) is the existing discovered_inputs mechanism (§ 17.3) plus depfile emission. The right answer for tests with non-filesystem hidden inputs (network, clock, $RANDOM) is the operator-side cook --test --rerun [PATTERN] flag, applied per-invocation. A persistent per-step opt-out would convert “I know this is non-deterministic” into “I’m too lazy to declare my inputs,” and the cache becomes a sieve.

The CLI escape hatch costs nothing in language surface and pushes authors toward correct input declaration; the language escape hatch costs spec breadth and grants permission for sloppiness. The trade is in favour of the CLI form.

C.6.26. Why timed-out + should_fail is “timed out”, not “passed”

A timeout is not an exit status; it is a runtime intervention. Treating it as failure-equivalent for the purpose of should_fail would mean any sufficiently slow test passes any should_fail claim, which destroys the modifier’s diagnostic value. The dedicated “timed out” outcome (§ 17.4) preserves the runner’s ability to flag timeouts distinctly in the report.

C.6.27. Why the member index is spelled [in], and why bare $<NAME> was not narrowed

The v0.14 spelling $<NAME[]> (CS-0098) was shorthand for a keyed-join design — $<NAME[k]>, “the producer’s output with the same key” — whose key surface was deferred with cross-probe joins. With keys gone from v1.0, the empty brackets indexed by nothing: line noise that suggests an array where there is none. CS-0137 respells the accessor $<NAME[in]>: the index names what actually fills the slot — the current member binding, the same in the fan-out body already reads via $<in>. It reads as real indexing (“render at this member”), and it re-admits the key idea additively in 1.x ($<NAME[in.FIELD]>, $<NAME[key]>) — the cheap direction under the hard-cut philosophy, which is why every bracket content other than the literal in is reserved and rejected today.

Two alternatives for the bare form were considered and declined. Inference — making bare $<NAME> mean the per-member output whenever the consumer iterates the same probe as NAME — makes the reference’s arity depend on the remote recipe’s header: the meaning of $<render> stops being decidable at the reference site, and a refactor that changes render’s driver silently flips every consumer between per-member and all-outputs. Rejection — a hard error on bare $<NAME> inside a fan-out body that could have used [in] — was declined in favour of one uniform bare meaning: bare $<NAME> is all of NAME’s terminal outputs, space-joined, in every position (§ 8.9). Per-member is always explicit; all-outputs is always bare.

C.13. On § 13 Execution model

The execution model in § 13 is organised around a two-phase design: every recipe is evaluated once to discover its work (the register phase) and then arranged for execution (the execute phase). This split is not cosmetic. It is the mechanism by which the Standard turns a scripting-flavoured surface syntax — arbitrary Lua embedded in a recipe body — into a dependency graph that can be reasoned about, parallelised, and cached. The rationale subsections below record why several load-bearing decisions in § 13 are the way they are.

C.13.1. Why the register phase is pure and non-blocking

The register phase evaluates each recipe’s Lua body once, with capture-mode bindings for the Cook Lua API (§ 14.1). The requirement that register-phase calls be pure with respect to the build state — no subprocesses against declared outputs, no output-file mutations, no blocking on external I/O — exists for three concrete reasons.

First, determinism of the graph. If the register phase could block on a subprocess whose exit depended on a file produced by another recipe’s execute phase, the topology of the dependency graph would depend on the order in which the implementation chose to register recipes. Two conforming implementations would then produce different graphs for the same Cookfile. By confining register-phase effects to appending entries to a record, the graph becomes a deterministic function of the Cookfile source and a bounded set of pre-existing inputs.

Second, bounded time. The register phase runs on a single thread per recipe. If it could block, a long-running subprocess in one recipe’s body would stall the discovery of every other recipe’s work. Since the execute phase cannot begin until registration completes, this would reduce the whole build to serial execution even when the Cookfile would otherwise permit heavy parallelism.

Third, cache reasoning. The cache (§ 17) keys work units on their register-phase record. If that record could reflect the transient state of a subprocess, cache hits would become order-dependent: the same command, on the same inputs, could produce different cache keys depending on whether an unrelated sibling step had already run. Purity eliminates that class of confusion.

The narrowly-scoped exception allowed by the closing paragraph of § 14.1 — implementations MAY permit register-phase effects whose only purpose is to feed the register-phase Lua control flow itself — is load-bearing for the cook.sh utility in § 21. That call’s return value drives whether a cook step fires at all, and suppressing it would force authors to split dynamic build logic across two passes.

C.13.2. Why chore bodies drain rather than serialise by default

A chore body is implicitly interactive (§ 7.3): § 16.2 requires it to drain all other running work before its commands begin. The alternative — allowing an interactive command to run concurrently with background work and only inherit the TTY — was rejected because it produces user-hostile behaviour in the common case. (Before CS-0134 this draining was requested per-line with an @ prefix; CS-0134 removed the @ marker and made every chore-body command drain by default, since chores exist precisely for terminal-sharing work.)

Chores are used for dev-loop commands like ./build/app, vim notes.md, and ./serve. These share the terminal with the human operator. If background work continues while such a command is in progress, its stdout and stderr interleave with the interactive session, corrupting the user’s view and often stealing focus from whatever TUI the command is presenting. Users who saw this behaviour during prototyping reported it as a defect even when the concurrent output was mechanically correct.

Draining is not the same as serial execution of the whole build. Work units of other recipes before the chore body ran MAY have run in parallel; work units after it MAY again. The drain is local to the chore body’s single-drain window (§ 7.3). This preserves parallelism for build work while giving interactive work the quiet terminal users expect.

Authors who want concurrent background work alongside an interactive session can obtain it by running the background work in a separate recipe and not depending on the chore — the dependency graph continues to permit parallel execution of independent recipes under the usual rules.

C.13.3. Why cache semantics are abstract rather than specified by hash algorithm

Section 8.6 fixes the set of observable inputs a cache MAY consider — command text, input paths, output paths, environment, and optionally input-file content and output-file mtime — but it does not fix the hash function, the on-disk layout, or the policy for when cache entries are evicted. The boundary is deliberate.

Cross-machine cache correctness. §8.6’s enumeration of permissible cache observables now includes machine identity and tool-binary content. The motivation is the shared/team cache: an artifact produced on one machine and served to another must remain correct against differences in compiler version, libc, locale, and the resolved binary that the command text names. A Cook implementation that hashes only “command text + input files” is correct on a single machine but unsafe across machines; the additional observables let an implementation be cross-machine-safe without taking on those observables when running locally. The denylist clause acknowledges that not every difference in the resolved environment affects build output (ambient session state, secrets unrelated to the artifact), and lets implementations filter without violating the minimum-observable enumeration of ¶2.

The set of observable inputs is a language-level property: a Cookfile author reasoning about why a step did or did not re-run needs to know what the implementation is allowed to consider. The hash function and storage format are engineering concerns that implementations should be free to evolve — a switch from one hash family to another, or from a flat file to an embedded database, changes nothing the author sees at the Cookfile level.

Specifying the hash function in the Standard would also lock every implementation into bit-compatibility with the reference, making caches transferable between implementations. That has never been a design goal. Caches are local artefacts; two implementations that both respect the observable-input rule produce equivalent cache decisions even if their on-disk caches are mutually unreadable.

The closing paragraph of § 17 — that caching is optional altogether — follows from the same principle. An implementation that treats every run as a clean build satisfies the dependency rules of § 13.1–§ 16.2 and therefore conforms. The Standard does not require caching; it regulates it when it is present.

C.13.4. Why diagnostic ordering is normative but diagnostic wording is not

The ordering rule in § 19.1 — syntactic diagnostics before semantic, first syntax error halts parsing — is normative because it determines what a Cookfile author sees on a single run. An implementation that reported a dependency cycle before reporting that a cook line was unterminated would mislead authors into debugging the graph when the real problem was a quote.

The wording is not normative because diagnostic quality is a product concern where implementations should be free to differentiate. A terse parser-level message suitable for a CI log and a richly-formatted message suitable for an editor overlay are both conforming so long as the ordering holds and the class of error is identifiable. This matches the convention established in § 0.3 and § 1.1: exact wording is out of scope; the diagnostic class is in scope.

C.13.5. Why the work-unit DAG is unified across recipes

Earlier reference implementations of the register phase maintained two graphs: a coarse recipe-level dependency graph and a per-wave work-unit DAG. The recipe-level graph drove a wave-grouping pass that grouped recipes into synchronisation barriers; the per-wave DAG handled fine-grained unit ordering within each wave. Cross-recipe edges were plumbed through the recipe-level structure rather than living on the work-unit graph directly. This was an implementation accident, not a design property, and § 13.3 retires it.

Two concrete costs motivated the unification.

First, same-wave merging logic was hidden in scheduler code. When {dep}-style implicit cross-recipe edges (§ 16.1) appeared in a Cookfile, the wave grouper had to merge the producing and consuming recipes into one wave so the per-wave DAG could see them as intra-wave edges. The merging rule was correct, but bespoke; nothing in the Cookfile language motivated its existence — it was scaffolding to bridge the recipe-level and unit-level graphs.

Second, recipe boundaries leaked into scheduling. Lifecycle events (RecipeStarted, RecipeCompleted) fired at wave boundaries rather than at the unit-state transitions that actually marked the start or end of a recipe’s work. The reported timing did not match observable execution.

The unified DAG eliminates both. Cross-recipe edges become ordinary edges between work units; the executor’s existing parallelism rules (§ 15.1) handle them directly. Recipe boundaries survive only as labels on regions of the DAG — useful for lifecycle reporting, not load-bearing for scheduling. Lifecycle events fire at first/last unit transitions per recipe, giving event timing that mirrors observable work.

The trade-off is a single ordering constraint at register time: cook.dep_output(other_recipe) is evaluated inside a recipe body and must observe the terminal outputs of other_recipe, which means other_recipe’s body must have been invoked first. A conforming implementation satisfies this by topologically sorting recipes by their declared requires before invoking bodies. That sort is a register-time sequencing helper, not a normative graph structure; § 13.3 explicitly leaves the ordering implementation-defined.

C.13.N. Name references as the sole implicit-dep source

CS-0009 removed the implicit cross-recipe dependency rule based on byte-equality between an input path of one recipe and a cook-output path of another (old § 10.5). Under the new rule (§ 16.1), only name references (§ 10) establish cross-recipe edges.

The motivation is conservatism about hidden coupling. The string-match rule coupled scheduling to filesystem path coincidence: a refactor that renamed build/lib/libmath.a to build/artifact/libmath.a would silently rewire the DAG, with no diagnostic pointing at the recipe boundary that shifted. Authors routinely write output paths that happen to match an unrelated recipe’s input, particularly in large workspaces where paths are constructed algorithmically. The edge can surprise.

The rule is also redundant once name references exist. Any dep the author meant to express by path equality can be expressed by $<lib> or $<lib.ACCESSOR> in the consuming recipe, or by : lib in the header. The former creates a fine-grained edge at the placeholder site (§ 10.6) — strictly more precise than the path-match rule, which always produced a whole-recipe edge.

The small cost is a one-line migration for any existing Cookfile that relied on the rule: add $<lib> to the shell body, or add : lib to the header.

C.13.O. Dep-driven iteration via output-pattern accessors

CS-0009 specifies that $<lib.ACCESSOR> appearing in a cook step’s output pattern list is both a template substitution AND an iteration-source declaration: the step’s work units are generated per item in lib’s output list rather than per item in this recipe’s own input list (§ 10.4).

The alternative considered was an explicit declaration such as cook "build/$<stem>.o" from lib using "…", where from lib names the iteration driver and placeholder substitutions are orthogonal. The explicit form is easier to spec and eliminates the context-sensitivity of $<lib> vs $<lib.ACCESSOR>. It was rejected on surface-syntax grounds: the accessor placeholder already mirrors the own-iteration placeholder set ($<stem>, $<name>, $<ext>, $<dir>, § 9.3), no new keyword is introduced, and Cookfiles already shipped with the terse form.

The cost — that a step’s iteration source is determined by placeholder inspection — is contained by the “one distinct recipe per step” rule (§ 10.4): the inspection is unambiguous, and the diagnostic for mixing two distinct drivers is straightforward.

C.13.P. Closed accessor set

The set of path accessors (§ 10.3) is {stem, name, ext, dir} and is closed. Adding an accessor is a change to this Standard.

The alternative — opening the set to every zero-arg path.* function — was rejected because the resolver in § 10.2 splits on a dot and checks the SUFFIX against a small fixed set. Opening the set would require the resolver to consult the path module at parse time, introducing semantic lookup into what is currently a syntactic pass and exposing resolver output to extensions of the standard library. Growth of surface syntax should be a Standard change; growth of Lua helpers should not require one.

A zero-arg path.* addition not in the accessor set remains reachable from step bodies via a { … } Lua block (§ 8.4, § 8.5). The closed-set rule does not restrict what authors can compute — only what syntactic shortcuts exist for the common cases.

C.16. On § 16 Cross-recipe ordering

§ 16.1 admits exactly one source of ordering — a name reference — and § 10.6 says what does not count as one. The subsections below record why those two rules survived a mechanism (§ 22.9) that at first reading looks like an exception to them, and why the two diagnostics that sit alongside them (§ 16.1.1, § 16.1.2) are deliberately scoped differently from each other and from their § 22.1.2 cousin. The subsections above (§ C.13, in particular the “Name references as the sole implicit-dep source” note recording CS-0009’s removal of the input/serves byte-equality rule — the note this Standard’s older text cited as “App. B.5.N” before the v0.10 reorg renumbered App. B into App. C) give the historical background these sections build on.

C.16.1. Why a name reference is evidence and a path match is not

§ 10.6 forbids inferring a cross-recipe edge from path-string equality. § 22.9 lets a module, running inside a recipe body it does not own, declare a cross-recipe edge outright. These read as contradictory only if the prohibition is taken to be about mechanisms. It is not: it is about evidence.

An edge asserts that the author meant one recipe to run before another. The question a conforming implementation must answer is what licenses that assertion. A path string that happens to equal another recipe’s declared output licenses nothing, because the author never named the referent — the match is a coincidence of spelling, and an implementation that promoted it to an edge would be guessing at an intent nobody expressed. Two authors constructing output paths algorithmically in a large workspace collide by accident; a refactor renaming build/lib/libmath.a to build/artifact/libmath.a silently rewires the graph with no diagnostic pointing at the boundary that moved.

cook.require_recipe(name) supplies exactly the evidence § 10.6 demands. Its argument is a recipe name — never a path — written by the author directly, or by a module acting on a name the author wrote (links = {"idLib"}). The referent is named, so the edge is authored, not inferred. § 22.9 is therefore an instance of the “Name reference ⇒ cross-recipe edge” rule and not an exception to it, which is why § 10.6‘s prohibition needs no amendment to accommodate it: the API cannot launder a path match into an edge, because it never sees a path. Only § 16.1‘s enumeration of where a name reference may be written had to widen; the category it enumerates did not move.

The alternative design — inferring the ordering from an output↔input path match, so that a consumer declaring build/gen.a as an input would be ordered after whichever recipe declares it as an output — was rejected on precisely this ground, and the cost of taking it would have been structural rather than stylistic. It would have reversed § 10.6 and § 16.1, reinstating by the back door the byte-equality rule CS-0009 removed. It would also have forced a re-think of § 22.1.2‘s terminality rule, which exists because globs cannot be lexically matched: an implementation that derives ordering from path matching owes an answer for the outputs it cannot match, and the only answers available are to leave glob-output recipes silently unordered (the footgun § C.17.1 closes) or to resolve globs against the filesystem at plan time (which makes the graph a function of disk state rather than of the source). Declining to infer keeps both problems from arising: an unmatched glob and an unmatched literal are treated identically, because neither was ever evidence of anything.

What the author loses by this choice is nothing they cannot spell. The dependency they meant is one : producer, one $<producer>, or one cook.require_recipe("producer") away — and where they plainly meant it and did not say it, § 16.1.2 tells them so rather than leaving them to find out under --jobs > 1.

C.16.2. Why § 16.1.2 is closure-scoped where § 22.1.2 is workspace-wide

§ 22.1.2‘s cross-recipe-edge check fires on any lexical match anywhere in the workspace, without regard to what is being built. § 16.1.2 fires only when producer and consumer are in the same build closure. The two look like inconsistent scopings of the same idea. They are not the same idea.

§ 22.1.2 states an ownership claim. A glob or directory output declares that the build owns a pattern or a subtree whose membership is not knowable until execute time (§ C.17.1); another recipe naming a file inside it as a literal input has made a claim on bytes it cannot be told about, because there is no register-time file-content edge to be had. That defect is a property of the two declarations alone. It is wrong on Tuesday and wrong under --jobs 1, and no scheduling makes it right, so scoping it to a closure would only make it detectable in some invocations and not others.

§ 16.1.2 states a scheduling hazard. A literal output is an ordinary artifact, build-owned by nothing, and legitimately referenceable: an author may declare build/gen.a as an output of one recipe and an input of another and be entirely correct about it, so long as the two never run without an ordering between them. cook producer && cook consumer is exactly that arrangement, and it is a workflow the Standard has no business rejecting — the consumer’s inputs are on disk, put there by an invocation that has already finished. The only defect is missing ordering, and missing ordering is a hazard only where the scheduler is free to pick the wrong one, which is only where both recipes are in one closure. A workspace-wide scoping would therefore reject a legitimate build for a race it cannot have, and the tripwire CS-0142 left behind for exactly this decision (raw_path_input_does_not_order_producer_before_consumer, which builds its consumer alone) is what a workspace-wide check reddens first.

The two rules divide cleanly along that line, and the division is why they never double-report: terminal outputs stay § 22.1.2‘s business, literal outputs stay § 16.1.2‘s.

C.16.3. Why § 16.1.2 is directed where § 16.1.1 is undirected

§ 16.1.1 is satisfied by an ordering path between the two recipes in either direction. § 16.1.2 is satisfied only by a path from the consumer to the producer. The asymmetry follows from what each rule is protecting against.

Write-write is symmetric. Two recipes writing one path race, and the harm is that the winner is undefined — the artifact’s contents become non-deterministic and every cache key derived from them becomes unstable. Either ordering fixes that, because either ordering makes the outcome defined. The Standard has no opinion about which of the two writers should win; that is the author’s call, and by adding an edge in either direction they have made it. Any path suffices, so the predicate is undirected.

Read-after-write is asymmetric. Only producer-first is correct. The reverse path — the consumer ordered before the producer — is not a partial fix or a lesser evil: it pins the stale read in place on every run, turning what would otherwise be a scheduling-dependent race into a deterministic wrong answer. It is worse than no ordering at all, since an unordered build at least fails loudly some fraction of the time. An undirected predicate would wave it through as “these two are dep-related, nothing to see here” — silently blessing the one arrangement that is guaranteed to be wrong, and doing so precisely because the author took the trouble to declare an edge. § 16.1.2 therefore passes only when the consumer transitively requires the producer, and the reference implementation’s directed reachability walk is deliberately not shared with § 16.1.1‘s undirected connected(): the two predicates answer different questions and unifying them would erase this distinction.

C.21. On § 21 Cook Lua API

The Cook Lua API is deliberately small and sharply phase-split. Most build-system DSLs expose a single large API whose functions do different things depending on when they are called — declaration during a “load” pass, execution during a “run” pass, with the split encoded by convention rather than by contract. The Cook Standard commits to the split in its normative text: every function is classified as register-phase, execute-phase, or both, and a conforming implementation MUST install each function in the phases it is documented for.

The reasoning is pragmatic:

  • The register phase is the cache boundary. Everything that happens during registration is observed by the cache (§ 17). Writing a file during registration makes that file a register-time input in a way that cache hashes do not see. The small cook.sh / cook.exec surface is the entire register-time effect mechanism; the larger fs.* surface is available in both phases because authors legitimately want it in both.
  • The execute phase is the parallelism boundary. Execute-phase work runs on a pool of worker VMs, each with its own Lua state. A function that mutates shared capture state cannot sensibly run on a worker. cook.add_unit is register-phase only for exactly this reason.
  • Keeping the surface small means the phase split is memorable. A surface this size can be held in an author’s head: cook.add_unit, the cook.sh/cook.exec/cook.interactive helpers, seven fs.* functions, six path.* functions, and the execute-phase using-block globals. That is intentional.

C.21.1. Why placeholder substitutions exist alongside the using-block globals

§ 9.3 defines $<in> (unit-centric: the loop member in one-to-one, the space-joined collected input set in many-to-one — App. E CS-0130), $<out>, $<out_N>, and the dotted-accessor $<NAME.ACCESSOR> family for shell blocks (and, by § 10.5, test / bare shell_command). § 23.1 defines input, output, inputs, outputs, and input_N for >{ ... } Lua-code blocks. Both express the same fundamental idea — “the current input and output of a step” — and an earlier draft of the Standard considered collapsing them to one.

They remain separate because they inhabit different typographic contexts. A shell using-string is a shell line; shell has no binding mechanism for “the current file”, so a placeholder syntax is the only option that keeps the surface readable. A Lua using-block is Lua code; Lua has direct variable binding, so exposing input and outputs as globals is more Lua-idiomatic than expanding $<in> inside Lua text.

The substitution also differs in timing. Shell using-string placeholders are substituted at register-time code generation — by the time the unit is recorded, the shell string is already concrete text. Lua using-block globals are bound at execute time on the worker VM. Collapsing the two would require either executing Lua fragments at register time (a re-entrancy hazard) or deferring shell substitution to execute time (a loss of register-phase observability). Keeping them separate is the less-bad outcome.

C.21.2. Why fs.* and path.* are separate namespaces

fs.* performs I/O against the Cookfile’s working directory. path.* performs pure string manipulation on path components and is working-directory-independent. An earlier sketch merged them under a single path namespace, distinguishing “pure” from “I/O” functions by naming convention. This was rejected.

Keeping the namespaces apart matches a common Lua convention (see the standard library’s io vs. string), and — more importantly — it mirrors the phase-split reasoning in the chapter intro. path.* can never observe anything phase-sensitive, so merging it with fs.* would invite readers to assume every fs.* call is similarly pure. The namespace split is a small, cheap signal that I/O is happening.

C.21.3. Why cook.add_unit is register-phase only

A naive design might allow cook.add_unit inside >{ ... } Lua blocks — that is, inside an execute-phase step — as a way to dynamically produce further units based on build outputs. The Standard forbids this: cook.add_unit is register-phase only.

The reason is the cache. The set of work units is the input to the scheduler (§ 13), and the scheduler’s correctness depends on knowing all units before any of them runs. A unit added during execution would have to either (a) force re-planning mid-execute — which invalidates any parallelism already in flight — or (b) sneak past the scheduler and run outside its ordering guarantees. Both options break the contract. Authors who need data-driven unit counts call cook.sh at register time (§ 24.1) to compute the data, then call cook.add_unit for each computed unit, all during registration. The examples in cpp.lua (e.g., cpp.static_library) are the canonical shape.

C.21.4. Why the cook-step using-string recognises recipe names

A $<TOKEN> in a using-string that is neither a known placeholder nor a recipe name falls through to a declared-variable lookup (§ 9.3). A $<TOKEN> that matches a recipe name in the same Cookfile is instead rewritten to cook.dep_output("TOKEN"). This is a deliberate asymmetry: in practice, cross-recipe references to “the outputs of another recipe” are far more common than declared variables that happen to share a name with a recipe. Giving recipe names priority in the fallback order means authors rarely need to choose between the two at the token level — the parser picks the right one.

C.21.5. Why output-pattern is the sole iteration source

Pre-CS-0022, iteration mode was decided by two non-adjacent regions: §6.7 said “$<in> in the using-string drives one-to-one”; §5.4 said “$<lib.ACCESSOR> in the output pattern drives dep-driven one-to-one.” Both rules co-existed peacefully when authors wrote consistent code, but they failed silently on inconsistent code: cook "out" using "do $<in>" (literal output, $<in> in using-string) iterated per input under the §6.7 rule, with every iteration writing to the same literal output, clobbering successive results.

CS-0022 makes the output pattern the sole iteration source. The output pattern is the declarative part of a cook step — it describes what files the step produces. If those filenames are parameterized (via $<in.ACCESSOR> or $<lib.ACCESSOR>), iteration follows. If they are literal, the step does not iterate. Authors who wrote the silent-clobber pattern get a load-time error pointing at the literal-output / $<in>-in-body mismatch.

C.21.6. Why using "cmd" was removed

The string and block forms expressed the same thing — a shell command — through different surfaces. With shell blocks first-class (CS-0022 also extends § 10.5 and § 9.3 to cover shell_block content) the string form bought one character of brevity per call site against the cost of a separate normative branch in §6.7 and a separate dispatch arm in the body production. The cost was paid every time an author moved a one-line command onto two lines. The Standard now has one shell surface — { cmd } for one-liners; { cmd1\n cmd2 } for multi-line — and one Lua surface, >{ ... }.

C.21.7. Why bare path-accessors were removed

Two spellings of the same concept ($<stem> and $<in.stem>) with position-dependent admissibility ($<stem> valid in output patterns, $<in.stem> valid in using-clause bodies under earlier proposals) is the kind of memorise-by-rote rule a Standard pays for forever. CS-0022 collapses the surface to one spelling — $<NAME.ACCESSOR> — valid in both positions, with NAME ∈ { in, out, out_N, libname }. Bare $<stem> reads, post-CS-0022, as a $<TOKEN> resolving through § 10.2 step 4 to a declared-variable lookup of stem. That fallthrough is the wrong failure mode for code being migrated, so the implementation MUST emit a specific diagnostic for the four bare accessor names that points at the new spelling.

C.21.8. Why $<lib.ACCESSOR> is rejected in using-clause bodies

The § 10.4 firewall rejecting $<lib.ACCESSOR> in a step whose output pattern does not declare lib as the driver is preserved verbatim by CS-0022. Inside a step where lib is the driver, the spelling $<in.ACCESSOR> is used — the local name in for “the current iteration item” is universal across drivers, so the using-clause body never names the driver explicitly. Allowing $<lib.ACCESSOR> in the body would let an author smuggle a second iteration source past the output-pattern declaration, undoing § 8.4.1‘s single-driver invariant.

C.21.9. Why recipe names within a Cookfile MUST be unique

Earlier reference implementations of cook.recipe accepted duplicate registrations silently: whichever registration appeared first in the codegen-emitted Lua source won, and subsequent registrations of the same name became no-ops. The §22.3 uniqueness rule retires this behaviour and requires a hard error naming both sites.

The motivation is two common authoring footguns.

First, a surface recipe build block combined with a cook_cc.bin("build", …) call inside a register block. Authors writing these together expected one form to override the other; neither did so deterministically without documentation; the actual behaviour was source-order dependent. Source-order dependence is invisible at the Cookfile level — the author cannot see codegen output and would not think to consider it.

Second, two wrapper calls registering the same name (for example, two cook_cc.bin("game", …) calls in different register blocks of the same Cookfile). The first call’s registration won; the second silently no-op’d. An author writing the second call had no signal that it was being ignored short of running the build and noticing the second call’s side effects (probes, output paths) were absent.

Both patterns are authoring errors. The §22.3 rule makes them diagnosable. The diagnostic names both registration sites and identifies the kind of each so the author knows precisely what to rename.

The choice not to designate a winner (i.e. to error rather than to define a precedence) reflects a broader principle: silent source-order precedence is a recurring source of long-lived bugs in build systems. When two declarations conflict, naming both sites pushes the resolution to the author at the moment of conflict, instead of compounding into mis-debugged runtime behaviour later.

C.21.10. Why a module-minted recipe is annotated rather than refused, and why origin is explicit

Recipe identity belongs to the user: cook list should read as a list of the things this project can do, and every name in it should be one the Cookfile author put there. Modules are therefore step contributors (§ 12.7.8), and the two shapes that still require a module to mint a recipe — data-driven fan-out, where the names derive from parsed data the author never typed, and a support recipe, whose work has nowhere else to live — are carve-outs from that rule rather than an alternative to it. origin (§ 22.3) is what keeps those carve-outs honest: a name the user did not write is listed together with the module surface that wrote it, so a reader can tell the two apart at a glance instead of grepping module sources.

Why annotate rather than refuse. The support-recipe carve-out was described during design as “non-invocable by intent”, which left open whether an implementation should refuse a dispatch naming such a recipe rather than merely annotate it. It should not, for five reasons.

  • Paragraph 2 of § 22.7 is already a normative MUST: a dispatch naming a recipe registered only through register-phase Lua executes identically to one naming a surface declaration. Refusing would mean amending an existing MUST, not clarifying one — a materially larger change than an annotation warrants, and one that would have to be argued on its own terms.
  • Refusing invents an invocability tier the language does not have. Nothing else in Cook distinguishes “registered” from “registered but not runnable”. Minting that distinction to serve a presentation annotation is disproportionate to what the annotation is for.
  • It would make the listing lie. A refusal would have cook list advertise a name that errors when typed — inverting the point of surfacing minted recipes loudly in the first place.
  • A support recipe is a real DAG node. Other recipes depend on its outputs; running it directly is harmless, and is exactly what an author wants when debugging why a generated file came out wrong.
  • “By intent” is the module author’s intent, and the annotation is how that intent reaches the user. It says: this name is not yours, here is who made it and why. That is the whole of what the phrase was ever promising. The engine need not police it, and an engine that did would be enforcing one author’s preference as a language rule.

Why origin is explicit rather than inferred. An implementation could plausibly derive an origin without the author’s help — by walking the Lua call stack at the cook.recipe call site, or by tagging every registration that did not come from the code generator. Both were rejected on one ground: misattribution. A call-stack walk names whatever frame happens to be on the stack, which for a module dispatching through a shared internal helper is the helper, not the surface the user called. A registration-site tag records that a module registered the recipe, never which module surface a reader should go look up. Both produce a plausible-looking string that is wrong in precisely the cases where the annotation matters most — deep module internals — and a wrong attribution is worse than none, because the reader has no way to know it is wrong. origin is author-supplied because the module author is the only party that knows the answer.

This does not reverse the CS-0077 stance. That entry deliberately pushed the surface-vs-Lua registration distinction out of the Standard: how an implementation tells a codegen-emitted registration from a hand-written cook.recipe call is implementation-defined (the reference implementation tags surface registrations with a codegen-private helper), and the Standard does not require that distinction to be observable anywhere. origin is not that distinction re-admitted through a side door, because it is not a property of where a registration came from at all. It is module-author-supplied intent — a value a human wrote into a metadata table for another human to read. An implementation remains free to tag registration sites however it likes, and still may not surface such a tag as attribution. That origin rides only the public cook.recipe path, and cannot appear on a surface declaration, follows from who supplies it: a surface recipe/chore declaration has no author-facing metadata table to put one in, and its author is the user, who needs no attribution to themselves.

C.11. On § 11.1 Cross-Cookfile composition and § 12.1 use

Cross-Cookfile composition (§ 11.1) and Lua module loading (§ 12.1) are deliberately split into two declarations that look similar but answer different questions. use answers “give me additional Lua for my recipes to call”; import answers “tie another Cookfile’s recipes into my dependency graph.” Conflating them — for example, by making import also bring a Lua value into scope — would force authors to pick one semantics at every call site and would blur the two namespaces the dependency resolver walks. The Standard keeps them distinct so that each declaration has one job, and lives in a different chapter: use belongs with the Cook Lua API because it loads Lua code, whereas import belongs with cross-Cookfile composition because it binds an alias for cross-recipe references.

Built-in modules are resolved before cook_modules/ (§ 12.1) so that adding a new built-in in a future version of the implementation cannot silently break a project that happens to have a cook_modules/<name>.lua of the same name: the built-in always wins, so a project author who wants to override it renames their local module or picks a different name. The converse ordering — consulting cook_modules/ first — would make the set of built-in names a latent hazard: every new built-in would be a silent semantic change for every project that had colonised the name first. v0.1 of the Standard defines no built-ins, but fixes the ordering now so that the first built-in to ship can do so without a migration.

The choice of a project-local module tree — resolved only relative to the enclosing Cookfile — rather than a global registry or a walk up through ancestor directories is a deliberate choice for locality. A Cookfile’s modules are the ones it ships with; they travel with the project, they are the ones code review sees, and they are the ones git clone reproduces. A global registry would require side-channel installation; an ancestor walk would make module resolution sensitive to where on disk the project happened to live. Both are costs the Standard declines to pay.

Locality survived CS-0206 and CS-0207 intact, but it is worth saying what each of them did to the argument above. CS-0206’s path form does not weaken it: a path is still resolved relative to the enclosing Cookfile and still may not leave that Cookfile’s subtree, so a module is still one git clone reproduces and still one code review sees — more so, in fact, since it is now a tracked file in the place it belongs rather than a copy or a symlink inside a package directory. What CS-0207 changed is only where the installed tree sits, from a top-level cook_modules/ to .cook/modules/.

That move is the second half of a split, and the first half is why it was needed. cook_modules/ was two things wearing one directory: the package manager’s install root, and the only legal home for a hand-authored module. A directory holding source and build output at once can be given no correct ignore rule — the reference implementation’s own repository carried two contradictory rules for it, one of which silently dropped a fixture from a release — and the split is what makes each half’s rule obvious. Generated goes under the build-output root, which is ignorable wholesale and regenerable from a manifest. Source is source, tracked and reviewed, reached by path.

The §12 search-path order adopts the LuaRocks tree layout (share/lua/<ver>/, lib/lua/<ver>/) as the on-disk shape so cook can interoperate with luarocks.org rockspecs without forking layout policy.

Until CS-0207 that order was led by a hand-vendored top level: a file at cook_modules/<name>.lua outranked the rock-installed copy, so an author could override a published module by dropping a file beside it. The Standard no longer offers that, and the reason is not that overriding is unwanted but that shadowing by precedence is the wrong mechanism for it. Nothing in the Cookfile records that the shadowing happened. A reader sees use cook_cc and, to learn which cook_cc ran, has to know the search order and go inspect the tree — and so does a reviewer, and so does anyone debugging a machine where the file is present and another where it is not. CS-0206’s path form answers the same need by naming the file: use cook_cc ./lua/cook_cc/init.lua is a line a reader can read. The capability is subsumed, the ambiguity is removed, and the search order collapses to one lookup with two candidates.

C.11.0. Why a path-form use may not leave the Cookfile’s subtree, and why // is refused

import accepts a workspace-root sigil (//path) precisely so that a deeply nested Cookfile can name a cross-cutting target without counting ..s. use’s path form (§ 12.2.1) refuses that sigil and refuses .. outright, so it is more confined than the declaration it is modelled on. That inversion is worth an explanation, because “be consistent with import” is the obvious design and it is wrong here.

The two declarations bind different kinds of thing. An import binds a name into the dependency graph: what crosses the boundary is an alias the resolver walks, and the imported Cookfile’s own units keep their own working directory. A use binds code that runs inside this unit. Under § 17.1.1.3 the file a module was loaded from is a determinant of the loading unit, recorded so that a reader on another machine can re-hash it against its own tree and compose the same key. That recording is only possible for a path expressed relative to the unit’s own working directory; a host-absolute path folded into a shared key names a location no other machine has, which is the machine-inferred identity § 17.1.1 forbids.

So the boundary is not a policy choice made in this chapter and then imposed on the cache; it is the cache’s boundary, surfaced in the language. An implementation that admitted use //build/helpers.lua would be left with two options at the moment of the load: refuse it anyway, which makes the grammar a lie, or load it and drop the file from the determinant set, which serves one machine’s answer to another machine that has a different helpers.lua. The second is the failure class this Standard is least willing to ship, because it is silent — no warning, no rebuild, no attribution. Refusing the spelling makes the constraint visible at the point where an author can do something about it: move the file, or give the two Cookfiles each their own.

The cost is real and is accepted. A monorepo cannot put one helpers.lua at the workspace root and reach it from every member Cookfile; each member that wants it must own a copy, or the shared logic must be published as a package and reached by name — which is what a package tree is for, and which is keyed correctly because an installed module resolves inside the working directory. The Standard prefers a build whose shared cache is sound over one whose helper files are DRY.

C.11.1. Why duplicate-import is a parse-time error rather than last-wins

A “last-wins” override is tempting because it is what most language include-systems do for simple #include and because it composes cleanly with machine-generated fragments. The Standard rejects it for two reasons.

First, the duplicate-import rule is the only defence against a silent bug where a user edits one import backend ./services/backend into import backend ./services/backend_v2 in a diff that also intends to keep the first line around for some other recipe to consume. Last-wins would silently redirect every backend.* dependency in the file to the new target, and the first import would become dead code with no diagnostic. A parse-time error surfaces the mistake at the moment of the edit.

Second, a duplicate-import is diagnosable locally — it is a property of a single Cookfile’s token stream, which the parser already holds in full. Pushing the check to a later stage (workspace load, DAG construction) would mean the diagnostic arrives after some non-trivial work has succeeded, and the error message would have to reconstruct which line declared which alias. Parse time is where the information is cheapest.

C.11.2. Why import cycles are detected at workspace-load time rather than at parse time

Symmetrically, cycles are NOT caught at parse time because they cannot be: a single Cookfile’s tokens cannot know whether import b ../b eventually loops back. Cycle detection requires the parser to have run on every Cookfile the edge set transitively touches, and that only happens at workspace-load time. Pushing cycle detection earlier would either require a separate pre-pass — effectively re-parsing every Cookfile before validating any — or would require import to be resolved synchronously during parsing, which would re-introduce the coupling that v0.1 deliberately separates (the parser produces an AST; the workspace loader consumes it).

Canonicalisation at the edge boundary (§ 11.5) is load-bearing. Without it, import b ./b and import b ./services/../b would be different nodes in the cycle graph, and the diamond dedup rule of § 11.5 would fire on one but not the other. Canonicalising to absolute, symlink-resolved paths collapses these spellings into one node.

C.11.3. Why local module names do not collide with reserved recipe segments

The reserved recipe segments of § 3.4 (stem, name, ext, dir, in, out) are reserved for the final segment of a recipe or dependency name, not for identifiers in general. A local module may therefore be named, for example, out.lua in cook_modules/, and bound by use out; the binding would collide with nothing in the grammar, because use resolves against BARE_IDENTIFIER and STRING (App. A.2) and is not subject to the recipe-segment reservation.

The Standard declines to extend the reservation to module names because the two namespaces are independently diagnosable: a recipe body that writes out.foo() is a module_call only if out has been bound by a prior use declaration in the same Cookfile (§ 4.4). Without the use, the same line is a shell_command, and the dispatch cascade of App. A.4 will classify it so. Reserving the name globally would not prevent any bug the dispatch already handles, and would remove an axis of expressiveness from module authors.

C.10.1. out_0 rejection at resolution rather than fall-through

The closed-set resolver introduced with the sigil placeholder syntax rejects $<out_0> as a MalformedOutIndex error rather than letting it fall through to the recipe-then-env-lookup steps. Lexically, out_0 matches the out_indexed production ("out_" DIGIT+ admits 0); the rejection happens at resolution, not lexing.

The choice between “reject at resolution” and “fall through to env” was decided in favor of the former because the diagnostic surface is meaningfully better:

  • Reject path: “placeholder $<out_0>: malformed out_N (N must be ≥ 1)” — names the exact problem.
  • Fall-through path: “placeholder $<out_0>: env var ‘out_0’ was not declared” — misleading; suggests the author meant an env var when they actually mistyped a builtin.

Authors who type $<out_0> are virtually always misremembering that out_N is 1-indexed; the diagnostic should help them notice that, not redirect them to the env-var declaration system.

The BuiltinMatch::Malformed variant in the resolver makes the hard-stop explicit in code: match_builtin returns three outcomes — Yes (valid builtin), Malformed (builtin shape, structurally invalid), and No (not a builtin shape at all). Only No falls through; Malformed short-circuits to an error without consulting the recipe or env namespaces.

C.21.1. The cook.require_var runtime mechanism

§ 10.2 step 4 (declared variable) defers the “declared” check to register time, after config-block evaluation has captured the declared names. The reference implementation realizes this contract through a Lua function cook.require_var(name) injected alongside the read-only var global, cook.dep_output, and the other cook.* helpers.

The function checks name against a frozen keyset captured immediately after the config-block phase completes. A missing name produces a RuntimeError with a diagnostic that names the consulted token, the closest declared names, and the recommended var.NAME = ... declaration form. The keyset is frozen at the end of config-block evaluation; CS-0172 additionally makes the store unreachable for writing outside a config body, so there is no post-freeze write to reason about.

The implementation’s VarKeyset::freeze is additive (multiple calls union their captured names); the reference engine invokes freeze exactly once, immediately after config-block dispatch returns, so the additive property is unobservable in production code.

Two design alternatives were considered and rejected:

  • Codegen-time check. The codegen could parse config-block source for env.NAME = ... patterns and reject undeclared references at register-time emission. This requires the codegen to understand Lua syntax, including conditional assignments and dynamic key construction (env[some_var] = ...). The runtime check is simpler, more accurate, and produces the same author-facing diagnostic at virtually the same time (register time vs. codegen time).
  • Silent fallthrough to cook.env[name]. This was the pre-sigil-placeholder-syntax behaviour and it caused real bugs: a typo such as $<HOEM> silently expanded to an empty string, propagated through cache, and triggered spurious reruns. The closed-set resolution rule explicitly rejects this option to make every placeholder either resolve to a known thing or fail loudly.

C.A. On Appendix A

To be filled in.

Why cc rather than cpp (§ 28) [#rationale.cc-not-cpp]

The blessed C-family build module is named cc, not cpp. The module handles .c and .cpp (plus .cc, .cxx) sources uniformly: extension dispatch chooses the appropriate compiler from a single toolchain triple. Naming it cpp would privilege C++ over C even though every public function (cc.bin, cc.lib, cc.find, etc.) is equally idiomatic for either language. The name cc aligns with the historical Unix toolchain convention (cc(1), $CC make-variable) and with Rust’s cc crate, both of which describe “C-family compilation” without language privilege.

Why §9 Standard Modules is normative [#rationale.stdmods-normative]

Blessed modules are part of the Cook contract that anyone — not only Liora Labs — may reimplement. The official rock is one implementation; an alternative implementation (e.g., a Rust-side native module exposing the same Lua surface, or a strictly-pinned older snapshot) must produce semantically equivalent behaviour against the §9 sections it claims to implement. Specifying the surface normatively in numbered Standard chapters — rather than informatively in an appendix — makes conformance checkable: every public function lists its semantics with RFC-2119 MUST / SHOULD keywords; every option table field is enumerated; every error condition is documented.

Why cc.find v0.1 specified pkg-config-only [#rationale.find-v01-history]

The v0.1 wording (released in cook_cc 0.1.x) specified pkg-config as the sole cc.find strategy. This was deliberate: M0 (the chapter introduction) optimised for a tight normative surface that real Cookfiles could verify against, while leaving room to extend. v0.2 (M1, this revision) generalises to a multi-strategy chain; the v0.1 wording is preserved here so anyone bisecting against cook_cc 0.1.x has a referenceable spec. There is no permanent break — every v0.1 call site (cc.find("zlib") etc.) remains a valid v0.2 call site, and pkg-config remains a chain stage.

Why cmake-compat ships on legacy --find-package [#rationale.cmake-compat-legacy]

cmake 4.x marks the --find-package legacy mode “Do not use” in cmake --help. cmake-compat ships on this mode anyway because:

  1. The annotation is years-old guidance, not a removal timeline. The cmake project carries it for nudging users into proper cmake projects, not as a deprecation deadline.
  2. Reimplementing the equivalent Config-resolution logic natively is the post-M6 “full CMake interpretation” north star, explicitly deferred as not-committed.
  3. The migration path if cmake ever removes legacy mode is to swap cmake_compat.lua internals to a temp-project find_package(X CONFIG REQUIRED) + message(STATUS …) extraction approach. That migration is an internal-only change with no spec impact — cc.find’s surface stays identical.

The trust boundary is documented: anything cmake-compat finds is found by cmake’s own resolver, so accuracy is delegated; the only Cook-implemented part is the output parser specified in §9.2.3.8.2/3.

Why cmake-compat sits after pkg-config in the default chain [#rationale.cmake-compat-position]

The original module roadmap proposed “after curated, before pkg-config” for cmake-compat. v0.3 places cmake-compat after pkg-config for three reasons:

  1. Speed. pkg-config returns in ~10ms; the cmake subprocess takes ~100–500ms per probe. Projects whose dependencies all ship .pc files never pay the cmake cost in the default chain.
  2. Substrate stability. pkg-config’s upstream has not deprecated the discovery mode; cmake’s --find-package legacy annotation gives pkg-config the longer life expectancy as a build-system contract.
  3. Curated finders own the per-library overrides. The handful of libraries where cmake-compat is preferred over pkg-config (e.g. SDL3 has no .pc; old libsdl2.pc may lie around) get the explicit opts.cmake = true opt-in, which lifts cmake-compat into step 3 and skips pkg-config for that call. The opt-in is the right surface for “I know this is Config-only” — callers express intent at the call site rather than via global resolver order.

Why cc.find v0.2 documented a four-stage chain [#rationale.find-v02-history]

The v0.2 wording (released in cook_cc 0.2.x) specified a four-stage chain: project → curated → pkg-config → bare-probe. v0.3 (M2) inserts cmake-compat between pkg-config and bare-probe and adds the cmake opt. Every v0.2 call site remains a valid v0.3 call site; pkg-config remains a chain stage. Bisects against cook_cc 0.2.x reference this paragraph.

C.17. On § 17 Cache semantics

C.17.1. Glob output terminality

Cook propagates cache invalidation between steps via the content fingerprints of their shared files: a downstream step that declares an upstream’s output as one of its inputs[] re-fingerprints when that file’s bytes change. This linkage requires the path to be statically declared at register time on both sides.

A glob pattern entry in outputs[] (§ 22.1.2) cannot participate in this linkage because the resolved file set is not known until execute time. A downstream that declared a specific path expecting it to be produced by an upstream glob would have no way to express a file-content edge at register time, and Cook’s cache check has no did-my-upstream-rebuilt signal. The downstream would silently consume stale bytes after the upstream re-ran.

The terminal-output rule (§22.1.2 “Cross-recipe edges”) eliminates this footgun by construction. Authors who want file-content invalidation propagation use literal outputs[] entries; authors who only need ordering use requires = { ... }; the engine catches the misuse at register time via a purely lexical match of downstream input strings against upstream pattern strings. The rule aligns Cook with the Turborepo model (tasks depend on tasks, not files) that the cook_pnpm module adapts.

Directory outputs (CS-0119) reuse this same glob terminality machinery — a trailing-slash entry is treated as the pattern dir/**/* for the purposes of the cross-recipe edge rule (§22.1.2) — because the subtree’s file set is unknown until execute time, for precisely the same reason a glob’s match set is. The additional build-owned reconciliation (§17.6) closes a hit≠miss gap that surfaced when a producer (e.g. wasm-pack pkg/, Vite dist/) emits files the author did not enumerate: a cache restore that replays only the recorded file set would leave stray files from a prior run on disk, producing a subtree that differs from a fresh build. Reconciliation removes every file in the declared subtree that is not a member of the resolved or restored set, and prunes empty directories, ensuring the on-disk state is byte-identical to what a fresh run would produce. This subsumes the orphan-on-shrink stale-output sweep for the directory form: files that a previous run produced but the current run no longer declares are removed exactly as orphans are swept by §17.7. Narrow and ** glob entries (dir/**, dir/*.js) are deliberately unchanged — they remain additive — to avoid altering the behavior of shipped patterns such as cook_pnpm’s .next/**.

C.22. On the native probe declaration

C.22.1. Why probes graduated from the Lua-string cook.probe() API to native surface syntax

The cook.probe() Lua API predates the native probe declaration by several Standard revisions. Until CS-0091 (data-driven fan-out), probes were primarily a module-author tool: cook_cc hoists compiler-detection probes out of recipe bodies so that all recipes share one observation of the compiler per invocation (§ 22.5.8) rather than each shelling out independently. In that context, the string-produce form was acceptable — module authors write Lua fluently, the [[ ]] long-string quoting is idiomatic, and the call site is small relative to the surrounding module machinery.

CS-0091 changed the economics. Data-driven fan-out via gather <probe> makes probes the primary mechanism for data-driven builds: any Cookfile author who wants one output per JSON record, one deploy per host, or one test per case writes a probe. The population is no longer “module authors” — it is every author writing a data-driven build. At that scale, the ergonomic friction of the string API became a first-class concern:

  • No lexer or editor support. A produce = [[ … ]] long-string is opaque to syntax highlighters, language servers, and linters. Indentation, keyword completion, and error underlining stop at the string boundary.
  • [[ ]] escaping hazards. A produce body that embeds a Lua string containing ]] must use a level-qualified long string ([=[ … ]=]), which is non-obvious and easy to get wrong silently.
  • Table-of-tables ceremony. Expressing the produce, inputs.files, and inputs.requires fields in a nested table literal has more syntactic noise than the information content warrants. The resulting call is longer than the logic it encodes.
  • Discoverability. A new Cook user reading a Cookfile that uses gather <probe> finds the probe declaration buried inside a register block, co-located with arbitrary Lua, rather than at top level where the Cookfile’s data sources belong.

The native probe declaration (§ 22.5.2) is pure sugar over the retained cook.probe() API. Every native probe lowers to an equivalent cook.probe() call at codegen time; the API is not deprecated and remains the correct surface for probes that declare an inline opts.inputs.tools list (not yet expressible natively — deferred to a future native-surface extension; a named tools NAME top-level declaration is the native surface for a sealable tool set, a different thing). The mapping is direct:

Native formAPI equivalent
gather "glob" !"glob"inputs.files = {"glob", "!glob"}
Header dep list probe X: dep1 dep2inputs.requires = {"dep1", "dep2"}
json { sh }produce = [[ return cook.json_decode(cook.sh("sh")) ]]
lines { sh }produce = [[ … cook.sh lines split … ]]
>{ lua }produce = [[ lua ]]

The decision to ship native syntax as sugar rather than as a replacement reflects the Standard’s general posture: the Lua API is the extension point; surface syntax is a readability layer over it. Authors who need capabilities the native form does not yet cover fall back to cook.probe() without ceremony.

C.22.2. Why a probe value is not cached across invocations

§ 22.5.8 required an implementation to serve a probe’s value from the content-addressed store whenever the probe’s fingerprint hit, and CS-0243 removed the requirement and the store entry with it. The argument for removal is not that caching an observation is expensive to get right; it is that it is not a well-posed thing to get right.

A cache is sound when the key covers everything the cached computation depends on. A probe is the construct whose whole purpose is to observe state the build does not produce and the author did not enumerate — which compiler is on the PATH, what the host reports about itself, what sits in a directory nobody declared. What could be keyed on was only what was declared (§ 22.5.4). The gap between those two sets is not an implementation defect to be closed; it is the definition of the construct. Every repair in this area widened what was folded into that declared set — module source (CS-0204), a tool identity that resolves but cannot be read (CS-0214), a matched file that exists but is unreadable (CS-0241) — and each repair was correct and none of them was the last one, because the next probe body reads something else.

The comparison that settles it is with the construct next door. A recipe unit is cached, and soundly, because its inputs are declared, its outputs are declared, and the language refuses to infer a determinant the author did not write down (§ 17.1.1). That refusal is what makes the unit cache trustworthy, and it is exactly what made the probe cache untrustworthy: the same rule applied to a construct built to look past its own declarations. The language now gives each construct the treatment its declarations earn — units are cached, probes are observed — and an author whose observation is genuinely expensive is directed to express it as a unit, where the declarations that make caching sound are the ones the surface already demands.

Removal costs less than it appears to, because the probe cache was never what made a build fast. A unit that seals a probe keys on the probe’s value, so an observation that comes back with the same answer changes no key and cancels no hit; what the cache saved was the cost of asking, and asking is milliseconds against a build measured in seconds. Sharing the answer between machines, which § 17.5 once mandated, was worth even less: for a declaration producer the reader must hash its own tree to compute the key it would fetch under, so it has the answer before it can ask for it, and for a host query the answer is a property of the machine that gave it and has no business travelling.

What is deliberately kept is the record. The value is still written to .cook/probes/<key>.json on every completion, because “what did this build observe” is a question every cache-explanation surface has to answer and it cannot be reconstructed afterwards. A record that is never read back as a source cannot go stale, which is the distinction the whole removal turns on: the hazard was never in writing the value down.

C.23. On § 22.10 cook.on_register_complete

C.23.1. Why a run-last callback rather than an all_recipe_names() query

An implementation could, in principle, expose the finished registration pass’s recipe name set as a queryable value rather than as a callback hook: something like cook.all_recipe_names(), read after the fact from a module’s own bookkeeping. That would not serve the motivating consumer (§ 22.10, Note 22.10.1). The recipe name set is knowable early — every recipe’s header is parsed, and its name registered via cook.recipe (§ 22.3), before any recipe body runs at all (§ 22.7). A query over names is therefore no earlier, and no more informative, than what a module can already collect itself by watching cook.recipe calls.

What a consumer like a compile_commands() aggregator actually needs is not the name set; it is module state accumulated by recipe-body target-maker calls — the per-target compiler-flag tables, source lists, and export records a cc.bin / cc.lib call builds up as each target’s body runs. That state exists only after every recipe body has been evaluated, because a target maker populates it from inside the body it is called from, and there is no way to know from a name alone what a body will do when it runs. A name-only query answers “which recipes exist”; the consumer needs “what did every recipe’s body actually register”, which is a strictly later and strictly richer question.

Nor would a query let the module order its own end-of-register work against anything else. Writing compile_commands.json is itself an action with a place in the pass — it MUST happen after the last body and it MAY itself need register-phase capabilities (fs.write, reading other modules’ exports). A query is a passive read with no hook for sequencing side effects; cook.on_register_complete is a callback precisely because the consumer’s need is not data but a place to run code, at a point in the pass no query can express.

C.23.2. Why minting recipes from a callback is banned

cook.on_register_complete fires after the registration pass’s body-invocation loop has completed (§ 22.10). A recipe registered at that point via cook.recipe (§ 22.3) would be added to the discovered set with no further opportunity for its body to be evaluated in the current pass — the loop that evaluates bodies has already finished its single pass over the set it knew about. Such a recipe would appear dispatchable (§ 22.7), yet dispatching it would find no work units, no tests, and no exports: a Cookfile with a plausible-looking recipe name that silently does nothing.

This is the same failure shape § 22.9‘s “forcing MUST NOT degrade to a silent skip” rule exists to close on the forcing side: register and execute disagreeing about what is being built, with no diagnostic at any point. Banning the mint outright — rejecting the registration attempt with a named error, rather than accepting it and leaving the resulting recipe inert — is preferred to the alternative of running a second body-invocation pass over newly-minted recipes, which would reopen the same question cook.on_register_complete exists to close (when is the pass actually done?) and could recurse without a defined termination point if a later-minted recipe’s own callback minted a further recipe. A callback that needs the effect of a recipe — a file written, a target’s outputs finalized — has the tools to produce that effect directly (cook.add_unit, fs.write, cook.export) without minting a name for it; the restriction costs a module nothing it could otherwise safely have.

C.24. On § 22.12 — why a module may register a chore, and why only during its own evaluation

§ 12.7.8 states that a recipe’s name, and the fact that it is invocable, belong to the author of the Cookfile — and states it as a categorical MUST. Read as covering every invocable name, it forbids a module from offering a verb at all, which would settle the question by accident rather than on its merits. The merits are worth stating, because the rule is a good one and the carve-out has to earn its exemption.

What the rule protects is the author’s build. A recipe is a node in the DAG: minting one changes what cook builds, what that build depends on, and what the cache covers, and it does so without the author having written anything. That is a real hazard, and the two exempted shapes in § 12.7.8 are narrow because it is.

A chore is not that. It is uncached, absent from the DAG, and does nothing whatsoever until someone types its name. A module that registers one has added a command available to the author, not work the author’s build will silently perform. cc.add is a tool in the sense that git add is a tool: whoever runs it has asked for it by name at the moment it runs.

The argument would still be uncomfortable if a module could register add, because a module would then be writing into the space of undotted names the author uses for their own recipes and chores — and an author who wrote chore add would collide with a module they had merely used. Requiring the prefix removes the overlap: undotted names remain exclusively the author’s, and dotted ones announce their provenance at the point of use. Someone reading cook cc.add lib math in a shell history can tell it came from a module, and which one, without opening the Cookfile. The prefix is therefore not decoration on top of the argument; it is the part of the design that makes the argument true, which is why § 22.12 states it as a MUST with no metadata opt-in.

The registration window has a narrower origin: it is forced by the prefix check rather than chosen for the ergonomics it happens to produce. To validate that a chore’s prefix identifies the module registering it, an implementation must know which module is registering it. During the evaluation of a module’s own chunk that identity is exact. After the chunk returns — for a function the module hands back and a Cookfile calls later — an implementation can determine only which module was loaded most recently, which is a different fact, and one that coincides with the right answer only until a second use appears. A rule enforced against an identity that is usually correct is not a guarantee; it is a guarantee-shaped thing that fails on exactly the multi-module Cookfiles where namespacing matters most. Narrowing the window to where the identity is certain keeps the rule sound; the alternative — accepting the registration and validating against a best guess — trades a real property for a convenience.

The ergonomic consequence is a happy side effect rather than the motivation. Because a module’s chores exist as soon as it is loaded, use cook_cc is the entirety of what an author writes to obtain the verbs: nothing further need be called, and the set of available commands is a function of the use declarations alone.

C.25. On § 22.13 — why edits splice rather than re-render

There are two ways to change one field of a call in a Cookfile. Parse the Lua to a table, change the value, print the table back. Or find the byte range of the thing to change and insert into it. The first is enormously easier and it is the wrong one, for a reason worth writing down because it is not obvious until it has happened to someone.

A Cookfile is a program. cook_cc.bin({ standard = cxx_std, sources = glob_sources() }) holds a variable reference and a function call, and the round trip through a table sees neither — it sees whatever those evaluated to on the run that happened to do the editing. It writes back a literal. The build still works, so nothing announces the change, and the author discovers months later that their configurable standard has been frozen to c++20 since the afternoon they ran cook cc.link app math.

Comments go the same way, and they go entirely: a table has nowhere to put them. So does field order, replaced by whatever pairs() yields. So does alignment, and the author’s choice about trailing commas, and the blank line they left between the sources and the link options.

None of this is a rendering bug to be fixed with a better printer. The information is gone at the moment of evaluation; a printer can only invent something plausible in its place. Which is the real objection — the failure is not that the output is ugly but that it is confidently wrong, and silent.

Splicing has none of these properties, and it has them by construction rather than by care. The edit is an insertion at one offset. Everything else is not preserved so much as never touched, which is a stronger guarantee than any amount of fidelity work on a re-render, and one that holds for constructs the implementation has never heard of.

The cost is that splicing must be told exactly where to insert, and can fail to find out. That cost is what buys the honest failure mode. When a re-rendering implementation meets a shape it does not understand, it does not know that it has, and it writes something. When a splicing implementation cannot find links in the call, it knows precisely that, and unless it was asked to do otherwise the only correct thing to do is say so and change nothing — which is why § 22.13 requires the error to name what was sought and the file to be left byte-identical. The author is told to make the edit themselves, which is a worse outcome than the edit succeeding and a far better one than the edit appearing to succeed.

The qualification is CS-0221’s opt-in creation, and it does not weaken any of this. A caller that asks for a field to be created has supplied the missing information — that the field’s absence is not a surprise but the thing it means to fix — so the implementation is no longer guessing at what the author intended. A caller that has not said so is in exactly the position described above, and is answered exactly as described above. That is why the request is spelled at the call site rather than inferred from the shape of the file: the only party that can tell “the field I expected is missing” apart from “the field I am adding is not there yet” is the one that decided which of the two it was doing.

The division of labour follows from what the frame grammar knows. Recipes and module calls are structure, and are located structurally — this is exactly the multi-line, brace-nesting, string-containing-braces problem that hand-written scanning gets wrong. Fields are not: a Cookfile’s embedded Lua is opaque to the frame grammar by design, so there is no links node to ask for. Fields are therefore found by scanning, but only within a span the parser handed over, which is what keeps the scan from running somewhere it should not. The rules § 22.13 imposes on that scan — braces inside strings and comments do not close a list, a field name matches a top-level key rather than a substring, and the entry is anchored to the end of the code rather than to the last visible byte — are each a way a plain find silently edits the wrong bytes, and a comment is precisely what this whole layer exists to protect.

Those rules are stated at the level of “a string literal” rather than “a quoted string” on evidence, not on principle. A first implementation understood "…" and '…' and the -- line comment, and reasoned that the remaining spellings were too rare to matter inside a call argument — a defensible-sounding judgement that was wrong in three directions at once, all of them silent. A } inside [[a}b]] closed the list early and the new entry was inserted into the middle of the author’s string. A -- inside a long string opened a comment that was never there, and the entry landed inside the string again. A --[[ … ]] comment with real code after it on the same line ran to end of line and ate the braces that closed the call, so a file that was correct was reported as a syntax error. The forms are not equally common, but the cost of not knowing one of them does not scale with how common it is; it is the same corrupted file every time.

The nested-key rule is the one worth defending separately, because unlike the others it has a plausible alternative: edit the nearest match and let the author notice. The reason not to is that this is the single failure in the family that leaves no evidence. A brace miscounted inside a literal produces something that usually will not parse. An entry spliced into a comment is visible on the next read. An entry added to opts = { links = … } instead of the outer links produces a Cookfile that parses, looks exactly like the file the author wrote, and builds — against a link list they never edited. Failing instead costs them one manual edit, which is the trade § 22.13 makes everywhere else and makes here for the same reason.

C.26. On § 7.6 — why a chore body is not run unasked

The register phase evaluates recipe bodies to discover work. It is a discovery pass, and its bodies were understood to be declarative: a body called cook.add_unit and the pass recorded what it said. Under that reading, running one body more than strictly necessary costs a little time and nothing else, and an implementation may reasonably evaluate anything cheap to evaluate. That is how a chore that declared no parameters came to have its body run on every pass, including passes for a target it had nothing to do with. It could not be observed to run, because everything it could do was recorded in a structure the planner then discarded.

Two things falsified the reading, in opposite directions.

The first is that a chore body was never quite declarative even then. § 22.12 and § 22.13 give it Lua, a filesystem, and — since § 22.13 — the author’s own Cookfile as a writeable target. The verbs those sections exist to support are cc.add, cc.link, cc.new: commands whose entire purpose is to change something outside the build graph. A verb of that shape running during an unrelated build is not a wasted evaluation; it is the tool doing its job at a moment nobody chose.

The second is that the compensation the eager evaluation was supposed to provide had quietly stopped existing. The justification given for it was that a listing surface wants to know what a chore registers. Discovery surfaces do not go through body invocation at all — a chore is registered by the call that declares it, before any body runs — so the pass was paying for a benefit it was not delivering, and had been for as long as the two mechanisms had been separate.

What is left, once both are seen, is that the only bodies whose units anyone reads are the ones in the build closure, which are exactly the ones reachability already selects. The rule therefore does not trade safety against capability. It stops doing work whose product was thrown away, and the safety property falls out of that.

The alternative that gets proposed first is to leave the invocation alone and refuse the dangerous calls: let the body run, and raise if it reaches for cook.cookfile.* or a filesystem write while the pass is speculative. It is an appealing shape — it needs no author action, it fails loudly, and it reads as the conservative change. It is nevertheless the wrong one, for two independent reasons.

It cannot be a guarantee. This Standard governs the surfaces it defines; a chore body is Lua, and an implementation that gives it a general-purpose interpreter has given it that interpreter’s own file and process facilities too. A rule that fences cook.cookfile.append while os.execute stands open is a rule about which door was locked, not about whether the room can be entered. The reformatting verb the rule is meant to stop is precisely the one that would shell out.

And its failure mode is worse than the failure it repairs. The refusal has to be raised inside a body the implementation chose to run, during a pass the author asked to do something else. Raised as an error, one module’s verb breaks every build in the project until the module is fixed by someone who is not the person suffering it. Caught and discarded instead, the body is abandoned partway and the pass continues — which is the skip this section requires, arrived at by a longer route, after the first half of the body has already run.

Neither objection would matter if skipping cost something. It does not, so the question resolves on the first two paragraphs and the fence is not needed.

The rule is deliberately stated over reachability rather than over any property of the chore, because the defect being repaired was exactly a proxy standing in for the question. Whether a chore declares parameters says nothing about whether its body writes; it happened to correlate with whether the body could be called without binding argv, and that accident is what decided, for a while, which verbs fired unasked and which did not.