Skip to content

Commit e991c6f

Browse files
authored
feat: add duplicate stability linters (#215)
1 parent d3f06a8 commit e991c6f

13 files changed

+297
-93
lines changed

src/linter/constants.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export const LINT_MESSAGES = {
44
missingIntroducedIn: "Missing 'introduced_in' field in the API doc entry",
55
missingChangeVersion: 'Missing version field in the API doc entry',
66
invalidChangeVersion: 'Invalid version number: {{version}}',
7+
duplicateStabilityNode: 'Duplicate stability node',
78
};

src/linter/engine.mjs

+3-24
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,9 @@
33
/**
44
* Creates a linter engine instance to validate ApiDocMetadataEntry entries
55
*
6-
* @param {import('./types').LintRule} rules Lint rules to validate the entries against
6+
* @param {import('./types').LintRule[]} rules Lint rules to validate the entries against
77
*/
88
const createLinterEngine = rules => {
9-
/**
10-
* Validates a ApiDocMetadataEntry entry against all defined rules
11-
*
12-
* @param {ApiDocMetadataEntry} entry
13-
* @returns {import('./types').LintIssue[]}
14-
*/
15-
const lint = entry => {
16-
const issues = [];
17-
18-
for (const rule of rules) {
19-
const ruleIssues = rule(entry);
20-
21-
if (ruleIssues.length > 0) {
22-
issues.push(...ruleIssues);
23-
}
24-
}
25-
26-
return issues;
27-
};
28-
299
/**
3010
* Validates an array of ApiDocMetadataEntry entries against all defined rules
3111
*
@@ -35,15 +15,14 @@ const createLinterEngine = rules => {
3515
const lintAll = entries => {
3616
const issues = [];
3717

38-
for (const entry of entries) {
39-
issues.push(...lint(entry));
18+
for (const rule of rules) {
19+
issues.push(...rule(entries));
4020
}
4121

4222
return issues;
4323
};
4424

4525
return {
46-
lint,
4726
lintAll,
4827
};
4928
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { LINT_MESSAGES } from '../constants.mjs';
2+
3+
/**
4+
* Checks if there are multiple stability nodes within a chain.
5+
*
6+
* @param {ApiDocMetadataEntry[]} entries
7+
* @returns {Array<import('../types').LintIssue>}
8+
*/
9+
export const duplicateStabilityNodes = entries => {
10+
const issues = [];
11+
let currentDepth = 0;
12+
let currentStability = -1;
13+
14+
for (const entry of entries) {
15+
const { depth } = entry.heading.data;
16+
const entryStability = entry.stability.children[0]?.data.index ?? -1;
17+
18+
if (
19+
depth > currentDepth &&
20+
entryStability >= 0 &&
21+
entryStability === currentStability
22+
) {
23+
issues.push({
24+
level: 'warn',
25+
message: LINT_MESSAGES.duplicateStabilityNode,
26+
location: {
27+
path: entry.api_doc_source,
28+
position: entry.stability.children[0].children[0].position,
29+
},
30+
});
31+
} else {
32+
currentDepth = depth;
33+
currentStability = entryStability;
34+
}
35+
}
36+
37+
return issues;
38+
};

src/linter/rules/index.mjs

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict';
22

3+
import { duplicateStabilityNodes } from './duplicate-stability-nodes.mjs';
34
import { invalidChangeVersion } from './invalid-change-version.mjs';
45
import { missingChangeVersion } from './missing-change-version.mjs';
56
import { missingIntroducedIn } from './missing-introduced-in.mjs';
@@ -8,6 +9,7 @@ import { missingIntroducedIn } from './missing-introduced-in.mjs';
89
* @type {Record<string, import('../types').LintRule>}
910
*/
1011
export default {
12+
'duplicate-stability-nodes': duplicateStabilityNodes,
1113
'invalid-change-version': invalidChangeVersion,
1214
'missing-change-version': missingChangeVersion,
1315
'missing-introduced-in': missingIntroducedIn,

src/linter/rules/invalid-change-version.mjs

+29-20
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,39 @@ import { valid } from 'semver';
44
/**
55
* Checks if any change version is invalid
66
*
7-
* @param {ApiDocMetadataEntry} entry
7+
* @param {ApiDocMetadataEntry[]} entries
88
* @returns {Array<import('../types').LintIssue>}
99
*/
10-
export const invalidChangeVersion = entry => {
11-
if (entry.changes.length === 0) {
12-
return [];
13-
}
10+
export const invalidChangeVersion = entries => {
11+
const issues = [];
12+
13+
for (const entry of entries) {
14+
if (entry.changes.length === 0) continue;
15+
16+
const allVersions = entry.changes
17+
.filter(change => change.version)
18+
.flatMap(change =>
19+
Array.isArray(change.version) ? change.version : [change.version]
20+
);
1421

15-
const allVersions = entry.changes
16-
.filter(change => change.version)
17-
.flatMap(change =>
18-
Array.isArray(change.version) ? change.version : [change.version]
22+
const invalidVersions = allVersions.filter(
23+
version => valid(version) === null
1924
);
2025

21-
const invalidVersions = allVersions.filter(
22-
version => valid(version) === null
23-
);
26+
issues.push(
27+
...invalidVersions.map(version => ({
28+
level: 'warn',
29+
message: LINT_MESSAGES.invalidChangeVersion.replace(
30+
'{{version}}',
31+
version
32+
),
33+
location: {
34+
path: entry.api_doc_source,
35+
position: entry.yaml_position,
36+
},
37+
}))
38+
);
39+
}
2440

25-
return invalidVersions.map(version => ({
26-
level: 'warn',
27-
message: LINT_MESSAGES.invalidChangeVersion.replace('{{version}}', version),
28-
location: {
29-
path: entry.api_doc_source,
30-
position: entry.yaml_position,
31-
},
32-
}));
41+
return issues;
3342
};
+20-14
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
/**
22
* Checks if any change version is missing
33
*
4-
* @param {ApiDocMetadataEntry} entry
4+
* @param {ApiDocMetadataEntry[]} entries
55
* @returns {Array<import('../types').LintIssue>}
66
*/
7-
export const missingChangeVersion = entry => {
8-
if (entry.changes.length === 0) {
9-
return [];
7+
export const missingChangeVersion = entries => {
8+
const issues = [];
9+
10+
for (const entry of entries) {
11+
if (entry.changes.length === 0) continue;
12+
13+
issues.push(
14+
...entry.changes
15+
.filter(change => !change.version)
16+
.map(() => ({
17+
level: 'warn',
18+
message: 'Missing change version',
19+
location: {
20+
path: entry.api_doc_source,
21+
position: entry.yaml_position,
22+
},
23+
}))
24+
);
1025
}
1126

12-
return entry.changes
13-
.filter(change => !change.version)
14-
.map(() => ({
15-
level: 'warn',
16-
message: 'Missing change version',
17-
location: {
18-
path: entry.api_doc_source,
19-
position: entry.yaml_position,
20-
},
21-
}));
27+
return issues;
2228
};

src/linter/rules/missing-introduced-in.mjs

+12-10
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,24 @@ import { LINT_MESSAGES } from '../constants.mjs';
33
/**
44
* Checks if `introduced_in` field is missing
55
*
6-
* @param {ApiDocMetadataEntry} entry
6+
* @param {ApiDocMetadataEntry[]} entries
77
* @returns {Array<import('../types.d.ts').LintIssue>}
88
*/
9-
export const missingIntroducedIn = entry => {
10-
// Early return if not a top-level heading or if introduced_in exists
11-
if (entry.heading.depth !== 1 || entry.introduced_in) {
12-
return [];
13-
}
9+
export const missingIntroducedIn = entries => {
10+
const issues = [];
11+
12+
for (const entry of entries) {
13+
// Early continue if not a top-level heading or if introduced_in exists
14+
if (entry.heading.depth !== 1 || entry.introduced_in) continue;
1415

15-
return [
16-
{
16+
issues.push({
1717
level: 'info',
1818
message: LINT_MESSAGES.missingIntroducedIn,
1919
location: {
2020
path: entry.api_doc_source,
2121
},
22-
},
23-
];
22+
});
23+
}
24+
25+
return issues;
2426
};

src/linter/tests/engine.test.mjs

+5-5
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ describe('createLinterEngine', () => {
1111

1212
const engine = createLinterEngine([rule1, rule2]);
1313

14-
engine.lint(assertEntry);
14+
engine.lintAll([assertEntry]);
1515

1616
assert.strictEqual(rule1.mock.callCount(), 1);
1717
assert.strictEqual(rule2.mock.callCount(), 1);
1818

19-
assert.deepEqual(rule1.mock.calls[0].arguments, [assertEntry]);
20-
assert.deepEqual(rule2.mock.calls[0].arguments, [assertEntry]);
19+
assert.deepEqual(rule1.mock.calls[0].arguments, [[assertEntry]]);
20+
assert.deepEqual(rule2.mock.calls[0].arguments, [[assertEntry]]);
2121
});
2222

2323
it('should return the aggregated issues from all rules', () => {
@@ -26,7 +26,7 @@ describe('createLinterEngine', () => {
2626

2727
const engine = createLinterEngine([rule1, rule2]);
2828

29-
const issues = engine.lint(assertEntry);
29+
const issues = engine.lintAll([assertEntry]);
3030

3131
assert.equal(issues.length, 3);
3232
assert.deepEqual(issues, [infoIssue, warnIssue, errorIssue]);
@@ -37,7 +37,7 @@ describe('createLinterEngine', () => {
3737

3838
const engine = createLinterEngine([rule]);
3939

40-
const issues = engine.lint(assertEntry);
40+
const issues = engine.lintAll([assertEntry]);
4141

4242
assert.deepEqual(issues, []);
4343
});

0 commit comments

Comments
 (0)