Skip to content

feat: create linter system #191

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from 6 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
32 changes: 30 additions & 2 deletions bin/cli.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import { resolve } from 'node:path';
import { argv } from 'node:process';
import { argv, exit } from 'node:process';

import { Command, Option } from 'commander';

Expand All @@ -12,6 +12,8 @@ import generators from '../src/generators/index.mjs';
import createMarkdownLoader from '../src/loaders/markdown.mjs';
import createMarkdownParser from '../src/parsers/markdown.mjs';
import createNodeReleases from '../src/releases.mjs';
import { Linter } from '../src/linter/index.mjs';
import reporters from '../src/linter/reporters/index.mjs';

const availableGenerators = Object.keys(generators);

Expand Down Expand Up @@ -50,6 +52,12 @@ program
'Set the processing target modes'
).choices(availableGenerators)
)
.addOption(new Option('--skip-linting', 'Skip linting').default(false))
.addOption(
new Option('--reporter', 'Specify the linter reporter')
.choices(Object.keys(reporters))
.default('console')
)
.parse(argv);

/**
Expand All @@ -66,7 +74,17 @@ program
* @type {Options}
* @description The return type for values sent to the program from the CLI.
*/
const { input, output, target = [], version, changelog } = program.opts();
const {
input,
output,
target = [],
version,
changelog,
skipValidation,
reporter,
} = program.opts();

const linter = skipValidation ? undefined : new Linter();

const { loadFiles } = createMarkdownLoader();
const { parseApiDocs } = createMarkdownParser();
Expand All @@ -80,6 +98,8 @@ const { runGenerators } = createGenerator(parsedApiDocs);
// Retrieves Node.js release metadata from a given Node.js version and CHANGELOG.md file
const { getAllMajors } = createNodeReleases(changelog);

linter?.lintAll(parsedApiDocs);

await runGenerators({
// A list of target modes for the API docs parser
generators: target,
Expand All @@ -92,3 +112,11 @@ await runGenerators({
// A list of all Node.js major versions with LTS status
releases: await getAllMajors(),
});

if (linter) {
linter.report(reporter);

if (linter.hasError) {
exit(1);
}
}
66 changes: 66 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"acorn": "^8.14.0",
"@actions/core": "^1.11.1",
"commander": "^13.1.0",
"estree-util-visit": "^2.0.0",
"dedent": "^1.5.3",
Expand Down
3 changes: 3 additions & 0 deletions src/generators/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SemVer } from 'semver';
import type availableGenerators from './index.mjs';
import type { ApiDocReleaseEntry } from '../types';
import type { Linter } from '../linter/index.mjs';

declare global {
// All available generators as an inferable type, to allow Generator interfaces
Expand Down Expand Up @@ -30,6 +31,8 @@ declare global {

// A list of all Node.js major versions and their respective release information
releases: Array<ApiDocReleaseEntry>;

linter: Linter | undefined;
}

export interface GeneratorMetadata<I extends any, O extends any> {
Expand Down
66 changes: 66 additions & 0 deletions src/linter/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @ts-check
'use strict';

import reporters from './reporters/index.mjs';
import { invalidChangeVersion } from './rules/invalid-change-version.mjs';
import { missingChangeVersion } from './rules/missing-change-version.mjs';
import { missingIntroducedIn } from './rules/missing-introduced-in.mjs';

/**
* Lint issues in ApiDocMetadataEntry entries
*/
export class Linter {
/**
* @type {Array<import('./types.d.ts').LintIssue>}
*/
#issues = [];

/**
* @type {Array<import('./types.d.ts').LintRule>}
*/
#rules = [missingIntroducedIn, missingChangeVersion, invalidChangeVersion];

/**
* @param {ApiDocMetadataEntry} entry
* @returns {void}
*/
lint(entry) {
for (const rule of this.#rules) {
const issues = rule(entry);

if (issues.length > 0) {
this.#issues.push(...issues);
}
}
}

/**
* @param {ApiDocMetadataEntry[]} entries
* @returns {void}
*/
lintAll(entries) {
for (const entry of entries) {
this.lint(entry);
}
}

/**
* @param {keyof reporters} reporterName
*/
report(reporterName) {
const reporter = reporters[reporterName];

for (const issue of this.#issues) {
reporter(issue);
}
}

/**
* Returns whether there are any issues with a level of 'error'
*
* @returns {boolean}
*/
get hasError() {
return this.#issues.some(issue => issue.level === 'error');
}
}
31 changes: 31 additions & 0 deletions src/linter/reporters/console.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// @ts-check

'use strict';

import { styleText } from 'node:util';

/**
* @type {Record<import('../types.d.ts').IssueLevel, string>}
*/
const levelToColorMap = {
info: 'gray',
warn: 'yellow',
error: 'red',
};

/**
* @type {import('../types.d.ts').Reporter}
*/
export default issue => {
const position = issue.location.position
? ` (${issue.location.position.start}:${issue.location.position.end})`
: '';

console.log(
styleText(
// @ts-expect-error ForegroundColors is not exported
levelToColorMap[issue.level],
`${issue.message} at ${issue.location.path}${position}`
)
);
};
24 changes: 24 additions & 0 deletions src/linter/reporters/github.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// @ts-check

'use strict';

import * as core from '@actions/core';

/**
* GitHub action reporter for
*
* @type {import('../types.d.ts').Reporter}
*/
export default issue => {
const actions = {
warn: core.warning,
error: core.error,
info: core.notice,
};

(actions[issue.level] || core.notice)(issue.message, {
file: issue.location.path,
startLine: issue.location.position?.start.line,
endLine: issue.location.position?.end.line,
});
};
9 changes: 9 additions & 0 deletions src/linter/reporters/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use strict';

import console from './console.mjs';
import github from './github.mjs';

export default /** @type {const} */ ({
console,
github,
});
33 changes: 33 additions & 0 deletions src/linter/rules/invalid-change-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { validateVersion } from '../utils/semver.mjs';

/**
* Checks if any change version is invalid.
*
* @param {ApiDocMetadataEntry} entry
* @returns {Array<import('../types').LintIssue>}
*/
export const invalidChangeVersion = entry => {
if (entry.changes.length === 0) {
return [];
}

const allVersions = entry.changes
.filter(change => change.version)
.flatMap(change =>
Array.isArray(change.version) ? change.version : [change.version]
);

const invalidVersions = allVersions.filter(
version => !validateVersion(version.substring(1)) // Trim the leading 'v' from the version string
);

return invalidVersions.map(version => ({
level: 'warn',
message: `Invalid version number: ${version}`,
location: {
path: entry.api_doc_source,
line: entry.yaml_position.start.line,
column: entry.yaml_position.start.column,
},
}));
};
23 changes: 23 additions & 0 deletions src/linter/rules/missing-change-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Checks if any change version is missing.
*
* @param {ApiDocMetadataEntry} entry
* @returns {Array<import('../types').LintIssue>}
*/
export const missingChangeVersion = entry => {
if (entry.changes.length === 0) {
return [];
}

return entry.changes
.filter(change => !change.version)
.map(() => ({
level: 'warn',
message: 'Missing change version',
location: {
path: entry.api_doc_source,
line: entry.yaml_position.start.line,
column: entry.yaml_position.start.column,
},
}));
};
Loading
Loading