src/lint/plan.bend

bolt@1.27.0.0 · 637 lines · 77 definitions

1# src/lint/plan: `bolt` (the lint) as a pure planner. It reads a World
2# (src/lint/world.bend) and returns either the questions it still needs
3# answered (`wants`) or the Plan: the lines to print and the exit status
4# (`plan`). The interpreter (src/lint.bend) loops on `wants`, answering each
5# question by IO, until nothing is wanted, then prints `plan`'s lines and
6# exits with its status. The design is docs/rfc/bolt-lint-planner.md.
7#
8# With no files named, the planner walks the tree under `.` as a fold over the
9# World's listings: a directory not listed yet reads as empty and is asked
10# for, so each round of the loop lists one more level of the tree. Once every
11# directory the walk reads is listed, it asks for the text of each source and
12# of every bolt.bend candidate of their directories, each once.
13# A directory's candidates are taken once it is resolved against the World's
14# working directory (Config.home), so a relative path is graded by the same
15# bolt.bend wherever bolt runs from.
16# `wants` never parses a file; `plan` parses and lints once, and reads the
17# settings of each bolt.bend that grades a directory for `setting` (C011). Once every rule
18# has run and each finding is graded, the noqa comments (src/noqa.bend)
19# silence what they name, and then the `noqa` rule reports the comments that
20# silenced nothing.
21import Base
22import bolt@1.27.0.0/src/lazy/lazy.bend as Lazy
23import bolt@1.27.0.0/src/lint/world.bend as W
24import bolt@1.27.0.0/src/finding.bend as F
25import bolt@1.27.0.0/src/src.bend as Src
26import bolt@1.27.0.0/src/role.bend as Role
27import bolt@1.27.0.0/src/rules.bend as Rules
28import bolt@1.27.0.0/src/rules/imports.bend as Imports
29import bolt@1.27.0.0/src/config.bend as Config
30import bolt@1.27.0.0/src/noqa.bend as Noqa
31import bolt@1.27.0.0/src/rules/style/noqa.bend as NoqaRule
32import bolt@1.27.0.0/src/rules/correctness/setting.bend as Setting
33
34# what `bolt` prints, in order, and the status it exits with
35type Plan is Data:
36  Plan{lines: List<&2, String>, exit: U32}
37
38# the lines a plan prints
39def lines.of(pl: Plan) -> List<&2, String>:
40  Plan{lines, _exit} = pl
41  lines
42
43# the status a plan exits with
44def exit.of(pl: Plan) -> U32:
45  Plan{_lines, exit} = pl
46  exit
47
48# ---------------------------------------------------------------------------
49# the walk: every `.bend` file under `.`, sorted. A hidden name (`.git`,
50# `.gate`, `.claude`) and `node_modules` are not descended into. The walk keeps
51# a queue of directories rather than nesting, as Bend has no mutual recursion:
52# one def shrinks one argument, the fuel, which bounds how many directories are
53# read.
54
55# a directory the walk never descends into
56def walk.skipped(+name: String) -> Bool:
57  Bool.or(String.starts_with(name, "."), String.eq(name, "node_modules"))
58
59# an entry's name without the "/" that marks it a directory
60def walk.bare(+name: String) -> String:
61  String.take(name, Nat.sub(String.length(name), 1n))
62
63# a name directly under a directory, as a path
64def walk.under(+dir: String, +name: String) -> String:
65  dir ++ "/" ++ name
66
67# the sub-directories of a listing worth descending into, as paths
68def walk.dirs(es: List<&2, String>, +dir: String) -> List<&2, String>:
69  match es:
70    case Nil{}:
71      Nil{}
72    case Con{+e, rest}:
73      +more = walk.dirs(rest, dir)
74      +keep = Bool.and(String.ends_with(e, "/"), Bool.not(walk.skipped(walk.bare(e))))
75      Bool.pick(List<&2, String>, keep, walk.under(dir, walk.bare(e)) <> more, more)
76
77# the `.bend` files of a listing, as paths (a directory's name ends in "/")
78def walk.bends(es: List<&2, String>, +dir: String) -> List<&2, String>:
79  match es:
80    case Nil{}:
81      Nil{}
82    case Con{+e, rest}:
83      +more = walk.bends(rest, dir)
84      Bool.pick(List<&2, String>, String.ends_with(e, ".bend"), walk.under(dir, e) <> more, more)
85
86# a walk once it stops: the `.bend` files it found, and every directory it
87# read, the last one read first
88type Walk is Data:
89  Walk{found: List<&2, String>, read: List<&2, String>}
90
91# the files a walk found
92def walk.found(wk: Walk) -> List<&2, String>:
93  Walk{found, _read} = wk
94  found
95
96# the directories a walk read, in the order it read them
97def walk.read(wk: Walk) -> List<&2, String>:
98  Walk{_found, read} = wk
99  List.reverse(&2, String, read)
100
101# every `.bend` file under the directories still queued, the fuel bounding how
102# many directories are read, each read through the World's listing. What is
103# found is put in front of what came before, not after it, so the walk stays
104# linear; `files` sorts at the end regardless.
105def walk(
106  fuel: Nat,
107  +world: W.World,
108  queue: List<&2, String>,
109  acc: List<&2, String>,
110  seen: List<&2, String>
111) -> Walk:
112  match fuel:
113    case 0n:
114      Walk{acc, seen}
115    case 1n+f:
116      match queue:
117        case Nil{}:
118          Walk{acc, seen}
119        case Con{+dir, rest}:
120          +es = W.listing(world, dir)
121          walk(f, world, List.append(&2, String, rest, walk.dirs(es, dir)),
122            List.append(&2, String, walk.bends(es, dir), acc), dir <> seen)
123
124# how many directories the walk reads at most; past it the walk stops,
125# silently. A literal, not `U32.to_nat(100000)`: the checker keeps a literal
126# whole and steps it one at a time, so laws/ScopeWalksWholeTree.bend's law can name the bound,
127# where it would expand the call in unary and overflow. The compiler emits
128# the same `U32.to_nat` for either.
129def limit() -> Nat:
130  100000n
131
132# the walk of the tree under `.`, reading at most `limit()` directories
133def tree(+world: W.World) -> Walk:
134  walk(limit(), world, ["."], [], [])
135
136# ---------------------------------------------------------------------------
137# the files of the run
138
139# the files named, or every `.bend` file the walk finds when none were
140def files.named(paths: List<&2, String>, +world: W.World) -> List<&2, String>:
141  match paths:
142    case Nil{}:
143      +found = walk.found(tree(world))
144      List.sort(~String, ~String.is_le, found)
145    case Con{p, rest}:
146      p <> rest
147
148# the files the lint runs over
149def files(+world: W.World) -> List<&2, String>:
150  files.named(W.paths.of(world), world)
151
152# a bolt.bend is configuration, not source
153def is_config(+pp: String) -> Bool:
154  Bool.or(String.eq(pp, "bolt.bend"), String.ends_with(pp, "/bolt.bend"))
155
156# the paths that are source
157def sources(ps: List<&2, String>) -> List<&2, String>:
158  match ps:
159    case Nil{}:
160      Nil{}
161    case Con{+p, rest}:
162      +more = sources(rest)
163      Bool.pick(List<&2, String>, is_config(p), more, p <> more)
164
165# the source files of the run, in file-list order
166def inputs(+world: W.World) -> List<&2, String>:
167  sources(files(world))
168
169# a path as the one file it names wherever it is spelled from: resolved
170# against the working directory when relative, then normalized
171# (src/rules/imports.bend), so `a.bend`, `./a.bend` and `/w/a.bend` run
172# from `/w` are one
173def abs(+cwd: String, +path: String) -> String:
174  Imports.norm(Bool.pick(String, String.starts_with(path, "/"), path, cwd ++ "/" ++ path))
175
176# each path resolved (abs)
177def norms(ps: List<&2, String>, +cwd: String) -> List<&2, String>:
178  match ps:
179    case Nil{}:
180      Nil{}
181    case Con{p, rest}:
182      abs(cwd, p) <> norms(rest, cwd)
183
184# the paths that resolve to none of the paths seen
185def unseen(ps: List<&2, String>, +cwd: String, +seen: List<&2, String>) -> List<&2, String>:
186  match ps:
187    case Nil{}:
188      Nil{}
189    case Con{+p, rest}:
190      +more = unseen(rest, cwd, seen)
191      Bool.pick(List<&2, String>, Imports.has(seen, abs(cwd, p)), more, p <> more)
192
193# the files a run reads its roles from: every source the walk of the tree
194# finds, then each source of the run the walk does not find (one named
195# outside the working directory), so each file is read once. A file's role
196# follows the imports, and the file that makes a named file code, a
197# package's main.bend that imports it, may be named or not
198def graph(+world: W.World) -> List<&2, String>:
199  +found = sources(walk.found(tree(world)))
200  +cwd = W.cwd.of(world)
201  List.append(&2, String, found, unseen(inputs(world), cwd, norms(found, cwd)))
202
203# ---------------------------------------------------------------------------
204# what the plan reads
205
206# whether a path is among the paths
207def seen.has(ps: List<&2, String>, +pp: String) -> Bool:
208  match ps:
209    case Nil{}:
210      False{}
211    case Con{p, rest}:
212      Lazy.or_else(String.eq(p, pp), _u => seen.has(rest, pp))
213
214# each path once, where it first appears
215def uniq(ps: List<&2, String>, +seen: List<&2, String>) -> List<&2, String>:
216  match ps:
217    case Nil{}:
218      Nil{}
219    case Con{+p, rest}:
220      +dup = seen.has(seen, p)
221      +more = uniq(rest, p <> seen)
222      Bool.pick(List<&2, String>, dup, more, p <> more)
223
224# ---------------------------------------------------------------------------
225# the packages (laws/ScopePackageRootsFound.bend): every directory the walk reads but `.` whose
226# listing names a main.bend, in the order the walk reads them; none when files
227# were named
228
229# the directories the walk read that hold a main.bend, `.` left out
230def roots.of(ds: List<&2, String>, +world: W.World) -> List<&2, String>:
231  match ds:
232    case Nil{}:
233      Nil{}
234    case Con{+d, rest}:
235      +more = roots.of(rest, world)
236      +root = Bool.and(Bool.not(String.eq(d, ".")), seen.has(W.listing(world, d), "main.bend"))
237      Bool.pick(List<&2, String>, root, d <> more, more)
238
239# the package roots but `.`, as the walk names them (`./w3c`)
240def roots.named(paths: List<&2, String>, +world: W.World) -> List<&2, String>:
241  match paths:
242    case Nil{}:
243      roots.of(walk.read(tree(world)), world)
244    case Con{_p, _rest}:
245      Nil{}
246
247# the package roots but `.`
248def roots(+world: W.World) -> List<&2, String>:
249  roots.named(W.paths.of(world), world)
250
251# each package root as the project rules take it: its directory as a digest
252# names one (`w3c/`)
253def packages(rs: List<&2, String>) -> List<&2, String>:
254  match rs:
255    case Nil{}:
256      Nil{}
257    case Con{r, rest}:
258      Imports.dir_of(r ++ "/main.bend") <> packages(rest)
259
260# the directory of each path
261def dirs.of(ps: List<&2, String>) -> List<&2, String>:
262  match ps:
263    case Nil{}:
264      Nil{}
265    case Con{p, rest}:
266      Config.dir_of(p) <> dirs.of(rest)
267
268# the directory of every source, each once: every finding's path is a
269# source's, so these are the directories grading needs
270def conf.dirs(+ps: List<&2, String>) -> List<&2, String>:
271  uniq(dirs.of(ps), [])
272
273# the bolt.bend candidates of a directory as a path names it, nearest first:
274# the directory resolved against the working directory, then each parent up to
275# the root
276def conf.chain(+world: W.World, dir: String) -> List<&2, String>:
277  Config.chain(Config.home(W.cwd.of(world), dir))
278
279# the bolt.bend candidates of each directory, nearest first
280def conf.cands(ds: List<&2, String>, +world: W.World) -> List<&2, String>:
281  match ds:
282    case Nil{}:
283      Nil{}
284    case Con{d, rest}:
285      List.append(&2, String, conf.chain(world, d), conf.cands(rest, world))
286
287# the listing of each directory, as a question
288def asks.entries(ds: List<&2, String>) -> List<&2, W.Ask>:
289  match ds:
290    case Nil{}:
291      Nil{}
292    case Con{d, rest}:
293      W.Entries{d} <> asks.entries(rest)
294
295# the text of each path, as a question
296def asks.texts(ps: List<&2, String>) -> List<&2, W.Ask>:
297  match ps:
298    case Nil{}:
299      Nil{}
300    case Con{p, rest}:
301      W.Contents{p} <> asks.texts(rest)
302
303# the listings the plan reads: every directory the walk of the tree reads,
304# whether files were named or not, since a named file's role is read from the
305# imports of the whole tree (graph)
306def lists(+world: W.World) -> List<&2, W.Ask>:
307  asks.entries(walk.read(tree(world)))
308
309# the texts the plan reads: each source, every file of the graph, and every
310# bolt.bend candidate of the sources' directories, each once
311def texts(+world: W.World) -> List<&2, W.Ask>:
312  +ps = inputs(world)
313  asks.texts(uniq(List.concat(&2, String, [ps, graph(world), conf.cands(conf.dirs(ps), world)]),
314    []))
315
316# every question the plan reads the World through: the listings, then the
317# texts
318def looks(+world: W.World) -> List<&2, W.Ask>:
319  List.append(&2, W.Ask, lists(world), texts(world))
320
321# the questions a World does not answer yet
322def unanswered(asks: List<&2, W.Ask>, +world: W.World) -> List<&2, W.Ask>:
323  match asks:
324    case Nil{}:
325      Nil{}
326    case Con{+a, rest}:
327      +more = unanswered(rest, world)
328      Bool.pick(List<&2, W.Ask>, W.answered(world, a), more, a <> more)
329
330# the listings still open, or, once there are none, the texts still open
331def wants.pick(ls: List<&2, W.Ask>, +world: W.World) -> List<&2, W.Ask>:
332  match ls:
333    case Nil{}:
334      unanswered(texts(world), world)
335    case Con{a, rest}:
336      a <> rest
337
338# the questions a World still leaves open. The file list is known only once
339# every directory the walk reads is listed, so the texts wait for the walk.
340def wants(+world: W.World) -> List<&2, W.Ask>:
341  wants.pick(unanswered(lists(world), world), world)
342
343# ---------------------------------------------------------------------------
344# the findings
345
346# a source path and the text the World holds for it
347type Got is Data:
348  Got{path: String, text: Maybe<&2, String>}
349
350# each source with what the World answers for it
351def gots(ps: List<&2, String>, +world: W.World) -> List<&2, Got>:
352  match ps:
353    case Nil{}:
354      Nil{}
355    case Con{+p, rest}:
356      Got{p, W.text(world, p)} <> gots(rest, world)
357
358# one finding for each source that could not be read
359def unread(gs: List<&2, Got>) -> List<&2, F.Finding>:
360  match gs:
361    case Nil{}:
362      Nil{}
363    case Con{Got{+path, None{}}, rest}:
364      F.Finding{path, 0, 0, 0, "read", "Cannot read this file."} <> unread(rest)
365    case Con{Got{_path, Some{_text}}, rest}:
366      unread(rest)
367
368# every source that was read, parsed once for every rule
369def srcs_of(gs: List<&2, Got>) -> List<&2, Src.Src>:
370  match gs:
371    case Nil{}:
372      Nil{}
373    case Con{Got{_path, None{}}, rest}:
374      srcs_of(rest)
375    case Con{Got{path, Some{text}}, rest}:
376      Src.of(path, text) <> srcs_of(rest)
377
378# every source's findings from the per-file rules, each told its role among
379# the marks (src/role.bend)
380def per_file.role(+ms: List<&2, Role.Mark>, ss: List<&2, Src.Src>) -> List<&2, F.Finding>:
381  match ss:
382    case Nil{}:
383      Nil{}
384    case Con{s, rest}:
385      Src.Src{+path, text, toks, tree, bound, items} = s
386      List.concat(&2, F.Finding, [Rules.on.role(Role.of(ms, path), Src.Src{path, text, toks, tree, bound, items}),
387        per_file.role(ms, rest)])
388
389# the facts (src/role.bend) of each file the World read, from its text, each
390# at its path resolved against the working directory (abs)
391def facts.of(gs: List<&2, Got>, +cwd: String) -> List<&2, Role.Fact>:
392  match gs:
393    case Nil{}:
394      Nil{}
395    case Con{Got{_path, None{}}, rest}:
396      facts.of(rest, cwd)
397    case Con{Got{path, Some{text}}, rest}:
398      Role.fact.text(abs(cwd, path), text) <> facts.of(rest, cwd)
399
400# each path's mark as the path is spelled: its role among marks taken over
401# resolved paths (abs), neither when it has none there
402def marks.key(ps: List<&2, String>, +cwd: String, +ms: List<&2, Role.Mark>) -> List<&2, Role.Mark>:
403  match ps:
404    case Nil{}:
405      Nil{}
406    case Con{+p, rest}:
407      Role.Mark{Imports.norm(p), Role.of.norm(ms, abs(cwd, p))} <> marks.key(rest, cwd, ms)
408
409# the marks of a run over files named on the line: every file of the graph
410# read for its imports, the walk taken over their resolved paths, and each
411# named file's role looked up there as it is named
412def marks.named(+world: W.World) -> List<&2, Role.Mark>:
413  +cwd = W.cwd.of(world)
414  marks.key(inputs(world), cwd, Role.marks.facts(facts.of(gots(graph(world), world), cwd)))
415
416# the marks a run's roles are read from: over the whole tree, the sources'
417# own; over files named on the line, the graph's, every file of the tree
418# read for its imports, so the main.bend that imports a named file makes it
419# code even when it is not named
420def marks.run(paths: List<&2, String>, +world: W.World, +ss: List<&2, Src.Src>) -> List<&2, Role.Mark>:
421  match paths:
422    case Nil{}:
423      Role.marks(ss)
424    case Con{_p, _rest}:
425      marks.named(world)
426
427# the path of the first candidate the World holds a text for, as a list of
428# one; none when it holds none
429def conf.first(gs: List<&2, Got>) -> List<&2, String>:
430  match gs:
431    case Nil{}:
432      Nil{}
433    case Con{Got{path, Some{_text}}, _rest}:
434      [path]
435    case Con{Got{_path, None{}}, rest}:
436      conf.first(rest)
437
438# the bolt.bend that grades each directory, in directory order: the first of
439# its candidates the World read, as conf.near reads it; none for a directory
440# graded by the defaults
441def conf.used(ds: List<&2, String>, +world: W.World) -> List<&2, String>:
442  match ds:
443    case Nil{}:
444      Nil{}
445    case Con{d, rest}:
446      List.append(&2, String, conf.first(gots(conf.chain(world, d), world)), conf.used(rest, world))
447
448# every bolt.bend grading reads, each once, in the order its directories come
449def graders(+world: W.World) -> List<&2, String>:
450  uniq(conf.used(conf.dirs(inputs(world)), world), [])
451
452# the `setting` rule's findings on each bolt.bend the World read, in order
453def settings.at(gs: List<&2, Got>) -> List<&2, F.Finding>:
454  match gs:
455    case Nil{}:
456      Nil{}
457    case Con{Got{+path, Some{text}}, rest}:
458      List.append(&2, F.Finding, Setting.check(path, text), settings.at(rest))
459    case Con{Got{_path, None{}}, rest}:
460      settings.at(rest)
461
462# the `setting` rule's findings: each unknown setting of every bolt.bend
463# grading reads (src/rules/correctness/setting.bend)
464def settings(+world: W.World) -> List<&2, F.Finding>:
465  settings.at(gots(graders(world), world))
466
467# every finding of every rule, ungraded, from the sources and what the World
468# answers for them: the read failures, the per-file findings of each source,
469# then `setting`'s over each bolt.bend grading reads, then the project rules'
470# over every source and the other packages
471def found.at(+world: W.World, +gs: List<&2, Got>, +ss: List<&2, Src.Src>) -> List<&2, F.Finding>:
472  List.concat(&2, F.Finding, [unread(gs), per_file.role(marks.run(W.paths.of(world), world, ss), ss), settings(world),
473    Rules.project(ss, packages(roots(world)))])
474
475# every finding of every rule, ungraded
476def found(+world: W.World) -> List<&2, F.Finding>:
477  +gs = gots(inputs(world), world)
478  found.at(world, gs, srcs_of(gs))
479
480# ---------------------------------------------------------------------------
481# grading
482
483# a directory and the config its findings are graded by
484type Conf is Data:
485  Conf{dir: String, config: Config.Config}
486
487# what the World holds at each path
488def texts.at(ps: List<&2, String>, +world: W.World) -> List<&2, Maybe<&2, String>>:
489  match ps:
490    case Nil{}:
491      Nil{}
492    case Con{p, rest}:
493      W.text(world, p) <> texts.at(rest, world)
494
495# a directory's config: the nearest bolt.bend the World could read among its
496# candidates, or the defaults
497def conf.near(+world: W.World, dir: String) -> Config.Config:
498  Config.nearest(texts.at(conf.chain(world, dir), world))
499
500# each directory's config
501def confs.at(ds: List<&2, String>, +world: W.World) -> List<&2, Conf>:
502  match ds:
503    case Nil{}:
504      Nil{}
505    case Con{+d, rest}:
506      Conf{d, conf.near(world, d)} <> confs.at(rest, world)
507
508# the config of every directory grading needs
509def confs(+world: W.World) -> List<&2, Conf>:
510  confs.at(conf.dirs(inputs(world)), world)
511
512# the config of a directory, from the table; one the table does not hold is
513# worked out the same way (every finding's directory is in the table, so this
514# never happens in a run)
515def conf.find(cs: List<&2, Conf>, +world: W.World, +dir: String) -> Config.Config:
516  match cs:
517    case Nil{}:
518      conf.near(world, dir)
519    case Con{Conf{d, cfg}, rest}:
520      Lazy.stop(Config.Config, String.eq(d, dir), cfg, _u => conf.find(rest, world, dir))
521
522# the config a finding at a path is graded by: its directory's
523def config.at(+cs: List<&2, Conf>, +world: W.World, path: String) -> Config.Config:
524  conf.find(cs, world, Config.dir_of(path))
525
526# every finding graded by the config of its directory, in order, the ones at
527# off dropped
528def grade(+cs: List<&2, Conf>, +world: W.World, fs: List<&2, F.Finding>) -> List<&2, F.Graded>:
529  match fs:
530    case Nil{}:
531      Nil{}
532    case Con{F.Finding{+path, line, col, len, rule, msg}, rest}:
533      cfg = config.at(cs, world, path)
534      List.append(&2, F.Graded, Rules.graded(cfg, [F.Finding{path, line, col, len, rule, msg}]), grade(cs, world, rest))
535
536# ---------------------------------------------------------------------------
537# silencing, then the `noqa` rule
538
539# the `noqa` rule's findings on each file's marks, against the graded
540# findings, each put at its file as a per-file rule's are
541def noqa(ms: List<&2, Noqa.Marks>, +whole: Bool, +gs: List<&2, F.Graded>) -> List<&2, F.Finding>:
542  match ms:
543    case Nil{}:
544      Nil{}
545    case Con{Noqa.Marks{+path, mks}, rest}:
546      List.append(&2, F.Finding, Rules.on.at(path, NoqaRule.check(mks, whole, path, gs)), noqa(rest, whole, gs))
547
548# the graded findings no mark silences, in order, then the `noqa` rule's,
549# graded by the grader given: nothing silences what `noqa` reports
550def after(
551  +ms: List<&2, Noqa.Marks>,
552  +whole: Bool,
553  +gs: List<&2, F.Graded>,
554  grader: List<&2, F.Finding> -> List<&2, F.Graded>
555) -> List<&2, F.Graded>:
556  List.append(&2, F.Graded, Noqa.keep(ms, gs), grader(noqa(ms, whole, gs)))
557
558# every finding of the run, graded, then silenced by the marks of the
559# sources, then `noqa`'s own, which judges a project rule's code only when
560# the run is the whole tree. The sources are parsed once for both.
561def final(+world: W.World) -> List<&2, F.Graded>:
562  +gs = gots(inputs(world), world)
563  +ss = srcs_of(gs)
564  +cs = confs(world)
565  after(Noqa.of_srcs(ss), List.is_empty(&2, String, W.paths.of(world)), grade(cs, world, found.at(world, gs, ss)),
566    fs => grade(cs, world, fs))
567
568# ---------------------------------------------------------------------------
569# the plan
570
571# one more at error, or the count as it was
572def errors.at(ll: Config.Level, more: U32) -> U32:
573  match ll:
574    case Config.Off{}:
575      more
576    case Config.Warn{}:
577      more
578    case Config.Error{}:
579      U32.inc(more)
580
581# how many are errors
582def errors(gs: List<&2, F.Graded>) -> U32:
583  match gs:
584    case Nil{}:
585      0
586    case Con{F.Graded{ll, _ff}, rest}:
587      errors.at(ll, errors(rest))
588
589# one more below error, or the count as it was
590def warnings.at(ll: Config.Level, more: U32) -> U32:
591  match ll:
592    case Config.Off{}:
593      U32.inc(more)
594    case Config.Warn{}:
595      U32.inc(more)
596    case Config.Error{}:
597      more
598
599# how many are not errors
600def warnings(gs: List<&2, F.Graded>) -> U32:
601  match gs:
602    case Nil{}:
603      0
604    case Con{F.Graded{ll, _ff}, rest}:
605      warnings.at(ll, warnings(rest))
606
607# whether any is an error
608def has_error(gs: List<&2, F.Graded>) -> Bool:
609  match gs:
610    case Nil{}:
611      False{}
612    case Con{F.Graded{ll, _ff}, rest}:
613      +more = has_error(rest)
614      Bool.or(Config.is_error(ll), more)
615
616# `1 error`, `2 errors`
617def count(+nn: U32, +what: String) -> String:
618  U32.show(nn) ++ " " ++ what ++ Bool.pick(String, U32.is_eq(nn, 1), "", "s")
619
620# `clean`, or the counts
621def summary(+gs: List<&2, F.Graded>) -> String:
622  Lazy.stop(String, List.is_empty(&2, F.Graded, gs), "clean",
623    _u => count(errors(gs), "error") ++ ", " ++ count(warnings(gs), "warning"))
624
625# every graded finding as a line
626def show_all(gs: List<&2, F.Graded>) -> List<&2, String>:
627  match gs:
628    case Nil{}:
629      Nil{}
630    case Con{g, rest}:
631      F.show_graded(g) <> show_all(rest)
632
633# `bolt` over a World: every graded finding no mark silences as a line, then
634# `noqa`'s, then the summary of those, and exit 1 exactly when one is an error
635def plan(+world: W.World) -> Plan:
636  +gs = final(world)
637  Plan{List.append(&2, String, show_all(gs), [summary(gs)]), Bool.pick(U32, has_error(gs), 1, 0)}