WEP: Package File Exports — Assets and Submodules as API
Context
A Wado package ships more than its entry module. package-gale-highlight-wado
carries grammar/Wado.g4 and grammar/Wado.highlights.scm; another package
carries a .proto, a JSON Schema, a .css, an icon. Inside the package those
files are ordinary Kiln inputs and #include_str arguments. Outside it they do
not exist:
- A Kiln
from/inputspath must start with./or../and resolves against the declaring file (Kiln §"Use-site syntax"). Nothing names a file inside a dependency. - A module specifier names a package and resolves to its
[package].libentry; it "carries no interface segment" (Package and Module Specifier Syntax), and no file segment either. A package's other.wadomodules are unreachable the same way its.g4is. #include_strresolves relative to the including file (Compile-Time File Inclusion).wado publishuploads a component and nothing else, so for a registry dependency there is no file on disk to reach even if the syntax existed.
A package cannot say which of its files it offers, and a consumer cannot name one. Neither half is useful alone: a reference reads as a filesystem path, so adding one without the list would make a package's directory layout its API by accident.
Decision
One allowlist, both kinds of file
A package declares the files a consumer may name. Everything else is invisible, whatever the directory contains.
[package]
name = "gale-highlight-wado"
lib = "src/lib.wado"
exports = [
"grammar/Wado.g4",
"grammar/Wado.highlights.scm",
"src/highlight.wado",
]
One list covers assets and .wado submodules alike. What differs between them
is what a consumer does with the file: import it as a module, feed it to a
generator, inline it. The package promises the same thing either way, and its
owner decides, exactly as pub and export decide for an item.
The key is exports, after npm's field of the same name and meaning. It sits
beside the export visibility keyword without colliding: export marks one
item as crossing the CM ABI, exports lists the files a package offers, and no
position accepts both.
A refusal names the package, not the path. Reaching for an unlisted file answers
"package X exports no file Y" whether or not the file is there, so the list
never doubles as a directory listing.
Naming an exported file
"<package-specifier>/<path>"
The path is relative to the package root (the directory holding its
wado.toml), forward-slash separated, and carries its extension:
use { highlight } from "wado-lang:gale-highlight-wado/src/highlight.wado";
use { Parser } from "lib:gale-highlight-wado/grammar/Wado.g4" with {
generator: { module: "lib:gale" },
};
let template = #include_str("wado-lang:my-pkg/templates/index.html");
- A specifier with no path segment keeps its current meaning: the package's
[package].libentry. The extension separates the two forms, so it is required rather than conventional. It also keeps a file reference from reading like a WIT interface segment. - Every specifier form that can carry a package coordinate carries a path the
same way: an open coordinate (
ns:pkg[@ver]) and alib:nickname alike. ..and absolute paths are rejected, not resolved. The path is a key into the allowlist, not a traversal that happens to start at the package root.- The reference resolves against the consuming manifest's own
[dependencies]/[build-dependencies], like every other specifier. There is no path into a transitive dependency.
This revises §"Specifier forms" of
Package and Module Specifier Syntax:
a specifier names a package, optionally followed by one exported file. Its
prohibition is on interface segments, and that one stands. An interface is
selected in the use { … } list, and a file is not an interface.
The allowlist is API
- Adding an entry is additive. Removing or renaming one breaks consumers, like
removing an
export fn. wado publishships every listed file, and refuses a list naming a file the package does not carry. The publisher sees that failure, instead of a consumer months later.- A source dependency (path / git) serves the files from its checkout. A
registry dependency carries them in the
wado:packagesection (Provider Metadata), which grows an asset area beside the.wadosources it already carries. Consumer selection stays all-or-nothing: a dependency consumed through the CM path has no files to offer, and says so.
An exported submodule offers its pub items
pub already means "public API"
(Visibility); the
allowlist decides which files carry it. A consumer that names an exported
submodule may use every pub item in it, whether or not the lib entry
re-exports that item. internal stays package-local, and a file the allowlist
does not name offers nothing.
A pub item that no exported file reaches is therefore public in name only. The
compiler says so, as a lint in the unused family: the declaration claims an API
the package does not offer, and the fix is to export the file or drop the item
to internal.
Paths compare after NFC, and case must match
A reference and an allowlist entry are compared after NFC normalization, so a
name that round-trips through a decomposing filesystem still matches. Case is
not folded: grammar/wado.g4 does not name grammar/Wado.g4, on any
filesystem. A case-insensitive filesystem would otherwise let a package build on
its author's machine and fail on a consumer's, or the reverse.
A case-only mismatch is an error like any other unlisted file. Naming the near match in the diagnostic is better ergonomics; it does not make the reference valid.
Kiln
fromandinputsaccept an exported-file reference wherever they accept a./path today.- The invocation's identity records the logical reference
(
ns:pkg@ver/path), never where the file sits on this machine. That covers the cache key, the dedup tuple, and thesourcesof the#![generated]header. A dependency's checkout lives under$WADO_ROOTat a version-dependent path, so hashing that path would make the cache machine-dependent and break the committed-cache workflow (Kiln §"Caching"). - Generated files land in the consuming project's output tree, exactly as they do for a local schema. A dependency's checkout is shared between projects and is never written to.
- Kiln's design principles hold unchanged. Every input is still enumerated literally at the use site (principle 2), since a package coordinate in front of the path does not make the set dynamic, and the generator still receives its inputs by value under the same sandbox (principle 1).
- Naming a dependency's
.g4yourself means running the generator yourself, with your options. A package that wants to own its generation instead runs Kiln on its own files and exports the resulting module.
A dependency's own invocations follow the cache rule
A package may generate from its own assets and export what comes out. Its
clauses then have to fire in a consuming build, since the loader meets that
package's use { … } from "./grammar/Wado.g4" while compiling it. Clauses are
therefore harvested across the dependency edge at any depth, and each one is
resolved by the rule Kiln already applies to a local invocation
(Kiln §"Caching"):
- A recorded output travels with the package: the generated
.wadofiles and the<primary>.kiln.jsonbeside them. The consuming build uses them and resolves no generator at all. - Nothing recorded, and the consuming build runs the generator: it resolves it
through the dependency's
[build-dependencies], and writes the output into the consuming project's tree.
Which arm a package takes is the one it already chose with output_dir: a
tracked directory commits the output and ships it, the default gitignored one
does not. The boundary adds no switch of its own.
A recorded output is trusted, not verified. Re-deriving its cache key means
resolving and hashing the dependency's generator, which is the work the record
exists to avoid. Consume-only mode already trusts an artifact on that reasoning.
Keeping the record fresh is the dependency's own wado check.
Both arms rest on the sandbox. Running a generator the consumer never named is acceptable because a generator is a pure function of its inputs, with no clock, no network, no filesystem and no environment (principle 1). Reusing a recorded output is acceptable for the same reason: purity is what makes the recorded bytes reproducible rather than a snapshot of someone's machine.
What goes wrong inside a dependency is reported against that dependency: the
package coordinate and its version, whatever the generator failed on. A path
under $WADO_ROOT names a directory the user did not create, cannot edit, and
will not see again after a version bump. The coordinate is what tells them which
package to pin, patch, or report to.
Deliberate omissions
Glob patterns, in the allowlist as much as at a use site. A pattern would spare a package with many assets some typing, and would cost the list its whole point: what is offered would depend on what happens to sit in the directory, so a file dropped in becomes API and a file renamed silently stops being it. Naming each file is the mechanism, not the ceremony around it.
Directory listing, already refused for local Kiln inputs. Writing into a
dependency. Naming a file of a dependency's dependency: a reference resolves
against your own [dependencies], and a package you did not declare offers you
nothing. (Its own generation still runs, at whatever depth it sits — that is the
package's business, not a file you named.)
Files from a reserved namespace. core: / wasi: are bundled in the
compiler and have no wado.toml to carry an allowlist, so they export nothing.
Nothing rules it out later — a bundled allowlist would be a table in the
compiler — but no need has come up, and the specifier rule is easier to state
without the exception.
Roadmap
[package].exportsinwado-manifest: parse, and validate each entry as a package-root-relative path with an extension and no.., NFC-normalized. Done when a manifest round-trips it andwado publishrefuses a list naming a file the package does not carry.- Specifier resolution: split
<coordinate>/<path.ext>once (kiln::parse_specalready separates the segment), resolve the package through the dependency index, check the allowlist under NFC with case compared exactly, and refuse at package granularity. Done whenuse { … } from "lib:x/src/y.wado"compiles against a path dependency, and an unlisted path produces the package-level diagnostic, a case-only mismatch among them. - Kiln
from/inputs: accept the reference, key the cache and the header on the logical form, write outputs into the consumer's tree. Done whengrammar/Wado.g4can be consumed from a second package with a warm, machine-independent cache. - Harvest across the dependency edge: walk a dependency's modules, anchor each
clause's paths on that dependency's package root as
ns:pkg@ver/path, and key its redirect by the identity the loader gives that module. Done when a consuming build reports a dependency's malformed clause instead of ignoring it. - Consume a recorded output: resolve a dependency's invocation from the
<primary>.kiln.jsonbeside its committed output, as consume-only mode does. Done whenpackage-gale-highlight-wadobuilds as a dependency with no generator run in the consuming build. - Run what has no record: resolve the generator through the dependency's own
[build-dependencies], pin it in the consuming project's lock as part of the transitive build graph, and write the output into that project's tree. Done when a dependency that commits no output builds in a consumer, and a second build of it hits a warm cache. #include_str/#include_bytes: the same reference, resolved through the same path.- Registry distribution: assets and recorded Kiln outputs travel in the
wado:packagesection and are extracted into the shared cache on fetch, soCompilerHost::load_sourceserves them like any other file. - The unreachable-
publint: apubitem that no exported file reaches, reported in the unused family. Done when a package with apubitem outside itslibreach and outsideexportsreports it, and adding the file toexportsclears it.
Known gaps
- Whether a
libentry and an exported submodule that reach the same file resolve to one module identity. Two identities would make one declaration two nominal types, which Module Loader §"Canonical module identity" avoids for local paths; the package boundary has not been checked against it.
