1use super::Rule;
12
13const CUES: &[&str] = &[
17 "instead of",
18 "rather than",
19 "ask before",
20 "check before",
21 "never",
22 "don't",
23 "do not",
24 "avoid",
25 "stop using",
26 "not ",
27];
28
29const TABLE: &[(&[&str], &str)] = &[
31 (&["npm"], r"re:npm\s+(install|i|add|ci)\b"),
32 (&["yarn"], r"re:yarn(\s+(add|install)\b|\s*$)"),
33 (&["pip", "pip3"], r"re:(python3?\s+-m\s+)?pip3?\s+install\b"),
34 (
35 &["force-push", "force"],
36 r"re:git\s+push\b.*(--force( |$)|-f( |$)|--force-with-lease\S*.*[\s:=](refs/heads/)?(main|master|develop)(\s|:|$)|[\s:=](refs/heads/)?(main|master|develop)(\s|:).*--force-with-lease)",
41 ),
42 (&["push"], r"re:git\s+push\b.*\b(main|master)\b"),
43 (&[".env"], r"re:.*>>?\s*\S*\.env\b"),
44 (
45 &["migration", "migrations", "migrate"],
46 r"re:(psql\b.*-f\s*\S*migrat|rails\s+db:migrate|bin/rails\s+db:migrate|alembic\s+upgrade|flyway\s+migrate|prisma\s+migrate\s+deploy|sqlx\s+migrate\s+run|diesel\s+migration\s+run|knex\s+migrate|goose\b.*\bup\b)",
47 ),
48 (
49 &["terraform", "tofu"],
50 r"re:(terraform|tofu)\b.*\b(destroy|apply\b.*-destroy)\b",
51 ),
52 (&["kubectl"], r"re:kubectl\b.*\bdelete\b"),
53 (
54 &["docker"],
55 r"re:docker\s+(system\s+prune|volume\s+(rm|prune)|rm|rmi)\b",
56 ),
57];
58
59const REMOTE_WORDS: &[&str] = &["staging", "stage", "prod", "production", "live"];
61
62const MIGRATION_REMOTE: &str = r"re:.*\b(staging|stage|prod|production|live)\b.*\b(migrat\w*|upgrade)\b|.*\b(migrat\w*|upgrade)\b.*\b(staging|stage|prod|production|live)\b";
64
65const RUN_WORDS: &[&str] = &[
67 "run",
68 "running",
69 "apply",
70 "applying",
71 "against",
72 "migrate",
73 "migrating",
74];
75
76fn forbidden_part(text: &str) -> Option<String> {
79 let lower = format!(" {} ", text.to_lowercase());
80 let (at, cue) = CUES
81 .iter()
82 .filter_map(|c| lower.find(&format!(" {c}")).map(|i| (i, *c)))
83 .min_by_key(|(i, _)| *i)?;
84 let rest = &lower[at + 1 + cue.len()..];
85 let end = rest.find([';', '.', ':']).map_or(rest.len(), |i| {
86 let next = rest[i + 1..].chars().next();
88 if rest.as_bytes()[i] == b'.' && next.is_some_and(char::is_alphanumeric) {
89 rest[i + 1..]
90 .find([';', ':'])
91 .map_or(rest.len(), |j| i + 1 + j)
92 } else {
93 i
94 }
95 });
96 Some(rest[..end].to_string())
97}
98
99fn words(clause: &str) -> Vec<String> {
101 clause
102 .split(|c: char| c.is_whitespace() || matches!(c, ',' | '(' | ')' | '"' | '`' | '\''))
103 .map(|w| w.trim_end_matches(['.', '!', '?']).to_lowercase())
104 .filter(|w| !w.is_empty())
105 .collect()
106}
107
108#[must_use]
113pub fn rule_candidates(text: &str) -> Vec<Rule> {
114 let Some(part) = forbidden_part(text) else {
115 return Vec::new();
116 };
117 let named = words(&part);
118 let mut rules: Vec<Rule> = Vec::new();
119 for (keys, pattern) in TABLE {
120 if keys.iter().any(|k| named.iter().any(|w| w == k))
121 && !rules.iter().any(|r| r.pattern == *pattern)
122 {
123 if keys.contains(&"migration") && !named.iter().any(|w| RUN_WORDS.contains(&w.as_str()))
127 {
128 continue;
129 }
130 if *pattern == TABLE[4].1 && rules.iter().any(|r| r.pattern == TABLE[3].1) {
132 continue;
133 }
134 let pattern = if keys.contains(&"migration")
137 && named.iter().any(|w| REMOTE_WORDS.contains(&w.as_str()))
138 {
139 MIGRATION_REMOTE
140 } else {
141 *pattern
142 };
143 if rules.iter().any(|r| r.pattern == pattern) {
144 continue;
145 }
146 rules.push(Rule {
147 pattern: pattern.to_string(),
148 verdict: "ask".into(),
149 reason: text.trim().to_string(),
150 });
151 }
152 }
153 rules.retain(|rule| !crate::blankets_ordinary_pushes(&rule.pattern));
156 if crate::under_a_runner() {
157 rules.retain(|rule| !crate::broader_than_cited(&rule.pattern, &rule.reason));
158 }
159 rules
160}
161
162const STOP: &[&str] = &[
164 "a", "an", "and", "any", "are", "as", "at", "be", "before", "by", "every", "for", "from", "in",
165 "into", "is", "it", "its", "of", "on", "or", "our", "the", "them", "this", "to", "use",
166 "using", "run", "running", "with", "when", "we", "you", "your", "always", "never", "instead",
167 "prefer", "keep", "make", "do", "not", "plain", "file", "files", "repo", "here", "all", "each",
168 "new", "via", "than", "rather", "only", "first", "then",
169];
170
171fn stem(word: &str) -> &str {
173 word.strip_suffix('s')
174 .filter(|s| s.len() >= 3)
175 .unwrap_or(word)
176}
177
178struct CommandWords {
181 heads: Vec<String>,
182 pieces: Vec<String>,
183}
184
185fn command_words(line: &str) -> CommandWords {
186 let mut heads = Vec::new();
187 let mut pieces = Vec::new();
188 for segment in line.split(['|', ';', '&', '\n']) {
189 let mut toks = segment.split_whitespace().peekable();
190 while let Some(t) = toks.peek() {
192 let assignment = t.split_once('=').is_some_and(|(k, _)| {
193 !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
194 });
195 if assignment || matches!(*t, "sudo" | "env" | "command" | "exec" | "time" | "nice") {
196 toks.next();
197 } else {
198 break;
199 }
200 }
201 let Some(program) = toks.next() else { continue };
202 let program = program.rsplit('/').next().unwrap_or(program).to_lowercase();
203 heads.push(program.clone());
204 let rest: Vec<&str> = toks.collect();
205 if let Some(sub) = rest.iter().find(|w| !w.starts_with('-')) {
206 if matches!(
207 program.as_str(),
208 "git" | "cargo" | "go" | "docker" | "kubectl" | "npm"
209 ) {
210 heads.push(sub.to_lowercase());
211 }
212 }
213 for (i, w) in rest.iter().enumerate() {
214 let target = if let Some(t) = w.strip_prefix(">>").or_else(|| w.strip_prefix('>')) {
215 if t.is_empty() {
216 rest.get(i + 1).copied()
217 } else {
218 Some(t)
219 }
220 } else {
221 None
222 };
223 if let Some(t) = target {
224 let base = t.rsplit('/').next().unwrap_or(t).to_lowercase();
225 heads.push(base);
226 }
227 for p in w.split(|c: char| !c.is_alphanumeric()) {
228 if p.len() >= 3 && !p.chars().all(|c| c.is_ascii_digit()) {
229 pieces.push(p.to_lowercase());
230 }
231 }
232 }
233 }
234 CommandWords { heads, pieces }
235}
236
237#[must_use]
245pub fn bears_on(preference: &str, line: &str) -> bool {
246 let clause = forbidden_part(preference).unwrap_or_else(|| preference.to_lowercase());
247 let named: Vec<String> = words(&clause)
248 .into_iter()
249 .filter(|w| !STOP.contains(&w.as_str()) && w.len() >= 2)
250 .collect();
251 if named.is_empty() {
252 return false;
253 }
254 let cmd = command_words(line);
255 if cmd.heads.iter().any(|h| named.iter().any(|w| w == h)) {
256 return true;
257 }
258 let mut shared: Vec<&str> = named
259 .iter()
260 .map(|w| stem(w))
261 .filter(|w| cmd.pieces.iter().any(|p| stem(p) == *w))
262 .collect();
263 shared.sort_unstable();
264 shared.dedup();
265 shared.len() >= 2
266}
267
268#[must_use]
270pub fn naming_command<'a>(
271 preferences: &'a [(String, String)],
272 line: &str,
273 cap: usize,
274) -> Vec<&'a (String, String)> {
275 if line.trim().is_empty() {
276 return Vec::new();
277 }
278 preferences
279 .iter()
280 .filter(|(_, text)| bears_on(text, line))
281 .take(cap)
282 .collect()
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::rule_matches;
289
290 const PREFS: &[&str] = &[
291 "Use pnpm instead of npm for every JavaScript package install in this repo",
292 "Run cargo clippy with -D warnings before every commit",
293 "Never push directly to main; open a pull request from a feature branch",
294 "Write commit subjects in the imperative mood with a conventional prefix such as fix: or feat:",
295 "Use uv to run Python tools instead of pip or plain python",
296 "Keep secrets in 1Password and never write them into .env files",
297 "Format SQL migrations with sqlfluff and never edit a migration that has been applied",
298 "Use tabs for indentation in Go files and gofmt them",
299 "Ask before running any database migration against staging or production",
300 "Prefer table-driven tests in Go and parametrize in pytest",
301 ];
302
303 fn asks(text: &str, line: &str) -> bool {
305 rule_candidates(text)
306 .iter()
307 .any(|r| rule_matches(&r.pattern, line))
308 }
309
310 #[test]
311 fn a_forbidding_preference_proposes_an_ask_over_its_command() {
312 assert!(asks(PREFS[0], "npm install lodash"));
313 assert!(asks(PREFS[0], "npm i -D vitest"));
314 assert!(!asks(PREFS[0], "pnpm add lodash"));
315 assert!(!asks(PREFS[0], "npm test"));
316 assert!(asks(PREFS[2], "git push origin main"));
317 assert!(!asks(PREFS[2], "git push origin feature/x"));
318 assert!(asks(PREFS[4], "pip install black"));
319 assert!(asks(PREFS[4], "python3 -m pip install -r requirements.txt"));
320 assert!(!asks(PREFS[4], "uv pip install black"));
321 assert!(asks(PREFS[5], "echo STRIPE_KEY=sk_live_x >> .env"));
322 assert!(!asks(PREFS[5], "cat .env.example"));
323 assert!(asks(PREFS[8], "psql -h staging-db -f migrations/007.sql"));
324 assert!(asks(PREFS[8], "RAILS_ENV=production bin/rails db:migrate"));
325 assert!(asks(PREFS[8], "alembic -x env=prod upgrade head"));
326 assert!(!asks(PREFS[8], "alembic upgrade head"));
327 assert!(!asks(PREFS[8], "psql -f migrations/001_init.sql app_dev"));
328 assert!(asks(
329 "Ask before running a database migration",
330 "bin/rails db:migrate"
331 ));
332 assert!(!asks(PREFS[8], "psql -h staging-db -c 'select 1'"));
333 assert!(asks(
334 "Never force push to shared branches",
335 "git push --force origin dev"
336 ));
337 assert!(!asks(
338 "Never force push to shared branches",
339 "git push origin main"
340 ));
341 assert!(asks("Never force push", "git push --force origin dev"));
342 assert!(asks("Never force push", "git push -f origin dev"));
343 assert!(asks(
344 "Never force push",
345 "git push --force-with-lease origin HEAD:main"
346 ));
347 assert!(asks(
348 "Never force push",
349 "git push --force-with-lease=main:abc123 origin main"
350 ));
351 assert!(!asks("Never force push", "git push -u origin feature"));
352 assert!(asks(
353 "Never force push",
354 "git push --force-with-lease origin develop"
355 ));
356 assert!(asks(
357 "Never force push",
358 "git push origin main --force-with-lease"
359 ));
360 assert!(!asks(
361 "Never force push",
362 "git push --force-with-lease origin feature"
363 ));
364 assert!(!asks(
365 "Never force push",
366 "git push --force-with-lease --force-if-includes origin feature/main-menu"
367 ));
368 assert!(!asks(
369 "Never force push",
370 "git push --force-with-lease origin maintenance"
371 ));
372 assert!(!asks(
373 "Never force push",
374 "git push --force-if-includes origin main"
375 ));
376 assert!(asks(
377 "Never run terraform destroy",
378 "terraform -chdir=infra destroy"
379 ));
380 assert!(asks("Don't use yarn here", "yarn add lodash"));
381 assert!(asks("Don't use yarn here", "yarn"));
382 for text in [
384 PREFS[1],
385 PREFS[3],
386 PREFS[6],
387 PREFS[7],
388 PREFS[9],
389 "Never use unwrap in library code",
390 ] {
391 assert!(rule_candidates(text).is_empty(), "{text}");
392 }
393 let n = PREFS
394 .iter()
395 .filter(|p| !rule_candidates(p).is_empty())
396 .count();
397 assert_eq!(n, 5, "five of the ten preferences carry a rule");
398 assert!(
399 rule_candidates(PREFS[6]).is_empty(),
400 "editing a migration is not running one"
401 );
402 assert!(rule_candidates(PREFS[0])
403 .iter()
404 .all(|r| r.verdict == "ask" && r.reason == PREFS[0]));
405 }
406
407 #[test]
408 fn a_preference_bears_on_the_command_it_names() {
409 let yes = [
410 (0, "npm install lodash"),
411 (2, "git push origin main"),
412 (1, "git commit -m 'fix: x'"),
413 (3, "git commit -m 'fix: x'"),
414 (4, "pip install black"),
415 (5, "echo STRIPE_KEY=sk_live_x >> .env"),
416 (8, "psql -h staging-db -f migrations/007.sql"),
417 (9, "pytest -q tests/"),
418 (7, "go build ./..."),
419 ];
420 for (i, line) in yes {
421 assert!(bears_on(PREFS[i], line), "{} on {line}", PREFS[i]);
422 }
423 let no = [
424 (0, "pnpm add lodash"),
425 (0, "pip install black"),
426 (4, "uv run black ."),
427 (2, "git status"),
428 (5, "cat README.md"),
429 (8, "psql -h localhost -c 'select 1'"),
430 (6, "ls"),
431 ];
432 for (i, line) in no {
433 assert!(!bears_on(PREFS[i], line), "{} on {line}", PREFS[i]);
434 }
435 let prefs: Vec<(String, String)> = PREFS
436 .iter()
437 .enumerate()
438 .map(|(i, t)| (format!("id{i}"), (*t).to_string()))
439 .collect();
440 let hit = naming_command(&prefs, "npm install lodash", 2);
441 assert_eq!(hit.len(), 1);
442 assert_eq!(hit[0].0, "id0");
443 assert!(naming_command(&prefs, " ", 2).is_empty());
444 }
445}