src/doctor/plan.bend

ezx@1.7.0.0 · 1,039 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.7.0.0/src/toml/toml.bend as T
54import ezx@1.7.0.0/src/doctor/world.bend as DW
55import ezx@1.7.0.0/src/lock/plan.bend as P
56import ezx@1.7.0.0/src/lock/world.bend as W
57import ezx@1.7.0.0/src/lock/lock.bend as L
58import ezx@1.7.0.0/src/ledger/manifest.bend as M
59import ezx@1.7.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
538# the source a lock question names
539def ask.src(ask: W.Ask) -> L.Src:
540  match ask:
541    case W.Pkg{_hash, src, _hub}:
542      src
543    case W.Name{_nv, _hub}:
544      L.Hub{}
545    case W.Lock{}:
546      L.Hub{}
547
548# a tree BEND_LIB held, as the answer the lock reads, ahead of the rest. One
549# it did not hold answers nothing, so the lock goes on asking for it.
550def tree.put(+ask: W.Ask, answer: DW.Answer, rest: List<&2, W.Reply>) -> List<&2, W.Reply>:
551  match answer:
552    case DW.Laid{manifest, ss}:
553      W.Reply{ask, W.Got{tree.how(ask.src(ask)), manifest, ss}} <> rest
554    case _:
555      rest
556
557# a reply kept when it answers a lock question
558def tree.reply(ask: DW.Ask, answer: DW.Answer, rest: List<&2, W.Reply>) -> List<&2, W.Reply>:
559  match ask:
560    case DW.Tree{a}:
561      tree.put(a, answer, rest)
562    case _:
563      rest
564
565# every tree BEND_LIB held, as the lock's answers
566def trees(rs: List<&2, DW.Reply>) -> List<&2, W.Reply>:
567  match rs:
568    case []:
569      []
570    case DW.Reply{ask, answer} <> t:
571      tree.reply(ask, answer, trees(t))
572
573# the lock's text, "" when there is none, as the lock's own question reads it
574def lock.text(lock: Maybe<&2, String>) -> String:
575  match lock:
576    case None{}:
577      ""
578    case Some{text}:
579      text
580
581# a name's file BEND_LIB held, as the answer the lock reads for the name,
582# ahead of the rest. One it did not hold answers nothing, so the lock goes on
583# asking for it.
584def name.put(+nv: String, got: Maybe<&2, String>, rest: List<&2, W.Reply>) -> List<&2, W.Reply>:
585  match got:
586    case Some{text}:
587      W.Reply{W.Name{nv, "BEND_LIB/names"}, W.Said{text}} <> rest
588    case None{}:
589      rest
590
591# a reply kept when it answers a name's question
592def name.reply(ask: DW.Ask, answer: DW.Answer, rest: List<&2, W.Reply>) -> List<&2, W.Reply>:
593  match ask:
594    case DW.Name{nv}:
595      match answer:
596        case DW.Held{got}:
597          name.put(nv, got, rest)
598        case _:
599          rest
600    case _:
601      rest
602
603# every name's file BEND_LIB held, as the lock's answers
604def held(rs: List<&2, DW.Reply>) -> List<&2, W.Reply>:
605  match rs:
606    case []:
607      []
608    case DW.Reply{ask, answer} <> t:
609      name.reply(ask, answer, held(t))
610
611# the World doctor puts to the lock planner: a plain lock over the ledger's
612# text as it is, the sources as git listed them, the lock as it is, which
613# answers the lock's question about the names it records, every tree
614# BEND_LIB held, and every name's file it held, never the network. The
615# directory a lock runs in only anchors the questions it asks, which doctor
616# answers by hash from BEND_LIB, so it is left empty.
617def relock.world(
618  ledger: Maybe<&2, String>,
619  lock: Maybe<&2, String>,
620  listing: W.Listing,
621  +rs: List<&2, DW.Reply>
622) -> W.World:
623  W.World{W.Args{False{}, "", ""}, ledger, listing,
624    List.append(&2, W.Reply, trees(rs), W.Reply{W.Lock{}, W.Locked{lock.text(lock)}} <> held(rs)),
625    []}
626
627# that World, over the sources as a list of files
628def relock(+world: DW.World, fs: List<&2, W.Source>) -> W.World:
629  relock.world(DW.ledger(world), DW.lock(world), W.Listed{fs}, DW.replies(world))
630
631# the line for a lock `ez lock` would write as it is
632def fresh.line() -> String:
633  DW.lockfile() ++ ": up to date with ez.toml and the sources"
634
635# the line for one it would write otherwise
636def stale.line() -> String:
637  DW.lockfile() ++ ": out of date with ez.toml and the sources; run ez lock"
638
639# the verdict, once the two texts were compared
640def fresh.said(same: Bool) -> Note:
641  match same:
642    case True{}:
643      Note{False{}, fresh.line()}
644    case False{}:
645      Note{True{}, stale.line()}
646
647# the lock against what the lock planner decided: the text it would write, or
648# why it would write none
649def fresh.doc(doc: P.Doc, +text: String) -> Note:
650  match doc:
651    case P.Doc{made}:
652      fresh.said(String.eq(made, text))
653    case P.Stop{why}:
654      Note{True{}, DW.lockfile() ++ ": not checked, since ez lock refuses here: " ++ why}
655
656# the hashes of lock questions
657def ask.hashes(as: List<&2, W.Ask>) -> List<&2, String>:
658  match as:
659    case []:
660      []
661    case h <> t:
662      W.ask.hash(h) <> ask.hashes(t)
663
664# the lock, once every name the lock planner asks about is known: when none
665# is left, what it decided; when some are, their files are not under
666# BEND_LIB, and doctor does not ask the hub for them
667def fresh.named(as: List<&2, W.Ask>, +lw: W.World, +text: String) -> Note:
668  match as:
669    case []:
670      fresh.doc(P.decide(W.inputs(lw)), text)
671    case h <> t:
672      Note{True{}, DW.lockfile() ++ ": not checked, since BEND_LIB lacks names/" ++
673        String.join(ask.hashes(h <> t), " names/") ++ " and doctor does not use the network"}
674
675# the lock, once every question the lock planner asks is known: when none is
676# left, its names, then what it decided; when some are, they are about
677# packages BEND_LIB does not hold, and doctor does not ask the network for
678# them
679def fresh.away(as: List<&2, W.Ask>, +lw: W.World, +text: String) -> Note:
680  match as:
681    case []:
682      fresh.named(P.wants.names(lw), lw, text)
683    case h <> t:
684      Note{True{}, DW.lockfile() ++ ": not checked, since BEND_LIB lacks " ++
685        String.join(ask.hashes(h <> t), " ") ++ " and doctor does not use the network"}
686
687# the check over the sources git listed. Sources git could not list are the
688# drift report's to say.
689def fresh.listing(+world: DW.World, listing: W.Listing, +text: String) -> List<&2, Note>:
690  match listing:
691    case W.Listed{fs}:
692      +lw = relock(world, fs)
693      [fresh.away(P.wants(lw), lw, text)]
694    case W.Unlisted{_why}:
695      []
696
697# the check, once the sources were heard. They are asked for only when the
698# ledger reads, so a ledger that does not is not checked against.
699def fresh.heard(got: DW.Heard, +world: DW.World, +text: String) -> List<&2, Note>:
700  match got:
701    case DW.Heard{a}:
702      match a:
703        case DW.Listed{l}:
704          fresh.listing(world, l, text)
705        case _:
706          []
707    case DW.Open{}:
708      []
709
710# the check over a lock that is there. With no lock there is nothing to
711# compare, and the lock's own line says whether that is a problem.
712def fresh.notes(lock: Maybe<&2, String>, +world: DW.World) -> List<&2, Note>:
713  match lock:
714    case None{}:
715      []
716    case Some{text}:
717      fresh.heard(DW.sources(world), world, text)
718
719# what doctor says of the lock against the lock `ez lock` would write
720def checked(+world: DW.World) -> List<&2, Note>:
721  fresh.notes(DW.lock(world), world)
722
723# each lock question, put to BEND_LIB
724def trees.asked(as: List<&2, W.Ask>) -> List<&2, DW.Ask>:
725  match as:
726    case []:
727      []
728    case h <> t:
729      DW.Tree{h} <> trees.asked(t)
730
731# each of the lock's name questions, put to BEND_LIB as the name's file. The
732# lock's own question never reaches here, since the World answers it.
733def names.asked(as: List<&2, W.Ask>) -> List<&2, DW.Ask>:
734  match as:
735    case []:
736      []
737    case h <> t:
738      DW.Name{W.ask.hash(h)} <> names.asked(t)
739
740# the lock questions over the sources git listed, packages and then names
741def trees.world(+lw: W.World) -> List<&2, DW.Ask>:
742  List.append(&2, DW.Ask, trees.asked(P.wants(lw)), names.asked(P.wants.names(lw)))
743
744def trees.listing(
745  ledger: Maybe<&2, String>,
746  lock: Maybe<&2, String>,
747  listing: W.Listing,
748  rs: List<&2, DW.Reply>
749) -> List<&2, DW.Ask>:
750  match listing:
751    case W.Listed{fs}:
752      trees.world(relock.world(ledger, lock, W.Listed{fs}, rs))
753    case W.Unlisted{_why}:
754      []
755
756# the lock questions, once the sources were heard
757def trees.heard(
758  got: DW.Heard,
759  ledger: Maybe<&2, String>,
760  lock: Maybe<&2, String>,
761  rs: List<&2, DW.Reply>
762) -> List<&2, DW.Ask>:
763  match got:
764    case DW.Heard{a}:
765      match a:
766        case DW.Listed{l}:
767          trees.listing(ledger, lock, l, rs)
768        case _:
769          []
770    case DW.Open{}:
771      []
772
773# every package and name the lock planner asks about, while there is a lock
774# to check. These depend on the sources and on the trees already read, so
775# they come in rounds, as the lock's own do.
776def trees.of(
777  ledger: Maybe<&2, String>,
778  lock: Maybe<&2, String>,
779  held: Maybe<&2, String>,
780  +rs: List<&2, DW.Reply>
781) -> List<&2, DW.Ask>:
782  match lock:
783    case None{}:
784      []
785    case Some{_text}:
786      trees.heard(DW.heard(rs, DW.Sources{}), ledger, held, rs)
787
788# ---------------------------------------------------------------------------
789# the plan
790
791# the ledger as doctor sees it: its package, its entry and its dependencies.
792# Its `[tools.*]` pins are not dependencies, so they are dropped before
793# anything but the lock check is decided (EZ-LED-5). The lock check reads the
794# ledger's text as `ez lock` does, since the lock records the pins.
795def ledger.seen(ledger: M.Read) -> M.Read:
796  match ledger:
797    case M.Bad{why}:
798      M.Bad{why}
799    case M.Good{m}:
800      M.Manifest{n, e, b, h, pa, pv, ds, _ts} = m
801      M.Good{M.Manifest{n, e, b, h, pa, pv, ds, []}}
802
803# the toolchain, the ledger, the lock and the library, each on its line, and
804# the lock check's line after them
805def head.notes(
806  +ledger: M.Read,
807  +cc: String,
808  +lib: String,
809  +lock: Maybe<&2, String>,
810  +rs: List<&2, DW.Reply>,
811  cs: List<&2, Note>
812) -> List<&2, Note>:
813  +lk = locked.of(lock)
814  List.append(&2, Note, probes(programs(cc), rs),
815    List.append(&2, Note,
816      [ledger.note(ledger), lock.note(deps.count(ledger), lk),
817        lib.note(lib.at(lib), locked.hashes(lk), lock.names(lock), rs)], cs))
818
819# every line doctor says, in order: the toolchain, the ledger, the lock, the
820# library, the lock check, and last the drift report over the sources as they
821# were heard
822def notes.of(
823  +ledger: M.Read,
824  got: DW.Heard,
825  +cc: String,
826  +lib: String,
827  lock: Maybe<&2, String>,
828  +rs: List<&2, DW.Reply>,
829  cs: List<&2, Note>
830) -> List<&2, Note>:
831  List.append(&2, Note, head.notes(ledger, cc, lib, lock, rs, cs), imports.notes(ledger, got))
832
833# whether a note is a problem
834def note.wrong(finding: Note) -> Bool:
835  Note{w, _l} = finding
836  w
837
838# whether anything found was a problem. The rest arrives as a thunk and only
839# the arm that wants it applies it.
840def wrong(ns: List<&2, Note>) -> Bool:
841  match ns:
842    case []:
843      False{}
844    case h <> t:
845      has.step(note.wrong(h), _u => wrong(t))
846
847# each line said
848def says(ns: List<&2, Note>) -> List<&2, P.Effect>:
849  match ns:
850    case []:
851      []
852    case Note{_w, l} <> t:
853      P.Say{l} <> says(t)
854
855# a problem must fail the command, or a script around it learns nothing. The
856# lines already said what the problem is.
857def outcome.of(bad: Bool) -> P.Outcome:
858  match bad:
859    case True{}:
860      P.Refused{""}
861    case False{}:
862      P.Success{}
863
864# the plan for these notes: every line said, and the command failed when one
865# of them is a problem
866def plan.notes(+ns: List<&2, Note>) -> P.Plan:
867  P.Plan{says(ns), outcome.of(wrong(ns))}
868
869# the plan over what a World read, field by field, with the lock check's
870# lines
871def plan.of(
872  +ledger: M.Read,
873  +cc: String,
874  +lib: String,
875  lock: Maybe<&2, String>,
876  +rs: List<&2, DW.Reply>,
877  cs: List<&2, Note>
878) -> P.Plan:
879  plan.notes(notes.of(ledger, DW.heard(rs, DW.Sources{}), cc, lib, lock, rs, cs))
880
881# `ez doctor` over a World
882def plan(+world: DW.World) -> P.Plan:
883  plan.of(ledger.seen(DW.read(world)), DW.cc(world), DW.lib(world), DW.lock(world),
884    DW.replies(world), checked(world))
885
886# whether a plan ends in a refusal
887def refuses.plan(pl: P.Plan) -> Bool:
888  P.Plan{_es, outcome} = pl
889  match outcome:
890    case P.Success{}:
891      False{}
892    case P.Refused{_why}:
893      True{}
894
895# whether `ez doctor` fails on a World
896def refuses(+world: DW.World) -> Bool:
897  refuses.plan(plan(world))
898
899# ---------------------------------------------------------------------------
900# what is still to ask
901
902# a question kept when it has no answer yet
903def open.put(got: DW.Heard, ask: DW.Ask, rest: List<&2, DW.Ask>) -> List<&2, DW.Ask>:
904  match got:
905    case DW.Heard{_a}:
906      rest
907    case DW.Open{}:
908      ask <> rest
909
910# the questions of a list that have no answer yet
911def open(asks: List<&2, DW.Ask>, +rs: List<&2, DW.Reply>) -> List<&2, DW.Ask>:
912  match asks:
913    case []:
914      []
915    case +h <> t:
916      open.put(DW.heard(rs, h), h, open(t, rs))
917
918# a version question for every program
919def probe.asks(ns: List<&2, String>) -> List<&2, DW.Ask>:
920  match ns:
921    case []:
922      []
923    case h <> t:
924      DW.Probe{h} <> probe.asks(t)
925
926# the library is looked in only when the lock names a package to look for
927def lib.asks(+dir: String, hs: List<&2, String>) -> List<&2, DW.Ask>:
928  match hs:
929    case []:
930      []
931    case _h <> _t:
932      [DW.Lib{dir}]
933
934# the sources are read only when there is a ledger to compare them with
935def sources.asks(ledger: M.Read) -> List<&2, DW.Ask>:
936  match ledger:
937    case M.Bad{_why}:
938      []
939    case M.Good{_m}:
940      [DW.Sources{}]
941
942# a question for every name's file the lock records
943def names.asks(ns: List<&2, L.Name>) -> List<&2, DW.Ask>:
944  match ns:
945    case []:
946      []
947    case L.Name{nv, _hash} <> t:
948      DW.Name{nv} <> names.asks(t)
949
950# every question doctor asks on what a World read, besides the lock check's
951def asks.of(ledger: M.Read, +cc: String, +lib: String, +lock: Maybe<&2, String>) -> List<&2, DW.Ask>:
952  List.append(&2, DW.Ask, probe.asks(programs(cc)),
953    List.append(&2, DW.Ask, lib.asks(lib.at(lib), locked.hashes(locked.of(lock))),
954      List.append(&2, DW.Ask, names.asks(lock.names(lock)), sources.asks(ledger))))
955
956# the questions still open, or the plan once there are none
957type Step is Data:
958  Asking{asks: List<&2, DW.Ask>}
959  Run{plan: P.Plan}
960
961# the step, once the open questions are known
962def step.asks(
963  asks: List<&2, DW.Ask>,
964  +ledger: M.Read,
965  +cc: String,
966  +lib: String,
967  lock: Maybe<&2, String>,
968  +rs: List<&2, DW.Reply>,
969  cs: List<&2, Note>
970) -> Step:
971  match asks:
972    case []:
973      Run{plan.of(ledger, cc, lib, lock, rs, cs)}
974    case h <> t:
975      Asking{h <> t}
976
977# the step over what a World read, field by field, with the lock check's
978# questions and lines. The first round asks everything but the lock check's
979# trees, which follow the sources and each other, as the lock's own questions
980# do.
981def step.of(
982  +ledger: M.Read,
983  +cc: String,
984  +lib: String,
985  +lock: Maybe<&2, String>,
986  +rs: List<&2, DW.Reply>,
987  ts: List<&2, DW.Ask>,
988  cs: List<&2, Note>
989) -> Step:
990  step.asks(open(List.append(&2, DW.Ask, asks.of(ledger, cc, lib, lock), ts), rs),
991    ledger, cc, lib, lock, rs, cs)
992
993# what the interpreter does next on a World
994def step(+world: DW.World) -> Step:
995  step.of(ledger.seen(DW.read(world)), DW.cc(world), DW.lib(world), DW.lock(world),
996    DW.replies(world), trees.of(DW.ledger(world), DW.lock(world), DW.lock(world), DW.replies(world)),
997    checked(world))
998
999# the questions a World still leaves open
1000def wants(+world: DW.World) -> List<&2, DW.Ask>:
1001  +ledger = ledger.seen(DW.read(world))
1002  open(List.append(&2, DW.Ask, asks.of(ledger, DW.cc(world), DW.lib(world), DW.lock(world)),
1003    trees.of(DW.ledger(world), DW.lock(world), DW.lock(world), DW.replies(world))), DW.replies(world))
1004
1005# ---------------------------------------------------------------------------
1006# law vocabulary: ez never calls these
1007
1008# whether an effect says this line
1009def said.one(effect: P.Effect, +line: String) -> Bool:
1010  match effect:
1011    case P.Say{text}:
1012      String.eq(text, line)
1013    case P.Write{_at, _text}:
1014      False{}
1015    case P.Lay{_place, _hash, _files}:
1016      False{}
1017    case P.Drop{_hash}:
1018      False{}
1019    case P.Name{_nv, _hash}:
1020      False{}
1021
1022# whether a list of effects says this line
1023def said.in(es: List<&2, P.Effect>, +line: String) -> Bool:
1024  match es:
1025    case []:
1026      False{}
1027    case e <> t:
1028      has.step(said.one(e, line), _u => said.in(t, line))
1029
1030# whether a plan says this line
1031def said(pl: P.Plan, +line: String) -> Bool:
1032  P.Plan{es, _outcome} = pl
1033  said.in(es, line)
1034
1035# the drift report doctor makes of a World's sources against a ledger's
1036# dependencies
1037def drift.of(fs: List<&2, W.Source>, +ds: List<&2, M.Dep>) -> List<&2, String>:
1038  +hs = imports(fs)
1039  drift(hs, ds)