feat(pkg): package manager — fetch + MVS resolve + namespace registration (#63)
Implements the v1 direction decided in the RFC as a set of `x` subcommands
plus a small, contained compiler change.
* URL-as-identity, no registry — a dependency is named by its git import
path and a `git tag vX.Y.Z` publishes a version.
* Minimum Version Selection — a `require` is a minimum; the resolver picks
the greatest required minimum per module, then the reachable closure at
those versions. Deterministic, no SAT solver (tools/x/pkg.ludic).
* Content-addressed global store + per-project links — packages live once in
~/.ludic/store keyed by a content hash; each project links them under
ludic_modules/. package.ludic (manifest) + package.lock.ludic (lock).
* Namespace registration for source packages via a module-root import
fallback in the compiler: do_import resolves a non-local, non-absolute
import under $LUDIC_MODULES (default ludic_modules/), so a fetched
package's Ludic compiles into the consumer the way the built-in stdlib
does. Collisions and missing prebuilt targets are hard errors.
Commands: x add / x get / x update / x verify / x vendor. New hermetic suite
`x test-pkg` (stands up throwaway git repos, offline) is gated inside `x test`.
Existing programs compile byte-for-byte identically (the import fallback only
fires when the local path is absent); the C-free bootstrap fixpoint holds and
the seed is regenerated. Full suite: 87 passed, package suite: 12 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
237e13c95e
commit
2c44bae496
10 changed files with 17409 additions and 16387 deletions
120
docs/PACKAGES.md
Normal file
120
docs/PACKAGES.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Ludic packages
|
||||
|
||||
Ludic has a package manager built into the task runner (`bin/x`). It fetches,
|
||||
resolves, stores and links third-party packages with no new infrastructure to
|
||||
run — it drives plain `git` and rides on the Forgejo host and its release tags.
|
||||
|
||||
This is the v1 implementation of the direction decided in issue #63.
|
||||
|
||||
## The four decisions
|
||||
|
||||
| Axis | Ludic's choice |
|
||||
| --- | --- |
|
||||
| Where source comes from | **URL-as-identity, no registry.** The import path *is* the git location — `git.workshopsoft.io/user/pkg`. A `git tag vX.Y.Z` publishes a version. No account, no publish step, no index to run. |
|
||||
| Version resolution | **Minimum Version Selection (MVS), Go-style.** A `require` is a *minimum*; the resolver picks, per module, the greatest of every required minimum, then keeps the reachable closure at those versions. Deterministic, no SAT solver. |
|
||||
| Where dependencies live | **A content-addressed global store + per-project links (pnpm-style).** One immutable store keyed by a file-content hash (`~/.ludic/store/<sha256>`); each project gets a light linked view under `ludic_modules/` instead of a full copy. |
|
||||
| Manifest / lock | **`package.ludic`** (declared deps + provided namespaces + kind + targets) and **`package.lock.ludic`** (resolved versions + content hashes). |
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
x add <module>[@version] add a dependency to package.ludic, then resolve + fetch + link
|
||||
x get resolve every dependency in package.ludic, link them, write the lock
|
||||
x update [module] bump a dependency (or all) to its latest published version, then relock
|
||||
x verify check every locked package against the store by content hash
|
||||
x vendor copy the resolved packages into ./vendor for hermetic/offline builds
|
||||
```
|
||||
|
||||
`x add` with no `@version` picks the latest published tag and records it as the
|
||||
minimum. All the install commands print the resolved build list and write
|
||||
`package.lock.ludic`.
|
||||
|
||||
## The manifest — `package.ludic`
|
||||
|
||||
A line-oriented manifest. `#` starts a comment; strings are double-quoted.
|
||||
|
||||
```
|
||||
package "git.workshopsoft.io/orkun/greeter" # this package's import path
|
||||
version "1.2.0" # the version this checkout publishes
|
||||
kind source # source | prebuilt
|
||||
provides "Greet" # the Foo.* namespace(s) it registers (repeatable)
|
||||
|
||||
require "git.workshopsoft.io/orkun/util" "1.0.0" # a dependency and its minimum version
|
||||
|
||||
# a prebuilt lib also declares the targets it ships:
|
||||
# kind prebuilt
|
||||
# targets "native-arm64" "wasm32"
|
||||
```
|
||||
|
||||
A consumer project's manifest is the same file, usually with only `require`
|
||||
lines (the `package`/`version` fields describe a *publishable* package and are
|
||||
optional for a leaf application).
|
||||
|
||||
## The lockfile — `package.lock.ludic`
|
||||
|
||||
Generated by `x get`; do not edit by hand. One line per resolved module, pinning
|
||||
its selected version, content hash, kind and provided namespaces:
|
||||
|
||||
```
|
||||
# package.lock.ludic — generated by `x get`. Do not edit by hand.
|
||||
lock 1
|
||||
module "git.workshopsoft.io/orkun/greeter" version "1.2.0" hash "sha256:…" kind "source" provides "Greet"
|
||||
module "git.workshopsoft.io/orkun/util" version "1.0.0" hash "sha256:…" kind "source" provides "Util"
|
||||
```
|
||||
|
||||
`x verify` rehashes each store entry and confirms the project links to it, so a
|
||||
tampered or missing dependency is caught before it reaches a build.
|
||||
|
||||
## The store and the project view
|
||||
|
||||
Fetched packages live once in a global, immutable, content-addressed store:
|
||||
|
||||
```
|
||||
~/.ludic/store/<sha256>/… the package tree at a version (no .git)
|
||||
~/.ludic/store/cache/<module>/ a git clone cache used during resolution
|
||||
```
|
||||
|
||||
Each project gets a lightweight view — `ludic_modules/<import-path>` is a symlink
|
||||
into the store — so many projects share one copy and nothing is duplicated
|
||||
per-project. Override the store location with `$LUDIC_STORE`.
|
||||
|
||||
## Consuming a package — namespace registration
|
||||
|
||||
A **source package** ships plain Ludic. The consumer imports the package files by
|
||||
their import path:
|
||||
|
||||
```
|
||||
program App {
|
||||
import "git.workshopsoft.io/orkun/greeter/greet.ludic"
|
||||
entry { print(greet_hello()) }
|
||||
}
|
||||
```
|
||||
|
||||
The compiler resolves an import first relative to the importing file, then — for
|
||||
a non-absolute path that is not found — under the package module root
|
||||
(`$LUDIC_MODULES`, default `ludic_modules/`). So a fetched package's code is
|
||||
spliced into the build and its namespace becomes available exactly the way the
|
||||
built-in stdlib namespaces (Regex.\*, Grid.\*, …) are. Because Ludic compiles
|
||||
ahead-of-time, a source package is compiled *into* the consumer's binary — no
|
||||
ABI seam, and the whole-program guarantees (determinism, replay, `world_save`)
|
||||
still hold.
|
||||
|
||||
Two packages may not register the same `Foo.*` namespace — a collision is a hard
|
||||
error naming both modules.
|
||||
|
||||
## Prebuilt libraries
|
||||
|
||||
A `kind prebuilt` package ships a compiled artifact per target it declares in
|
||||
`targets`. Resolution selects the artifact for the build target
|
||||
(`$LUDIC_TARGET`, else `native-<arch>` for the host). If a needed target is not
|
||||
shipped it is a hard error — unless the package also ships source, in which case
|
||||
the source path is used. Prebuilt libs are the escape hatch for closed-source or
|
||||
other-language code over the engine's stable C-ABI; source packages are the
|
||||
default because they keep cross-compilation (including the wasm target) and the
|
||||
compile-time ECS first-class.
|
||||
|
||||
## Offline / hermetic builds
|
||||
|
||||
`x vendor` copies the resolved packages out of the store into `./vendor`. Build
|
||||
against the copy with `LUDIC_MODULES=vendor`, so the build needs neither the
|
||||
network nor the global store.
|
||||
Loading…
Add table
Add a link
Reference in a new issue