mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 23:26:39 +03:00
detector: add 5 new antipatterns (em-dash-overuse, broken-image, marketing-buzzword, numbered-section-markers, aphoristic-cadence)
Consolidates eval-side detection logic into the canonical impeccable
detector. Before this change, the eval harness had its own duplicate
implementations of em-dash, copy-slop, and broken-image checks. They
now live alongside the existing 28 antipatterns in the impeccable
registry, available to the CLI, browser extension, critique skill,
and eval (via the existing slop grader child-process call).
New antipatterns:
- em-dash-overuse: 5+ em-dashes in body text content (threshold
permits legitimate prose use of em-dash; only triggers on AI
cadence-level density)
- broken-image: <img> with empty src, missing src, or src="#"
- marketing-buzzword: SaaS phrase list (streamline / empower /
supercharge / enterprise-grade / cutting-edge / etc)
- numbered-section-markers: repeated 01 / 02 / 03 sequence as
section labels — the AI editorial scaffold one tier deeper than
tracked eyebrow chips
- aphoristic-cadence: 3+ manufactured-contrast ("Not a X. A Y.")
or short-rebuttal ("Sentence. No clause." / "Sentence. Just
clause.") constructions in body text
Engine wiring:
- broken-image runs as a static-html element rule (selector: img)
and a fallback regex matcher (for non-HTML files)
- em-dash / buzzword / numbered / aphoristic run as regex
page-analyzers, factored into a new runTextContentAnalyzers()
helper that both detectText (non-HTML) and detectHtml (HTML)
call, so .html files get the same coverage as .css/.tsx
Tests: 166 detector + 12 browser + 24 fixture all pass.
Browser detector rebuilt (162.7 KB).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
53dd54c5a2
commit
1ae6846bc6
@@ -247,6 +247,52 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
|
||||
},
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
description:
|
||||
'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'numbered section markers',
|
||||
},
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
{
|
||||
id: 'marketing-buzzword',
|
||||
category: 'slop',
|
||||
name: 'Marketing buzzword',
|
||||
description:
|
||||
'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'marketing buzzwords',
|
||||
},
|
||||
{
|
||||
id: 'aphoristic-cadence',
|
||||
category: 'slop',
|
||||
name: 'Aphoristic-cadence copy',
|
||||
description:
|
||||
'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'aphoristic cadence',
|
||||
},
|
||||
{
|
||||
id: 'broken-image',
|
||||
category: 'quality',
|
||||
name: 'Broken or placeholder image',
|
||||
description:
|
||||
'<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.',
|
||||
skillSection: 'Imagery',
|
||||
skillGuideline: 'broken image references',
|
||||
},
|
||||
|
||||
// ── Quality: general design and accessibility issues ──
|
||||
{
|
||||
|
||||
@@ -11,6 +11,17 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
|
||||
const hasBorderRadius = (line) => /border-radius/i.test(line);
|
||||
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
|
||||
|
||||
/** Strip HTML to plain text — drops script/style/comments/tags so
|
||||
* content-text analyzers don't false-positive on code or CSS. */
|
||||
function stripHtmlToText(html) {
|
||||
return html
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
if (!m) return false;
|
||||
@@ -116,6 +127,14 @@ const REGEX_MATCHERS = [
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
} },
|
||||
// --- Broken image: src="" or src="#" or src=" " ---
|
||||
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => m[0].slice(0, 100) },
|
||||
// --- Broken image: <img> with no src attribute at all ---
|
||||
{ id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,
|
||||
test: (m) => !/\bsrc\s*=/i.test(m[0]),
|
||||
fmt: (m) => m[0].slice(0, 100) },
|
||||
];
|
||||
|
||||
const REGEX_ANALYZERS = [
|
||||
@@ -202,6 +221,87 @@ const REGEX_ANALYZERS = [
|
||||
if (total < 5 || centered / total <= 0.7) return [];
|
||||
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
|
||||
},
|
||||
// Em-dash overuse: 5+ em-dashes or "--" in body text content
|
||||
// (occasional em-dash use in prose is fine; the pattern fires only
|
||||
// when count crosses into AI-cadence territory).
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(text) !== null) count++;
|
||||
if (count < 5) return [];
|
||||
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
||||
},
|
||||
// Marketing buzzwords: SaaS phrase list
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const lower = text.toLowerCase();
|
||||
const BUZZWORDS = [
|
||||
'streamline your', 'empower your', 'supercharge your',
|
||||
'unleash your', 'unleash the power', 'leverage the power',
|
||||
'built for the modern', 'trusted by leading', 'trusted by the world',
|
||||
'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade',
|
||||
'next-generation', 'cutting-edge', 'transform your business',
|
||||
'revolutionize', 'game-changer', 'game changing',
|
||||
'mission-critical', 'best of breed', 'future-proof', 'future proof',
|
||||
'seamless experience', 'seamlessly integrate',
|
||||
'drive engagement', 'drive growth', 'drive results',
|
||||
'harness the power',
|
||||
];
|
||||
let count = 0;
|
||||
let firstSample = '';
|
||||
for (const phrase of BUZZWORDS) {
|
||||
let from = 0;
|
||||
while (true) {
|
||||
const idx = lower.indexOf(phrase, from);
|
||||
if (idx === -1) break;
|
||||
count++;
|
||||
if (!firstSample) {
|
||||
firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim();
|
||||
}
|
||||
from = idx + phrase.length;
|
||||
}
|
||||
}
|
||||
if (count === 0) return [];
|
||||
return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];
|
||||
},
|
||||
// Numbered section markers (01 / 02 / 03 ...)
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const re = /\b(0[1-9]|1[0-2])\b/g;
|
||||
const seen = new Set();
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) seen.add(m[1]);
|
||||
if (seen.size < 3) return [];
|
||||
const sorted = [...seen].sort();
|
||||
let sequential = 0;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;
|
||||
}
|
||||
if (sequential < 2) return [];
|
||||
return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];
|
||||
},
|
||||
// Aphoristic cadence: manufactured-contrast + short-rebuttal
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g;
|
||||
const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g;
|
||||
let count = 0;
|
||||
let firstSample = '';
|
||||
let m;
|
||||
NOT_A_RE.lastIndex = 0;
|
||||
while ((m = NOT_A_RE.exec(text)) !== null) {
|
||||
count++;
|
||||
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
|
||||
}
|
||||
SHORT_REBUTTAL_RE.lastIndex = 0;
|
||||
while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) {
|
||||
count++;
|
||||
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
|
||||
}
|
||||
if (count < 3) return [];
|
||||
return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
|
||||
},
|
||||
// Dark glow (page-level: dark bg + colored box-shadow with blur)
|
||||
(content, filePath) => {
|
||||
// Check if page has a dark background
|
||||
@@ -320,6 +420,36 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null,
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Page-level analyzers that scan rendered text content (em-dash use,
|
||||
* buzzword phrases, numbered section markers, aphoristic cadence).
|
||||
* These are detector-agnostic — they work on any HTML/text source
|
||||
* and don't need a parsed DOM. Exported so detectHtml can call them
|
||||
* for `.html` files (which otherwise skip the regex engine). */
|
||||
const TEXT_CONTENT_ANALYZER_IDS = [
|
||||
'em-dash-overuse',
|
||||
'marketing-buzzword',
|
||||
'numbered-section-markers',
|
||||
'aphoristic-cadence',
|
||||
];
|
||||
|
||||
function runTextContentAnalyzers(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
if (!isFullPage(content)) return [];
|
||||
// The 4 text-content analyzers are at indices 4-7 in REGEX_ANALYZERS.
|
||||
const findings = [];
|
||||
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
|
||||
const analyzer = REGEX_ANALYZERS[4 + i];
|
||||
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
phase: 'text-content',
|
||||
ruleId,
|
||||
target: filePath,
|
||||
}, () => analyzer(content, filePath)));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function detectText(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
const findings = [];
|
||||
@@ -386,6 +516,10 @@ function detectText(content, filePath, options = {}) {
|
||||
'flat-type-hierarchy',
|
||||
'monotonous-spacing',
|
||||
'everything-centered',
|
||||
'em-dash-overuse',
|
||||
'marketing-buzzword',
|
||||
'numbered-section-markers',
|
||||
'aphoristic-cadence',
|
||||
'dark-glow',
|
||||
];
|
||||
for (let i = 0; i < REGEX_ANALYZERS.length; i++) {
|
||||
@@ -405,8 +539,10 @@ function detectText(content, filePath, options = {}) {
|
||||
export {
|
||||
REGEX_MATCHERS,
|
||||
REGEX_ANALYZERS,
|
||||
TEXT_CONTENT_ANALYZER_IDS,
|
||||
extractStyleBlocks,
|
||||
extractCSSinJS,
|
||||
runRegexMatchers,
|
||||
runTextContentAnalyzers,
|
||||
detectText,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
resolveBackground,
|
||||
resolveBorderRadiusPx,
|
||||
} from '../../rules/checks.mjs';
|
||||
import { detectText } from '../regex/detect-text.mjs';
|
||||
import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs';
|
||||
import {
|
||||
StaticDocument,
|
||||
buildStaticStyleMap,
|
||||
@@ -64,6 +64,20 @@ function checkStaticPageTypography(document, window) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkElementBrokenImage(el) {
|
||||
const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src;
|
||||
// Missing src attribute entirely
|
||||
if (src === undefined || src === null) {
|
||||
return [{ id: 'broken-image', snippet: '<img> with no src attribute' }];
|
||||
}
|
||||
const trimmed = String(src).trim();
|
||||
// Empty or placeholder-only src values
|
||||
if (trimmed === '' || trimmed === '#') {
|
||||
return [{ id: 'broken-image', snippet: `<img src="${src}">` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const STATIC_ELEMENT_RULES = [
|
||||
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
|
||||
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
|
||||
@@ -72,6 +86,7 @@ const STATIC_ELEMENT_RULES = [
|
||||
{ id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) },
|
||||
{ id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) },
|
||||
{ id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) },
|
||||
{ id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) },
|
||||
{ id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
|
||||
];
|
||||
|
||||
@@ -166,6 +181,14 @@ async function detectHtml(filePath, options = {}) {
|
||||
))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
// Text-content analyzers (em-dash overuse, marketing buzzwords,
|
||||
// numbered section markers, aphoristic cadence) live in the regex
|
||||
// engine. Call them from here so .html files get the same coverage
|
||||
// as .css/.tsx files. These are scoped to text content only and
|
||||
// don't overlap with static-html's element/page rules.
|
||||
for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) {
|
||||
findings.push(finding(f.antipattern, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
|
||||
@@ -145,6 +145,52 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
|
||||
},
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
description:
|
||||
'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'numbered section markers',
|
||||
},
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
{
|
||||
id: 'marketing-buzzword',
|
||||
category: 'slop',
|
||||
name: 'Marketing buzzword',
|
||||
description:
|
||||
'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'marketing buzzwords',
|
||||
},
|
||||
{
|
||||
id: 'aphoristic-cadence',
|
||||
category: 'slop',
|
||||
name: 'Aphoristic-cadence copy',
|
||||
description:
|
||||
'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'aphoristic cadence',
|
||||
},
|
||||
{
|
||||
id: 'broken-image',
|
||||
category: 'quality',
|
||||
name: 'Broken or placeholder image',
|
||||
description:
|
||||
'<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.',
|
||||
skillSection: 'Imagery',
|
||||
skillGuideline: 'broken image references',
|
||||
},
|
||||
|
||||
// ── Quality: general design and accessibility issues ──
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user