diff --git a/eslint.config.mjs b/eslint.config.mjs index 11b6858e..6a2b97b6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -65,7 +65,7 @@ export default [ }, // Override rules for test files to disable JSDoc rules { - files: ['src/**/*.test.mjs'], + files: ['**/__tests__/**'], rules: { 'jsdoc/check-alignment': 'off', 'jsdoc/check-indentation': 'off', diff --git a/src/linter/constants.mjs b/src/linter/constants.mjs index 934b32d2..2c7a51ab 100644 --- a/src/linter/constants.mjs +++ b/src/linter/constants.mjs @@ -1,6 +1,6 @@ 'use strict'; -export const INTRDOCUED_IN_REGEX = //; +export const INTRODUCED_IN_REGEX = //; export const LLM_DESCRIPTION_REGEX = //; diff --git a/src/linter/rules/__tests__/duplicate-stability-nodes.test.mjs b/src/linter/rules/__tests__/duplicate-stability-nodes.test.mjs index db1d5760..38db1bad 100644 --- a/src/linter/rules/__tests__/duplicate-stability-nodes.test.mjs +++ b/src/linter/rules/__tests__/duplicate-stability-nodes.test.mjs @@ -1,8 +1,9 @@ import { deepStrictEqual, strictEqual } from 'node:assert'; -import { describe, it, mock } from 'node:test'; +import { describe, it } from 'node:test'; import { LINT_MESSAGES } from '../../constants.mjs'; import { duplicateStabilityNodes } from '../duplicate-stability-nodes.mjs'; +import { createContext } from './utils.mjs'; // Mock data structure for creating test entries const createStabilityNode = (value, line = 1, column = 1) => ({ @@ -39,15 +40,6 @@ const createHeadingNode = (depth, line = 1, column = 1) => ({ }, }); -const createContext = (nodes, path = 'file.md') => ({ - tree: { - type: 'root', - children: nodes, - }, - path, - report: mock.fn(), -}); - describe('duplicateStabilityNodes', () => { it('should not report when there are no stability nodes', () => { const context = createContext([ diff --git a/src/linter/rules/__tests__/invalid-change-version.test.mjs b/src/linter/rules/__tests__/invalid-change-version.test.mjs index bdae5c40..ce7d97a9 100644 --- a/src/linter/rules/__tests__/invalid-change-version.test.mjs +++ b/src/linter/rules/__tests__/invalid-change-version.test.mjs @@ -1,12 +1,13 @@ import { deepStrictEqual, strictEqual } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { execPath } from 'node:process'; -import { describe, it, mock } from 'node:test'; +import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; import dedent from 'dedent'; import { invalidChangeVersion } from '../invalid-change-version.mjs'; +import { createContext } from './utils.mjs'; describe('invalidChangeVersion', () => { it('should not report if all change versions are non-empty', () => { @@ -20,19 +21,12 @@ changes: - version: v5.0.0 -->`; - const context = { - tree: { - type: 'root', - children: [ - { - type: 'html', - value: yamlContent, - }, - ], + const context = createContext([ + { + type: 'html', + value: yamlContent, }, - report: mock.fn(), - getIssues: mock.fn(), - }; + ]); invalidChangeVersion(context); @@ -50,23 +44,16 @@ changes: - version: -->`; - const context = { - tree: { - type: 'root', - children: [ - { - type: 'html', - value: yamlContent, - position: { - start: { line: 1, column: 1, offset: 1 }, - end: { line: 1, column: 1, offset: 1 }, - }, - }, - ], + const context = createContext([ + { + type: 'html', + value: yamlContent, + position: { + start: { line: 1, column: 1, offset: 1 }, + end: { line: 1, column: 1, offset: 1 }, + }, }, - report: mock.fn(), - getIssues: mock.fn(), - }; + ]); invalidChangeVersion(context); @@ -126,19 +113,12 @@ changes: - version: v5.0.0 -->`; - const context = { - tree: { - type: 'root', - children: [ - { - type: 'html', - value: yamlContent, - }, - ], + const context = createContext([ + { + type: 'html', + value: yamlContent, }, - report: mock.fn(), - getIssues: mock.fn(), - }; + ]); invalidChangeVersion(context); @@ -156,23 +136,16 @@ changes: - version: v5.0.0 -->`; - 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 }, - }, - }, - ], + const context = createContext([ + { + 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); @@ -200,23 +173,16 @@ changes: - version: v5.0.0 -->`; - 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 }, - }, - }, - ], + const context = createContext([ + { + 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); diff --git a/src/linter/rules/__tests__/missing-introduced-in.test.mjs b/src/linter/rules/__tests__/missing-introduced-in.test.mjs deleted file mode 100644 index 443d2553..00000000 --- a/src/linter/rules/__tests__/missing-introduced-in.test.mjs +++ /dev/null @@ -1,83 +0,0 @@ -import { deepStrictEqual, strictEqual } from 'node:assert'; -import { describe, it, mock } from 'node:test'; - -import { missingIntroducedIn } from '../../rules/missing-introduced-in.mjs'; - -describe('missingIntroducedIn', () => { - it('should not report if the introduced_in field is not missing', () => { - const context = { - tree: { - type: 'root', - children: [ - { - type: 'html', - value: '', - }, - ], - }, - report: mock.fn(), - getIssues: mock.fn(), - }; - - missingIntroducedIn(context); - - strictEqual(context.report.mock.callCount(), 0); - }); - - it('should report an issue if the introduced_in field is missing in the first entry', () => { - const context = { - tree: { - type: 'root', - children: [ - { - type: 'heading', - depth: 2, - }, - { - type: 'html', - value: '', - }, - ], - }, - report: mock.fn(), - getIssues: mock.fn(), - }; - - missingIntroducedIn(context); - - strictEqual(context.report.mock.callCount(), 1); - - const call = context.report.mock.calls[0]; - - deepStrictEqual(call.arguments, [ - { - level: 'info', - message: "Missing 'introduced_in' field in the API doc entry", - }, - ]); - }); - - it('should report an issue if the introduced_in property is missing', () => { - const context = { - tree: { - type: 'root', - children: [], - }, - report: mock.fn(), - getIssues: mock.fn(), - }; - - missingIntroducedIn(context); - - strictEqual(context.report.mock.callCount(), 1); - - const call = context.report.mock.calls[0]; - - deepStrictEqual(call.arguments, [ - { - level: 'info', - message: "Missing 'introduced_in' field in the API doc entry", - }, - ]); - }); -}); diff --git a/src/linter/rules/__tests__/missing-metadata.test.mjs b/src/linter/rules/__tests__/missing-metadata.test.mjs new file mode 100644 index 00000000..f6cf8d28 --- /dev/null +++ b/src/linter/rules/__tests__/missing-metadata.test.mjs @@ -0,0 +1,54 @@ +import { strictEqual } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { createContext } from './utils.mjs'; +import { missingMetadata } from '../../rules/missing-metadata.mjs'; + +describe('missingMetadata', () => { + it('should not report when both fields are present', () => { + const context = createContext([ + { type: 'html', value: '' }, + { type: 'html', value: '' }, + ]); + + missingMetadata(context); + strictEqual(context.report.mock.callCount(), 0); + }); + + it('should report only llm_description when introduced_in is present', () => { + const context = createContext([ + { type: 'html', value: '' }, + ]); + + missingMetadata(context); + strictEqual(context.report.mock.callCount(), 1); + strictEqual(context.report.mock.calls[0].arguments[0].level, 'warn'); + }); + + it('should not report llm_description when paragraph fallback exists', () => { + const context = createContext([ + { type: 'html', value: '' }, + { type: 'paragraph', children: [{ type: 'text', value: 'desc' }] }, + ]); + + missingMetadata(context); + strictEqual(context.report.mock.callCount(), 0); + }); + + it('should report both when nothing is present', () => { + const context = createContext([{ type: 'heading', depth: 1 }]); + + missingMetadata(context); + strictEqual(context.report.mock.callCount(), 2); + }); + + it('should report only introduced_in when llm_description is present', () => { + const context = createContext([ + { type: 'html', value: '' }, + ]); + + missingMetadata(context); + strictEqual(context.report.mock.callCount(), 1); + strictEqual(context.report.mock.calls[0].arguments[0].level, 'info'); + }); +}); diff --git a/src/linter/rules/__tests__/utils.mjs b/src/linter/rules/__tests__/utils.mjs new file mode 100644 index 00000000..22c8d2cd --- /dev/null +++ b/src/linter/rules/__tests__/utils.mjs @@ -0,0 +1,7 @@ +import { mock } from 'node:test'; + +export const createContext = children => ({ + tree: { type: 'root', children }, + report: mock.fn(), + getIssues: mock.fn(), +}); diff --git a/src/linter/rules/index.mjs b/src/linter/rules/index.mjs index bb06a4a5..09e0e462 100644 --- a/src/linter/rules/index.mjs +++ b/src/linter/rules/index.mjs @@ -2,8 +2,7 @@ import { duplicateStabilityNodes } from './duplicate-stability-nodes.mjs'; import { invalidChangeVersion } from './invalid-change-version.mjs'; -import { missingIntroducedIn } from './missing-introduced-in.mjs'; -import { missingLlmDescription } from './missing-llm-description.mjs'; +import { missingMetadata } from './missing-metadata.mjs'; /** * @type {Record} @@ -11,6 +10,5 @@ import { missingLlmDescription } from './missing-llm-description.mjs'; export default { 'duplicate-stability-nodes': duplicateStabilityNodes, 'invalid-change-version': invalidChangeVersion, - 'missing-introduced-in': missingIntroducedIn, - 'missing-llm-description': missingLlmDescription, + 'missing-metadata': missingMetadata, }; diff --git a/src/linter/rules/missing-introduced-in.mjs b/src/linter/rules/missing-introduced-in.mjs deleted file mode 100644 index 1353e39e..00000000 --- a/src/linter/rules/missing-introduced-in.mjs +++ /dev/null @@ -1,26 +0,0 @@ -'use strict'; - -import { INTRDOCUED_IN_REGEX, LINT_MESSAGES } from '../constants.mjs'; -import { findTopLevelEntry } from '../utils/find.mjs'; - -/** - * Checks if `introduced_in` field is missing in the top-level entry. - * - * @param {import('../types.d.ts').LintContext} context - * @returns {void} - */ -export const missingIntroducedIn = context => { - const introducedIn = findTopLevelEntry( - context.tree, - node => node.type === 'html' && INTRDOCUED_IN_REGEX.test(node.value) - ); - - if (introducedIn) { - return; - } - - return context.report({ - level: 'info', - message: LINT_MESSAGES.missingIntroducedIn, - }); -}; diff --git a/src/linter/rules/missing-llm-description.mjs b/src/linter/rules/missing-llm-description.mjs deleted file mode 100644 index ec3bb1c4..00000000 --- a/src/linter/rules/missing-llm-description.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import { LINT_MESSAGES, LLM_DESCRIPTION_REGEX } from '../constants.mjs'; -import { findTopLevelEntry } from '../utils/find.mjs'; - -/** - * Checks if a top-level entry is missing a llm_description field or a paragraph - * node. - * - * @param {import('../types.d.ts').LintContext} context - * @returns {void} - */ -export const missingLlmDescription = context => { - const llmDescription = findTopLevelEntry( - context.tree, - node => node.type === 'html' && LLM_DESCRIPTION_REGEX.test(node.value) - ); - - if (llmDescription) { - return; - } - - // Check if there is a paragraph node in the top-level entry that can be used - // as fallback for llm_description - const paragraph = findTopLevelEntry( - context.tree, - node => node.type === 'paragraph' - ); - - if (paragraph) { - return; - } - - context.report({ - level: 'warn', - message: LINT_MESSAGES.missingLlmDescription, - }); -}; diff --git a/src/linter/rules/missing-metadata.mjs b/src/linter/rules/missing-metadata.mjs new file mode 100644 index 00000000..f07e3366 --- /dev/null +++ b/src/linter/rules/missing-metadata.mjs @@ -0,0 +1,77 @@ +'use strict'; + +import { find } from 'unist-util-find'; +import { findBefore } from 'unist-util-find-before'; + +import { + INTRODUCED_IN_REGEX, + LINT_MESSAGES, + LLM_DESCRIPTION_REGEX, +} from '../constants.mjs'; + +/** + * Finds the first node that matches the condition before the first h2 heading, + * this area is considered the top-level section of the tree + * + * @param {import('mdast').Node} node + * @param {import('unist-util-find').TestFn} condition + */ +const findTopLevelEntry = (node, condition) => { + const h2 = find(node, { type: 'heading', depth: 2 }); + return h2 ? findBefore(node, h2, condition) : find(node, condition); +}; + +// Simplified metadata checks - llmDescription can fall back to paragraph +const METADATA_CHECKS = Object.freeze([ + { + name: 'introducedIn', + regex: INTRODUCED_IN_REGEX, + level: 'info', + message: LINT_MESSAGES.missingIntroducedIn, + }, + { + name: 'llmDescription', + regex: LLM_DESCRIPTION_REGEX, + level: 'warn', + message: LINT_MESSAGES.missingLlmDescription, + }, +]); + +/** + * Checks if required metadata fields are missing in the top-level entry. + * + * @param {import('../types.d.ts').LintContext} context + * @returns {void} + */ +export const missingMetadata = context => { + const foundMetadata = new Set(); + let hasParagraph = false; + + findTopLevelEntry(context.tree, node => { + if (node.type === 'html') { + for (const check of METADATA_CHECKS) { + if (check.regex?.test(node.value)) { + foundMetadata.add(check.name); + } + } + } else if (node.type === 'paragraph') { + hasParagraph = true; + } + return false; // Continue searching + }); + + // Report missing metadata + for (const check of METADATA_CHECKS) { + if (!foundMetadata.has(check.name)) { + // The first paragraph can also be the LLM description. + if (check.name === 'llmDescription' && hasParagraph) { + continue; + } + + context.report({ + level: check.level, + message: check.message, + }); + } + } +}; diff --git a/src/linter/utils/find.mjs b/src/linter/utils/find.mjs deleted file mode 100644 index 3da03e53..00000000 --- a/src/linter/utils/find.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { find } from 'unist-util-find'; -import { findBefore } from 'unist-util-find-before'; - -/** - * Finds the first node that matches the condition before the first h2 heading, - * this area is considered the top-level section of the tree - * - * @param {import('mdast').Node} node - * @param {import('unist-util-find').TestFn} condition - */ -export const findTopLevelEntry = (node, condition) => { - const h2 = find(node, { type: 'heading', depth: 2 }); - - // If there isn't h2, search the entire tree - if (!h2) { - return find(node, condition); - } - - return findBefore(node, h2, condition); -};