diff --git a/Cargo.lock b/Cargo.lock index eadb3ed..30e51fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -339,6 +359,7 @@ version = "0.1.0" dependencies = [ "aho-corasick", "serde", + "thiserror", "toml", ] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 16f2b72..45e0cc2 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,7 +1,7 @@ //! Command-line interface for scanning prose with bundled trope detectors. +use std::fs; use std::{ - fs, io::{self, Read}, path::PathBuf, process::ExitCode, @@ -10,7 +10,7 @@ use std::{ use clap::Parser; use owo_colors::{OwoColorize, Stream}; use tropius_core::{ - detector::{Detector, Finding, FindingKind}, + detector::{Detector, Finding}, patterns::Severity, }; @@ -27,13 +27,10 @@ fn main() -> ExitCode { } match run(Args::parse()) { - Ok(has_findings) => { - if has_findings { - ExitCode::from(1) - } else { - ExitCode::SUCCESS - } - } + Ok(has_findings) => match has_findings { + true => ExitCode::from(1), + false => ExitCode::SUCCESS, + }, Err(error) => { eprintln!( "{} {error}", @@ -74,7 +71,7 @@ fn print_finding(finding: &Finding) { println!( "{} {} {} {}:{} {}", severity_label(finding.severity), - kind_label(finding.kind), + finding.kind.label(), finding .rule_id .if_supports_color(Stream::Stdout, |text| text.bold()), @@ -99,10 +96,3 @@ fn severity_label(severity: Severity) -> String { .to_string(), } } - -fn kind_label(kind: FindingKind) -> &'static str { - match kind { - FindingKind::Phrase => "phrase", - FindingKind::CharacterClass => "char", - } -} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 8aaa7ff..e4c4e38 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -6,4 +6,5 @@ edition = "2024" [dependencies] aho-corasick = "1" serde = { version = "1", features = ["derive"] } +thiserror = "2" toml = "1" diff --git a/crates/core/src/detector.rs b/crates/core/src/detector.rs index cbfedbf..56463c2 100644 --- a/crates/core/src/detector.rs +++ b/crates/core/src/detector.rs @@ -1,12 +1,15 @@ //! Text detectors for phrase-based and structural trope signals. pub mod char_class; +pub mod repetition; +pub mod structural; -use std::{error::Error, fmt}; +use std::fmt::Display; use aho_corasick::{AhoCorasick, MatchKind}; -use crate::patterns::{Pattern, PatternLoadError, PatternValidationError, Severity}; +use crate::errors::DetectorBuildError; +use crate::patterns::{Pattern, Severity}; use crate::patterns::{bundled_patterns, validate_patterns}; /// Finds trope signals in prose. @@ -53,6 +56,7 @@ impl Detector { pub fn scan(&self, text: &str) -> Vec { let mut findings = self.scan_phrases(text); findings.extend(char_class::scan_unicode_decoration(text)); + findings.extend(structural::scan_structural(text)); findings.sort_by_key(|finding| finding.start); findings } @@ -97,6 +101,27 @@ pub struct Finding { pub end: usize, } +impl Finding { + pub fn structural( + rule_id: &str, + rule_name: &str, + severity: Severity, + text: &str, + start: usize, + end: usize, + ) -> Finding { + Finding { + rule_id: rule_id.to_owned(), + rule_name: rule_name.to_owned(), + severity, + kind: FindingKind::Structural, + matched: text[start..end].to_owned(), + start, + end, + } + } +} + /// The kind of detector that produced a finding. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FindingKind { @@ -104,58 +129,23 @@ pub enum FindingKind { Phrase, /// Character-class match from a structural detector. CharacterClass, + /// Document or sentence structure matched by heuristic detectors. + Structural, } -/// Errors that can happen while building a detector. -#[derive(Debug)] -pub enum DetectorBuildError { - /// Bundled pattern files could not be loaded. - PatternLoad(PatternLoadError), - /// Pattern dictionaries failed validation. - PatternValidation(PatternValidationError), - /// The Aho-Corasick automaton could not be built. - AhoCorasick(aho_corasick::BuildError), -} - -impl fmt::Display for DetectorBuildError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::PatternLoad(error) => write!(formatter, "{error}"), - Self::PatternValidation(error) => { - write!(formatter, "invalid pattern dictionary: {error}") - } - Self::AhoCorasick(error) => { - write!(formatter, "failed to build phrase matcher: {error}") - } - } - } -} - -impl Error for DetectorBuildError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::PatternLoad(error) => Some(error), - Self::PatternValidation(error) => Some(error), - Self::AhoCorasick(error) => Some(error), - } - } -} - -impl From for DetectorBuildError { - fn from(error: PatternLoadError) -> Self { - Self::PatternLoad(error) - } -} - -impl From for DetectorBuildError { - fn from(error: PatternValidationError) -> Self { - Self::PatternValidation(error) +impl Display for FindingKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + FindingKind::Phrase => "phrase", + FindingKind::CharacterClass => "char", + FindingKind::Structural => "struct", + }) } } -impl From for DetectorBuildError { - fn from(error: aho_corasick::BuildError) -> Self { - Self::AhoCorasick(error) +impl FindingKind { + pub fn label(self) -> String { + self.to_string() } } diff --git a/crates/core/src/detector/repetition.rs b/crates/core/src/detector/repetition.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/core/src/detector/structural.rs b/crates/core/src/detector/structural.rs new file mode 100644 index 0000000..718a749 --- /dev/null +++ b/crates/core/src/detector/structural.rs @@ -0,0 +1,381 @@ +//! Structural detectors for trope signals that are not literal phrase matches. + +use crate::patterns::Severity; + +use super::Finding; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Span { + start: usize, + end: usize, +} + +/// Finds structural trope signals in text. +pub fn scan_structural(text: &str) -> Vec { + let sentences = sentence_spans(text); + let paragraphs = paragraph_spans(text); + + let mut findings = Vec::new(); + findings.extend(scan_anaphora(text, &sentences)); + findings.extend(scan_tricolon(text, &sentences)); + findings.extend(scan_short_punchy_fragments(text, &sentences)); + findings.extend(scan_listicle_in_trench_coat(text, ¶graphs)); + findings.extend(scan_fractal_summaries(text, ¶graphs)); + findings.extend(scan_historical_analogy_stacking(text, &sentences)); + findings +} + +fn scan_anaphora(text: &str, sentences: &[Span]) -> Vec { + let starts: Vec<_> = sentences + .iter() + .filter_map(|sentence| sentence_start_key(text, *sentence).map(|key| (*sentence, key))) + .collect(); + + starts + .windows(3) + .filter(|window| window[0].1 == window[1].1 && window[1].1 == window[2].1) + .map(|window| { + Finding::structural( + "sentence_structure.anaphora_abuse", + "Anaphora Abuse", + Severity::Medium, + text, + window[0].0.start, + window[2].0.end, + ) + }) + .collect() +} + +fn scan_tricolon(text: &str, sentences: &[Span]) -> Vec { + sentences + .iter() + .filter(|sentence| { + let value = &text[sentence.start..sentence.end]; + let separators = value.matches(',').count() + value.matches(';').count(); + separators >= 2 && repeated_clause_starts(value) >= 2 + }) + .map(|sentence| { + Finding::structural( + "sentence_structure.tricolon_abuse", + "Tricolon Abuse", + Severity::Medium, + text, + sentence.start, + sentence.end, + ) + }) + .collect() +} + +fn scan_short_punchy_fragments(text: &str, sentences: &[Span]) -> Vec { + let mut findings = Vec::new(); + let mut run_start = None; + let mut run_end = 0; + let mut run_len = 0; + + for sentence in sentences { + if word_count(&text[sentence.start..sentence.end]) <= 4 { + run_start.get_or_insert(sentence.start); + run_end = sentence.end; + run_len += 1; + } else { + if run_len >= 3 { + findings.push(Finding::structural( + "paragraph_structure.short_punchy_fragments", + "Short Punchy Fragments", + Severity::Medium, + text, + run_start.unwrap(), + run_end, + )); + } + run_start = None; + run_end = 0; + run_len = 0; + } + } + + if run_len >= 3 { + findings.push(Finding::structural( + "paragraph_structure.short_punchy_fragments", + "Short Punchy Fragments", + Severity::Medium, + text, + run_start.unwrap(), + run_end, + )); + } + + findings +} + +fn scan_listicle_in_trench_coat(text: &str, paragraphs: &[Span]) -> Vec { + let mut ordinal_hits = Vec::new(); + + for paragraph in paragraphs { + if paragraph_starts_with_ordinal(&text[paragraph.start..paragraph.end]) { + ordinal_hits.push(*paragraph); + } + } + + ordinal_hits + .windows(3) + .map(|window| { + Finding::structural( + "paragraph_structure.listicle_in_trench_coat", + "Listicle in a Trench Coat", + Severity::Medium, + text, + window[0].start, + window[2].end, + ) + }) + .collect() +} + +fn scan_fractal_summaries(text: &str, paragraphs: &[Span]) -> Vec { + let mut hits = Vec::new(); + + for paragraph in paragraphs { + let value = text[paragraph.start..paragraph.end].trim_start(); + if starts_with_any_ci( + value, + &[ + "in this section", + "as we've seen", + "as we have seen", + "in summary", + "to sum up", + "in conclusion", + ], + ) { + hits.push(*paragraph); + } + } + + if hits.len() < 3 { + return Vec::new(); + } + + vec![Finding::structural( + "composition.fractal_summaries", + "Fractal Summaries", + Severity::Medium, + text, + hits[0].start, + hits[hits.len() - 1].end, + )] +} + +fn scan_historical_analogy_stacking(text: &str, sentences: &[Span]) -> Vec { + sentences + .windows(3) + .filter(|window| { + window + .iter() + .all(|sentence| has_analogy_marker(&text[sentence.start..sentence.end])) + }) + .map(|window| { + Finding::structural( + "composition.historical_analogy_stacking", + "Historical Analogy Stacking", + Severity::Medium, + text, + window[0].start, + window[2].end, + ) + }) + .collect() +} + +fn sentence_spans(text: &str) -> Vec { + let mut spans = Vec::new(); + let mut start = 0; + + for (index, character) in text.char_indices() { + if matches!(character, '.' | '!' | '?') { + push_trimmed_span(text, &mut spans, start, index + character.len_utf8()); + start = index + character.len_utf8(); + } + } + + push_trimmed_span(text, &mut spans, start, text.len()); + spans +} + +fn paragraph_spans(text: &str) -> Vec { + let mut spans = Vec::new(); + let mut start = 0; + + for (index, _) in text.match_indices("\n\n") { + push_trimmed_span(text, &mut spans, start, index); + start = index + 2; + } + + push_trimmed_span(text, &mut spans, start, text.len()); + spans +} + +fn push_trimmed_span(text: &str, spans: &mut Vec, start: usize, end: usize) { + let value = &text[start..end]; + let trimmed = value.trim(); + + if trimmed.is_empty() { + return; + } + + let leading = value.len() - value.trim_start().len(); + let trailing = value.len() - value.trim_end().len(); + + spans.push(Span { + start: start + leading, + end: end - trailing, + }); +} + +fn sentence_start_key(text: &str, sentence: Span) -> Option { + let words = words(&text[sentence.start..sentence.end]); + + if words.is_empty() { + None + } else { + Some(words.into_iter().take(2).collect::>().join(" ")) + } +} + +fn repeated_clause_starts(sentence: &str) -> usize { + let starts: Vec<_> = sentence + .split([',', ';']) + .filter_map(|clause| words(clause).into_iter().next()) + .collect(); + + starts + .windows(2) + .filter(|window| window[0] == window[1]) + .count() +} + +fn paragraph_starts_with_ordinal(paragraph: &str) -> bool { + starts_with_any_ci( + paragraph.trim_start(), + &[ + "the first", + "first,", + "first ", + "the second", + "second,", + "second ", + "the third", + "third,", + ], + ) +} + +fn has_analogy_marker(sentence: &str) -> bool { + let lower = sentence.to_ascii_lowercase(); + let examples = [ + "apple", "facebook", "stripe", "aws", "spotify", "uber", "airbnb", "shopify", "discord", + "web", "mobile", "social", "cloud", + ]; + + examples.iter().any(|example| lower.contains(example)) +} + +fn starts_with_any_ci(value: &str, prefixes: &[&str]) -> bool { + let lower = value.to_ascii_lowercase(); + prefixes.iter().any(|prefix| lower.starts_with(prefix)) +} + +fn word_count(value: &str) -> usize { + words(value).len() +} + +fn words(value: &str) -> Vec { + value + .split(|character: char| !character.is_ascii_alphanumeric() && character != '\'') + .filter(|word| !word.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_anaphora_abuse() { + let findings = scan_structural( + "They assume users pay. They assume builders arrive. They assume markets form.", + ); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "sentence_structure.anaphora_abuse") + ); + } + + #[test] + fn detects_tricolon_abuse() { + let findings = scan_structural( + "Products impress people, products empower teams, products create worlds.", + ); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "sentence_structure.tricolon_abuse") + ); + } + + #[test] + fn detects_short_punchy_fragments() { + let findings = scan_structural("He published this. Openly. In a book. As a priest."); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "paragraph_structure.short_punchy_fragments") + ); + } + + #[test] + fn detects_listicle_in_trench_coat() { + let findings = scan_structural( + "The first wall is access.\n\nThe second wall is pricing.\n\nThe third wall is trust.", + ); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "paragraph_structure.listicle_in_trench_coat") + ); + } + + #[test] + fn detects_fractal_summaries() { + let findings = scan_structural( + "In this section, we examine access.\n\nAs we've seen, access matters.\n\nIn summary, access wins.", + ); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "composition.fractal_summaries") + ); + } + + #[test] + fn detects_historical_analogy_stacking() { + let findings = scan_structural( + "Apple did not build Uber. Facebook did not build Spotify. AWS did not build Airbnb.", + ); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "composition.historical_analogy_stacking") + ); + } +} diff --git a/crates/core/src/errors.rs b/crates/core/src/errors.rs new file mode 100644 index 0000000..a4c5b50 --- /dev/null +++ b/crates/core/src/errors.rs @@ -0,0 +1,66 @@ +//! Error types returned by core pattern loading and detector construction. + +use thiserror::Error; + +/// Errors that can happen while building a detector. +#[derive(Debug, Error)] +pub enum DetectorBuildError { + /// Bundled pattern files could not be loaded. + #[error(transparent)] + PatternLoad(#[from] PatternLoadError), + /// Pattern dictionaries failed validation. + #[error("invalid pattern dictionary: {0}")] + PatternValidation(#[from] PatternValidationError), + /// The Aho-Corasick automaton could not be built. + #[error("failed to build phrase matcher: {0}")] + AhoCorasick(#[from] aho_corasick::BuildError), +} + +/// Errors that can happen while loading bundled pattern dictionaries. +#[derive(Debug, Error)] +pub enum PatternLoadError { + /// A TOML file could not be deserialized. + #[error("failed to parse pattern TOML: {0}")] + Toml(#[from] toml::de::Error), + /// Pattern dictionaries parsed but failed semantic validation. + #[error("invalid pattern dictionary: {0}")] + Validation(#[from] PatternValidationError), +} + +/// Validation errors for parsed pattern dictionaries. +#[derive(Debug, Clone, Error, PartialEq, Eq)] +pub enum PatternValidationError { + /// A pattern id is empty or only whitespace. + #[error("pattern id cannot be empty")] + EmptyPatternId, + /// A pattern name is empty or only whitespace. + #[error("pattern `{id}` name cannot be empty")] + EmptyPatternName { + /// The id of the invalid pattern. + id: String, + }, + /// A pattern has no phrases. + #[error("pattern `{id}` must have at least one phrase")] + EmptyPhraseList { + /// The id of the invalid pattern. + id: String, + }, + /// A pattern phrase is empty or only whitespace. + #[error("pattern `{id}` contains an empty phrase")] + EmptyPhrase { + /// The id of the invalid pattern. + id: String, + }, + /// Two patterns use the same id. + #[error("duplicate pattern id `{id}`")] + DuplicatePatternId { + /// The duplicate pattern id. + id: String, + }, + /// Two pattern entries use the same phrase after ASCII case folding. + #[error("duplicate phrase `{phrase}`")] + DuplicatePhrase { + /// The duplicate phrase as it appeared in the later entry. + phrase: String, + }, +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 4f0a113..c842707 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,4 +1,5 @@ //! Core detection and pattern-loading logic for `tropius`. pub mod detector; +pub mod errors; pub mod patterns; diff --git a/crates/core/src/patterns.rs b/crates/core/src/patterns.rs index 601821e..f519e82 100644 --- a/crates/core/src/patterns.rs +++ b/crates/core/src/patterns.rs @@ -1,6 +1,8 @@ //! Pattern dictionary types and bundled TOML loading. -use std::{collections::HashSet, error::Error, fmt}; +use std::collections::HashSet; + +use crate::errors::{PatternLoadError, PatternValidationError}; /// TOML files bundled into `tropius-core`. pub const BUNDLED_PATTERN_FILES: &[(&str, &str)] = &[ @@ -20,6 +22,45 @@ pub const BUNDLED_PATTERN_FILES: &[(&str, &str)] = &[ ), ]; +/// Severity attached to a pattern or detector finding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + /// Low-confidence or low-impact signal. + Low, + /// Medium-confidence signal. + Medium, + /// High-confidence signal. + High, +} + +/// A deserialized TOML pattern file. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct PatternFile { + /// Pattern entries declared by the file. + pub patterns: Vec, +} + +impl PatternFile { + /// Deserializes a pattern file from TOML text. + pub fn from_toml(input: &str) -> Result { + toml::from_str(input) + } +} + +/// One phrase-based trope pattern. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct Pattern { + /// Stable dotted id, such as `word_choice.delve`. + pub id: String, + /// Human-readable pattern name. + pub name: String, + /// Pattern severity. + pub severity: Severity, + /// Literal phrases matched case-insensitively by the phrase detector. + pub phrases: Vec, +} + /// Loads and validates the pattern dictionaries bundled with the crate. pub fn bundled_patterns() -> Result, PatternLoadError> { let mut patterns = Vec::new(); @@ -81,137 +122,6 @@ pub fn validate_patterns(patterns: &[Pattern]) -> Result<(), PatternValidationEr Ok(()) } -/// Errors that can happen while loading bundled pattern dictionaries. -#[derive(Debug)] -pub enum PatternLoadError { - /// A TOML file could not be deserialized. - Toml(toml::de::Error), - /// Pattern dictionaries parsed but failed semantic validation. - Validation(PatternValidationError), -} - -impl fmt::Display for PatternLoadError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Toml(error) => write!(formatter, "failed to parse pattern TOML: {error}"), - Self::Validation(error) => write!(formatter, "invalid pattern dictionary: {error}"), - } - } -} - -impl Error for PatternLoadError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Toml(error) => Some(error), - Self::Validation(error) => Some(error), - } - } -} - -impl From for PatternLoadError { - fn from(error: toml::de::Error) -> Self { - Self::Toml(error) - } -} - -impl From for PatternLoadError { - fn from(error: PatternValidationError) -> Self { - Self::Validation(error) - } -} - -/// Validation errors for parsed pattern dictionaries. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PatternValidationError { - /// A pattern id is empty or only whitespace. - EmptyPatternId, - /// A pattern name is empty or only whitespace. - EmptyPatternName { - /// The id of the invalid pattern. - id: String, - }, - /// A pattern has no phrases. - EmptyPhraseList { - /// The id of the invalid pattern. - id: String, - }, - /// A pattern phrase is empty or only whitespace. - EmptyPhrase { - /// The id of the invalid pattern. - id: String, - }, - /// Two patterns use the same id. - DuplicatePatternId { - /// The duplicate pattern id. - id: String, - }, - /// Two pattern entries use the same phrase after ASCII case folding. - DuplicatePhrase { - /// The duplicate phrase as it appeared in the later entry. - phrase: String, - }, -} - -impl fmt::Display for PatternValidationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::EmptyPatternId => write!(formatter, "pattern id cannot be empty"), - Self::EmptyPatternName { id } => { - write!(formatter, "pattern `{id}` name cannot be empty") - } - Self::EmptyPhraseList { id } => { - write!(formatter, "pattern `{id}` must have at least one phrase") - } - Self::EmptyPhrase { id } => { - write!(formatter, "pattern `{id}` contains an empty phrase") - } - Self::DuplicatePatternId { id } => write!(formatter, "duplicate pattern id `{id}`"), - Self::DuplicatePhrase { phrase } => write!(formatter, "duplicate phrase `{phrase}`"), - } - } -} - -impl Error for PatternValidationError {} - -/// Severity attached to a pattern or detector finding. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Severity { - /// Low-confidence or low-impact signal. - Low, - /// Medium-confidence signal. - Medium, - /// High-confidence signal. - High, -} - -/// A deserialized TOML pattern file. -#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] -pub struct PatternFile { - /// Pattern entries declared by the file. - pub patterns: Vec, -} - -impl PatternFile { - /// Deserializes a pattern file from TOML text. - pub fn from_toml(input: &str) -> Result { - toml::from_str(input) - } -} - -/// One phrase-based trope pattern. -#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] -pub struct Pattern { - /// Stable dotted id, such as `word_choice.delve`. - pub id: String, - /// Human-readable pattern name. - pub name: String, - /// Pattern severity. - pub severity: Severity, - /// Literal phrases matched case-insensitively by the phrase detector. - pub phrases: Vec, -} - #[cfg(test)] mod tests { use super::*; diff --git a/todo.md b/todo.md index 7909853..079bfff 100644 --- a/todo.md +++ b/todo.md @@ -41,7 +41,7 @@ phrases = [ ## Trope Coverage Checklist Current phrase coverage: 22 of 33 source sections. -Implemented non-Aho detectors: 1. +Implemented non-Aho detectors: 7. - [x] Quietly and Other Magic Adverbs - [x] Delve and Friends @@ -50,13 +50,13 @@ Implemented non-Aho detectors: 1. - [x] Negative Parallelism - [x] Not X. Not Y. Just Z. - [x] The X? A Y. -- [ ] Anaphora Abuse - structural detector -- [ ] Tricolon Abuse - structural detector +- [x] Anaphora Abuse - structural detector +- [x] Tricolon Abuse - structural detector - [x] It's Worth Noting - [x] Superficial Analyses - [x] False Ranges -- [ ] Short Punchy Fragments - structural detector -- [ ] Listicle in a Trench Coat - structural detector +- [x] Short Punchy Fragments - structural detector +- [x] Listicle in a Trench Coat - structural detector - [x] Here's the Kicker - [x] Think of It As - [x] Imagine a World Where @@ -69,10 +69,10 @@ Implemented non-Aho detectors: 1. - [x] Em-Dash Addiction - [ ] Bold-First Bullets - markdown-aware detector - [x] Unicode Decoration - character-class detector -- [ ] Fractal Summaries - structural detector +- [x] Fractal Summaries - structural detector - [ ] The Dead Metaphor - repetition detector -- [ ] Historical Analogy Stacking - structural detector -- [ ] One-Point Dilution - semantic or repetition detector +- [x] Historical Analogy Stacking - structural detector +- [ ] One-Point Dilution - repetition detector (or semantic) - [ ] Content Duplication - repetition detector - [x] The Signposted Conclusion - [x] Despite Its Challenges