From bc18fda49c2f378f2d9a26d1e630dd6eef24643e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:09:29 +0000 Subject: [PATCH 1/7] fix(readme-generator): warn about mistyped and missing section markers A marker whose name misses a section by a letter, and a README with no section markers at all, both produced a successful run that generated nothing, with the reason logged at debug level only. Before any section is written, generate now warns about a marker whose name is within two edits of a section name, suggesting that section, and about a README with no marker for any section, pointing at README.example.md. A name far from every section is left alone: the template's own [.github/ghadocs/examples/] marker and other tools' markers use the same syntax. Markers inside code are examples and are not reported. The exit code is unchanged. Fixes #644 Fixes #641 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- .github/ghadocs/branding.svg | 2 +- README.md | 2 +- __tests__/markers.test.ts | 48 ++++++++++++++++- __tests__/readme-generator.test.ts | 28 ++++++++++ src/markers.ts | 82 ++++++++++++++++++++++++++++++ src/readme-generator.ts | 9 +++- 6 files changed, 167 insertions(+), 4 deletions(-) diff --git a/.github/ghadocs/branding.svg b/.github/ghadocs/branding.svg index ee6d5285..b7809eea 100644 --- a/.github/ghadocs/branding.svg +++ b/.github/ghadocs/branding.svg @@ -1,3 +1,3 @@ - + diff --git a/README.md b/README.md index 8dc6ead6..263f7edd 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ value replaced by `***REDACTED***`. Keys whose names look sensitive (`auth`, ```yaml -- uses: bitflight-devops/github-action-readme-generator@v2.0.0 +- uses: bitflight-devops/github-action-readme-generator@v2.0.1 with: # Description: The absolute or relative path to the `action.yml` file to read in # from. diff --git a/__tests__/markers.test.ts b/__tests__/markers.test.ts index eb1dbd3e..e1756f35 100644 --- a/__tests__/markers.test.ts +++ b/__tests__/markers.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it } from 'vite-plus/test'; -import { locateSection } from '../src/markers.js'; +import { diagnoseMarkers, locateSection } from '../src/markers.js'; /** The body `locateSection` finds, or its reason for finding none. */ const body = (source: string, name = 'inputs'): string => { @@ -146,3 +146,49 @@ describe('locateSection', () => { expect(body(source.replaceAll(lookalike, name), name)).toBe('\ny'); }); }); + +describe('diagnoseMarkers', () => { + const sections = ['title', 'inputs', 'outputs']; + + it.each([ + ['a missing letter', 'input', 'inputs'], + ['a different case', 'Inputs', 'inputs'], + ['a transposition', 'otuputs', 'outputs'], + ])('suggests the section for a name with %s', (_label, name, section) => { + const source = `\n\n\n\n`; + + expect(diagnoseMarkers(source, sections)).toStrictEqual([ + `The marker on line 4 names no section. Did you mean '${section}'?`, + ]); + }); + + // README.example.md carries a `[.github/ghadocs/examples/]` marker that + // nothing fills, and other tools use the same comment syntax. + it('ignores a marker name that is not close to any section', () => { + const source = [ + '', + '', + '', + '', + ].join('\n'); + + expect(diagnoseMarkers(source, sections)).toStrictEqual([]); + }); + + it('ignores a mistyped marker inside code', () => { + const source = '\n\n\n```\n\n```\n'; + + expect(diagnoseMarkers(source, sections)).toStrictEqual([]); + }); + + it('reports a README with no section markers once', () => { + const [warning, ...rest] = diagnoseMarkers('# README\n', sections); + + expect(warning).toContain('The README has no section markers'); + expect(rest).toStrictEqual([]); + }); + + it('does not report a README whose only section marker is unpaired as having none', () => { + expect(diagnoseMarkers('\n', sections)).toStrictEqual([]); + }); +}); diff --git a/__tests__/readme-generator.test.ts b/__tests__/readme-generator.test.ts index 9cd84824..e9cf2bc3 100644 --- a/__tests__/readme-generator.test.ts +++ b/__tests__/readme-generator.test.ts @@ -33,6 +33,9 @@ describe('ReadmeGenerator', () => { mockLogTask = new LogTask('mock'); mockInputs = new Inputs({}, mockLogTask); mockInputs.readmeEditor = new ReadmeEditor('./README.md'); + vi.mocked(mockInputs.readmeEditor.getReadmeContent).mockReturnValue( + '\n\n', + ); // The auto-mocked Inputs has no config; the real constructor always builds // one, and generate() reads the `prettier` flag off it. mockInputs.config = { get: vi.fn().mockReturnValue(undefined) } as unknown as Inputs['config']; @@ -128,6 +131,31 @@ describe('ReadmeGenerator', () => { expect(readmeGenerator.outputSections).toHaveBeenCalledWith(combinedSections); }); + // #641 and #644: marker problems are reported before any section is + // written, where a successful run would otherwise hide them. + it('warns about a README with no section markers', async () => { + vi.mocked(mockInputs.readmeEditor.getReadmeContent).mockReturnValue('# README\n'); + readmeGenerator.updateSections = vi.fn().mockReturnValue([]); + readmeGenerator.resolveUpdates = vi.fn().mockResolvedValue({}); + readmeGenerator.outputSections = vi.fn(); + + await readmeGenerator.generate(); + + expect(mockLogTask.warn).toHaveBeenCalledWith( + expect.stringContaining('The README has no section markers'), + ); + }); + + it('warns nothing for a README whose markers name sections', async () => { + readmeGenerator.updateSections = vi.fn().mockReturnValue([]); + readmeGenerator.resolveUpdates = vi.fn().mockResolvedValue({}); + readmeGenerator.outputSections = vi.fn(); + + await readmeGenerator.generate(); + + expect(mockLogTask.warn).not.toHaveBeenCalled(); + }); + it.each([ ['unset', undefined, true], ['true', true, true], diff --git a/src/markers.ts b/src/markers.ts index 6b56f3c2..274c4a81 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -161,3 +161,85 @@ export function locateSection(source: string, name: string): SectionSpan { const indent = source.slice(from, end.index).match(/\n[\t ]*$/); return { found: true, start: from, end: indent ? end.index - indent[0].length : end.index }; } + +/** + * The number of single-character edits between two strings. + * @param {string} a - One string. + * @param {string} b - The other string. + * @returns {number} - The Levenshtein distance. + */ +function editDistance(a: string, b: string): number { + let previous = Array.from({ length: b.length + 1 }, (_, index) => index); + for (let i = 1; i <= a.length; i++) { + const current = [i]; + for (let j = 1; j <= b.length; j++) { + const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1); + current.push(Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, substitution)); + } + previous = current; + } + return previous[b.length] ?? 0; +} + +/** + * The section a mistyped marker name most likely meant: one within two edits, + * ignoring case. A name further from every section is some other tool's + * marker, or one this tool does not fill, and is not the user's mistake. + * @param {string} name - A marker name that is not a section. + * @param {readonly string[]} sections - The section names. + * @returns {string | undefined} - The closest section, if one is close. + */ +function closestSection(name: string, sections: readonly string[]): string | undefined { + let best: { section: string; distance: number } | undefined; + for (const section of sections) { + const distance = editDistance(name.toLowerCase(), section); + if (distance <= 2 && (best === undefined || distance < best.distance)) { + best = { section, distance }; + } + } + return best?.section; +} + +/** + * Warnings about markers that stop a README from being generated as its + * author intended, found before any section is written: + * + * - a marker whose name is a near miss of a section name, which the tool + * otherwise skips in silence; + * - a README with no marker for any section, which the tool otherwise leaves + * unchanged in silence. + * + * Markers inside code are examples and are not reported. + * @param {string} source - The document. + * @param {readonly string[]} sections - Every section name the tool knows. + * @returns {string[]} - One message per problem. + */ +export function diagnoseMarkers(source: string, sections: readonly string[]): string[] { + const code = codeRanges(source); + const warnings: string[] = []; + for (const match of source.matchAll(/(?/g)) { + const [marker, , name = ''] = match; + const inCode = code.some(([from, to]) => match.index >= from && match.index < to); + if (inCode || sections.includes(name)) { + continue; + } + const section = closestSection(name, sections); + if (section !== undefined) { + const [line] = linesOf(source, [match.index]); + warnings.push( + `The marker ${marker} on line ${line} names no section. Did you mean '${section}'?`, + ); + } + } + + const missing = (name: string): boolean => { + const span = locateSection(source, name); + return !span.found && span.reason === 'missing'; + }; + if (sections.every(missing)) { + warnings.push( + 'The README has no section markers, so nothing was generated. Add a pair such as and where each section belongs; README.example.md shows every section.', + ); + } + return warnings; +} diff --git a/src/readme-generator.ts b/src/readme-generator.ts index 11b851a7..8d3ade8e 100644 --- a/src/readme-generator.ts +++ b/src/readme-generator.ts @@ -8,10 +8,11 @@ import * as core from '@actions/core'; -import type { ReadmeSection } from './constants.js'; +import { README_SECTIONS, type ReadmeSection } from './constants.js'; import { isPrettierEnabled } from './helpers.js'; import type Inputs from './inputs.js'; import type LogTask from './logtask/index.js'; +import { diagnoseMarkers } from './markers.js'; import updateSection from './sections/index.js'; export type SectionKV = Record; @@ -94,6 +95,12 @@ export class ReadmeGenerator { * @returns Promise resolving when done */ async generate(providedSections: ReadmeSection[] = this.inputs.sections): Promise { + for (const warning of diagnoseMarkers( + this.inputs.readmeEditor.getReadmeContent(), + README_SECTIONS, + )) { + this.log.warn(warning); + } const sectionPromises = this.updateSections(providedSections); const sections = await this.resolveUpdates(sectionPromises); From 2124cb477b1a8e050b1695a0f17d5943612f219f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:20:52 +0000 Subject: [PATCH 2/7] fix(markers): report a mistyped pair wherever it sits The mistyped-marker check set aside every marker inside code, so a single mistyped pair after an unclosed generated fence went unreported. It now follows locateSection's rule: a single pair counts wherever it sits, and code only sets aside markers that are not one pair. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- __tests__/markers.test.ts | 17 +++++++++++++++++ src/markers.ts | 29 ++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/__tests__/markers.test.ts b/__tests__/markers.test.ts index e1756f35..e8dc2818 100644 --- a/__tests__/markers.test.ts +++ b/__tests__/markers.test.ts @@ -175,6 +175,23 @@ describe('diagnoseMarkers', () => { expect(diagnoseMarkers(source, sections)).toStrictEqual([]); }); + // A single pair counts wherever it sits, as in `locateSection`: generated + // text can hold an unclosed fence, which would otherwise hide the pair. + it('reports a single mistyped pair after an unclosed fence', () => { + const source = [ + '', + '```', + '', + '', + '', + ].join('\n'); + + expect(diagnoseMarkers(source, sections)).toStrictEqual([ + "The marker on line 4 names no section. Did you mean 'inputs'?", + "The marker on line 5 names no section. Did you mean 'inputs'?", + ]); + }); + it('ignores a mistyped marker inside code', () => { const source = '\n\n\n```\n\n```\n'; diff --git a/src/markers.ts b/src/markers.ts index 274c4a81..bed81442 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -216,18 +216,33 @@ function closestSection(name: string, sections: readonly string[]): string | und */ export function diagnoseMarkers(source: string, sections: readonly string[]): string[] { const code = codeRanges(source); - const warnings: string[] = []; + const byName = new Map(); for (const match of source.matchAll(/(?/g)) { - const [marker, , name = ''] = match; - const inCode = code.some(([from, to]) => match.index >= from && match.index < to); - if (inCode || sections.includes(name)) { - continue; + const name = match[2] ?? ''; + if (!sections.includes(name)) { + byName.set(name, [...(byName.get(name) ?? []), match]); } + } + + const warnings: string[] = []; + for (const [name, markers] of byName) { const section = closestSection(name, sections); - if (section !== undefined) { + if (section === undefined) { + continue; + } + // The rule `locateSection` follows: a single pair counts wherever it + // sits, and code only sets aside markers that are not one pair. + const starts = markers.filter((match) => match[1] === 'start'); + const ends = markers.filter((match) => match[1] === 'end'); + const live = isPair(starts, ends) + ? markers + : markers.filter( + (match) => !code.some(([from, to]) => match.index >= from && match.index < to), + ); + for (const match of live) { const [line] = linesOf(source, [match.index]); warnings.push( - `The marker ${marker} on line ${line} names no section. Did you mean '${section}'?`, + `The marker ${match[0]} on line ${line} names no section. Did you mean '${section}'?`, ); } } From 6a789cda3b79b19c7f5a804955dfc618f8e9e77c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:31:59 +0000 Subject: [PATCH 3/7] fix(markers): group markers by the section they are meant for A typo on one side splits a pair across two names, so the pair rule saw neither side as a pair and set the typo aside when it sat after an unclosed fence. Markers are now grouped by their section, or the section a near-miss name was meant to be, before the pair rule is applied. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- __tests__/markers.test.ts | 28 ++++++++++++++++++++++++++++ src/markers.ts | 26 ++++++++++++++------------ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/__tests__/markers.test.ts b/__tests__/markers.test.ts index e8dc2818..676642e3 100644 --- a/__tests__/markers.test.ts +++ b/__tests__/markers.test.ts @@ -192,6 +192,34 @@ describe('diagnoseMarkers', () => { ]); }); + // One mistyped side splits the pair across two names; the pair rule has to + // see both sides to know the pair counts. + it('reports a pair with one mistyped side after an unclosed fence', () => { + const source = [ + '', + '```', + '', + '', + '', + ].join('\n'); + + expect(diagnoseMarkers(source, sections)).toStrictEqual([ + "The marker on line 4 names no section. Did you mean 'inputs'?", + ]); + }); + + it('ignores a mistyped example inside code next to a real pair', () => { + const source = [ + '```', + '', + '```', + '', + '', + ].join('\n'); + + expect(diagnoseMarkers(source, sections)).toStrictEqual([]); + }); + it('ignores a mistyped marker inside code', () => { const source = '\n\n\n```\n\n```\n'; diff --git a/src/markers.ts b/src/markers.ts index bed81442..90d532f1 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -216,20 +216,20 @@ function closestSection(name: string, sections: readonly string[]): string | und */ export function diagnoseMarkers(source: string, sections: readonly string[]): string[] { const code = codeRanges(source); - const byName = new Map(); + // Markers are grouped by the section they are meant for: their own name, or + // the section a near-miss name was meant to be. A typo can split one pair + // across two names, and the pair rule below needs the whole pair. + const bySection = new Map(); for (const match of source.matchAll(/(?/g)) { const name = match[2] ?? ''; - if (!sections.includes(name)) { - byName.set(name, [...(byName.get(name) ?? []), match]); + const section = sections.includes(name) ? name : closestSection(name, sections); + if (section !== undefined) { + bySection.set(section, [...(bySection.get(section) ?? []), match]); } } const warnings: string[] = []; - for (const [name, markers] of byName) { - const section = closestSection(name, sections); - if (section === undefined) { - continue; - } + for (const [section, markers] of bySection) { // The rule `locateSection` follows: a single pair counts wherever it // sits, and code only sets aside markers that are not one pair. const starts = markers.filter((match) => match[1] === 'start'); @@ -240,10 +240,12 @@ export function diagnoseMarkers(source: string, sections: readonly string[]): st (match) => !code.some(([from, to]) => match.index >= from && match.index < to), ); for (const match of live) { - const [line] = linesOf(source, [match.index]); - warnings.push( - `The marker ${match[0]} on line ${line} names no section. Did you mean '${section}'?`, - ); + if (match[2] !== section) { + const [line] = linesOf(source, [match.index]); + warnings.push( + `The marker ${match[0]} on line ${line} names no section. Did you mean '${section}'?`, + ); + } } } From a9af27140a67a7610d63f53a57b5a50db201e8b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:41:22 +0000 Subject: [PATCH 4/7] fix(markers): treat closed code as examples in marker diagnostics The mistyped-marker check used the single-pair rule as a stand-in for "the markers sit after an unclosed fence", and each edge case of that stand-in produced a missed or false warning. It now asks the real question: a marker inside closed code is an example, and a marker inside a fence that nothing closes is reported. The missing-markers warning now checks the sections being generated, so --sections=inputs against a README with only a title pair warns, and an empty selection does not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- __tests__/markers.test.ts | 22 +++++++- __tests__/readme-generator.test.ts | 2 +- src/markers.ts | 80 +++++++++++++++++------------- src/readme-generator.ts | 1 + 4 files changed, 68 insertions(+), 37 deletions(-) diff --git a/__tests__/markers.test.ts b/__tests__/markers.test.ts index 676642e3..db44564a 100644 --- a/__tests__/markers.test.ts +++ b/__tests__/markers.test.ts @@ -220,6 +220,14 @@ describe('diagnoseMarkers', () => { expect(diagnoseMarkers(source, sections)).toStrictEqual([]); }); + // A real start marker and a mistyped example end marker in closed code are + // not a pair. + it('ignores a mistyped example in closed code after a real start marker', () => { + const source = ['', '```', '', '```'].join('\n'); + + expect(diagnoseMarkers(source, sections)).toStrictEqual([]); + }); + it('ignores a mistyped marker inside code', () => { const source = '\n\n\n```\n\n```\n'; @@ -229,10 +237,22 @@ describe('diagnoseMarkers', () => { it('reports a README with no section markers once', () => { const [warning, ...rest] = diagnoseMarkers('# README\n', sections); - expect(warning).toContain('The README has no section markers'); + expect(warning).toContain('The README has no markers for the sections being generated'); expect(rest).toStrictEqual([]); }); + // `--sections=inputs` against a README with only a title pair generates + // nothing, so the warning is about the requested sections. + it('reports missing markers for the requested sections only', () => { + const source = '\n\n'; + + expect(diagnoseMarkers(source, sections, ['inputs'])).toStrictEqual([ + 'The README has no markers for the sections being generated (inputs), so nothing was generated. Add a pair such as and where each section belongs; README.example.md shows every section.', + ]); + expect(diagnoseMarkers(source, sections, ['title'])).toStrictEqual([]); + expect(diagnoseMarkers(source, sections, [])).toStrictEqual([]); + }); + it('does not report a README whose only section marker is unpaired as having none', () => { expect(diagnoseMarkers('\n', sections)).toStrictEqual([]); }); diff --git a/__tests__/readme-generator.test.ts b/__tests__/readme-generator.test.ts index e9cf2bc3..89f066f8 100644 --- a/__tests__/readme-generator.test.ts +++ b/__tests__/readme-generator.test.ts @@ -142,7 +142,7 @@ describe('ReadmeGenerator', () => { await readmeGenerator.generate(); expect(mockLogTask.warn).toHaveBeenCalledWith( - expect.stringContaining('The README has no section markers'), + expect.stringContaining('The README has no markers for the sections being generated'), ); }); diff --git a/src/markers.ts b/src/markers.ts index 90d532f1..d38ebfd6 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -200,52 +200,62 @@ function closestSection(name: string, sections: readonly string[]): string | und return best?.section; } +/** + * Whether a code range is a fence that nothing closes. It runs to the end of + * the document, so the markers inside it are not examples: generated text can + * hold such a fence, and it would otherwise hide every marker after it. + * @param {string} code - The source text of a code range. + * @returns {boolean} - Whether it is an unclosed fence. + */ +function isUnclosedFence(code: string): boolean { + const lines = code.split('\n'); + const opener = /^[\t >]*(`{3,}|~{3,})/.exec(lines[0] ?? '')?.[1]; + if (opener === undefined) { + return false; + } + const closer = (lines.at(-1) ?? '').replace(/^[\t >]*/, '').trimEnd(); + const closes = + lines.length > 1 && + closer.length >= opener.length && + closer === opener.charAt(0).repeat(closer.length); + return !closes; +} + /** * Warnings about markers that stop a README from being generated as its * author intended, found before any section is written: * * - a marker whose name is a near miss of a section name, which the tool * otherwise skips in silence; - * - a README with no marker for any section, which the tool otherwise leaves - * unchanged in silence. + * - a README with no marker for any section being generated, which the tool + * otherwise leaves unchanged in silence. * - * Markers inside code are examples and are not reported. + * A marker inside closed code is an example and is not reported. A marker + * inside a fence that nothing closes is reported, as `locateSection` would + * still find it. * @param {string} source - The document. * @param {readonly string[]} sections - Every section name the tool knows. + * @param {readonly string[]} requested - The sections being generated. * @returns {string[]} - One message per problem. */ -export function diagnoseMarkers(source: string, sections: readonly string[]): string[] { - const code = codeRanges(source); - // Markers are grouped by the section they are meant for: their own name, or - // the section a near-miss name was meant to be. A typo can split one pair - // across two names, and the pair rule below needs the whole pair. - const bySection = new Map(); +export function diagnoseMarkers( + source: string, + sections: readonly string[], + requested: readonly string[] = sections, +): string[] { + const examples = codeRanges(source).filter( + ([from, to]) => !isUnclosedFence(source.slice(from, to)), + ); + const warnings: string[] = []; for (const match of source.matchAll(/(?/g)) { const name = match[2] ?? ''; - const section = sections.includes(name) ? name : closestSection(name, sections); - if (section !== undefined) { - bySection.set(section, [...(bySection.get(section) ?? []), match]); - } - } - - const warnings: string[] = []; - for (const [section, markers] of bySection) { - // The rule `locateSection` follows: a single pair counts wherever it - // sits, and code only sets aside markers that are not one pair. - const starts = markers.filter((match) => match[1] === 'start'); - const ends = markers.filter((match) => match[1] === 'end'); - const live = isPair(starts, ends) - ? markers - : markers.filter( - (match) => !code.some(([from, to]) => match.index >= from && match.index < to), - ); - for (const match of live) { - if (match[2] !== section) { - const [line] = linesOf(source, [match.index]); - warnings.push( - `The marker ${match[0]} on line ${line} names no section. Did you mean '${section}'?`, - ); - } + const section = sections.includes(name) ? undefined : closestSection(name, sections); + const example = examples.some(([from, to]) => match.index >= from && match.index < to); + if (section !== undefined && !example) { + const [line] = linesOf(source, [match.index]); + warnings.push( + `The marker ${match[0]} on line ${line} names no section. Did you mean '${section}'?`, + ); } } @@ -253,9 +263,9 @@ export function diagnoseMarkers(source: string, sections: readonly string[]): st const span = locateSection(source, name); return !span.found && span.reason === 'missing'; }; - if (sections.every(missing)) { + if (requested.length > 0 && requested.every(missing)) { warnings.push( - 'The README has no section markers, so nothing was generated. Add a pair such as and where each section belongs; README.example.md shows every section.', + `The README has no markers for the sections being generated (${requested.join(', ')}), so nothing was generated. Add a pair such as and where each section belongs; README.example.md shows every section.`, ); } return warnings; diff --git a/src/readme-generator.ts b/src/readme-generator.ts index 8d3ade8e..80db7750 100644 --- a/src/readme-generator.ts +++ b/src/readme-generator.ts @@ -98,6 +98,7 @@ export class ReadmeGenerator { for (const warning of diagnoseMarkers( this.inputs.readmeEditor.getReadmeContent(), README_SECTIONS, + providedSections, )) { this.log.warn(warning); } From 39e6ee2ffb8249c6d0f96b2421f65d24065e236f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:44:01 +0000 Subject: [PATCH 5/7] test(markers): cover a mistyped marker quoted in inline code Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- __tests__/markers.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/__tests__/markers.test.ts b/__tests__/markers.test.ts index db44564a..0543812e 100644 --- a/__tests__/markers.test.ts +++ b/__tests__/markers.test.ts @@ -228,6 +228,13 @@ describe('diagnoseMarkers', () => { expect(diagnoseMarkers(source, sections)).toStrictEqual([]); }); + it('ignores a mistyped marker quoted in inline code', () => { + const source = + '\n\n\nUse `x ` here.\n'; + + expect(diagnoseMarkers(source, sections)).toStrictEqual([]); + }); + it('ignores a mistyped marker inside code', () => { const source = '\n\n\n```\n\n```\n'; From 79c6d86e816cdd4fedc05c0add55ccdcdbae65b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:52:55 +0000 Subject: [PATCH 6/7] fix(markers): read only fenced blocks as possible unclosed fences The unclosed-fence check read a code range's kind from its text, so inline code opened by triple backticks, or an indented block whose first line is backticks, was taken for a fence that nothing closes, and a mistyped example inside it was reported. The parser's node kind now decides: only a fenced code block, whose node starts at its fence, can be unclosed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- __tests__/markers.test.ts | 9 +++++++++ src/markers.ts | 22 ++++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/__tests__/markers.test.ts b/__tests__/markers.test.ts index 0543812e..2be96667 100644 --- a/__tests__/markers.test.ts +++ b/__tests__/markers.test.ts @@ -228,6 +228,15 @@ describe('diagnoseMarkers', () => { expect(diagnoseMarkers(source, sections)).toStrictEqual([]); }); + it.each([ + ['inline code opened by triple backticks', 'Use ```x ``` here.'], + ['an indented code block opening with backticks', 'para\n\n ```\n '], + ])('ignores a mistyped marker in %s', (_label, example) => { + const source = `\n\n\n${example}\n`; + + expect(diagnoseMarkers(source, sections)).toStrictEqual([]); + }); + it('ignores a mistyped marker quoted in inline code', () => { const source = '\n\n\nUse `x ` here.\n'; diff --git a/src/markers.ts b/src/markers.ts index d38ebfd6..235ce8b6 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -68,17 +68,23 @@ function escapeRegExp(text: string): string { * Parsed with the markdown parser prettier already bundles, so containers, * HTML blocks and indentation follow Markdown's rules rather than a regex. * @param {string} source - The document. - * @returns {Array<[number, number]>} - The code ranges. + * @returns {Array<[number, number, boolean]>} - The code ranges, each with + * whether it is a fenced code block. */ -function codeRanges(source: string): [number, number][] { +function codeRanges(source: string): [number, number, boolean][] { // The parser drops a leading byte order mark, which shifts its offsets. const shift = source.startsWith('') ? 1 : 0; const parser = markdown.parsers.markdown; const root = parser.parse(source.slice(shift), {} as never) as MarkdownNode; - const ranges: [number, number][] = []; + const ranges: [number, number, boolean][] = []; const walk = (node: MarkdownNode): void => { if ((node.type === 'code' || node.type === 'inlineCode') && node.position) { - ranges.push([node.position.start.offset + shift, node.position.end.offset + shift]); + const from = node.position.start.offset + shift; + const to = node.position.end.offset + shift; + // A fenced block's node starts at its fence; an indented block's node + // starts at its indentation, and inline code is its own kind. + const fenced = node.type === 'code' && /^(`{3}|~{3})/.test(source.slice(from, to)); + ranges.push([from, to, fenced]); } for (const child of node.children ?? []) { walk(child); @@ -201,15 +207,15 @@ function closestSection(name: string, sections: readonly string[]): string | und } /** - * Whether a code range is a fence that nothing closes. It runs to the end of + * Whether a fenced code block is one that nothing closes. It runs to the end of * the document, so the markers inside it are not examples: generated text can * hold such a fence, and it would otherwise hide every marker after it. - * @param {string} code - The source text of a code range. + * @param {string} code - The source text of a fenced code block. * @returns {boolean} - Whether it is an unclosed fence. */ function isUnclosedFence(code: string): boolean { const lines = code.split('\n'); - const opener = /^[\t >]*(`{3,}|~{3,})/.exec(lines[0] ?? '')?.[1]; + const opener = /^(`{3,}|~{3,})/.exec(lines[0] ?? '')?.[1]; if (opener === undefined) { return false; } @@ -244,7 +250,7 @@ export function diagnoseMarkers( requested: readonly string[] = sections, ): string[] { const examples = codeRanges(source).filter( - ([from, to]) => !isUnclosedFence(source.slice(from, to)), + ([from, to, fenced]) => !fenced || !isUnclosedFence(source.slice(from, to)), ); const warnings: string[] = []; for (const match of source.matchAll(/(?/g)) { From f9b50e38a12f7256afb4122a1235fac8e20454bc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:55:22 +0000 Subject: [PATCH 7/7] refactor(markers): drop the unreachable opener check Only fenced code blocks reach isUnclosedFence, and a fenced block's node starts at its fence, so the branch for a missing opener could not run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- src/markers.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/markers.ts b/src/markers.ts index 235ce8b6..45545a74 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -215,10 +215,8 @@ function closestSection(name: string, sections: readonly string[]): string | und */ function isUnclosedFence(code: string): boolean { const lines = code.split('\n'); - const opener = /^(`{3,}|~{3,})/.exec(lines[0] ?? '')?.[1]; - if (opener === undefined) { - return false; - } + // A fenced block's node starts at its fence, so the opener is always there. + const opener = /^(`{3,}|~{3,})/.exec(lines[0] ?? '')?.[1] ?? '```'; const closer = (lines.at(-1) ?? '').replace(/^[\t >]*/, '').trimEnd(); const closes = lines.length > 1 &&