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)