src/doctor/plan.bend
ezx@1.10.0.0 · 1,041 lines · 0 definitions
1# doctor/plan: `ez doctor` as a pure planner. It reads a World
2# (doctor/world.bend) and returns either the questions it still needs
3# answered (`step`) or a Plan: the lines to print and how the command ends
4# (`plan`). The interpreter (doctor/run.bend) loops on `step`, answering each
5# question by IO, until nothing is left to ask, then prints the plan.
6#
7# Doctor says what would stop this project from building. Each line reports
8# what was found rather than a tick, because the useful answers here are
9# values: which bend, which compiler, which packages are on disk, and which
10# hashes the source and the ledger disagree about. A line that is a problem
11# fails the command, as `cargo check` fails on the first thing it cannot
12# build, though doctor goes on to say everything it found.
13#
14# The plan is lines said and an outcome, and nothing else: doctor writes no
15# file, lays no tree and removes nothing (EZ-VEN-4). The questions it asks
16# write nothing either: a program asked for its version, `ls` of the library,
17# git's listing of the tracked sources, and a package's files read from under
18# BEND_LIB.
19#
20# A named import, `import <name>@<version>/...`, is read the same way: its
21# name is what the import line names, reported when no dependency of the
22# ledger records it, and a dependency recorded by a name is used when an
23# import line names it by that name or by its hash. Each name the lock
24# records has its file under `BEND_LIB/names`, which bend reads the name's
25# hash from before it asks the hub; one missing, or naming another hash, is
26# reported with the library, and fails the command.
27#
28# The drift report is the ledger against the import lines. A package's `0x`
29# hash is written in two places, the `[deps.<name>]` section of ez.toml and
30# the `import 0x...` line that uses it, so the two can disagree. ez treats the
31# ledger as the record and the import lines as the truth, and reports the
32# disagreement both ways rather than rewriting anyone's source (EZ-VEN-5). An
33# import line is one the lock reads as one: in the header of a `.bend` file
34# git tracks outside `.ez/`, found by the package walk's own scan (`P.roots`),
35# so doctor and `ez lock` never disagree about what the source imports. A
36# ledger's `[tools.*]` pins are not dependencies, and the report never reads
37# them (EZ-LED-5).
38#
39# The lock is checked the way `cargo --locked` and `uv lock --check` check
40# one, without the network: doctor puts a plain `ez lock` to the lock's own
41# planner (lock/plan.bend), over the ledger as it is, tools and all, the
42# sources git lists, and the trees BEND_LIB holds, and compares the text it
43# would write with ez.lock.toml byte for byte (EZ-VEN-6). So every hash,
44# source, file sum and tool pin the lock records is checked, by the code that
45# writes them. A package the lock would read and BEND_LIB lacks is one doctor
46# cannot see, and the lock is then said to be unchecked rather than guessed
47# at; that, a lock ez lock would refuse to write, and a lock it would write
48# otherwise, each fail the command.
49#
50# The Effect, Plan and Outcome types are the lock's (lock/plan.bend), as for
51# the other commands.
52import Base
53import ezx@1.10.0.0/src/toml/toml.bend as T
54import ezx@1.10.0.0/src/doctor/world.bend as DW
55import ezx@1.10.0.0/src/lock/plan.bend as P
56import ezx@1.10.0.0/src/lock/world.bend as W
57import ezx@1.10.0.0/src/lock/lock.bend as L
58import ezx@1.10.0.0/src/ledger/manifest.bend as M
59import ezx@1.10.0.0/src/share/args.bend as Args
60
61# one thing doctor looked at: what to print, and whether it is a problem
62type Note is Data:
63 Note{wrong: Bool, line: String}
64
65# a count with its noun, kept in agreement
66def plural(+count: Nat, one: String, many: String) -> String:
67 Nat.show(count) ++ " " ++ Bool.pick(String, Nat.is_eq(count, 1n), one, many)
68
69# the lines a run printed, or none when it failed, so a program's complaint on
70# stderr is never mistaken for an answer
71def answered(ok: Bool, +text: String) -> List<&2, String>:
72 match ok:
73 case True{}:
74 Args.nonempty(String.lines(text))
75 case False{}:
76 []
77
78# ---------------------------------------------------------------------------
79# the toolchain
80
81# the first line of what a program printed
82def banner.of(ls: List<&2, String>) -> String:
83 match ls:
84 case []:
85 ""
86 case h <> _t:
87 h
88
89# a version banner, which is one line however many a program prints
90def banner(text: String) -> String:
91 banner.of(String.lines(text))
92
93# what a version run said about the program it ran
94def probe.note(ok: Bool, name: String, +text: String) -> Note:
95 match ok:
96 case True{}:
97 Note{False{}, name ++ ": " ++ banner(text)}
98 case False{}:
99 Note{True{}, name ++ ": not on PATH"}
100
101# what the answer to a version question says
102def probe.answer(answer: DW.Answer, +name: String) -> Note:
103 match answer:
104 case DW.Printed{ok, text}:
105 probe.note(ok, name, text)
106 case _:
107 Note{True{}, name ++ ": not on PATH"}
108
109# a program's line, once it was asked
110def probe.heard(got: DW.Heard, +name: String) -> Note:
111 match got:
112 case DW.Heard{a}:
113 probe.answer(a, name)
114 case DW.Open{}:
115 Note{True{}, name ++ ": not on PATH"}
116
117# the C compiler the native lane will use: `$CC` when it is set, else `cc`.
118# Same source and a different compiler is a different binary, so doctor names
119# it.
120def cc.name(+var: String) -> String:
121 Bool.pick(String, String.is_empty(var), "cc", var)
122
123# the programs doctor asks for their versions. bend's check report changed
124# wording in 2.0.32, which the proof gate reads, and the native lane needs a C
125# compiler, so the version is the finding.
126def programs(+cc: String) -> List<&2, String>:
127 ["bend", cc.name(cc), "git"]
128
129# every program's line
130def probes(ns: List<&2, String>, +rs: List<&2, DW.Reply>) -> List<&2, Note>:
131 match ns:
132 case []:
133 []
134 case +h <> t:
135 probe.heard(DW.heard(rs, DW.Probe{h}), h) <> probes(t, rs)
136
137# ---------------------------------------------------------------------------
138# the ledger and the lock
139
140# the dependencies of a ledger that was read
141def deps.of(manifest: M.Manifest) -> List<&2, M.Dep>:
142 M.Manifest{_n, _e, _b, _h, _pa, _pv, ds, _ts} = manifest
143 ds
144
145# how many dependencies a ledger names; one that did not read names none
146def deps.count(ledger: M.Read) -> Nat:
147 match ledger:
148 case M.Bad{_why}:
149 0n
150 case M.Good{m}:
151 List.length(&2, M.Dep, deps.of(m))
152
153# what the ledger turned out to be
154def ledger.note(ledger: M.Read) -> Note:
155 match ledger:
156 case M.Bad{why}:
157 Note{True{}, DW.toml() ++ ": " ++ why}
158 case M.Good{m}:
159 M.Manifest{n, e, _b, _h, _pa, _pv, ds, _ts} = m
160 Note{False{}, DW.toml() ++ ": " ++ n ++ ", entry " ++ e ++ ", " ++
161 plural(List.length(&2, M.Dep, ds), "dependency", "dependencies")}
162
163# what the lock turned out to be: the packages it records, or why it could
164# not be read, or no lock at all
165type Locked is Data:
166 Locked{hashes: List<&2, String>}
167 Unread{why: String}
168 Unlocked{}
169
170# the verdict on a parse, with the answer in hand
171def locked.pick(ok: Bool, why: String, ss: List<&2, T.Sect>) -> Locked:
172 match ok:
173 case True{}:
174 Locked{L.hashes(ss)}
175 case False{}:
176 Unread{why}
177
178# a lock that parsed, by the packages it records; one that did not, by why
179def locked.parsed(toml: T.Toml) -> Locked:
180 T.Toml{+bad, _name, _pairs, ss} = toml
181 locked.pick(String.is_empty(bad), bad, ss)
182
183# the lock's text, read
184def locked.of(got: Maybe<&2, String>) -> Locked:
185 match got:
186 case None{}:
187 Unlocked{}
188 case Some{text}:
189 locked.parsed(T.parse(text))
190
191# the packages a lock records; none when it did not read or is not there
192def locked.hashes(lk: Locked) -> List<&2, String>:
193 match lk:
194 case Locked{hs}:
195 hs
196 case Unread{_why}:
197 []
198 case Unlocked{}:
199 []
200
201# whether a ledger with this many dependencies needs a lock that names some
202def lock.needs(deps: Nat) -> Bool:
203 match deps:
204 case 0n:
205 False{}
206 case 1n+_n:
207 True{}
208
209# whether the lock is a problem: it names no package while the ledger has
210# dependencies it should have resolved. A project with no dependencies has
211# nothing to lock, so a lock naming nothing, or no lock at all, is its due.
212def lock.wrong(deps: Nat, hs: List<&2, String>) -> Bool:
213 match hs:
214 case []:
215 lock.needs(deps)
216 case _h <> _t:
217 False{}
218
219# what the lock amounts to. A lock that is there and does not parse is a
220# problem whatever the ledger holds, since `ez fetch` refuses it, as cargo
221# refuses a Cargo.lock it cannot read.
222def lock.note(+deps: Nat, lk: Locked) -> Note:
223 match lk:
224 case Locked{+hs}:
225 +wrong = lock.wrong(deps, hs)
226 Note{wrong,
227 DW.lockfile() ++ ": " ++ plural(List.length(&2, String, hs), "package", "packages") ++
228 Bool.pick(String, wrong, " (it names none; run ez lock)", "")}
229 case Unread{why}:
230 Note{True{}, DW.lockfile() ++ ": " ++ why}
231 case Unlocked{}:
232 +wrong = lock.needs(deps)
233 Note{wrong, DW.lockfile() ++ ": " ++
234 Bool.pick(String, wrong, "none; run ez lock", "none, and nothing to lock")}
235
236# ---------------------------------------------------------------------------
237# BEND_LIB
238
239# where the packages this build resolves against live. It is what ez itself
240# passes to every bend it starts, so an unset BEND_LIB is not a finding: the
241# default is `.ez/lib` beside the project, and what matters is what is in it.
242def lib.at(+var: String) -> String:
243 Bool.pick(String, String.is_empty(var), ".ez/lib", var)
244
245# the names `ls` gave for the library, none when it gave none
246def lib.have(got: DW.Heard) -> List<&2, String>:
247 match got:
248 case DW.Heard{a}:
249 match a:
250 case DW.Printed{ok, text}:
251 answered(ok, text)
252 case _:
253 []
254 case DW.Open{}:
255 []
256
257# the rest of a scan only when this one was not it. `Bool.or` reduces both
258# of its sides, so a scan written with it reads the whole list even when the
259# first entry answers. The rest arrives as a thunk.
260def has.step(here: Bool, rest: Unit -> Bool) -> Bool:
261 match here:
262 case True{}:
263 True{}
264 case False{}:
265 rest(Unit{})
266
267# whether a list holds this string
268def has(ss: List<&2, String>, +want: String) -> Bool:
269 match ss:
270 case []:
271 False{}
272 case +h <> t:
273 has.step(String.eq(h, want), _u => has(t, want))
274
275# a string kept when the other list lacks it
276def missing.put(hit: Bool, item: String, rest: List<&2, String>) -> List<&2, String>:
277 match hit:
278 case True{}:
279 rest
280 case False{}:
281 item <> rest
282
283# the strings of `xs` that `known` does not hold
284def missing(xs: List<&2, String>, +known: List<&2, String>) -> List<&2, String>:
285 match xs:
286 case []:
287 []
288 case +h <> t:
289 missing.put(has(known, h), h, missing(t, known))
290
291# the names the lock records, with the hash each names; none when there is
292# no lock, or when it does not read
293def lock.names(lock: Maybe<&2, String>) -> List<&2, L.Name>:
294 match lock:
295 case None{}:
296 []
297 case Some{text}:
298 L.names.read(text)
299
300# what a name's file says against the lock: nothing when it holds the lock's
301# hash, as bend trims it, and the fault otherwise
302def name.held(same: Bool, +nv: String, +hash: String, +was: String) -> List<&2, String>:
303 match same:
304 case True{}:
305 []
306 case False{}:
307 ["names/" ++ nv ++ " names " ++ was ++ " where the lock names " ++ hash]
308
309def name.got(got: Maybe<&2, String>, +nv: String, +hash: String) -> List<&2, String>:
310 match got:
311 case None{}:
312 ["names/" ++ nv ++ " is not there"]
313 case Some{text}:
314 +was = String.trim(text)
315 name.held(String.eq(was, hash), nv, hash, was)
316
317def name.heard(got: DW.Heard, +nv: String, +hash: String) -> List<&2, String>:
318 match got:
319 case DW.Heard{a}:
320 match a:
321 case DW.Held{m}:
322 name.got(m, nv, hash)
323 case _:
324 ["names/" ++ nv ++ " was not read"]
325 case DW.Open{}:
326 ["names/" ++ nv ++ " was not read"]
327
328# every name of the lock whose file under BEND_LIB is missing, or names
329# another hash than the lock does, which bend would read instead of asking
330# the hub
331def names.wrong(ns: List<&2, L.Name>, +rs: List<&2, DW.Reply>) -> List<&2, String>:
332 match ns:
333 case []:
334 []
335 case L.Name{+nv, +hash} <> t:
336 List.append(&2, String, name.heard(DW.heard(rs, DW.Name{nv}), nv, hash), names.wrong(t, rs))
337
338# what the library holds of the lock's names, said after its packages
339def names.said(+count: Nat) -> String:
340 match count:
341 case 0n:
342 ""
343 case 1n+_p:
344 " with their " ++ plural(count, "name", "names")
345
346# what is wrong with the library, each fault in its place
347def lib.faults(gone: List<&2, String>, bad: List<&2, String>) -> String:
348 match gone:
349 case []:
350 String.join(bad, "; ")
351 case _g <> _t:
352 match bad:
353 case []:
354 "lacks " ++ String.join(gone, " ")
355 case _b <> _u:
356 "lacks " ++ String.join(gone, " ") ++ "; " ++ String.join(bad, "; ")
357
358# BEND_LIB judged against the lock: every package the lock names has to be on
359# disk, because bend resolves a hub import during the check and a sandbox has
360# no network to fall back on, and so does every name's file, since bend
361# would otherwise ask the hub what the name names
362def lib.said(all: Bool, +dir: String, +count: Nat, +names: Nat, gone: List<&2, String>, bad: List<&2, String>) -> Note:
363 match all:
364 case True{}:
365 Note{False{}, "BEND_LIB: " ++ dir ++ ", all " ++
366 plural(count, "locked package", "locked packages") ++ " present" ++ names.said(names)}
367 case False{}:
368 Note{True{}, "BEND_LIB: " ++ dir ++ " " ++ lib.faults(gone, bad) ++ "; run ez fetch"}
369
370# the verdict, once what is wanted and what is there are both known
371def lib.judge(
372 +dir: String,
373 +want: List<&2, String>,
374 +ns: List<&2, L.Name>,
375 have: List<&2, String>,
376 +rs: List<&2, DW.Reply>
377) -> Note:
378 +gone = missing(want, have)
379 +bad = names.wrong(ns, rs)
380 lib.said(Bool.and(List.is_empty(&2, String, gone), List.is_empty(&2, String, bad)), dir,
381 List.length(&2, String, want), List.length(&2, L.Name, ns), gone, bad)
382
383# the library's line
384def lib.note(+dir: String, +want: List<&2, String>, +ns: List<&2, L.Name>, +rs: List<&2, DW.Reply>) -> Note:
385 lib.judge(dir, want, ns, lib.have(DW.heard(rs, DW.Lib{dir})), rs)
386
387# ---------------------------------------------------------------------------
388# the drift report
389
390# a head kept when the rest does not hold it again
391def once.put(dup: Bool, item: String, rest: List<&2, String>) -> List<&2, String>:
392 match dup:
393 case True{}:
394 rest
395 case False{}:
396 item <> rest
397
398# every string of a list once, each where it last stands
399def once(ss: List<&2, String>) -> List<&2, String>:
400 match ss:
401 case []:
402 []
403 case +h <> +t:
404 once.put(has(t, h), h, once(t))
405
406# every hub package the project's sources import, each once, by hash and by
407# name. These are the lock's roots: the header imports of every tracked
408# `.bend` file outside `.ez/`, as the package walk scans them.
409def imports(+fs: List<&2, W.Source>) -> List<&2, String>:
410 once(List.append(&2, String, P.roots(fs), P.roots.named(fs)))
411
412# a dependency's name, when the ledger records it by one
413def pinned.at(none: Bool, nv: String, rest: List<&2, String>) -> List<&2, String>:
414 match none:
415 case True{}:
416 rest
417 case False{}:
418 nv <> rest
419
420def pinned.nv(+nv: String, rest: List<&2, String>) -> List<&2, String>:
421 pinned.at(String.is_empty(nv), nv, rest)
422
423# the hashes a ledger's dependencies name, and the names it records them by
424def pinned(ds: List<&2, M.Dep>) -> List<&2, String>:
425 match ds:
426 case []:
427 []
428 case +h <> t:
429 M.dep.hash(h) <> pinned.nv(M.source.nv(M.source.dep(h)), pinned(t))
430
431# whether an import line names a dependency, by its hash or by its name
432def used(+hs: List<&2, String>, +dependency: M.Dep) -> Bool:
433 +nv = M.source.nv(M.source.dep(dependency))
434 Bool.or(has(hs, M.dep.hash(dependency)),
435 Bool.and(Bool.not(String.is_empty(nv)), has(hs, nv)))
436
437# how a hash imported and not in the ledger reads
438def unrecorded.line(+hash: String) -> String:
439 "drift: " ++ hash ++ " is imported but ez.toml does not name it"
440
441# every hash imported that the ledger does not name
442def unrecorded(hs: List<&2, String>) -> List<&2, String>:
443 match hs:
444 case []:
445 []
446 case h <> t:
447 unrecorded.line(h) <> unrecorded(t)
448
449# how a ledger entry nobody imports reads
450def unused.line(+dependency: M.Dep) -> String:
451 "drift: " ++ M.dep.name(dependency) ++ " = " ++ M.dep.hash(dependency) ++
452 " is in ez.toml but no import line uses it"
453
454# a dependency reported when nothing imports it
455def unused.put(hit: Bool, +dependency: M.Dep, rest: List<&2, String>) -> List<&2, String>:
456 match hit:
457 case True{}:
458 rest
459 case False{}:
460 unused.line(dependency) <> rest
461
462# every dependency of the ledger that no import line uses
463def unused(ds: List<&2, M.Dep>, +hs: List<&2, String>) -> List<&2, String>:
464 match ds:
465 case []:
466 []
467 case +h <> t:
468 unused.put(used(hs, h), h, unused(t, hs))
469
470# both directions of the disagreement: a hash the source imports that the
471# ledger never names, and a ledger entry no import line uses
472def drift(+hs: List<&2, String>, +ds: List<&2, M.Dep>) -> List<&2, String>:
473 List.append(&2, String, unrecorded(missing(hs, pinned(ds))), unused(ds, hs))
474
475# every line of a report, each a problem
476def flag(ls: List<&2, String>) -> List<&2, Note>:
477 match ls:
478 case []:
479 []
480 case h <> t:
481 Note{True{}, h} <> flag(t)
482
483# the report, or the one line that says there is nothing to report
484def drift.notes(+hs: List<&2, String>, lines: List<&2, String>) -> List<&2, Note>:
485 match lines:
486 case []:
487 [Note{False{}, "imports: " ++ plural(List.length(&2, String, hs), "package", "packages") ++
488 " imported, every one named in ez.toml"}]
489 case h <> t:
490 flag(h <> t)
491
492# the report over what git listed, or why it could not list
493def listing.notes(listing: W.Listing, +ds: List<&2, M.Dep>) -> List<&2, Note>:
494 match listing:
495 case W.Listed{fs}:
496 +hs = imports(fs)
497 drift.notes(hs, drift(hs, ds))
498 case W.Unlisted{_why}:
499 [Note{True{}, "imports: git could not list the .bend files it tracks here"}]
500
501# the report, once the sources were asked for
502def sources.notes(got: DW.Heard, +ds: List<&2, M.Dep>) -> List<&2, Note>:
503 match got:
504 case DW.Heard{a}:
505 match a:
506 case DW.Listed{l}:
507 listing.notes(l, ds)
508 case _:
509 [Note{True{}, "imports: the sources were not read"}]
510 case DW.Open{}:
511 [Note{True{}, "imports: the sources were not read"}]
512
513# the report over a ledger. One that did not read is its own problem, and
514# there is nothing to compare the source with.
515def imports.notes(ledger: M.Read, got: DW.Heard) -> List<&2, Note>:
516 match ledger:
517 case M.Bad{_why}:
518 [Note{False{}, "imports: not compared, since ez.toml was not read"}]
519 case M.Good{m}:
520 sources.notes(got, deps.of(m))
521
522# ---------------------------------------------------------------------------
523# the lock against the lock `ez lock` would write
524
525# how a tree read from BEND_LIB arrived, as the lock judges it. A git
526# package's tree under BEND_LIB is one `ez lock` reads from there too. A hub
527# package's is one `ez lock` would have the hub serve: its name is the digest
528# of its manifest and each file is checked against its sum, so bytes that pass
529# are the bytes the hub serves under that name, and bytes that do not are
530# refused either way.
531def tree.how(src: L.Src) -> W.How:
532 match src:
533 case L.Hub{}:
534 W.Served{}
535 case L.Git{_url, _rev, _entry, _root, _nar, _tag}:
536 W.Lib{}
537 case L.Member{_path, _entry}:
538 W.Lib{}
539
540# the source a lock question names
541def ask.src(ask: W.Ask) -> L.Src:
542 match ask:
543 case W.Pkg{_hash, src, _hub}:
544 src
545 case W.Name{_nv, _hub}:
546 L.Hub{}
547 case W.Lock{}:
548 L.Hub{}
549
550# a tree BEND_LIB held, as the answer the lock reads, ahead of the rest. One
551# it did not hold answers nothing, so the lock goes on asking for it.
552def tree.put(+ask: W.Ask, answer: DW.Answer, rest: List<&2, W.Reply>) -> List<&2, W.Reply>:
553 match answer:
554 case DW.Laid{manifest, ss}:
555 W.Reply{ask, W.Got{tree.how(ask.src(ask)), manifest, ss}} <> rest
556 case _:
557 rest
558
559# a reply kept when it answers a lock question
560def tree.reply(ask: DW.Ask, answer: DW.Answer, rest: List<&2, W.Reply>) -> List<&2, W.Reply>:
561 match ask:
562 case DW.Tree{a}:
563 tree.put(a, answer, rest)
564 case _:
565 rest
566
567# every tree BEND_LIB held, as the lock's answers
568def trees(rs: List<&2, DW.Reply>) -> List<&2, W.Reply>:
569 match rs:
570 case []:
571 []
572 case DW.Reply{ask, answer} <> t:
573 tree.reply(ask, answer, trees(t))
574
575# the lock's text, "" when there is none, as the lock's own question reads it
576def lock.text(lock: Maybe<&2, String>) -> String:
577 match lock:
578 case None{}:
579 ""
580 case Some{text}:
581 text
582
583# a name's file BEND_LIB held, as the answer the lock reads for the name,
584# ahead of the rest. One it did not hold answers nothing, so the lock goes on
585# asking for it.
586def name.put(+nv: String, got: Maybe<&2, String>, rest: List<&2, W.Reply>) -> List<&2, W.Reply>:
587 match got:
588 case Some{text}:
589 W.Reply{W.Name{nv, "BEND_LIB/names"}, W.Said{text}} <> rest
590 case None{}:
591 rest
592
593# a reply kept when it answers a name's question
594def name.reply(ask: DW.Ask, answer: DW.Answer, rest: List<&2, W.Reply>) -> List<&2, W.Reply>:
595 match ask:
596 case DW.Name{nv}:
597 match answer:
598 case DW.Held{got}:
599 name.put(nv, got, rest)
600 case _:
601 rest
602 case _:
603 rest
604
605# every name's file BEND_LIB held, as the lock's answers
606def held(rs: List<&2, DW.Reply>) -> List<&2, W.Reply>:
607 match rs:
608 case []:
609 []
610 case DW.Reply{ask, answer} <> t:
611 name.reply(ask, answer, held(t))
612
613# the World doctor puts to the lock planner: a plain lock over the ledger's
614# text as it is, the sources as git listed them, the lock as it is, which
615# answers the lock's question about the names it records, every tree
616# BEND_LIB held, and every name's file it held, never the network. The
617# directory a lock runs in only anchors the questions it asks, which doctor
618# answers by hash from BEND_LIB, so it is left empty.
619def relock.world(
620 ledger: Maybe<&2, String>,
621 lock: Maybe<&2, String>,
622 listing: W.Listing,
623 +rs: List<&2, DW.Reply>
624) -> W.World:
625 W.World{W.Args{False{}, "", ""}, ledger, listing,
626 List.append(&2, W.Reply, trees(rs), W.Reply{W.Lock{}, W.Locked{lock.text(lock)}} <> held(rs)),
627 []}
628
629# that World, over the sources as a list of files
630def relock(+world: DW.World, fs: List<&2, W.Source>) -> W.World:
631 relock.world(DW.ledger(world), DW.lock(world), W.Listed{fs}, DW.replies(world))
632
633# the line for a lock `ez lock` would write as it is
634def fresh.line() -> String:
635 DW.lockfile() ++ ": up to date with ez.toml and the sources"
636
637# the line for one it would write otherwise
638def stale.line() -> String:
639 DW.lockfile() ++ ": out of date with ez.toml and the sources; run ez lock"
640
641# the verdict, once the two texts were compared
642def fresh.said(same: Bool) -> Note:
643 match same:
644 case True{}:
645 Note{False{}, fresh.line()}
646 case False{}:
647 Note{True{}, stale.line()}
648
649# the lock against what the lock planner decided: the text it would write, or
650# why it would write none
651def fresh.doc(doc: P.Doc, +text: String) -> Note:
652 match doc:
653 case P.Doc{made}:
654 fresh.said(String.eq(made, text))
655 case P.Stop{why}:
656 Note{True{}, DW.lockfile() ++ ": not checked, since ez lock refuses here: " ++ why}
657
658# the hashes of lock questions
659def ask.hashes(as: List<&2, W.Ask>) -> List<&2, String>:
660 match as:
661 case []:
662 []
663 case h <> t:
664 W.ask.hash(h) <> ask.hashes(t)
665
666# the lock, once every name the lock planner asks about is known: when none
667# is left, what it decided; when some are, their files are not under
668# BEND_LIB, and doctor does not ask the hub for them
669def fresh.named(as: List<&2, W.Ask>, +lw: W.World, +text: String) -> Note:
670 match as:
671 case []:
672 fresh.doc(P.decide(W.inputs(lw)), text)
673 case h <> t:
674 Note{True{}, DW.lockfile() ++ ": not checked, since BEND_LIB lacks names/" ++
675 String.join(ask.hashes(h <> t), " names/") ++ " and doctor does not use the network"}
676
677# the lock, once every question the lock planner asks is known: when none is
678# left, its names, then what it decided; when some are, they are about
679# packages BEND_LIB does not hold, and doctor does not ask the network for
680# them
681def fresh.away(as: List<&2, W.Ask>, +lw: W.World, +text: String) -> Note:
682 match as:
683 case []:
684 fresh.named(P.wants.names(lw), lw, text)
685 case h <> t:
686 Note{True{}, DW.lockfile() ++ ": not checked, since BEND_LIB lacks " ++
687 String.join(ask.hashes(h <> t), " ") ++ " and doctor does not use the network"}
688
689# the check over the sources git listed. Sources git could not list are the
690# drift report's to say.
691def fresh.listing(+world: DW.World, listing: W.Listing, +text: String) -> List<&2, Note>:
692 match listing:
693 case W.Listed{fs}:
694 +lw = relock(world, fs)
695 [fresh.away(P.wants(lw), lw, text)]
696 case W.Unlisted{_why}:
697 []
698
699# the check, once the sources were heard. They are asked for only when the
700# ledger reads, so a ledger that does not is not checked against.
701def fresh.heard(got: DW.Heard, +world: DW.World, +text: String) -> List<&2, Note>:
702 match got:
703 case DW.Heard{a}:
704 match a:
705 case DW.Listed{l}:
706 fresh.listing(world, l, text)
707 case _:
708 []
709 case DW.Open{}:
710 []
711
712# the check over a lock that is there. With no lock there is nothing to
713# compare, and the lock's own line says whether that is a problem.
714def fresh.notes(lock: Maybe<&2, String>, +world: DW.World) -> List<&2, Note>:
715 match lock:
716 case None{}:
717 []
718 case Some{text}:
719 fresh.heard(DW.sources(world), world, text)
720
721# what doctor says of the lock against the lock `ez lock` would write
722def checked(+world: DW.World) -> List<&2, Note>:
723 fresh.notes(DW.lock(world), world)
724
725# each lock question, put to BEND_LIB
726def trees.asked(as: List<&2, W.Ask>) -> List<&2, DW.Ask>:
727 match as:
728 case []:
729 []
730 case h <> t:
731 DW.Tree{h} <> trees.asked(t)
732
733# each of the lock's name questions, put to BEND_LIB as the name's file. The
734# lock's own question never reaches here, since the World answers it.
735def names.asked(as: List<&2, W.Ask>) -> List<&2, DW.Ask>:
736 match as:
737 case []:
738 []
739 case h <> t:
740 DW.Name{W.ask.hash(h)} <> names.asked(t)
741
742# the lock questions over the sources git listed, packages and then names
743def trees.world(+lw: W.World) -> List<&2, DW.Ask>:
744 List.append(&2, DW.Ask, trees.asked(P.wants(lw)), names.asked(P.wants.names(lw)))
745
746def trees.listing(
747 ledger: Maybe<&2, String>,
748 lock: Maybe<&2, String>,
749 listing: W.Listing,
750 rs: List<&2, DW.Reply>
751) -> List<&2, DW.Ask>:
752 match listing:
753 case W.Listed{fs}:
754 trees.world(relock.world(ledger, lock, W.Listed{fs}, rs))
755 case W.Unlisted{_why}:
756 []
757
758# the lock questions, once the sources were heard
759def trees.heard(
760 got: DW.Heard,
761 ledger: Maybe<&2, String>,
762 lock: Maybe<&2, String>,
763 rs: List<&2, DW.Reply>
764) -> List<&2, DW.Ask>:
765 match got:
766 case DW.Heard{a}:
767 match a:
768 case DW.Listed{l}:
769 trees.listing(ledger, lock, l, rs)
770 case _:
771 []
772 case DW.Open{}:
773 []
774
775# every package and name the lock planner asks about, while there is a lock
776# to check. These depend on the sources and on the trees already read, so
777# they come in rounds, as the lock's own do.
778def trees.of(
779 ledger: Maybe<&2, String>,
780 lock: Maybe<&2, String>,
781 held: Maybe<&2, String>,
782 +rs: List<&2, DW.Reply>
783) -> List<&2, DW.Ask>:
784 match lock:
785 case None{}:
786 []
787 case Some{_text}:
788 trees.heard(DW.heard(rs, DW.Sources{}), ledger, held, rs)
789
790# ---------------------------------------------------------------------------
791# the plan
792
793# the ledger as doctor sees it: its package, its entry and its dependencies.
794# Its `[tools.*]` pins are not dependencies, so they are dropped before
795# anything but the lock check is decided (EZ-LED-5). The lock check reads the
796# ledger's text as `ez lock` does, since the lock records the pins.
797def ledger.seen(ledger: M.Read) -> M.Read:
798 match ledger:
799 case M.Bad{why}:
800 M.Bad{why}
801 case M.Good{m}:
802 M.Manifest{n, e, b, h, pa, pv, ds, _ts} = m
803 M.Good{M.Manifest{n, e, b, h, pa, pv, ds, []}}
804
805# the toolchain, the ledger, the lock and the library, each on its line, and
806# the lock check's line after them
807def head.notes(
808 +ledger: M.Read,
809 +cc: String,
810 +lib: String,
811 +lock: Maybe<&2, String>,
812 +rs: List<&2, DW.Reply>,
813 cs: List<&2, Note>
814) -> List<&2, Note>:
815 +lk = locked.of(lock)
816 List.append(&2, Note, probes(programs(cc), rs),
817 List.append(&2, Note,
818 [ledger.note(ledger), lock.note(deps.count(ledger), lk),
819 lib.note(lib.at(lib), locked.hashes(lk), lock.names(lock), rs)], cs))
820
821# every line doctor says, in order: the toolchain, the ledger, the lock, the
822# library, the lock check, and last the drift report over the sources as they
823# were heard
824def notes.of(
825 +ledger: M.Read,
826 got: DW.Heard,
827 +cc: String,
828 +lib: String,
829 lock: Maybe<&2, String>,
830 +rs: List<&2, DW.Reply>,
831 cs: List<&2, Note>
832) -> List<&2, Note>:
833 List.append(&2, Note, head.notes(ledger, cc, lib, lock, rs, cs), imports.notes(ledger, got))
834
835# whether a note is a problem
836def note.wrong(finding: Note) -> Bool:
837 Note{w, _l} = finding
838 w
839
840# whether anything found was a problem. The rest arrives as a thunk and only
841# the arm that wants it applies it.
842def wrong(ns: List<&2, Note>) -> Bool:
843 match ns:
844 case []:
845 False{}
846 case h <> t:
847 has.step(note.wrong(h), _u => wrong(t))
848
849# each line said
850def says(ns: List<&2, Note>) -> List<&2, P.Effect>:
851 match ns:
852 case []:
853 []
854 case Note{_w, l} <> t:
855 P.Say{l} <> says(t)
856
857# a problem must fail the command, or a script around it learns nothing. The
858# lines already said what the problem is.
859def outcome.of(bad: Bool) -> P.Outcome:
860 match bad:
861 case True{}:
862 P.Refused{""}
863 case False{}:
864 P.Success{}
865
866# the plan for these notes: every line said, and the command failed when one
867# of them is a problem
868def plan.notes(+ns: List<&2, Note>) -> P.Plan:
869 P.Plan{says(ns), outcome.of(wrong(ns))}
870
871# the plan over what a World read, field by field, with the lock check's
872# lines
873def plan.of(
874 +ledger: M.Read,
875 +cc: String,
876 +lib: String,
877 lock: Maybe<&2, String>,
878 +rs: List<&2, DW.Reply>,
879 cs: List<&2, Note>
880) -> P.Plan:
881 plan.notes(notes.of(ledger, DW.heard(rs, DW.Sources{}), cc, lib, lock, rs, cs))
882
883# `ez doctor` over a World
884def plan(+world: DW.World) -> P.Plan:
885 plan.of(ledger.seen(DW.read(world)), DW.cc(world), DW.lib(world), DW.lock(world),
886 DW.replies(world), checked(world))
887
888# whether a plan ends in a refusal
889def refuses.plan(pl: P.Plan) -> Bool:
890 P.Plan{_es, outcome} = pl
891 match outcome:
892 case P.Success{}:
893 False{}
894 case P.Refused{_why}:
895 True{}
896
897# whether `ez doctor` fails on a World
898def refuses(+world: DW.World) -> Bool:
899 refuses.plan(plan(world))
900
901# ---------------------------------------------------------------------------
902# what is still to ask
903
904# a question kept when it has no answer yet
905def open.put(got: DW.Heard, ask: DW.Ask, rest: List<&2, DW.Ask>) -> List<&2, DW.Ask>:
906 match got:
907 case DW.Heard{_a}:
908 rest
909 case DW.Open{}:
910 ask <> rest
911
912# the questions of a list that have no answer yet
913def open(asks: List<&2, DW.Ask>, +rs: List<&2, DW.Reply>) -> List<&2, DW.Ask>:
914 match asks:
915 case []:
916 []
917 case +h <> t:
918 open.put(DW.heard(rs, h), h, open(t, rs))
919
920# a version question for every program
921def probe.asks(ns: List<&2, String>) -> List<&2, DW.Ask>:
922 match ns:
923 case []:
924 []
925 case h <> t:
926 DW.Probe{h} <> probe.asks(t)
927
928# the library is looked in only when the lock names a package to look for
929def lib.asks(+dir: String, hs: List<&2, String>) -> List<&2, DW.Ask>:
930 match hs:
931 case []:
932 []
933 case _h <> _t:
934 [DW.Lib{dir}]
935
936# the sources are read only when there is a ledger to compare them with
937def sources.asks(ledger: M.Read) -> List<&2, DW.Ask>:
938 match ledger:
939 case M.Bad{_why}:
940 []
941 case M.Good{_m}:
942 [DW.Sources{}]
943
944# a question for every name's file the lock records
945def names.asks(ns: List<&2, L.Name>) -> List<&2, DW.Ask>:
946 match ns:
947 case []:
948 []
949 case L.Name{nv, _hash} <> t:
950 DW.Name{nv} <> names.asks(t)
951
952# every question doctor asks on what a World read, besides the lock check's
953def asks.of(ledger: M.Read, +cc: String, +lib: String, +lock: Maybe<&2, String>) -> List<&2, DW.Ask>:
954 List.append(&2, DW.Ask, probe.asks(programs(cc)),
955 List.append(&2, DW.Ask, lib.asks(lib.at(lib), locked.hashes(locked.of(lock))),
956 List.append(&2, DW.Ask, names.asks(lock.names(lock)), sources.asks(ledger))))
957
958# the questions still open, or the plan once there are none
959type Step is Data:
960 Asking{asks: List<&2, DW.Ask>}
961 Run{plan: P.Plan}
962
963# the step, once the open questions are known
964def step.asks(
965 asks: List<&2, DW.Ask>,
966 +ledger: M.Read,
967 +cc: String,
968 +lib: String,
969 lock: Maybe<&2, String>,
970 +rs: List<&2, DW.Reply>,
971 cs: List<&2, Note>
972) -> Step:
973 match asks:
974 case []:
975 Run{plan.of(ledger, cc, lib, lock, rs, cs)}
976 case h <> t:
977 Asking{h <> t}
978
979# the step over what a World read, field by field, with the lock check's
980# questions and lines. The first round asks everything but the lock check's
981# trees, which follow the sources and each other, as the lock's own questions
982# do.
983def step.of(
984 +ledger: M.Read,
985 +cc: String,
986 +lib: String,
987 +lock: Maybe<&2, String>,
988 +rs: List<&2, DW.Reply>,
989 ts: List<&2, DW.Ask>,
990 cs: List<&2, Note>
991) -> Step:
992 step.asks(open(List.append(&2, DW.Ask, asks.of(ledger, cc, lib, lock), ts), rs),
993 ledger, cc, lib, lock, rs, cs)
994
995# what the interpreter does next on a World
996def step(+world: DW.World) -> Step:
997 step.of(ledger.seen(DW.read(world)), DW.cc(world), DW.lib(world), DW.lock(world),
998 DW.replies(world), trees.of(DW.ledger(world), DW.lock(world), DW.lock(world), DW.replies(world)),
999 checked(world))
1000
1001# the questions a World still leaves open
1002def wants(+world: DW.World) -> List<&2, DW.Ask>:
1003 +ledger = ledger.seen(DW.read(world))
1004 open(List.append(&2, DW.Ask, asks.of(ledger, DW.cc(world), DW.lib(world), DW.lock(world)),
1005 trees.of(DW.ledger(world), DW.lock(world), DW.lock(world), DW.replies(world))), DW.replies(world))
1006
1007# ---------------------------------------------------------------------------
1008# law vocabulary: ez never calls these
1009
1010# whether an effect says this line
1011def said.one(effect: P.Effect, +line: String) -> Bool:
1012 match effect:
1013 case P.Say{text}:
1014 String.eq(text, line)
1015 case P.Write{_at, _text}:
1016 False{}
1017 case P.Lay{_place, _hash, _files}:
1018 False{}
1019 case P.Drop{_hash}:
1020 False{}
1021 case P.Name{_nv, _hash}:
1022 False{}
1023
1024# whether a list of effects says this line
1025def said.in(es: List<&2, P.Effect>, +line: String) -> Bool:
1026 match es:
1027 case []:
1028 False{}
1029 case e <> t:
1030 has.step(said.one(e, line), _u => said.in(t, line))
1031
1032# whether a plan says this line
1033def said(pl: P.Plan, +line: String) -> Bool:
1034 P.Plan{es, _outcome} = pl
1035 said.in(es, line)
1036
1037# the drift report doctor makes of a World's sources against a ledger's
1038# dependencies
1039def drift.of(fs: List<&2, W.Source>, +ds: List<&2, M.Dep>) -> List<&2, String>:
1040 +hs = imports(fs)
1041 drift(hs, ds)