1 # pub/plan: the planner for `ez publish`, pure. It reads a World 2 # (pub/world.bend) and either asks the questions still open or makes the plan. 3 # The package is sent to the hub by `bend --publish`, which is the only thing 4 # that can send it, but the `0x` name ez reports is the one the package walk 5 # (`pkg/pkg.bend`) computed from the files, never the one bend printed. 6 # Reading bend's output is a check, not a lookup: ez already knows the answer 7 # before bend runs, and what bend says is compared against it. 8 # 9 # Publishing is irreversible and public, so every check that can be made 10 # before the upload is made first, in this order: there is a ledger that 11 # parses, and it names the entry, and the name it publishes under, if any, is 12 # one bend takes (EZ-PUB-3); git answers `git status --porcelain 13 # --untracked-files=normal` about the project and names no path (EZ-PUB-1); 14 # the walk from the entry makes a package; git tracks every file of that 15 # package; and the hub the ledger names has every hub package the package 16 # imports (EZ-PUB-5). Only then is the upload asked for, and its answer is checked 17 # against ez's hash (EZ-PUB-2). bend cannot report a package's hash without 18 # uploading it, so that check comes after the upload, and a disagreement 19 # exits 1 with no import line rather than taking the upload back. 20 # 21 # Each stage of `decide` reads what it needs from the World itself rather 22 # than from the stage before, so a law about one stage holds of every World 23 # whatever the others say. 24 import Base 25 import ../lock/plan.bend as P 26 import ../tool/world.bend as TW 27 import ../ledger/manifest.bend as M 28 import ../pkg/pkg.bend as K 29 import ../pkg/path.bend as Path 30 import ../git/git.bend as G 31 import ./world.bend as PW 32 import ./blurb.bend as B 33 import ../lock/lock.bend as L 34 import ../share/sha.bend as Sha 35 import ../io/file.bend as F 36 37 # --------------------------------------------------------------------------- 38 # bend's answer 39 40 # how many characters a `0x` name is written with: the two of the prefix and 41 # the thirty-two `pkg/pkg.bend`'s `hash_of` takes off a sha256 42 def name.length() -> Nat: 43 34n 44 45 # whether a line is a `0x` package name and nothing else. Anchored at both 46 # ends: the prefix, the exact length, and every character after the prefix one 47 # a lowercase hex digest may hold. `git/git.bend` already walks a hex string 48 # for `is_rev`, and its walk stops at the first character that is not hex. 49 # 50 # The lines bend prints around its answer fail this, and `pub/LAWS.bend` says 51 # so for the two that hold the name, rather than leaving it to a reading of 52 # bend's source: `publishing N files, ... as 0x... (mining its proof of work)` 53 # and `import 0x.../entry.bend as Name` each start with something that is not 54 # `0x`. The terms notice and the license line bend 2.0.27 prints first 55 # (`Publishing to BendHub: ...`, `License: ...`) start with a word too. 56 def is_name(+word: String) -> Bool: 57 Bool.and(String.starts_with(word, "0x"), 58 Bool.and(Nat.is_eq(String.length(word), name.length()), 59 G.hex.all(String.to_list(String.drop(word, 2n))))) 60 61 # one line of bend's output weighed. A line that is a name must be ours, and 62 # makes the rest of the walk one that has seen a name; any other line is 63 # passed over. `Bool.and` builds both of its sides, so the rest of the walk 64 # arrives as a function of whether a name was seen and is applied once. 65 def agrees.step(name: Bool, same: Bool, seen: Bool, rest: Bool -> Bool) -> Bool: 66 match name: 67 case True{}: 68 Bool.and(same, rest(True{})) 69 case False{}: 70 rest(seen) 71 72 # whether bend's output, from this line on, agrees with ez's hash, `seen` 73 # saying whether a line before it was a name 74 def agrees.go(+ours: String, ls: List<&2, String>, seen: Bool) -> Bool: 75 match ls: 76 case []: 77 seen 78 case +h <> t: 79 agrees.step(is_name(h), String.eq(ours, h), seen, 80 s => agrees.go(ours, t, s)) 81 82 # whether bend's output agrees with ez's hash: at least one line is a `0x` 83 # name, and every line that is one is ez's hash. There is no second pattern 84 # behind this one. An output with no bare name answers nothing, and one that 85 # names two packages names one that is not ours, whichever it names first. 86 def agrees(+ours: String, ls: List<&2, String>) -> Bool: 87 agrees.go(ours, ls, False{}) 88 89 # the first line that is a `0x` name and not ours, "" when there is none 90 def other.step(odd: Bool, line: String, rest: Unit -> String) -> String: 91 match odd: 92 case True{}: 93 line 94 case False{}: 95 rest(Unit{}) 96 97 # the walk that finds it, one line at a time 98 def other(+ours: String, ls: List<&2, String>) -> String: 99 match ls: 100 case []: 101 "" 102 case +h <> t: 103 other.step(Bool.and(is_name(h), Bool.not(String.eq(ours, h))), h, 104 _u => other(ours, t)) 105 106 # --------------------------------------------------------------------------- 107 # the working tree 108 109 # one line of `git status --porcelain` weighed, with the rest of the walk 110 # behind a thunk so a status that names a hundred files stops at the first. 111 # Every line git prints there names a path whose state on disk is not the 112 # state the commit holds, so every one of them counts, whatever its two-letter 113 # code says, `??` for an untracked file included. 114 def dirty.step(blank: Bool, rest: Unit -> Bool) -> Bool: 115 match blank: 116 case True{}: 117 rest(Unit{}) 118 case False{}: 119 True{} 120 121 # whether any line of the status says anything. The only empty line git status 122 # ever produces is the one `String.lines` makes of the trailing newline, so an 123 # empty line is the absence of a change rather than a change with no name. 124 def dirty.lines(ls: List<&2, String>) -> Bool: 125 match ls: 126 case []: 127 False{} 128 case +h <> t: 129 dirty.step(String.is_empty(h), _u => dirty.lines(t)) 130 131 # one line of the other walk, the same way round 132 def blanks.step(blank: Bool, rest: Unit -> Bool) -> Bool: 133 match blank: 134 case True{}: 135 rest(Unit{}) 136 case False{}: 137 False{} 138 139 # whether the status named nothing. Stated as its own walk rather than as the 140 # negation of the one above, so `pub/LAWS.bend` can hold the two to each other: 141 # a tree is clean exactly when git named no path at all. 142 def blanks(ls: List<&2, String>) -> Bool: 143 match ls: 144 case []: 145 True{} 146 case +h <> t: 147 blanks.step(String.is_empty(h), _u => blanks(t)) 148 149 # whether `git status --porcelain` named a path. Ignored files are not named, 150 # and that is the project's call, written in its own `.gitignore`: `.ez/` and 151 # `bin/` hold build output that no commit should. What the package itself 152 # holds is checked apart from this (`stray` below), so an ignored file the 153 # entry imports is refused there. 154 def dirty(text: String) -> Bool: 155 dirty.lines(String.lines(text)) 156 157 # --------------------------------------------------------------------------- 158 # the name a package is published under 159 160 # one character of a version: a digit or a dot 161 def semver.char(+ch: Char) -> Bool: 162 Bool.or(Char.is_digit(ch), Char.is_eq(ch, '.')) 163 164 # whether every character of a version is a digit or a dot. A pre-release 165 # (`1.0.0-rc.1`) or build (`1.0.0+abc`) suffix holds a character that is 166 # neither, so this is where one is refused, whatever follows it. 167 def semver.chars(text: String) -> Bool: 168 match text: 169 case SNil{}: 170 True{} 171 case SCon{+h, t}: 172 +rest = semver.chars(t) 173 Bool.and(semver.char(h), rest) 174 175 # whether a version is MAJOR.MINOR.PATCH: digits and dots only, and three 176 # numbers, none with a leading zero (`K.ver.num`, the rule bend's NAMED 177 # holds each of its four numbers to) 178 def semver(+text: String) -> Bool: 179 +ps = String.split(text, '.') 180 Bool.and(semver.chars(text), 181 Bool.and(Nat.is_eq(List.length(&2, String, ps), 3n), K.ver.nums(ps))) 182 183 # how the ledger names the package on the hub: not at all, so it is 184 # published by its hash alone; by `@`, as bend's `--publish` 185 # takes it; or in a way ez refuses, and why 186 type Named is Data: 187 ByHash{} 188 ByName{nv: String} 189 Unnamed{why: String} 190 191 # why a `publish-as` with no `version` is refused 192 def why.nover(+name: String) -> String: 193 "ez: ez.toml's [package] has publish-as = \"" ++ name ++ "\" and no version; " ++ 194 "a named publish needs both, and one with neither publishes by hash" 195 196 # why a `version` with no `publish-as` is refused 197 def why.noname(+ver: String) -> String: 198 "ez: ez.toml's [package] has version = \"" ++ ver ++ "\" and no publish-as; " ++ 199 "a named publish needs both, and one with neither publishes by hash" 200 201 # why a `publish-as` the hub would not take is refused 202 def why.name(+name: String) -> String: 203 "ez: publish-as = \"" ++ name ++ "\" is not a hub name: a lowercase letter, " ++ 204 "then a-z, 0-9 and -, 1 to 64 characters in all" 205 206 # why a `version` that is not MAJOR.MINOR.PATCH is refused 207 def why.ver(+ver: String) -> String: 208 "ez: version = \"" ++ ver ++ "\" is not MAJOR.MINOR.PATCH: three numbers " ++ 209 "with no leading zeros and no pre-release or build suffix, which ez " ++ 210 "publishes as bend's four-part MAJOR.MINOR.PATCH.0" 211 212 # both keys given: the name first, then the version, then the name bend is 213 # asked for, the version with bend's fourth number, `0`, after it 214 def naming.both(name: Bool, ver: Bool, +pa: String, +pv: String) -> Named: 215 match name: 216 case False{}: 217 Unnamed{why.name(pa)} 218 case True{}: 219 match ver: 220 case False{}: 221 Unnamed{why.ver(pv)} 222 case True{}: 223 ByName{pa ++ "@" ++ pv ++ ".0"} 224 225 # which of the two keys the ledger gives. Neither is a publish by hash, as 226 # before the keys existed; one without the other is refused rather than 227 # guessed at, as `cargo publish` refuses a package with no version. 228 def naming.go(noname: Bool, nover: Bool, +pa: String, +pv: String) -> Named: 229 match noname: 230 case True{}: 231 match nover: 232 case True{}: 233 ByHash{} 234 case False{}: 235 Unnamed{why.noname(pv)} 236 case False{}: 237 match nover: 238 case True{}: 239 Unnamed{why.nover(pa)} 240 case False{}: 241 naming.both(K.name.part(pa), semver(pv), pa, pv) 242 243 # how `publish-as` and `version` name the package 244 def naming(+pa: String, +pv: String) -> Named: 245 naming.go(String.is_empty(pa), String.is_empty(pv), pa, pv) 246 247 # --------------------------------------------------------------------------- 248 # the ledger 249 250 # what the ledger says: the entry it names and how the package is named on 251 # the hub, by its hash or by a `@`, or why there is none 252 type Led is Data: 253 Led{entry: String, named: Named} 254 LedNo{why: String} 255 256 # the entry, with the name the package goes under, or why it goes under none 257 def led.named(+entry: String, got: Named) -> Led: 258 match got: 259 case ByHash{}: 260 Led{entry, ByHash{}} 261 case ByName{nv}: 262 Led{entry, ByName{nv}} 263 case Unnamed{why}: 264 LedNo{why} 265 266 # the entry a parsed ledger names, `main.bend` when it names none, and the 267 # name its `publish-as` and `version` give it 268 def led.read(ledger: M.Read) -> Led: 269 match ledger: 270 case M.Bad{why}: 271 LedNo{"ez: " ++ M.show(M.Bad{why})} 272 case M.Good{m}: 273 M.Manifest{_n, +e, _b, _h, +pa, +pv, _ds, _ts} = m 274 led.named(Bool.pick(String, String.is_empty(e), "main.bend", e), naming(pa, pv)) 275 276 # the ledger read. With no ez.toml there is no package, and nothing is 277 # guessed in its place (`cargo publish` refuses a directory with no 278 # Cargo.toml the same way). 279 def led.of(got: Maybe<&2, String>) -> Led: 280 match got: 281 case None{}: 282 LedNo{"ez: no " ++ PW.toml() ++ " here; ez publish sends the package " ++ 283 "a project's ledger names, and ez init makes one"} 284 case Some{text}: 285 led.read(M.parse(text)) 286 287 # what the World's ledger says 288 def led(world: PW.World) -> Led: 289 led.of(PW.ledger(world)) 290 291 # the entry, as the walk takes it, "" when the ledger names none 292 def entry.of(ledger: Led) -> String: 293 match ledger: 294 case Led{e, _nv}: 295 Path.norm(e) 296 case LedNo{_why}: 297 "" 298 299 # how the package goes on the hub; a ledger that names no entry sends 300 # nothing, so it is taken for one that goes by hash 301 def named.of(ledger: Led) -> Named: 302 match ledger: 303 case Led{_e, got}: 304 got 305 case LedNo{_why}: 306 ByHash{} 307 308 # how a World's package goes on the hub 309 def named(world: PW.World) -> Named: 310 named.of(led(world)) 311 312 # the `@` the upload names the package by, "" for none 313 def named.word(got: Named) -> String: 314 match got: 315 case ByName{nv}: 316 nv 317 case ByHash{}: 318 "" 319 case Unnamed{_why}: 320 "" 321 322 # the entry of a World 323 def entry(world: PW.World) -> String: 324 entry.of(led(world)) 325 326 # --------------------------------------------------------------------------- 327 # git status 328 329 # what `git status` said of the project, still to ask, or why it said 330 # nothing 331 type Status is Data: 332 Said{text: String} 333 StatusWait{} 334 StatusNo{why: String} 335 336 # the question: what git says of the directory the project is in 337 def locate(world: PW.World) -> PW.Ask: 338 PW.Tool{TW.Locate{PW.here(world)}} 339 340 # a status that git answered, or the command stopped with what git said. A 341 # directory that is not in a git repository has no commit for what goes to 342 # the hub to exist in, so it is refused rather than taken for clean. 343 def status.ran(ok: Bool, +text: String) -> Status: 344 match ok: 345 case True{}: 346 Said{text} 347 case False{}: 348 StatusNo{"ez: git status: " ++ text} 349 350 def status.told(got: TW.Answer) -> Status: 351 match got: 352 case TW.Located{_abs, _top, _head, ok, text}: 353 status.ran(ok, text) 354 case TW.Miss{why}: 355 StatusNo{why} 356 case _: 357 StatusNo{"ez: git was not asked about the project"} 358 359 def status.answer(got: PW.Answer) -> Status: 360 match got: 361 case PW.Told{t}: 362 status.told(t) 363 case PW.Ran{_ok, _text}: 364 StatusNo{"ez: git was not asked about the project"} 365 366 def status.of(got: PW.Heard) -> Status: 367 match got: 368 case PW.Open{}: 369 StatusWait{} 370 case PW.Heard{a}: 371 status.answer(a) 372 373 # what git status said of the project 374 def status(+world: PW.World) -> Status: 375 status.of(PW.heard(world, locate(world))) 376 377 # --------------------------------------------------------------------------- 378 # the package 379 380 # a file the walk asked for, added to what it is given: its text, or gone 381 # when there is none 382 def tree.told(got: TW.Answer, +path: String, rest: K.Tree) -> K.Tree: 383 match got: 384 case TW.Plain{m}: 385 K.tree.add(m, rest, path) 386 case _: 387 K.tree.gone(rest, path) 388 389 def tree.read(got: PW.Answer, +path: String, rest: K.Tree) -> K.Tree: 390 match got: 391 case PW.Told{t}: 392 tree.told(t, path, rest) 393 case PW.Ran{_ok, _text}: 394 K.tree.gone(rest, path) 395 396 def tree.tool(ask: TW.Ask, got: PW.Answer, rest: K.Tree) -> K.Tree: 397 match ask: 398 case TW.Read{path}: 399 tree.read(got, path, rest) 400 case _: 401 rest 402 403 def tree.one(ask: PW.Ask, got: PW.Answer, rest: K.Tree) -> K.Tree: 404 match ask: 405 case PW.Tool{q}: 406 tree.tool(q, got, rest) 407 case _: 408 rest 409 410 # every file read so far, as the walk is given them: not a whole checkout, so 411 # a file not read yet is a question 412 def tree.of(rs: List<&2, PW.Reply>) -> K.Tree: 413 match rs: 414 case []: 415 K.Tree{False{}, [], []} 416 case PW.Reply{ask, got} <> t: 417 tree.one(ask, got, tree.of(t)) 418 419 # the files the walk has been given 420 def tree(world: PW.World) -> K.Tree: 421 tree.of(PW.replies(world)) 422 423 # the walk's fuel: one step for the entry and one per line of the files it 424 # was given, since every import it can queue is a line of one of them 425 def fuel(fs: List<&2, K.Source>) -> Nat: 426 match fs: 427 case []: 428 1n 429 case K.Source{_at, text} <> t: 430 (List.length(&2, String, String.lines(text)) + fuel(t) : Nat) 431 432 def fuel.of(tr: K.Tree) -> Nat: 433 K.Tree{_whole, fs, _gone} = tr 434 fuel(fs) 435 436 # the package the entry is published as, over the files read so far 437 def walked(+world: PW.World) -> K.Walked: 438 K.of.tree(fuel.of(tree(world)), entry(world), tree(world)) 439 440 # the package's hash, "" while there is none 441 def hash.of(got: K.Walked) -> String: 442 match got: 443 case K.Walked{h, _root, _fs, _sums}: 444 h 445 case K.Asks{_at}: 446 "" 447 case K.Refused{_why}: 448 "" 449 450 # the hash ez computed for the package 451 def hash(+world: PW.World) -> String: 452 hash.of(walked(world)) 453 454 # the directory the package's paths are written from, "." for the project's 455 def root.of(got: K.Walked) -> String: 456 match got: 457 case K.Walked{_h, root, _fs, _sums}: 458 root 459 case K.Asks{_at}: 460 "." 461 case K.Refused{_why}: 462 "." 463 464 # the entry's path inside the package, which is where the hub serves it: the 465 # entry's name, under the directories the package re-rooted above it when an 466 # import climbs out of the entry's directory 467 def at(+world: PW.World) -> String: 468 K.inside(root.of(walked(world)), entry(world)) 469 470 # the files of a package, as paths of the project 471 def paths(+root: String, fs: List<&2, K.Source>) -> List<&2, String>: 472 match fs: 473 case []: 474 [] 475 case K.Source{at, _text} <> t: 476 Path.join(root, at) <> paths(root, t) 477 478 # --------------------------------------------------------------------------- 479 # what git tracks 480 481 # whether `git ls-files -v` names a path as tracked in the ordinary way. `H` 482 # is a file git holds and watches; a file marked `--assume-unchanged` (`h`) or 483 # `--skip-worktree` (`S`) is one whose changes `git status` does not report, 484 # so its text on disk need not be the commit's, and it counts as untracked. 485 def tracked.step(hit: Bool, rest: Unit -> Bool) -> Bool: 486 match hit: 487 case True{}: 488 True{} 489 case False{}: 490 rest(Unit{}) 491 492 def tracked.has(ls: List<&2, String>, +path: String) -> Bool: 493 match ls: 494 case []: 495 False{} 496 case +h <> t: 497 tracked.step(String.eq(h, "H " ++ path), _u => tracked.has(t, path)) 498 499 # the first path of the package that git does not track, "" when every one 500 # is tracked 501 def stray.step(kept: Bool, +path: String, rest: Unit -> String) -> String: 502 match kept: 503 case True{}: 504 rest(Unit{}) 505 case False{}: 506 path 507 508 def stray.first(+ls: List<&2, String>, ps: List<&2, String>) -> String: 509 match ps: 510 case []: 511 "" 512 case +h <> t: 513 stray.step(tracked.has(ls, h), h, _u => stray.first(ls, t)) 514 515 # what git said of the package's files: every one tracked, the first one it 516 # does not track, still to ask, or why it said nothing 517 type Kept is Data: 518 Kept{} 519 Stray{path: String} 520 KeptWait{} 521 KeptNo{why: String} 522 523 def kept.of(+first: String) -> Kept: 524 Bool.pick(Kept, String.is_empty(first), Kept{}, Stray{first}) 525 526 def kept.ran(ok: Bool, +text: String, +ps: List<&2, String>) -> Kept: 527 match ok: 528 case True{}: 529 kept.of(stray.first(String.lines(text), ps)) 530 case False{}: 531 KeptNo{"ez: git ls-files: " ++ text} 532 533 def kept.answer(got: PW.Answer, +ps: List<&2, String>) -> Kept: 534 match got: 535 case PW.Ran{ok, text}: 536 kept.ran(ok, text, ps) 537 case PW.Told{_t}: 538 KeptNo{"ez: git was not asked what it tracks"} 539 540 def kept.heard(got: PW.Heard, +ps: List<&2, String>) -> Kept: 541 match got: 542 case PW.Open{}: 543 KeptWait{} 544 case PW.Heard{a}: 545 kept.answer(a, ps) 546 547 def kept.walked(got: K.Walked, +world: PW.World) -> Kept: 548 match got: 549 case K.Walked{_h, +root, fs, _sums}: 550 kept.heard(PW.heard(world, PW.Tracked{}), paths(root, fs)) 551 case K.Asks{_at}: 552 KeptNo{"ez: the package was not walked"} 553 case K.Refused{why}: 554 KeptNo{why} 555 556 # whether git tracks every file of the package. An ignored file the entry 557 # imports is one `git status` never names, and one the upload would send 558 # though no commit holds it, so it is refused here. 559 def stray(+world: PW.World) -> Kept: 560 kept.walked(walked(world), world) 561 562 # --------------------------------------------------------------------------- 563 # the upload 564 565 # the words the upload runs: the entry, and after `--publish` the 566 # `@` the package goes under when it has one, which is how 567 # bend 2.0.27 publishes and names a package in one run (`bend 568 # --publish @`). With no name it is the upload by hash alone. 569 def upload.go(bare: Bool, +entry: String, +named: String) -> List<&2, String>: 570 match bare: 571 case True{}: 572 ["bend", entry, "--publish"] 573 case False{}: 574 ["bend", entry, "--publish", named] 575 576 def upload.line(+entry: String, +named: String) -> List<&2, String>: 577 upload.go(String.is_empty(named), entry, named) 578 579 # the files of a package, with their texts, at the paths the hub holds them 580 # at; none while there is no package 581 def files.of(got: K.Walked) -> List<&2, K.Source>: 582 match got: 583 case K.Walked{_h, _root, fs, _sums}: 584 fs 585 case K.Asks{_at}: 586 [] 587 case K.Refused{_why}: 588 [] 589 590 # what is said just before the upload: the line the hub will describe the 591 # package by (`B.blurb`). A package whose first file opens with `import Base` 592 # would be listed that way for good, so the author sees the line while the 593 # upload can still be stopped. 594 def said(+world: PW.World) -> String: 595 "hub description: " ++ B.blurb(files.of(walked(world))) 596 597 # the question: the entry sent, under the name the ledger gives it, with the 598 # line said before it 599 def upload(+world: PW.World) -> PW.Ask: 600 PW.Upload{entry(world), named.word(named(world)), said(world)} 601 602 # the upload's answer, looked up 603 def uploaded(+world: PW.World) -> PW.Heard: 604 PW.heard(world, upload(world)) 605 606 # whether an upload's answer agrees with ez's hash: bend exited 0, and its 607 # output agrees (`agrees`) 608 def agreed.answer(got: PW.Answer, +ours: String) -> Bool: 609 match got: 610 case PW.Ran{ok, text}: 611 Bool.and(ok, agrees(ours, String.lines(text))) 612 case PW.Told{_t}: 613 False{} 614 615 def agreed.of(got: PW.Heard, +ours: String) -> Bool: 616 match got: 617 case PW.Open{}: 618 False{} 619 case PW.Heard{a}: 620 agreed.answer(a, ours) 621 622 # whether the World's upload agrees with the hash ez computed 623 def agreed(+world: PW.World) -> Bool: 624 agreed.of(uploaded(world), hash(world)) 625 626 # why an upload that bend finished does not agree: it printed no line that 627 # is a name, or a name that is not ours. Both numbers are named, since the 628 # next question is which walk drifted, and neither is reported as the 629 # package's name. 630 def why.read(+ours: String, +text: String) -> String: 631 +theirs = other(ours, String.lines(text)) 632 Bool.pick(String, String.is_empty(theirs), 633 "ez: bend printed no line that is a 0x name, so there is nothing to " ++ 634 "check ez's hash against. bend said:\n" ++ text, 635 "ez: ez computed " ++ ours ++ " and bend published " ++ theirs ++ 636 ". One of the two package walks has drifted from the other, so " ++ 637 "neither name is safe to write down and ez has reported none.") 638 639 # why an upload refuses, `words` being the line it ran. bend has sent the 640 # package by the time it answers, since it cannot report the hash without 641 # sending it, so this is said after the fact. 642 def why.ran(ok: Bool, +words: String, +ours: String, +text: String) -> String: 643 match ok: 644 case False{}: 645 "ez: " ++ words ++ ": " ++ text 646 case True{}: 647 why.read(ours, text) 648 649 def why.answer(got: PW.Answer, +words: String, +ours: String) -> String: 650 match got: 651 case PW.Ran{ok, text}: 652 why.ran(ok, words, ours, text) 653 case PW.Told{_t}: 654 "ez: bend was not asked to publish" 655 656 def why.of(got: PW.Heard, +words: String, +ours: String) -> String: 657 match got: 658 case PW.Open{}: 659 "ez: bend was not asked to publish" 660 case PW.Heard{a}: 661 why.answer(a, words, ours) 662 663 # why the World's upload refuses 664 def why.upload(+world: PW.World) -> String: 665 why.of(uploaded(world), 666 String.join(upload.line(entry(world), named.word(named(world))), " "), 667 hash(world)) 668 669 # --------------------------------------------------------------------------- 670 # the hub packages the package imports 671 672 # A package on the hub is built from the hub: whoever imports it has bend 673 # fetch every hub package it imports in turn. So a package that imports one 674 # the hub does not have is one no one can build, and `ez publish` asks the 675 # hub about each before it sends anything, as `cargo publish` refuses a 676 # dependency that is not on crates.io (EZ-PUB-5). It asks the hub the ledger 677 # names, the one `ez lock` resolved the imports against. 678 679 # a hub package the package imports, as the hub is asked about it: by its 680 # `0x` name, whose manifest the hub serves at `//manifest`; or by 681 # its `@`, which the hub resolves at `/name/`, with 682 # the hash ez.lock.toml or ez.toml resolved the name to, "" when neither 683 # records one 684 type Probe is Data: 685 ProbeHash{hash: String} 686 ProbeName{nv: String, hash: String} 687 688 # the texts of the package's files 689 def texts(fs: List<&2, K.Source>) -> List<&2, String>: 690 match fs: 691 case []: 692 [] 693 case K.Source{_at, text} <> t: 694 text <> texts(t) 695 696 # a string kept unless it was seen before 697 def once.pick(dup: Bool, +word: String, rest: List<&2, String>) -> List<&2, String>: 698 match dup: 699 case True{}: 700 rest 701 case False{}: 702 word <> rest 703 704 # each string of a list once, where it first appears 705 def once.go(xs: List<&2, String>, +seen: List<&2, String>) -> List<&2, String>: 706 match xs: 707 case []: 708 [] 709 case +h <> t: 710 once.pick(L.seen.holds(seen, h), h, once.go(t, h <> seen)) 711 712 # each string of a list once 713 def once(xs: List<&2, String>) -> List<&2, String>: 714 once.go(xs, []) 715 716 # the texts of the package the walk made, none while there is none 717 def sources(+world: PW.World) -> List<&2, String>: 718 texts(files.of(walked(world))) 719 720 # every `0x` name the package's files import, once each 721 def hashes(+world: PW.World) -> List<&2, String>: 722 once(L.kids(sources(world))) 723 724 # every `@` they import, once each 725 def nvs(+world: PW.World) -> List<&2, String>: 726 once(L.kids.named(sources(world))) 727 728 # whether a list holds anything 729 def any(xs: List<&2, String>) -> Bool: 730 match xs: 731 case []: 732 False{} 733 case _h <> _t: 734 True{} 735 736 # the ledger read, a bad one when there is none; the planner reaches this 737 # stage only past a ledger that parsed 738 def parsed.of(got: Maybe<&2, String>) -> M.Read: 739 match got: 740 case None{}: 741 M.Bad{"no " ++ PW.toml()} 742 case Some{text}: 743 M.parse(text) 744 745 # the World's ledger, read 746 def parsed(world: PW.World) -> M.Read: 747 parsed.of(PW.ledger(world)) 748 749 # the hub the ledger names, bend's own when it names none 750 def hub(world: PW.World) -> String: 751 M.hub_of(parsed(world)) 752 753 # the dependencies the ledger records 754 def deps.of(got: M.Read) -> List<&2, M.Dep>: 755 match got: 756 case M.Bad{_why}: 757 [] 758 case M.Good{m}: 759 M.Manifest{_n, _e, _b, _h, _pa, _pv, ds, _ts} = m 760 ds 761 762 # the dependencies the World's ledger records 763 def deps(world: PW.World) -> List<&2, M.Dep>: 764 deps.of(parsed(world)) 765 766 # the lock's text, "" when there is none or it was not read 767 def lock.told(got: TW.Answer) -> String: 768 match got: 769 case TW.Plain{m}: 770 F.text_of(m) 771 case _: 772 "" 773 774 def lock.answer(got: PW.Answer) -> String: 775 match got: 776 case PW.Told{t}: 777 lock.told(t) 778 case PW.Ran{_ok, _text}: 779 "" 780 781 def lock.of(got: PW.Heard) -> String: 782 match got: 783 case PW.Open{}: 784 "" 785 case PW.Heard{a}: 786 lock.answer(a) 787 788 # ez.lock.toml as the planner was given it 789 def lock(+world: PW.World) -> String: 790 lock.of(PW.heard(world, PW.Lock{})) 791 792 # every name the lock resolves, then every one the ledger records with the 793 # hash it named when it was added 794 def names(+world: PW.World) -> List<&2, L.Name>: 795 List.append(&2, L.Name, L.names.read(lock(world)), L.ledger.names(parsed(world))) 796 797 # each `0x` name as the hub is asked about it 798 def probes.hash(hs: List<&2, String>) -> List<&2, Probe>: 799 match hs: 800 case []: 801 [] 802 case h <> t: 803 ProbeHash{h} <> probes.hash(t) 804 805 # each name, with the hash the lock or the ledger resolves it to 806 def probes.name(+ns: List<&2, L.Name>, vs: List<&2, String>) -> List<&2, Probe>: 807 match vs: 808 case []: 809 [] 810 case +h <> t: 811 ProbeName{h, L.name.find(ns, h)} <> probes.name(ns, t) 812 813 # every hub package the package imports, as the hub is asked about it 814 def probes(+world: PW.World) -> List<&2, Probe>: 815 List.append(&2, Probe, probes.hash(hashes(world)), probes.name(names(world), nvs(world))) 816 817 # the url the hub is asked 818 def probe.url(+hub: String, one: Probe) -> String: 819 match one: 820 case ProbeHash{h}: 821 L.url_of(hub, h, "manifest") 822 case ProbeName{nv, _h}: 823 hub ++ "/name/" ++ nv 824 825 # the question: a GET of that url 826 def probe.ask(+hub: String, one: Probe) -> PW.Ask: 827 PW.Hub{probe.url(hub, one)} 828 829 # the questions for a list of them 830 def probe.asks(+hub: String, ps: List<&2, Probe>) -> List<&2, PW.Ask>: 831 match ps: 832 case []: 833 [] 834 case h <> t: 835 probe.ask(hub, h) <> probe.asks(hub, t) 836 837 # the import, as the package's files write it 838 def probe.word(one: Probe) -> String: 839 match one: 840 case ProbeHash{h}: 841 h 842 case ProbeName{nv, _h}: 843 nv 844 845 # where the ledger says a dependency comes from 846 def origin.say(src: M.Source) -> String: 847 match src: 848 case M.Hub{_named}: 849 "hub" 850 case M.Git{url, _rev, _tag, _root, _nar, _vend}: 851 "git " ++ url 852 853 # a dependency as a refusal names it: its key and its origin 854 def dep.say(item: M.Dep) -> String: 855 M.Dep{n, _h, _e, src} = item 856 n ++ ", " ++ origin.say(src) 857 858 # whether the ledger's dependency is the one a probe is about: the same hash, 859 # or for a name, the same `@` or the hash it resolved to 860 def dep.is.go(one: Probe, +hash: String, src: M.Source) -> Bool: 861 match one: 862 case ProbeHash{ph}: 863 String.eq(hash, ph) 864 case ProbeName{nv, +ph}: 865 Bool.or(String.eq(M.source.nv(src), nv), 866 Bool.and(Bool.not(String.is_empty(ph)), String.eq(hash, ph))) 867 868 def dep.is(one: Probe, item: M.Dep) -> Bool: 869 M.Dep{_n, h, _e, src} = item 870 dep.is.go(one, h, src) 871 872 def dep.find.pick(hit: Bool, item: M.Dep, rest: Unit -> String) -> String: 873 match hit: 874 case True{}: 875 dep.say(item) 876 case False{}: 877 rest(Unit{}) 878 879 # the first dependency the ledger records for a probe, said, "" for none 880 def dep.find(+one: Probe, ds: List<&2, M.Dep>) -> String: 881 match ds: 882 case []: 883 "" 884 case +h <> t: 885 dep.find.pick(dep.is(one, h), h, _u => dep.find(one, t)) 886 887 # the import a refusal names, with the dependency key and origin the ledger 888 # records for it when it records one 889 def who.of(+word: String, +dep: String) -> String: 890 Bool.pick(String, String.is_empty(dep), word, word ++ " (" ++ dep ++ ")") 891 892 # a probe as a refusal names it 893 def who(+one: Probe, ds: List<&2, M.Dep>) -> String: 894 who.of(probe.word(one), dep.find(one, ds)) 895 896 # where one probe stands: on the hub, not asked yet, or off it, with the line 897 # a refusal says of it 898 type Seen is Data: 899 There{} 900 Unasked{} 901 Off{line: String} 902 903 # what the hub answers for a url it has nothing at: status 404, as 904 # `Web.get` says it 905 def absent(+text: String) -> Bool: 906 String.eq(text, "the server answered 404") 907 908 # the line for a package the hub does not have 909 def why.absent(+who: String) -> String: 910 " " ++ who ++ " is not on the hub; publish it first" 911 912 # the line for one the hub could not be asked about 913 def why.unasked(+who: String, +text: String) -> String: 914 " " ++ who ++ ": the hub could not be asked: " ++ text 915 916 # a GET that did not come back 200: the hub does not have it, or it could not 917 # be asked. Neither is the hub having it, so both refuse, and nothing is sent 918 # on a guess. 919 def seen.miss(gone: Bool, +text: String, +who: String) -> Seen: 920 match gone: 921 case True{}: 922 Off{why.absent(who)} 923 case False{}: 924 Off{why.unasked(who, text)} 925 926 # a manifest the hub served for a `0x` name: the package is there when the 927 # manifest hashes to the name, as `ez lock` checks it 928 def seen.hash(ok: Bool, +hash: String, +text: String, +who: String) -> Seen: 929 match ok: 930 case True{}: 931 Bool.pick(Seen, String.starts_with(Sha.hex(text), L.want(hash)), There{}, 932 Off{" " ++ who ++ ": the hub served a manifest that does not hash to it"}) 933 case False{}: 934 seen.miss(absent(text), text, who) 935 936 # why a name the hub answered is not the package imported 937 def why.named(+got: String, +want: String, +who: String) -> String: 938 Bool.pick(String, String.is_empty(want), 939 " " ++ who ++ ": the hub answered `" ++ got ++ "`, which is not a 0x name", 940 " " ++ who ++ ": the hub names `" ++ got ++ "` by it, and the lock " ++ 941 "resolved it to " ++ want) 942 943 # a name the hub resolved: the package is there when the hub names a `0x` 944 # name by it, and the one the lock resolved it to when the lock resolved it 945 def seen.named.go(good: Bool, +got: String, +want: String, +who: String) -> Seen: 946 match good: 947 case True{}: 948 There{} 949 case False{}: 950 Off{why.named(got, want, who)} 951 952 def seen.named(+got: String, +want: String, +who: String) -> Seen: 953 seen.named.go(Bool.and(Bool.or(String.eq(got, want), String.is_empty(want)), L.hash.ok(got)), 954 got, want, who) 955 956 # a name asked of the hub, answered 957 def seen.name(ok: Bool, +want: String, +text: String, +who: String) -> Seen: 958 match ok: 959 case True{}: 960 seen.named(String.trim(text), want, who) 961 case False{}: 962 seen.miss(absent(text), text, who) 963 964 # a probe's GET answered: whether it came back 200, and what it said 965 def seen.ran(one: Probe, ok: Bool, +text: String, +who: String) -> Seen: 966 match one: 967 case ProbeHash{h}: 968 seen.hash(ok, h, text, who) 969 case ProbeName{_nv, h}: 970 seen.name(ok, h, text, who) 971 972 def seen.answer(one: Probe, got: PW.Answer, +who: String) -> Seen: 973 match got: 974 case PW.Ran{ok, text}: 975 seen.ran(one, ok, text, who) 976 case PW.Told{_t}: 977 Off{" " ++ who ++ ": the hub was not asked"} 978 979 def seen.heard(got: PW.Heard, one: Probe, +who: String) -> Seen: 980 match got: 981 case PW.Open{}: 982 Unasked{} 983 case PW.Heard{a}: 984 seen.answer(one, a, who) 985 986 # where a probe stands in a World 987 def seen(+world: PW.World, +hub: String, +ds: List<&2, M.Dep>, +one: Probe) -> Seen: 988 seen.heard(PW.heard(world, probe.ask(hub, one)), one, who(one, ds)) 989 990 # the probes not asked yet, and the lines for the ones off the hub 991 type Tally is Data: 992 Tally{open: List<&2, Probe>, offs: List<&2, String>} 993 994 # a probe still to ask about, added 995 def tally.unasked(one: Probe, sofar: Tally) -> Tally: 996 Tally{open, offs} = sofar 997 Tally{one <> open, offs} 998 999 # a line for a probe off the hub, added 1000 def tally.offhub(line: String, sofar: Tally) -> Tally: 1001 Tally{open, offs} = sofar 1002 Tally{open, line <> offs} 1003 1004 # one probe's standing added to the rest's 1005 def tally.put(standing: Seen, one: Probe, sofar: Tally) -> Tally: 1006 match standing: 1007 case There{}: 1008 sofar 1009 case Unasked{}: 1010 tally.unasked(one, sofar) 1011 case Off{line}: 1012 tally.offhub(line, sofar) 1013 1014 # every probe'standing standing 1015 def tally(+world: PW.World, +hub: String, +ds: List<&2, M.Dep>, ps: List<&2, Probe>) -> Tally: 1016 match ps: 1017 case []: 1018 Tally{[], []} 1019 case +h <> t: 1020 tally.put(seen(world, hub, ds, h), h, tally(world, hub, ds, t)) 1021 1022 # every hub package the package imports on the hub, some still to ask about, 1023 # or why the command refuses 1024 type Hubbed is Data: 1025 OnHub{} 1026 HubWait{open: List<&2, Probe>} 1027 OffHub{why: String} 1028 1029 # why a package with imports off the hub is refused: every one of them, and 1030 # the hub that was asked 1031 def why.hub(+hub: String, lines: List<&2, String>) -> String: 1032 "ez: the package imports hub packages that " ++ hub ++ " does not have, or " ++ 1033 "could not be asked about, and a package on the hub has to build from the " ++ 1034 "hub alone, so nothing was sent:\n" ++ String.join(lines, "\n") 1035 1036 def hubbed.offs(offs: List<&2, String>, +hub: String) -> Hubbed: 1037 match offs: 1038 case []: 1039 OnHub{} 1040 case h <> t: 1041 OffHub{why.hub(hub, h <> t)} 1042 1043 # the tally read: every probe asked before any is judged, then every one off 1044 # the hub named at once 1045 def hubbed.go(open: List<&2, Probe>, offs: List<&2, String>, +hub: String) -> Hubbed: 1046 match open: 1047 case []: 1048 hubbed.offs(offs, hub) 1049 case h <> rest: 1050 HubWait{h <> rest} 1051 1052 def hubbed.of(sofar: Tally, +hub: String) -> Hubbed: 1053 Tally{open, offs} = sofar 1054 hubbed.go(open, offs, hub) 1055 1056 # where the World's hub imports stand 1057 def hubbed(+world: PW.World) -> Hubbed: 1058 +hub = hub(world) 1059 hubbed.of(tally(world, hub, deps(world), probes(world)), hub) 1060 1061 # whether they are all on the hub 1062 def on.hub.v(got: Hubbed) -> Bool: 1063 match got: 1064 case OnHub{}: 1065 True{} 1066 case HubWait{_open}: 1067 False{} 1068 case OffHub{_why}: 1069 False{} 1070 1071 def on.hub(+world: PW.World) -> Bool: 1072 on.hub.v(hubbed(world)) 1073 1074 # --------------------------------------------------------------------------- 1075 # where a World stands 1076 1077 # every check passed, questions still open, or why the command refuses 1078 type Verdict is Data: 1079 Go{} 1080 Pending{asks: List<&2, PW.Ask>} 1081 Stop{why: String} 1082 1083 # what a refusal for a dirty tree says. The lines git printed come with it, 1084 # since the next question is always which file. 1085 def why.dirty(+text: String) -> String: 1086 "ez: the working tree is dirty, and publishing is public and cannot be " ++ 1087 "taken back, so what goes to the hub has to exist in a commit. git " ++ 1088 "status says:\n" ++ text 1089 1090 # what a refusal for a file git does not track says 1091 def why.stray(+path: String) -> String: 1092 "ez: " ++ path ++ " is part of the package and git does not track it, " ++ 1093 "so what goes to the hub would not exist in a commit" 1094 1095 # the upload answered: bend's answer agrees, or the command refuses 1096 def decide.agreed(ok: Bool, +world: PW.World) -> Verdict: 1097 match ok: 1098 case True{}: 1099 Go{} 1100 case False{}: 1101 Stop{why.upload(world)} 1102 1103 # the upload, asked for once every check before it passed 1104 def decide.upload(got: PW.Heard, +world: PW.World) -> Verdict: 1105 match got: 1106 case PW.Open{}: 1107 Pending{[upload(world)]} 1108 case PW.Heard{_a}: 1109 decide.agreed(agreed(world), world) 1110 1111 # the hub imports: every one on the hub goes on to the upload, one still to 1112 # ask about is asked, and any other refuses, sending nothing (EZ-PUB-5) 1113 def decide.hub(on: Hubbed, +world: PW.World) -> Verdict: 1114 match on: 1115 case OnHub{}: 1116 decide.upload(uploaded(world), world) 1117 case HubWait{open}: 1118 Pending{probe.asks(hub(world), open)} 1119 case OffHub{why}: 1120 Stop{why} 1121 1122 # the lock, read first when the package imports a package by name, since it 1123 # holds the hash the name was resolved to 1124 def decide.lock(need: Bool, got: PW.Heard, +world: PW.World) -> Verdict: 1125 match need: 1126 case False{}: 1127 decide.hub(hubbed(world), world) 1128 case True{}: 1129 match got: 1130 case PW.Open{}: 1131 Pending{[PW.Lock{}]} 1132 case PW.Heard{_a}: 1133 decide.hub(hubbed(world), world) 1134 1135 # every file of the package tracked, or the first that is not refused 1136 def decide.kept(kept: Kept, +world: PW.World) -> Verdict: 1137 match kept: 1138 case Kept{}: 1139 decide.lock(any(nvs(world)), PW.heard(world, PW.Lock{}), world) 1140 case Stray{path}: 1141 Stop{why.stray(path)} 1142 case KeptWait{}: 1143 Pending{[PW.Tracked{}]} 1144 case KeptNo{why}: 1145 Stop{why} 1146 1147 # the walk: a package, a file it asks for, or why there is none 1148 def decide.walk(got: K.Walked, +world: PW.World) -> Verdict: 1149 match got: 1150 case K.Walked{_h, _root, _fs, _sums}: 1151 decide.kept(stray(world), world) 1152 case K.Asks{at}: 1153 Pending{[PW.Tool{TW.Read{at}}]} 1154 case K.Refused{why}: 1155 Stop{why} 1156 1157 # a status that named a path refuses, and nothing is read or sent 1158 def decide.clean(bad: Bool, +text: String, +world: PW.World) -> Verdict: 1159 match bad: 1160 case True{}: 1161 Stop{why.dirty(text)} 1162 case False{}: 1163 decide.walk(walked(world), world) 1164 1165 # git status: asked, answered, or failed 1166 def decide.status(said: Status, +world: PW.World) -> Verdict: 1167 match said: 1168 case Said{+text}: 1169 decide.clean(dirty(text), text, world) 1170 case StatusWait{}: 1171 Pending{[locate(world)]} 1172 case StatusNo{why}: 1173 Stop{why} 1174 1175 # the ledger: an entry, or why there is none 1176 def decide.led(ledger: Led, +world: PW.World) -> Verdict: 1177 match ledger: 1178 case Led{_e, _nv}: 1179 decide.status(status(world), world) 1180 case LedNo{why}: 1181 Stop{why} 1182 1183 # where a World stands 1184 def decide(+world: PW.World) -> Verdict: 1185 decide.led(led(world), world) 1186 1187 # --------------------------------------------------------------------------- 1188 # the plan 1189 1190 # what the import line names the package by: its `@` when it 1191 # was published under one, its hash otherwise 1192 def called.of(got: Named, +hash: String) -> String: 1193 match got: 1194 case ByName{nv}: 1195 nv 1196 case ByHash{}: 1197 hash 1198 case Unnamed{_why}: 1199 hash 1200 1201 # what a World's import line names the package by 1202 def called(+world: PW.World) -> String: 1203 called.of(named(world), hash(world)) 1204 1205 # what a publish that agreed says: the hash ez computed, then the import line 1206 # for the entry's path inside the package (`G.import.line`, which `ez add` 1207 # prints too), by the name the package was published under when it has one. 1208 # It writes nothing. 1209 def made(+world: PW.World) -> P.Plan: 1210 P.Plan{[P.Say{hash(world)}, P.Say{G.import.line(called(world), at(world))}], 1211 P.Success{}} 1212 1213 # the plan for where a World stands. One that still asks is a World the 1214 # interpreter did not finish, and is refused rather than guessed at. 1215 def plan.v(verdict: Verdict, +world: PW.World) -> P.Plan: 1216 match verdict: 1217 case Go{}: 1218 made(world) 1219 case Pending{_asks}: 1220 P.Plan{[], P.Refused{"ez: the publish of " ++ entry(world) ++ 1221 " was never resolved"}} 1222 case Stop{why}: 1223 P.Plan{[], P.Refused{why}} 1224 1225 # `ez publish` over a World 1226 def plan(+world: PW.World) -> P.Plan: 1227 plan.v(decide(world), world) 1228 1229 # the questions still open, or the plan once there are none 1230 type Step is Data: 1231 Asking{asks: List<&2, PW.Ask>} 1232 Run{plan: P.Plan} 1233 1234 # the step for where the World stands 1235 def step.v(verdict: Verdict, +world: PW.World) -> Step: 1236 match verdict: 1237 case Go{}: 1238 Run{made(world)} 1239 case Pending{asks}: 1240 Asking{asks} 1241 case Stop{why}: 1242 Run{plan.v(Stop{why}, world)} 1243 1244 # what the interpreter does next on a World 1245 def step(+world: PW.World) -> Step: 1246 step.v(decide(world), world) 1247 1248 # --------------------------------------------------------------------------- 1249 # law vocabulary 1250 1251 # whether a plan ends in success 1252 def succeeds.plan(pl: P.Plan) -> Bool: 1253 P.Plan{_es, outcome} = pl 1254 match outcome: 1255 case P.Success{}: 1256 True{} 1257 case P.Refused{_why}: 1258 False{} 1259 1260 # whether `ez publish` succeeds on a World 1261 def succeeds(+world: PW.World) -> Bool: 1262 succeeds.plan(plan(world)) 1263 1264 # whether `ez publish` refuses on a World 1265 def refuses(+world: PW.World) -> Bool: 1266 Bool.not(succeeds(world)) 1267 1268 # whether a verdict refuses outright: it asks nothing and sends nothing 1269 def stops.v(verdict: Verdict) -> Bool: 1270 match verdict: 1271 case Go{}: 1272 False{} 1273 case Pending{_asks}: 1274 False{} 1275 case Stop{_why}: 1276 True{} 1277 1278 # whether `ez publish` refuses a World outright, asking nothing more 1279 def stops(+world: PW.World) -> Bool: 1280 stops.v(decide(world)) 1281 1282 # whether a question is the upload 1283 def is.upload(ask: PW.Ask) -> Bool: 1284 match ask: 1285 case PW.Upload{_entry, _named, _says}: 1286 True{} 1287 case PW.Tool{_q}: 1288 False{} 1289 case PW.Tracked{}: 1290 False{} 1291 case PW.Lock{}: 1292 False{} 1293 case PW.Hub{_url}: 1294 False{} 1295 1296 # whether any of these questions is the upload 1297 def any.upload(asks: List<&2, PW.Ask>) -> Bool: 1298 match asks: 1299 case []: 1300 False{} 1301 case h <> t: 1302 +rest = any.upload(t) 1303 Bool.or(is.upload(h), rest) 1304 1305 # whether a verdict is at the upload or past it: it asks for the upload, or 1306 # has its answer and goes on 1307 def sends.v(verdict: Verdict) -> Bool: 1308 match verdict: 1309 case Go{}: 1310 True{} 1311 case Pending{asks}: 1312 any.upload(asks) 1313 case Stop{_why}: 1314 False{} 1315 1316 # whether `ez publish` gets as far as the upload on a World 1317 def sends(+world: PW.World) -> Bool: 1318 sends.v(decide(world))