review

review patchsets using your default editor
Log | Files | Refs

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 }