23. Packages and dependencies

A module is a directory in your project. A package is a directory somebody else wrote, and the difference is not technical — it is that you did not read it.

That sentence is the whole subject of this chapter and the next. Wolf’s answer to it starts somewhere unglamorous: with a file format that cannot do anything.

23.1 wolf.pkg is data

Here is a project with a dependency. Two directories: the program, and the package it uses.

pkg {
    name:    "den/logsearch",
    version: "0.1.0",
    edition: "1",

    deps: {
        rows: { path: "../rows" },
    },
}
use rows

fn main() -> !int {
    let w = rows.widest(31, 24)
    let r = rows.fit(50, 3)
    print("{w} wide, {r} rows")
    0
}

The dependency is an ordinary package with a manifest of its own:

pkg {
    name:    "den/rows",
    version: "1.4.0",
    edition: "1",
}

and it builds and runs with no step in between:

$ wolf run app/main.lu
31 wide, 47 rows

Read wolf.pkg again and notice what is not in it.

There is no code. Not a script, not an expression, not a string that gets interpolated — the values are strings, integers, bare words, lists, and maps, and that is the entire grammar. A manifest is data, and the consequence is the reason the format is shaped this way: reading a manifest tells you everything a package does to the machine that builds it, because a manifest cannot ask for anything to happen. Chapter 24 is about what that buys and what it costs; this section is about the reading.

name is owner/pkg, scoped from the start so that names are owned rather than claimed. version is dotted numerics. deps maps an alias — the identifier you write in use — to exactly one source. The alias is deliberately not the package name: rows is what your source says, and den/rows is who it is, so a rename upstream is a manifest edit rather than a search-and-replace through your code.

A dependency’s source is one of two things you can point at today: a local directory, or a pinned tag in a git repository.

rows:  { path: "../rows" }
regex: { git: "https://github.com/forest/regex", tag: "v2.1.0" }

A path dependency is a tree you can edit — the shape a workspace of related packages takes, and the shape this chapter’s examples use. A git dependency names a tag, never a branch: a dependency has to be a point, because a moving target is not a dependency, it is a subscription.

Two verbs read that graph back to you. wolf tree resolves the whole thing and draws it:

$ wolf tree --dir app
capability tree (I13)
den/logsearch 0.1.0 (root) caps=[]
└── rows 1.4.0 caps=[]
effective: []

and wolf why answers the question you actually have when a name shows up in that tree and you do not recognize it:

$ wolf why rows --dir app
den/logsearch (root) -> rows 1.4.0

One arrow here, because the graph is two nodes deep. In a real graph the chain is the answer: why prints the path from your package to the thing you did not ask for, which is the difference between deleting a dependency and finding out who wanted it.

The caps=[] in the tree and the effective: [] under it are §24.3’s subject, and they are printed here because they are not an add-on report — they are what the resolver has to know anyway.

Adding one

You can write the deps entry by hand. wolf add writes it for you, and does one more thing that matters:

$ wolf add rows --path ../rows --dir app
wolf add: created app/wolf.pkg (minimal manifest)
wolf add: rows 1.4.0 — capabilities []

Two lines, and the second one is the point: wolf add tells you the version it resolved and the authority the package declares, at the moment you take it on, unprompted. Compare with the ecosystems most of us came from, where adding a dependency prints a progress bar.

The edit it made is the edit you would have made:

pkg {
    name:    "local/app",
    version: "0.1.0",
    edition: "1",
    deps: {
        rows: { path: "../rows" },
    },
}

and the project it created builds:

$ wolf run app/main.lu
31 wide, 47 rows

It is still your file. wolf add splices an entry into the text and leaves your comments, your ordering, and your whitespace alone; wolf rm splices one out. Neither reformats the manifest, because the manifest is not the tool’s file to own.

wolf add also either lands whole or not at all. If the new dependency does not resolve, the manifest edit is rolled back before the command returns, so a failed add cannot leave you with a project that no longer builds for a reason you did not cause.

Exercise 23-1 (comprehension · prose) — Read den/logsearch’s manifest above. Answer from the file alone: what may this package do to the machine that builds it, what may it do to the machine that runs it, and which of those two answers required reading anything other than this file?

Exercise 23-6 (spelunking · wolf) — Run wolf tree and then wolf why on a project with one dependency, and then run wolf why for a name that is not in the graph. Report all three exit codes, and say what the third one is for.

23.3 wolf.sum — the ledger

wolf add wrote a second file, and it is a different kind of file from the manifest:

rows 1.4.0 - caps=-

One line per resolved dependency — the name, the version, a content address, and the capability set — under a one-line header telling you the file is the tool’s to write and not yours to edit. This one’s content address is -, because rows is a local tree you own and can edit — pinning a hash to a directory you are editing would make every save look like an attack. A dependency fetched from a tag carries a real address in that field, a b3: digest over the package’s source tree, and that is the field this section is about.

Notice what the ledger is not. It is not an input to resolution. Nothing in the file changes which versions a build selects; delete it and the same manifest resolves to the same graph. Lockfiles in other ecosystems do double duty — they decide versions and they witness bytes — and the two jobs pull in opposite directions, which is why “the lockfile is out of date” is a category of problem there and not here. wolf.sum has exactly one job: to remember what a human once fetched, so that a later build can notice if the bytes changed.

Because that is its only job, refreshing it is boring:

$ wolf update --dir app
wolf update: wolf.sum refreshed (1 entry)
$ wolf audit --dir app
capability tree (I13)
den/logsearch 0.1.0 (root) caps=[]
└── rows 1.4.0 caps=[]
effective: []

update re-derived the whole ledger and wrote the same bytes it read, because nothing had changed; the rendering is sorted and byte-stable by construction, so a ledger diff in review is a real difference rather than a formatting one. And audit agreeing silently is the ledger’s normal state: the world matches what was witnessed.

The interesting case is when it does not. The rule the ledger enforces is worth reading in the compiler’s own words:

$ wolf --explain E1506
E1506: a dependency's content hash does not match wolf.sum

`wolf.sum` is the integrity ledger: for every fetched dependency it
records a content hash over the package's source tree, and every later
build re-derives that hash and compares. A mismatch means the bits on
disk are not the bits the ledger witnessed — an edited store entry, a
moved tag, or a tampered mirror. The build refuses: mirrors and
transports are untrusted by construction, only the hash is. If the
change is intentional (you deliberately updated the dependency), run
`wolf update` to refresh the ledger; otherwise treat the mismatch as
the supply-chain alarm it is.

“Mirrors and transports are untrusted by construction, only the hash is” is the sentence to keep. It is why a moved tag is not a way to change what your build compiles: the tag is a name, the hash is the content, and the build compares content. An author who force-pushes v1.4.0 has not changed your dependency; they have broken your build, loudly, at the first command that touches it.

Two details of the hash are decisions rather than accidents. It is taken over the package’s source files — .lu, .wolfi, and wolf.pkg — so a stray editor backup, a .git directory, or a build cache in the tree cannot perturb a package’s identity. And it is one digest over the whole tree rather than a digest per file, so there is one number to compare and one number to record.

The last field on the line is the capability set, and it is there for the same reason the hash is: so that a later run can notice a change. That is chapter 24.

Exercise 23-5 (comprehension · prose) — An upstream author force-pushes tag v1.4.0 of den/rows so the same version name now serves different bytes. Walk the failure: what does your wolf.sum notice, what does the build do about it, and what would you have to run to tell wolf the change was intended? Then answer the pointed one: what does wolf.sum not protect, and who is exposed to that gap?

Exercise 23-8 (design) — Determinism has a price: a dependency’s bug fixes do not reach your users until you ask for them. A colleague calls this a security liability and wants automatic upgrades. Take wolf’s side without dodging the liability — name the mechanism that answers it and the reason auto-upgrading is the wrong layer for the answer.