From 88fa1bdbbc1e5755536e771b95e2dd31365d24f3 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 13 Feb 2026 18:36:09 -0800 Subject: [PATCH] test: add 3 edge-case tests for countInFile overlapping, replaceInFile $& tokens, parseSessionMetadata CRLF Round 123: Tests for countInFile non-overlapping regex match behavior (aaa with /aa/g returns 1 not 2), replaceInFile with $& and $$ substitution tokens in replacement strings, and parseSessionMetadata CRLF section boundary bleed where \n\n fails to match \r\n\r\n. Total: 929 tests, all passing. --- tests/lib/session-manager.test.js | 74 +++++++++++++++++++++++++++++ tests/lib/utils.test.js | 79 +++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/tests/lib/session-manager.test.js b/tests/lib/session-manager.test.js index ea468f16..56581889 100644 --- a/tests/lib/session-manager.test.js +++ b/tests/lib/session-manager.test.js @@ -2368,6 +2368,80 @@ file.ts } })) passed++; else failed++; + // ── Round 123: parseSessionMetadata with CRLF line endings — section boundaries break ── + console.log('\nRound 123: parseSessionMetadata (CRLF section boundaries — \\n\\n fails to match \\r\\n\\r\\n):'); + if (test('parseSessionMetadata CRLF content: \\n\\n boundary fails, lazy match bleeds across sections', () => { + // session-manager.js lines 119-134: regex uses (?=###|\n\n|$) to delimit sections. + // On CRLF content, a blank line is \r\n\r\n, NOT \n\n. The \n\n alternation + // won't match, so the lazy [\s\S]*? extends past the blank line until it hits + // ### or $. This means completed items may bleed into following sections. + // + // However, \s* in /### Completed\s*\n/ DOES match \r\n (since \r is whitespace), + // so section headers still match — only blank-line boundaries fail. + + // Test 1: CRLF with ### delimiter — works because ### is an alternation + const crlfWithHash = [ + '# Session Title\r\n', + '\r\n', + '### Completed\r\n', + '- [x] Task A\r\n', + '### In Progress\r\n', + '- [ ] Task B\r\n' + ].join(''); + const meta1 = sessionManager.parseSessionMetadata(crlfWithHash); + // ### delimiter still works — lazy match stops at ### In Progress + assert.ok(meta1.completed.length >= 1, + 'Completed section should find at least 1 item with ### boundary on CRLF'); + // Check that Task A is found (may include \r in the trimmed text) + const taskA = meta1.completed[0]; + assert.ok(taskA.includes('Task A'), + 'Should extract Task A from completed section'); + + // Test 2: CRLF with \n\n (blank line) delimiter — this is where it breaks + const crlfBlankLine = [ + '# Session\r\n', + '\r\n', + '### Completed\r\n', + '- [x] First task\r\n', + '\r\n', // Blank line = \r\n\r\n — won't match \n\n + 'Some other text\r\n' + ].join(''); + const meta2 = sessionManager.parseSessionMetadata(crlfBlankLine); + // On LF, blank line stops the lazy match. On CRLF, it bleeds through. + // The lazy [\s\S]*? stops at $ if no ### or \n\n matches, + // so "Some other text" may end up captured in the raw section text. + // But the items regex /- \[x\]\s*(.+)/g only captures checkbox lines, + // so the count stays correct despite the bleed. + assert.strictEqual(meta2.completed.length, 1, + 'Even with CRLF bleed, checkbox regex only matches "- [x]" lines'); + + // Test 3: LF version of same content — proves \n\n works normally + const lfBlankLine = '# Session\n\n### Completed\n- [x] First task\n\nSome other text\n'; + const meta3 = sessionManager.parseSessionMetadata(lfBlankLine); + assert.strictEqual(meta3.completed.length, 1, + 'LF version: blank line correctly delimits section'); + + // Test 4: CRLF notes section — lazy match goes to $ when \n\n fails + const crlfNotes = [ + '# Session\r\n', + '\r\n', + '### Notes for Next Session\r\n', + 'Remember to review\r\n', + '\r\n', + 'This should be separate\r\n' + ].join(''); + const meta4 = sessionManager.parseSessionMetadata(crlfNotes); + // On CRLF, \n\n fails → lazy match extends to $ → includes "This should be separate" + // On LF, \n\n works → notes = "Remember to review" only + const lfNotes = '# Session\n\n### Notes for Next Session\nRemember to review\n\nThis should be separate\n'; + const meta5 = sessionManager.parseSessionMetadata(lfNotes); + assert.strictEqual(meta5.notes, 'Remember to review', + 'LF: notes stop at blank line'); + // CRLF notes will be longer (bleed through blank line) + assert.ok(meta4.notes.length >= meta5.notes.length, + 'CRLF notes >= LF notes length (CRLF may bleed past blank line)'); + })) passed++; else failed++; + // Summary console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index 91ec8069..f22667e8 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -2110,6 +2110,85 @@ function runTests() { } })) passed++; else failed++; + // ── Round 123: countInFile with overlapping patterns — match(g) is non-overlapping ── + console.log('\nRound 123: countInFile (overlapping patterns — String.match(/g/) is non-overlapping):'); + if (test('countInFile counts non-overlapping matches only — "aaa" with /aa/g returns 1 not 2', () => { + // utils.js line 449: `content.match(regex)` with 'g' flag returns an array of + // non-overlapping matches. After matching "aa" starting at index 0, the engine + // advances to index 2, where only one "a" remains — no second match. + // This is standard JS regex behavior but can surprise users expecting overlap. + const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r123-overlap-')); + const testFile = path.join(tmpDir, 'test.txt'); + try { + // "aaa" — a human might count 2 occurrences of "aa" (at 0,1) but match(g) finds 1 + fs.writeFileSync(testFile, 'aaa'); + const count1 = utils.countInFile(testFile, 'aa'); + assert.strictEqual(count1, 1, + '"aaa".match(/aa/g) returns ["aa"] — only 1 non-overlapping match'); + + // "aaaa" — 2 non-overlapping matches (at 0,2), not 3 overlapping (at 0,1,2) + fs.writeFileSync(testFile, 'aaaa'); + const count2 = utils.countInFile(testFile, 'aa'); + assert.strictEqual(count2, 2, + '"aaaa".match(/aa/g) returns ["aa","aa"] — 2 non-overlapping, not 3 overlapping'); + + // "abab" with /aba/g — only 1 match (at 0), not 2 (overlapping at 0,2) + fs.writeFileSync(testFile, 'ababab'); + const count3 = utils.countInFile(testFile, 'aba'); + assert.strictEqual(count3, 1, + '"ababab".match(/aba/g) returns 1 — after match at 0, next try starts at 3'); + + // RegExp object behaves the same + fs.writeFileSync(testFile, 'aaa'); + const count4 = utils.countInFile(testFile, /aa/); + assert.strictEqual(count4, 1, + 'RegExp /aa/ also gives 1 non-overlapping match on "aaa" (g flag auto-added)'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + // ── Round 123: replaceInFile with $& and $$ substitution tokens in replacement string ── + console.log('\nRound 123: replaceInFile ($& and $$ substitution tokens in replacement):'); + if (test('replaceInFile replacement string interprets $& as matched text and $$ as literal $', () => { + // JS String.replace() interprets special patterns in the replacement string: + // $& → inserts the entire matched substring + // $$ → inserts a literal "$" character + // $' → inserts the portion after the matched substring + // $` → inserts the portion before the matched substring + // This is different from capture groups ($1, $2) already tested in Round 91. + const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r123-dollar-')); + const testFile = path.join(tmpDir, 'test.txt'); + try { + // $& — inserts the matched text itself + fs.writeFileSync(testFile, 'hello world'); + utils.replaceInFile(testFile, 'world', '[$&]'); + assert.strictEqual(utils.readFile(testFile), 'hello [world]', + '$& in replacement inserts the matched text "world" → "[world]"'); + + // $$ — inserts a literal $ sign + fs.writeFileSync(testFile, 'price is 100'); + utils.replaceInFile(testFile, '100', '$$100'); + assert.strictEqual(utils.readFile(testFile), 'price is $100', + '$$ becomes literal $ → "100" replaced with "$100"'); + + // $& with options.all — applies to each match + fs.writeFileSync(testFile, 'foo bar foo'); + utils.replaceInFile(testFile, 'foo', '($&)', { all: true }); + assert.strictEqual(utils.readFile(testFile), '(foo) bar (foo)', + '$& in replaceAll inserts each respective matched text'); + + // Combined $$ and $& in same replacement (3 $ + &) + fs.writeFileSync(testFile, 'item costs 50'); + utils.replaceInFile(testFile, '50', '$$$&'); + // In replacement string: $$ → "$" then $& → "50" so result is "$50" + assert.strictEqual(utils.readFile(testFile), 'item costs $50', + '$$$& (3 dollars + ampersand) means literal $ followed by matched text → "$50"'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + // Summary console.log('\n=== Test Results ==='); console.log(`Passed: ${passed}`);