Wado

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 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");

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

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

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"):

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

  1. [package].exports in wado-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 and wado publish refuses a list naming a file the package does not carry.
  2. Specifier resolution: split <coordinate>/<path.ext> once (kiln::parse_spec already 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 when use { … } 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.
  3. Kiln from / inputs: accept the reference, key the cache and the header on the logical form, write outputs into the consumer's tree. Done when grammar/Wado.g4 can be consumed from a second package with a warm, machine-independent cache.
  4. 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.
  5. Consume a recorded output: resolve a dependency's invocation from the <primary>.kiln.json beside its committed output, as consume-only mode does. Done when package-gale-highlight-wado builds as a dependency with no generator run in the consuming build.
  6. 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.
  7. #include_str / #include_bytes: the same reference, resolved through the same path.
  8. Registry distribution: assets and recorded Kiln outputs travel in the wado:package section and are extracted into the shared cache on fetch, so CompilerHost::load_source serves them like any other file.
  9. The unreachable-pub lint: a pub item that no exported file reaches, reported in the unused family. Done when a package with a pub item outside its lib reach and outside exports reports it, and adding the file to exports clears it.

Known gaps

References