Skip to content

Latest commit

 

History

History
309 lines (270 loc) · 20.8 KB

File metadata and controls

309 lines (270 loc) · 20.8 KB

← ROADMAP

Phase 8 — Packages and modules [L] [the foundation the library ecosystem is structured on]

Phase 4 Slice 5 made a file a compilation unit: an import: binds a qualifier to another file, resolution is relative to the importing file with an explicit .sth and no search path, and encapsulation came with it (default private, a per-file export: list, and the Elm-style split between exporting a type name and exporting its constructors). That is enough for personal reuse and not enough to structure an ecosystem: there is no unit above the file, nothing expresses a dependency between two bodies of code, the core / fixed / alloc / hosted layering is a convention about which words someone put in which file rather than a checked property, and a program's most-used words arrive without being asked for.

The model

A package is a directory with a manifest. The manifest declares the package's name, its layer, its dependencies, and which modules it makes public:

package: core ;
layer: core ;
module: text cmp ;

intrinsics is compiler-provided and needs no depends: entry: every package may use it implicitly, so declaring it carries no information. Attempting depends: intrinsics ... in any form is a parse-time error. The per-file import: intrinsics * ; is where the auditability argument applies.

A module is a file, and its name derives from its path within the package. text/ascii.sth is the module text::ascii; nesting is naming, with no separate mechanism behind it. A filename must be a legal module segment, which costs nothing since word names already admit -. Discovery and visibility are separate concerns: files are still discovered by walking the import graph, exactly as they are today, and the manifest's module: list adds nothing to that. What it does is name the modules reachable from outside the package, and its lines accumulate exactly as export:'s do, since a manifest's module: is to a package what a file's export: is to a file. An undeclared module is package-private: importable by name from its siblings, unnameable by a consumer. A package's public surface is normally a hub or two rather than every file, so the list stays short by construction, and a package that finds itself listing many modules is one that wants a hub.

Imports name modules, and that is the only form. There is no path-based import. The target comes first; the qualifier is optional and, when omitted, defaults to the module's last segment:

import: self::text::ascii a ;          \ own package, path-derived name, qualifier a
import: core::cmp ;                   \ dependency package, qualifier defaults to cmp
import: intrinsics * ;                \ every exported name, unqualified, no qualifier bound
import: core::text s | split trim | ; \ dependency, two names plus qualifier

The anchor is syntactic: a self:: prefix names the importing file's own package, package-root-relative. A bare first segment always names a dependency package. There is no inference and no ambiguity: a dependency named text and a local text/ directory coexist fine.

* is only recognized as the wildcard keyword in the position right after the target, with nothing else following but ; — it never appears inside | ... |. This keeps it from colliding with an actual word named * (already in use for multiplication in this symbol-operator style): inside the pipes, every token is an ordinary selective-import name, so import: mod | * | ; selectively imports the word called *, not a wildcard. Wildcard and selective import are mutually exclusive on one import: line; wanting both a wildcard and a bound qualifier for redundant qualified access is not supported, since it added nothing a full wildcard didn't already give.

The qualifier is always bound and is always a single segment, so use sites stay a::decode regardless of how deeply the module is nested; only its spelling in source is optional. A path-based import would bake a dependency's internal layout into every consumer, so moving a file inside a dependency would break them; naming modules is what stops that. Keeping the quoted form for the manifest-less case would leave a second resolution rule alive for one consumer, so it is deleted from the language and the manifest-less case is answered below instead.

Modules re-export, so a package curates its own surface. export: accepts a name the file imported as readily as one it declared, which makes a hub module: core can import intrinsics and re-export the subset it endorses, and text.sth can gather text::ascii/text::utf8 behind one public name. This is also the only renaming mechanism, which is why the manifest carries no path table: a hub that re-exports under a chosen name does the same job in Sooth source, where it is greppable and can be deprecated. Wildcard re-export is not provided; a hub lists what it promises, so a package's public surface stays enumerable for P8.S3's API description.

Nesting names, it does not nest visibility. Importing core::text brings nothing from core::text::utf8, and no intermediate module exists unless it is declared. An aggregate is a hub, per above, so the two features compose rather than overlap.

The intrinsics are a module too. BUILTIN_WORDS (src/check/declarations.rs:63-110, 40 names: the shuffles, the arithmetic, the u-prefixed comparisons, branch, tag, ., fill, len) is reachable only through an import: intrinsics ... line. It is a compiler-provided module, not a package with sources, so intrinsics is one reserved name resolved without a path. The table itself does not move, and has_self_tail_call keeps using it unchanged: only visibility is gated. >-prefixed conversions are claimed by prefix rather than by the table and are gated by the same rule.

Layers are checked. A package may not depend on one in a higher layer, so core depending on alloc is a located build error rather than a code-review observation, and DESIGN.md's "tag every stdlib word with the layer it needs" becomes a field with a rule behind it. Phase 9 builds fixed / alloc / hosted as packages that must pass it, which is what makes that phase's exit criteria mean anything.

A manifest is optional, and resolution falls back three ways. Highest priority: sooth build/run take an explicit --manifest <path> naming the manifest to resolve against, overriding discovery entirely; this is the escape hatch for a named entry file that is not sitting inside its package's own tree (a test harness fixture written to a temp directory, a one-off script pointed at a project without living in it). Failing that, a file's package is the nearest ancestor manifest. Failing that, the user-level manifest at $XDG_CONFIG_HOME/sooth/global_sooth.pkg supplies the depends: a scratch file resolves against. Failing all three, the file is an implicit anonymous package with no dependencies: it can import intrinsics and its own path-derived siblings, and naming any other package is a located error whose remedy is a manifest, --manifest, or the user-level file. A scratch file therefore stays frictionless without a second import form, and the old loophole (a manifest-less file reaching past a package's module: list into a private module) is not policed but unspellable.

--manifest is explicit, so it doesn't reopen the reproducibility guarantee below it. An ancestor manifest and the user-level fallback are both discovered, which is why the latter is barred inside a package; --manifest is named on the command line, so a CI invocation that pins it is exactly as reproducible as pinning the entry file itself. This is what answers dogfood finding F1's cost: the ~460 inline test fixtures can point at one shared manifest via --manifest rather than the harness generating one per fixture.

The fallback never applies inside a package. A file with an ancestor manifest resolves against that manifest and nothing else (an explicit --manifest may still override it, since that is a deliberate act at the call site, not a discovered one), so a package's build cannot depend on machine-local configuration. The same reasoning applies to the test corpus: fixtures must carry, be given, or be pointed at an explicit manifest rather than inheriting a developer's global one, or CI stops being reproducible.

Exit: no word resolves without an import:, including the intrinsics; a program builds against a dependency's module named as pkg::module; a module the package does not declare is unnameable from outside it, and a package not in depends: is unnameable at all; a package declaring a lower layer than its dependency is a located build error. Dogfood: lib/ restructured as packages, with a core package whose hub re-exports a curated subset of intrinsics and whose modules are the typed core, a collections package consuming it by module name, every example and golden importing what it uses, and a deliberate layer violation rejected.

Slices

Ruled by the paper dogfood in P8/dogfood/: manifests land first. Deleting the quoted-path form left single-mode imports with no spelling to migrate the corpus to before a manifest exists — nothing can say core::bool without a depends: table to resolve core against — so the reorder that dogfood finding F1 flagged as forced is now the slice order, not a risk to rule on later. The alternative, reserving core as a compiler-known package the way intrinsics is, was rejected: it re-privileges the standard library this phase exists to de-privilege.

The split is still on whether a manifest is required, it's just S1 that needs one now. Everything manifest-level (S1) has to exist before everything file-level (S2) can migrate the corpus once, to its final form, instead of twice with a red suite in between. S1 itself splits into S1a (the manifest and the checker/resolver work it drives) and S1b (the CLI-level question of which manifest resolves a given invocation): they share the manifest grammar and nothing else, no file and no checker pass, so bundling them would review a new file format alongside main.rs argument parsing as if they were one concern.

P8.S1a — Packages, manifests, path-derived module names, and the layer check. The manifest and its parser, package attribution over the discovered closure, module names derived from paths, the public-module list, cross-package pkg::module resolution, and the three checks (naming a module its package does not make public; naming a package no depends: entry lists; depending on a higher layer). Lands first because S2 has nothing to migrate the corpus to without it. Brief: docs/roadmap/P8/slice1-brief.md. Exit: a program builds against a dependency's module named as pkg::module; a package-private module is unnameable from outside; a layer violation is a located build error. Dogfood: a layered two-package tree with a deliberate violation rejected. lib/'s own restructuring is S2's: a sooth.pkg over lib/ makes arrays.sth's quoted-path import a hard error, so it moves with the corpus migration to module names.

P8.S1b — The --manifest CLI flag and the fallback chain. sooth build/run gain an explicit --manifest <path> flag ranked above discovery, then the nearest ancestor manifest, then the user-level manifest ($XDG_CONFIG_HOME/sooth/global_sooth.pkg), then an implicit anonymous package with no dependencies. This is what lets S2's ~460-inline-test-fixture migration point at one shared manifest instead of generating one per fixture. Brief: docs/roadmap/P8/slice1b-brief.md. Exit: sooth build entry.sth --manifest path/to/sooth.pkg resolves against the named manifest regardless of entry.sth's own directory, unconditionally overriding an ancestor manifest; a manifest-less, flag-less file resolves against the user-level manifest, then falls back to an implicit anonymous package with no dependencies; each fallback tier's failure names its own remedy. Dogfood: the test harness pointing a temp-directory fixture at a shared manifest via --manifest.

P8.S2 — Single-mode imports, the intrinsics module, wildcard import, and re-export. Delete parser::prelude_words and its two injection sites, shrink the mangling exemption to main/drop, gate BUILTIN_WORDS visibility on an intrinsics import, add the bare * wildcard form and re-export through export:, split lib/core.sth into modules with a hub, and migrate every .sth file and every inline test source to module names now that S1a gives them something to name and S1b gives fixtures a manifest to name it against. if's locals lose their if-- prefixes: bodies are checked in their own module's scope now, so the hygiene hack the whole-program environment forced is gone for good. Brief and probe results in docs/roadmap/P8/slice2-brief.md. Probing established that an imported inline combinator splices correctly (qualified and selective), that self-tail-to-loop lowering survives an imported if at 5M iterations, and that an inline poly word over imported comparisons monomorphizes at i64 and f64. It also found the mangling exemption is load-bearing rather than a bare-name convenience: a non-inline polymorphic word can call the prelude's poly < and cannot call an imported one, so deleting the prelude exposes the generic-calls-generic gap. No live corpus word is in that shape, so the slice accepts the narrowing behind a located diagnostic rather than pulling a P7 type-system fix into a packaging slice. Exit: no word resolves without an import:; is_prelude_word_name and parser::prelude_words are deleted; a hub module re-exports an imported word and a consumer uses it; the corpus builds and every golden passes; a non-inline poly word calling an imported poly word is a located error naming the caller, the callee, and the reason. Dogfood: examples/gcd.sth and factorial.sth building with explicit imports, and a core hub re-exporting a curated subset of intrinsics.

P8.S3 — The serialisable API description. "Which words, types, and externs are public" is answered by Phase 4 Slice 5's export: list plus the manifest's public-module list, and answered where it had to be, since a type cannot hold an invariant while its generated setters cross the boundary unchecked. What is left is one thing: a compiler pass that walks the checked AST, filters to the exported declarations of public modules, and emits a file listing every exported signature for the API diff to compare between versions. Bounding that surface by the public-module list is what stops a package-private refactor churning the diff. That is the remaining prerequisite in docs/dependency-management.md, and it is a packaging concern (letting other people depend on you with enforced semver) rather than a personal-reuse one, which is why it waited. Needs P7.S2 (statics) and P7.S3e (bounds), since a global clause on an exported word is part of that word's exported signature. Exit: a published package's API diff correctly classifies a PATCH/MINOR/MAJOR bump across a two-file change. Dogfood: sooth publish --check on a two-version bump of a small library, one that adds a word (MINOR) and one that removes one (MAJOR).

P8.S4 — Richer extern: payloads and unmangled exports. Pulled forward from Phase 12 because the gap is module/linkage machinery, not self-hosting content, and a progressive self-host (see P12) needs it before any stage-by-stage sequencing can even be planned. Two gaps, both currently unaddressed: extern: (Sooth calling out) is almost certainly scoped to flat/scalar C ABI shapes, with no way to pass a struct, array, or tagged union across the boundary; and there is no way for a Sooth module to export a word under its bare, unmangled name, so host-language code can link against it without knowing resolve::mangle's scheme (a fourth exemption category, alongside main/drop/prelude and statics). Exit: an extern: declaration can carry a struct-shaped argument or return, and a module can mark an exported word for unmangled linkage. Dogfood: a small Rust host program that links against a Sooth-compiled module by its bare word names and exchanges a struct across the boundary in both directions.

P8.S5 — Transitive re-export visibility for overload disambiguation. Carved out of P7b.S5's dispatch-correctness fix: is_name_visible_to_module (src/check/word_families.rs:1155) is direct-only (a candidate's declaring module must appear in the caller's own imports/selective map), so a module that sees a name only through a hub's re-export (import: self::lib * ; where lib itself re-exports another module's type) has zero visible candidates for that name under P7b.S5's tiered ctor-overload selection, even though the name is legitimately in scope. P7b.S5 rules this a scoped, accepted error rather than building transitive closure, precisely because the fix belongs here: it needs the import graph's re-export edges (S2's hub mechanism) already resolved, and it likely intersects the standing gap that a hub re-export carries word names but not type names on at least one existing path — the same limitation the REPL already hits from a different angle. Exit: a module reaches a colliding same-shaped generic ctor through a hub's re-export and dispatch resolves it exactly as a direct import would, with no regression to P7b.S5's direct-visibility cases. Dogfood: the P7b.S5 fixture that this slice's absence makes an accepted error, now resolving correctly through the hub.

Declined and deferred, with reasons

A Rust-style source-side mod declaration. Declined. mod foo; exists in Rust as the discovery mechanism, because Rust has no manifest-level listing of sources; Sooth discovers files by walking the import graph and declares its public surface in the manifest, so a source-side mount is a third mechanism for something already handled twice, and it buys the mod/use/pub use confusion with it. The declare-versus-use separation it offers is already present: the manifest declares, import: uses.

Intra-package visibility levels (a submodule visible only to its parent, Rust's private mod). Deferred, and this is what declining mod actually costs. Sooth has two levels, file (export:) and package (module:), with everything inside a package mutually reachable. Revisit when a package is large enough that its internal structure needs defending; none is.

super:: (parent-module-relative) imports. Deferred. self:: is package-root-absolute and already names every module in the package, so super:: adds nothing at the one-or-two nesting levels everything here occupies. It only pays for wholesale subtree moves at three- plus levels of nesting, and it is purely additive with no migration cost when that need arises.

Manifest path tables (module: text::ascii "text/ascii.sth" ;). Declined once hubs existed: a hub re-exporting under a chosen name is already a renaming mechanism, and two mechanisms for one job is the duplication this project avoids. The cost accepted with it is that directory layout is semantically load-bearing inside a package, so renaming a file renames a module. Public surfaces should therefore be hubs, behind which files can move freely.

Wildcard re-export (export: | * | ;). Declined: a hub exists to curate, and an implicit public surface is exactly what P8.S3 has to enumerate.

Quoted-path imports. Deleted rather than kept for the manifest-less case. Once every file inside a package names modules, the path form has exactly one consumer left, and carrying a second resolution rule for it costs more than the three-line manifest a scratch file writes when it wants more than intrinsics. Deleting it also makes the reach-past-the-module-list loophole unspellable rather than merely tolerated.

Listing every module in the manifest. Declined, which is a different thing from the public list the manifest does carry. Discovery is the import-graph walk and needs no listing; the module: entries are visibility only, so what gets written down is a package's promises rather than its contents. That keeps the list proportional to the public surface, which hubs keep small, and it bounds what P8.S3's API diff must treat as public.

A C-ABI export target (emitting a library other programs can link) is codegen work (symbol naming, calling convention, header generation) sharing nothing with dependency resolution. It is also the prerequisite for Rust↔Sooth FFI, which a self-hosted compiler module would need, so both wait.

Semver enforcement itself (the API diff and sooth publish --check) is tooling on top of P8.S3's format, specified in docs/dependency-management.md. Git dependencies are an additive grammar extension to depends:, not a redesign. A REPL exemption in the compiler is moot: the REPL no longer exists (P7.S9), so every file resolves manifests through the ordinary fallback chain above with no special case.