Skip to content

§ 8 — Step kinds

8. Step kinds

Normative. This chapter defines every step kind that appears in a recipe body or a chore body, the dispatch cascade that classifies each Content token, and the iteration-mode rules for cook and test steps. The execute-phase behaviour of a body’s compiled units is specified in § 15. As of CS-0134 a recipe body is purely declarative: it carries no imperative region, so body bundling (§ 15.2) — which coalesced a recipe’s imperative-region steps into a body unit — has nothing left to bundle and is vacuous. A recipe’s execute-phase work comes exclusively from the block body of a cook or test step (§ 8.4, § 8.6, § 8.7), each of which is its own work unit. Chore bodies keep their imperative steps; their execution model is defined in § 7 and is unaffected by § 15.2.

8.1. Step-dispatch cascade

Inside a recipe_body, each source line is first classified by the lexer (§ 3.10) into one of Comment, Blank, LuaBlockOpen, LuaLine, or Content. Comments and blank lines are skipped. (The doubled-> prefixes that a pre-CS-0134 lexer would have surfaced as InlineLuaLine / InlineLuaBlockOpen are no longer usable tokens anywhere in the language: § 3.7 rejects >> and >>{ unconditionally at the lexical layer, CS-0134, Note 3.7.1.)

CS-0134 makes the recipe body purely declarative. A LuaBlockOpen (>{) or LuaLine (>) token MUST NOT appear as a standalone top-level line of a recipe_body; a conforming implementation MUST reject such a line (see the rejection rule below). These two tokens remain admitted only in body position — as the opener of a cook, test, or probe step’s body, or inside a chore body, per § 3.7 and § 8.7.

A Content token inside a recipe_body is dispatched to exactly one declarative_item (App. A.3) by the priority cascade below (App. A.4, “Step-dispatch priority”). The first match wins; a conforming implementation MUST test the alternatives in this order:

  1. Prefix gather + separator (space, tab, or end of token) → gather_step.
  2. Prefix seal + separator → recipe-level seal_step (its operands contribute to the recipe seal set; § 8.4.3).
  3. Prefix unseal + separator → rejected as removed by CS-0225; omit the ref from the recipe seal set. This is the removed-keyword rule, and envs + separator belongs to it too: rejected as removed by CS-0226, with the same migration diagnostic § 22.5.2 requires of the probe-producer spelling, naming an ordinary shell probe such as lines { echo "$NAME" } as the replacement. A removed keyword reaching this position MUST NOT be answered by rule 7’s loose-shell diagnostic: that diagnostic’s advice is to move the line into a cook body or a chore, which is wrong for a construct no position accepts, and it names neither the removal nor the replacement. (CS-0235.)
  4. Prefix cook + separator → cook_step.
  5. Prefix test + separator → test_step.
  6. BARE_IDENTIFIER "." BARE_IDENTIFIER immediately followed by ( — a module call — → module_call, register-phase Lua (§ 4.4).
  7. Otherwise → REJECTED.

Prior to CS-0072 (Cook Standard v0.9), this cascade already included the rule-6 module-call classification. CS-0072 removed it from the recipe-body cascade, forcing authors to reach for the >> (inline_lua_line) or >>{ (inline_lua_block) prefix to get register-phase Lua inside a recipe body. CS-0134 REINSTATES rule 6 above, reversing the CS-0072 recipe-body removal, while simultaneously retiring >> / >>{ from the language entirely (§ 3.7). The equivalent top-level dispatch of § 4.4 is unaffected by this reinstatement.

A Content token that matches none of rules 1–6 MUST be rejected. In particular, a conforming implementation MUST reject each of the following recipe-body constructs, with a diagnostic that identifies the offending line and directs the author to the migration path — a cook "out" >{ ... } / cook "out" { ... } (or test) BLOCK body, where >{ / > survive as body openers (§ 8.7), or a chore, whose body retains the loose-shell and execute-Lua imperative surface (§ 7):

  • A loose/anonymous shell command (any Content line matching none of rules 1–6).
  • A line whose first non-whitespace character is @ (empty or not). The @ interactive prefix and its interactive_command step kind are removed from the language entirely by CS-0134 (§ 3.7); an @ line no longer parses as any step kind anywhere.
  • A standalone > or >{ line outside body position.
  • A standalone >> or >>{ line — already rejected at the lexical layer (§ 3.7) because CS-0134 removes both prefixes from the language outright; a recipe body is not a special case of that removal, it is simply one more place the prefixes no longer parse.

A Content token inside a recipe_body whose trimmed text begins with the keyword register followed by a separator (space, tab, or end-of-line) MUST be rejected with the diagnostic “register blocks are top-level only; move this outside the recipe body”. The rejection applies regardless of indentation depth: an indented register cannot be a RegisterHeader (which is column-0 only by § 3.10) and is also not a permitted step in this position. A Content token whose first token is the bare identifier register without a separator and without the module-call shape of rule 6 (e.g. bare register_foo) falls through to rule 7 and is rejected under the general rule above. The reference parser fires the keyword-specific rejection in parse_recipe’s step-dispatch loop (informative).

A recipe_body MUST contain at most one gather_step. A conforming implementation MUST reject a recipe with two or more gather_step items (App. A.3).

Every remaining recipe_body item — gather_step, cook_step, test_step, module_call, comment, Blank — is register-phase (App. A.3); CS-0134 leaves no imperative_item in a recipe body’s top-level line sequence. The pre-CS-0134 region ordering rule (App. A.3, “Region ordering rule”; § 6.3) required every declarative_item to precede the first imperative_item of the recipe body; under CS-0134 that constraint is satisfied vacuously for recipe bodies (there being no imperative_item left to order against) and imposes no further requirement here. It is likewise vacuous for chore bodies: CS-0134 removes the register-phase inline_lua_line / inline_lua_block (>> / >>{) steps from the language, leaving a chore body with only execute-phase (imperative) step kinds — shell_command, lua_line, lua_block — and therefore no declarative region to order against (§ 7.2).

Example 8.1.1

recipe build
    gather "src/*.c"
    cc.configure()
    cook "bin/$<in.stem>" { cc $<in> -o $<out> }
    test { ./bin/app }

The four body lines dispatch, in order, to: gather_step, module_call (rule 6 — register-phase; before CS-0134 this line would have needed to be written >> cc.configure()), cook_step, and test_step.

Example 8.1.2

recipe build
    vim notes.txt

The body line is a loose shell command (a Content token matching none of rules 1–6) and MUST be rejected; the diagnostic directs the author to a chore, which retains the loose-shell imperative surface a recipe body no longer admits.

Note 8.1.1

The cook-lang parser test test_empty_interactive_step_errors pinned rejection of a bare @ prior to CS-0134. CS-0134 removes the @ marker and the interactive_command step kind from the language entirely (§ 3.7): every @ line — bare or carrying a command — is now invalid syntax wherever it appears, and inside a recipe body it is rejected as a Content line matching none of rules 1–6.

The reference parser at cli/crates/cook-lang enforces the CS-0134 cascade in parse_recipe’s step-dispatch loop (informative); parse_chore’s step-dispatch loop keeps the >/>{ steps and loose (implicitly-interactive) shell commands a chore body admits, but — like every other context — no longer accepts an @ line (CS-0134).

8.2. Gathered inputs

A gather_step is introduced by gather followed by one or more quoted input items, one bare source name, or one $<recipe> reference. Quoted items resolve the same include-minus-exclude path set as the quoted gather form. A bare name selects a top-level files declaration or an array-valued probe (including the existing <key>:<field> selection); quoted globs MAY trail it and are inputs of every member unit. A $<recipe> reference selects that recipe’s declared output paths (CS-0239). A files-backed gather binds each matched path as $<in> and declares that path as the member unit’s input, and a recipe-backed gather does the same with the referenced recipe’s outputs. It is not a work unit and adds no DAG node.

gather says what the recipe’s command text names. A recipe containing gather MUST name its gathered set from at least one cook or test step, through $<in>, $<in.ACCESSOR>, or the corresponding execute-Lua input / inputs binding. Otherwise the implementation MUST reject it at load time with a diagnostic stating that the command never names the gathered files and directing the author to seal them instead. Conversely, a recipe that establishes no input source MUST reject such an own-input reference with a diagnostic stating that nothing gathers what the command references. A source is established by any of: a gather_step, in any of its quoted-glob, bare-source-name, or $<recipe> forms (this section); a dep-driven output pattern, where a $<lib.ACCESSOR> in the step’s own output list makes lib’s output list the source (§ 10.4); or a preceding cook_step in the same recipe, whose collected outputs are the source under the chained-step rule (§ 8.4.1, § 8.6.1). With none of them present there is nothing for the reference to name. Establishing a source is a separate question from consuming it one member at a time: § 8.4.1 decides cardinality from the output patterns alone, so a many-to-one step whose $<in> is the whole collected set has a source and no iteration item, and is admitted here. Both predicates are static and MUST NOT consult the filesystem. (CS-0237 restates the converse over the whole source set; naming only gather and the dep-driven pattern rejected a corpus case a conforming implementation accepts.) [added CS-0224]

A recipe body MAY contain at most one gather_step (§ A.3, “At-most-one-gather rule”). A second gather line within the same recipe body MUST be rejected.

The gather_step is introduced by the keyword gather and takes one of three forms (§ A.4, gather_step ::= "gather" ( input+ | probe_ref STRING* | recipe_ref ) NEWLINE). One lexical discriminator selects among them, at the first token after the keyword: a quote (or !) opens Form 1, $< opens Form 3, and anything else is read as Form 2’s bare name.

Form 1 — glob form. One or more input items, each of:

FormMeaning
STRINGinclude: the quoted text is a glob pattern added to the input set.
! STRINGexclude: the quoted text is a glob pattern subtracted from the input set.

Exclusion patterns without parent (..) components MUST match the normalized logical paths already selected by includes, using the same root anchors and glob segment rules. An implementation MUST NOT let an unrelated physical path remove a gathered path merely because both physical names normalize to the same spelling. Exclusions need not enumerate unrelated filesystem trees. Patterns containing permitted parent components retain filesystem resolution: missing/../file does not select file unless the intervening directory exists. Malformed exclusions MUST still be rejected even when includes select no files. (CS-0255.)

A conforming implementation MUST accept a gather line containing only excludes, only includes, or a mix of the two in any order. The semantic effect is the set difference (union of includes) \ (union of excludes); ordering of items on the line does not affect the result.

Form 2 — named-source form. A single bare probe_ref naming a files declaration or an ordinary probe, optionally followed by quoted glob patterns. The bare source alone supplies the recipe’s members; the trailing globs declare per-member file inputs (CS-0197, § 22.5.10) and MUST NOT be read as adding members. They are not input items of Form 1 and the set-difference rule above does not apply to them: each resolves once per step, not per member, and the resolved set joins every member unit’s declared inputs. Note 8.2.1 states which sources a bare name selects and what each form’s members bind to. Three constraints are normative, and a conforming implementation MUST reject each violation at parse time:

  1. At most one bare source. gather a b is rejected. Arity is one, not +.
  2. The bare source comes first. A quoted item MUST NOT precede it: gather "include/*.h" sources is rejected.
  3. Only plain globs MAY trail it. The trailing items are STRING*, so an exclusion is not admitted there: gather sources !"include/bad.h" is rejected.

Form 3 — recipe form (CS-0239). A single recipe_ref — a $<NAME> placeholder whose NAME resolves to a recipe under § 10.2. The recipe’s members are that recipe’s declared output paths (§ 10.4.1), one member per path.

The sigil is what selects this form, and it is load-bearing rather than decorative: a bare source resolves in the probe namespace (Form 2), so gather gen names a probe gen and reports one that was never declared, whatever recipes are in scope. $<gen> self-disambiguates, and it does so with the vocabulary the language already uses for “that recipe’s outputs” everywhere else — in a command body and in a cook output pattern alike.

Three constraints follow, and a conforming implementation MUST reject each violation:

  1. The source stands alone. Form 3 admits no trailing globs and no second source: gather $<gen> "include/*.h" and gather $<a> $<b> are both parse-time rejections. Form 2’s trailing STRING* exists because a data member has no path of its own (CS-0197); a recipe member IS a path and is its own per-member input, so the device has nothing to add here.

  2. The name MUST resolve to a recipe of the current Cookfile. A $<NAME> naming no such recipe is rejected, naming the reference. Because the name is not known to be a recipe until the whole Cookfile is read — § 10.2.4 makes a forward reference legal — the rejection is a load-time one and need not be a parse-time one.

    A qualified cross-Cookfile reference ($<lib.gen>, § 11.3) is not admitted in this position. This is a position declining a resolution, not a token meaning something different here: § 10.2 still resolves lib.gen to that recipe, and $<lib.gen> in a command body still substitutes its outputs. § 10.2.4 permits exactly this — “a position that cannot honour a resolution MUST refuse it” — and requires the refusal to name the position as the reason. A conforming implementation MUST therefore reject a dotted gather source with a diagnostic that (a) reproduces the reference as written, (b) says this position does not yet admit a qualified reference, and (c) does NOT report the recipe as undeclared — the name is declared, and telling the author otherwise sends them looking for a typo that is not there. Admitting it is a future amendment.

The three parts are stated separately because a diagnostic is only as good as the text that reaches the author, and a rule phrased as “mentions the position” is satisfiable by a message no one can read. A conformance check that asserts a diagnostic CONTAINS a substring cannot see a defect in the whitespace, ordering, or joining BETWEEN its substrings; the rendered message is the artifact under test, not the fragments an implementation happens to assemble it from. 3. A recipe MUST NOT gather its own outputs. recipe r / gather $<r> is rejected. It is the degenerate cycle: the member set would be the output of the units the member set registers. Longer cycles are caught by the cross-recipe cycle rule of § 10.6.

The reference creates the dependency edge, exactly as § 10.6 requires of every name reference: gather $<gen> puts gen in the gathering recipe’s build closure with no : gen dep-list entry, and the ordering is a consequence of the name rather than of any path. The path-literal spelling of the same intent — gather "build/*.conf" over what gen writes — is not equivalent and MUST NOT be read as one: a literal path surface establishes no edge at all (§ 10.6, “Only name references create edges”), so it races the producer, and it globs whatever is on disk rather than what a recipe declared.

The three forms are alternatives of one production, not mutually exclusive vocabularies: a named source with trailing globs — gather sources "include/*.h" — is Form 2 and is well-formed, pinned by standard/conformance/positive/068-gather-named-files. What a recipe cannot do is mix the arms, per Form 2’s constraints 2 and 3 and Form 3’s constraint 1.

The glob language itself — which characters are wildcards, whether ** matches across directory separators, and so on — is not defined by § 6 and is resolved by the execution model (§ 13).

Example 8.2.1

recipe lib
    gather "src/*.c" "include/*.h" !"src/lua.c" !"src/luac.c"
    cook "build/libmath.a" { ar rcs $<out> $<in> }

The input set is every .c file in src/ and every .h file in include/, minus src/lua.c and src/luac.c.

Note 8.2.1 — overload rule

A gather argument is any iterable source. A quoted token ("glob" / !"exclude") is a filesystem glob yielding path members. A bare gather source selects either a named files declaration, whose resolved manifest keys are path members, or an ordinary probe, whose resolved value MUST be an array and whose array elements are members (§ 22.5, §22.5.10). A non-array ordinary probe is rejected; a tools declaration is not iterable. A bare source MAY be followed by quoted glob patterns, which declare per-member file inputs rather than adding members (§8.2, Form 2; CS-0197); it MUST NOT be preceded by a quoted item, repeated, or followed by an exclusion. Members of all three forms bind as $<in> — path accessors ($<in.stem>, …) for path members, record-field access ($<in.FIELD>) for record members.

A $<recipe> argument (Form 3, CS-0239) is the third iterable source. Its members are path members and bind exactly as a glob’s do — $<in> is one path, $<in.stem> is that path’s stem — because a declared output IS a path. It admits no trailing globs and no exclusion.

The ! exclusion marker (glob form only) MUST immediately precede the opening " with no intervening whitespace: ! "foo" is rejected; !"foo" is accepted. See parser tests test_gather_with_excludes and test_gather_excludes_only.

Example 8.2.2 — per-output tests over another recipe’s artifacts

recipe gen
    gather "src/*.conf.in"
    cook "build/$<in.stem>.conf" { render $<in> > $<out> }

recipe check-confs
    gather $<gen>
    test { validate $<in> }

check-confs registers one test unit per file gen declared. Each unit declares the artifact it reads as a file input, so it is keyed on that artifact’s content: an artifact that goes bad re-runs its test rather than replaying a cached pass over it, and adding a source adds a test. The $<gen> reference is what orders gen ahead of check-confs; no : gen is written and none is needed.

Per-member reuse is a separate property from per-member keying, and this form does not yet deliver it: an implementation MAY over-approximate a member unit’s recorded input set with the whole upstream output list, in which case one changed artifact re-runs every member. The Standard requires the keying (a member unit MUST declare the artifact it reads) and permits the coarser recording, on the same ground § 10.6 permits a coarse edge — the failure mode is wasted work, not a wrong answer. The reference implementation is coarse here, in both this form and the dep-driven form of § 10.4, for one shared reason recorded in COOK-505.

This is the shape a test step could not otherwise take. Dep-driven iteration (§ 10.4) hangs its driver off an accessor in an output pattern, and a test step has no output pattern, so before CS-0239 “validate each artifact gen produced” had no spelling at all.

8.3. Step kinds — overview

A recipe_body is a sequence of recipe_items (§ A.3). Excluding the structural comment / Blank items, a conforming implementation recognises the following five step kinds — all of them register-phase:

§Step kindIntroduced byPhase
§ 8.2gather_stepkeyword gatherregister
§ 8.4.3.1seal_stepkeyword sealregister
§ 8.4, § 8.5cook_stepkeyword cookregister
§ 8.6test_stepkeyword testregister
§ 8.1module_callBARE_IDENTIFIER "." BARE_IDENTIFIER(…) shape (§ 4.4)register

The phase column classifies each step kind by when its body is evaluated: every recipe-body step kind runs during recipe registration (§ 13.1). This is a consequence of CS-0134’s purely declarative recipe body — the execute-phase lua_line / lua_block kinds of § 8.7 exist only in body position (inside a cook / test / probe step’s body, or inside a chore body), never as a standalone recipe-body item. A normative cross-section table appears in § 13.2.

A Content token inside a recipe body is classified into exactly one of the kinds above using the step-dispatch priority of § A.4 (“Step-dispatch priority”), reproduced here for convenience:

  1. gather + separator → gather_step (§ 8.2).
  2. seal + separator → recipe-level seal_step (§ 8.4.3).
  3. unseal + separator → rejected as removed by CS-0225; omit the ref from the recipe seal set. The removed-keyword rule: envs + separator joins it (CS-0226, CS-0235), and neither is ever answered by rule 7’s loose-shell diagnostic (§ 8.1).
  4. cook + separator → cook_step (§ 8.4, § 8.5).
  5. test + separator → test_step (§ 8.6).
  6. BARE_IDENTIFIER "." BARE_IDENTIFIER(…) → module_call, register-phase (§ 8.1, § 4.4).
  7. Otherwise → REJECTED (§ 8.1).

The >{ / > prefix forms are not subject to this cascade at all: outside body position they are simply not admitted as a recipe-body line — a standalone >{/> line is rejected under rule 7 above (§ 8.1). Inside body position they are selected at the lexical layer (§ 3.7) per § 8.7, regardless of what follows the prefix. The doubled-> forms >> and >>{ no longer exist in the language (CS-0134): they are rejected unconditionally at the lexical layer (§ 3.7), not by this cascade.

Note 8.3.1

The “separator” after a step keyword is space, tab, or end of token. A Content line whose first token is cooking matches none of rules 1–6 above — it is not cook followed by a separator, and it has no IDENT.IDENT( module-call shape — so it is REJECTED under rule 7, not silently accepted as a malformed cook_step. See parser test test_strip_keyword.

8.3.1. shell_block bodies: one invocation, set -e

Normative (CS-0190). A shell_block body — the { … } form admitted by every step kind that takes one, cook (§ 8.4, § 8.5) and test (§ 8.6) alike — denotes one shell invocation, not one per line. A conforming implementation MUST compose the block’s lines into a single shell text by joining them with LF in source order, MUST prefix that text with set -e, and MUST pass the result to one /bin/sh -c.

Three observable consequences follow, and they are the reason this is stated rather than left to the reader of an example:

  1. A failing line stops the block. set -e is part of the composed text, so a non-zero exit anywhere aborts the remaining lines and becomes the unit’s exit status. An author writing mkdir -p build above cc -o build/x x.c is entitled to know the second line does not run when the first fails.
  2. The block shares one shell. A cd, a shell variable, an exported name, or a trap set on one line is in effect on the next, because there is no second process to lose it. Splitting a block into one invocation per line would silently break every body that relies on this, and the difference is invisible in a body that happens not to.
  3. The block is one unit of captured output. § 24.1‘s ordering guarantee is stated over calls, and a block is one call however many lines it has. An implementation MUST NOT report a block’s lines as separately-ordered spawns.

The set -e prefix occupies no source line of its own: line k of the block is line k of the author’s body, and a diagnostic that cites a line number MUST cite the author’s.

This was observable long before it was stated. § 9.3‘s worked example shows a registered unit whose command field is set -e\ngcc -c main.c -o build/main.o, and § 15.2‘s historical note describes adjacent shell lines being “joined into a single set -e-prefixed cook.sh call”. A rule that decides whether a build stops at its first error belongs in normative text, not in an inference drawn from an example.

8.4. cook — single-output form

A cook_step (§ A.4, cook_step) declares a build output. The single-output form has exactly one STRING output pattern before the body (CS-0099 removed the using introducer; the body opener follows the pattern directly). The body is mandatory (CS-0133):

cook "out" body

The output is a STRING whose interior MAY contain substitution placeholders (e.g. $<in.stem>, $<in>, $<out>). The placeholder language is defined by § 9 for substitution shapes and by § 10 for cross-recipe references; § 6 treats placeholder text as opaque.

A conforming implementation MUST accept both of the following forms:

FormbodySemantic
cook "out" >{ ... }exec_lua_blockexecute-phase Lua block; produces the build effect, with inputs / outputs / input / output bindings per § 23.1.
cook "out" { ... }shell_blocksequence of shell commands, one per non-blank line of the block (§ 3.9, “Shell block line normalisation”). The block’s textual placeholders (§ 9.3) are substituted at register time.

After the output pattern(s), the body-dispatch order of § A.4 (“body dispatch order”) applies: >{ is tested before {. The first match wins; the bare-string form is no longer admitted (CS-0022), and the former using introducer is rejected with a migration diagnostic (CS-0099).

Example 8.4.1

recipe lib
    gather "lib/*.c"
    cook "build/obj/$<in.stem>.o" { gcc -c $<in> -o $<out> }
    cook "build/libmath.a" { ar rcs $<out> $<in> }

The first cook is a one-to-one mapping from each input to one object file, where $<in> is the loop member. The second is a many-to-one mapping producing a single archive from the collected set, where $<in> is the unit’s full input set, space-joined. Both use the inline (one-line) shell_block body form.

Note 8.4.1

The @ character inside a shell_block body is preserved verbatim — it is part of the block’s shell text, not an interactive-step marker. The interactive-step dispatch of § 8.8 applies only to a Content line’s first non-whitespace character, not to text nested inside a cook step’s body.

8.4.1. Iteration mode

A cook_step’s iteration mode is determined by its output pattern list, before the body is consulted. Three modes are defined:

Output pattern shapeModeDriverUnits produced
At least one output contains an accessor for its current driver ($<in.ACCESSOR> or $<lib.ACCESSOR>)One-to-one over driver outputsThe gathered input set or lib’s output list (§ 10.4)One per driver output
All outputs are literal (no accessor placeholders)Many-to-one (the “one” refers to one unit, not one output)None — one unit runs with the full input list visibleExactly one
At least one output contains $<in.ACCESSOR> and the recipe carries a gather <probe> source (§ 8.2)One-to-one over data membersThe source’s resolved member listOne per member

A conforming implementation MUST reject, with a load-time diagnostic, a cook_step whose output patterns mix iteration sources — that is, where two outputs select different drivers (one bears $<in.ACCESSOR> while another bears $<libmath.ACCESSOR>, or one bears $<in.ACCESSOR> while another is literal). All output patterns of a single cook_step MUST share one driver.

The iteration mode determines the number of work units produced; it is orthogonal to the cook_step’s declared output count. Each mode admits any number of declared outputs (one or more), as detailed in § 8.5.

gather does not select cardinality. An accessor-bearing output fans out; an all-literal output remains many-to-one. Thus gather "src/*.c" followed by a literal-output step produces exactly one unit whose $<in> is the complete gathered set. [added CS-0224]

When the recipe carries a gather <probe> source (§ 8.2), the output-pattern rule of this section selects between two modes (CS-0155; before that amendment every step was member-iterated “regardless of its output-pattern accessors”, which left literal-output steps registering one colliding unit per member over raw record text):

The gather source supplies the recipe’s members. An accessor-bearing cook output registers one unit per member; a later all-literal cook output registers one aggregate unit over the preceding outputs; an all-literal first cook step is rejected.

The mode selection above is stated over a gather <probe> source. CS-0239’s gather $<recipe> source (§ 8.2, Form 3) is selected by it identically: an accessor-bearing output registers one unit per member, an all-literal output does not, and the source decides neither. Two things about it differ, and both follow from its members being paths rather than records. $<in.ACCESSOR> is a path accessor there, not a record-field read — the data-member bullet’s prohibition below is scoped to a gather <probe> source and does not reach this one. And each member path is a declared file input of the first unit it drives, so that unit is keyed on the artifact’s content and not on the path string; a member whose upstream artifact changed re-runs rather than replaying a stale pass over it. A later step in the same recipe chains on the preceding step’s per-member output under the ordinary chained-step rule, and is keyed on the upstream artifact transitively through it.

  • A cook_step with at least one accessor-bearing output is in one-to-one over data members mode: the per-member binding is item (Lua) / $<in> / $<in.ACCESSOR> (where ACCESSOR selects a record field rather than a path component), and the path-accessor interpretation of $<in.ACCESSOR> does not apply (a data member has no input path). A conforming implementation MUST NOT allow $<in.ACCESSOR> accessors to be interpreted as path accessors when the recipe is driven by a gather <probe> source.

  • A cook_step whose outputs are all literal is the ordinary many-to-one gather of this section, and its input list is the preceding cook_step’s collected outputs — exactly the chained-step rule that applies in a glob-driven recipe: one unit, $<in> expanding to that collected list, space-joined. Data members are records, not file paths, so there is nothing path-shaped for a first step to gather: a conforming implementation MUST reject a literal-output cook_step with no preceding cook_step in a probe-driven recipe with a register-phase diagnostic that names the recipe and directs the author to fan out first or to aggregate member values in a >{ … } Lua body via cook.probes.get (§ 22.5.6).

    The same rejection binds a gather $<recipe> recipe (CS-0239), for a different reason and therefore with different advice: those members ARE paths, so the objection is not that there is nothing path-shaped to gather but that the aggregate already has a spelling needing no gather at all — cook "out" { … $<gen> … } substitutes gen’s whole output list and establishes the same edge (§ 10.5). A conforming implementation MUST NOT tell a recipe-source author that data members are not a collected file set; the diagnostic MUST name the alternative that is true of the source in hand.

Example 8.4.1.2

probe services
    json { echo '[ {"name":"web"}, {"name":"api"} ]' }

recipe configs
    gather services
    cook "build/$<in.name>.conf" { render $<in.name> > $<out> }
    cook "build/manifest.txt"    { cat $<in> > $<out> }

The first cook fans out: one unit per record, $<in.name> a record field. The second’s outputs are literal, so it gathers — one unit whose $<in> is the collected build/web.conf build/api.conf, not the member records. Removing a record drops its fan-out unit, sweeps its .conf (§ 17.7), and rebuilds the manifest.

Example 8.4.1.1

recipe build
    gather "src/*.c"
    cook "build/$<in.stem>.o" { gcc -c $<in> -o $<out> }
    cook "build/app" { gcc $<in> -o $<out> }

The first cook is one-to-one — its output pattern bears an accessor, so $<in> is the loop member. The second is many-to-one — an all-literal output, so $<in> is the collected input set. (The distinction cannot be annotated inline: a trailing # is not a comment, § 3.6.)

Note 8.4.1.1

The CS-0022 unification removes $<in> from the body’s contents as a separate iteration trigger. Iteration mode is decided exclusively by the output pattern list, not by which placeholders appear in the body. $<in> itself is admitted in both one-to-one and many-to-one bodies (CS-0130, § 9.3): only its value differs by mode — the loop member in one-to-one, the space-joined collected set in many-to-one.

8.4.2. Lua-expression output

A cook_step MAY substitute a parenthesised Lua expression for the quoted output-path STRING:

cook_step ::= "cook" ( STRING+ | "(" LUA_EXPR ")" ) body NEWLINE

A conforming implementation MUST accept the Lua-expression form under the following rules.

  1. Evaluation. The expression is evaluated once per input, at register time, against the Cookfile-scope Lua VM (the same VM used for chore-parameter Lua-expression defaults, § 7.1.1).
  2. Bindings. The expression sees the name input in scope: the current input’s path, a Lua string. When the recipe is driven by a gather <probe> source (§ 8.2), the expression instead sees item bound to the current data member (a table for record members, a string/number/boolean for scalar members) rather than input. The expression MAY also reference register-phase locals visible to the recipe — top-level Lua bindings, config block writes, and locals established by a register block (§ 5.4) or a recipe-body module_call (§ 8.1, rule 6). The standard library is available subject to the § 21 sandbox in effect at register time.
  3. Return value. The expression’s return value MUST coerce to a non-empty string under the same coercion rules the Standard applies to chore Lua-expression defaults (§ 7.1.1): string verbatim, number via Lua tostring, boolean to "true" or "false"; any other type — including nil, table, function, thread, userdata — is a register-phase diagnostic. The empty string is likewise a register-phase diagnostic.
  4. One-to-one only. The Lua-expression form is admitted exclusively when the resulting cook_step is in one-to-one over own inputs mode or one-to-one over data members mode (§ 8.4.1); in the data-member case the expression sees item rather than input (rule 2). The expression is evaluated per iteration element, so dep-driven and many-to-one modes — neither of which iterates element-by-element — have no well-defined evaluation contract. A conforming implementation MUST reject a Lua-expression-output cook_step whose recipe has no gather_step, or whose iteration source resolves to the empty set, with a register-phase diagnostic.
  5. Mutual exclusion with quoted-string outputs. Within a single cook_step, the output declaration is either (a) one or more STRING patterns with the $<...> sigil vocabulary of § 9.3, or (b) exactly one parenthesised Lua expression. A conforming implementation MUST reject a cook_step that mixes the two forms (e.g. cook "a.out" (input:gsub(...)) { ... }) with a register-phase diagnostic naming the offending tokens.
  6. Body. The body of a Lua-expression-output cook_step follows the same grammar as the quoted-string form (§ 8.4): exec_lua_block (>{ ... }) and shell_block ({ ... }) are both admitted; the body is mandatory (CS-0133). The placeholder vocabulary inside the body (§ 9.3) is the one-to-one vocabulary: $<in>, $<in.ACCESSOR>, $<out>. Note that $<out> resolves to the Lua-expression’s evaluated string for the current iteration; placeholders that index the output position list ($<out_1>, $<out_2>, …) have no use in this form because the form declares exactly one output per iteration.

Example 8.4.2.1

recipe translate-fr
    gather "docs/en/**/*.md"
    cook (input:gsub("/en/", "/fr/")) >{
        fs.write(output, transform(fs.read(input)))
    }

The expression input:gsub("/en/", "/fr/") runs once per matched input with input bound to that input’s path; the resulting string becomes the unit’s outputs[1]. The exec_lua_block sees the input and output bindings per § 23.1; output is the same string the Lua-expression produced.

Example 8.4.2.2

recipe thumbnail
    gather "assets/photos/*.jpg"
    cook (input:gsub("assets/photos", "build/thumb")) { convert $<in> -resize 256x256 $<out> }

Mixes the Lua-expression output form with a shell_block body. The shell block’s $<in> and $<out> placeholders resolve to the iteration’s input path and the Lua-expression’s evaluated string, respectively.

Note 8.4.2.1 — Cache key impact [#steps.cook-lua-expr-output.cache]

The expression’s resolved string is recorded as the unit’s outputs[1], and so participates in the cache key via the output-path observable of § 17.1 #3. The expression source text itself is part of the recipe’s parsed body and is therefore already folded into the unit’s command-text / Lua-chunk fingerprint per § 17.1 #1 — no separate fingerprinting pass for the expression is required.

Note 8.4.2.2 — Why one-to-one only [#steps.cook-lua-expr-output.rationale]

The Lua-expression form’s defining feature is per-input evaluation: each input produces one unit whose output path is the expression evaluated with input bound to that input. Dep-driven iteration (§ 10.4) iterates over a dependency’s output list, not over the step’s own input list, so input would have no canonical meaning. Many-to-one iteration runs the unit exactly once over the full input list, so per-input evaluation has no addressable iteration. The restriction is therefore not a guard against unsafe semantics but a consequence of the bind-input-per-input contract.

Note 8.4.2.3 — Register-phase diagnostic shape [#steps.cook-lua-expr-output.diagnostic]

A conforming implementation rejecting the resolved output value MUST surface a register-phase diagnostic whose message identifies (a) the offending input path and (b) the rule violated (non-string return, empty string). The reference implementation emits cook (LUA_EXPR) returned non-string or empty value for input <PATH> from the per-input register-phase guard; an alternative implementation MAY phrase the message differently but MUST carry equivalent information.

8.4.3. Cook-step disposition

The cook step IS the cacheable unit. One step → one content-addressed key → one disposition, however many output files it emits. The whole surface teaches a single mental model:

Body = what it’s made of. cook = one unit (≥1 output). Tail = what to do with that unit.

A unit’s cache participation is declared by two surfaces — inputs and output-facts. seal names determinant inputs for the recipe, while local/pinned/nondet state a fact about one unit’s output.

ConceptWritten asWhereScope
File inputsgather "…"body linerecipe-wide
Determinant inputsseal <probe-or-glob> …body linerecipe-wide
The unitcook "out1" "out2" { … }body lineone key, ≥1 output
Disposition… local / pinned / nondettail of the cookthat unit

One rule: a body line names a recipe-wide input; a tail states an output fact about one cook unit. The cache-key effect of each is normative in § 17 (Cache-trust v3); this section defines the surface and its parse-time rules.

A seal_operand follows the language-wide quoted-versus-bare law: a bare BARE_PROBE_KEY names a probe or top-level declaration, while a quoted STRING is a file glob and !STRING excludes matches from the quoted set of that step. Quoted operands desugar to one anonymous files probe; they are invalidate-only, never bind $<in>, and never make the recipe iterate. To consume paths as data, use gather (§ 8.2), not seal. The implementation-reserved @seal: probe-key prefix identifies those anonymous declarations and MUST be rejected on every author-written declaration surface, including cook.probe; compiler-generated anonymous declarations register through an implementation-private path. An unmatched quoted glob MUST be rejected as an unresolved file determinant; an unresolved bare operand MUST instead identify the attempted probe key.

There is no inline environment or tool determinant form. Those determinants remain named declarations:

  • Unconsumed environment. An ambient environment variable that no unit consumes (so § 17.1.2 does not auto-fold it) is folded by declaring an ordinary shell probe over the variable(s), for example lines { echo "$CFLAGS"; echo "$LDFLAGS" }, and sealing that probe by name. The probe is named, can group multiple variables, and is shareable; because it has no declared source it re-observes on every reached invocation, while consuming units key on its value (§ 22.5.8, § 17.1.1). Environment a unit does consume continues to auto-fold via § 17.1.2, unchanged.
  • Tool determinants. A toolchain is folded by sealing a tools declaration (§ 22.5.2) by name. Both ambient kinds therefore resolve to the same uniform “seal a named probe” surface, with no inline environment or tool special forms to parse.

8.4.3.1. seal steps

seal is a recipe-body step — a second input stream, sibling of gather, declaring determinant inputs alongside gather’s consumed inputs. A conforming implementation MUST enforce, at parse time:

  1. Recipe-level seal is the baseline. A seal <operands> step adds the named refs and anonymous file determinant to the recipe’s determinant baseline, which applies to every cacheable unit in the recipe — every cook unit and every test unit (§ 8.6; CS-0159). Bare seal (zero operands) MUST be rejected. An exclusion MUST be immediately adjacent to ! and a seal step containing exclusions MUST contain at least one quoted include. The step, not the physical line, is the unit both rules are asked over: a seal step MAY span continuation lines (CS-0238, § A.4, “Multi-line patterns”). A recipe whose baseline is non-empty but which declares no cacheable unit at all MUST be rejected: the seal has nowhere to land.
  2. Additive and order-independent. Multiple recipe-level seal steps union their refs. The baseline applies to every cacheable unit regardless of textual position (it is declarative, not positional): seal after a cook or test still applies to it. The effective set is sorted and de-duplicated.
  3. There is one tier. Trailing seal and every unseal position were removed by CS-0225. A conforming implementation MUST reject them with migration diagnostics: trailing seal names the recipe-level step, and unseal says not to put the ref in the recipe’s seal set.
  4. An unresolvable ref is a seal diagnostic. A named ref that resolves to no declaration MUST be rejected, and the rejection MUST be stated in terms of the seal step the author wrote: it MUST name seal, quote the ref, and name the declaration kinds a ref resolves against — a probe, a top-level files, or a top-level tools. An implementation that folds seal refs into some internal consumer list MUST NOT let the ref inherit that list’s diagnostic; § 22.5.6 rule 1’s mandated sentence describes the probes field of cook.add_unit, which is API surface a Cookfile author never wrote. (CS-0235.)

Recipe boundaries break seals implicitly. The recipe is the outermost seal scope; nothing propagates across recipe dependency edges. A downstream recipe folds an upstream’s determinants in transitively via the upstream’s OUTPUT CONTENT hashes (consumed as gather), so re-sealing downstream would double-count. There is exactly one seal tier — the recipe seal set — and no per-unit or config-default inheritance tier.

Note 8.4.3.1 — boundary of a future blanket seal (informative). The seal surface is explicit-only: every determinant is an explicitly named probe ref or file glob. No absolute / blanket form (a hypothetical “seal everything visible”) exists, and a bare seal is rejected (rule 1). An explicit file glob is not a blanket: its author names the filesystem set directly. Any future blanket form is a NEW determinant set, not a re-reading of this one, and MUST be introduced under its own change ID with its own key-version bump.

8.4.3.2. Trailing modifiers (cook_mods)

cook_mods ::= share_mod?
share_mod ::= "local" | "pinned" | "nondet"

The tail of a cook step tunes that one unit. The cook_mods are line-final: they follow the body’s closing } on the same physical line (a cook step always has a body, CS-0133). Tokens on a subsequent line are not part of the cook_mods (a } on its own line followed by a seal … line dispatches the latter as a recipe-level seal step). A conforming implementation MUST enforce:

  1. share_mod is a single optional slot. At most one of local / pinned / nondet MAY appear. local opts out of sharing (§ 17.1.3); pinned is designated-producer / fetch-only (§ 17.1.3); nondet declares the output intrinsically non-reproducible, waiving byte-equivalence (§ 17.1.4).
  2. record is renamed nondet. The record keyword in cook_mods MUST be rejected with a migration diagnostic.
  3. Non-reserved words. seal, unseal, local, pinned, and nondet are NOT reserved words. seal is recognised as a step only at a recipe-body line position; local/pinned/nondet only in the trailing cook_mods position.

8.4.3.3. Test units

A test unit carries the recipe seal set exactly as a cook unit does. It admits no trailing modifiers: seal/unseal were removed by CS-0225, and output dispositions do not apply because a test produces no artifact.

Example 8.4.3.1 — the whole surface once

The probes toolchain and flags are declared elsewhere (§ 22.5):

recipe build
    gather "src/*.c"
    seal toolchain flags
    cook "bin/app" "bin/app.map" { cc $<in> -Wl,-Map=bin/app.map -o bin/app }
    cook "bin/app.dbg" { cc -g $<in> -o $<out> } local
    cook "bin/plugin"  { cc -shared $<in> -o $<out> }
    cook "gen/ver.h"   { date > $<out> } nondet

bin/app/bin/app.map form one unit: one key, two outputs. bin/plugin is a separate unit under the same recipe seal set. Every unit is sealed on toolchain and flags; bin/app.dbg is kept local; gen/ver.h is nondet, so a hit reuses the stored copy rather than requiring byte-equivalence.

The example carries no inline annotations, because it cannot: a # is a comment only at the head of a line — § 3.6 forbids treating it as one in any other lexical position — so a trailing # on a gather, seal or cook line is ordinary input to that step and is rejected by it. Each body names $<in> for the same reason the surface exists: a recipe that gathers and never names what it gathered is refused by §8.2’s own rule. standard/conformance/positive/cook-disposition-whole-surface holds a byte-identical copy of this example and pins that copy’s parse. Nothing extracts the fence above, so the two are kept in step by hand: an edit here that is not mirrored there is not caught by any test.

Example 8.4.3.2 — one seal tier

recipe build
    seal a b c
    cook "x.o" { cc -c x.c -o x.o }

The recipe seal set {a, b, c} applies to x.o.

Example 8.4.3.3 — a sealed test

recipe check
    gather "tests/*.c"
    seal toolchain
    cook "build/$<in.stem>" { cc $<in> -o $<out> }
    test { ./build/$<in.stem> }
    test { ./scripts/lint.sh }

seal toolchain is the recipe-wide baseline: it reaches the cook unit and both test units alike. The recipe seal set reaches both test units, exactly as it reaches the cook. A change to toolchain therefore re-runs both tests even when the consumed binary is restored byte-identically from cache.

Note 8.4.3.2 — Conformance fixtures [#steps.cook-disposition.fixtures]

Positive: standard/conformance/positive/cook-disposition-seal-line, cook-disposition-seal-stacked, cook-disposition-local-line, cook-disposition-pinned-line, cook-disposition-nondet, cook-disposition-seal-order-independent, cook-disposition-seal-multi-cook-baseline, cook-disposition-seal-shell-probe, cook-disposition-seal-tools-probe.

Negative: standard/conformance/negative/cook-disposition-local-and-pinned, cook-disposition-double-share-mod, cook-disposition-bare-seal, cook-disposition-record-renamed.

Test-unit seal (CS-0159) — positive: test-seal-baseline, test-seal-all-units, test-seal-multiple-refs, test-seal-only-recipe. Negative: test-seal-bare, test-seal-share-mod.

8.5. cook — multi-output form

A cook_step MAY declare two or more outputs by listing additional STRING patterns before the body:

cook "a" "b" ... body

When two or more outputs are present and a body is given, the body is one of the two block forms (§ 8.4): exec_lua_block (>{ ... }) or shell_block ({ ... }). The bare-string form was removed in CS-0022, and cook "a" "b" "cmd" parses as a three-output cook with no body — a trailing STRING joins the output-pattern list rather than being read as a body — which is then rejected because a body is mandatory (CS-0133).

Like the single-output form, a multi-output cook_step MUST have a body (CS-0133); cook "a" "b" with no body is a parse error.

Example 8.5.1

recipe generate
    gather "src/*.rs"
    cook "staging/out.js" "staging/out.wasm" {
        ./fake-generator.sh
        mkdir -p staging
        cp pkg/out.js $<out_1>
        cp pkg/out.wasm $<out_2>
    }

One cook step declares two outputs produced together by a sequence of shell commands. The commands are the four non-blank lines of the shell block, in order (§ 3.9). The placeholders $<out_1> and $<out_2> resolve to the unit’s first and second declared outputs, in declaration order, per § 9.3.

Example 8.5.2

recipe build_lua
    gather "src/*.rs"
    cook "staging-lua/out.js" "staging-lua/out.wasm" >{
        os.execute("mkdir -p staging-lua")
        local sources = table.concat(inputs, " ")
        local js = io.open(outputs[1], "w")
        js:write("// generated from " .. sources .. "\n")
        js:close()
        local wasm = io.open(outputs[2], "w")
        wasm:write("BINARY\n")
        wasm:close()
    }

The same two outputs are produced by a Lua block. The inputs and outputs names visible to the block are established by the execution model (§ 13) and the Cook Lua API (§ 23.1); § 6 requires only that a block-form body be accepted.

Note 8.5.1

The shape cook "a" "b" using "cmd" was rejected pre-CS-0022 with a “block body” diagnostic, and under CS-0022 with a bare-string-removal diagnostic. Since CS-0099 the using token itself is rejected at this position with the migration diagnostic of § A.4 (“using is rejected”); conformance fixture cs0099-using-keyword-rejected pins it.

8.6. test step

A test_step (§ A.4, test_step) registers a work unit that is reported as a test. It has a body — either a shell_block or an exec_lua_block (§ A.4) — and admits no leading STRING or trailing modifiers. The body follows the keyword directly.

What makes a unit a test (CS-0186). The kind selects the reporter, and nothing else: a test unit is a target of the implementation’s test runner, contributes to its pass/fail tally and to the process exit status of § 19.2, and is named in its report. That is the whole of the distinction. In every other respect it is an ordinary work unit — registered by the one registration function (§ 22.1, CS-0185), scheduled by the same graph, and cached by the same rules under the same key (§ 17.1.1).

This section formerly drew the distinction the other way round, as a unit that “reports pass/fail rather than producing artifacts”. The two halves of that are separate claims and only the first is the definition. A test declares no outputs (§ 22.1 requires it), but declaring no outputs is a consequence of what a test is for, not the thing that makes it one: it decides that a cache hit replays a verdict instead of restoring bytes (§ 17.1.1.1), which is a fact about caching that an output-less step kind which is not a test would share. A conforming implementation MUST NOT infer either property from the other (§ 22.1, CS-0185), which is what keeps that door open.

test_step ::= "test" body NEWLINE
body ::= shell_block | exec_lua_block

A conforming implementation MUST accept both test { ... } (a shell_block body) and test >{ ... } (an exec_lua_block body), and MUST reject any leading STRING.

Iteration is decided by a single signal — whether the body references the iteration item (§ 8.6.1). A shell body containing $<in> / $<in.ACCESSOR>, or a Lua body binding input, puts the step in one-to-one mode: a fan-out over the iteration source with one cached pass/fail unit per item. Each unit keys on its own item alone — whatever the source is — so editing one item re-runs one unit and no other (§ 17.4 rule 1, CS-0182 as extended by CS-0186). A body that references no iteration item is a single unit — one node. When the recipe supplies a source (gather, or an upstream cook’s outputs), the unit is keyed on the whole source set (the engine’s ManyToOne). With no source but a non-empty recipe seal set it receives a seal-only key under § 17.1.1.4; with neither source nor seal it runs uncached (the engine’s OneShot). A step iterating a gather <probe> source is cached on its member even though it names no file (§ 17.4 rule 1, CS-0186).

What a cached test unit is keyed on, however, IS declarable: a test is a cacheable unit, so it carries the recipe seal set on the same footing as a cook unit (§ 8.4.3.3, § 17.4 rule 1). Whether a sourceless observing unit receives a key is defined by § 17.1.1.4 (CS-0223).

This is the deliberate parallel with cook (§ 8.4.1): the iteration accessor lives wherever the step’s defining payload is — the output pattern for cook, the body for test. CS-0022’s hazard (a body $<in> conflicting with a literal output pattern) cannot arise for test, which has no output pattern for the body signal to conflict with.

A failed test propagates to its dependents (CS-0177). A test_step unit that fails, times out, or is blocked is a failed unit for scheduling purposes: a conforming implementation MUST NOT execute any unit that depends on it, and MUST report each such unit as blocked naming the failed test as the cause. This is the general unit-failure rule (§ 22.5.8 states it for probes), and the test kind is not exempt from it. This holds for a dependent of any kind — a cook step, a chore body, or another test — and the rule is what makes a dependency on a test a gate: chore ship: checks MUST NOT run ship’s body when checks is red. An implementation that runs the dependent and reports the failure only in its exit status does not conform, because the dependent’s side effects have already happened by then.

The rule reaches dependents, not siblings. A unit that merely runs alongside a failed test is unaffected, so a test runner (§ 19.2) still executes and reports the whole suite rather than halting at the first failure. Blocking is a consequence of the dependency edge, never of shared scheduling.

Trailing content after the body is rejected. A conforming implementation MUST reject every token after the body’s closing }. When the rejected trailing token is seal or unseal, it MUST emit the CS-0225 migration diagnostic of § 8.4.3.3. When it is should_fail, timeout, or as, a conforming implementation SHOULD emit a did-you-mean diagnostic naming the removed modifier and the v1.0 migration path:

  • should_fail: “should_fail was removed in v1.0; invert the check in the body, e.g. test { ! grep -q foo $<in> }”.
  • timeout: “timeout was removed in v1.0”.
  • as: “as was removed in v1.0; tests auto-name”.

None of should_fail, timeout, or as were ever reserved words (§ 3.4); they simply stop being recognised in this trailing position.

8.6.1. Test iteration mode

A test_step has two author-facing modes, selected by whether its body references the iteration item. The single-unit mode maps onto one of the engine’s two batch dispositions according to whether the recipe supplies a source:

Body references the iteration item?Recipe sourceMode
$<in> / $<in.ACCESSOR> (shell) or input (Lua) presentnon-empty (required)one-to-one — one cached pass/fail unit per item
absenta source is declared (non-empty)single unit, keyed on the whole source set (engine ManyToOne)
absentno source, non-empty recipe seal setsingle unit, keyed on the seal set (CS-0223)
absentneither source nor sealsingle unit, uncached, runs every invocation (engine OneShot)

A shell body has no batched-set placeholder (App. E CS-0130 removed the former all-inputs shell placeholder), so a shell single-unit body runs its command once over the unit. A Lua single-unit body MAY bind inputs to observe the collected source set. A Lua body MUST NOT reference both input and inputs.

The iteration source for a test_step is the preceding cook_step’s output list (flattened across all units it produces), falling back to the recipe’s resolved gather (§ 8.2) if no cook_step precedes it. When the recipe is driven by a gather <probe> source (§ 8.2), the iteration source is instead the resolved data-member list, and the per-member placeholders remain $<in> / $<in.ACCESSOR> (Lua: item), where ACCESSOR selects a record field rather than a path component; such a step is in one-to-one over data members mode.

A conforming implementation MUST reject:

  • A Lua-block body that references both an iteration-item binding (input) and a batched-source binding (inputs).
  • A one-to-one test_step (its body references the iteration item) whose iteration source resolves empty — a fan-out over nothing is an author error.

A test_step with no declared source is admitted. A non-empty recipe seal set gives it the seal-only key of § 17.1.1.4; with neither resolved source nor seal it has no cache key and runs uncached (the engine’s one-shot disposition).

The Lua identifier scan operates on the same lexical text the brace-balance lexer (§ 3.9) operates on — strings, comments, and long strings are excluded; only free-identifier-position references count.

Example 8.6.1

recipe smoke
    gather "tests/*.c"
    cook "build/$<in.stem>" { cc $<in> -o $<out> }
    test { ./$<in> }

Each test binary from the cook step’s output is run in turn; the step is one-to-one and expects each invocation to exit zero.

Example 8.6.2

recipe check
    test { cargo test }

No preceding cook, no gather, and no recipe seal: the step has neither source nor seal, so it is one-shot and runs uncached on every invocation of check.

Example 8.6.3

recipe coverage
    gather "src/*.c"
    cook "build/bin/$<in.stem>.test" { cc $<in> -o $<out> }
    test >{
        for _, bin in ipairs(inputs) do
            cook.sh("./" .. bin .. " --check")
        end
    }

The Lua body references inputs (no input); the recipe supplies a source (the cook step’s outputs), so the step is a single unit keyed on the whole source set (engine ManyToOne), run once with all test binaries visible as inputs.

Note 8.6.1

A test_step running a Lua body where cook.sh/cook.exec returns non-zero fails the test by the same rule as an uncaught Lua error.

8.7. Lua steps — line and block forms

The Cookfile language has one family of Lua step, the execute-phase > / >{ forms, distinguished from each other only by whether they open a nested block. As of CS-0134, both forms are admitted only in body position — as the opener of a cook, test, or probe step’s body, or as a standalone step inside a chore body — and MUST NOT appear as a standalone top-level line of a recipe_body (§ 8.1).

Surface formToken (§ 3.10)PhaseAdmitted positions
> body \nLuaLineexecutecook/test/probe body opener; standalone inside a chore body
>{ body } \nLuaBlockOpenexecutecook/test/probe body opener; standalone inside a chore body

Note 8.7.0 — Removal of the doubled-> forms (CS-0134)

Prior to CS-0134, a second family existed: the register-phase doubled-> prefixes >> (InlineLuaLine) and >>{ (InlineLuaBlockOpen), admitted as standalone declarative-region steps inside a recipe body. CS-0134 REMOVED both prefixes from the language entirely — they are rejected unconditionally at the lexical layer (§ 3.7, Note 3.7.1), not merely repositioned. A Cookfile that needs register-phase Lua inside a recipe body now writes a bare module call instead (§ 8.1, rule 6): ns.fn(...) where a pre-CS-0134 Cookfile would have written >> ns.fn(...).

8.7.1. Delimitation

A lua_line step’s body is the remainder of the line after > and an optional single space; the body MAY be empty (App. A.5, “lua_line MAY be empty”).

A lua_block step’s body is the source text between the opening >{ and the matching }, collected by the brace-balance algorithm of § 3.9. The closing } line is consumed but is not part of the block’s body.

A conforming implementation MUST NOT reinterpret the Lua body text. Comments, strings, and other Lua constructs inside the body are the Lua runtime’s concern, with the single exception that the brace-balance algorithm (§ 3.9) MUST ignore braces appearing inside Lua line comments, double-quoted strings, single-quoted strings, and long strings ([[...]]) when locating the closing } of a Lua block.

8.7.2. Execution phase

lua_line and lua_block are execute-phase steps in both of their admitted positions. Their bodies are never evaluated during registration.

When a lua_line or lua_block opens a cook / test / probe step’s body, its body IS that step’s registered work unit (§ 8.4, § 8.6), evaluated execute-phase with the inputs / outputs / input / output bindings of § 23.1.

When a lua_line or lua_block occurs as a standalone step inside a chore body, its lua_code payload runs during the execute phase on the chore’s single drain thread; the execution model for chore-body imperative steps — which is the single-drain rule of § 7.3, NOT the (CS-0134-vacuous) recipe body-bundling of § 15.2 — is specified in § 7.

Register-phase Lua inside a recipe body — the role the removed >> / >>{ prefixes used to fill — is now written as a bare module call (§ 8.1, rule 6): ns.fn(...), dispatched and evaluated in the recipe-registration Lua scope alongside gather / cook / test (§ 8.1, § 4.4).

8.7.3. Examples

recipe build
    cc.configure()
    cook "build/app" >{
        cook.sh(string.format("%s %s -o %s",
            var.CXX, table.concat(inputs, " "), output))
    }

The bare cc.configure() line is a module_call (§ 8.1, rule 6), register-phase — the role a pre-CS-0134 Cookfile would have filled with >> cc.configure(). The cook step’s >{ } body is a lua_block in body position: execute-phase Lua-with-bindings, run after the recipe’s other register-phase steps have been recorded; see § 23.1.

chore smoke
    cd build
    ./app --selftest
    > if not fs.exists("build/.smoke.ok") then error("smoke failed") end

Standalone lua_line / lua_block steps survive inside a chore body, which is unaffected by CS-0134’s recipe-body restriction (§ 7). The > line runs after the two preceding shell steps, in the chore’s execute-phase model.

chore legacy
    > print("start")
    >{
        if true then
            local t = {1, 2, 3}
            print(#t)
        end
    }
    >

The first step is a single-line execute-phase Lua statement. The second is an execute-phase Lua block whose body contains a literal { that does not prematurely close the block. The third is an empty lua_line. All three are standalone steps of the chore body (§ 7); the empty third line contributes nothing.

Note 8.7.1

A Lua block (either form) opened on an indented line closes on the line where the running brace-depth first returns to zero, not on the first bare }. A line like local s = "}" does not close the enclosing block because the } sits inside a double-quoted string (§ 3.9). See parser tests test_lua_block_brace_in_string and test_lua_block_brace_in_comment.

Note 8.7.2

The execute-phase API surface available inside a lua_line or lua_block is a strict subset of the register-phase API: register-only helpers (cook.add_unit, cook.exec, cook.interactive, cook.step_group, cook.recipe) MUST raise a Lua runtime error when called from execute-phase Lua. cook.sh is available in both phases (§ 24.1). See § C.6.12 for the rationale.

8.8. Shell steps

A shell_command is a chore-body step kind (§ 7.2). CS-0134 removed it from the recipe-body dispatch cascade: a recipe body is purely declarative (§ 8.1), and a recipe-body Content line that matches none of the declarative step kinds is REJECTED (§ 8.1, rule 7; Note 8.1.1), not classified as a shell step. A shell_command is defined here and is admitted only inside a chore body (§ 7).

A shell_command is any Content line inside a chore body that is not classified as one of the step kinds earlier in the § 7.2 dispatch cascade. Its body is the trimmed source line; a conforming implementation MUST preserve the body text verbatim, including any internal quoting, variable references, and shell metacharacters.

Every chore-body shell_command is implicitly interactive: it runs on the chore’s single drain thread with the controlling terminal inherited (§ 7.3), so a child such as fzf sees a real TTY. There is no separate marker or step kind for interactivity. CS-0134 removed the @ interactive prefix — and the interactive_command step kind it once introduced — from the language entirely (§ 3.7): a line beginning with @ is now invalid syntax everywhere. The interactivity a pre-CS-0134 author spelled with @ is simply the chore-body default.

Example 8.8.1

chore play
    mkdir -p .cook/scratch
    printf 'a\nb\nc\n' | fzf > .cook/scratch/pick
    echo "picked: $(cat .cook/scratch/pick)"

All three lines are shell_command steps of the chore body. Because they sit in a chore body they each run under the chore’s default-interactive single drain (§ 7.3), so fzf inherits the user’s terminal and can draw a full-screen prompt. No marker is needed; the chore form supplies the interactivity.

Note 8.8.1

A @ embedded inside a cook step’s { ... } shell-block body, or elsewhere inside a quoted string, is a literal @ — ordinary shell text, not a language marker, since CS-0134 removed @ from the Cookfile grammar entirely (§ 3.7). See parser test test_at_in_cook_body_is_not_interactive.

8.9. Cross-recipe per-member output accessor

Inside a gather <probe> fan-out body (§ 8.2), a placeholder of the form $<NAME[in]> — where NAME is a recipe declared in the same Cookfile — resolves to that recipe’s terminal output for the current fan-out member. It is the per-member counterpart of bare $<NAME> (which substitutes all terminal outputs of NAME, space-joined, and is unchanged by this feature).

$<NAME[in]> performs three jobs simultaneously:

  1. Path substitution. At register time the placeholder is replaced by the member-specific output path: cook.dep_output_member("NAME", cook.member_to_string(item)), where item is the fan-out loop variable.
  2. Recipe-level DAG ordering edge. The compiler extracts $<NAME[in]> as an ordering constraint identical to the edge produced by a bare $<NAME> reference: NAME MUST build before the referencing recipe.
  3. Per-member fingerprint fold. The referenced output is folded into the current member’s cache fingerprint, not the recipe’s aggregate fingerprint. Editing a single upstream member (e.g. one .blend file changing render’s output for "outro") rebuilds only the mux member that depends on it.

Same-probe precondition (v1). Consumer and producer MUST iterate the same probe. The [in] index indexes into the producer’s member map using the consumer’s current item key; if the two recipes are driven by different probes, the key might not exist. Cross-probe joins via declared alignment keys are a tracked follow-up and MUST NOT be attempted in this version. A conforming implementation that detects a same-probe violation at register time SHOULD emit a diagnostic; otherwise the missing-key lookup produces a register-phase error at cook.dep_output_member.

Fan-out body only. $<NAME[in]> MUST NOT appear outside a gather <probe> fan-out body. A conforming implementation MUST reject it with a load-time error if no gather <probe> driver is present in the recipe.

Bracket index (CS-0137). The only valid bracket content in v1.0 is the literal in — the current member binding, the same binding $<in> reads (§ 8.2). A conforming implementation MUST reject the pre-v1.0 empty-bracket spelling $<NAME[]> with a did-you-mean diagnostic containing “$<NAME[]> was respelled $<NAME[in]> in v1.0”, and MUST reject any other bracket content ($<NAME[key]>, $<NAME[in.FIELD]>, …) with a diagnostic noting that member-field joins are not part of v1.0. The bracketed-index position is reserved so 1.x keyed joins can be admitted additively. $<NAME[in]> where NAME is not a recipe in scope is a load-time error naming the unknown recipe — a trailing bracket group never falls through to env-var lookup.

Relation to $<NAME>. Both forms record a recipe-level dependency on NAME. $<NAME> substitutes the full output list (useful in a many-to-one step that needs all of NAME’s artifacts); $<NAME[in]> substitutes only the current-member output (useful in a fan-out step that needs to stay per-member). The two forms MAY coexist in the same Cookfile but MUST NOT coexist in the same step unless NAME differs between the two placeholders. Bare $<NAME> keeps its single uniform meaning in every position — all of NAME’s terminal outputs, space-joined — including inside a fan-out body whose recipe iterates the same probe as NAME. An exact colon-qualified recipe name uses that same spelling: $<dotnet:build> names dotnet:build when that recipe is in scope; otherwise the colon form remains a probe reference. There is no context-dependent narrowing of the bare form to a per-member value and no rejection of the bare form inside fan-out bodies; per-member access is always spelled explicitly with [in]. See § C.6.27.

Example 8.9.1

probe scenes
    json { echo '[{"id":"intro"},{"id":"outro"}]' }

recipe render
    gather scenes
    cook "build/$<in.id>.silent.mp4" { ffmpeg -i $<in.id>.blend $<out> }

recipe tts
    gather scenes
    cook "build/$<in.id>.wav" { tts $<in.id> $<out> }

recipe mux
    gather scenes
    cook "build/$<in.id>.mp4" { ffmpeg -i $<render[in]> -i $<tts[in]> $<out> }

For member {"id":"intro"}, mux builds build/intro.mp4 by combining build/intro.silent.mp4 (from render) and build/intro.wav (from tts). For member {"id":"outro"} the same pattern repeats independently. Only the outro member’s units are dirty when outro.blend changes — intro.mp4 is unaffected.

Note 8.9.1 — Conformance fixture

standard/conformance/positive/recipe-member-accessor pins this surface. The parse.txt golden confirms that $<render[in]> and $<tts[in]> are preserved verbatim in the ShellBlock strings at the parser layer; the codegen layer (CS-0098, respelled by CS-0137) lowers them to cook.dep_output_member calls. Two negative fixtures pin the CS-0137 rejections, both codegen-phase: standard/conformance/negative/cs0137-recipe-member-empty-index-respelled (the pre-v1.0 empty-bracket spelling MUST be rejected with a did-you-mean naming the [in] respelling) and standard/conformance/negative/cs0137-recipe-member-nonliteral-index (any bracket content other than the literal in MUST be rejected with a diagnostic noting member-field joins are not part of v1.0).