From bc18fda49c2f378f2d9a26d1e630dd6eef24643e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:09:29 +0000 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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 74371f06c45b310707b7cc1824aa72b9dedb458d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:50:15 +0000 Subject: [PATCH 6/8] fix(markers): never pair a marker with an example in closed code A section with one start marker and one end marker was located wherever the pair sat, even when one side was an example in a later code block. The tool then replaced everything between the real start marker and the example, including the user's prose. A marker inside closed code (inline code, an indented block, or a fence its closing fence ends) is now always an example. A marker inside a fence that nothing closes still counts, so such a fence cannot hide the markers after it. The mistyped-marker check and the contract verifier read markers the same way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- __tests__/markers.test.ts | 31 +++++-- __tests__/readme-editor.test.ts | 22 +++++ __tests__/verify-readme-contract.test.ts | 22 +++++ docs/tool-contract.md | 18 ++-- scripts/verify-readme-contract.mjs | 38 ++++---- src/markers.ts | 110 +++++++++++------------ 6 files changed, 154 insertions(+), 87 deletions(-) diff --git a/__tests__/markers.test.ts b/__tests__/markers.test.ts index 0543812e..4e3363fe 100644 --- a/__tests__/markers.test.ts +++ b/__tests__/markers.test.ts @@ -44,13 +44,30 @@ describe('locateSection', () => { expect(body(afterExample('\\'))).toBe('\nx'); }); - // A single pair is located wherever it sits. Generated text can hold an - // unclosed fence — a description whose fence the description updater - // flattened — and code detection must not let it hide the pairs after it. - it('locates a single pair even after an unclosed fence', () => { - expect( - body(`\n\`\`\`\n\n${afterExample()}`), - ).toBe('\nx'); + // A fence that nothing closes runs to the end of the document, and markers + // inside it still count, so it cannot hide the pairs after it. + it('locates pairs inside and after a fence that nothing closes', () => { + const source = `\n\`\`\`\n\n${afterExample()}`; + + expect(body(source, 'description')).toBe('\n```'); + expect(body(source)).toBe('\nx'); + }); + + describe('markers inside closed code', () => { + it('does not pair a start marker with an end marker in a later code example', () => { + const source = ['', 'prose', '```', '', '```'].join( + '\n', + ); + + expect(body(source)).toBe(''); + }); + + it.each([ + ['a fenced code block', ['```', '', '', '```']], + ['inline code', ['Add `x ` to your README.']], + ])('reports a pair that is only an example in %s as missing', (_label, example) => { + expect(body(example.join('\n'))).toBe(''); + }); }); // #691: a README that documents the markers repeats them. diff --git a/__tests__/readme-editor.test.ts b/__tests__/readme-editor.test.ts index 23e62d33..6fa2c234 100644 --- a/__tests__/readme-editor.test.ts +++ b/__tests__/readme-editor.test.ts @@ -270,6 +270,28 @@ describe('ReadmeEditor', () => { }, ); + // The only end marker is an example in closed code, so pairing it with + // the real start marker would replace the prose and code between them. + it('leaves the file unchanged when the only end marker is in a code example', async () => { + const original = [ + '', + '', + USER_PROSE, + '', + '```markdown', + '', + '```', + '', + ].join('\n'); + fs.writeFileSync(readmePath, original, 'utf8'); + const editor = new ReadmeEditor(readmePath); + editor.updateSection('inputs', UNALIGNED); + + await editor.dumpToFile(); + + expect(read()).toBe(original); + }); + it.each([ ['above', (fence: string) => [fence, '', ...pair]], ['below', (fence: string) => [...pair, '', fence]], diff --git a/__tests__/verify-readme-contract.test.ts b/__tests__/verify-readme-contract.test.ts index 36eacad3..a7b7004f 100644 --- a/__tests__/verify-readme-contract.test.ts +++ b/__tests__/verify-readme-contract.test.ts @@ -920,6 +920,28 @@ describe('README contract verifier regressions', () => { expect(output).toContain('content outside the section markers is byte-identical'); }); + // A start marker and an end marker inside a closed code example are not a + // pair, so a run that filled between them rewrote the user's text. + it('rejects a run that paired a start marker with an end marker in a code example', () => { + const original = [ + '', + '', + 'USER PROSE', + '', + '```markdown', + '', + '```', + '', + ].join('\n'); + const filled = ['', '', 'NEW', '', '```', ''].join( + '\n', + ); + + expect(annotations(() => verify(filled, ACTION, undefined, '.', original))).toContain( + 'rewrote content outside the section markers', + ); + }); + it('names an ambiguous section and accepts it left unchanged', () => { const original = withProse( [ diff --git a/docs/tool-contract.md b/docs/tool-contract.md index f7579187..73ab8213 100644 --- a/docs/tool-contract.md +++ b/docs/tool-contract.md @@ -47,15 +47,15 @@ bytes, and its spans are written as LF, because no single ending reproduces it. Which bytes are a span is a separate question, decided by `src/markers.ts` rather than by the formatter. A marker straight after a backtick or a backslash -is quoted, and is never a marker. A section with one start marker and one end -marker after it is filled wherever the pair sits. For any other shape, the -markers inside code — inline code, fenced or indented code blocks — are examples -and do not count. If the markers that still count are not a single pair, the -section is left unchanged with a warning naming the marker lines, because any -guess at the intended pair would replace text outside it. Code decides only -when the markers are not a single pair: generated text can hold an unclosed -fence, and a code check on every lookup would let that fence hide every pair -after it. +is quoted, and is never a marker. A marker inside closed code — inline code, an +indented code block, or a fenced code block that its closing fence ends — is an +example and does not count. A marker inside a fence that nothing closes does +count: that fence runs to the end of the document, and text generated from an +action's metadata can hold one, so treating it as code would hide every marker +after it. A section is filled only when the markers that count are one start +marker and one end marker after it. Any other shape leaves the section +unchanged, with a warning naming the marker lines, because any guess at the +intended pair would replace text outside it. The line break and indentation before an end marker that starts its line belong to the marker, not the span. A padded span always puts its end marker on its diff --git a/scripts/verify-readme-contract.mjs b/scripts/verify-readme-contract.mjs index 88337068..7bd239ff 100644 --- a/scripts/verify-readme-contract.mjs +++ b/scripts/verify-readme-contract.mjs @@ -118,13 +118,26 @@ const fail = (message) => { console.log(`::error::${message}`); }; -/** The half-open ranges Markdown renders as code, from prettier's parser. */ -const codeRanges = (source) => { +/** + * The half-open ranges whose markers are examples, from prettier's parser: + * inline code, indented code, and fenced code that its closing fence ends. A + * fence that nothing closes runs to the end of the document and is not one. + */ +const exampleRanges = (source) => { const shift = source.startsWith('') ? 1 : 0; const ranges = []; + const closedFence = (text) => { + const lines = text.split('\n'); + const opener = /^[\t >]*(`{3,}|~{3,})/.exec(lines[0])?.[1]; + if (!opener) return true; + const closer = lines.at(-1).replace(/^[\t >]*/, '').trimEnd(); + return lines.length > 1 && closer.length >= opener.length && /^(`+|~+)$/.test(closer) && closer[0] === opener[0]; + }; const walk = (node) => { 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; + if (closedFence(source.slice(from, to))) ranges.push([from, to]); } (node.children ?? []).forEach(walk); }; @@ -139,8 +152,7 @@ const codeRanges = (source) => { * Written apart from `src/markers.ts` on purpose, so each implementation * checks the other rather than agreeing by construction. The rules are the * same: a marker straight after a backtick or backslash is quoted, the name is - * matched literally, and when the markers are not a single pair, the markers - * inside code are examples. + * matched literally, and a marker inside closed code is an example. */ const markersOf = (source, name) => { const literal = name.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'); @@ -148,16 +160,12 @@ const markersOf = (source, name) => { [...source.matchAll(new RegExp(`(?`, 'g'))].map( (match) => ({ at: match.index, after: match.index + match[0].length }), ); - let starts = find('start'); - let ends = find('end'); - const pair = starts.length === 1 && ends.length === 1 && ends[0].at >= starts[0].after; - if (!pair) { - const code = codeRanges(source); - const live = ({ at }) => !code.some(([from, to]) => at >= from && at < to); - starts = starts.filter(live); - ends = ends.filter(live); - } - return { starts, ends }; + const starts = find('start'); + const ends = find('end'); + if (starts.length === 0 && ends.length === 0) return { starts, ends }; + const examples = exampleRanges(source); + const counts = ({ at }) => !examples.some(([from, to]) => at >= from && at < to); + return { starts: starts.filter(counts), ends: ends.filter(counts) }; }; /** diff --git a/src/markers.ts b/src/markers.ts index d38ebfd6..1864bf1e 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -5,16 +5,17 @@ * A marker straight after a backtick or a backslash is quoted or escaped, and * is never a marker. The name is matched literally. * - * A section with one start marker and one end marker after it is located - * wherever the pair sits. Any other shape — a README that documents the - * markers repeats them — has the markers inside code set aside as examples: - * inline code, fenced or indented code blocks. Code decides only when the - * markers are not a single pair: text this tool generated can hold an unclosed - * fence, and a code check on every lookup would let that fence hide every pair - * after it. + * A marker inside closed code — inline code, an indented code block, or a + * fenced code block that its closing fence ends — is an example and does not + * count. A marker inside a fence that nothing closes does count: such a fence + * runs to the end of the document, and text generated from an action's + * metadata can hold one, so treating it as code would hide every marker after + * it. * - * Any other shape is reported rather than guessed at, because a wrong guess - * replaces text outside the pair, which is the user's. + * A section is located only when the markers that count are one start marker + * and one end marker after it. Any other shape is reported rather than guessed + * at, because a wrong guess replaces text outside the pair, which is the + * user's. */ import * as markdown from 'prettier/plugins/markdown'; @@ -99,21 +100,42 @@ function linesOf(source: string, offsets: number[]): number[] { } /** - * Whether the markers are exactly one start marker and one end marker after it. - * @param {RegExpExecArray[]} starts - The start markers. - * @param {RegExpExecArray[]} ends - The end markers. - * @returns {boolean} - Whether they form a single pair. + * Whether a code range is a fence that nothing closes. + * @param {string} code - The source text of a code range. + * @returns {boolean} - Whether it is an unclosed fence. */ -function isPair(starts: RegExpExecArray[], ends: RegExpExecArray[]): boolean { - const [start] = starts; - const [end] = ends; - return ( - starts.length === 1 && - ends.length === 1 && - start !== undefined && - end !== undefined && - end.index >= start.index + start[0].length - ); +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; +} + +/** The last document `exampleRanges` parsed, and its result. */ +let parsed: { source: string; ranges: [number, number][] } | undefined; + +/** + * The half-open ranges of `source` whose markers are examples: every code + * range except a fence that nothing closes. The last result is kept, because + * each section is located more than once in the same document. + * @param {string} source - The document. + * @returns {Array<[number, number]>} - The example ranges. + */ +function exampleRanges(source: string): [number, number][] { + if (parsed?.source !== source) { + parsed = { + source, + ranges: codeRanges(source).filter(([from, to]) => !isUnclosedFence(source.slice(from, to))), + }; + } + return parsed.ranges; } /** @@ -131,12 +153,12 @@ export function locateSection(source: string, name: string): SectionSpan { let starts = markers('start'); let ends = markers('end'); - if (!isPair(starts, ends)) { - const code = codeRanges(source); - const live = (match: RegExpExecArray): boolean => - !code.some(([from, to]) => match.index >= from && match.index < to); - starts = starts.filter(live); - ends = ends.filter(live); + if (starts.length > 0 || ends.length > 0) { + const examples = exampleRanges(source); + const counts = (match: RegExpExecArray): boolean => + !examples.some(([from, to]) => match.index >= from && match.index < to); + starts = starts.filter(counts); + ends = ends.filter(counts); } const lines = (): number[] => linesOf( @@ -200,27 +222,6 @@ 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: @@ -230,9 +231,8 @@ function isUnclosedFence(code: string): boolean { * - a README with no marker for any section being generated, which the tool * otherwise leaves unchanged in silence. * - * 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. + * Markers are read as `locateSection` reads them: one inside closed code is + * an example and is not reported. * @param {string} source - The document. * @param {readonly string[]} sections - Every section name the tool knows. * @param {readonly string[]} requested - The sections being generated. @@ -243,9 +243,7 @@ export function diagnoseMarkers( sections: readonly string[], requested: readonly string[] = sections, ): string[] { - const examples = codeRanges(source).filter( - ([from, to]) => !isUnclosedFence(source.slice(from, to)), - ); + const examples = exampleRanges(source); const warnings: string[] = []; for (const match of source.matchAll(/(?/g)) { const name = match[2] ?? ''; From 79c6d86e816cdd4fedc05c0add55ccdcdbae65b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:52:55 +0000 Subject: [PATCH 7/8] 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 8/8] 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 &&