src/rules/laws/trace.bend

bolt@1.11.0.0 · 415 lines · 0 definitions

1# rule trace (project-wide, opt-in): SPEC.md and the laws agree. SPEC.md,
2# read from the directory bolt runs in, lists requirements in tables whose
3# header is exactly `| ID | Requirement | Level | Status | Law |`, and trust
4# rows in tables headed `| ID | Assumption | Why it is trusted |`. An ID is
5# uppercase letters and digits in two or more segments joined by `-`
6# (`BOLT-CFG-1`); a law proves one when a line of its comment block is exactly
7# that ID (`# BOLT-CFG-1`). A Proved row, proved or pending, is a claim: a
8# proved row's Law cell names the laws that prove it, and a pending row's may
9# name laws that prove part of it while the row stays pending. The findings: a
10# row that is not a well-formed requirement or trust row, an ID that does not
11# match the pattern included; an ID listed twice; a proved row whose Law cell
12# names no law; a Trusted row that names a law; a Law entry of a Proved row,
13# proved or pending, whose law is missing, has no `for`/`exs` binder, or lacks
14# the row's tag; a Trusted row with no trust row; and a tag
15# naming an ID SPEC.md does not list as Proved, proved or pending. A Law entry
16# is `<path> <law>`, the path relative to SPEC.md, entries joined by `; `.
17# Only a bolt.bend that names the rule turns it on (src/config.bend). It runs
18# only when bolt lints the whole tree, never over files named on the line.
19import Base
20import bolt@1.11.0.0/src/finding.bend as F
21import bolt@1.11.0.0/src/rules/digest.bend as Digest
22import bolt@1.11.0.0/src/rules/imports.bend as Imports
23import bolt@1.11.0.0/src/lazy/lazy.bend as Lazy
24
25# reading SPEC.md
26# ---------------
27
28# one requirement row: its ID, level and status, the Law cell's entries, and
29# its line (0-based)
30type Row is Data:
31  Row{id: String, level: String, status: String, laws: List<&2, String>, line: U32}
32
33# an ID, or a reason, at a line
34type Mark is Data:
35  Mark{id: String, line: U32}
36
37# which table the lines are in; Head is the header row, whose `| :-- |` line
38# comes next
39type Mode is Data:
40  Out{}
41  ReqHead{}
42  Reqs{}
43  TrustHead{}
44  Trusts{}
45
46# what SPEC.md says: the requirement rows, the trust rows, the rows that are
47# not well formed (why, where), each list in reverse file order while read
48type Spec is Data:
49  Spec{mode: Mode, reqs: List<&2, Row>, trust: List<&2, Mark>, bad: List<&2, Mark>}
50
51# the requirement table's header
52def req_head() -> String:
53  "| ID | Requirement | Level | Status | Law |"
54
55# the trust table's header
56def trust_head() -> String:
57  "| ID | Assumption | Why it is trusted |"
58
59# a cell's text, spaces trimmed
60def trim(+ss: String) -> String:
61  String.trim(ss)
62
63# a table line read so far: the cell being read and the cells before it, each
64# backwards
65type Cells is Data:
66  Cells{cur: List<&2, Char>, done: List<&2, String>}
67
68# one char onto the cells read: a bar ends the cell, any other char extends it
69def cells.at(bar: Bool, cc: Char, st: Cells) -> Cells:
70  match bar:
71    case True{}:
72      Cells{cur, done} = st
73      Cells{[], trim(String.from_list(List.reverse(&2, Char, cur))) <> done}
74    case False{}:
75      Cells{cur, done} = st
76      Cells{cc <> cur, done}
77
78# the cells of a table line, split at every `|` that does not follow a `\`
79# (an escaped `\|`); esc says the char before was a `\`. It matches no char
80# pattern, so the proofs can take a char apart by what Char.is_eq says of it
81def cells.go(cs: List<&2, Char>, esc: Bool, st: Cells) -> List<&2, String>:
82  match cs:
83    case Nil{}:
84      Cells{cur, done} = st
85      List.reverse(&2, String, String.from_list(List.reverse(&2, Char, cur)) <> done)
86    case Con{+c, t}:
87      cells.go(t, Char.is_eq(c, '\\'), cells.at(Bool.and(Char.is_eq(c, '|'), Bool.not(esc)), c, st))
88
89# the cells between a line's outer bars
90def cells(+line: String) -> List<&2, String>:
91  +all = cells.go(String.to_list(String.trim(line)), False{}, Cells{[], []})
92  List.take(&2, String, List.drop(&2, String, all, 1n), Nat.sub(List.length(&2, String, all), 2n))
93
94# the i-th cell, or empty
95def cell(+cs: List<&2, String>, ii: Nat) -> String:
96  Maybe.default(&2, String, List.get(&2, String, cs, ii), "")
97
98def entries.go(ss: List<&2, String>) -> List<&2, String>:
99  match ss:
100    case Nil{}:
101      Nil{}
102    case Con{+s, rest}:
103      +more = entries.go(rest)
104      Bool.pick(List<&2, String>, String.is_empty(trim(s)), more, trim(s) <> more)
105
106# a Law cell's entries
107def entries(ss: String) -> List<&2, String>:
108  entries.go(String.split(ss, ';'))
109
110# a requirement row onto the spec, or a malformed one
111def add_req(+line: String, +nn: U32, st: Spec) -> Spec:
112  Spec{+mode, +reqs, +trust, +bad} = st
113  +cs = cells(line)
114  Lazy.stop(Spec, Bool.not(Nat.is_eq(List.length(&2, String, cs), 5n)),
115    Spec{mode, reqs, trust, Mark{"a requirement row must have five cells", nn} <> bad},
116    _u => Spec{mode, Row{cell(cs, 0n), cell(cs, 2n), cell(cs, 3n), entries(cell(cs, 4n)), nn} <> reqs, trust, bad})
117
118# a trust row onto the spec, or a malformed one
119def add_trust(+line: String, +nn: U32, st: Spec) -> Spec:
120  Spec{+mode, +reqs, +trust, +bad} = st
121  +cs = cells(line)
122  Bool.pick(Spec, Nat.is_eq(List.length(&2, String, cs), 3n),
123    Spec{mode, reqs, Mark{cell(cs, 0n), nn} <> trust, bad},
124    Spec{mode, reqs, trust, Mark{"a trust row must have three cells", nn} <> bad})
125
126# the spec in another mode
127def moved(st: Spec, mm: Mode) -> Spec:
128  Spec{mode, reqs, trust, bad} = st
129  Spec{mm, reqs, trust, bad}
130
131# a line inside a table: the header's `| :-- |` line, a row, or the end
132def in_table(mode: Mode, +line: String, +nn: U32, st: Spec) -> Spec:
133  match mode:
134    case Out{}:
135      st
136    case ReqHead{}:
137      moved(st, Reqs{})
138    case Reqs{}:
139      add_req(line, nn, st)
140    case TrustHead{}:
141      moved(st, Trusts{})
142    case Trusts{}:
143      add_trust(line, nn, st)
144
145# the mode a spec is in
146def mode_of(st: Spec) -> Mode:
147  Spec{mode, reqs, trust, bad} = st
148  mode
149
150# one line of SPEC.md onto what it says
151def step(+line: String, +nn: U32, +st: Spec) -> Spec:
152  +tt = String.trim(line)
153  Bool.pick(Spec, String.eq(tt, req_head()), moved(st, ReqHead{}),
154    Bool.pick(Spec, String.eq(tt, trust_head()), moved(st, TrustHead{}),
155      Lazy.stop(Spec, Bool.not(String.starts_with(tt, "|")), moved(st, Out{}),
156        _u => in_table(mode_of(st), tt, nn, st))))
157
158def parse.go(ls: List<&2, String>, +nn: U32, st: Spec) -> Spec:
159  match ls:
160    case Nil{}:
161      st
162    case Con{l, rest}:
163      parse.go(rest, (nn + 1 : U32), step(l, nn, st))
164
165# what SPEC.md's text says
166def parse(text: String) -> Spec:
167  parse.go(String.lines(text), 0, Spec{Out{}, [], [], []})
168
169# IDs
170# ---
171
172# is the char an uppercase letter or a digit?
173def id_char(cc: Char) -> Bool:
174  +u = Char.to_u32(cc)
175  Bool.or(Bool.and(U32.is_ge(u, 65), U32.is_le(u, 90)), Bool.and(U32.is_ge(u, 48), U32.is_le(u, 57)))
176
177# is every char an uppercase letter or a digit?
178def id_chars(cs: List<&2, Char>) -> Bool:
179  match cs:
180    case Nil{}:
181      True{}
182    case Con{c, rest}:
183      Lazy.and_then(id_char(c), _u => id_chars(rest))
184
185# is every char of the segment an uppercase letter or a digit, and is there one?
186def segment(+ss: String) -> Bool:
187  Bool.and(Bool.not(String.is_empty(ss)), id_chars(String.to_list(ss)))
188
189# is every segment well formed?
190def segments(ss: List<&2, String>) -> Bool:
191  match ss:
192    case Nil{}:
193      True{}
194    case Con{s, rest}:
195      Lazy.and_then(segment(s), _u => segments(rest))
196
197# does the ID start with a letter?
198def lettered(+ss: String) -> Bool:
199  +c = Maybe.default(&2, Char, List.head(&2, Char, String.to_list(ss)), '0')
200  +u = Char.to_u32(c)
201  Bool.and(U32.is_ge(u, 65), U32.is_le(u, 90))
202
203# is the text an ID: `[A-Z][A-Z0-9]*(-[A-Z0-9]+)+`?
204def is_id(+ss: String) -> Bool:
205  +parts = String.split(ss, '-')
206  Bool.and(Bool.and(Nat.is_ge(List.length(&2, String, parts), 2n), segments(parts)), lettered(ss))
207
208# checking
209# --------
210
211# a finding in SPEC.md
212def at(+nn: U32, msg: String) -> F.Finding:
213  F.Finding{"SPEC.md", nn, 0, 0, "trace", msg}
214
215# how many rows have this ID (a Nat, so the count is the same in any order)
216def rows_with(rs: List<&2, Row>, +id: String) -> Nat:
217  match rs:
218    case Nil{}:
219      0n
220    case Con{Row{+i, lv, s, ls, n}, rest}:
221      +m = rows_with(rest, id)
222      Bool.pick(Nat, String.eq(i, id), 1n+m, m)
223
224# how many trust rows have this ID
225def marks_with(ms: List<&2, Mark>, +id: String) -> Nat:
226  match ms:
227    case Nil{}:
228      0n
229    case Con{Mark{+i, n}, rest}:
230      +m = marks_with(rest, id)
231      Bool.pick(Nat, String.eq(i, id), 1n+m, m)
232
233# the digest of a LAWS.bend at a path, when it was read
234def laws_file(ds: List<&2, Digest.Digest>, +pp: String) -> Maybe<&2, List<&2, Digest.Law>>:
235  match ds:
236    case Nil{}:
237      None{}
238    case Con{Digest.Digest{path, +norm, dir, is_laws, is_law_file, exempt, tops, says, deps, unsafes, laws}, rest}:
239      Lazy.stop(Maybe<&2, List<&2, Digest.Law>>, Bool.and(is_laws, String.eq(norm, pp)), Some{laws},
240        _u => laws_file(rest, pp))
241
242# the law of that name
243def law_named(ls: List<&2, Digest.Law>, +name: String) -> Maybe<&2, Digest.Law>:
244  match ls:
245    case Nil{}:
246      None{}
247    case Con{Digest.Law{+n, l, b, d}, rest}:
248      Lazy.stop(Maybe<&2, Digest.Law>, String.eq(n, name), Some{Digest.Law{n, l, b, d}}, _u => law_named(rest, name))
249
250# is the ID one of the doc lines?
251def tagged(doc: List<&2, String>, +id: String) -> Bool:
252  match doc:
253    case Nil{}:
254      False{}
255    case Con{d, rest}:
256      Lazy.or_else(String.eq(trim(d), id), _u => tagged(rest, id))
257
258# what is wrong with the law a Proved row names, found or not
259def judge_law(mm: Maybe<&2, Digest.Law>, +id: String, +entry: String, +nn: U32) -> List<&2, F.Finding>:
260  match mm:
261    case None{}:
262      [at(nn, id ++ " names " ++ entry ++ ", which is not a law in any LAWS.bend that bolt read.")]
263    case Some{Digest.Law{n, l, +b, +d}}:
264      List.append(&2, F.Finding,
265        Bool.pick(List<&2, F.Finding>, b, [], [at(nn, id ++ " names " ++ entry ++ ", a law with no `for` or `exs` binder.")]),
266        Bool.pick(List<&2, F.Finding>, tagged(d, id), [],
267          [at(nn, id ++ " names " ++ entry ++ ", but that law is not tagged # " ++ id ++ ".")]))
268
269# the law file of an entry, then the law in it
270def judge_file(
271  mm: Maybe<&2, List<&2, Digest.Law>>,
272  +name: String,
273  +id: String,
274  +entry: String,
275  +nn: U32
276) -> List<&2, F.Finding>:
277  match mm:
278    case None{}:
279      [at(nn, id ++ " names " ++ entry ++ ", but its file is not a LAWS.bend that bolt read.")]
280    case Some{ls}:
281      judge_law(law_named(ls, name), id, entry, nn)
282
283# one Law entry of a Proved row: `<path> <law>`
284def judge_entry(+ds: List<&2, Digest.Digest>, +id: String, +entry: String, +nn: U32) -> List<&2, F.Finding>:
285  +words = String.split(entry, ' ')
286  +path = Imports.norm(cell(words, 0n))
287  Lazy.stop(List<&2, F.Finding>, Bool.not(Nat.is_eq(List.length(&2, String, words), 2n)),
288    [at(nn, id ++ ": a Law entry must be <path> <law>, not " ++ entry ++ ".")],
289    _u => judge_file(laws_file(ds, path), cell(words, 1n), id, entry, nn))
290
291# every Law entry of a Proved row
292def judge_entries(es: List<&2, String>, +ds: List<&2, Digest.Digest>, +id: String, +nn: U32) -> List<&2, F.Finding>:
293  match es:
294    case Nil{}:
295      Nil{}
296    case Con{e, rest}:
297      List.append(&2, F.Finding, judge_entry(ds, id, e, nn), judge_entries(rest, ds, id, nn))
298
299# is the row a claim: Proved, and proved or pending?
300def claims(+lv: String, +st: String) -> Bool:
301  Bool.and(String.eq(lv, "Proved"), Bool.or(String.eq(st, "proved"), String.eq(st, "pending")))
302
303# what is wrong with a row's level, status and Law cell
304def shape(+lv: String, +st: String, +ls: List<&2, String>) -> String:
305  +proved = Bool.and(String.eq(lv, "Proved"), String.eq(st, "proved"))
306  +trusted = Bool.and(String.eq(lv, "Trusted"), String.is_empty(st))
307  +none = List.is_empty(&2, String, ls)
308  Bool.pick(String, Bool.not(Bool.or(claims(lv, st), trusted)),
309    "the level must be Proved or Trusted, and the status proved or pending for Proved, empty for Trusted",
310    Bool.pick(String, Bool.and(proved, none), "a proved row must name its law",
311      Bool.pick(String, Bool.and(trusted, Bool.not(none)), "a Trusted row must not name a law", "")))
312
313# every finding of one requirement row
314def judge_row(rr: Row, +sp: Spec, +ds: List<&2, Digest.Digest>) -> List<&2, F.Finding>:
315  Row{+id, +lv, +st, +ls, +nn} = rr
316  Spec{mode, reqs, trust, bad} = sp
317  +why = shape(lv, st, ls)
318  List.concat(&2, F.Finding, [
319    Bool.pick(List<&2, F.Finding>, is_id(id), [], [at(nn, id ++ " is not a valid ID; an ID matches [A-Z][A-Z0-9]*(-[A-Z0-9]+)+.")]),
320    Bool.pick(List<&2, F.Finding>, Nat.is_gt(rows_with(reqs, id), 1n), [at(nn, id ++ " is listed twice.")], []),
321    Bool.pick(List<&2, F.Finding>, String.is_empty(why), [], [at(nn, id ++ ": " ++ why ++ ".")]),
322    Bool.pick(List<&2, F.Finding>, Bool.and(String.eq(lv, "Trusted"), Nat.is_eq(marks_with(trust, id), 0n)),
323      [at(nn, id ++ " is Trusted but has no row in the trust boundary table.")], []),
324    Lazy.stop(List<&2, F.Finding>, Bool.not(claims(lv, st)), [], _u => judge_entries(ls, ds, id, nn))])
325
326# every finding of every requirement row
327def judge_rows(rs: List<&2, Row>, +sp: Spec, +ds: List<&2, Digest.Digest>) -> List<&2, F.Finding>:
328  match rs:
329    case Nil{}:
330      Nil{}
331    case Con{r, rest}:
332      List.append(&2, F.Finding, judge_row(r, sp, ds), judge_rows(rest, sp, ds))
333
334# is the ID a row that is a claim: Proved, and proved or pending?
335def claimed(rs: List<&2, Row>, +id: String) -> Bool:
336  match rs:
337    case Nil{}:
338      False{}
339    case Con{Row{+i, +lv, +st, ls, n}, rest}:
340      Lazy.or_else(Bool.and(claims(lv, st), String.eq(i, id)), _u => claimed(rest, id))
341
342# the tags of a law SPEC.md does not list as Proved, proved or pending
343def stray(
344  doc: List<&2, String>,
345  +rs: List<&2, Row>,
346  +path: String,
347  +name: String,
348  +ll: U32
349) -> List<&2, F.Finding>:
350  match doc:
351    case Nil{}:
352      Nil{}
353    case Con{d, rest}:
354      +tt = trim(d)
355      +more = stray(rest, rs, path, name, ll)
356      Lazy.stop(List<&2, F.Finding>, Lazy.or_else(Bool.not(is_id(tt)), _u => claimed(rs, tt)), more,
357        _u => F.Finding{path, ll, 0, 0, "trace",
358          "Law " ++ name ++ " is tagged " ++ tt ++ ", which is not a Proved row in SPEC.md with status proved or pending."} <> more)
359
360def strays.laws(ls: List<&2, Digest.Law>, +rs: List<&2, Row>, +path: String) -> List<&2, F.Finding>:
361  match ls:
362    case Nil{}:
363      Nil{}
364    case Con{Digest.Law{+n, +l, b, d}, rest}:
365      List.append(&2, F.Finding, stray(d, rs, path, n, l), strays.laws(rest, rs, path))
366
367# every stray tag of every law file
368def strays(ds: List<&2, Digest.Digest>, +rs: List<&2, Row>) -> List<&2, F.Finding>:
369  match ds:
370    case Nil{}:
371      Nil{}
372    case Con{Digest.Digest{+path, norm, dir, is_laws, is_law_file, exempt, tops, says, deps, unsafes, laws}, rest}:
373      List.append(&2, F.Finding, strays.laws(laws, rs, path), strays(rest, rs))
374
375# the rows that are not rows
376def malformed(ms: List<&2, Mark>) -> List<&2, F.Finding>:
377  match ms:
378    case Nil{}:
379      Nil{}
380    case Con{Mark{why, nn}, rest}:
381      at(nn, "Malformed row: " ++ why ++ ".") <> malformed(rest)
382
383# every trust row whose ID is not an ID, or is listed twice
384def twins(ms: List<&2, Mark>, +all: List<&2, Mark>) -> List<&2, F.Finding>:
385  match ms:
386    case Nil{}:
387      Nil{}
388    case Con{Mark{+id, +nn}, rest}:
389      +more = twins(rest, all)
390      +twice = Bool.pick(List<&2, F.Finding>, Nat.is_gt(marks_with(all, id), 1n), at(nn, id ++ " is listed twice.") <> more,
391        more)
392      Bool.pick(List<&2, F.Finding>, is_id(id), twice, at(nn, id ++ " is not a valid ID; an ID matches [A-Z][A-Z0-9]*(-[A-Z0-9]+)+.") <> twice)
393
394# the rule over a SPEC.md that was read
395def check.spec(+sp: Spec, +ds: List<&2, Digest.Digest>) -> List<&2, F.Finding>:
396  Spec{mode, +reqs, +trust, bad} = sp
397  List.concat(&2, F.Finding, [malformed(List.reverse(&2, Mark, bad)),
398    judge_rows(List.reverse(&2, Row, reqs), sp, ds), twins(List.reverse(&2, Mark, trust), trust), strays(ds, reqs)])
399
400# the rule over SPEC.md's text (None when there is none) and every file read
401def check.at(mm: Maybe<&2, String>, +ds: List<&2, Digest.Digest>) -> List<&2, F.Finding>:
402  match mm:
403    case None{}:
404      [at(0, "Cannot read SPEC.md.")]
405    case Some{text}:
406      check.spec(parse(text), ds)
407
408# the rule, when bolt lints the whole tree; a run over the files named on the
409# line holds only some of the laws, so it has nothing sound to check
410def check(whole: Bool, mm: Maybe<&2, String>, +ds: List<&2, Digest.Digest>) -> List<&2, F.Finding>:
411  match whole:
412    case True{}:
413      check.at(mm, ds)
414    case False{}:
415      Nil{}