diff --git a/bin/cli.mjs b/bin/cli.mjs index df1cbc4e..77c5c6e2 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -106,7 +106,7 @@ const { const linter = createLinter(lintDryRun, disableRule); const { loadFiles } = createMarkdownLoader(); -const { parseApiDocs } = createMarkdownParser(); +const { parseApiDocs } = createMarkdownParser(linter); const apiDocFiles = await loadFiles(input, ignore); diff --git a/src/constants.mjs b/src/constants.mjs index 473cf35b..6264360a 100644 --- a/src/constants.mjs +++ b/src/constants.mjs @@ -425,4 +425,9 @@ export const LINT_MESSAGES = { missingIntroducedIn: "Missing 'introduced_in' field in the API doc entry", missingChangeVersion: 'Missing version field in the API doc entry', invalidChangeVersion: 'Invalid version number: {{version}}', + malformedDeprecationHeader: 'Malformed deprecation header', + outOfOrderDeprecationCode: + "Deprecation code '{{code}}' out of order (expected {{expectedCode}})", + invalidLinterDeclaration: "Invalid linter declaration '{{declaration}}'", + malformedLinterDeclaration: 'Malformed linter declaration: {{message}}', }; diff --git a/src/generators/legacy-json/types.d.ts b/src/generators/legacy-json/types.d.ts index 347ab62d..6334dfd4 100644 --- a/src/generators/legacy-json/types.d.ts +++ b/src/generators/legacy-json/types.d.ts @@ -8,7 +8,7 @@ export interface HierarchizedEntry extends ApiDocMetadataEntry { /** * List of child entries that are part of this entry's hierarchy. */ - hierarchyChildren: ApiDocMetadataEntry[]; + hierarchyChildren: HierarchizedEntry[]; } /** diff --git a/src/generators/legacy-json/utils/buildSection.mjs b/src/generators/legacy-json/utils/buildSection.mjs index c284286b..fc788a31 100644 --- a/src/generators/legacy-json/utils/buildSection.mjs +++ b/src/generators/legacy-json/utils/buildSection.mjs @@ -1,4 +1,4 @@ -import { buildHierarchy } from './buildHierarchy.mjs'; +import { buildHierarchy } from '../../../utils/buildHierarchy.mjs'; import { getRemarkRehype } from '../../../utils/remark.mjs'; import { transformNodesToString } from '../../../utils/unist.mjs'; import { parseList } from './parseList.mjs'; diff --git a/src/linter/constants.mjs b/src/linter/constants.mjs new file mode 100644 index 00000000..e483a326 --- /dev/null +++ b/src/linter/constants.mjs @@ -0,0 +1,6 @@ +'use strict'; + +// Validates a deprecation header from doc/api/deprecation.md and captures the +// code +// For example, `DEP0001: `http.OutgoingMessage.prototype.flush` captures `0001` +export const DEPRECATION_HEADER_REGEX = /DEP(\d{4}): .+?/; diff --git a/src/linter/engine.mjs b/src/linter/engine.mjs index 41d8f3a2..8c2738df 100644 --- a/src/linter/engine.mjs +++ b/src/linter/engine.mjs @@ -10,13 +10,15 @@ const createLinterEngine = rules => { * Validates a ApiDocMetadataEntry entry against all defined rules * * @param {ApiDocMetadataEntry} entry + * @param {import('./types').LintDeclarations} + * @param declarations * @returns {import('./types').LintIssue[]} */ - const lint = entry => { + const lint = (entry, declarations) => { const issues = []; for (const rule of rules) { - const ruleIssues = rule(entry); + const ruleIssues = rule([entry], declarations); if (ruleIssues.length > 0) { issues.push(...ruleIssues); @@ -30,13 +32,19 @@ const createLinterEngine = rules => { * Validates an array of ApiDocMetadataEntry entries against all defined rules * * @param {ApiDocMetadataEntry[]} entries + * @param {import('./types').LintDeclarations} + * @param declarations * @returns {import('./types').LintIssue[]} */ - const lintAll = entries => { + const lintAll = (entries, declarations) => { const issues = []; - for (const entry of entries) { - issues.push(...lint(entry)); + for (const rule of rules) { + const ruleIssues = rule(entries, declarations); + + if (ruleIssues.length > 0) { + issues.push(...ruleIssues); + } } return issues; diff --git a/src/linter/index.mjs b/src/linter/index.mjs index 451d6ee3..10746054 100644 --- a/src/linter/index.mjs +++ b/src/linter/index.mjs @@ -1,5 +1,6 @@ 'use strict'; +import { LINT_MESSAGES } from '../constants.mjs'; import createLinterEngine from './engine.mjs'; import reporters from './reporters/index.mjs'; import rules from './rules/index.mjs'; @@ -22,6 +23,13 @@ const createLinter = (dryRun, disabledRules) => { .map(([, rule]) => rule); }; + /** + * @type {import('./types').LintDeclarations} + */ + const declarations = { + skipDeprecation: [], + }; + const engine = createLinterEngine(getEnabledRules(disabledRules)); /** @@ -37,7 +45,7 @@ const createLinter = (dryRun, disabledRules) => { * @param entries */ const lintAll = entries => { - issues.push(...engine.lintAll(entries)); + issues.push(...engine.lintAll(entries, declarations)); }; /** @@ -58,6 +66,87 @@ const createLinter = (dryRun, disabledRules) => { } }; + /** + * Parse an inline-declaration found in the markdown input + * + * @param {string} declaration + */ + const parseLinterDeclaration = declaration => { + // Trim off any excess spaces from the beginning & end + declaration = declaration.trim(); + + // Extract the name for the declaration + const [name, ...value] = declaration.split(' '); + + switch (name) { + case 'skip-deprecation': { + if (value.length !== 1) { + issues.push({ + level: 'error', + location: { + // TODO, + path: '', + position: 0, + }, + message: LINT_MESSAGES.malformedLinterDeclaration.replace( + '{{message}}', + `Expected 1 argument, got ${value.length}` + ), + }); + + break; + } + + // Get the deprecation code. This should be something like DEP0001. + const deprecation = value[0]; + + // Extract the number from the code + const deprecationCode = Number(deprecation.substring('DEP'.length)); + + // Make sure this is a valid deprecation code, output an error otherwise + if ( + deprecation.length !== 7 || + !deprecation.startsWith('DEP') || + isNaN(deprecationCode) + ) { + issues.push({ + level: 'error', + location: { + // TODO, + path: '', + position: 0, + }, + message: LINT_MESSAGES.malformedLinterDeclaration.replace( + '{{message}}', + `Invalid deprecation code ${deprecation}` + ), + }); + + break; + } + + declarations.skipDeprecation.push(deprecationCode); + + break; + } + default: { + issues.push({ + level: 'error', + location: { + // TODO + path: '', + position: 0, + }, + message: LINT_MESSAGES.invalidLinterDeclaration.replace( + '{{declaration}}', + name + ), + }); + break; + } + } + }; + /** * Checks if any error-level issues were found during linting * @@ -70,6 +159,7 @@ const createLinter = (dryRun, disabledRules) => { return { lintAll, report, + parseLinterDeclaration, hasError, }; }; diff --git a/src/linter/rules/deprecation-code-order.mjs b/src/linter/rules/deprecation-code-order.mjs new file mode 100644 index 00000000..453ebff1 --- /dev/null +++ b/src/linter/rules/deprecation-code-order.mjs @@ -0,0 +1,82 @@ +'use strict'; + +import { LINT_MESSAGES } from '../../constants.mjs'; +import { buildHierarchy } from '../../utils/buildHierarchy.mjs'; +import { DEPRECATION_HEADER_REGEX } from '../constants.mjs'; +import getDeprecationEntries from './utils/getDeprecationEntries.mjs'; + +/** + * @param {ApiDocMetadataEntry} deprecation + * @param {number} expectedCode + * @returns {Array} + */ +function lintDeprecation(deprecation, expectedCode) { + // Try validating the header (`DEPXXXX: ...`) and extract the code for us to + // look at + const match = deprecation.heading.data.text.match(DEPRECATION_HEADER_REGEX); + + if (!match) { + // Malformed header + return [ + { + level: 'error', + location: { + path: deprecation.api_doc_source, + position: deprecation.yaml_position, + }, + message: LINT_MESSAGES.malformedDeprecationHeader, + }, + ]; + } + + const code = Number(match[1]); + + return code === expectedCode + ? [] + : [ + { + level: 'error', + location: { + path: deprecation.api_doc_source, + position: deprecation.yaml_position, + }, + message: LINT_MESSAGES.outOfOrderDeprecationCode + .replaceAll('{{code}}', match[1]) + .replace('{{expectedCode}}', `${expectedCode}`.padStart(4, '0')), + }, + ]; +} + +/** + * Checks if any deprecation codes are out of order + * + * @type {import('../types').LintRule} + */ +export const deprecationCodeOrder = (entries, declarations) => { + if (entries.length === 0 || entries[0].api !== 'deprecations') { + // This is only relevant to doc/api/deprecations.md + return []; + } + + const issues = []; + + const hierarchy = buildHierarchy(entries); + + hierarchy.forEach(root => { + const deprecations = getDeprecationEntries(root.hierarchyChildren); + + let expectedCode = 1; + + for (const deprecation of deprecations || []) { + while (declarations.skipDeprecation.includes(expectedCode)) { + expectedCode++; + } + + issues.push(...lintDeprecation(deprecation, expectedCode)); + + expectedCode++; + } + }); + + return issues; +}; diff --git a/src/linter/rules/index.mjs b/src/linter/rules/index.mjs index b780eca9..ef061c26 100644 --- a/src/linter/rules/index.mjs +++ b/src/linter/rules/index.mjs @@ -1,5 +1,6 @@ 'use strict'; +import { deprecationCodeOrder } from './deprecation-code-order.mjs'; import { invalidChangeVersion } from './invalid-change-version.mjs'; import { missingChangeVersion } from './missing-change-version.mjs'; import { missingIntroducedIn } from './missing-introduced-in.mjs'; @@ -11,4 +12,5 @@ export default { 'invalid-change-version': invalidChangeVersion, 'missing-change-version': missingChangeVersion, 'missing-introduced-in': missingIntroducedIn, + 'deprecation-code-order': deprecationCodeOrder, }; diff --git a/src/linter/rules/invalid-change-version.mjs b/src/linter/rules/invalid-change-version.mjs index 779901aa..4964f47c 100644 --- a/src/linter/rules/invalid-change-version.mjs +++ b/src/linter/rules/invalid-change-version.mjs @@ -1,3 +1,5 @@ +'use strict'; + import { LINT_MESSAGES } from '../../constants.mjs'; import { valid } from 'semver'; diff --git a/src/linter/rules/missing-change-version.mjs b/src/linter/rules/missing-change-version.mjs index 67b42a19..896b3dd8 100644 --- a/src/linter/rules/missing-change-version.mjs +++ b/src/linter/rules/missing-change-version.mjs @@ -1,3 +1,5 @@ +'use strict'; + /** * Checks if any change version is missing * diff --git a/src/linter/rules/missing-introduced-in.mjs b/src/linter/rules/missing-introduced-in.mjs index 80224c9d..e0c58a34 100644 --- a/src/linter/rules/missing-introduced-in.mjs +++ b/src/linter/rules/missing-introduced-in.mjs @@ -1,3 +1,5 @@ +'use strict'; + import { LINT_MESSAGES } from '../../constants.mjs'; /** diff --git a/src/linter/rules/utils/getDeprecationEntries.mjs b/src/linter/rules/utils/getDeprecationEntries.mjs new file mode 100644 index 00000000..9e008bae --- /dev/null +++ b/src/linter/rules/utils/getDeprecationEntries.mjs @@ -0,0 +1,15 @@ +'use strict'; + +/** + * @param {Array} hierarchy + * @returns {Array | undefined} + */ +export default function getDeprecationEntries(hierarchy) { + for (const child of hierarchy) { + if (child.slug === 'list-of-deprecated-apis') { + return child.hierarchyChildren; + } + } + + return undefined; +} diff --git a/src/linter/types.d.ts b/src/linter/types.d.ts index 719b1fd8..2dba7c25 100644 --- a/src/linter/types.d.ts +++ b/src/linter/types.d.ts @@ -13,6 +13,13 @@ export interface LintIssue { location: LintIssueLocation; } -type LintRule = (input: ApiDocMetadataEntry) => LintIssue[]; +export interface LintDeclarations { + skipDeprecation: Array; +} + +type LintRule = ( + input: Array, + declarations: LintDeclarations +) => LintIssue[]; export type Reporter = (msg: LintIssue) => void; diff --git a/src/parsers/markdown.mjs b/src/parsers/markdown.mjs index 800a51b6..a76663bf 100644 --- a/src/parsers/markdown.mjs +++ b/src/parsers/markdown.mjs @@ -15,9 +15,9 @@ import { createNodeSlugger } from '../utils/slugger.mjs'; /** * Creates an API doc parser for a given Markdown API doc file * - * @param {import('./linter/index.mjs').Linter | undefined} linter + * @param {ReturnType | undefined} linter */ -const createParser = () => { +const createParser = linter => { // Creates an instance of the Remark processor with GFM support // which is used for stringifying the AST tree back to Markdown const remarkProcessor = getRemark(); @@ -142,6 +142,16 @@ const createParser = () => { addYAMLMetadata(node, apiEntryMetadata); }); + // Visits all HTML nodes from the current subtree to check for linter declarations. + // If there are, it gives them to the linter to parse and use. + visit(subTree, createQueries.UNIST.isLinterComment, node => { + if (linter) { + linter.parseLinterDeclaration( + node.value.match(createQueries.QUERIES.linterComment)[1] + ); + } + }); + // Visits all Text nodes from the current subtree and if there's any that matches // any API doc type reference and then updates the type reference to be a Markdown link visit(subTree, createQueries.UNIST.isTextWithType, (node, _, parent) => @@ -150,6 +160,7 @@ const createParser = () => { // Removes already parsed items from the subtree so that they aren't included in the final content remove(subTree, [createQueries.UNIST.isYamlNode]); + remove(subTree, [createQueries.UNIST.isLinterComment]); // Applies the AST transformations to the subtree based on the API doc entry Metadata // Note that running the transformation on the subtree isn't costly as it is a reduced tree diff --git a/src/queries.mjs b/src/queries.mjs index d160f060..ed9429a5 100644 --- a/src/queries.mjs +++ b/src/queries.mjs @@ -200,6 +200,8 @@ createQueries.QUERIES = { stabilityIndexPrefix: /Stability: ([0-5])/, // ReGeX for retrieving the inner content from a YAML block yamlInnerContent: /^/, + // ReGeX for retrieiving inline linting directives + linterComment: /^$/, }; createQueries.UNIST = { @@ -216,6 +218,12 @@ createQueries.UNIST = { */ isYamlNode: ({ type, value }) => type === 'html' && createQueries.QUERIES.yamlInnerContent.test(value), + /** + * @param {import('@types/mdast').Html} html + * @returns {boolean} + */ + isLinterComment: ({ type, value }) => + type === 'html' && createQueries.QUERIES.linterComment.test(value), /** * @param {import('@types/mdast').Text} text * @returns {boolean} diff --git a/src/generators/legacy-json/utils/buildHierarchy.mjs b/src/utils/buildHierarchy.mjs similarity index 93% rename from src/generators/legacy-json/utils/buildHierarchy.mjs rename to src/utils/buildHierarchy.mjs index ed2b4143..1f28d693 100644 --- a/src/generators/legacy-json/utils/buildHierarchy.mjs +++ b/src/utils/buildHierarchy.mjs @@ -1,10 +1,12 @@ +'use strict'; + /** * Recursively finds the most suitable parent entry for a given `entry` based on heading depth. * * @param {ApiDocMetadataEntry} entry * @param {ApiDocMetadataEntry[]} entries * @param {number} startIdx - * @returns {import('../types.d.ts').HierarchizedEntry} + * @returns {import('../generators/legacy-json/types').HierarchizedEntry} */ function findParent(entry, entries, startIdx) { // Base case: if we're at the beginning of the list, no valid parent exists. @@ -44,7 +46,7 @@ function findParent(entry, entries, startIdx) { * current index - 1. * * @param {Array} entries - * @returns {Array} + * @returns {Array} */ export function buildHierarchy(entries) { const roots = [];