moar static analysis

This commit is contained in:
TC
2026-06-14 20:04:37 +10:00
parent 3f524a1e0b
commit 5bb1212efd

View File

@@ -1,7 +1,5 @@
import { detectPassiveVoice } from './passiveVoice'
const WEASEL_PATTERN = /\b(very|quite|rather|fairly|really|just|basically|actually|literally|clearly|obviously|simply|absolutely|totally|completely|certainly|probably|mostly|nearly|almost|somewhat|arguably|undeniably|remarkably|incredibly|extremely|highly)\b/gi
export interface PolishDimension {
label: string
score: number // 0100
@@ -13,8 +11,15 @@ export interface PolishScore {
dimensions: {
passiveVoice: PolishDimension
weaselWords: PolishDimension
adverbDensity: PolishDimension
filterWords: PolishDimension
showTell: PolishDimension
sentenceRhythm: PolishDimension
paragraphRhythm: PolishDimension
repeatedStarters: PolishDimension
wordVariety: PolishDimension
cliches: PolishDimension
overusedWords: PolishDimension
dialogueTags: PolishDimension
}
}
@@ -28,11 +33,11 @@ function rateScore(issueCount: number, wordCount: number, cap: number): number {
function stripMarkdown(text: string): string {
return text
.replace(/^#{1,6}\s+/gm, '') // headings
.replace(/\*\*?|__?/g, '') // bold/italic
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links
.replace(/`[^`]+`/g, '') // inline code
.replace(/^>\s+/gm, '') // blockquotes
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*?|__?/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/`[^`]+`/g, '')
.replace(/^>\s+/gm, '')
}
function countWordsIn(text: string): number {
@@ -41,13 +46,39 @@ function countWordsIn(text: string): number {
}
function splitSentences(text: string): string[] {
return text
.split(/(?<=[.!?])\s+/)
.map(s => s.trim())
.filter(s => s.length > 0)
return text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(s => s.length > 0)
}
// Sentence rhythm: coefficient of variation of sentence lengths (higher variance = better rhythm)
// ── Weasel words ──────────────────────────────────────────────────────────────
const WEASEL_PATTERN = /\b(very|quite|rather|fairly|really|basically|actually|literally|clearly|obviously|simply|absolutely|totally|completely|certainly|probably|mostly|nearly|almost|somewhat|arguably|undeniably|remarkably|incredibly|extremely|highly)\b/gi
// ── Adverb density ────────────────────────────────────────────────────────────
const ADVERB_PATTERN = /\b\w+ly\b/gi
const ADVERB_EXCEPTIONS = new Set(['only','early','daily','likely','lonely','lovely','elderly','friendly','lively','deadly','holy','ugly','silly','hilly','belly','bully','rally','ally','jelly','fully','ully'])
function adverbDensityScore(text: string, wordCount: number): { score: number; issueCount: number } {
const matches = (text.match(ADVERB_PATTERN) ?? []).filter(w => !ADVERB_EXCEPTIONS.has(w.toLowerCase()))
return { score: rateScore(matches.length, wordCount, 20), issueCount: matches.length }
}
// ── Filter words ──────────────────────────────────────────────────────────────
const FILTER_PATTERN = /\b(she saw|he saw|she heard|he heard|she felt|he felt|she noticed|he noticed|she realized|he realized|she thought|he thought|she wondered|he wondered|she knew|he knew|she watched|he watched|she could see|he could see|she could hear|he could hear|she remembered|he remembered|she decided|he decided)\b/gi
function filterWordScore(text: string, wordCount: number): { score: number; issueCount: number } {
const matches = (text.match(FILTER_PATTERN) ?? []).length
return { score: rateScore(matches, wordCount, 8), issueCount: matches }
}
// ── Show / tell proxies ───────────────────────────────────────────────────────
const EMOTION_ADJECTIVES = 'angry|sad|happy|afraid|scared|nervous|anxious|excited|lonely|confused|surprised|disappointed|embarrassed|ashamed|jealous|tired|bored|furious|terrified|delighted|miserable|guilty|proud|content|relieved|frustrated|hopeful|desperate|bitter|disgusted|horrified|depressed|irritated|overwhelmed|resentful|heartbroken|ecstatic|joyful|melancholy|sorrowful|gloomy|cheerful|peaceful|calm|worried|tense|uneasy|weary'
const SHOW_TELL_PATTERN = new RegExp(`\\b(was|were|is|are)\\s+(very\\s+)?(${EMOTION_ADJECTIVES})\\b`, 'gi')
function showTellScore(text: string, wordCount: number): { score: number; issueCount: number } {
const matches = (text.match(SHOW_TELL_PATTERN) ?? []).length
return { score: rateScore(matches, wordCount, 10), issueCount: matches }
}
// ── Sentence rhythm ───────────────────────────────────────────────────────────
function sentenceRhythmScore(text: string): { score: number; issueCount: number } {
const sentences = splitSentences(text)
if (sentences.length < 3) return { score: 100, issueCount: 0 }
@@ -55,10 +86,8 @@ function sentenceRhythmScore(text: string): { score: number; issueCount: number
const lengths = sentences.map(s => s.split(/\s+/).length)
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length
const variance = lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length
const stdDev = Math.sqrt(variance)
const cv = mean > 0 ? stdDev / mean : 0
const cv = mean > 0 ? Math.sqrt(variance) / mean : 0
// Count "monotonous runs": 3+ consecutive sentences within 2 words of each other in length
let runsCount = 0
let runLen = 1
for (let i = 1; i < lengths.length; i++) {
@@ -70,113 +99,176 @@ function sentenceRhythmScore(text: string): { score: number; issueCount: number
}
}
// cv >= 0.6 = great rhythm; 0.3 = mediocre; < 0.3 = monotonous
const score = Math.min(100, Math.round((cv / 0.6) * 100))
return { score, issueCount: runsCount }
}
// Word variety: type-token ratio in sliding windows of 100 words
// ── Paragraph rhythm ──────────────────────────────────────────────────────────
function paragraphRhythmScore(text: string): { score: number; issueCount: number } {
const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(p => p.length > 0)
if (paragraphs.length < 3) return { score: 100, issueCount: 0 }
const lengths = paragraphs.map(p => countWordsIn(p))
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length
const variance = lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length
const cv = mean > 0 ? Math.sqrt(variance) / mean : 0
// Count wall-of-text paragraphs (> 150 words) and tiny runs (≤ 5 words each)
const walls = lengths.filter(l => l > 150).length
const score = Math.min(100, Math.round((cv / 0.8) * 100))
return { score, issueCount: walls }
}
// ── Repeated sentence starters ────────────────────────────────────────────────
function repeatedStarterScore(text: string): { score: number; issueCount: number } {
const sentences = splitSentences(text)
if (sentences.length < 4) return { score: 100, issueCount: 0 }
const starters = sentences.map(s => s.split(/\s+/)[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? '')
let runs = 0
let runLen = 1
for (let i = 1; i < starters.length; i++) {
if (starters[i] === starters[i - 1] && starters[i] !== '') {
runLen++
if (runLen === 3) runs++
} else {
runLen = 1
}
}
// Also penalise pronoun-heavy openings overall
const PRONOUNS = new Set(['he','she','i','they','it','we','you'])
const pronounStarts = starters.filter(w => PRONOUNS.has(w)).length
const pronounRatio = pronounStarts / starters.length
const runPenalty = Math.min(60, runs * 15)
const pronounPenalty = Math.max(0, Math.round((pronounRatio - 0.4) / 0.4 * 40))
const score = Math.max(0, 100 - runPenalty - pronounPenalty)
return { score, issueCount: runs }
}
// ── Word variety ──────────────────────────────────────────────────────────────
const SKIP_WORDS = new Set(['the','a','an','and','or','but','in','on','at','to','for','of','with','is','was','are','were','it','he','she','they','i','you','we','be','been','being','that','this','those','these','have','has','had','do','did','does','not','so','as','if','by','from','into','than','then','when','where','who','which','what','his','her','their','its','my','your','our','up','out','about','after','before','all','some','one','no','more','also'])
function wordVarietyScore(text: string): { score: number; issueCount: number } {
const words = text.toLowerCase().match(/\b[a-z']+\b/g) ?? []
if (words.length < 20) return { score: 100, issueCount: 0 }
const WINDOW = 100
const SKIP_WORDS = new Set(['the','a','an','and','or','but','in','on','at','to','for','of','with','is','was','are','were','it','he','she','they','i','you','we','be','been','being','that','this','those','these','have','has','had','do','did','does','not','so','as','if','by','from','into','than','then','when','where','who','which','what','his','her','their','its','my','your','our','up','out','about','after','before','all','some','one','no','more','also'])
const contentWords = words.filter(w => !SKIP_WORDS.has(w))
if (contentWords.length < 20) return { score: 100, issueCount: 0 }
const WINDOW = 100
const ttrs: number[] = []
for (let i = 0; i + WINDOW <= contentWords.length; i += Math.floor(WINDOW / 2)) {
const window = contentWords.slice(i, i + WINDOW)
const unique = new Set(window).size
ttrs.push(unique / window.length)
const win = contentWords.slice(i, i + WINDOW)
ttrs.push(new Set(win).size / win.length)
}
const avgTTR = ttrs.reduce((a, b) => a + b, 0) / ttrs.length
// TTR >= 0.7 = rich; 0.4 = acceptable; < 0.4 = repetitive
const score = Math.min(100, Math.max(0, Math.round(((avgTTR - 0.4) / 0.3) * 100)))
// Count repeated content words within 50-word windows
let repeatedCount = 0
for (let i = 0; i + 50 <= contentWords.length; i += 25) {
const window = contentWords.slice(i, i + 50)
const win = contentWords.slice(i, i + 50)
const counts = new Map<string, number>()
for (const w of window) counts.set(w, (counts.get(w) ?? 0) + 1)
for (const w of win) counts.set(w, (counts.get(w) ?? 0) + 1)
for (const [, count] of counts) if (count >= 3) repeatedCount++
}
const score = Math.min(100, Math.max(0, Math.round(((avgTTR - 0.4) / 0.3) * 100)))
return { score, issueCount: repeatedCount }
}
// Dialogue tag quality: penalise said-bookisms and adverbs on tags
// ── Clichés ───────────────────────────────────────────────────────────────────
const CLICHES = [
'heart pounding','blood ran cold','butterflies in','stomach churned','knees weak',
'breath caught','time stood still','world fell away','tears streamed','voice cracked',
'eyes widened','jaw dropped','skin crawled','hair stood on end','spine tingled',
'heart sank','heart leapt','heart raced','mind went blank','hands trembled',
'face drained','colour drained','blood froze','heart stopped','chest tightened',
'lump in her throat','lump in his throat','pit of her stomach','pit of his stomach',
'at the end of the day','all hell broke loose','couldn\'t believe her eyes',
'couldn\'t believe his eyes','needle in a haystack','tip of the iceberg',
'dead of night','ray of hope','last but not least','speak of the devil',
'cold as ice','sharp as a knife','black as night','white as snow','red as blood',
'silence was deafening','deafening silence','elephant in the room',
]
const CLICHE_PATTERN = new RegExp(CLICHES.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'gi')
function clicheScore(text: string, wordCount: number): { score: number; issueCount: number } {
const matches = (text.match(CLICHE_PATTERN) ?? []).length
return { score: rateScore(matches, wordCount, 6), issueCount: matches }
}
// ── Overused filler words ─────────────────────────────────────────────────────
const OVERUSED_PATTERN = /\b(just|suddenly|started to|began to|that|somehow|somehow|anyway|whatever|stuff|things|got|get)\b/gi
function overusedWordScore(text: string, wordCount: number): { score: number; issueCount: number } {
const matches = (text.match(OVERUSED_PATTERN) ?? []).length
return { score: rateScore(matches, wordCount, 25), issueCount: matches }
}
// ── Dialogue tags ─────────────────────────────────────────────────────────────
const SAID_BOOKISMS = /\b(hissed|snapped|exclaimed|barked|spat|snarled|growled|shrieked|whined|croaked|breathed|murmured)\b/gi
const ADV_TAG = /\b(said|asked|replied|whispered)\s+\w+ly\b/gi
function dialogueTagScore(text: string, wordCount: number): { score: number; issueCount: number } {
const bookisms = (text.match(SAID_BOOKISMS) ?? []).length
const advTags = (text.match(ADV_TAG) ?? []).length
const total = bookisms + advTags
const total = (text.match(SAID_BOOKISMS) ?? []).length + (text.match(ADV_TAG) ?? []).length
return { score: rateScore(total, wordCount, 8), issueCount: total }
}
// ── Main export ───────────────────────────────────────────────────────────────
export function computePolishScore(rawText: string): PolishScore {
const text = stripMarkdown(rawText)
const wordCount = countWordsIn(text)
const skip = wordCount <= 10
const passiveIssues = wordCount > 10 ? detectPassiveVoice(text).length : 0
const weaselIssues = wordCount > 10 ? (text.match(WEASEL_PATTERN) ?? []).length : 0
const dim = (label: string, score: number, issueCount: number): PolishDimension => ({ label, score, issueCount })
const passive: PolishDimension = {
label: 'Passive voice',
score: rateScore(passiveIssues, wordCount, 12),
issueCount: passiveIssues
}
const passiveIssues = skip ? 0 : detectPassiveVoice(text).length
const weaselIssues = skip ? 0 : (text.match(WEASEL_PATTERN) ?? []).length
const weasel: PolishDimension = {
label: 'Weasel words',
score: rateScore(weaselIssues, wordCount, 10),
issueCount: weaselIssues
}
const adverb = skip ? { score: 100, issueCount: 0 } : adverbDensityScore(text, wordCount)
const filter = skip ? { score: 100, issueCount: 0 } : filterWordScore(text, wordCount)
const showTell = skip ? { score: 100, issueCount: 0 } : showTellScore(text, wordCount)
const sRhythm = skip ? { score: 100, issueCount: 0 } : sentenceRhythmScore(text)
const pRhythm = skip ? { score: 100, issueCount: 0 } : paragraphRhythmScore(text)
const starters = skip ? { score: 100, issueCount: 0 } : repeatedStarterScore(text)
const variety = skip ? { score: 100, issueCount: 0 } : wordVarietyScore(text)
const cliche = skip ? { score: 100, issueCount: 0 } : clicheScore(text, wordCount)
const overused = skip ? { score: 100, issueCount: 0 } : overusedWordScore(text, wordCount)
const dialogue = skip ? { score: 100, issueCount: 0 } : dialogueTagScore(text, wordCount)
const rhythmResult = sentenceRhythmScore(text)
const rhythm: PolishDimension = {
label: 'Sentence rhythm',
score: rhythmResult.score,
issueCount: rhythmResult.issueCount
}
const varietyResult = wordVarietyScore(text)
const variety: PolishDimension = {
label: 'Word variety',
score: varietyResult.score,
issueCount: varietyResult.issueCount
}
const dialogueResult = dialogueTagScore(text, wordCount)
const dialogue: PolishDimension = {
label: 'Dialogue tags',
score: dialogueResult.score,
issueCount: dialogueResult.issueCount
}
const weights = { passive: 0.2, weasel: 0.2, rhythm: 0.25, variety: 0.2, dialogue: 0.15 }
const overall = Math.round(
passive.score * weights.passive +
weasel.score * weights.weasel +
rhythm.score * weights.rhythm +
variety.score * weights.variety +
dialogue.score * weights.dialogue
)
const scores = [
rateScore(passiveIssues, wordCount, 12),
rateScore(weaselIssues, wordCount, 10),
adverb.score,
filter.score,
showTell.score,
sRhythm.score,
pRhythm.score,
starters.score,
variety.score,
cliche.score,
overused.score,
dialogue.score,
]
const overall = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
return {
overall,
dimensions: {
passiveVoice: passive,
weaselWords: weasel,
sentenceRhythm: rhythm,
wordVariety: variety,
dialogueTags: dialogue
passiveVoice: dim('Passive voice', rateScore(passiveIssues, wordCount, 12), passiveIssues),
weaselWords: dim('Weasel words', rateScore(weaselIssues, wordCount, 10), weaselIssues),
adverbDensity: dim('Adverb density', adverb.score, adverb.issueCount),
filterWords: dim('Filter words', filter.score, filter.issueCount),
showTell: dim('Show / tell', showTell.score, showTell.issueCount),
sentenceRhythm: dim('Sentence rhythm', sRhythm.score, sRhythm.issueCount),
paragraphRhythm: dim('Paragraph rhythm', pRhythm.score, pRhythm.issueCount),
repeatedStarters: dim('Sentence starters', starters.score, starters.issueCount),
wordVariety: dim('Word variety', variety.score, variety.issueCount),
cliches: dim('Clichés', cliche.score, cliche.issueCount),
overusedWords: dim('Filler words', overused.score, overused.issueCount),
dialogueTags: dim('Dialogue tags', dialogue.score, dialogue.issueCount),
}
}
}