From cd8228c1b589ae8bf248a197f856352bac8ac3d9 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 13 Jul 2026 05:03:55 +0300 Subject: [PATCH 1/2] refactor: route all markdown parsing through mdq() (plan 022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce the "no regex/includes/line-splitting on markdown" rule across 8 violation sites. Adds one additive accessor — MarkdownQuery.meta() returning {type, depth, text} — which removes the need for hand-rolled marked.lexer walks. The `marked` import is now gone from experience-tracker and experience-compactor. - researcher Summary regex + parser `.includes('Extended Research')` -> mdq queries - planner cleanExperienceFlows + table swap -> positional replaces with re-query-after-each-mutation (no more String.replace on section raw text / /^---/gm regex) - experience-tracker: whole-section truncation (never cuts mid-fence), writeAction/writeFlow dedup via mdq, listTocHeadings/extractHeadingSection/ renderAsHowTo rebuilt on meta() - experience-compactor: listSections on mdq; clickXY regex -> isNonReusableCode - knowledge-tracker firstLine via mdq().meta() Deviations (both improvements, verified): renderAsHowTo matches with startsWith via meta() (the plan's contains-matcher would infinite-loop on titles containing the marker); planner's "and N more discoveries" note-append was previously dead code (no-op after the tail blockquotes were removed) and now correctly appends. Step 5 verified byte-identical to the old lexer walk; Step 7's clickXY->isNonReusableCode broadening pinned by no test. Co-Authored-By: Claude Opus 4.8 --- src/ai/experience-compactor.ts | 29 ++------ src/ai/planner.ts | 57 ++++++++++------ src/ai/researcher.ts | 8 +-- src/ai/researcher/parser.ts | 2 +- src/experience-tracker.ts | 96 +++++++++++---------------- src/knowledge-tracker.ts | 3 +- src/utils/markdown-query.ts | 9 +++ tests/unit/experience-tracker.test.ts | 26 ++++++++ tests/unit/markdown-query.test.ts | 45 +++++++++++++ 9 files changed, 167 insertions(+), 108 deletions(-) diff --git a/src/ai/experience-compactor.ts b/src/ai/experience-compactor.ts index 4f46f6a..38c3437 100644 --- a/src/ai/experience-compactor.ts +++ b/src/ai/experience-compactor.ts @@ -1,11 +1,11 @@ import { unlinkSync } from 'node:fs'; import dedent from 'dedent'; -import { type Tokens, marked } from 'marked'; import { z } from 'zod'; import { type ExperienceFile, type ExperienceTracker, RECENT_WINDOW_DAYS } from '../experience-tracker.js'; import { Observability } from '../observability.js'; import { createDebug, log, tag } from '../utils/logger.js'; import { mdq } from '../utils/markdown-query.js'; +import { isNonReusableCode } from '../utils/step-analyzer.js'; import { generalizeUrl, hasDynamicUrlSegment } from '../utils/url-matcher.js'; import type { Agent } from './agent.js'; import type { Provider } from './provider.js'; @@ -438,28 +438,9 @@ export class ExperienceCompactor implements Agent { } function listSections(content: string): { title: string; raw: string }[] { - const tokens = marked.lexer(content); - const sections: { title: string; raw: string }[] = []; - - let currentHeading: string | null = null; - let currentRaw = ''; - - const flush = () => { - if (currentHeading !== null) sections.push({ title: currentHeading, raw: currentRaw }); - }; - - for (const token of tokens) { - const raw = (token as any).raw || ''; - if (token.type === 'heading' && (token as Tokens.Heading).depth === 2) { - flush(); - currentHeading = (token as Tokens.Heading).text.trim(); - currentRaw = raw; - continue; - } - if (currentHeading !== null) currentRaw += raw; - } - flush(); - return sections; + const sections = mdq(content).query('section2').each(); + const metas = mdq(content).query('section2').meta(); + return sections.map((q, i) => ({ title: metas[i].text.trim(), raw: q.text() })); } function dropEmptySections(content: string): string { @@ -483,7 +464,7 @@ function dropNonReusableSections(content: string): string { for (const section of sections) { const raw = section.text(); - if (!/\bI\.clickXY\s*\(/.test(raw)) continue; + if (!isNonReusableCode(raw)) continue; result = result.replace(raw, ''); } diff --git a/src/ai/planner.ts b/src/ai/planner.ts index 508e524..9cb91cc 100644 --- a/src/ai/planner.ts +++ b/src/ai/planner.ts @@ -280,29 +280,43 @@ export class Planner extends PlannerBase implements Agent { } private cleanExperienceFlows(text: string): string | null { - const seenTitles = new Set(); let result = text; - for (const section of [...mdq(text).query('section2').each(), ...mdq(text).query('section3').each()]) { - const heading = section.query('heading').text().trim(); - const body = mdq(section.text()) - .query('heading') - .replace('') - .replace(/^---\s*$/gm, '') - .trim(); - - if (!body || seenTitles.has(heading)) { - result = result.replace(section.text(), ''); - continue; + while (true) { + const sections = [...mdq(result).query('section2').each(), ...mdq(result).query('section3').each()]; + const seenTitles = new Set(); + let removed = false; + for (const section of sections) { + const heading = section.query('heading').text().trim(); + const withoutHeadings = mdq(section.text()).query('heading').replace(''); + const body = mdq(withoutHeadings).query('hr').replace('').trim(); + if (body && !seenTitles.has(heading)) { + seenTitles.add(heading); + continue; + } + result = section.replace(''); + removed = true; + break; } - seenTitles.add(heading); + if (!removed) break; + } - const blockquotes = section.query('blockquote').each(); - if (blockquotes.length <= 10) continue; - for (const bq of blockquotes.slice(10)) { - result = result.replace(bq.text(), ''); + const trimmedTitles = new Set(); + while (true) { + const sections = [...mdq(result).query('section2').each(), ...mdq(result).query('section3').each()]; + let trimmed = false; + for (const section of sections) { + const heading = section.query('heading').text().trim(); + if (trimmedTitles.has(heading)) continue; + const count = section.query('blockquote').count(); + if (count <= 10) continue; + const kept = mdq(section.text()).query('blockquote[10:]').replace(''); + result = section.replace(`${kept.trimEnd()}\n> ... and ${count - 10} more discoveries\n`); + trimmedTitles.add(heading); + trimmed = true; + break; } - result = result.replace(section.text().trim(), `${section.text().trim()}\n> ... and ${blockquotes.length - 10} more discoveries`); + if (!trimmed) break; } return result.trim() || null; @@ -381,15 +395,16 @@ export class Planner extends PlannerBase implements Agent { deep: true, }); let plannerResearch = mdq(research).query('code').replace(''); - for (const table of mdq(plannerResearch).query('table').each()) { - const rawTable = table.text(); + const tableCount = mdq(plannerResearch).query('table').count(); + for (let i = 0; i < tableCount; i++) { + const table = mdq(plannerResearch).query('table').each()[i]; const rows = table.toJson(); if (rows.length === 0 || !rows[0].Element) continue; const elementWithType = rows.map((r) => ({ Element: r.Element, Type: r.Type || '', })); - plannerResearch = plannerResearch.replace(rawTable, jsonToTable(elementWithType, ['Element', 'Type'])); + plannerResearch = table.replace(jsonToTable(elementWithType, ['Element', 'Type'])); } const hasFocusedOverlay = hasFocusedSection(plannerResearch); diff --git a/src/ai/researcher.ts b/src/ai/researcher.ts index 9eaa3b7..9cc331b 100644 --- a/src/ai/researcher.ts +++ b/src/ai/researcher.ts @@ -315,11 +315,9 @@ export class Researcher extends ResearcherBase implements Agent { researchFile = saveResearch(stateHash, result.text, combinedHtml); } - const summaryMatch = result.text.match(/## Summary\s*\n+([\s\S]*?)(?=\n##|$)/i); - if (summaryMatch) { - const summaryLine = summaryMatch[1].trim().split('\n')[0].trim().slice(0, 200); - if (summaryLine) this.experienceTracker.updateSummary(this.actionResult!, summaryLine); - } + const summaryText = mdq(result.text).query('section2(/^summary/)').query('paragraph[0]').text().trim(); + const summaryLine = summaryText.split('\n')[0]?.trim().slice(0, 200); + if (summaryLine) this.experienceTracker.updateSummary(this.actionResult!, summaryLine); tag('multiline').log(formatResearchSummary(result.text, { visionUsed: this.hasScreenshotToAnalyze })); tag('success').log('Research complete'); diff --git a/src/ai/researcher/parser.ts b/src/ai/researcher/parser.ts index f6cab4d..7dc0f24 100644 --- a/src/ai/researcher/parser.ts +++ b/src/ai/researcher/parser.ts @@ -94,7 +94,7 @@ export function extractContainerFromBlockquote(sectionMarkdown: string): string } export function parseResearchSections(markdown: string): ResearchSection[] { - const hasExtendedResearch = markdown.includes('\n# Extended Research') || markdown.startsWith('# Extended Research'); + const hasExtendedResearch = mdq(markdown).query('section1(~"Extended Research")').count() > 0; return parseSections(markdown) .filter((s) => !SKIP_SECTIONS.has(s.name.toLowerCase()) && !s.name.toLowerCase().includes('data:')) diff --git a/src/experience-tracker.ts b/src/experience-tracker.ts index 6ccd171..f41ac7f 100644 --- a/src/experience-tracker.ts +++ b/src/experience-tracker.ts @@ -1,7 +1,6 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import { basename, dirname, join } from 'node:path'; import matter from 'gray-matter'; -import { type Tokens, marked } from 'marked'; import type { ActionResult } from './action-result.js'; import { ConfigParser } from './config.js'; import { KnowledgeTracker } from './knowledge-tracker.js'; @@ -178,7 +177,12 @@ export class ExperienceTracker { this.ensureExperienceFile(state); const stateHash = state.getStateHash(); const { content, data } = this.readExperienceFile(stateHash); - if (content.includes(action.code)) { + if ( + mdq(content) + .query('code') + .meta() + .some((m) => m.text.trim() === action.code.trim()) + ) { debugLog('Skipping duplicate action', action.code); return; } @@ -206,7 +210,12 @@ export class ExperienceTracker { const stateHash = state.getStateHash(); const { content, data } = this.readExperienceFile(stateHash); - if (content.includes(body)) { + if ( + mdq(content) + .query('section2') + .each() + .some((s) => s.text().trim() === body.trim()) + ) { debugLog('Skipping duplicate flow body'); return; } @@ -277,9 +286,14 @@ export class ExperienceTracker { }); }) .map((experience) => { - const lines = experience.content.split('\n'); - if (lines.length <= maxLines) return experience; - return { ...experience, content: lines.slice(0, maxLines).join('\n') }; + if (experience.content.split('\n').length <= maxLines) return experience; + let content = experience.content; + while (content.split('\n').length > maxLines) { + const sections = mdq(content).query('section2').each(); + if (sections.length <= 1) break; + content = sections[sections.length - 1].replace(''); + } + return { ...experience, content }; }); } @@ -424,49 +438,22 @@ export class ExperienceTracker { } function listTocHeadings(content: string): { index: number; level: 2 | 3; title: string }[] { - const tokens = marked.lexer(content); - const result: { index: number; level: 2 | 3; title: string }[] = []; - let index = 0; - for (const token of tokens) { - if (token.type !== 'heading') continue; - const heading = token as Tokens.Heading; - if (heading.depth !== 2 && heading.depth !== 3) continue; - index++; - result.push({ index, level: heading.depth as 2 | 3, title: heading.text }); - } - return result; + return mdq(content) + .query('heading') + .meta() + .filter((m) => m.depth === 2 || m.depth === 3) + .map((m, i) => ({ index: i + 1, level: m.depth as 2 | 3, title: m.text })); } function extractHeadingSection(content: string, sectionIndex: number): { title: string; body: string } | null { - const tokens = marked.lexer(content); - const matching: { tokenIdx: number; depth: number; text: string }[] = []; - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - if (token.type !== 'heading') continue; - const heading = token as Tokens.Heading; - if (heading.depth !== 2 && heading.depth !== 3) continue; - matching.push({ tokenIdx: i, depth: heading.depth, text: heading.text }); - } + const sections = mdq(content).query('section').each(); + const metas = mdq(content).query('section').meta(); + const matching = metas.map((meta, i) => ({ meta, section: sections[i] })).filter((entry) => entry.meta.depth === 2 || entry.meta.depth === 3); if (sectionIndex < 1 || sectionIndex > matching.length) return null; const target = matching[sectionIndex - 1]; - let endTokenIdx = tokens.length; - for (let j = target.tokenIdx + 1; j < tokens.length; j++) { - const token = tokens[j]; - if (token.type !== 'heading') continue; - if ((token as Tokens.Heading).depth <= target.depth) { - endTokenIdx = j; - break; - } - } - - const body = tokens - .slice(target.tokenIdx, endTokenIdx) - .map((t) => (t as any).raw || '') - .join(''); - return { title: target.text, body }; + return { title: target.meta.text, body: target.section.text() }; } function indexToLetters(index: number): string { @@ -535,21 +522,18 @@ function generateActionContent(title: string, code: string, explanation?: string } function renderAsHowTo(content: string): string { - const tokens = marked.lexer(content); - let result = ''; - for (const token of tokens) { - if (token.type === 'heading' && (token as Tokens.Heading).depth === 2) { - const text = (token as Tokens.Heading).text.trim(); - if (text.startsWith('FLOW:')) { - result += `## HOW to ${text.slice(5).trim()} (multi-step)\n\n`; - continue; - } - if (text.startsWith('ACTION:')) { - result += `## HOW to ${text.slice(7).trim()} (single-step)\n\n`; - continue; - } + let result = content; + for (const { marker, suffix } of [ + { marker: 'FLOW:', suffix: 'multi-step' }, + { marker: 'ACTION:', suffix: 'single-step' }, + ]) { + while (true) { + const headings = mdq(result).query('h2').meta(); + const idx = headings.findIndex((h) => h.text.trim().startsWith(marker)); + if (idx === -1) break; + const title = headings[idx].text.trim().slice(marker.length).trim(); + result = mdq(result).query(`h2[${idx}]`).replace(`## HOW to ${title} (${suffix})\n\n`); } - result += (token as any).raw || ''; } return result; } diff --git a/src/knowledge-tracker.ts b/src/knowledge-tracker.ts index 02a37c7..5587102 100644 --- a/src/knowledge-tracker.ts +++ b/src/knowledge-tracker.ts @@ -5,6 +5,7 @@ import { ActionResult } from './action-result.js'; import { ConfigParser } from './config.js'; import { getCliName } from './utils/cli-name.ts'; import { createDebug } from './utils/logger.js'; +import { mdq } from './utils/markdown-query.js'; const debugLog = createDebug('explorbot:knowledge-tracker'); @@ -190,7 +191,7 @@ export class KnowledgeTracker { return this.knowledgeFiles.map((knowledge) => { const content = knowledge.content.trim(); - const firstLine = content.split('\n')[0]?.trim() || ''; + const firstLine = mdq(content).meta()[0]?.text.split('\n')[0]?.trim() || ''; return { url: knowledge.url, firstLine, diff --git a/src/utils/markdown-query.ts b/src/utils/markdown-query.ts index 4ebee4a..02af9bf 100644 --- a/src/utils/markdown-query.ts +++ b/src/utils/markdown-query.ts @@ -459,6 +459,15 @@ export class MarkdownQuery { each(): MarkdownQuery[] { return this.matches.map((m) => new MarkdownQuery(this.source, [m])); } + + meta(): Array<{ type: string; depth: number | null; text: string }> { + return this.matches.map((range) => { + const token = range.token as any; + let depth: number | null = null; + if (token.type === 'heading') depth = token.depth; + return { type: token.type, depth, text: getTokenText(range.token) }; + }); + } } export function mdq(source: string): MarkdownQuery { diff --git a/tests/unit/experience-tracker.test.ts b/tests/unit/experience-tracker.test.ts index e065aeb..9ad9a97 100644 --- a/tests/unit/experience-tracker.test.ts +++ b/tests/unit/experience-tracker.test.ts @@ -349,6 +349,32 @@ describe('ExperienceTracker', () => { }); }); + describe('getSuccessfulExperience rendering', () => { + it('rewrites FLOW/ACTION headings to HOW to phrasing', () => { + const state = new ActionResult({ html: '', url: 'https://example.com/howto', title: 'HowTo' }); + const body = ['## FLOW: create a test', '', '* Click create', '', '```js', 'I.click("Create")', '```', '', '---', '', '## ACTION: save the form', '', '```js', 'I.click("Save")', '```', ''].join('\n'); + experienceTracker.writeExperienceFile(state.getStateHash(), body, { url: '/howto', title: 'HowTo' }); + + const combined = experienceTracker.getSuccessfulExperience(state).join('\n'); + + expect(combined).toContain('## HOW to create a test (multi-step)'); + expect(combined).toContain('## HOW to save the form (single-step)'); + expect(combined).not.toContain('## FLOW:'); + expect(combined).not.toContain('## ACTION:'); + }); + + it('rewrites and terminates when a title contains the marker', () => { + const state = new ActionResult({ html: '', url: 'https://example.com/edge', title: 'Edge' }); + const body = ['## FLOW: undo FLOW: steps', '', '* step', '', '---', ''].join('\n'); + experienceTracker.writeExperienceFile(state.getStateHash(), body, { url: '/edge', title: 'Edge' }); + + const combined = experienceTracker.getSuccessfulExperience(state).join('\n'); + + expect(combined).toContain('## HOW to undo FLOW: steps (multi-step)'); + expect(combined).not.toContain('## FLOW:'); + }); + }); + describe('table-of-contents lettering', () => { it('assigns contiguous fileTags when a zero-section file sorts first', () => { const url = '/lettering-page'; diff --git a/tests/unit/markdown-query.test.ts b/tests/unit/markdown-query.test.ts index 46cafe4..166b703 100644 --- a/tests/unit/markdown-query.test.ts +++ b/tests/unit/markdown-query.test.ts @@ -592,6 +592,51 @@ Not a section. }); }); + describe('meta', () => { + it('returns heading type, depth, and unwrapped text', () => { + const metas = mdq(sampleMarkdown).query('h2').meta(); + expect(metas).toHaveLength(3); + expect(metas[0]).toEqual({ type: 'heading', depth: 2, text: 'API' }); + expect(metas[1].text).toBe('Settings'); + expect(metas[2].text).toBe('FAQ'); + expect(metas.every((m) => !m.text.includes('#'))).toBe(true); + }); + + it('returns section heading depth and title without markup', () => { + const metas = mdq(sampleMarkdown).query('section("API")').meta(); + expect(metas).toHaveLength(1); + expect(metas[0]).toEqual({ type: 'heading', depth: 2, text: 'API' }); + }); + + it('returns h3 section depth', () => { + const metas = mdq(sampleMarkdown).query('section3(~"Auth")').meta(); + expect(metas).toHaveLength(1); + expect(metas[0].depth).toBe(3); + expect(metas[0].text).toBe('Authentication'); + }); + + it('returns code body without fences and null depth', () => { + const metas = mdq(sampleMarkdown).query('code').meta(); + expect(metas).toHaveLength(2); + expect(metas[0].type).toBe('code'); + expect(metas[0].depth).toBeNull(); + expect(metas[0].text).toContain('const token = getToken()'); + expect(metas[0].text).not.toContain('```'); + }); + + it('returns paragraph text with null depth', () => { + const metas = mdq(sampleMarkdown).query('paragraph(~"intro")').meta(); + expect(metas).toHaveLength(1); + expect(metas[0].type).toBe('paragraph'); + expect(metas[0].depth).toBeNull(); + expect(metas[0].text).toContain('intro paragraph'); + }); + + it('returns empty array when no matches', () => { + expect(mdq(sampleMarkdown).query('h6').meta()).toEqual([]); + }); + }); + describe('replace', () => { it('should replace matched content', () => { const result = mdq(sampleMarkdown).query('heading("FAQ")').replace('## Questions\n'); From b94d4c558dcff9efdd523b4f2ec64c53ae413ce5 Mon Sep 17 00:00:00 2001 From: Denys Kuchma Date: Wed, 15 Jul 2026 19:26:40 +0200 Subject: [PATCH 2/2] Refactor --- src/ai/planner.ts | 81 ++++++++++++++----------------- src/ai/tester.ts | 5 +- src/experience-tracker.ts | 22 +++------ src/utils/markdown-query.ts | 7 ++- tests/unit/markdown-query.test.ts | 9 ++++ 5 files changed, 63 insertions(+), 61 deletions(-) diff --git a/src/ai/planner.ts b/src/ai/planner.ts index 9cb91cc..f3dcbbd 100644 --- a/src/ai/planner.ts +++ b/src/ai/planner.ts @@ -281,42 +281,35 @@ export class Planner extends PlannerBase implements Agent { private cleanExperienceFlows(text: string): string | null { let result = text; - - while (true) { - const sections = [...mdq(result).query('section2').each(), ...mdq(result).query('section3').each()]; - const seenTitles = new Set(); - let removed = false; - for (const section of sections) { - const heading = section.query('heading').text().trim(); - const withoutHeadings = mdq(section.text()).query('heading').replace(''); - const body = mdq(withoutHeadings).query('hr').replace('').trim(); - if (body && !seenTitles.has(heading)) { - seenTitles.add(heading); - continue; - } - result = section.replace(''); - removed = true; - break; - } - if (!removed) break; + const seenTitles = new Set(); + for (const selector of ['section2', 'section3']) { + result = mdq(result) + .query(selector) + .replaceEach((section) => { + const heading = section.query('heading').text().trim(); + const withoutHeadings = mdq(section.text()).query('heading').replace(''); + const body = mdq(withoutHeadings).query('hr').replace('').trim(); + if (body && !seenTitles.has(heading)) { + seenTitles.add(heading); + return section.text(); + } + return ''; + }); } const trimmedTitles = new Set(); - while (true) { - const sections = [...mdq(result).query('section2').each(), ...mdq(result).query('section3').each()]; - let trimmed = false; - for (const section of sections) { - const heading = section.query('heading').text().trim(); - if (trimmedTitles.has(heading)) continue; - const count = section.query('blockquote').count(); - if (count <= 10) continue; - const kept = mdq(section.text()).query('blockquote[10:]').replace(''); - result = section.replace(`${kept.trimEnd()}\n> ... and ${count - 10} more discoveries\n`); - trimmedTitles.add(heading); - trimmed = true; - break; - } - if (!trimmed) break; + for (const selector of ['section2', 'section3']) { + result = mdq(result) + .query(selector) + .replaceEach((section) => { + const heading = section.query('heading').text().trim(); + if (trimmedTitles.has(heading)) return section.text(); + const count = section.query('blockquote').count(); + if (count <= 10) return section.text(); + const kept = mdq(section.text()).query('blockquote[10:]').replace(''); + trimmedTitles.add(heading); + return `${kept.trimEnd()}\n> ... and ${count - 10} more discoveries\n`; + }); } return result.trim() || null; @@ -395,17 +388,17 @@ export class Planner extends PlannerBase implements Agent { deep: true, }); let plannerResearch = mdq(research).query('code').replace(''); - const tableCount = mdq(plannerResearch).query('table').count(); - for (let i = 0; i < tableCount; i++) { - const table = mdq(plannerResearch).query('table').each()[i]; - const rows = table.toJson(); - if (rows.length === 0 || !rows[0].Element) continue; - const elementWithType = rows.map((r) => ({ - Element: r.Element, - Type: r.Type || '', - })); - plannerResearch = table.replace(jsonToTable(elementWithType, ['Element', 'Type'])); - } + plannerResearch = mdq(plannerResearch) + .query('table') + .replaceEach((table) => { + const rows = table.toJson(); + if (rows.length === 0 || !rows[0].Element) return table.text(); + const elementWithType = rows.map((r) => ({ + Element: r.Element, + Type: r.Type || '', + })); + return jsonToTable(elementWithType, ['Element', 'Type']); + }); const hasFocusedOverlay = hasFocusedSection(plannerResearch); const focusNote = hasFocusedOverlay ? "IMPORTANT: One section is marked as **Focused** — this is the user's current focus area. Concentrate testing on the Focused section FIRST — test all interactions inside it before planning tests for the rest of the page." : ''; diff --git a/src/ai/tester.ts b/src/ai/tester.ts index abbe786..7794ad3 100644 --- a/src/ai/tester.ts +++ b/src/ai/tester.ts @@ -813,8 +813,9 @@ export class Tester extends TaskAgent implements Agent { - When selecting related entities from a list, do not choose rows/options/cards marked as "0 items", "0 tests", or otherwise empty if the scenario requires selecting real content. - In selection pickers, counters such as "Selected 0", "Matched tests 0", or disabled Save/Apply mean the selection did not register. Choose a non-empty item or change filters before submitting. - A passed form/click command only means the command executed. If a required field remains empty, submit stays disabled, or the expected text is not visible, treat the action as not completed and correct the missing field/state. - - For filter/tab scenarios, success requires BOTH: the requested filter/tab is visibly active/selected AND the list content matches that filter. Do not finish from only one of these signals. - - Empty-state text such as "No matched items" only proves a filter when the requested filter/tab is active and the empty state belongs to the filtered list. + - For filter/tab scenarios, success requires BOTH: the requested state is evidenced by a selected control, URL/query, or another explicit state indicator AND the list content matches that state. Do not finish from only one of these signals. + - Once the requested control state and matching content are visible, finish the scenario instead of repeating the interaction to obtain a different kind of state indicator. + - Empty-state text such as "No matched items" only proves a filter when the requested state is explicit and the empty state belongs to the filtered list. - When filling complex form with lot of actions performed, use see() to look which fields were filled and which are not - When verify() fails, use see() to visually confirm the result — visual confirmation is equally valid evidence - For visual state verification (active tabs, selected items, counts, colors), prefer see() over DOM-based verify() diff --git a/src/experience-tracker.ts b/src/experience-tracker.ts index f41ac7f..2711fc1 100644 --- a/src/experience-tracker.ts +++ b/src/experience-tracker.ts @@ -522,20 +522,14 @@ function generateActionContent(title: string, code: string, explanation?: string } function renderAsHowTo(content: string): string { - let result = content; - for (const { marker, suffix } of [ - { marker: 'FLOW:', suffix: 'multi-step' }, - { marker: 'ACTION:', suffix: 'single-step' }, - ]) { - while (true) { - const headings = mdq(result).query('h2').meta(); - const idx = headings.findIndex((h) => h.text.trim().startsWith(marker)); - if (idx === -1) break; - const title = headings[idx].text.trim().slice(marker.length).trim(); - result = mdq(result).query(`h2[${idx}]`).replace(`## HOW to ${title} (${suffix})\n\n`); - } - } - return result; + return mdq(content) + .query('h2') + .replaceEach((heading) => { + const text = heading.meta()[0]?.text.trim() || ''; + if (text.startsWith('FLOW:')) return `## HOW to ${text.slice(5).trim()} (multi-step)\n\n`; + if (text.startsWith('ACTION:')) return `## HOW to ${text.slice(7).trim()} (single-step)\n\n`; + return heading.text(); + }); } export interface ExperienceFile { diff --git a/src/utils/markdown-query.ts b/src/utils/markdown-query.ts index 02af9bf..f421195 100644 --- a/src/utils/markdown-query.ts +++ b/src/utils/markdown-query.ts @@ -408,6 +408,10 @@ export class MarkdownQuery { } replace(content: string): string { + return this.replaceEach(() => content); + } + + replaceEach(replacer: (match: MarkdownQuery, index: number) => string): string { const sorted = [...this.matches].sort((a, b) => a.start - b.start); const kept: MatchedRange[] = []; @@ -418,10 +422,11 @@ export class MarkdownQuery { lastEnd = range.start + range.length; } + const replacements = kept.map((range, index) => replacer(new MarkdownQuery(this.source, [range]), index)); let result = this.source; for (let i = kept.length - 1; i >= 0; i--) { const range = kept[i]; - result = result.slice(0, range.start) + content + result.slice(range.start + range.length); + result = result.slice(0, range.start) + replacements[i] + result.slice(range.start + range.length); } return result; diff --git a/tests/unit/markdown-query.test.ts b/tests/unit/markdown-query.test.ts index 166b703..7ed4bca 100644 --- a/tests/unit/markdown-query.test.ts +++ b/tests/unit/markdown-query.test.ts @@ -667,6 +667,15 @@ Not a section. const result = mdq(md).query('section').replace('REPLACED\n'); expect(result).toBe('REPLACED\n'); }); + + it('should replace each match without stale offsets', () => { + const md = '## Short\n\nText\n\n## Much Longer Heading\n\nMore\n'; + const result = mdq(md) + .query('h2') + .replaceEach((heading, index) => `## ${index + 1}: ${heading.meta()[0].text}\n\n`); + + expect(result).toBe('## 1: Short\n\nText\n\n## 2: Much Longer Heading\n\nMore\n'); + }); }); describe('composable chaining', () => {