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.
+
+
+
+