§ 12 — Modules
12. Modules
Normative. This chapter defines the
usedeclaration, module resolution, the load-time lifecycle, and the catalogue index that points at Part IV. The Lua API callcook.load_modulethat implements resolution at runtime is defined in § 24; this chapter specifies what the surface declaration means and when module code runs.
12.1. The use declaration
A use declaration has one of two forms (App. A.2; the lexical and syntactic rules are restated in § 5.1):
- the name form,
use <name>, where<name>is aLUA_IDENTwritten bare or quoted; - the path form,
use <path>oruse <alias> <path>, where<path>is atree_pathending in.luaand<alias>is aLUA_IDENT.
The two forms are disjoint by construction, not ordered by precedence: App. A.2 selects the path form if and only if the last argument ends in .lua, and a <name> admits no . at all, so no argument can be read both ways and there is nothing for a precedence rule to arbitrate. [amended CS-0206]
A conforming implementation MUST resolve a name form by applying the following steps in order, with the first matching step winning:
- If
<name>designates a built-in module (§ 12.4), the built-in module is selected. - Otherwise, the implementation MUST attempt to resolve
<name>as an installed local module by the lookup algorithm of § 12.5.
A conforming implementation MUST resolve a path form by selecting the file at <path>, resolved relative to the declaring Cookfile’s directory, subject to the containment rule of § 12.2. The path form consults neither the built-in set nor the installed package tree.
A conforming implementation MUST reject a Cookfile for which resolution does not succeed, with a diagnostic that names the argument and indicates which locations were searched. The resolution step MUST occur before any recipe body executes; failure to resolve is a load-time error, not a run-time error.
The result of resolution is a Lua table bound in the Cookfile’s Lua environment under an identifier that a conforming implementation MUST derive as follows:
- Name form — the identifier is
<name>with each ASCII hyphen (-) replaced by an underscore (_). All other characters admitted by the name production pass through unchanged. - Path form with an explicit
<alias>— the identifier is<alias>, which App. A.2 already constrains to aLUA_IDENT. - Path form without an alias — the identifier is
<path>’s final segment with its.luasuffix removed, put through the same hyphen-to-underscore rewrite. A conforming implementation MUST reject a declaration whose derived identifier is not aLUA_IDENT, with a diagnostic that names the derived identifier and directs the author to the explicit-alias form. [amended CS-0206]
A conforming implementation MUST reject a Cookfile in which two use declarations bind the same identifier to different targets, with a diagnostic naming the identifier and both source lines. Two declarations that name the same target are not a conflict: § 12.3.2 already makes the second a memo hit. [amended CS-0206]
A conforming implementation MUST make this binding visible to every subsequent lua_line, lua_block, cook_step body, test_step body, chore body, probe produce body, top-level module_call (§ 4.4), recipe-body module_call (§ 8.1), and register_block body (§ 5.4) in the same Cookfile. [amended CS-0205]
A module MAY expose a function named init on its returned table. If present, init MUST be called exactly once immediately after the module’s Lua source has been executed, before any recipe body in the enclosing Cookfile is registered. The full lifecycle contract for init() and for module loading is in § 12.3.
Example 12.1.1
use cpp
cpp.bin("colorize", { sources = { "src/colorize.c" } })
The declaration use cpp binds the identifier cpp in the Cookfile’s Lua environment to the table returned by .cook/modules/share/lua/5.4/cpp.lua (or .cook/modules/share/lua/5.4/cpp/init.lua). The top-level cpp.bin(...) call is dispatched as a top-level module_call (§ 4.4) — a register-phase expression that runs against the same register-phase Lua VM as a register_block body.
Example 12.1.2
use ./build/helpers.lua
use fmt ./tools/lua/code-formatting.lua
recipe check
> helpers.assert_clean()
> fmt.run()
The first declaration binds helpers, derived from the basename. The second binds fmt explicitly; its file’s basename would have derived code_formatting, which is a legal identifier, so the alias here is a preference rather than a necessity. Both files are ordinary tracked source in the project, reached where they live.
Note 12.1.1
The hyphen-to-underscore rewrite predates CS-0035 and was written for a use_name drawn from BARE_IDENTIFIER, which permits internal hyphens where Lua identifiers do not. CS-0035 then narrowed the name to LUA_IDENT and rejected hyphens outright, which left the rewrite describing a spelling no conforming Cookfile could express — a rule with no reachable input, reported as COOK-436.
CS-0206 makes it reachable rather than deleting it. A path form’s derived identifier comes from a file basename, which passes through no name production and is under no obligation to be a Lua identifier: use ./build/my-helpers.lua binds my_helpers, and the file on disk keeps its own name, unrewritten. That is the same rule the name form was always supposed to state, now applied where it has an input. The name form’s clause is retained for a future name production that admits hyphens; today it is a no-op there, and this note says so rather than leaving a reader to discover it.
Note 12.1.2
The list above names the execute-phase bodies individually because an implementation that generates code per body kind will bind the alias per body kind, and one omitted kind is a silent hole: for a year the reference implementation bound the alias in a chore body and in no other execute-phase body, so use greet followed by test >{ greet.say() } failed with attempt to index a nil value (global 'greet') (CS-0205). “Visible” is a property of each body, not of the Cookfile.
Visibility binds per body, but it is not a requirement to evaluate the module for every body. An implementation binds the alias as a Lua local, which only code that names the alias lexically can reach, so a body that names no alias cannot observe whether one was bound and an implementation MAY skip it there. § 24.2 states that option and the conditions on it.
12.2. use scope is lexical per Cookfile
A use declaration’s alias is scoped to its declaring Cookfile and is not visible across import boundaries. Each Cookfile in a workspace defines its own use-resolution scope. The aliases established by use foo in Cookfile A are not in scope for any Lua evaluated from Cookfile B’s recipes, and vice versa, regardless of import direction.
For an execute-phase work unit, “the calling work unit’s source Cookfile” (§ 24.2) is the Cookfile that contained the recipe whose body unit is being evaluated. A conforming implementation MUST resolve cook.load_module(name) and require(name) against that Cookfile’s own module tree and MUST NOT fall back to a sibling Cookfile’s.
A conforming implementation MUST resolve a name-form cook.load_module(name) at register phase, and MUST configure package.path and package.cpath for each execute-phase work unit, such that the calling Cookfile’s installed-module tree at .cook/modules/ is searched in the following order: (1) .cook/modules/share/lua/<lua-version>/?.lua; (2) .cook/modules/share/lua/<lua-version>/?/init.lua; for native modules: .cook/modules/lib/lua/<lua-version>/?.<so-ext>. <lua-version> is the embedded Lua’s MAJOR.MINOR (currently 5.4). <so-ext> is the platform’s loadable-module extension (.so on Linux/macOS, .dll on Windows). [amended CS-0207]
Until CS-0207 this list had four .lua candidates and two native ones, led in each case by a hand-vendored top level — cook_modules/?.lua, cook_modules/?.<so-ext> — whose whole purpose was to let a project shadow an installed rock by dropping a file beside it. § 27.1.2 built a workflow on that precedence and CS-0207 withdrew both. The capability is subsumed rather than lost: an author who needs a patched module points a path-form use (§ 12.1) at their own file, which names the file it means, where shadowing by search order left no record in the Cookfile that it had happened. With the top level gone there is no precedence question left for this section to specify.
A conforming implementation MUST also configure package.path and package.cpath (following the same search order) on the register-phase Lua VM before executing the body of any module loaded via cook.load_module. This ensures that sub-requires within a multi-file module (e.g., require("cook_cc.toolchain") inside cook_cc/init.lua) resolve against the project’s installed-module tree rather than the system Lua paths. A conforming implementation MUST allow C extension modules (.so/.dll) to be loaded during register-phase cook.load_module evaluation; the register-phase VM MUST NOT restrict native module loading. [amended CS-0066]
A conforming implementation MAY expose cook.load_module(name) on the execute-phase Lua VM as an extension over the register-phase requirement above; when exposed, the execute-phase cook.load_module MUST honour the same resolution order, and the same path-form rules of § 12.2.1. Recipe-body Lua scripts that call cook.load_module(name) directly therefore see the same set of candidate paths as register-phase callers, eliminating “works at register but not at execute” surprises. [amended CS-0069]
Cross-Cookfile composition is at the recipe level via qualified name references (§ 11.3), not at the module level. The cook runtime treats each Cookfile’s module tree as an independent search root, and a Cookfile shares module code with another only within its own subtree, by the path form of § 12.2.1. Until CS-0206 this paragraph directed an author to “a vendored copy, a symlink, or a shared parent”; a vendored copy is a second file to keep in step with the first, and a symlink is a vendored copy an author cannot see. The path form is the sanctioned answer, and the containment rule states how far it reaches. [amended CS-0206]
12.2.1. A path-form use is confined to the declaring Cookfile’s subtree
Normative (CS-0206). A path form’s
<path>is resolved relative to the declaring Cookfile’s directory. A conforming implementation MUST reject a<path>that is absolute, that begins with the//workspace-root sigil, or that contains a..segment, and MUST reject a<path>whose resolved location — after following any symbolic link — lies outside the declaring Cookfile’s directory subtree. The same obligations apply to a path argument reachingcook.load_moduledirectly (§ 24.2), which the surface check cannot see.
The path form deliberately relaxes what this section otherwise forbids: it lets a Cookfile reach a Lua file that is in no package directory at all. What it does not relax is locality. The containment boundary is the declaring Cookfile’s own subtree — exactly the region this section already treats as that Cookfile’s own — so a Cookfile still cannot reach a sibling’s files by any spelling.
The boundary is chosen, and the // sigil that import_path admits is refused, for a reason that is not stylistic. § 17.1.1.3 keys a work unit on the resolved path of every module it loaded, and those paths must be recorded in a form a different machine can reconstruct against its own tree — a host-absolute path folded into a shared key names a location that machine does not have, which is exactly the toolchain identity § 17.1.1 forbids an implementation from inferring. The only path form that reconstructs is one relative to the unit’s own working directory. A module reached across that boundary therefore cannot be keyed, and an implementation that admitted the reach would have to choose between refusing the load and serving a shared cache entry composed without the file it ran. Refusing the spelling is the same decision, taken where the author can see it rather than in a cache the author cannot. See § C.11.0.
Two Cookfiles can still reach one file — a Cookfile and a Cookfile in its own subdirectory may each name it — and when they do, Example 12.2.1’s rule is unchanged: the load is keyed (working_dir, target), so the same source file evaluated from two Cookfiles is two module instances with independent state.
Example 12.2.1
ws/Cookfile: import b ./b use cpp # ws/.cook/modules/share/lua/5.4/cpp.lua recipe top cpp.plan("a") # register-phase > cpp.assemble("a") # execute-phase, same alias b.build # qualified dep on B's recipe end
ws/b/Cookfile: use proto # ws/b/.cook/modules/share/lua/5.4/proto.lua recipe build proto.regen() > proto.verify() endtop’s body unit sees cpp (only). b.build’s body unit sees proto (only). Neither sees the other’s modules. Filesystem-level sharing — for example, two Cookfiles vendoring the same cpp.lua — is independent: each Cookfile’s worker VM caches its load by (working_dir, name), so the same source file evaluated from two Cookfiles is two distinct module instances with independent state.
12.3. Module lifecycle
This section specifies when module code executes and what scope it observes.
12.3.1. Load order
A conforming implementation MUST execute module code in the following order, for each Cookfile in a workspace, before any recipe body of that Cookfile registers:
- For each
use <name>declaration (§ 12.1) in source order, resolve<name>against the search paths of § 12.5 and § 12.4. The first hit wins. - Execute the resolved module’s top-level chunk on the calling Cookfile’s register-phase Lua VM. The chunk MUST return a Lua table; a chunk that does not return a table is a load-time error. The returned table is bound under the alias derived from
<name>per § 12.1. - If the returned table has an
initfield bound to a function value, callinit()exactly once on the register-phase VM. Theinit()call MAY readcook.probes(§ 24) and MAY call register-phase Cook Lua API (§ 22). - Repeat for the next
use <name>declaration in source order. A laterusedeclaration’s module body MAY observe state established by an earlierusedeclaration’sinit().
Module code MUST NOT register work units during top-level chunk execution. Work-unit registration is the responsibility of recipe bodies; modules typically expose functions (e.g. target makers) that recipe bodies call.
12.3.2. Per-VM caching
A conforming implementation MUST cache the resolved module table by (working_dir, target), where target is the declaration’s module name or, for a path form, its path (§ 12.1). A second use of the same target within the same Cookfile returns the same table without re-reading or re-evaluating the file; the top-level chunk and init() MUST run at most once per (working_dir, target) per Lua VM as a direct consequence of this caching contract. The cache is per-VM: a register-phase VM and an execute-phase VM that both load the same module each independently load the module top-level chunk once.
A name and a path never collide in this key space: a name is a LUA_IDENT and admits no ., while a path ends in .lua (App. A.2). Two different paths that denote one file — a spelling with and without a leading ./, say — MUST be normalised to one key, so that a Cookfile naming the same file twice gets one instance rather than two. [amended CS-0206]
12.3.3. Cycle detection
A conforming implementation MUST detect cook.load_module cycles. A cycle is a re-entrant call to cook.load_module(target) that occurs while an earlier load of the same (working_dir, target) is still in flight on this VM — that is, the earlier call’s module body or init() has not yet returned. On detection, the implementation MUST raise a runtime error whose message begins with module cycle detected: and includes the cycle path rendered as the in-flight module names joined by ->, with the offending re-entered name appended.
Detection MUST survive recoverable errors: when a module’s body or init() raises, the implementation MUST remove that module from the in-flight set so a later retry on the same VM can proceed (and a genuine subsequent cycle remains detectable).
12.3.4. Cross-VM rehydration
When a module’s init() records state into cook.probes (§ 24) during register-phase load, an execute-phase VM that later loads the same module MUST observe that cached state. The execute-phase implementation MAY use in-memory-only storage scoped to the worker’s lifetime (no cross-invocation persistence). [Pinned by CS-0070.]
cook.export / cook.import are register-phase only as of CS-0200 and take no part in rehydration. This paragraph previously required a register-time export to be observable to an execute-phase cook.import, citing CS-0071. That requirement contradicted § 24.5, which permitted “a per-worker scratch store” and set the minimum bar at round-tripping “within a single worker VM” — a per-worker store cannot make a register-time value observable. The reference implementation followed § 24.5 and therefore violated this paragraph for the whole life of both: an execute-phase cook.import returned nil for every register-time export, and the fixture the citation claimed never existed. See CS-0200.
12.3.5. Register-completion callbacks
A module MAY queue work to run once, after every recipe body of the registration pass has been evaluated, by calling cook.on_register_complete (§ 22.10) — from its top-level chunk, from init(), or from a target maker a recipe body calls. § 22.10 is the authoritative definition of the callback’s signature, ordering, and restrictions; this subsection situates it in the lifecycle this chapter defines.
A module’s top-level chunk and its init() function (§ 12.3.1) run before any recipe body in the enclosing Cookfile registers — they are this lifecycle’s start. A callback queued via cook.on_register_complete runs after every recipe body of the pass has registered — it is the lifecycle’s end, the finalize counterpart to init()’s start. Where init() lets a module establish state before any recipe body could depend on it, a queued callback lets a module act on state recipe bodies accumulated while registering — state that, by construction, is not complete until the last body of the pass has run.
12.3.6. Module source is a determinant
Normative (CS-0204). A module is source. Loading one is running its code, so its content decides what the loading body does, and a conforming implementation MUST treat it as a determinant of every work unit whose body loaded it. § 17.1.1.3 states the rule for work units; this subsection states where in the lifecycle the obligation falls, and where it does not.
Execute phase: observe the load. For a work unit body evaluated on an execute-phase VM, the obligation is to observe: a conforming implementation MUST record the resolved path of every module the body loaded, through every door it offers into module code, and MUST NOT substitute the module search directory for the loaded set.
Register phase: already covered, and why. A module that runs at register phase reaches a work unit only through the unit’s DECLARATION — the command text or Lua chunk it emits, the inputs and outputs it declares, the probes it seals on. Every one of those is already a determinant (§ 17.1.1), so a register-phase module change that changes what a unit does necessarily changes the unit’s key, and one that changes nothing about the declaration changes nothing about the unit. A conforming implementation therefore need not observe register-phase loads on a work unit’s behalf, and this is a consequence of the key’s definition rather than an exemption granted here.
12.4. Built-in modules
A built-in module is supplied by the implementation and available without any project-level file. No built-in modules are defined by v0.1 of this Standard; the built-in kind is reserved to allow future extension (§ C.11).
12.5. Local modules
A local module is Lua the project supplies, as opposed to a built-in (§ 12.4). There are two kinds, reached by the two forms of § 12.1, and an implementation MUST NOT resolve one kind through the other’s lookup:
- An installed module is a package placed in the project’s module tree by a package manager and reached by name. The lookup algorithm resolves
<name>against the candidate list of § 12.2, in that order; the first existing file wins. - A path-addressed module is an ordinary tracked file of the project, reached by the path form. There is no lookup and no candidate list: the declaration names the file, and either it exists or resolution fails. It is subject to the containment rule of § 12.2.1.
The two kinds differ in what an author owes them. An installed module is regenerable from a manifest and belongs in the project’s ignore rules; a path-addressed module is source, tracked and reviewed like the rest of the project, and belongs in no ignore rule at all. Before CS-0206 both lived in one directory, which is why no ignore rule for that directory could be correct.
This section previously named two candidate paths — <name>.lua and <name>/init.lua — where § 12.2 normatively specifies the full list. The two had drifted; § 12.2 is and was the authority, and this section now cites it rather than restating it. [amended CS-0206]
12.6. Standard module catalogue
A conforming implementation MAY ship a curated set of blessed modules distributed through the official LuaRocks-backed registry (rocks.usecook.com). The Standard governs each blessed module’s public surface in Part IV (Standard module catalogue, § 27). The current catalogue is:
| Module | Section |
|---|---|
cc | § 28 |
Future blessed modules are added additively to the catalogue without disturbing the resolution rules in this chapter.
12.7. Module-authoring contract
Normative. This section assembles, for the author of a Cook module (Part IV), the obligations that the phase-classified API chapters (§ 22, § 23, § 24) and the probe and catalogue chapters (§ 22.5, § 27) define individually. It introduces no new API surface; it constrains how a module composes the existing surfaces so that a module written against it caches soundly and shares across machines. A module MUST satisfy every MUST in this section in addition to the per-API rules it cross-references.
12.7.1. Phase obligations
A module’s top-level chunk and its init() function (§ 12.3) run on the register-phase Lua VM. During top-level chunk execution a module MUST NOT register work units (§ 12.3.1); a module instead exposes functions — target makers — that recipe bodies call to register work.
A target maker invoked from a recipe body runs at register phase and MAY call the register-phase API (§ 22), including cook.add_unit (§ 22.1), cook.step_group, cook.recipe, and cook.probe (§ 22.5.3).
Code a module records for later execution — a >{ … } body carried on a unit — runs at execute phase. Such code MAY call only the execute-phase API (§ 23) and the both-phase API (§ 24); calling a register-only function from execute-phase Lua MUST raise a runtime error (§ C.6.12). The both-phase API (§ 24) is the only surface a module may call in either phase. A module MAY also queue work to run once every recipe body of the registration pass has registered, via cook.on_register_complete (§ 22.10).
12.7.2. Registering work units
A module registers a work unit by calling cook.add_unit (§ 22.1); that section is the authoritative field reference and a module MUST conform to its field-typing discipline (CS-0127). A module:
- MUST declare a unit’s real file inputs in
inputsand its real outputs inoutput/outputs, so the cache keys on content rather than on an under-declared surface; - MUST carry a determinant a probe produces into the key via
seal(§ 8.4.3.1) — a bareprobesreference (§ 22.5.6) orders the unit after the probe and enables substitution but does NOT re-key the consumer (§ 22.5.7) — never by interpolating a probe value into the command at register time in a form the cache cannot observe; - SHOULD leave the sharing and reproducibility disposition (
sharing,record) to the recipe author’s surface (§ 8.4.3) rather than hard-coding it, unless the module owns a unit that is intrinsically local or non-reproducible.
12.7.3. Registering probes
The sanctioned surface for a module to register a re-observed computation — compiler detection, a package-config query, a resolved toolchain identity — is a probe. A module registers one by calling cook.probe(key, opts) (§ 22.5.3) with opts.inputs declaring its determinants (tools, files, requires; § 22.5.4) and opts.produce a Lua source string — the body of a producing function that the implementation wraps and runs on a worker VM each time the probe is reached (§ 22.5.8), returning a serialisable value (§ 22.5.5).
A determinant a module detects MUST be observable in the probe’s value, since the value is what seal carries into a consuming unit’s key: a tool via a tools declaration, whose resolved-binary content hash is the value itself (or via inputs.tools, with produce composing the identity it needs into what it returns); a file via a files declaration, whose value is the manifest of the matched files’ content hashes (or via inputs.files, with produce reading the file); an upstream probe via inputs.requires, which orders only — an upstream’s value does not fold into its downstream’s value by declaring requires, so a module that wants the upstream’s value composes it explicitly in produce. A module SHOULD name its probe keys "<module-prefix>:<name>" (e.g. "cc:zlib") to avoid collisions (§ 22.5.3).
12.7.4. Reading probe values and dependency outputs
The sanctioned read of a probe value is cook.probes.get(key) (§ 24.4.1); on the execute-phase VM it reads the per-run probe-value store. A $<key> or $<key.field> placeholder in a command desugars to exactly this read (§ 22.5.7), so a module MAY consume a probe either textually in a command or programmatically in a >{ … } body.
A module publishes a target’s transitive-link information with cook.export and reads a dependency’s with cook.import (§ 24.5); it reads a dependency recipe’s outputs with cook.dep_output / cook.dep_output_list (§ 24.7) or the equivalent $<NAME> surface. cook.dep_output / cook.dep_output_list are both-phase (§ 24); cook.export / cook.import are register-phase only (§ 24.5, CS-0200). The register-phase key/value methods cook.probes.set and cook.probes.scope are deprecated (§ 24.4.2); a new module SHOULD use cook.probe for observed values rather than a hand-managed store.
12.7.5. Seal policy
Normative. A module MUST seal a unit only on a deterministic determinant — one whose value is a pure function of declared inputs, such as a lockfile’s content hash or a resolved toolchain’s identity/content hash (obtainable without Lua-side hashing via
cook.tools.id, § 24.9; fold thehashfield, never thepath— CS-0158). A module MUST NOT seal on a value a toolchain can produce nondeterministically — a build timestamp, an embedded absolute temporary path, a randomised build id — because sealing folds that value into the shared cache key (§ 17.1.1) and a nondeterministic value makes the key unreproducible across machines, defeating the cross-machine reuse that § 17 exists to provide.
Every determinant a module seals MUST be expressed as a named probe: the seal surface admits no raw environment or tool reference (§ 8.4.3). An unconsumed environment variable is folded by an ordinary shell probe over it, sealed by name; a toolchain is folded by sealing a tools declaration by name (§ 8.4.3, § 22.5.2). A module registering its own probe has no lower-level surface than this: it declares the probe with cook.probe (§ 22.5.3) and reads what it needs in the produce body, whose return value is what a consumer seals on. A module MUST NOT attempt to fold a machine-inferred determinant — target triple, libc version, locale — that the author did not declare; the engine infers none of its own (§ 17.1.1), and a module that reaches around the declared-determinant model reintroduces exactly the unsound-cache failure mode the single-key design removed.
This subsection constrains a module’s choice of what to seal; the seal surface, its grammar, and its cache-key effect are defined normatively at § 8.4.3 and § 17.1.1 and are not restated here.
12.7.6. Probe-key naming
A probe-value placeholder splits on . to separate the probe key from a field selector ($<key.field>, § 22.5.7). A module that derives a probe key from arbitrary text — a package name, a target label — MUST sanitise every segment to the PROBE_SEG character set (§ 22.5.2) so a stray . cannot be misread as a field selector; this is the sanctioned pattern behind the cc module’s key derivation. A bare probe key is PROBE_SEG (":" PROBE_SEG)*, with no segment-count limit; the quoted-string key form (§ 22.5.2) is the escape hatch for a spelling outside PROBE_SEG.
12.7.7. Publishing a blessed module
A module distributed through the official registry (§ 12.6, § 27.1) is published as a LuaRocks rockspec served from rocks.usecook.com. A blessed module:
- MUST version its rockspec revisions monotonically in the LuaRocks
X.Y.Z-Rscheme, bumping the revisionRfor a repackage and the versionX.Y.Zfor a surface change; - MUST reflect any change to its public surface in the module’s Part IV catalogue section (§ 27) under a Standard change entry (App. E) before or alongside the implementation, per the spec-first rule of § 0.6;
- SHOULD pin its source with a
git+httpsURL and omitsource.dirso the rendered index resolves the module verbatim.
The distribution mechanics — the .cook/modules/ search paths a published module lands in — are § 12.2 and § 27.1.1; this subsection governs the publishing contract, not the Lua API.
12.7.8. Minting recipes
A recipe’s name, and the fact that it is invocable, belong to the author of the Cookfile. A module is ordinarily a step contributor: it exposes target makers that a user-written recipe body calls to register work units (§ 12.7.2), and the recipe those units belong to is one the user declared. A module SHOULD NOT call cook.recipe (§ 22.3) to mint a recipe the user did not ask for.
This is a categorical MUST, not merely a SHOULD-NOT, for the ordinary case: a single-target maker — a target maker whose call contributes the units of exactly one target — MUST be a step contributor. It MUST be invoked from within a user-written recipe body, from which it derives that recipe’s identity via cook.recipe_name() (§ 22.8), and it MUST NOT itself call cook.recipe. Only the two shapes below, neither of which is a single-target maker, are exempted from this MUST.
Exports are orthogonal to this rule. Nothing in this subsection touches cook.export / cook.import semantics (§ 24.5): a target’s exported transitive-link surface is a property of the target’s own options, independent of which recipe owns the unit that produced it. A step contributor exports under the enclosing recipe’s identity exactly as a minted recipe would — who owns the recipe and what the recipe exports are separate questions, and this subsection answers only the first.
Two shapes legitimately require a module to mint recipes:
- Data-driven fan-out — one call mints N recipes derived from data the module parses at register time, where neither N nor the names are knowable to the Cookfile author in advance (for example, one build recipe per member of a workspace manifest).
- Support recipes — a top-level configuration call whose work has to live in some recipe, where no user-declared recipe is its natural home (for example, rendering a generated configuration header).
A module that mints a recipe under either shape:
- MUST pass an
originin thecook.recipemetadata table (§ 22.3), so that a listing surface can attribute the minted name to the module that produced it (§ 22.7). The conventional value is the module-qualified name of the minting function — for example,"cook_pnpm.workspace"for the fan-out shape,"cook_cc.config_header"for the support-recipe shape; - MUST document that the call mints recipes, and how the minted names are derived, in the module’s Part IV catalogue section (§ 27) — or, for a module outside the catalogue, in that module’s own documentation.
The annotation is attribution, not a restriction. A minted recipe is an ordinary member of the discovered set and remains dispatchable by name like any other (§ 22.7); a module author who regards a support recipe as an internal detail conveys that through the annotation and the documentation this subsection requires, not by expecting an implementation to refuse the invocation. See § C.21.10.
Scope: recipes, not chores (CS-0176). Everything above is scoped to recipes. A module MAY register a chore of its own through cook.chore (§ 22.12) without any of the shapes above applying, subject to that section’s namespace rule and registration window.
A module’s identity, for the namespace rule (CS-0206). That namespace rule checks a chore’s first segment against the module registering it, so a module needs a name of its own — a fact about the module, not about what a use declaration bound it to. A conforming implementation MUST take a name form’s identity to be its <name>, and a path form’s to be the final path segment with its .lua suffix removed. The hyphen rewrite of § 12.1 is NOT applied: that rewrite exists to produce a Lua local, and this is the module naming itself.
The three questions a use declaration raises about a target have three different answers, and an implementation that collapses any two of them is wrong in a way that is hard to see. The alias is a Lua local, so my-mod.lua binds my_mod. The identity is the disk name, so the same module is my-mod. The load key (§ 12.3.2) is the whole target, because two files with one basename in two directories are two modules. A path-form module whose identity were taken to be its whole path could register no chore at all: every prefix it could legally claim would contain a /, which no chore name may.
The distinction is not a loophole in the ownership rule but the reason the rule exists. What the rule protects is the author’s build: a recipe is a node in the DAG, and minting one silently changes what cook does — what gets built, what it depends on, what the cache covers. A chore changes none of that. It is uncached, absent from the DAG, and does nothing at all until invoked by name; a module that registers one has added a command the author may run, not work the author’s build will do.
The namespace prefix cook.chore requires is what makes this hold rather than merely assert it. A module-registered chore is invocable only under a name that identifies its module, so an author reading cook cc.add lib math can see both that the verb came from a module and which one. The author’s own namespace — the undotted names, the ones they wrote in recipe and chore headers — remains theirs alone, and a module cannot register into it. Ownership is preserved by partitioning the namespace, not by forbidding the registration.