Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ghadocs/branding.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ value replaced by `***REDACTED***`. Keys whose names look sensitive (`auth`,
<!-- start usage -->

```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.
Expand Down
129 changes: 128 additions & 1 deletion __tests__/markers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 = `<!-- start title -->\n<!-- end title -->\n\n<!-- start ${name} -->\n`;

expect(diagnoseMarkers(source, sections)).toStrictEqual([
`The marker <!-- start ${name} --> 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 = [
'<!-- start title -->',
'<!-- end title -->',
'<!-- start [.github/ghadocs/examples/] -->',
'<!-- end [.github/ghadocs/examples/] -->',
].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 = [
'<!-- start title -->',
'```',
'<!-- end title -->',
'<!-- start input -->',
'<!-- end input -->',
].join('\n');

expect(diagnoseMarkers(source, sections)).toStrictEqual([
"The marker <!-- start input --> on line 4 names no section. Did you mean 'inputs'?",
"The marker <!-- end input --> 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 = [
'<!-- start title -->',
'```',
'<!-- end title -->',
'<!-- start input -->',
'<!-- end inputs -->',
].join('\n');

expect(diagnoseMarkers(source, sections)).toStrictEqual([
"The marker <!-- start input --> on line 4 names no section. Did you mean 'inputs'?",
]);
});

it('ignores a mistyped example inside code next to a real pair', () => {
const source = [
'```',
'<!-- start input -->',
'```',
'<!-- start inputs -->',
'<!-- end inputs -->',
].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 = ['<!-- start inputs -->', '```', '<!-- end input -->', '```'].join('\n');

expect(diagnoseMarkers(source, sections)).toStrictEqual([]);
});

it.each([
['inline code opened by triple backticks', 'Use ```x <!-- start input -->``` here.'],
['an indented code block opening with backticks', 'para\n\n ```\n <!-- start input -->'],
])('ignores a mistyped marker in %s', (_label, example) => {
const source = `<!-- start title -->\n<!-- end title -->\n\n${example}\n`;

expect(diagnoseMarkers(source, sections)).toStrictEqual([]);
});

it('ignores a mistyped marker quoted in inline code', () => {
const source =
'<!-- start title -->\n<!-- end title -->\n\nUse `x <!-- start input -->` here.\n';

expect(diagnoseMarkers(source, sections)).toStrictEqual([]);
});

it('ignores a mistyped marker inside code', () => {
const source = '<!-- start title -->\n<!-- end title -->\n\n```\n<!-- start input -->\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 = '<!-- start title -->\n<!-- end title -->\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 <!-- start inputs --> and <!-- end inputs --> 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('<!-- start inputs -->\n', sections)).toStrictEqual([]);
});
});
28 changes: 28 additions & 0 deletions __tests__/readme-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<!-- start title -->\n<!-- end title -->\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'];
Expand Down Expand Up @@ -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],
Expand Down
121 changes: 117 additions & 4 deletions src/markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)),
);
Comment on lines +250 to +252

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep lone typos inside unclosed fences as examples

When an unclosed fenced example contains only one near-miss marker, such as a lone <!-- start input -->, this filter removes the entire code range from examples and emits a typo warning. Because this is not a complete start/end pair, locateSection would still set the marker aside as code; only a complete inferred pair should bypass code filtering. Otherwise valid third-party READMEs receive false warnings for documented examples.

AGENTS.md reference: AGENTS.md:L14-L19

Useful? React with 👍 / 👎.

const warnings: string[] = [];
for (const match of source.matchAll(/(?<![`\\])<!--\s+(start|end)\s+(\S+)\s+-->/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 <!-- start inputs --> and <!-- end inputs --> where each section belongs; README.example.md shows every section.`,
);
}
return warnings;
}
10 changes: 9 additions & 1 deletion src/readme-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
Expand Down Expand Up @@ -94,6 +95,13 @@ export class ReadmeGenerator {
* @returns Promise resolving when done
*/
async generate(providedSections: ReadmeSection[] = this.inputs.sections): Promise<void> {
for (const warning of diagnoseMarkers(
this.inputs.readmeEditor.getReadmeContent(),
README_SECTIONS,
Comment thread
Jamie-BitFlight marked this conversation as resolved.
providedSections,
)) {
this.log.warn(warning);
}
const sectionPromises = this.updateSections(providedSections);
const sections = await this.resolveUpdates(sectionPromises);

Expand Down
Loading