Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions src/linter/constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const LLM_DESCRIPTION_REGEX = /<!--\s?llm_description=.*-->/;

export const LINT_MESSAGES = {
missingIntroducedIn: "Missing 'introduced_in' field in the API doc entry",
invalidChangeProperty: 'Invalid change property type: {{type}}',
missingChangeVersion: 'Missing version field in the API doc entry',
invalidChangeVersion: 'Invalid version number: {{version}}',
duplicateStabilityNode: 'Duplicate stability node',
Expand Down
173 changes: 160 additions & 13 deletions src/linter/rules/__tests__/invalid-change-version.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,7 @@ changes:
<!-- YAML
changes:
- version:
- v15.7.0
- v14.18.0
- version: v6.4.0
- version:
- pr-url: https://github.com/nodejs/node/pull/1
-->`;

const context = {
Expand All @@ -70,17 +67,27 @@ changes:

invalidChangeVersion(context);

strictEqual(context.report.mock.callCount(), 1);
strictEqual(context.report.mock.callCount(), 2);

const call = context.report.mock.calls[0];
const callArguments = context.report.mock.calls.flatMap(
call => call.arguments
);

deepStrictEqual(call.arguments, [
deepStrictEqual(callArguments, [
{
level: 'error',
message: 'Missing version field in the API doc entry',
position: {
start: { line: 1, column: 1, offset: 1 },
end: { line: 1, column: 1, offset: 1 },
start: { line: 3 },
end: { line: 3 },
},
},
{
level: 'error',
message: 'Missing version field in the API doc entry',
position: {
start: { line: 4 },
end: { line: 4 },
},
},
]);
Expand Down Expand Up @@ -182,8 +189,8 @@ changes:
level: 'error',
message: 'Invalid version number: INVALID_VERSION',
position: {
start: { column: 1, line: 7, offset: 103 },
end: { column: 35, line: 7, offset: 137 },
start: { line: 11 },
end: { line: 11 },
},
},
]);
Expand Down Expand Up @@ -226,10 +233,150 @@ changes:
level: 'error',
message: 'Invalid version number: REPLACEME',
position: {
start: { column: 1, line: 7, offset: 103 },
end: { column: 35, line: 7, offset: 137 },
start: { line: 11 },
end: { line: 11 },
},
},
]);
});

it('should report an issue if changes is not a sequence', () => {
const yamlContent = dedent`
<!-- YAML
changes:
abc:
def:
-->`;

const context = {
tree: {
type: 'root',
children: [
{
type: 'html',
value: yamlContent,
position: {
start: { column: 1, line: 7, offset: 103 },
end: { column: 35, line: 7, offset: 137 },
},
},
],
},
report: mock.fn(),
getIssues: mock.fn(),
};

invalidChangeVersion(context);
strictEqual(context.report.mock.callCount(), 1);
const call = context.report.mock.calls[0];
deepStrictEqual(call.arguments, [
{
level: 'error',
message: 'Invalid change property type: PLAIN',
position: {
start: { line: 8 },
end: { line: 8 },
},
},
]);
});

it('should report an issue if version is not a mapping', () => {
const yamlContent = dedent`
<!-- YAML
changes:
version:
- abc
- def
-->`;

const context = {
tree: {
type: 'root',
children: [
{
type: 'html',
value: yamlContent,
position: {
start: { column: 1, line: 7, offset: 103 },
end: { column: 35, line: 7, offset: 137 },
},
},
],
},
report: mock.fn(),
getIssues: mock.fn(),
};

invalidChangeVersion(context);
strictEqual(context.report.mock.callCount(), 1);
const call = context.report.mock.calls[0];
deepStrictEqual(call.arguments, [
{
level: 'error',
message: 'Invalid change property type: PLAIN',
position: {
start: { line: 8 },
end: { line: 8 },
},
},
]);
});

it("should skip validations if yaml root node isn't a mapping", () => {
const yamlContent = dedent`
<!-- YAML
- abc
- def
-->`;

const context = {
tree: {
type: 'root',
children: [
{
type: 'html',
value: yamlContent,
position: {
start: { column: 1, line: 7, offset: 103 },
end: { column: 35, line: 7, offset: 137 },
},
},
],
},
report: mock.fn(),
getIssues: mock.fn(),
};

invalidChangeVersion(context);
strictEqual(context.report.mock.callCount(), 0);
});

it('should skip validations if changes node is missing', () => {
const yamlContent = dedent`
<!-- YAML
added: v0.1.91
-->`;

const context = {
tree: {
type: 'root',
children: [
{
type: 'html',
value: yamlContent,
position: {
start: { column: 1, line: 7, offset: 103 },
end: { column: 35, line: 7, offset: 137 },
},
},
],
},
report: mock.fn(),
getIssues: mock.fn(),
};

invalidChangeVersion(context);
strictEqual(context.report.mock.callCount(), 0);
});
});
115 changes: 86 additions & 29 deletions src/linter/rules/invalid-change-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@

import { valid, parse } from 'semver';
import { visit } from 'unist-util-visit';
import yaml from 'yaml';
import { isMap, isSeq, LineCounter, parseDocument } from 'yaml';

import { enforceArray } from '../../utils/array.mjs';
import {
extractYamlContent,
normalizeYamlSyntax,
} from '../../utils/parser/index.mjs';
import createQueries from '../../utils/queries/index.mjs';
import { LINT_MESSAGES } from '../constants.mjs';
import {
createYamlIssueReporter,
findPropertyByName,
normalizeNode,
} from '../utils/yaml.mjs';

const NODE_RELEASED_VERSIONS = env.NODE_RELEASED_VERSIONS?.split(',');

Expand Down Expand Up @@ -42,20 +46,51 @@
/**
* Determines if a given version is invalid.
*
* @param {string} version - The version to check.
* @param {import('yaml').Scalar} version - The version to check.
* @param {unknown} _ - Unused parameter.
* @param {{ length: number }} context - Array containing the length property.
* @returns {boolean} True if the version is invalid, otherwise false.
*/
const isInvalid = NODE_RELEASED_VERSIONS
? (version, _, { length }) =>
? ({ value }, _, { length }) =>
!(
isValidReplaceMe(version, length) ||
isIgnoredVersion(version) ||
NODE_RELEASED_VERSIONS.includes(version.replace(/^v/, ''))
isValidReplaceMe(value, length) ||
isIgnoredVersion(value) ||
NODE_RELEASED_VERSIONS.includes(value.replace(/^v/, ''))
)
: ({ value }, _, { length }) =>
!(isValidReplaceMe(value, length) || valid(value));

/**
* Validates and extracts versions of a change node
*
* @param {object} root0
* @param {import('../types.d.ts').LintContext} root0.context
* @param {import('yaml').Node} root0.node
* @param {(message: string, node: import('yaml').Node<unknown>) => import('../types.d.ts').IssueDescriptor} root0.createYamlIssue
*/
export const extractVersions = ({ context, node, createYamlIssue }) => {
if (!isMap(node)) {
context.report(
createYamlIssue(
LINT_MESSAGES.invalidChangeProperty.replace('{{type}}', node.type),
node

Check warning on line 77 in src/linter/rules/invalid-change-version.mjs

View check run for this annotation

Codecov / codecov/patch

src/linter/rules/invalid-change-version.mjs#L74-L77

Added lines #L74 - L77 were not covered by tests
)
: (version, _, { length }) =>
!(isValidReplaceMe(version, length) || valid(version));
);

return [];
}

Check warning on line 82 in src/linter/rules/invalid-change-version.mjs

View check run for this annotation

Codecov / codecov/patch

src/linter/rules/invalid-change-version.mjs#L79-L82

Added lines #L79 - L82 were not covered by tests

const versionNode = findPropertyByName(node, 'version');

if (!versionNode) {
context.report(createYamlIssue(LINT_MESSAGES.missingChangeVersion, node));

return [];
}

return normalizeNode(versionNode.value);
};

/**
* Identifies invalid change versions from metadata entries.
Expand All @@ -69,31 +104,53 @@

const normalizedYaml = normalizeYamlSyntax(yamlContent);

// TODO: Use YAML AST to provide better issues positions
/**
* @type {ApiDocRawMetadataEntry}
*/
const { changes } = yaml.parse(normalizedYaml);
const lineCounter = new LineCounter();
const document = parseDocument(normalizedYaml, { lineCounter });

const createYamlIssue = createYamlIssueReporter(node, lineCounter);

// Skip if yaml isn't a mapping
if (!isMap(document.contents)) {
return;
}

const changesNode = findPropertyByName(document.contents, 'changes');

if (!changes) {
// Skip if changes node is not present
if (!changesNode) {
return;
}

changes.forEach(({ version }) =>
enforceArray(version)
// Validate changes node is a sequence
if (!isSeq(changesNode.value)) {
return context.report(
createYamlIssue(
LINT_MESSAGES.invalidChangeProperty.replace(
'{{type}}',
changesNode.value.type
),
changesNode.key
)
);
}

changesNode.value.items.forEach(node => {
extractVersions({ context, node, createYamlIssue })
.filter(Boolean) // Filter already reported empt items,
.filter(isInvalid)
.forEach(version =>
context.report({
level: 'error',
message: version
? LINT_MESSAGES.invalidChangeVersion.replace(
'{{version}}',
version
)
: LINT_MESSAGES.missingChangeVersion,
position: node.position,
})
)
);
context.report(
createYamlIssue(
version?.value
? LINT_MESSAGES.invalidChangeVersion.replace(
'{{version}}',
version.value
)
: LINT_MESSAGES.missingChangeVersion,
version
)
)
);
});
});
};
6 changes: 5 additions & 1 deletion src/linter/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Root } from 'mdast';
import { Position } from 'unist';
import reporters from './reporters/index.mjs';
import { VFile } from 'vfile';

export type IssueLevel = 'info' | 'warn' | 'error';

export interface Position {
start: { line: number };
end: { line: number };
}

export interface LintIssueLocation {
path: string; // The absolute path to the file
position?: Position;
Expand Down
Loading
Loading