- developer move; all inbound links repointed (root docs, plan/, plan/compiler/, exploration, superpowers plans+specs, in-progress marker), board's own links re-based one level deeper - prose mentions inside landed plans left as historical records Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8.2 KiB
Iteration 17 — library projects and dependency privacy: design
Status: ✅ LANDED 2026-08-20 — implemented as specified; the impact analysis held (driver-only, VM/
.wob/GC untouched). Approved, then parked the same day by directive, then unparked and executed. Decisions were settled in the iteration (four forks + impact analysis); this spec makes them buildable. The plan follows after review. Board: docs/00-status.md.Per repo convention this spec carries concept, reason, and required behavior in words only — no implementation code.
Goal
A project can say it is a library, and a dependency can keep modules to
itself. Concretely: docs/examples/writeonce-framework declares
kind = "library" in its wo.toml, woc <dir> on it typechecks the whole
project with no entry required (the iteration-16 --emit workaround is
deleted), its parser and serve-loop plumbing move under internal/ where
the web-app cannot import them, and just web-app proves nothing public
broke.
Background (the two gaps, from iterations 15/16)
- A project without
fn maincannot be checked: manifest presence forces build mode, which errors "nomainentry point found". The framework is verified today throughwoc --emit— a wart. pubis module-public with no dep-private tier:parse_requestis exactly as importable by the consuming app asHandler. Nothing marks "this module is the library's own business".
Settled decisions (normative, from the iteration)
- Library-ness is manifest-declared: top-level
kindkey, values"program"(the default when absent) and"library"; any other value is a manifest error. - Privacy is Go's
internal/directory rule, applied at the[deps]boundary only: a consumer cannotusea dependency module whose path contains aninternalsegment; inside the dependency the same import stays legal. - Lib+bin duality is allowed: a library may carry an entry-shaped
main;kind = "library"changes only the DEFAULT action ofwoc <dir>. - Inherited from Go, explicitly not checked: an internal type may appear in a public signature. The consumer can hold and pass such a value but cannot import the module to name its type. Library author's smell to avoid, not a diagnostic.
Design
1. The manifest kind key
One top-level key in wo.toml, read where the manifest is already parsed
(the driver's manifest reader in compiler/bin/main.ml). Absent means
program — every existing project keeps its behavior. A value other than
"program" or "library" is rejected with the new WO-E109, naming the
value and the two legal ones. The key is meaningful only in the project
being invoked; a DEPENDENCY's kind is read but not enforced in v1
(iteration 15 already never uses a dep's main, so a program consumed as a
dep already behaves as a library — recorded, not policed).
2. Driver modes
woc <dir>on a program: unchanged — build, requiringmain.woc <dir>on a library: CHECK mode — the full pipeline runs (parse, typecheck, interface satisfaction, borrow/ownership pass, GC inference) over the whole project including its[deps], and stops before image emission. No entry is required. Exit 0 with no output on success, 1 with diagnostics otherwise — a green check must mean exactly what a green build means, minus the artifact. Deps are fetched/locked the same as build mode (a library must be checkable offline once locked).woc build <dir> -o <app>on a library WITH amain: builds the binary — the dual case (demo/self-test). Without amain: the existing no-entry error, extended to name the kind so the message explains itself ("this project is a library; add a main or check it with woc ").--emit, dump flags,--update-deps: unchanged; they never required an entry or already carry their own rules.
3. The internal/ rule
Where: the dep-use resolution step in compile_image — the same place
iteration 15 prefixes dep module paths — because that is the only spot that
knows which file belongs to which project. The rule: a use in a file that
does NOT belong to dependency X, naming a module of dependency X whose
dep-relative path contains a segment exactly equal to internal, is
rejected with WO-E108 at that use, naming the dependency and the module.
Files INSIDE dependency X importing the same module are untouched. Modules
are directory-shaped (module_of_multi: dep name + relative directory), so
"segment" means a path component — framework/internal and anything under
it, never a substring match (a module named internals_x is not caught).
Scope notes, normative: the rule fires only across the [deps] boundary
(decision 3 — dep-boundary-only; Go's subtree rule is a recorded possible
tightening). The root project's own internal/ directories are legal to
import from anywhere inside the root project. Transitive deps stay
rejected by iteration 15's WO-E106, so dep-to-dep imports cannot occur.
4. Diagnostics (error catalog additions)
- WO-E108 — dep-internal module imported across the
[deps]boundary. Points at the offendinguse, names the dependency and the internal module, and says the module is internal to that dependency. - WO-E109 — invalid manifest
kindvalue. Names the given value and the two legal ones. - The no-entry build error gains the library wording described in §2; no new code, better message.
5. Framework reorg (the proof by use)
wo.tomlgainskind = "library".http/parse.woandhttp/serve.womove tointernal/(moduleframework/internalwhen consumed). They are plumbing the web-app never imports: the request parser, the carry-state record, the serve loop, theDispatcherseam.http/types.wo(Req/Resp + builders),router/router.wo(Handler/Middleware/Route/Mw), andapp.wo(App) stay where they are — the public surface does not move.- Intra-framework imports update to the new module path; the web-app
changes NOTHING — it already imports only
framework,framework/http,framework/router. - The framework README's verification note replaces the
--emitworkaround with the check-mode invocation.
6. Acceptance gate
Extend scripts/web-app-accept.sh (it already builds the framework remote
and the consuming app): the standing 14 checks stay, plus (a) check mode —
woc on the framework copy exits 0 with no entry present; (b) privacy — a
temp copy of the app with one added use framework/internal fails
compile and the output names WO-E108; (c) kind validation — a temp
manifest with a junk kind fails naming WO-E109. Compiler-side, the
corpus grows compile-fail fixtures for WO-E108/E109 and a run fixture is
not needed (no runtime behavior exists to pin — the VM is untouched by
design). Standing gates (just woc-test, just deps-accept,
just oop-e2e, samples) stay green.
Out of scope (recorded, deliberate)
Go's full subtree rule; a [lib] manifest section; pub(lib)-style
keyword visibility; manifest export allowlists; multiple named binaries
per project (cmd/ convention); enforcing a dep's own kind; internal
types in public signatures as a diagnostic; any VM, .wob, or GC change
(impact analysis in the iteration: visibility is compile-time name
resolution; libraries compile whole-program into the consumer's image;
GC inference stays whole-program and app usage may promote dep classes —
intended).
Success criteria
- Given the framework with
kind = "library"and nomain, whenwoc <dir>runs, then it exits 0 having run the full pipeline, andwoc buildon it fails with the message naming the kind. - Given the web-app importing
framework/internal, when it compiles, then WO-E108 points at theuseand names the framework; the framework's own files importing it stay legal. - Given the reorganized framework, when
just web-appruns, then all standing checks pass unchanged — the public surface did not move. - Given any existing project with no
kindkey, when it builds, then nothing changed — absent means program.