From 7dcca2bb3632fcbaf8cb03d50efba298c52314c1 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Jul 2026 00:27:59 -0700 Subject: [PATCH] Count em-dash HTML entities in em-dash-overuse The em-dash-overuse text analyzer ran stripHtmlToText over raw markup, which drops tags but leaves character entities intact. A model that wrote —, —, or — rendered a real em-dash the counter never saw, so 12 entity-escaped dashes on a live page slipped through. Decode the em-dash entities (named, zero-padded decimal, upper/lower hex) to the literal glyph before counting. En-dash entities stay untouched: the rule counts em-dashes, and the literal en-dash was never counted either. The gap lived only in the regex / static-HTML path (detectText and detect-html's runTextContentAnalyzers, both over raw HTML). The browser adapter never ran this analyzer, so build:browser and build:extension produce no diff. Co-Authored-By: Claude Fable 5 --- cli/engine/engines/regex/detect-text.mjs | 10 ++- tests/detect-antipatterns-fixtures.test.mjs | 77 +++++++++++++++++++ .../antipatterns/em-dash-entities.html | 17 ++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/antipatterns/em-dash-entities.html diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 669dedcb9..686aae95a 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -309,8 +309,16 @@ const REGEX_ANALYZERS = [ // 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). + // + // stripHtmlToText drops tags but leaves character-entity escapes intact, so + // a model that writes `—`, `—`, or `—` renders an em-dash + // the counter never saw. Decode the em-dash entities (named, zero-padded + // decimal, upper/lower hex) to the literal glyph first. En-dash entities are + // deliberately left alone: the rule counts em-dashes, and the literal `–` + // was never counted either. (content, filePath) => { - const text = stripHtmlToText(content); + const text = stripHtmlToText(content) + .replace(/—|�*8212;|�*2014;/gi, '—'); let count = 0; const re = /[—]|--(?=\S)/g; while (re.exec(text) !== null) count++; diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index fca2e27ab..8ddfebf02 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -917,3 +917,80 @@ describe('detectHtml — generated-UI tells', () => { ); }); }); + +describe('em-dash overuse — HTML entity escapes', () => { + // Build a full page so the page-level text-content analyzer runs. `body` is the + // prose that carries the dashes; the doctype/html scaffold is required by + // isFullPage(). Each dash spelling is a separate case because the rule counts + // per page, not per element. + const page = (body) => + `t` + + `

A real page heading of ordinary length

${body}

`; + + // Six dashes clears the 5+ threshold. Sentence fragments keep the surrounding + // prose realistic so nothing else in the pipeline objects. + const sixNamed = 'fast — cheap — honest — simple — quiet — kind — done'; + const sixNumeric = 'fast — cheap — honest — simple — quiet — kind — done'; + const sixHex = 'fast — cheap — honest — simple — quiet — kind — done'; + const sixHexUpper = 'fast — cheap — honest — simple — quiet — kind — done'; + const sixNumericPadded = 'fast — cheap — honest — simple — quiet — kind — done'; + // Three literal glyphs + three named entities render identically; the count + // must see all six. + const mixed = 'fast — cheap — honest — simple — quiet — kind — done'; + + const SHOULD_FLAG = { + 'named —': sixNamed, + 'numeric —': sixNumeric, + 'hex —': sixHex, + 'uppercase-hex —': sixHexUpper, + 'zero-padded decimal —': sixNumericPadded, + 'mixed literal + entity': mixed, + }; + + // False-positive shapes: none of these should trip the em-dash counter. + const SHOULD_PASS = { + // Below the 5+ threshold: occasional em-dash entity use is legitimate prose. + 'two entities below threshold': 'fast — cheap — done, otherwise plain sentences fill the paragraph body', + // En-dashes are a different character and a different job (ranges); the em-dash + // rule must not decode or count them. + 'en-dash entities': 'pages 10–20 and 30–40 and 50–60 and 70–80 and 90–100 and 1–2', + 'numeric en-dash entities': 'pages 10–20 and 30–40 and 50–60 and 70–80 and 90–100 and 1–2', + // Double-escaped: the visible text is the literal string "—", not a dash. + 'double-escaped ampersand': 'write — and — and — and — and — and — literally', + // Unrelated entities must never be miscounted as dashes. + 'non-dash entities': 'a b © c … d & e ™ f ® g ° h § i ¶', + // Ordinary hyphenated compounds are single hyphens, not the double-hyphen tell. + 'hyphenated compounds': 'state-of-the-art, well-being, high-quality, self-service, end-to-end, at-a-glance copy', + }; + + const emDashCount = (findings) => + findings.filter((r) => r.antipattern === 'em-dash-overuse').length; + + for (const [label, body] of Object.entries(SHOULD_FLAG)) { + it(`flags em-dash overuse spelled as ${label}`, () => { + const findings = detectText(page(body), 'em-dash.html'); + assert.equal( + emDashCount(findings), 1, + `expected em-dash-overuse for "${label}", got: ${findings.map((r) => r.antipattern).join(', ') || 'none'}`, + ); + }); + } + + for (const [label, body] of Object.entries(SHOULD_PASS)) { + it(`does not flag ${label}`, () => { + const findings = detectText(page(body), 'em-dash.html'); + assert.equal( + emDashCount(findings), 0, + `"${label}" should not flag em-dash overuse`, + ); + }); + } + + it('static-HTML path decodes entity em-dashes too (fixture file)', async () => { + const findings = await detectHtml(path.join(FIXTURES, 'em-dash-entities.html')); + assert.equal( + findings.filter((r) => r.antipattern === 'em-dash-overuse').length, 1, + 'em-dash-entities.html should flag em-dash overuse via the static-HTML path', + ); + }); +}); diff --git a/tests/fixtures/antipatterns/em-dash-entities.html b/tests/fixtures/antipatterns/em-dash-entities.html new file mode 100644 index 000000000..655efcdaf --- /dev/null +++ b/tests/fixtures/antipatterns/em-dash-entities.html @@ -0,0 +1,17 @@ + + + + + Em-dash entity overuse + + +
+

A page whose em-dashes hide inside HTML entities

+

+ The product is fast — it is also cheap — and it is honest + — which matters — more than speed — or price + — in the long run. +

+
+ +