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..2be96667 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,130 @@ 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([]); + }); + + // 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'?", + ]); + }); + + // 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([]); + }); + + // 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.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'; + + 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 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 9cd84824..89f066f8 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 markers for the sections being generated'), + ); + }); + + 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..45545a74 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); @@ -161,3 +167,110 @@ 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; +} + +/** + * 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 fenced code block. + * @returns {boolean} - Whether it is an unclosed fence. + */ +function isUnclosedFence(code: string): boolean { + const lines = code.split('\n'); + // 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 && + 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 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. + * @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[], + requested: readonly string[] = sections, +): string[] { + const examples = codeRanges(source).filter( + ([from, to, fenced]) => !fenced || !isUnclosedFence(source.slice(from, to)), + ); + const warnings: string[] = []; + for (const match of source.matchAll(/(?/g)) { + const name = match[2] ?? ''; + 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}'?`, + ); + } + } + + const missing = (name: string): boolean => { + const span = locateSection(source, name); + return !span.found && span.reason === 'missing'; + }; + if (requested.length > 0 && requested.every(missing)) { + warnings.push( + `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 11b851a7..80db7750 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,13 @@ 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, + providedSections, + )) { + this.log.warn(warning); + } const sectionPromises = this.updateSections(providedSections); const sections = await this.resolveUpdates(sectionPromises);