Skip to content

Commit 958d139

Browse files
committed
cleanup yaml rules
1 parent e754df8 commit 958d139

File tree

10 files changed

+211
-83
lines changed

10 files changed

+211
-83
lines changed

packages/remark-lint/src/api.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@ export default {
1818
},
1919
plugins: [
2020
...basePreset.plugins,
21+
22+
// Internal Rules
2123
duplicateStabilityNodes,
2224
yamlComments,
2325
hashedSelfReference,
2426
orderedReferences,
2527
requiredMetadata,
28+
29+
// External Rules
2630
remarkLintNoUnusedDefinitions,
2731
[remarkLintFencedCodeFlag, { allowEmpty: false }],
2832
[remarkLintMaximumLineLength, 120],

packages/remark-lint/src/rules/__tests__/utils.mjs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,43 @@ import { mock } from 'node:test';
44
import remarkParse from 'remark-parse';
55
import { unified } from 'unified';
66

7-
export const testRule = (rule, markdown, expected, vfileOptions) => {
8-
const processor = unified().use(remarkParse);
9-
const tree = processor.parse(markdown);
7+
/**
8+
* Tests a markdown rule against a markdown string
9+
*/
10+
export const testRule = (rule, markdown, expected, vfileOptions = {}) => {
11+
// Parse the markdown once
12+
const tree = unified().use(remarkParse).parse(markdown);
1013

14+
// Create a mock vfile
1115
const vfile = {
1216
...vfileOptions,
1317
message: mock.fn(),
1418
messages: [],
1519
};
1620

21+
// Execute the rule
1722
rule()(tree, vfile, () => {});
1823

24+
// Assert that the expected messages were reported
1925
assert.deepEqual(
2026
vfile.message.mock.calls.map(call => call.arguments[0]),
2127
expected
2228
);
2329
};
30+
31+
/**
32+
* Tests a YAML rule against a YAML string
33+
*/
34+
export function testYamlRule(rule, yaml, expected) {
35+
// Create a mock reporter
36+
const report = mock.fn();
37+
38+
// Execute the rule
39+
rule(yaml, report);
40+
41+
// Assert that the expected messages were reported
42+
assert.deepEqual(
43+
report.mock.calls.flatMap(call => call.arguments),
44+
expected
45+
);
46+
}

packages/remark-lint/src/rules/yaml/__tests__/ordered-yaml-keys.test.mjs renamed to packages/remark-lint/src/rules/__tests__/yaml/ordered-yaml-keys.test.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it } from 'node:test';
22

3-
import validateKeys from '../ordered-yaml-keys.mjs';
4-
import { testRule } from './utils.mjs';
3+
import validateKeys from '../../yaml/ordered-yaml-keys.mjs';
4+
import { testYamlRule } from '../utils.mjs';
55

66
const testCases = [
77
{
@@ -56,7 +56,7 @@ const testCases = [
5656
describe('ordered-yaml-keys', () => {
5757
for (const { name, input, expected } of testCases) {
5858
it(name, () => {
59-
testRule(validateKeys, input, expected);
59+
testYamlRule(validateKeys, input, expected);
6060
});
6161
}
6262
});

packages/remark-lint/src/rules/yaml/__tests__/validate-changes.test.mjs renamed to packages/remark-lint/src/rules/__tests__/yaml/validate-changes.test.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it } from 'node:test';
22

3-
import validateChanges from '../validate-changes.mjs';
4-
import { testRule } from './utils.mjs';
3+
import validateChanges from '../../yaml/validate-changes.mjs';
4+
import { testYamlRule } from '../utils.mjs';
55

66
const testCases = [
77
{
@@ -138,7 +138,7 @@ const testCases = [
138138
describe('validate-changes', () => {
139139
for (const { name, input, expected } of testCases) {
140140
it(name, () => {
141-
testRule(validateChanges, input, expected);
141+
testYamlRule(validateChanges, input, expected);
142142
});
143143
}
144144
});

packages/remark-lint/src/rules/yaml/__tests__/validate-versions.test.mjs renamed to packages/remark-lint/src/rules/__tests__/yaml/validate-versions.test.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it } from 'node:test';
22

3-
import validateVersionRule from '../validate-versions.mjs';
4-
import { testRule } from './utils.mjs';
3+
import validateVersionRule from '../../yaml/validate-versions.mjs';
4+
import { testYamlRule } from '../utils.mjs';
55

66
const testCases = [
77
{
@@ -59,7 +59,7 @@ const testCases = [
5959
describe('validate-version', () => {
6060
for (const { name, input, expected } of testCases) {
6161
it(name, () => {
62-
testRule(validateVersionRule, input, expected);
62+
testYamlRule(validateVersionRule, input, expected);
6363
});
6464
}
6565
});

packages/remark-lint/src/rules/yaml/__tests__/utils.mjs

Lines changed: 0 additions & 13 deletions
This file was deleted.

packages/remark-lint/src/rules/yaml/index.mjs

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,44 @@ import orderedYamlKeys from './ordered-yaml-keys.mjs';
66
import validateChanges from './validate-changes.mjs';
77
import validateVersions from './validate-versions.mjs';
88

9-
const isYaml = node =>
10-
node.type === 'html' && node.value.match(/^<!--\s?YAML\n/);
9+
const YAML_HTML_COMMENT_RE = /^<!--\s?YAML\s*\n/;
10+
const HTML_COMMENT_OPEN = '<!--';
11+
const HTML_COMMENT_CLOSE = '-->';
12+
const RULES = [orderedYamlKeys, validateVersions, validateChanges];
1113

12-
const rules = [orderedYamlKeys, validateVersions, validateChanges];
14+
/**
15+
* Determine if a node is a YAML-bearing HTML comment.
16+
* @param {import('unist').Node} node
17+
*/
18+
const isYamlHtmlComment = node =>
19+
node.type === 'html' && YAML_HTML_COMMENT_RE.test(node.value);
1320

14-
const yamlComments = (tree, vfile) => {
15-
visit(tree, isYaml, node => {
16-
if (!node.value.startsWith('<!-- ')) {
21+
/**
22+
* Lints YAML embedded inside HTML comments in Markdown AST.
23+
* @param {import('unist').Node} tree
24+
* @param {import('vfile').VFile} vfile
25+
*/
26+
function yamlComments(tree, vfile) {
27+
visit(tree, isYamlHtmlComment, node => {
28+
const trimmed = node.value.trim();
29+
30+
// Consistency check for "<!-- " (space after open)
31+
if (!trimmed[HTML_COMMENT_OPEN.length + 1] === ' ') {
1732
vfile.message("Expected ' ' after '<!--'", node);
1833
}
1934

20-
// "#" comments out the first line, and we remove the last
21-
// three characters: "-->"
22-
const yaml = parse(`#${node.value.slice(0, -3)}`);
35+
if (!trimmed.endsWith(HTML_COMMENT_CLOSE)) {
36+
vfile.message('YAML comment is not properly closed with "-->"', node);
37+
return;
38+
}
39+
40+
// "#" comments out the first line ("<!-- YAML"), and we remove the trailing "-->"
41+
const parsed = parse(`#${trimmed.slice(0, -HTML_COMMENT_CLOSE.length)}`);
42+
2343
const report = (...args) => vfile.message(...args, node);
2444

25-
rules.forEach(rule => rule(yaml, report));
45+
RULES.forEach(rule => rule(parsed, report));
2646
});
27-
};
47+
}
2848

2949
export default lintRule('node-core:yaml-comments', yamlComments);

packages/remark-lint/src/rules/yaml/ordered-yaml-keys.mjs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
1-
const VALID_KEYS = ['added', 'napiVersion', 'deprecated', 'removed', 'changes'];
1+
/**
2+
* Default allowed keys and their required order at the top level.
3+
* Order matters for validation.
4+
*/
5+
export const DEFAULT_VALID_KEYS = [
6+
'added',
7+
'napiVersion',
8+
'deprecated',
9+
'removed',
10+
'changes',
11+
];
12+
13+
/**
14+
* Validate that:
15+
* - Only valid keys are present
16+
* - Keys appear in the expected order (relative order respected)
17+
*
18+
* @param {Record<string, unknown>} yaml - The YAML object to validate.
19+
* @param {(message: string) => void} report - Reporting function
20+
* @param {readonly string[]} [validKeys=DEFAULT_VALID_KEYS] - Allowed keys in the expected order.
21+
* @param {string} [prefix=''] - Message prefix for context.
22+
*/
23+
export default function orderedYamlKeys(
24+
yaml,
25+
report,
26+
validKeys = DEFAULT_VALID_KEYS,
27+
prefix = ''
28+
) {
29+
if (!yaml || typeof yaml !== 'object' || Array.isArray(yaml)) return;
230

3-
export default (yaml, report, validKeys = VALID_KEYS, prefix = '') => {
431
const keys = Object.keys(yaml);
532

633
// Check for invalid keys
@@ -15,6 +42,8 @@ export default (yaml, report, validKeys = VALID_KEYS, prefix = '') => {
1542
const index = validKeys.indexOf(key);
1643

1744
if (index === -1) {
45+
// Non-validated keys are ignored for ordering, since
46+
// they were already reported as invalid above
1847
continue;
1948
}
2049

@@ -26,4 +55,4 @@ export default (yaml, report, validKeys = VALID_KEYS, prefix = '') => {
2655
}
2756
lastIndex = index;
2857
}
29-
};
58+
}
Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,90 @@
11
import orderedYamlKeys from './ordered-yaml-keys.mjs';
22
import { validateVersion } from './validate-versions.mjs';
33

4-
const VALID_KEYS = ['version', 'pr-url', 'description'];
5-
const VALID_PR_URL =
6-
/^https:\/\/github.com\/nodejs(?:-private)?\/node(?:-private)?\/pull\/\d+$/;
4+
const CHANGE_VALID_KEYS = ['version', 'pr-url', 'description'];
5+
const VALID_PR_URL_RE =
6+
/^https:\/\/github\.com\/nodejs(?:-private)?\/node(?:-private)?\/pull\/\d+$/;
7+
const PRIVATE_PR_STARTER =
8+
'https://github.com/nodejs-private/node-private/pull/';
9+
const COMMIT_SHA_RE = /^[0-9a-f]{40}$/i;
710

11+
/**
12+
* A change is security-related if it references a PR in the private Node.js repo,
13+
* with a valid commit.
14+
* @param {Record<string, unknown>} change
15+
* @returns {boolean}
16+
*/
817
const isSecurityRelated = change =>
9-
typeof change['pr-url'] === 'string' &&
10-
change['pr-url']?.startsWith(
11-
'https://github.com/nodejs-private/node-private/pull/'
12-
);
18+
typeof change?.['pr-url'] === 'string' &&
19+
change['pr-url'].startsWith(PRIVATE_PR_STARTER) &&
20+
typeof change?.['commit'] === 'string';
1321

22+
/**
23+
* Anything below v1.0 is older than this format.
24+
* @param {Record<string, unknown>} change
25+
* @returns {boolean}
26+
*/
1427
const isAncient = change =>
15-
typeof change.version === 'string' && change.version.startsWith('v0.');
28+
typeof change?.version === 'string' && change.version.startsWith('v0.');
1629

17-
export default ({ changes }, report) => {
30+
/**
31+
* Validate the "changes" array within the YAML object.
32+
*
33+
* @param {{ changes?: unknown }} yaml
34+
* @param {(message: string) => void} report
35+
*/
36+
export default function validateChanges({ changes }, report) {
1837
if (changes === undefined) {
38+
// Nothing to validate
1939
return;
2040
}
2141

2242
if (!Array.isArray(changes)) {
23-
return report('"changes" must be an Array');
43+
report('"changes" must be an Array');
44+
return;
2445
}
2546

2647
changes.forEach((change, index) => {
27-
// Validate security information, if it exists
48+
const prefix = `In "changes[${index}]": `;
49+
50+
if (!change || typeof change !== 'object' || Array.isArray(change)) {
51+
report(`${prefix}Item must be an object`);
52+
}
53+
54+
// Security-related validations
2855
if (isSecurityRelated(change)) {
29-
if ('commit' in change) {
30-
if (isNaN(`0x${change.commit}`)) {
31-
report(`In "changes[${index}]": Invalid commit: "${change.commit}"`);
32-
}
56+
const commit = change.commit;
3357

34-
// Remove the "commit" key so we can validate keys like normal.
35-
delete change.commit;
58+
if (!COMMIT_SHA_RE.test(commit)) {
59+
report(`${prefix}Invalid commit: "${commit}"`);
3660
}
61+
62+
// Remove the "commit" key so we can validate keys like normal.
63+
delete change.commit;
3764
}
3865

39-
// Anything below v1.0 is older than this format
66+
// For non-ancient entries, validate PR URL, keys, and description presence
4067
if (!isAncient(change)) {
41-
// Validate the PR URL
42-
if (!VALID_PR_URL.test(change['pr-url'])) {
43-
report(
44-
`In "changes[${index}]": "${change['pr-url']}" is not a valid PR URL.`
45-
);
68+
const prUrl = change['pr-url'];
69+
70+
if (!VALID_PR_URL_RE.test(prUrl)) {
71+
report(`${prefix}"${prUrl}" is not a valid PR URL.`);
4672
}
4773

48-
// Validate the keys
49-
orderedYamlKeys(change, report, VALID_KEYS, `In "changes[${index}]": `);
74+
// Key validation
75+
orderedYamlKeys(change, report, CHANGE_VALID_KEYS, prefix);
5076
}
5177

52-
// Validate the versions
78+
// Version validation
5379
validateVersion(change.version, report, `changes[${index}].version`);
5480

55-
// Validate the description
56-
if (change.description.trim().length === 0) {
57-
report(`In "changes[${index}]": Description cannot be empty`);
81+
// Description validation
82+
if (typeof change.description !== 'string') {
83+
report(`${prefix}Description must be a string`);
84+
} else if (change.description.trim().length === 0) {
85+
report(`${prefix}Description cannot be empty`);
5886
} else if (!change.description.endsWith('.')) {
59-
report(`In "changes[${index}]": Description must end with a "."`);
87+
report(`${prefix}Description must end with a "."`);
6088
}
6189
});
62-
};
90+
}

0 commit comments

Comments
 (0)