From 22ffa13db0959f57460f11abdbbac3a04d955e4a Mon Sep 17 00:00:00 2001 From: scanner Date: Wed, 7 Oct 2026 22:55:32 -0400 Subject: [PATCH] fix: normalize curly apostrophes and strip ANSI before pattern matching Signed-off-by: scanner --- CHANGELOG.md | 2 ++ src/checker.test.ts | 5 +++++ src/checker.ts | 2 +- src/watcher.test.ts | 9 +++++++++ src/watcher.ts | 7 +++---- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc20fda..34a8351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project are documented in this file. ### Fixed +- `check`/`watch`: fold curly apostrophes (U+2018/U+2019/U+02BC) and curly double quotes into ASCII before matching; the previous normalization was a no-op (#185) +- `watch`: run pattern matching on ANSI-stripped text so inline SGR codes no longer split phrases and hide detections (#186) - `check`: read piped stdin to EOF instead of rejecting input that arrives more than 100 ms after start (#183) - `watch`/`attach`: reject unknown `--mode` and `--rebuttal` values with exit 1 instead of silently running watch mode or never sending rebuttals (#176) - `sightings --json` now prints `[]` instead of ANSI-colored prose when no sightings are recorded (#175) diff --git a/src/checker.test.ts b/src/checker.test.ts index d15daea..1462969 100644 --- a/src/checker.test.ts +++ b/src/checker.test.ts @@ -100,6 +100,11 @@ describe('check', () => { // The CLI's colour tiers (cli.ts colorConfidence) assume the matcher's // confidence floor. Pin that floor so a change to KEYWORD_WEIGHT or // MIN_KEYWORD_CONFIDENCE has to revisit the tiers deliberately. + it('folds curly apostrophes into ASCII before matching', () => { + const result = check('that is another agent/team\u2019s job'); + assert.strictEqual(result.matches[0]?.confidence, 1.0); + }); + describe('confidence floor', () => { // 1 * KEYWORD_WEIGHT + MIN_KEYWORD_CONFIDENCE; 0.15 + 0.3 is // 0.44999999999999996 in IEEE doubles, so compare with a tolerance. diff --git a/src/checker.ts b/src/checker.ts index dfb6c65..fa26082 100644 --- a/src/checker.ts +++ b/src/checker.ts @@ -9,7 +9,7 @@ const FILLER_WORDS = /\b(is|are|was|were|been|being|has|have|had|do|does|did|wil const VERB_SUFFIXES = /\b(\w+?)(ing|ed|s)\b/g; function normalizeText(text: string): string { - return text.toLowerCase().replace(/['']/g, "'").replace(/\s+/g, ' ').trim(); + return text.toLowerCase().replace(/[\u2018\u2019\u02BC]/g, "'").replace(/[\u201C\u201D]/g, '"').replace(/\s+/g, ' ').trim(); } function reduceText(text: string): string { diff --git a/src/watcher.test.ts b/src/watcher.test.ts index 8e3a691..ad06016 100644 --- a/src/watcher.test.ts +++ b/src/watcher.test.ts @@ -206,6 +206,15 @@ describe('event buffering and flushing', () => { watcher.stop(); }); + it('detects phrases split by inline ANSI SGR codes', () => { + const { watcher, detections } = makeWatcher(); + const w = internals(watcher); + w.handleEvent(rawOutput('test-session', 'all \x1b[1mdone\x1b[0m / steady state')); + w.handleEvent(stateChange('test-session', 'working', 'idle')); + assert.strictEqual(detections.length, 1); + watcher.stop(); + }); + it('flushBuffer is a no-op when the buffer is empty', () => { const { watcher, detections } = makeWatcher(); internals(watcher).flushBuffer(); diff --git a/src/watcher.ts b/src/watcher.ts index e9af4e1..ccd1c8a 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -269,15 +269,14 @@ export class Watcher extends EventEmitter { this.flushTimer = null; } - // Only used to build the human-readable log preview below; pattern - // matching runs against the raw, unstripped `text`. Uses the shared - // escape-stripping helper from sanitize.ts so this cleanup can't drift + // Strip escapes before matching so inline SGR codes cannot split phrases. + // Uses the shared helper from sanitize.ts so this cleanup can't drift // out of sync with the operator-facing print-safety boundary. const stripped = stripTerminalEscapes(text, '').trim(); const preview = stripped.slice(0, 200).replace(/\n/g, ' '); this.log(`flush #${this.flushCount}: checking ${lineCount} lines (${text.length} chars)`); this.log(`flush #${this.flushCount}: text: "${preview}${stripped.length > 200 ? '...' : ''}"`); - const result = check(text, { excuses: this.excuses }); + const result = check(stripped, { excuses: this.excuses }); if (result.clean) { this.log(`flush #${this.flushCount}: clean`);