src/lock/world.bend

ezx@1.8.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.8.0.0/src/lock/lock.bend as L
19import ezx@1.8.0.0/src/lock/up.bend as Up
20import ezx@1.8.0.0/src/ledger/upgrade.bend as U
21import ezx@1.8.0.0/src/ledger/manifest.bend as M
22import ezx@1.8.0.0/src/pkg/pkg.bend as K
23import ezx@1.8.0.0/src/hub/hub.bend as Web
24import ezx@1.8.0.0/src/share/sha.bend as Sha
25import ezx@1.8.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}