message.odin (22131B)
1 package check 2 3 // The checks here need no model. They measure the commit message and 4 // report what fails, before anything is asked of a provider and whether or 5 // not one can answer. 6 7 import "base:runtime" 8 import "core:fmt" 9 import "core:math" 10 import "core:slice" 11 import "core:strings" 12 import "vendor:zlib" 13 14 import "../change" 15 import "../finding" 16 import "../txt" 17 18 // min_entropy is the Shannon entropy, in bits per byte, under which a 19 // message is one short phrase said over rather than a description of a 20 // change. Ordinary commit messages measure between 3.7 and 4.9; 21 // entropy_floor is the length under which entropy says nothing. 22 min_entropy :: 3.2 23 entropy_floor :: 40 24 25 // max_compression is the share of its length a message keeps after zlib, 26 // under which the message is one block of text pasted or repeated whole. 27 // No written commit message measured keeps less than 0.26; 28 // compression_floor is the length under which zlib cannot beat its own 29 // framing. 30 max_compression :: 0.20 31 compression_floor :: 400 32 33 // common_idf is the natural log under which a word counts as one of the 34 // repository's commonest: it appears in at least a fifth of the subjects. 35 // history_floor is the number of subjects under which the frequencies are 36 // too thin to judge a message by. 37 common_idf :: 1.6 38 history_floor :: 100 39 40 // digits are the characters whose presence spares a message: a version or 41 // an issue number is information however few words carry it. 42 digits :: "0123456789" 43 44 // body_floor_lines is the size of diff under which a body is optional; 45 // max_body_words the length over which a body is listing what the diff 46 // already shows. 47 body_floor_lines :: 50 48 max_body_words :: 150 49 50 // temporal_coupling is the Jaccard over which history says two files 51 // change together; temporal_support the fewest commits they must share 52 // before it is a pattern; temporal_findings caps how many pairs one 53 // change is asked about. 54 temporal_coupling :: 0.7 55 temporal_support :: 5 56 temporal_findings :: 3 57 58 @(private = "file") 59 venting_words: map[string]bool 60 @(private = "file") 61 stop_words: map[string]bool 62 @(private = "file") 63 openers: map[string]bool 64 @(private = "file") 65 invariant_verbs: map[string]bool 66 @(private = "file") 67 irregular_past: map[string]bool 68 @(private = "file") 69 non_verb_ing: map[string]bool 70 @(private = "file") 71 brands: map[string]bool 72 73 @(init) 74 init_message_words :: proc "contextless" () { 75 context = runtime.default_context() 76 // The exclamations and profanities of a message written in the moment 77 // of the mistake. Every word here is unambiguous: stupid, dumb, 78 // finally, eventually and annoying all appear in measured history 79 // describing the code legitimately. 80 venting_words = set( 81 `fuck fucking fucked shit bullshit wtf damn dammit damnit oops oopsie whoops 82 ugh argh grr sigh fml yolo idk`, 83 ) 84 // The function words, which name nothing. 85 stop_words = set( 86 `a an the and or but if then than that this these those 87 to of in on at by for with from into up out it its is are was were be been being am 88 as so not no nor do does did done doing can could will would shall should may might 89 must have has had having i we you they he she him her them his hers their ours your 90 my me us what which who whom when where why how all any both each few more most 91 other some such only own same too very just also now here there over under again 92 further once about between through during before after above below because while 93 until against`, 94 ) 95 // The words whose presence alone is narration. 96 openers = set(`i we my this these those`) 97 // The verbs whose past and imperative share a form. 98 invariant_verbs = set( 99 `read cut set put let hit cost split shut cast hurt quit burst spread slit`, 100 ) 101 // The past forms no suffix rule could catch. 102 irregular_past = set( 103 `wrote made kept went got ran brought built bought caught drove found held left met 104 paid sent spent took won sold freed`, 105 ) 106 // The words that end in ing without being a verb's gerund. 107 non_verb_ing = set( 108 `during nothing something anything everything morning evening offing outing bring 109 king ring sing spring string swing thing wing cling sting fling`, 110 ) 111 // The product names written with an inner capital. 112 brands = set( 113 `gRPC iOS macOS iPadOS watchOS tvOS iPhone iPad iCloud eBay jQuery 114 PayPal YouTube GitHub GitLab OpenAI WebAssembly LaTeX TeX`, 115 ) 116 } 117 118 // measured is the message the checks read. It is empty for a staged 119 // change without a supplied message, and every message check says nothing 120 // then. 121 measured :: proc(c: ^change.Change) -> string { 122 return strings.trim_space(c.message) 123 } 124 125 // check_entropy reports a message whose characters carry too little 126 // entropy: the shape of a placeholder, a keyboard mash, or one phrase 127 // repeated. 128 check_entropy :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 129 msg := measured(s.c) 130 if msg == "" || len(msg) < entropy_floor { 131 return 132 } 133 h := shannon_entropy(msg) 134 if h >= min_entropy { 135 return 136 } 137 append( 138 out, 139 static( 140 "message-low-entropy", 141 .Must_Fix, 142 fmt.aprintf( 143 "the commit message measures %.1f bits of Shannon entropy per byte, under the %.1f beneath which a message is a phrase repeated rather than a description; ordinary messages measure 3.7 to 4.9", 144 h, 145 min_entropy, 146 ), 147 "write a message that says what the change does and why", 148 ), 149 ) 150 } 151 152 // check_compressibility reports a message zlib keeps only a fraction of: 153 // the shape of text pasted or repeated wholesale, such as a licence notice 154 // or a log, rather than prose written for this change. 155 check_compressibility :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 156 msg := measured(s.c) 157 if msg == "" || len(msg) < compression_floor { 158 return 159 } 160 ratio := compression_ratio(msg) 161 if ratio >= max_compression { 162 return 163 } 164 append( 165 out, 166 static( 167 "message-boilerplate", 168 .Must_Fix, 169 fmt.aprintf( 170 "the commit message compresses to %.0f%% of its length, under the %.0f%% beneath which it is one block of text pasted or repeated rather than prose about the change; no written message measured keeps less than 26%%", 171 100 * ratio, 172 100 * max_compression, 173 ), 174 "keep only what the reader needs of the quoted text, and write the rest", 175 ), 176 ) 177 } 178 179 // check_common reports a message made entirely of the words this 180 // repository's own history uses most, which names nothing the change 181 // touches. A word the history has never used is the one thing a message 182 // like this cannot have, which is why a message holding any rarer word is 183 // left to the reader. 184 check_common :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 185 msg := measured(s.c) 186 if msg == "" || len(s.c.history) < history_floor { 187 return 188 } 189 words := content_words(msg, context.temp_allocator) 190 if len(words) == 0 { 191 return 192 } 193 if strings.has_prefix(msg, "Merge ") || 194 strings.has_prefix(msg, "Squashed ") || 195 strings.contains_any(msg, digits) { 196 return 197 } 198 freq := frequencies(s.c.history, context.temp_allocator) 199 n := len(s.c.history) 200 for w in words { 201 if math.ln(f64(n) / f64(1 + freq[w])) > common_idf { 202 return 203 } 204 } 205 ground := diff_words(s.c, context.temp_allocator) 206 for w in words { 207 if ground[w] { 208 return 209 } 210 } 211 listed := sorted_keys(words) 212 append( 213 out, 214 static( 215 "message-common-words", 216 .Must_Fix, 217 fmt.aprintf( 218 "the commit message is made of the repository's commonest commit words — %s — with nothing rarer than a fifth of its %d commit subjects, and it names nothing the change touches; a word the history has not used is the one thing a message like this cannot have", 219 strings.join(listed, ", ", context.temp_allocator), 220 n, 221 ), 222 "name the part and the fault, in words the change itself uses", 223 ), 224 ) 225 } 226 227 // identifier_shaped matches the words of a message that name code rather 228 // than describe it: a camel-cased or snake-cased word, a dotted or slashed 229 // path, a call, or anything in backticks. 230 identifier_shaped :: "`[^`]+`|\\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\\b|\\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\\b|\\b[A-Za-z][A-Za-z0-9_]*\\(\\)|\\b[A-Za-z][A-Za-z0-9_-]*(?:[./][A-Za-z0-9_-]+)+\\.[a-z]{1,5}\\b" 231 232 // links matches a URL in a message. 233 links :: `\bhttps?://\S+` 234 235 // check_names_unknown reports a message that names an identifier the 236 // repository does not hold: not in the diff, not in any file at the end 237 // of the change, not a path in the tree. A message naming code that is 238 // not there describes work the diff does not contain. 239 check_names_unknown :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 240 msg := measured(s.c) 241 if msg == "" { 242 return 243 } 244 msg = remove_all(links, msg, context.temp_allocator) 245 names := make([dynamic]string, context.temp_allocator) 246 seen := make(map[string]bool, context.temp_allocator) 247 for m in find_all(identifier_shaped, msg) { 248 name := strings.trim_suffix(strings.trim_space(strings.trim(m, "`")), "()") 249 if name == "" || 250 seen[name] || 251 strings.contains_any(name, " \t") || 252 strings.contains(name, "://") || 253 brand_shaped(name) { 254 continue 255 } 256 seen[name] = true 257 append(&names, name) 258 } 259 if len(names) == 0 { 260 return 261 } 262 missing := make([dynamic]string, context.temp_allocator) 263 for name in names { 264 if strings.contains(s.c.diff, name) || strings.contains(s.c.stat, name) { 265 continue 266 } 267 found := false 268 for f in s.files { 269 if strings.contains(f, name) { 270 found = true 271 break 272 } 273 } 274 if !found { 275 for _, data in s.sources { 276 if strings.contains(string(data), name) { 277 found = true 278 break 279 } 280 } 281 } 282 if !found { 283 append(&missing, name) 284 } 285 } 286 if len(missing) == 0 { 287 return 288 } 289 append( 290 out, 291 static( 292 "message-names-unknown", 293 .Consider, 294 fmt.aprintf( 295 "the commit message names %s, and nothing by that name is in the diff or anywhere in the repository at the end of the change; a message naming code that is not there describes work the diff does not contain", 296 quoted(missing[:], context.temp_allocator), 297 ), 298 "name what the change actually touches, as the code spells it", 299 ), 300 ) 301 } 302 303 // brand_shaped is whether a word is a product name rather than a name 304 // from the code: on the list, or a short lowercase prefix before a run of 305 // capitals, which is how gRPC and iOS are spelled and how no identifier 306 // is. 307 brand_shaped :: proc(word: string) -> bool { 308 if brands[word] { 309 return true 310 } 311 i := 0 312 for i < len(word) && word[i] >= 'a' && word[i] <= 'z' { 313 i += 1 314 } 315 if i == 0 || i > 2 || i == len(word) { 316 return false 317 } 318 for j in i ..< len(word) { 319 if word[j] < 'A' || word[j] > 'Z' { 320 return false 321 } 322 } 323 return true 324 } 325 326 // check_venting reports a message whose words are the author's reaction 327 // rather than the change's description: oops, whoops, damn, profanity. 328 check_venting :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 329 msg := measured(s.c) 330 if msg == "" { 331 return 332 } 333 hit := make(map[string]bool, context.temp_allocator) 334 for piece in fields(msg, context.temp_allocator) { 335 w := strings.to_lower(piece, context.temp_allocator) 336 if venting_words[w] { 337 hit[w] = true 338 } 339 } 340 if len(hit) == 0 { 341 return 342 } 343 append( 344 out, 345 static( 346 "message-frustration", 347 .Must_Fix, 348 fmt.aprintf( 349 "the commit message is an exclamation — %s — where a description should be; the log then records the author's feeling, and the change goes undescribed", 350 strings.join(sorted_keys(hit), ", ", context.temp_allocator), 351 ), 352 "describe the change, not the moment", 353 ), 354 ) 355 } 356 357 // check_mood reports a subject that does not open as a command. The 358 // discipline is package: explainer, with the explainer in the imperative 359 // mood: a subject that opens in the past tense or on a gerund records 360 // that a thing was done, and one that opens on the author narrates the 361 // author. Verbs that wear one form for every mood are spared, as are 362 // articles: an explainer may be a noun phrase on purpose. 363 check_mood :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 364 msg := measured(s.c) 365 if msg == "" { 366 return 367 } 368 subject := subject_of(msg) 369 if strings.has_prefix(subject, "Merge ") || strings.has_prefix(subject, "Squashed ") { 370 return 371 } 372 words := fields(explainer(subject), context.temp_allocator) 373 if len(words) == 0 { 374 return 375 } 376 first := strings.to_lower(words[0], context.temp_allocator) 377 what := "" 378 switch { 379 case openers[first]: 380 what = "narration" 381 case invariant_verbs[first]: 382 return 383 case irregular_past[first] || 384 (len(first) >= 4 && strings.has_suffix(first, "ed") && !strings.has_suffix(first, "eed")): 385 what = "the past tense" 386 case len(first) >= 5 && strings.has_suffix(first, "ing") && !non_verb_ing[first]: 387 what = "a gerund" 388 case: 389 return 390 } 391 append( 392 out, 393 static( 394 "message-not-imperative", 395 .Must_Fix, 396 fmt.aprintf( 397 "the commit message opens on %s — %q — where the discipline is a command: package: explainer, with the explainer in the imperative mood; a subject that opens in the past tense, on a gerund, or on the author records what was done rather than saying what to do", 398 what, 399 words[0], 400 ), 401 "open the subject on its verb, in the imperative", 402 ), 403 ) 404 } 405 406 // subject_of is a message's first line. 407 subject_of :: proc(msg: string) -> string { 408 if i := strings.index_byte(msg, '\n'); i >= 0 { 409 return msg[:i] 410 } 411 return msg 412 } 413 414 // explainer is the part of a subject after its package prefix: the part 415 // after "review:" in "review: measure it". A subject without a lowercase 416 // prefix is all explainer. 417 explainer :: proc(subject: string) -> string { 418 i := strings.index(subject, ": ") 419 if i < 2 || i > 23 { 420 return subject 421 } 422 for j in 0 ..< i { 423 r := subject[j] 424 if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_') { 425 return subject 426 } 427 } 428 return subject[i + 2:] 429 } 430 431 // check_body asks a large change to say something in its body: a diff of 432 // more than body_floor_lines owes a body, however short, and no body may 433 // exceed max_body_words. The diff records what moved; the body is the 434 // only place the change's why is recorded. A change that only moves text 435 // around, whose subject says what it did, owes nothing. 436 check_body :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 437 msg := measured(s.c) 438 if msg == "" { 439 return 440 } 441 subject := subject_of(msg) 442 if strings.has_prefix(subject, "Merge ") || strings.has_prefix(subject, "Squashed ") { 443 return 444 } 445 n := body_words(msg) 446 if n > max_body_words { 447 append( 448 out, 449 static( 450 "message-long-body", 451 .Must_Fix, 452 fmt.aprintf( 453 "the commit message's body holds %d words, over the %d the discipline allows; past that a body lists what the diff already shows, and the reader of the log stops before the why", 454 n, 455 max_body_words, 456 ), 457 "cut the body to the change's why", 458 ), 459 ) 460 return 461 } 462 if n > 0 { 463 return 464 } 465 changed := changed_lines(s.c.diff) 466 if changed <= body_floor_lines || moved(s.c.diff) { 467 return 468 } 469 append( 470 out, 471 static( 472 "message-no-body", 473 .Must_Fix, 474 fmt.aprintf( 475 "the change is %d lines of diff, and the commit message carries no body; the diff records what moved, and the body is the only place the change's why is recorded", 476 changed, 477 ), 478 "write the body, saying why the change is what it is", 479 ), 480 ) 481 } 482 483 // check_temporal reports a changed file whose history names a partner the 484 // change does not touch: over the last thousand commits before the 485 // change, at least temporal_coupling of the commits touching either file 486 // have touched both, at least temporal_support times. Files that change 487 // together this reliably usually fail together. 488 check_temporal :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 489 temporal, counted := s.c.temporal.? 490 if !counted { 491 return 492 } 493 changed := make(map[string]bool, context.temp_allocator) 494 for f in s.c.files { 495 changed[f] = true 496 } 497 Pair :: struct { 498 file: string, 499 partner: change.Partner, 500 joint: int, 501 j: f64, 502 } 503 pairs := make([dynamic]Pair, context.temp_allocator) 504 for file, partners in temporal.partners { 505 for p in partners { 506 if changed[p.name] { 507 continue 508 } 509 joint := temporal.commits[file] + temporal.commits[p.name] - p.shared 510 if joint <= 0 { 511 continue 512 } 513 j := f64(p.shared) / f64(joint) 514 if j < temporal_coupling { 515 // The list is nearest first, so the rest of this file's 516 // partners are further away still. 517 break 518 } 519 if p.shared < temporal_support { 520 continue 521 } 522 append(&pairs, Pair{file, p, joint, j}) 523 } 524 } 525 slice.sort_by_cmp(pairs[:], proc(a, b: Pair) -> slice.Ordering { 526 if a.partner.shared != b.partner.shared { 527 return .Less if a.partner.shared > b.partner.shared else .Greater 528 } 529 if a.j != b.j { 530 return .Less if a.j > b.j else .Greater 531 } 532 if a.file != b.file { 533 return .Less if a.file < b.file else .Greater 534 } 535 if a.partner.name != b.partner.name { 536 return .Less if a.partner.name < b.partner.name else .Greater 537 } 538 return .Equal 539 }) 540 for p, i in pairs { 541 if i == temporal_findings { 542 break 543 } 544 append( 545 out, 546 static( 547 "history-coupled-file", 548 .Must_Fix, 549 fmt.aprintf( 550 "history ties %s to %s: %d of the %d commits touching either file have touched both, and this change touches %s without %s; files that change together this reliably usually fail together, and the half of the pair left out is where a forgotten change usually is", 551 p.file, 552 p.partner.name, 553 p.partner.shared, 554 p.joint, 555 p.file, 556 p.partner.name, 557 ), 558 fmt.aprintf( 559 "touch %s too, or be sure it stands without this change", 560 p.partner.name, 561 ), 562 file = p.file, 563 ), 564 ) 565 } 566 } 567 568 // moved is whether a diff's added and removed sides hold the same lines: 569 // the change rearranged text rather than changing it. 570 moved :: proc(diff: string) -> bool { 571 counts := make(map[string]int, context.temp_allocator) 572 n := 0 573 rest := diff 574 for line in strings.split_lines_iterator(&rest) { 575 if !strings.has_prefix(line, "+") && !strings.has_prefix(line, "-") { 576 continue 577 } 578 if strings.has_prefix(line, "+++") || strings.has_prefix(line, "---") { 579 continue 580 } 581 s := strings.trim_space(line[1:]) 582 if s == "" { 583 continue 584 } 585 n += 1 586 counts[s] += 1 if line[0] == '+' else -1 587 } 588 for _, v in counts { 589 if v != 0 { 590 return false 591 } 592 } 593 return n > 0 594 } 595 596 // body_words counts the words below the subject line. 597 body_words :: proc(msg: string) -> int { 598 i := strings.index_byte(msg, '\n') 599 if i < 0 { 600 return 0 601 } 602 return len(fields(msg[i + 1:], context.temp_allocator)) 603 } 604 605 // changed_lines counts the lines a diff adds or removes, without the 606 // diff's own framing. 607 changed_lines :: proc(diff: string) -> int { 608 n := 0 609 rest := diff 610 for line in strings.split_lines_iterator(&rest) { 611 if !strings.has_prefix(line, "+") && !strings.has_prefix(line, "-") { 612 continue 613 } 614 if strings.has_prefix(line, "+++") || strings.has_prefix(line, "---") { 615 continue 616 } 617 n += 1 618 } 619 return n 620 } 621 622 // content_words is the vocabulary of a text: lowercased, split on 623 // anything that is not a letter or digit, split again at camel humps, 624 // depluralised, and stripped of the words that name nothing. 625 content_words :: proc(text: string, allocator := context.allocator) -> map[string]bool { 626 out := make(map[string]bool, allocator) 627 for piece in fields(text, context.temp_allocator) { 628 for part in humps(piece, context.temp_allocator) { 629 w := txt.depluralise(strings.to_lower(part, allocator)) 630 if len(w) > 2 && !stop_words[w] { 631 out[w] = true 632 } 633 } 634 } 635 return out 636 } 637 638 // humps splits containerSniff into container and sniff, so that a message 639 // naming a thing meets the identifier for it in the diff. 640 humps :: proc(s: string, allocator := context.allocator) -> []string { 641 out := make([dynamic]string, allocator) 642 start := 0 643 prev_lower := false 644 for r, i in s { 645 upper := r >= 'A' && r <= 'Z' 646 if i > 0 && upper && prev_lower { 647 append(&out, s[start:i]) 648 start = i 649 } 650 prev_lower = r >= 'a' && r <= 'z' 651 } 652 if start < len(s) { 653 append(&out, s[start:]) 654 } 655 return out[:] 656 } 657 658 // frequencies counts, over the repository's subjects, in how many of them 659 // each content word appears. 660 frequencies :: proc(history: []string, allocator := context.allocator) -> map[string]int { 661 out := make(map[string]int, allocator) 662 for s in history { 663 for w in content_words(s, allocator) { 664 out[w] += 1 665 } 666 } 667 return out 668 } 669 670 // diff_words is the vocabulary of what a change touches: the file paths, 671 // and the words of every line it adds or removes. 672 diff_words :: proc(c: ^change.Change, allocator := context.allocator) -> map[string]bool { 673 out := make(map[string]bool, allocator) 674 for path in c.files { 675 stem := path 676 if dot := strings.last_index_byte(path, '.'); dot > strings.last_index_byte(path, '/') { 677 stem = path[:dot] 678 } 679 for w in content_words(stem, allocator) { 680 out[w] = true 681 } 682 } 683 rest := c.diff 684 for line in strings.split_lines_iterator(&rest) { 685 if strings.has_prefix(line, "+++") || 686 strings.has_prefix(line, "---") || 687 strings.has_prefix(line, "@@") || 688 strings.has_prefix(line, "diff ") || 689 strings.has_prefix(line, "index ") || 690 strings.has_prefix(line, "\\ ") { 691 continue 692 } 693 if strings.has_prefix(line, "+") || strings.has_prefix(line, "-") { 694 for w in content_words(line[1:], allocator) { 695 out[w] = true 696 } 697 } 698 } 699 return out 700 } 701 702 // shannon_entropy is the entropy of s in bits per byte, over its bytes. 703 shannon_entropy :: proc(s: string) -> f64 { 704 n := len(s) 705 if n == 0 { 706 return 0 707 } 708 counts: [256]int 709 for i in 0 ..< n { 710 counts[s[i]] += 1 711 } 712 h: f64 713 for c in counts { 714 if c == 0 { 715 continue 716 } 717 p := f64(c) / f64(n) 718 h -= p * math.log2(p) 719 } 720 return h 721 } 722 723 // compression_ratio is the share of its length s keeps after zlib, at 724 // the library's default level, which is the ratio the threshold was 725 // measured with. 726 compression_ratio :: proc(s: string) -> f64 { 727 if len(s) == 0 { 728 return 1 729 } 730 bound := zlib.compressBound(zlib.uLong(len(s))) 731 dest := make([]byte, int(bound), context.temp_allocator) 732 dest_len := zlib.uLongf(bound) 733 source := transmute([]byte)s 734 if zlib.compress2(raw_data(dest), &dest_len, raw_data(source), zlib.uLong(len(s)), -1) != 0 { 735 return 1 736 } 737 return f64(dest_len) / f64(len(s)) 738 }