src/lock/world.bend
ezx@1.5.0.0 · 560 lines · 0 definitions
1# lock/world: everything `ez lock` reads, as one value, and what the lock may
2# depend on in it. The planner (lock/plan.bend) is a pure function of a
3# World; the interpreter (lock/run.bend) gathers one, answering each question
4# the planner asks by IO, and executes the plan the planner returns. The
5# design is docs/rfc/ez-lock-planner.md.
6#
7# A World is the ledger's text, the committed `.bend` files, one answer per
8# package the planner asked about, and, under `--upgrade`, one answer per
9# question the upgrade asked (lock/up.bend). There is no field for the
10# environment, the clock, untracked files, `.ez/origins.toml` or BEND_LIB's
11# path, because the lock reads none of them. A package a dependency imports
12# by `<name>@<version>` adds two questions, asked only when the ledger does
13# not name it: the lock being rewritten, which already records every name it
14# resolved, and, for a name it does not record, what the hub says the name
15# names. Both are answers like any other, and enter the lock through their
16# verdicts.
17import Base
18import ezx@1.5.0.0/src/lock/lock.bend as L
19import ezx@1.5.0.0/src/lock/up.bend as Up
20import ezx@1.5.0.0/src/ledger/upgrade.bend as U
21import ezx@1.5.0.0/src/ledger/manifest.bend as M
22import ezx@1.5.0.0/src/pkg/pkg.bend as K
23import ezx@1.5.0.0/src/hub/hub.bend as Web
24import ezx@1.5.0.0/src/share/sha.bend as Sha
25import ezx@1.5.0.0/src/share/say.bend as Say
26
27# the command as it was run: `--upgrade`, the name `--package` gave ("" for
28# none), and the directory it runs in, which is the project root. A relative
29# path source is recorded as it was given, relative to that root, and every
30# question the lock asks about it names it anchored there, as the planner
31# computes it (`P.anchor.src`, `Up.anchor`).
32type Args is Data:
33 Args{upgrade: Bool, only: String, here: String}
34
35# one committed `.bend` file: its path and its text
36type Source is Data:
37 Source{at: String, text: String}
38
39# what `git ls-files '*.bend'` answered: the committed sources, or why git
40# could not list them
41type Listing is Data:
42 Listed{files: List<&2, Source>}
43 Unlisted{why: String}
44
45# how the interpreter came by a package's bytes. A tree already under
46# BEND_LIB, vendored or left by an earlier lock, was read from there. One that
47# was not was cloned at the rev the ledger pins, and weighed. A hub package was
48# served by the hub.
49type How is Data:
50 Lib{}
51 Clone{nar: String}
52 Served{}
53
54# a question the planner asks, answered by one IO action: a package's
55# manifest and the text of each file it names, where the source says to read
56# it from, and the hub is where a hub package is served; the hash the hub
57# says a `<name>@<version>` names, `GET <hub>/name/<nv>`; or the lock being
58# rewritten, ez.lock.toml as it is.
59type Ask is Data:
60 Pkg{hash: String, src: L.Src, hub: String}
61 Name{nv: String, hub: String}
62 Lock{}
63
64# the answer to one question: the manifest found and the text of each file it
65# names, in the manifest's order; what the hub said a name names, as it said
66# it; the lock's text, "" when there is none; or why IO could not answer
67type Answer is Data:
68 Got{how: How, manifest: String, srcs: List<&2, String>}
69 Miss{why: String}
70 Said{text: String}
71 Locked{text: String}
72
73# one question, answered
74type Reply is Data:
75 Reply{ask: Ask, answer: Answer}
76
77# everything `ez lock` reads. The ledger is ez.toml's text, or None when
78# there is no ez.toml. `ups` answers the upgrade's questions, and is empty for
79# a plain lock, which asks none.
80type World is Data:
81 World{args: Args, ledger: Maybe<&2, String>, listing: Listing,
82 replies: List<&2, Reply>, ups: List<&2, Up.Reply>}
83
84# what the planner makes of one answer: the files and their texts, once every
85# check passed, or why it refused them; or, for a name or the lock, the names
86# it resolves, each to its hash
87type Verdict is Data:
88 Ok{files: List<&2, K.Item>, srcs: List<&2, String>}
89 No{why: String}
90 Named{names: List<&2, L.Name>}
91
92# one package hash and its verdict
93type Judged is Data:
94 Judged{hash: String, verdict: Verdict}
95
96# the part of a World the lock may depend on: the ledger the lock is made
97# from, read, which under `--upgrade` is the one the upgrade leaves; why the
98# upgrade refuses, "" when it does not; the committed sources; and the verdict
99# on every package. A reply enters only through its verdict, so how the bytes
100# arrived does not, and bytes that failed a check enter only as the refusal
101# they caused.
102type Inputs is Data:
103 Inputs{ledger: M.Read, stop: String, listing: Listing, judged: List<&2, Judged>}
104
105# whether the command was `ez lock --upgrade`
106def args.upgrade(world: World) -> Bool:
107 World{args, _ledger, _listing, _replies, _ups} = world
108 Args{up, _only, _here} = args
109 up
110
111# the name `--package` gave, "" for none
112def args.only(world: World) -> String:
113 World{args, _ledger, _listing, _replies, _ups} = world
114 Args{_up, only, _here} = args
115 only
116
117# the directory the command runs in
118def here.of(args: Args) -> String:
119 Args{_up, _only, here} = args
120 here
121
122# the directory the lock runs in, which is the project root
123def args.here(world: World) -> String:
124 World{args, _ledger, _listing, _replies, _ups} = world
125 here.of(args)
126
127# the ledger's text
128def ledger.of(world: World) -> Maybe<&2, String>:
129 World{_args, ledger, _listing, _replies, _ups} = world
130 ledger
131
132# the lock's path, which is fixed
133def lockfile() -> String:
134 "ez.lock.toml"
135
136# what a question asks about, which is the key its answer is judged and
137# looked up under: a package's hash, a name, or the lock's path. A `0x` name,
138# a `<name>@<version>` and the lock's path are three different strings.
139def ask.hash(ask: Ask) -> String:
140 match ask:
141 case Pkg{hash, _src, _hub}:
142 hash
143 case Name{nv, _hub}:
144 nv
145 case Lock{}:
146 lockfile()
147
148# ---------------------------------------------------------------------------
149# judging one answer
150
151# whether every text hashes to the sum its file is recorded with. A list of
152# texts that is longer or shorter than the files is not the package.
153def sums.ok(fs: List<&2, K.Item>, ss: List<&2, String>) -> Bool:
154 match fs ss:
155 case Nil{} Nil{}:
156 True{}
157 case Nil{} Con{_h, _t}:
158 False{}
159 case Con{_f, _g} Nil{}:
160 False{}
161 case Con{f, g} Con{s, t}:
162 +rest = sums.ok(g, t)
163 Bool.and(String.eq(Sha.hex(s), K.file.sum(f)), rest)
164
165# each file with its text, as it is laid
166def laid(fs: List<&2, K.Item>, ss: List<&2, String>) -> List<&2, Source>:
167 match fs ss:
168 case Nil{} Nil{}:
169 []
170 case Nil{} Con{_s, _t}:
171 []
172 case Con{_f, _g} Nil{}:
173 []
174 case Con{f, g} Con{s, t}:
175 Source{K.file.at(f), s} <> laid(g, t)
176
177# a laid file weighed by its own text
178def weigh(src: Source) -> K.Item:
179 Source{at, +text} = src
180 K.Item{at, Sha.hex(text)}
181
182# every laid file weighed by its own text
183def weighs(ls: List<&2, Source>) -> List<&2, K.Item>:
184 match ls:
185 case []:
186 []
187 case h <> t:
188 weigh(h) <> weighs(t)
189
190# the manifest of the files laid, weighed by their own texts, which is the
191# manifest a tree is laid with
192def manifest(+ls: List<&2, Source>) -> String:
193 K.manifest_of(K.files_of(weighs(ls)))
194
195# the `0x` name of that manifest
196def named(+ls: List<&2, Source>) -> String:
197 K.hash_of(K.files_of(weighs(ls)))
198
199# the verdict once the files, weighed by their own texts, were asked whether
200# they hash to the name. Their manifest is the one the tree is laid with, and
201# the manifest that was read hashes to the name already, so a package whose
202# manifest is not written the way ez writes one (a line per file, in path
203# order, each file once) is refused here rather than laid under a name its
204# laid manifest does not hash to.
205def body.laid(ok: Bool, +hash: String, fs: List<&2, K.Item>, ss: List<&2, String>) -> Verdict:
206 match ok:
207 case True{}:
208 Ok{fs, ss}
209 case False{}:
210 No{"ez: " ++ hash ++ ": its manifest is not written the way its name is taken" ++
211 " (one line per file, in path order, each file once)"}
212
213# the verdict once the texts were weighed against the manifest
214def body.sums(ok: Bool, +hash: String, +fs: List<&2, K.Item>, +ss: List<&2, String>) -> Verdict:
215 match ok:
216 case True{}:
217 body.laid(String.eq(hash, named(laid(fs, ss))), hash, fs, ss)
218 case False{}:
219 No{"ez: " ++ hash ++ ": a file does not match its manifest"}
220
221# the verdict once each path was asked whether it escapes the package
222def body.escape.go(clean: Bool, +bad: String, +hash: String, +fs: List<&2, K.Item>, +ss: List<&2, String>) -> Verdict:
223 match clean:
224 case True{}:
225 body.sums(sums.ok(fs, ss), hash, fs, ss)
226 case False{}:
227 No{"ez: " ++ hash ++ ": its manifest escapes the package (" ++ bad ++ ")"}
228
229# the verdict once no path was found to escape the package
230def body.escape(+bad: String, +hash: String, +fs: List<&2, K.Item>, +ss: List<&2, String>) -> Verdict:
231 body.escape.go(String.is_empty(bad), bad, hash, fs, ss)
232
233# the verdict once the manifest was known to be the one the hash names
234def body.named(ok: Bool, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
235 match ok:
236 case True{}:
237 +fs = L.manifest.files(String.lines(manifest))
238 body.escape(K.escaped(fs), hash, fs, ss)
239 case False{}:
240 No{"ez: " ++ hash ++ ": its manifest does not hash to its name"}
241
242# a package's bytes, checked the same way whatever they came from: the
243# manifest is the one the `0x` name is the digest of, no path leaves the
244# package, each text hashes to its file's sum, and the files, weighed by their
245# own texts, hash to the name, which is `ez fetch`'s check too. A tree under
246# BEND_LIB is checked against its name like any other, so a stale or edited
247# tree there is refused rather than locked.
248def body(+hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
249 body.named(Web.matches(L.want(hash), manifest), hash, manifest, ss)
250
251# a git package that arrived with no files is refused, never locked as an
252# empty table
253def git.body(empty: Bool, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
254 match empty:
255 case True{}:
256 No{"ez: " ++ hash ++ ": its manifest names no files"}
257 case False{}:
258 body(hash, manifest, ss)
259
260# a git package's bytes under the rule its name was taken by. Since bend
261# 2.0.27 a package holds the LICENSE files beside its sources, and the walk
262# takes them along. A ledger written before names the same checkout without
263# them, and that package is the checkout's too: when the walk's manifest does
264# not hash to the name, the package without its LICENSE files is judged
265# instead, and locked when that is the one the name is the digest of.
266def git.rule(named: Bool, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
267 match named:
268 case True{}:
269 git.body(List.is_empty(&2, K.Item, L.manifest.files(String.lines(manifest))),
270 hash, manifest, ss)
271 case False{}:
272 +fs = L.manifest.files(String.lines(manifest))
273 +bs = K.bare(fs)
274 git.body(List.is_empty(&2, K.Item, bs), hash, K.manifest.lines(bs),
275 K.bare.srcs(fs, ss))
276
277# a git package's bytes once its checkout was weighed. A tree read from
278# BEND_LIB is judged as a clone that weighed to the narHash the ledger
279# records, so the same bytes lock the same way whichever way they came.
280def git.weighed.go(same: Bool, +why: String, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
281 match same:
282 case True{}:
283 git.rule(Web.matches(L.want(hash), manifest), hash, manifest, ss)
284 case False{}:
285 No{why}
286
287def git.weighed(+why: String, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
288 git.weighed.go(String.is_empty(why), why, hash, manifest, ss)
289
290# a git package, by how it arrived
291def git.how(
292 how: How,
293 +hash: String,
294 +url: String,
295 +rev: String,
296 +nar: String,
297 +manifest: String,
298 +ss: List<&2, String>
299) -> Verdict:
300 match how:
301 case Lib{}:
302 git.weighed(L.nar.why(nar, nar, url, rev), hash, manifest, ss)
303 case Clone{got}:
304 git.weighed(L.nar.why(nar, got, url, rev), hash, manifest, ss)
305 case Served{}:
306 No{"ez: " ++ hash ++ " is a git package and was not read from git"}
307
308# a hub package, by how it arrived. Only the hub serves one.
309def hub.how(how: How, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
310 match how:
311 case Lib{}:
312 No{"ez: " ++ hash ++ " is a hub package and was not served by the hub"}
313 case Clone{_got}:
314 No{"ez: " ++ hash ++ " is a hub package and was not served by the hub"}
315 case Served{}:
316 body(hash, manifest, ss)
317
318# a package's answer against where the ledger says it comes from
319def accept.src(src: L.Src, how: How, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
320 match src:
321 case L.Hub{}:
322 hub.how(how, hash, manifest, ss)
323 case L.Git{url, rev, _entry, _root, nar, _tag}:
324 git.how(how, hash, url, rev, nar, manifest, ss)
325
326# why a hub package could not be read. One the ledger names is explained the
327# way a hub miss always was. One it does not name is said to be unnamed, since
328# that, and not the hub, is what a person has to fix.
329def hub.why(named: Bool, +hash: String, +hub: String, +why: String) -> String:
330 match named:
331 case True{}:
332 Say.hub.explain(hash, L.url_of(hub, hash, "manifest"), why)
333 case False{}:
334 "ez: " ++ hash ++ " is imported, ez.toml does not name it, and the hub at " ++
335 hub ++ " did not serve it (" ++ why ++ "). A package taken from git is recorded with `ez add`."
336
337# why IO could not answer, said for where the package comes from
338def miss.src(src: L.Src, named: Bool, +hash: String, +hub: String, +why: String) -> String:
339 match src:
340 case L.Hub{}:
341 hub.why(named, hash, hub, why)
342 case L.Git{_url, _rev, _entry, _root, _nar, _tag}:
343 why
344
345# what the hub said a name names, once it is known whether the answer is a
346# `0x` name
347def said.ok(ok: Bool, +nv: String, +hash: String, +hub: String) -> Verdict:
348 match ok:
349 case True{}:
350 Named{[L.Name{nv, hash}]}
351 case False{}:
352 No{"ez: the hub at " ++ hub ++ " names no package " ++ nv ++ " (it answered `" ++
353 hash ++ "`)"}
354
355# the hub's answer for a name: the `0x` name it gives, trimmed as bend trims
356# it, or a refusal when it gives none
357def said(+nv: String, +hub: String, +text: String) -> Verdict:
358 +hash = String.trim(text)
359 said.ok(L.hash.ok(hash), nv, hash, hub)
360
361# the lock being rewritten: every name it records that bend would read back
362def locked(text: String) -> Verdict:
363 Named{L.names.read(text)}
364
365# one answer judged
366def accept(+orgs: List<&2, L.Origin>, +hub: String, +hash: String, answer: Answer) -> Verdict:
367 match answer:
368 case Miss{why}:
369 No{miss.src(L.origin(orgs, hash), L.known(orgs, hash), hash, hub, why)}
370 case Got{how, manifest, ss}:
371 accept.src(L.origin(orgs, hash), how, hash, manifest, ss)
372 case Said{text}:
373 said(hash, hub, text)
374 case Locked{text}:
375 locked(text)
376
377# every reply judged, in the order they were given
378def judge(+orgs: List<&2, L.Origin>, +hub: String, rs: List<&2, Reply>) -> List<&2, Judged>:
379 match rs:
380 case []:
381 []
382 case Reply{ask, answer} <> t:
383 +hash = ask.hash(ask)
384 Judged{hash, accept(orgs, hub, hash, answer)} <> judge(orgs, hub, t)
385
386# the names a verdict resolves: those of a name's or the lock's answer, and
387# none for a package
388def verdict.names(verdict: Verdict) -> List<&2, L.Name>:
389 match verdict:
390 case Ok{_fs, _ss}:
391 []
392 case No{_why}:
393 []
394 case Named{ns}:
395 ns
396
397# every name the answers resolve, in the order they were given. The lock's
398# answer is asked for before any name's, so a name it records is never put
399# to the hub.
400def names.of(js: List<&2, Judged>) -> List<&2, L.Name>:
401 match js:
402 case []:
403 []
404 case Judged{_h, v} <> t:
405 List.append(&2, L.Name, verdict.names(v), names.of(t))
406
407# the ledger, read. A missing one is refused as a ledger that does not
408# parse: only `ez init` makes a ledger, and a lock of none is not a lock.
409def parsed(ledger: Maybe<&2, String>) -> M.Read:
410 match ledger:
411 case None{}:
412 M.Bad{"no ez.toml here; ez lock works on a project's ledger, and ez init makes one"}
413 case Some{text}:
414 M.parse(text)
415
416# the origins a ledger records
417def origins(+ledger: Maybe<&2, String>) -> List<&2, L.Origin>:
418 L.ledger.read(parsed(ledger))
419
420# the dependency ez.toml names `name`, as `M.dep` finds it
421def dep(world: World, name: String) -> M.Dep:
422 World{_args, ledger, _listing, _replies, _ups} = world
423 M.dep(parsed(ledger), name)
424
425# the hub a ledger names
426def ledger.hub(+ledger: Maybe<&2, String>) -> String:
427 M.hub_of(parsed(ledger))
428
429# ---------------------------------------------------------------------------
430# the ledger the lock is made from
431
432# a plain lock's is ez.toml as it is. An upgrade's is what the upgrade
433# decides on its answers: the ledger it writes, or its refusal, or the
434# questions it still has.
435def next.up(up: Bool, +only: String, +ledger: Maybe<&2, String>, ups: List<&2, Up.Reply>) -> Up.Next:
436 match up:
437 case False{}:
438 Up.plain(parsed(ledger))
439 case True{}:
440 Up.next(only, parsed(ledger), ups)
441
442# what the upgrade decides on a World, or nothing around a plain lock
443def next(args: Args, +ledger: Maybe<&2, String>, ups: List<&2, Up.Reply>) -> Up.Next:
444 Args{up, +only, _here} = args
445 next.up(up, only, ledger, ups)
446
447# the origins the lock reads packages by
448def next.origins(+nx: Up.Next) -> List<&2, L.Origin>:
449 L.ledger.read(Up.next.read(nx))
450
451# the hub it names
452def next.hub(+nx: Up.Next) -> String:
453 M.hub_of(Up.next.read(nx))
454
455# a tree the upgrade checked out at a new rev, as the answer to the package
456# question the lock would ask about it: a clone, weighed
457def laid.reply(laid: Up.Laid, +orgs: List<&2, L.Origin>, +hub: String) -> Reply:
458 Up.Laid{+hash, nar, manifest, srcs} = laid
459 Reply{Pkg{hash, L.origin(orgs, hash), hub}, Got{Clone{nar}, manifest, srcs}}
460
461def laid.replies(ls: List<&2, Up.Laid>, +orgs: List<&2, L.Origin>, +hub: String) -> List<&2, Reply>:
462 match ls:
463 case []:
464 []
465 case h <> t:
466 laid.reply(h, orgs, hub) <> laid.replies(t, orgs, hub)
467
468# the package answers the lock reads: the trees the upgrade checked out, then
469# every answer the World holds
470def replies.of(+nx: Up.Next, replies: List<&2, Reply>) -> List<&2, Reply>:
471 List.append(&2, Reply,
472 laid.replies(Up.next.trees(nx), next.origins(nx), next.hub(nx)), replies)
473
474# every package answer judged against the ledger the lock is made from
475def judged.of(+nx: Up.Next, replies: List<&2, Reply>) -> List<&2, Judged>:
476 List.append(&2, Judged,
477 judge(next.origins(nx), next.hub(nx),
478 laid.replies(Up.next.trees(nx), next.origins(nx), next.hub(nx))),
479 judge(next.origins(nx), next.hub(nx), replies))
480
481# every committed source with each hash the upgrade moved rewritten in its
482# imports, as the upgrade writes it
483def sources.swap(+ss: List<&2, U.Swap>, fs: List<&2, Source>) -> List<&2, Source>:
484 match fs:
485 case []:
486 []
487 case Source{at, text} <> t:
488 Source{at, U.reimport.many(ss, text)} <> sources.swap(ss, t)
489
490def listing.swap(+ss: List<&2, U.Swap>, listing: Listing) -> Listing:
491 match listing:
492 case Listed{fs}:
493 Listed{sources.swap(ss, fs)}
494 case Unlisted{why}:
495 Unlisted{why}
496
497# the committed sources as the lock reads them: as the upgrade leaves them,
498# which is as they are when no hash moved, and always for a plain lock
499def listing.moved(ss: List<&2, U.Swap>, listing: Listing) -> Listing:
500 match ss:
501 case []:
502 listing
503 case h <> t:
504 listing.swap(h <> t, listing)
505
506def listing.of(+nx: Up.Next, listing: Listing) -> Listing:
507 listing.moved(Up.next.swaps(nx), listing)
508
509# what a World is, as far as the lock may tell, once the upgrade decided
510def inputs.of(+nx: Up.Next, listing: Listing, replies: List<&2, Reply>) -> Inputs:
511 Inputs{Up.next.read(nx), Up.next.stop(nx), listing.of(nx, listing),
512 judged.of(nx, replies)}
513
514# what a World is, as far as the lock may tell
515def inputs(world: World) -> Inputs:
516 World{args, +ledger, listing, replies, ups} = world
517 inputs.of(next(args, ledger, ups), listing, replies)
518
519# ---------------------------------------------------------------------------
520# a fresh clone's World
521
522# one answer as a fresh clone would have it: a tree read from BEND_LIB is
523# instead cloned at the rev the ledger pins and weighed to the narHash the
524# ledger records. Anything else is what it was.
525def reclone.how(how: How, +src: L.Src) -> How:
526 match how:
527 case Lib{}:
528 Clone{L.src.nar(src)}
529 case Clone{got}:
530 Clone{got}
531 case Served{}:
532 Served{}
533
534def reclone.one(+orgs: List<&2, L.Origin>, +hash: String, answer: Answer) -> Answer:
535 match answer:
536 case Miss{why}:
537 Miss{why}
538 case Got{how, manifest, ss}:
539 Got{reclone.how(how, L.origin(orgs, hash)), manifest, ss}
540 case Said{text}:
541 Said{text}
542 case Locked{text}:
543 Locked{text}
544
545# every answer as a fresh clone would have it. A tree the upgrade checked
546# out is a clone already.
547def reclone.all(+orgs: List<&2, L.Origin>, rs: List<&2, Reply>) -> List<&2, Reply>:
548 match rs:
549 case []:
550 []
551 case Reply{+ask, answer} <> t:
552 Reply{ask, reclone.one(orgs, ask.hash(ask), answer)} <> reclone.all(orgs, t)
553
554# the World a fresh clone of the same commit gathers: the same ledger, the
555# same committed sources, and every tree it would have read from BEND_LIB
556# cloned and weighed instead, since a clone has nothing there
557def reclone(world: World) -> World:
558 World{+args, +ledger, listing, replies, +ups} = world
559 World{args, ledger, listing,
560 reclone.all(next.origins(next(args, ledger, ups)), replies), ups}