diff --git a/src/commands/lint.ts b/src/commands/lint.ts index e14e05456..3cfaa7ce0 100644 --- a/src/commands/lint.ts +++ b/src/commands/lint.ts @@ -68,6 +68,49 @@ const LLM_PREAMBLES = [ /^Absolutely\.?\s*Here[^.\n]*\.?\s*\n*/gim, ]; +// ── Whole-page code-fence wrap (LLM artifact) ─────────────────────── +// +// An LLM sometimes returns an ENTIRE page wrapped in a ```markdown fence. +// Strip that wrap — but ONLY when the whole body is wrapped, never when a note +// merely *contains* a ```markdown block mid-document (e.g. a Notion export that +// fences a config snippet). The earlier code detected the wrap with /m regexes +// (a fence anywhere) but stripped the closing fence with a string-anchored +// regex, so a mid-document block lost only its trailing ``` and was left with +// an open fence — re-corrupted on every autopilot lint cycle. Detection and fix +// now share this single check so they can't drift apart again. + +function frontmatterLength(content: string): number { + const m = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/.exec(content); + return m ? m[0].length : 0; +} + +/** + * Treat a page as fence-wrapped only when the entire body (after any YAML + * frontmatter) is a single ```markdown fence: the first non-blank body line + * opens it, the last non-blank body line closes it, and no bare ``` appears + * between them (which would mean the fence closed early — not a whole-page + * wrap). Returns the unwrapped content (frontmatter preserved) when wrapped. + */ +function analyzeMarkdownWrap(content: string): { wrapped: boolean; unwrapped: string } { + const fmLen = frontmatterLength(content); + const head = content.slice(0, fmLen); + const lines = content.slice(fmLen).split('\n'); + + let first = 0; + while (first < lines.length && lines[first].trim() === '') first++; + let last = lines.length - 1; + while (last >= 0 && lines[last].trim() === '') last--; + + if (first >= last) return { wrapped: false, unwrapped: content }; + if (!/^```(?:markdown|md)\s*$/.test(lines[first].trim())) return { wrapped: false, unwrapped: content }; + if (lines[last].trim() !== '```') return { wrapped: false, unwrapped: content }; + for (let i = first + 1; i < last; i++) { + if (lines[i].trim().startsWith('```')) return { wrapped: false, unwrapped: content }; + } + + return { wrapped: true, unwrapped: head + lines.slice(first + 1, last).join('\n') }; +} + // ── Rules ────────────────────────────────────────────────────────── /** @@ -126,8 +169,10 @@ export function lintContent(content: string, filePath: string, opts: LintContent } } - // Rule: Wrapping code fences (```markdown ... ```) - if (content.match(/^```(?:markdown|md)\s*\n/m) && content.match(/\n```\s*$/m)) { + // Rule: Wrapping code fences — the WHOLE page body wrapped in ```markdown … + // ``` (an LLM artifact). A ```markdown block mid-document is legitimate and + // must NOT trigger this (see analyzeMarkdownWrap). + if (analyzeMarkdownWrap(content).wrapped) { issues.push({ file: filePath, line: 1, rule: 'code-fence-wrap', message: 'Page wrapped in ```markdown code fences (LLM artifact)', @@ -291,9 +336,10 @@ export function fixContent(content: string): string { fixed = fixed.replace(pattern, ''); } - // Fix wrapping code fences - fixed = fixed.replace(/^```(?:markdown|md)\s*\n/, ''); - fixed = fixed.replace(/\n```\s*$/, ''); + // Fix wrapping code fences — only a genuine whole-page wrap (see + // analyzeMarkdownWrap); never strip the closing fence of a mid-note block. + const wrap = analyzeMarkdownWrap(fixed); + if (wrap.wrapped) fixed = wrap.unwrapped; // Clean up excessive blank lines left by fixes fixed = fixed.replace(/\n{3,}/g, '\n\n'); diff --git a/test/lint.test.ts b/test/lint.test.ts index ca38b8d23..ef6cddadc 100644 --- a/test/lint.test.ts +++ b/test/lint.test.ts @@ -32,6 +32,14 @@ describe('lintContent', () => { expect(issues.some(i => i.rule === 'code-fence-wrap')).toBe(true); }); + test('does not flag a mid-document markdown code block as a page wrap', () => { + // Regression: a note that merely CONTAINS a ```markdown block (e.g. a + // Notion export fencing a config snippet) is not a whole-page wrap. + const content = '---\ntitle: Notes\ntype: note\ncreated: 2026-05-25\n---\n\nIntro paragraph.\n\n```markdown\nKEY=value\nPORT=3002\n```\n'; + const issues = lintContent(content, 'test.md'); + expect(issues.some(i => i.rule === 'code-fence-wrap')).toBe(false); + }); + test('detects placeholder dates', () => { const content = '---\ntitle: Test\ntype: person\ncreated: YYYY-MM-DD\n---\n\n# Test'; const issues = lintContent(content, 'test.md'); @@ -97,6 +105,17 @@ describe('fixContent', () => { expect(fixed).toContain('# Title'); }); + test('leaves a mid-document markdown code block intact (keeps its closing fence)', () => { + // Regression for the autopilot churn: fixContent used to strip only the + // trailing ```, leaving the fence open and the note re-corrupted each cycle. + const input = '---\ntitle: Notes\ntype: note\ncreated: 2026-05-25\n---\n\nIntro paragraph.\n\n```markdown\nKEY=value\nPORT=3002\n```\n'; + const fixed = fixContent(input); + const fenceLines = (fixed.match(/^```/gm) ?? []).length; + expect(fenceLines).toBe(2); + expect(fixed).toContain('```markdown'); + expect(fixed.trimEnd().endsWith('```')).toBe(true); + }); + test('cleans up excessive blank lines after fix', () => { const input = 'Of course. Here is the brain page.\n\n\n\n# Title\n\nContent.'; const fixed = fixContent(input);