Modules¶
A PCL module is a named record. The composed root of a program is a record of modules; the IR layer evaluates it via CIA (Conflict-aware Inheritance with Arbitration) and produces, for each field, its winning contribution plus a provenance chain.
Definition¶
A module is module NAME { field = expr … }. Fields are typed by the checker; values can be:
- literals (
"foo",42,true, lists, records); - references to other modules' fields (
Other.field); - conditionals (
if cond then a else b); Setting<τ>(value, priority)for prioritised contributions (see scenarios below);- secret references (
{from_env="X"}or{from_file="P"}).
Cross-module references¶
Modules reference each other's fields by ModuleName.field:
module Caddy {
pname = "caddy"
version = "2.9.1"
…
}
module CaddyService {
package = Caddy # whole record
bin = Caddy.binary # one field — frontend canonicalises
}
The lower-er emits IrRef "Caddy__binary" (modules' fields are flattened into the top-level bindings table under <module>__<field>). The evaluator resolves this through normal substitution; there's no special "module import" mechanism. Cross-service refs are cross-module refs.
Whole-tree composition¶
PCL has no import. Every .pcl file passed to punix check / punix build is composed into one program. Modules from different files share the same flat namespace (<module>__<field>).
punix check pkgs/ # composes every *.pcl file under pkgs/
punix check pkgs/foo.pcl pkgs/bar.pcl # composes both
If two files both declare module Hello, the type checker raises a located [E3] (conflicting definitions). To layer overrides cleanly, use scenarios (below).
Scenarios¶
A scenario is a named delta that augments the base modules. Useful for "dev vs prod," "demo vs real," "force this version everywhere":
module Db {
port = 5432
host = "localhost"
}
scenario Prod {
Db.host = "db.prod.example"
Db.port = Setting<Int>(5433, priority = 100)
}
punix build --scenario Prod evaluates with the delta applied. CIA arbitration handles overlapping contributions: a Setting with higher priority wins; same-priority is a located [E4] (conflict).
Scenarios are first-class; they're part of the canonical derivation. Two builds with different scenarios produce different store paths (because the resolved values differ).
Required slots; option¶
module Database {
option dsn: Str # required, no default
option timeout: Int = 30 # required-with-default
}
option ℓ: τ declares a required slot. If no contribution provides it when the composed root is type-checked, the result is [E3] (required ∅).
Overriding a module you did not write¶
A contribution can target any option in the composed tree, including one declared by a package you synced rather than wrote. That is how you get the official collection, but with one package changed without forking it. Compose one tree that holds both:
mytree/
official/ # the collection, as a real directory (a copy or a checkout)
overrides.pcl # your deltas
module MyOverrides {
ripgrep.version = force "14.1.1"
ripgrep.hash = force "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e"
}
punix check mytree
punix why mytree ripgrep.url
# ripgrep.url = 'https://github.com/BurntSushi/ripgrep/archive/refs/tags/14.1.1.tar.gz'
A command takes one path, and the loader composes every *.pcl beneath it. A symlink to the collection is not walked, so the copy has to be real; that is the one sharp edge here, and it surfaces as [E2] contribution to unknown module.
Every corpus recipe declares exactly three options, and they are the whole surface you may override:
| Option | Why it is overridable |
|---|---|
version |
which upstream release this is |
url |
where the bytes come from (a fork, a mirror, a different host) |
hash |
the digest those bytes must have |
deps, recipeArgs, recipe and the rest are derived members, and a contribution to one is a located [E4]. They are how the package happens to build this week; an overlay reaching into them would break on the next corpus update.
Note what the example does not set. A corpus url is written f"https://…/{version}.tar.gz", so it follows the version on its own: overriding version alone fetches the new tarball, and a stale hash then fails at the integrity check with both digests named. Where a URL cannot follow its version (a git repo, a pip wheel, a commit-hash tarball), overriding version by itself is refused at punix check time, before anything is fetched:
overrides.pcl:2: error: [E16] 'bash.version' is overridden but 'bash.source' does not
follow it; this would build the old source under the new version
official/bash.pcl:6: note: the source is declared here and never reads 'version'
overrides.pcl:2: note: contribute 'bash.hash', 'bash.url' too, or derive it from the
version with f"…{version}…"
The failure [E16] exists to prevent is the silent one: fetching the old tarball, matching the old hash, building, and storing those bytes under the new version's name.
What's in scope of what¶
The composed root is one big record. Every module is in scope of every other module within the same punix check. There's no "where to put this file" question; put it anywhere under the directory you pass to punix.
Where in the code¶
src/punix/frontend/parser.py: module/scenario syntax.src/punix/frontend/types.py: ScopeType +[E1]–[E6].src/punix/frontend/lower.py: module flattening into the IR's binding table.src/punix/ir/evaluator.py: CIA arbitration.
Related¶
- Overview: the language at a glance.
- Recipes:
recipe = "std.…"registry. - Concepts: content-addressing: what flows from modules into the canonical derivation.