From a669d09bc2b29fb33c76bfe02c3a9ee1df240866 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Fri, 21 Mar 2025 11:30:49 -0400 Subject: [PATCH 1/8] feat: update stability css --- src/generators/legacy-html/assets/style.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/generators/legacy-html/assets/style.css b/src/generators/legacy-html/assets/style.css index f2fcb3a..7c6d8f2 100644 --- a/src/generators/legacy-html/assets/style.css +++ b/src/generators/legacy-html/assets/style.css @@ -318,6 +318,10 @@ hr.line { margin: 0 0 1rem; padding: 1rem; line-height: 1.5; + + position: sticky; + top: 3rem; + z-index: 1; } .api_stability p:first-of-type { @@ -869,6 +873,10 @@ kbd { position: relative; top: 0; } + + .api_stability { + top: 0; + } } @media not screen, (height <= 1000px) { From ca33c9f946192e4e66e2d55e3a015d4deaed8aa1 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Fri, 21 Mar 2025 14:49:12 -0400 Subject: [PATCH 2/8] add stability linting --- bin/cli.mjs | 7 +- src/constants.mjs | 420 ++++++++++++++++++ src/linter/engine.mjs | 13 +- src/linter/index.mjs | 11 +- .../rules/duplicate-stability-nodes.mjs | 38 ++ src/linter/rules/index.mjs | 12 +- src/linter/types.d.ts | 4 +- 7 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 src/linter/rules/duplicate-stability-nodes.mjs diff --git a/bin/cli.mjs b/bin/cli.mjs index 27c3d06..9f35e47 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -15,6 +15,9 @@ import rules from '../src/linter/rules/index.mjs'; import createMarkdownLoader from '../src/loaders/markdown.mjs'; import createMarkdownParser from '../src/parsers/markdown.mjs'; import createNodeReleases from '../src/releases.mjs'; +import createLinter from '../src/linter/index.mjs'; +import reporters from '../src/linter/reporters/index.mjs'; +import rules from '../src/linter/rules/index.mjs'; const availableGenerators = Object.keys(generators); @@ -61,7 +64,9 @@ program ) .addOption( new Option('--disable-rule [rule...]', 'Disable a specific linter rule') - .choices(Object.keys(rules)) + .choices( + Object.keys(multiEntryRules).concat(Object.keys(singleEntryRules)) + ) .default([]) ) .addOption( diff --git a/src/constants.mjs b/src/constants.mjs index 040a8c1..473cf35 100644 --- a/src/constants.mjs +++ b/src/constants.mjs @@ -6,3 +6,423 @@ export const DOC_NODE_VERSION = process.version; // This is the Node.js CHANGELOG to be consumed to generate a list of all major Node.js versions export const DOC_NODE_CHANGELOG_URL = 'https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md'; + +// This is the Node.js Base URL for viewing a file within GitHub UI +export const DOC_NODE_BLOB_BASE_URL = + 'https://github.com/nodejs/node/blob/HEAD/'; + +// This is the Node.js API docs base URL for editing a file on GitHub UI +export const DOC_API_BLOB_EDIT_BASE_URL = + 'https://github.com/nodejs/node/edit/main/doc/api/'; + +// Base URL for a specific Node.js version within the Node.js API docs +export const DOC_API_BASE_URL_VERSION = 'https://nodejs.org/docs/latest-v'; + +// This is the perma-link within the API docs that reference the Stability Index +export const DOC_API_STABILITY_SECTION_REF_URL = + 'documentation.html#stability-index'; + +// This is the base URL of the MDN Web documentation +export const DOC_MDN_BASE_URL = 'https://developer.mozilla.org/en-US/docs/Web/'; + +// This is the base URL for the MDN JavaScript documentation +export const DOC_MDN_BASE_URL_JS = `${DOC_MDN_BASE_URL}JavaScript/`; + +// This is the base URL for the MDN JavaScript primitives documentation +export const DOC_MDN_BASE_URL_JS_PRIMITIVES = `${DOC_MDN_BASE_URL_JS}Data_structures`; + +// This is the base URL for the MDN JavaScript global objects documentation +export const DOC_MDN_BASE_URL_JS_GLOBALS = `${DOC_MDN_BASE_URL_JS}Reference/Global_Objects/`; + +// These are YAML keys from the Markdown YAML metadata that should be +// removed and appended to the `update` key +export const DOC_API_YAML_KEYS_UPDATE = [ + 'added', + 'removed', + 'deprecated', + 'introduced_in', + 'napiVersion', +]; + +// These are string replacements specific to Node.js API docs for anchor IDs +export const DOC_API_SLUGS_REPLACEMENTS = [ + { from: /node.js/i, to: 'nodejs' }, // Replace Node.js + { from: /&/, to: '-and-' }, // Replace & + { from: /[/_,:;\\ ]/g, to: '-' }, // Replace /_,:;\. and whitespace + { from: /--+/g, to: '-' }, // Replace multiple hyphens with single + { from: /^-/, to: '' }, // Remove any leading hyphen + { from: /-$/, to: '' }, // Remove any trailing hyphen +]; + +// These are regular expressions used to determine if a given Markdown heading +// is a specific type of API Doc entry (e.g., Event, Class, Method, etc) +// and to extract the inner content of said Heading to be used as the API doc entry name +export const DOC_API_HEADING_TYPES = [ + { + type: 'method', + regex: + // Group 1: foo[bar]() + // Group 2: foo.bar() + // Group 3: foobar() + /^`?(?:\w*(?:(\[[^\]]+\])|(?:\.(\w+)))|(\w+))\([^)]*\)`?$/i, + }, + { type: 'event', regex: /^Event: +`?['"]?([^'"]+)['"]?`?$/i }, + { + type: 'class', + regex: + /^Class: +`?([A-Z]\w+(?:\.[A-Z]\w+)*(?: +extends +[A-Z]\w+(?:\.[A-Z]\w+)*)?)`?$/i, + }, + { + type: 'ctor', + regex: /^(?:Constructor: +)?`?new +([A-Z]\w+(?:\.[A-Z]\w+)*)\([^)]*\)`?$/i, + }, + { + type: 'classMethod', + regex: + /^Static method: +`?[A-Z]\w+(?:\.[A-Z]\w+)*(?:(\[\w+\.\w+\])|\.(\w+))\([^)]*\)`?$/i, + }, + { + type: 'property', + regex: + /^(?:Class property: +)?`?[A-Z]\w+(?:\.[A-Z]\w+)*(?:(\[\w+\.\w+\])|\.(\w+))`?$/i, + }, +]; + +// This is a mapping for the `API` updates within the Markdown content and their respective +// content that should be mapping into `changes` property for better mapping on HTML +export const DOC_API_UPDATE_MAPPING = { + added: 'Added in', + removed: 'Removed in', + deprecated: 'Deprecated since', + introduced_in: 'Introduced in', + napiVersion: 'N-API Version', +}; + +// This is a mapping for types within the Markdown content and their respective +// JavaScript primitive types within the MDN JavaScript docs +// @see DOC_MDN_BASE_URL_JS_PRIMITIVES +export const DOC_TYPES_MAPPING_PRIMITIVES = { + boolean: 'Boolean', + integer: 'Number', // Not a primitive, used for clarification. + null: 'Null', + number: 'Number', + string: 'String', + symbol: 'Symbol', + undefined: 'Undefined', +}; + +// https://github.com/nodejs/node/blob/main/doc/api/cli.md#options +// This slug should reference the section where the available +// options are defined. +export const DOC_SLUG_OPTIONS = 'options'; + +// https://github.com/nodejs/node/blob/main/doc/api/cli.md#environment-variables-1 +// This slug should reference the section where the available +// environment variables are defined. +export const DOC_SLUG_ENVIRONMENT = 'environment-variables-1'; + +// This is a mapping for types within the Markdown content and their respective +// JavaScript globals types within the MDN JavaScript docs +// @see DOC_MDN_BASE_URL_JS_GLOBALS +export const DOC_TYPES_MAPPING_GLOBALS = { + ...Object.fromEntries( + [ + 'AggregateError', + 'Array', + 'ArrayBuffer', + 'DataView', + 'Date', + 'Error', + 'EvalError', + 'Function', + 'Map', + 'NaN', + 'Object', + 'Promise', + 'Proxy', + 'RangeError', + 'ReferenceError', + 'RegExp', + 'Set', + 'SharedArrayBuffer', + 'SyntaxError', + 'Symbol', + 'TypeError', + 'URIError', + 'WeakMap', + 'WeakSet', + + 'TypedArray', + 'Float32Array', + 'Float64Array', + 'Int8Array', + 'Int16Array', + 'Int32Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Uint16Array', + 'Uint32Array', + ].map(e => [e, e]) + ), + bigint: 'BigInt', + 'WebAssembly.Instance': 'WebAssembly/Instance', +}; + +// This is a mapping for types within the Markdown content and their respective +// Node.js types within the Node.js API docs (refers to a different API doc page) +// Note: These hashes are generated with the GitHub Slugger +export const DOC_TYPES_MAPPING_NODE_MODULES = { + AbortController: 'globals.html#class-abortcontroller', + AbortSignal: 'globals.html#class-abortsignal', + + AlgorithmIdentifier: 'webcrypto.html#class-algorithmidentifier', + AsyncHook: 'async_hooks.html#async_hookscreatehookcallbacks', + AsyncLocalStorage: 'async_context.html#class-asynclocalstorage', + AsyncResource: 'async_hooks.html#class-asyncresource', + + AesCbcParams: 'webcrypto.html#class-aescbcparams', + AesCtrParams: 'webcrypto.html#class-aesctrparams', + AesGcmParams: 'webcrypto.html#class-aesgcmparams', + AesKeyGenParams: 'webcrypto.html#class-aeskeygenparams', + + Blob: 'buffer.html#class-blob', + BroadcastChannel: + 'worker_threads.html#class-broadcastchannel-extends-eventtarget', + Buffer: 'buffer.html#class-buffer', + + ByteLengthQueuingStrategy: 'webstreams.html#class-bytelengthqueuingstrategy', + + Channel: 'diagnostics_channel.html#class-channel', + ChildProcess: 'child_process.html#class-childprocess', + Cipher: 'crypto.html#class-cipher', + ClientHttp2Session: 'http2.html#class-clienthttp2session', + ClientHttp2Stream: 'http2.html#class-clienthttp2stream', + + CountQueuingStrategy: 'webstreams.html#class-countqueuingstrategy', + + Crypto: 'webcrypto.html#class-crypto', + CryptoKey: 'webcrypto.html#class-cryptokey', + CryptoKeyPair: 'webcrypto.html#class-cryptokeypair', + + CustomEvent: 'events.html#class-customevent', + + Decipher: 'crypto.html#class-decipher', + DiffieHellman: 'crypto.html#class-diffiehellman', + DiffieHellmanGroup: 'crypto.html#class-diffiehellmangroup', + Domain: 'domain.html#class-domain', + + Duplex: 'stream.html#class-streamduplex', + + ECDH: 'crypto.html#class-ecdh', + EcdhKeyDeriveParams: 'webcrypto.html#class-ecdhkeyderiveparams', + EcdsaParams: 'webcrypto.html#class-ecdsaparams', + EcKeyGenParams: 'webcrypto.html#class-eckeygenparams', + EcKeyImportParams: 'webcrypto.html#class-eckeyimportparams', + Ed448Params: 'webcrypto.html#class-ed448params', + + Event: 'events.html#class-event', + EventEmitter: 'events.html#class-eventemitter', + EventListener: 'events.html#event-listener', + EventTarget: 'events.html#class-eventtarget', + + File: 'buffer.html#class-file', + FileHandle: 'fs.html#class-filehandle', + + Handle: 'net.html#serverlistenhandle-backlog-callback', + Hash: 'crypto.html#class-hash', + Histogram: 'perf_hooks.html#class-histogram', + HkdfParams: 'webcrypto.html#class-hkdfparams', + Hmac: 'crypto.html#class-hmac', + HmacImportParams: 'webcrypto.html#class-hmacimportparams', + HmacKeyGenParams: 'webcrypto.html#class-hmackeygenparams', + + Http2SecureServer: 'http2.html#class-http2secureserver', + Http2Server: 'http2.html#class-http2server', + Http2Session: 'http2.html#class-http2session', + Http2Stream: 'http2.html#class-http2stream', + + Immediate: 'timers.html#class-immediate', + + IntervalHistogram: + 'perf_hooks.html#class-intervalhistogram-extends-histogram', + + KeyObject: 'crypto.html#class-keyobject', + + MIMEParams: 'util.html#class-utilmimeparams', + MessagePort: 'worker_threads.html#class-messageport', + + MockModuleContext: 'test.html#class-mockmodulecontext', + + NodeEventTarget: 'events.html#class-nodeeventtarget', + + Pbkdf2Params: 'webcrypto.html#class-pbkdf2params', + PerformanceEntry: 'perf_hooks.html#class-performanceentry', + PerformanceNodeTiming: 'perf_hooks.html#class-performancenodetiming', + PerformanceObserver: 'perf_hooks.html#class-performanceobserver', + PerformanceObserverEntryList: + 'perf_hooks.html#class-performanceobserverentrylist', + + Readable: 'stream.html#class-streamreadable', + ReadableByteStreamController: + 'webstreams.html#class-readablebytestreamcontroller', + ReadableStream: 'webstreams.html#class-readablestream', + ReadableStreamBYOBReader: 'webstreams.html#class-readablestreambyobreader', + ReadableStreamBYOBRequest: 'webstreams.html#class-readablestreambyobrequest', + ReadableStreamDefaultController: + 'webstreams.html#class-readablestreamdefaultcontroller', + ReadableStreamDefaultReader: + 'webstreams.html#class-readablestreamdefaultreader', + + RecordableHistogram: + 'perf_hooks.html#class-recordablehistogram-extends-histogram', + + RsaHashedImportParams: 'webcrypto.html#class-rsahashedimportparams', + RsaHashedKeyGenParams: 'webcrypto.html#class-rsahashedkeygenparams', + RsaOaepParams: 'webcrypto.html#class-rsaoaepparams', + RsaPssParams: 'webcrypto.html#class-rsapssparams', + + ServerHttp2Session: 'http2.html#class-serverhttp2session', + ServerHttp2Stream: 'http2.html#class-serverhttp2stream', + + Sign: 'crypto.html#class-sign', + + StatementSync: 'sqlite.html#class-statementsync', + + Stream: 'stream.html#stream', + + SubtleCrypto: 'webcrypto.html#class-subtlecrypto', + + TestsStream: 'test.html#class-testsstream', + + TextDecoderStream: 'webstreams.html#class-textdecoderstream', + TextEncoderStream: 'webstreams.html#class-textencoderstream', + + Timeout: 'timers.html#class-timeout', + Timer: 'timers.html#timers', + + Tracing: 'tracing.html#tracing-object', + TracingChannel: 'diagnostics_channel.html#class-tracingchannel', + + Transform: 'stream.html#class-streamtransform', + TransformStream: 'webstreams.html#class-transformstream', + TransformStreamDefaultController: + 'webstreams.html#class-transformstreamdefaultcontroller', + + URL: 'url.html#the-whatwg-url-api', + URLSearchParams: 'url.html#class-urlsearchparams', + + Verify: 'crypto.html#class-verify', + + Writable: 'stream.html#class-streamwritable', + WritableStream: 'webstreams.html#class-writablestream', + WritableStreamDefaultController: + 'webstreams.html#class-writablestreamdefaultcontroller', + WritableStreamDefaultWriter: + 'webstreams.html#class-writablestreamdefaultwriter', + + Worker: 'worker_threads.html#class-worker', + + X509Certificate: 'crypto.html#class-x509certificate', + + 'brotli options': 'zlib.html#class-brotlioptions', + + 'cluster.Worker': 'cluster.html#class-worker', + + 'crypto.constants': 'crypto.html#cryptoconstants', + + 'dgram.Socket': 'dgram.html#class-dgramsocket', + + 'errors.Error': 'errors.html#class-error', + + 'fs.Dir': 'fs.html#class-fsdir', + 'fs.Dirent': 'fs.html#class-fsdirent', + 'fs.FSWatcher': 'fs.html#class-fsfswatcher', + 'fs.ReadStream': 'fs.html#class-fsreadstream', + 'fs.StatFs': 'fs.html#class-fsstatfs', + 'fs.Stats': 'fs.html#class-fsstats', + 'fs.StatWatcher': 'fs.html#class-fsstatwatcher', + 'fs.WriteStream': 'fs.html#class-fswritestream', + + 'http.Agent': 'http.html#class-httpagent', + 'http.ClientRequest': 'http.html#class-httpclientrequest', + 'http.IncomingMessage': 'http.html#class-httpincomingmessage', + 'http.OutgoingMessage': 'http.html#class-httpoutgoingmessage', + 'http.Server': 'http.html#class-httpserver', + 'http.ServerResponse': 'http.html#class-httpserverresponse', + + 'http2.Http2ServerRequest': 'http2.html#class-http2http2serverrequest', + 'http2.Http2ServerResponse': 'http2.html#class-http2http2serverresponse', + + 'import.meta': 'esm.html#importmeta', + + 'module.SourceMap': 'module.html#class-modulesourcemap', + + 'net.BlockList': 'net.html#class-netblocklist', + 'net.Server': 'net.html#class-netserver', + 'net.Socket': 'net.html#class-netsocket', + 'net.SocketAddress': 'net.html#class-netsocketaddress', + + 'os.constants.dlopen': 'os.html#dlopen-constants', + + 'readline.Interface': 'readline.html#class-readlineinterface', + 'readline.InterfaceConstructor': 'readline.html#class-interfaceconstructor', + 'readlinePromises.Interface': 'readline.html#class-readlinepromisesinterface', + + 'repl.REPLServer': 'repl.html#class-replserver', + + require: 'modules.html#requireid', + + 'stream.Duplex': 'stream.html#class-streamduplex', + 'stream.Readable': 'stream.html#class-streamreadable', + 'stream.Transform': 'stream.html#class-streamtransform', + 'stream.Writable': 'stream.html#class-streamwritable', + + 'tls.SecureContext': 'tls.html#tlscreatesecurecontextoptions', + 'tls.Server': 'tls.html#class-tlsserver', + 'tls.TLSSocket': 'tls.html#class-tlstlssocket', + + 'tty.ReadStream': 'tty.html#class-ttyreadstream', + 'tty.WriteStream': 'tty.html#class-ttywritestream', + + 'vm.Module': 'vm.html#class-vmmodule', + 'vm.Script': 'vm.html#class-vmscript', + 'vm.SourceTextModule': 'vm.html#class-vmsourcetextmodule', + 'vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER': + 'vm.html#vmconstantsuse_main_context_default_loader', + + 'zlib options': 'zlib.html#class-options', +}; + +// This is a mapping for miscellaneous types within the Markdown content and their respective +// external reference on appropriate 3rd-party vendors/documentation sites. +export const DOC_TYPES_MAPPING_OTHER = { + any: `${DOC_MDN_BASE_URL_JS_PRIMITIVES}#Data_types`, + this: `${DOC_MDN_BASE_URL_JS}Reference/Operators/this`, + + ArrayBufferView: + 'https://developer.mozilla.org/en-US/docs/Web/API/ArrayBufferView', + + AsyncIterator: 'https://tc39.github.io/ecma262/#sec-asynciterator-interface', + AsyncIterable: 'https://tc39.github.io/ecma262/#sec-asynciterable-interface', + AsyncFunction: 'https://tc39.es/ecma262/#sec-async-function-constructor', + + 'Module Namespace Object': + 'https://tc39.github.io/ecma262/#sec-module-namespace-exotic-objects', + + AsyncGeneratorFunction: + 'https://tc39.es/proposal-async-iteration/#sec-asyncgeneratorfunction-constructor', + + Iterable: `${DOC_MDN_BASE_URL_JS}Reference/Iteration_protocols#The_iterable_protocol`, + Iterator: `${DOC_MDN_BASE_URL_JS}Reference/Iteration_protocols#The_iterator_protocol`, + + FormData: `${DOC_MDN_BASE_URL}API/FormData`, + Headers: `${DOC_MDN_BASE_URL}/API/Headers`, + Response: `${DOC_MDN_BASE_URL}/API/Response`, + Request: `${DOC_MDN_BASE_URL}/API/Request`, +}; + +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}}', +}; diff --git a/src/linter/engine.mjs b/src/linter/engine.mjs index 41d8f3a..619eba3 100644 --- a/src/linter/engine.mjs +++ b/src/linter/engine.mjs @@ -3,9 +3,12 @@ /** * Creates a linter engine instance to validate ApiDocMetadataEntry entries * - * @param {import('./types').LintRule} rules Lint rules to validate the entries against + * @param {{ + * multiEntryRules: import('./types').MultipleEntriesLintRules[] + * singleEntryRules: import('./types').SingleEntryLintRule[] + * }} rules Lint rules to validate the entries against */ -const createLinterEngine = rules => { +const createLinterEngine = ({ multiEntryRules, singleEntryRules }) => { /** * Validates a ApiDocMetadataEntry entry against all defined rules * @@ -15,7 +18,7 @@ const createLinterEngine = rules => { const lint = entry => { const issues = []; - for (const rule of rules) { + for (const rule of singleEntryRules) { const ruleIssues = rule(entry); if (ruleIssues.length > 0) { @@ -35,6 +38,10 @@ const createLinterEngine = rules => { const lintAll = entries => { const issues = []; + for (const rule of multiEntryRules) { + issues.push(...rule(entries)); + } + for (const entry of entries) { issues.push(...lint(entry)); } diff --git a/src/linter/index.mjs b/src/linter/index.mjs index 451d6ee..3364db8 100644 --- a/src/linter/index.mjs +++ b/src/linter/index.mjs @@ -2,7 +2,7 @@ import createLinterEngine from './engine.mjs'; import reporters from './reporters/index.mjs'; -import rules from './rules/index.mjs'; +import { multiEntryRules, singleEntryRules } from './rules/index.mjs'; /** * Creates a linter instance to validate ApiDocMetadataEntry entries @@ -13,16 +13,19 @@ import rules from './rules/index.mjs'; const createLinter = (dryRun, disabledRules) => { /** * Retrieves all enabled rules - * + * @param {Record} rules * @returns {import('./types').LintRule[]} */ - const getEnabledRules = () => { + const getEnabledRules = rules => { return Object.entries(rules) .filter(([ruleName]) => !disabledRules.includes(ruleName)) .map(([, rule]) => rule); }; - const engine = createLinterEngine(getEnabledRules(disabledRules)); + const engine = createLinterEngine({ + multiEntryRules: getEnabledRules(multiEntryRules), + singleEntryRules: getEnabledRules(singleEntryRules), + }); /** * Lint issues found during validations diff --git a/src/linter/rules/duplicate-stability-nodes.mjs b/src/linter/rules/duplicate-stability-nodes.mjs new file mode 100644 index 0000000..bd4d163 --- /dev/null +++ b/src/linter/rules/duplicate-stability-nodes.mjs @@ -0,0 +1,38 @@ +import { LINT_MESSAGES } from '../../constants.mjs'; + +/** + * Checks if there are multiple stability nodes within a chain. + * + * @param {ApiDocMetadataEntry[]} entries + * @returns {Array} + */ +export const duplicateStabilityNodes = entries => { + const issues = []; + let currentDepth = 0; + let currentStability = -1; + + for (const entry of entries) { + const { depth } = entry.heading.data; + const entryStability = entry.stability.children[0]?.data.index ?? -1; + + if ( + depth > currentDepth && + entryStability >= 0 && + entryStability === currentStability + ) { + issues.push({ + level: 'warn', + message: LINT_MESSAGES.duplicateStabilityNode, + location: { + path: entry.api_doc_source, + position: entry.yaml_position, + }, + }); + } else { + currentDepth = depth; + currentStability = entryStability; + } + } + + return issues; +}; diff --git a/src/linter/rules/index.mjs b/src/linter/rules/index.mjs index b780eca..97c6dad 100644 --- a/src/linter/rules/index.mjs +++ b/src/linter/rules/index.mjs @@ -1,14 +1,22 @@ 'use strict'; +import { duplicateStabilityNodes } from './duplicate-stability-nodes.mjs'; import { invalidChangeVersion } from './invalid-change-version.mjs'; import { missingChangeVersion } from './missing-change-version.mjs'; import { missingIntroducedIn } from './missing-introduced-in.mjs'; /** - * @type {Record} + * @type {Record} */ -export default { +export const singleEntryRules = { 'invalid-change-version': invalidChangeVersion, 'missing-change-version': missingChangeVersion, 'missing-introduced-in': missingIntroducedIn, }; + +/** + * @type {Record} + */ +export const multiEntryRules = { + 'duplicate-stability-nodes': duplicateStabilityNodes, +}; diff --git a/src/linter/types.d.ts b/src/linter/types.d.ts index 719b1fd..b775520 100644 --- a/src/linter/types.d.ts +++ b/src/linter/types.d.ts @@ -13,6 +13,8 @@ export interface LintIssue { location: LintIssueLocation; } -type LintRule = (input: ApiDocMetadataEntry) => LintIssue[]; +type LintRule = MultipleEntriesLintRules | SingleEntryLintRule; +type MultipleEntriesLintRules = (input: ApiDocMetadataEntry[]) => LintIssue[]; +type SingleEntryLintRule = (input: ApiDocMetadataEntry) => LintIssue[]; export type Reporter = (msg: LintIssue) => void; From 8f7cd78cccd23374fef6586fdc05f7ee9caf8d15 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Fri, 21 Mar 2025 21:24:45 -0400 Subject: [PATCH 3/8] Update style.css --- src/generators/legacy-html/assets/style.css | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/generators/legacy-html/assets/style.css b/src/generators/legacy-html/assets/style.css index 7c6d8f2..f2fcb3a 100644 --- a/src/generators/legacy-html/assets/style.css +++ b/src/generators/legacy-html/assets/style.css @@ -318,10 +318,6 @@ hr.line { margin: 0 0 1rem; padding: 1rem; line-height: 1.5; - - position: sticky; - top: 3rem; - z-index: 1; } .api_stability p:first-of-type { @@ -873,10 +869,6 @@ kbd { position: relative; top: 0; } - - .api_stability { - top: 0; - } } @media not screen, (height <= 1000px) { From 6cefedd6b28768690c5291e7efe71e0daccc2156 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sat, 22 Mar 2025 09:50:40 -0400 Subject: [PATCH 4/8] add tests --- src/linter/tests/engine.test.mjs | 20 ++- .../rules/duplicate-stability-nodes.test.mjs | 155 ++++++++++++++++++ 2 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 src/linter/tests/rules/duplicate-stability-nodes.test.mjs diff --git a/src/linter/tests/engine.test.mjs b/src/linter/tests/engine.test.mjs index cbbd1c5..dc1c705 100644 --- a/src/linter/tests/engine.test.mjs +++ b/src/linter/tests/engine.test.mjs @@ -9,33 +9,39 @@ describe('createLinterEngine', () => { const rule1 = mock.fn(() => []); const rule2 = mock.fn(() => []); - const engine = createLinterEngine([rule1, rule2]); + const engine = createLinterEngine({ + singleEntryRules: [rule1], + multiEntryRules: [rule2], + }); - engine.lint(assertEntry); + engine.lintAll([assertEntry]); assert.strictEqual(rule1.mock.callCount(), 1); assert.strictEqual(rule2.mock.callCount(), 1); assert.deepEqual(rule1.mock.calls[0].arguments, [assertEntry]); - assert.deepEqual(rule2.mock.calls[0].arguments, [assertEntry]); + assert.deepEqual(rule2.mock.calls[0].arguments, [[assertEntry]]); }); it('should return the aggregated issues from all rules', () => { const rule1 = mock.fn(() => [infoIssue, warnIssue]); const rule2 = mock.fn(() => [errorIssue]); - const engine = createLinterEngine([rule1, rule2]); + const engine = createLinterEngine({ + singleEntryRules: [rule1], + multiEntryRules: [rule2], + }); - const issues = engine.lint(assertEntry); + const issues = engine.lintAll([assertEntry]); assert.equal(issues.length, 3); - assert.deepEqual(issues, [infoIssue, warnIssue, errorIssue]); + assert.deepEqual(issues, [errorIssue, infoIssue, warnIssue]); }); it('should return an empty array when no issues are found', () => { const rule = () => []; - const engine = createLinterEngine([rule]); + const engine = createLinterEngine({ singleEntryRules: [rule] }); const issues = engine.lint(assertEntry); diff --git a/src/linter/tests/rules/duplicate-stability-nodes.test.mjs b/src/linter/tests/rules/duplicate-stability-nodes.test.mjs new file mode 100644 index 0000000..828ffa1 --- /dev/null +++ b/src/linter/tests/rules/duplicate-stability-nodes.test.mjs @@ -0,0 +1,155 @@ +import { describe, it } from 'node:test'; +import { deepStrictEqual } from 'assert'; +import { duplicateStabilityNodes } from '../../rules/duplicate-stability-nodes.mjs'; +import { LINT_MESSAGES } from '../../../constants.mjs'; + +// Mock data structure for creating test entries +const createEntry = ( + depth, + stabilityIndex, + source = 'file.yaml', + position = { line: 1, column: 1 } +) => ({ + heading: { data: { depth } }, + stability: { children: [{ data: { index: stabilityIndex } }] }, + api_doc_source: source, + yaml_position: position, +}); + +describe('duplicateStabilityNodes', () => { + it('returns empty array when there are no entries', () => { + deepStrictEqual(duplicateStabilityNodes([]), []); + }); + + it('returns empty array when there are no duplicate stability nodes', () => { + const entries = [createEntry(1, 0), createEntry(2, 1), createEntry(3, 2)]; + deepStrictEqual(duplicateStabilityNodes(entries), []); + }); + + it('detects duplicate stability nodes within a chain', () => { + const entries = [ + createEntry(1, 0), + createEntry(2, 0), // Duplicate stability node + ]; + + deepStrictEqual(duplicateStabilityNodes(entries), [ + { + level: 'warn', + message: LINT_MESSAGES.duplicateStabilityNode, + location: { + path: 'file.yaml', + position: { line: 1, column: 1 }, + }, + }, + ]); + }); + + it('resets stability tracking when depth decreases', () => { + const entries = [ + createEntry(1, 0), + createEntry(2, 0), // This should trigger an issue + createEntry(1, 1), + createEntry(2, 1), // This should trigger another issue + ]; + + deepStrictEqual(duplicateStabilityNodes(entries), [ + { + level: 'warn', + message: LINT_MESSAGES.duplicateStabilityNode, + location: { + path: 'file.yaml', + position: { line: 1, column: 1 }, + }, + }, + { + level: 'warn', + message: LINT_MESSAGES.duplicateStabilityNode, + location: { + path: 'file.yaml', + position: { line: 1, column: 1 }, + }, + }, + ]); + }); + + it('handles missing stability nodes gracefully', () => { + const entries = [ + createEntry(1, -1), + createEntry(2, -1), + createEntry(3, 0), + createEntry(4, 0), // This should trigger an issue + ]; + + deepStrictEqual(duplicateStabilityNodes(entries), [ + { + level: 'warn', + message: LINT_MESSAGES.duplicateStabilityNode, + location: { + path: 'file.yaml', + position: { line: 1, column: 1 }, + }, + }, + ]); + }); + + it('handles entries with no stability property gracefully', () => { + const entries = [ + { + heading: { data: { depth: 1 } }, + stability: { children: [] }, + api_doc_source: 'file.yaml', + yaml_position: { line: 2, column: 5 }, + }, + createEntry(2, 0), + ]; + deepStrictEqual(duplicateStabilityNodes(entries), []); + }); + + it('handles entries with undefined stability index', () => { + const entries = [ + createEntry(1, undefined), + createEntry(2, undefined), + createEntry(3, 1), + createEntry(4, 1), // This should trigger an issue + ]; + deepStrictEqual(duplicateStabilityNodes(entries), [ + { + level: 'warn', + message: LINT_MESSAGES.duplicateStabilityNode, + location: { + path: 'file.yaml', + position: { line: 1, column: 1 }, + }, + }, + ]); + }); + + it('handles mixed depths and stability nodes correctly', () => { + const entries = [ + createEntry(1, 0), + createEntry(2, 1), + createEntry(3, 1), // This should trigger an issue + createEntry(2, 2), + createEntry(3, 2), // This should trigger another issue + ]; + + deepStrictEqual(duplicateStabilityNodes(entries), [ + { + level: 'warn', + message: LINT_MESSAGES.duplicateStabilityNode, + location: { + path: 'file.yaml', + position: { line: 1, column: 1 }, + }, + }, + { + level: 'warn', + message: LINT_MESSAGES.duplicateStabilityNode, + location: { + path: 'file.yaml', + position: { line: 1, column: 1 }, + }, + }, + ]); + }); +}); From 1901729bba83fcd0fcf041b42b0e3a4c2cd1ba20 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sat, 22 Mar 2025 14:47:08 -0400 Subject: [PATCH 5/8] fixes --- bin/cli.mjs | 4 +- src/linter/engine.mjs | 34 ++----------- src/linter/index.mjs | 11 ++--- src/linter/rules/index.mjs | 12 ++--- src/linter/rules/invalid-change-version.mjs | 49 +++++++++++-------- src/linter/rules/missing-change-version.mjs | 34 +++++++------ src/linter/rules/missing-introduced-in.mjs | 22 +++++---- src/linter/tests/engine.test.mjs | 18 +++---- .../rules/invalid-change-version.test.mjs | 15 ++++-- .../rules/missing-change-version.test.mjs | 12 +++-- .../rules/missing-introduced-in.test.mjs | 22 +++++---- src/linter/types.d.ts | 4 +- 12 files changed, 109 insertions(+), 128 deletions(-) diff --git a/bin/cli.mjs b/bin/cli.mjs index 9f35e47..673e726 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -64,9 +64,7 @@ program ) .addOption( new Option('--disable-rule [rule...]', 'Disable a specific linter rule') - .choices( - Object.keys(multiEntryRules).concat(Object.keys(singleEntryRules)) - ) + .choices(Object.keys(rules)) .default([]) ) .addOption( diff --git a/src/linter/engine.mjs b/src/linter/engine.mjs index 619eba3..9ae164c 100644 --- a/src/linter/engine.mjs +++ b/src/linter/engine.mjs @@ -3,32 +3,9 @@ /** * Creates a linter engine instance to validate ApiDocMetadataEntry entries * - * @param {{ - * multiEntryRules: import('./types').MultipleEntriesLintRules[] - * singleEntryRules: import('./types').SingleEntryLintRule[] - * }} rules Lint rules to validate the entries against + * @param {import('./types').LintRule[]} rules Lint rules to validate the entries against */ -const createLinterEngine = ({ multiEntryRules, singleEntryRules }) => { - /** - * Validates a ApiDocMetadataEntry entry against all defined rules - * - * @param {ApiDocMetadataEntry} entry - * @returns {import('./types').LintIssue[]} - */ - const lint = entry => { - const issues = []; - - for (const rule of singleEntryRules) { - const ruleIssues = rule(entry); - - if (ruleIssues.length > 0) { - issues.push(...ruleIssues); - } - } - - return issues; - }; - +const createLinterEngine = rules => { /** * Validates an array of ApiDocMetadataEntry entries against all defined rules * @@ -38,19 +15,14 @@ const createLinterEngine = ({ multiEntryRules, singleEntryRules }) => { const lintAll = entries => { const issues = []; - for (const rule of multiEntryRules) { + for (const rule of rules) { issues.push(...rule(entries)); } - for (const entry of entries) { - issues.push(...lint(entry)); - } - return issues; }; return { - lint, lintAll, }; }; diff --git a/src/linter/index.mjs b/src/linter/index.mjs index 3364db8..451d6ee 100644 --- a/src/linter/index.mjs +++ b/src/linter/index.mjs @@ -2,7 +2,7 @@ import createLinterEngine from './engine.mjs'; import reporters from './reporters/index.mjs'; -import { multiEntryRules, singleEntryRules } from './rules/index.mjs'; +import rules from './rules/index.mjs'; /** * Creates a linter instance to validate ApiDocMetadataEntry entries @@ -13,19 +13,16 @@ import { multiEntryRules, singleEntryRules } from './rules/index.mjs'; const createLinter = (dryRun, disabledRules) => { /** * Retrieves all enabled rules - * @param {Record} rules + * * @returns {import('./types').LintRule[]} */ - const getEnabledRules = rules => { + const getEnabledRules = () => { return Object.entries(rules) .filter(([ruleName]) => !disabledRules.includes(ruleName)) .map(([, rule]) => rule); }; - const engine = createLinterEngine({ - multiEntryRules: getEnabledRules(multiEntryRules), - singleEntryRules: getEnabledRules(singleEntryRules), - }); + const engine = createLinterEngine(getEnabledRules(disabledRules)); /** * Lint issues found during validations diff --git a/src/linter/rules/index.mjs b/src/linter/rules/index.mjs index 97c6dad..eb0a6b0 100644 --- a/src/linter/rules/index.mjs +++ b/src/linter/rules/index.mjs @@ -6,17 +6,11 @@ import { missingChangeVersion } from './missing-change-version.mjs'; import { missingIntroducedIn } from './missing-introduced-in.mjs'; /** - * @type {Record} + * @type {Record} */ -export const singleEntryRules = { +export default { + 'duplicate-stability-nodes': duplicateStabilityNodes, 'invalid-change-version': invalidChangeVersion, 'missing-change-version': missingChangeVersion, 'missing-introduced-in': missingIntroducedIn, }; - -/** - * @type {Record} - */ -export const multiEntryRules = { - 'duplicate-stability-nodes': duplicateStabilityNodes, -}; diff --git a/src/linter/rules/invalid-change-version.mjs b/src/linter/rules/invalid-change-version.mjs index 927f35f..465e28a 100644 --- a/src/linter/rules/invalid-change-version.mjs +++ b/src/linter/rules/invalid-change-version.mjs @@ -4,30 +4,39 @@ import { valid } from 'semver'; /** * Checks if any change version is invalid * - * @param {ApiDocMetadataEntry} entry + * @param {ApiDocMetadataEntry[]} entries * @returns {Array} */ -export const invalidChangeVersion = entry => { - if (entry.changes.length === 0) { - return []; - } +export const invalidChangeVersion = entries => { + const issues = []; + + for (const entry of entries) { + if (entry.changes.length === 0) continue; + + const allVersions = entry.changes + .filter(change => change.version) + .flatMap(change => + Array.isArray(change.version) ? change.version : [change.version] + ); - const allVersions = entry.changes - .filter(change => change.version) - .flatMap(change => - Array.isArray(change.version) ? change.version : [change.version] + const invalidVersions = allVersions.filter( + version => valid(version) === null ); - const invalidVersions = allVersions.filter( - version => valid(version) === null - ); + issues.push( + ...invalidVersions.map(version => ({ + level: 'warn', + message: LINT_MESSAGES.invalidChangeVersion.replace( + '{{version}}', + version + ), + location: { + path: entry.api_doc_source, + position: entry.yaml_position, + }, + })) + ); + } - return invalidVersions.map(version => ({ - level: 'warn', - message: LINT_MESSAGES.invalidChangeVersion.replace('{{version}}', version), - location: { - path: entry.api_doc_source, - position: entry.yaml_position, - }, - })); + return issues; }; diff --git a/src/linter/rules/missing-change-version.mjs b/src/linter/rules/missing-change-version.mjs index 67b42a1..e540ee0 100644 --- a/src/linter/rules/missing-change-version.mjs +++ b/src/linter/rules/missing-change-version.mjs @@ -1,22 +1,28 @@ /** * Checks if any change version is missing * - * @param {ApiDocMetadataEntry} entry + * @param {ApiDocMetadataEntry[]} entries * @returns {Array} */ -export const missingChangeVersion = entry => { - if (entry.changes.length === 0) { - return []; +export const missingChangeVersion = entries => { + const issues = []; + + for (const entry of entries) { + if (entry.changes.length === 0) continue; + + issues.push( + ...entry.changes + .filter(change => !change.version) + .map(() => ({ + level: 'warn', + message: 'Missing change version', + location: { + path: entry.api_doc_source, + position: entry.yaml_position, + }, + })) + ); } - return entry.changes - .filter(change => !change.version) - .map(() => ({ - level: 'warn', - message: 'Missing change version', - location: { - path: entry.api_doc_source, - position: entry.yaml_position, - }, - })); + return issues; }; diff --git a/src/linter/rules/missing-introduced-in.mjs b/src/linter/rules/missing-introduced-in.mjs index 8dc7989..785bc6b 100644 --- a/src/linter/rules/missing-introduced-in.mjs +++ b/src/linter/rules/missing-introduced-in.mjs @@ -3,22 +3,24 @@ import { LINT_MESSAGES } from '../constants.mjs'; /** * Checks if `introduced_in` field is missing * - * @param {ApiDocMetadataEntry} entry + * @param {ApiDocMetadataEntry[]} entries * @returns {Array} */ -export const missingIntroducedIn = entry => { - // Early return if not a top-level heading or if introduced_in exists - if (entry.heading.depth !== 1 || entry.introduced_in) { - return []; - } +export const missingIntroducedIn = entries => { + const issues = []; + + for (const entry of entries) { + // Early continue if not a top-level heading or if introduced_in exists + if (entry.heading.depth !== 1 || entry.introduced_in) continue; - return [ - { + issues.push({ level: 'info', message: LINT_MESSAGES.missingIntroducedIn, location: { path: entry.api_doc_source, }, - }, - ]; + }); + } + + return issues; }; diff --git a/src/linter/tests/engine.test.mjs b/src/linter/tests/engine.test.mjs index dc1c705..ac33921 100644 --- a/src/linter/tests/engine.test.mjs +++ b/src/linter/tests/engine.test.mjs @@ -9,17 +9,14 @@ describe('createLinterEngine', () => { const rule1 = mock.fn(() => []); const rule2 = mock.fn(() => []); - const engine = createLinterEngine({ - singleEntryRules: [rule1], - multiEntryRules: [rule2], - }); + const engine = createLinterEngine([rule1, rule2]); engine.lintAll([assertEntry]); assert.strictEqual(rule1.mock.callCount(), 1); assert.strictEqual(rule2.mock.callCount(), 1); - assert.deepEqual(rule1.mock.calls[0].arguments, [assertEntry]); + assert.deepEqual(rule1.mock.calls[0].arguments, [[assertEntry]]); assert.deepEqual(rule2.mock.calls[0].arguments, [[assertEntry]]); }); @@ -27,23 +24,20 @@ describe('createLinterEngine', () => { const rule1 = mock.fn(() => [infoIssue, warnIssue]); const rule2 = mock.fn(() => [errorIssue]); - const engine = createLinterEngine({ - singleEntryRules: [rule1], - multiEntryRules: [rule2], - }); + const engine = createLinterEngine([rule1, rule2]); const issues = engine.lintAll([assertEntry]); assert.equal(issues.length, 3); - assert.deepEqual(issues, [errorIssue, infoIssue, warnIssue]); + assert.deepEqual(issues, [infoIssue, warnIssue, errorIssue]); }); it('should return an empty array when no issues are found', () => { const rule = () => []; - const engine = createLinterEngine({ singleEntryRules: [rule] }); + const engine = createLinterEngine([rule]); - const issues = engine.lint(assertEntry); + const issues = engine.lintAll([assertEntry]); assert.deepEqual(issues, []); }); diff --git a/src/linter/tests/rules/invalid-change-version.test.mjs b/src/linter/tests/rules/invalid-change-version.test.mjs index 6f44519..561e055 100644 --- a/src/linter/tests/rules/invalid-change-version.test.mjs +++ b/src/linter/tests/rules/invalid-change-version.test.mjs @@ -5,16 +5,21 @@ import { assertEntry } from '../fixtures/entries.mjs'; describe('invalidChangeVersion', () => { it('should return an empty array if all change versions are valid', () => { - const issues = invalidChangeVersion(assertEntry); + const issues = invalidChangeVersion([assertEntry]); deepEqual(issues, []); }); it('should return an issue if a change version is invalid', () => { - const issues = invalidChangeVersion({ - ...assertEntry, - changes: [...assertEntry.changes, { version: ['v13.9.0', 'REPLACEME'] }], - }); + const issues = invalidChangeVersion([ + { + ...assertEntry, + changes: [ + ...assertEntry.changes, + { version: ['v13.9.0', 'REPLACEME'] }, + ], + }, + ]); deepEqual(issues, [ { diff --git a/src/linter/tests/rules/missing-change-version.test.mjs b/src/linter/tests/rules/missing-change-version.test.mjs index bafab7b..9ca6ce2 100644 --- a/src/linter/tests/rules/missing-change-version.test.mjs +++ b/src/linter/tests/rules/missing-change-version.test.mjs @@ -5,16 +5,18 @@ import { assertEntry } from '../fixtures/entries.mjs'; describe('missingChangeVersion', () => { it('should return an empty array if all change versions are non-empty', () => { - const issues = missingChangeVersion(assertEntry); + const issues = missingChangeVersion([assertEntry]); deepEqual(issues, []); }); it('should return an issue if a change version is missing', () => { - const issues = missingChangeVersion({ - ...assertEntry, - changes: [...assertEntry.changes, { version: undefined }], - }); + const issues = missingChangeVersion([ + { + ...assertEntry, + changes: [...assertEntry.changes, { version: undefined }], + }, + ]); deepEqual(issues, [ { diff --git a/src/linter/tests/rules/missing-introduced-in.test.mjs b/src/linter/tests/rules/missing-introduced-in.test.mjs index f6ca0fe..4671a8b 100644 --- a/src/linter/tests/rules/missing-introduced-in.test.mjs +++ b/src/linter/tests/rules/missing-introduced-in.test.mjs @@ -5,25 +5,29 @@ import { assertEntry } from '../fixtures/entries.mjs'; describe('missingIntroducedIn', () => { it('should return an empty array if the introduced_in field is not missing', () => { - const issues = missingIntroducedIn(assertEntry); + const issues = missingIntroducedIn([assertEntry]); deepEqual(issues, []); }); it('should return an empty array if the heading depth is not equal to 1', () => { - const issues = missingIntroducedIn({ - ...assertEntry, - heading: { ...assertEntry.heading, depth: 2 }, - }); + const issues = missingIntroducedIn([ + { + ...assertEntry, + heading: { ...assertEntry.heading, depth: 2 }, + }, + ]); deepEqual(issues, []); }); it('should return an issue if the introduced_in property is missing', () => { - const issues = missingIntroducedIn({ - ...assertEntry, - introduced_in: undefined, - }); + const issues = missingIntroducedIn([ + { + ...assertEntry, + introduced_in: undefined, + }, + ]); deepEqual(issues, [ { diff --git a/src/linter/types.d.ts b/src/linter/types.d.ts index b775520..cd23651 100644 --- a/src/linter/types.d.ts +++ b/src/linter/types.d.ts @@ -13,8 +13,6 @@ export interface LintIssue { location: LintIssueLocation; } -type LintRule = MultipleEntriesLintRules | SingleEntryLintRule; -type MultipleEntriesLintRules = (input: ApiDocMetadataEntry[]) => LintIssue[]; -type SingleEntryLintRule = (input: ApiDocMetadataEntry) => LintIssue[]; +type LintRule = (input: ApiDocMetadataEntry[]) => LintIssue[]; export type Reporter = (msg: LintIssue) => void; From 701b2b7a71748716e586ebc72892d3d5f0311f54 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Mon, 24 Mar 2025 18:28:28 -0400 Subject: [PATCH 6/8] rebase --- bin/cli.mjs | 3 - src/constants.mjs | 420 ------------------ src/linter/constants.mjs | 1 + .../rules/duplicate-stability-nodes.mjs | 2 +- .../rules/duplicate-stability-nodes.test.mjs | 2 +- 5 files changed, 3 insertions(+), 425 deletions(-) diff --git a/bin/cli.mjs b/bin/cli.mjs index 673e726..27c3d06 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -15,9 +15,6 @@ import rules from '../src/linter/rules/index.mjs'; import createMarkdownLoader from '../src/loaders/markdown.mjs'; import createMarkdownParser from '../src/parsers/markdown.mjs'; import createNodeReleases from '../src/releases.mjs'; -import createLinter from '../src/linter/index.mjs'; -import reporters from '../src/linter/reporters/index.mjs'; -import rules from '../src/linter/rules/index.mjs'; const availableGenerators = Object.keys(generators); diff --git a/src/constants.mjs b/src/constants.mjs index 473cf35..040a8c1 100644 --- a/src/constants.mjs +++ b/src/constants.mjs @@ -6,423 +6,3 @@ export const DOC_NODE_VERSION = process.version; // This is the Node.js CHANGELOG to be consumed to generate a list of all major Node.js versions export const DOC_NODE_CHANGELOG_URL = 'https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md'; - -// This is the Node.js Base URL for viewing a file within GitHub UI -export const DOC_NODE_BLOB_BASE_URL = - 'https://github.com/nodejs/node/blob/HEAD/'; - -// This is the Node.js API docs base URL for editing a file on GitHub UI -export const DOC_API_BLOB_EDIT_BASE_URL = - 'https://github.com/nodejs/node/edit/main/doc/api/'; - -// Base URL for a specific Node.js version within the Node.js API docs -export const DOC_API_BASE_URL_VERSION = 'https://nodejs.org/docs/latest-v'; - -// This is the perma-link within the API docs that reference the Stability Index -export const DOC_API_STABILITY_SECTION_REF_URL = - 'documentation.html#stability-index'; - -// This is the base URL of the MDN Web documentation -export const DOC_MDN_BASE_URL = 'https://developer.mozilla.org/en-US/docs/Web/'; - -// This is the base URL for the MDN JavaScript documentation -export const DOC_MDN_BASE_URL_JS = `${DOC_MDN_BASE_URL}JavaScript/`; - -// This is the base URL for the MDN JavaScript primitives documentation -export const DOC_MDN_BASE_URL_JS_PRIMITIVES = `${DOC_MDN_BASE_URL_JS}Data_structures`; - -// This is the base URL for the MDN JavaScript global objects documentation -export const DOC_MDN_BASE_URL_JS_GLOBALS = `${DOC_MDN_BASE_URL_JS}Reference/Global_Objects/`; - -// These are YAML keys from the Markdown YAML metadata that should be -// removed and appended to the `update` key -export const DOC_API_YAML_KEYS_UPDATE = [ - 'added', - 'removed', - 'deprecated', - 'introduced_in', - 'napiVersion', -]; - -// These are string replacements specific to Node.js API docs for anchor IDs -export const DOC_API_SLUGS_REPLACEMENTS = [ - { from: /node.js/i, to: 'nodejs' }, // Replace Node.js - { from: /&/, to: '-and-' }, // Replace & - { from: /[/_,:;\\ ]/g, to: '-' }, // Replace /_,:;\. and whitespace - { from: /--+/g, to: '-' }, // Replace multiple hyphens with single - { from: /^-/, to: '' }, // Remove any leading hyphen - { from: /-$/, to: '' }, // Remove any trailing hyphen -]; - -// These are regular expressions used to determine if a given Markdown heading -// is a specific type of API Doc entry (e.g., Event, Class, Method, etc) -// and to extract the inner content of said Heading to be used as the API doc entry name -export const DOC_API_HEADING_TYPES = [ - { - type: 'method', - regex: - // Group 1: foo[bar]() - // Group 2: foo.bar() - // Group 3: foobar() - /^`?(?:\w*(?:(\[[^\]]+\])|(?:\.(\w+)))|(\w+))\([^)]*\)`?$/i, - }, - { type: 'event', regex: /^Event: +`?['"]?([^'"]+)['"]?`?$/i }, - { - type: 'class', - regex: - /^Class: +`?([A-Z]\w+(?:\.[A-Z]\w+)*(?: +extends +[A-Z]\w+(?:\.[A-Z]\w+)*)?)`?$/i, - }, - { - type: 'ctor', - regex: /^(?:Constructor: +)?`?new +([A-Z]\w+(?:\.[A-Z]\w+)*)\([^)]*\)`?$/i, - }, - { - type: 'classMethod', - regex: - /^Static method: +`?[A-Z]\w+(?:\.[A-Z]\w+)*(?:(\[\w+\.\w+\])|\.(\w+))\([^)]*\)`?$/i, - }, - { - type: 'property', - regex: - /^(?:Class property: +)?`?[A-Z]\w+(?:\.[A-Z]\w+)*(?:(\[\w+\.\w+\])|\.(\w+))`?$/i, - }, -]; - -// This is a mapping for the `API` updates within the Markdown content and their respective -// content that should be mapping into `changes` property for better mapping on HTML -export const DOC_API_UPDATE_MAPPING = { - added: 'Added in', - removed: 'Removed in', - deprecated: 'Deprecated since', - introduced_in: 'Introduced in', - napiVersion: 'N-API Version', -}; - -// This is a mapping for types within the Markdown content and their respective -// JavaScript primitive types within the MDN JavaScript docs -// @see DOC_MDN_BASE_URL_JS_PRIMITIVES -export const DOC_TYPES_MAPPING_PRIMITIVES = { - boolean: 'Boolean', - integer: 'Number', // Not a primitive, used for clarification. - null: 'Null', - number: 'Number', - string: 'String', - symbol: 'Symbol', - undefined: 'Undefined', -}; - -// https://github.com/nodejs/node/blob/main/doc/api/cli.md#options -// This slug should reference the section where the available -// options are defined. -export const DOC_SLUG_OPTIONS = 'options'; - -// https://github.com/nodejs/node/blob/main/doc/api/cli.md#environment-variables-1 -// This slug should reference the section where the available -// environment variables are defined. -export const DOC_SLUG_ENVIRONMENT = 'environment-variables-1'; - -// This is a mapping for types within the Markdown content and their respective -// JavaScript globals types within the MDN JavaScript docs -// @see DOC_MDN_BASE_URL_JS_GLOBALS -export const DOC_TYPES_MAPPING_GLOBALS = { - ...Object.fromEntries( - [ - 'AggregateError', - 'Array', - 'ArrayBuffer', - 'DataView', - 'Date', - 'Error', - 'EvalError', - 'Function', - 'Map', - 'NaN', - 'Object', - 'Promise', - 'Proxy', - 'RangeError', - 'ReferenceError', - 'RegExp', - 'Set', - 'SharedArrayBuffer', - 'SyntaxError', - 'Symbol', - 'TypeError', - 'URIError', - 'WeakMap', - 'WeakSet', - - 'TypedArray', - 'Float32Array', - 'Float64Array', - 'Int8Array', - 'Int16Array', - 'Int32Array', - 'Uint8Array', - 'Uint8ClampedArray', - 'Uint16Array', - 'Uint32Array', - ].map(e => [e, e]) - ), - bigint: 'BigInt', - 'WebAssembly.Instance': 'WebAssembly/Instance', -}; - -// This is a mapping for types within the Markdown content and their respective -// Node.js types within the Node.js API docs (refers to a different API doc page) -// Note: These hashes are generated with the GitHub Slugger -export const DOC_TYPES_MAPPING_NODE_MODULES = { - AbortController: 'globals.html#class-abortcontroller', - AbortSignal: 'globals.html#class-abortsignal', - - AlgorithmIdentifier: 'webcrypto.html#class-algorithmidentifier', - AsyncHook: 'async_hooks.html#async_hookscreatehookcallbacks', - AsyncLocalStorage: 'async_context.html#class-asynclocalstorage', - AsyncResource: 'async_hooks.html#class-asyncresource', - - AesCbcParams: 'webcrypto.html#class-aescbcparams', - AesCtrParams: 'webcrypto.html#class-aesctrparams', - AesGcmParams: 'webcrypto.html#class-aesgcmparams', - AesKeyGenParams: 'webcrypto.html#class-aeskeygenparams', - - Blob: 'buffer.html#class-blob', - BroadcastChannel: - 'worker_threads.html#class-broadcastchannel-extends-eventtarget', - Buffer: 'buffer.html#class-buffer', - - ByteLengthQueuingStrategy: 'webstreams.html#class-bytelengthqueuingstrategy', - - Channel: 'diagnostics_channel.html#class-channel', - ChildProcess: 'child_process.html#class-childprocess', - Cipher: 'crypto.html#class-cipher', - ClientHttp2Session: 'http2.html#class-clienthttp2session', - ClientHttp2Stream: 'http2.html#class-clienthttp2stream', - - CountQueuingStrategy: 'webstreams.html#class-countqueuingstrategy', - - Crypto: 'webcrypto.html#class-crypto', - CryptoKey: 'webcrypto.html#class-cryptokey', - CryptoKeyPair: 'webcrypto.html#class-cryptokeypair', - - CustomEvent: 'events.html#class-customevent', - - Decipher: 'crypto.html#class-decipher', - DiffieHellman: 'crypto.html#class-diffiehellman', - DiffieHellmanGroup: 'crypto.html#class-diffiehellmangroup', - Domain: 'domain.html#class-domain', - - Duplex: 'stream.html#class-streamduplex', - - ECDH: 'crypto.html#class-ecdh', - EcdhKeyDeriveParams: 'webcrypto.html#class-ecdhkeyderiveparams', - EcdsaParams: 'webcrypto.html#class-ecdsaparams', - EcKeyGenParams: 'webcrypto.html#class-eckeygenparams', - EcKeyImportParams: 'webcrypto.html#class-eckeyimportparams', - Ed448Params: 'webcrypto.html#class-ed448params', - - Event: 'events.html#class-event', - EventEmitter: 'events.html#class-eventemitter', - EventListener: 'events.html#event-listener', - EventTarget: 'events.html#class-eventtarget', - - File: 'buffer.html#class-file', - FileHandle: 'fs.html#class-filehandle', - - Handle: 'net.html#serverlistenhandle-backlog-callback', - Hash: 'crypto.html#class-hash', - Histogram: 'perf_hooks.html#class-histogram', - HkdfParams: 'webcrypto.html#class-hkdfparams', - Hmac: 'crypto.html#class-hmac', - HmacImportParams: 'webcrypto.html#class-hmacimportparams', - HmacKeyGenParams: 'webcrypto.html#class-hmackeygenparams', - - Http2SecureServer: 'http2.html#class-http2secureserver', - Http2Server: 'http2.html#class-http2server', - Http2Session: 'http2.html#class-http2session', - Http2Stream: 'http2.html#class-http2stream', - - Immediate: 'timers.html#class-immediate', - - IntervalHistogram: - 'perf_hooks.html#class-intervalhistogram-extends-histogram', - - KeyObject: 'crypto.html#class-keyobject', - - MIMEParams: 'util.html#class-utilmimeparams', - MessagePort: 'worker_threads.html#class-messageport', - - MockModuleContext: 'test.html#class-mockmodulecontext', - - NodeEventTarget: 'events.html#class-nodeeventtarget', - - Pbkdf2Params: 'webcrypto.html#class-pbkdf2params', - PerformanceEntry: 'perf_hooks.html#class-performanceentry', - PerformanceNodeTiming: 'perf_hooks.html#class-performancenodetiming', - PerformanceObserver: 'perf_hooks.html#class-performanceobserver', - PerformanceObserverEntryList: - 'perf_hooks.html#class-performanceobserverentrylist', - - Readable: 'stream.html#class-streamreadable', - ReadableByteStreamController: - 'webstreams.html#class-readablebytestreamcontroller', - ReadableStream: 'webstreams.html#class-readablestream', - ReadableStreamBYOBReader: 'webstreams.html#class-readablestreambyobreader', - ReadableStreamBYOBRequest: 'webstreams.html#class-readablestreambyobrequest', - ReadableStreamDefaultController: - 'webstreams.html#class-readablestreamdefaultcontroller', - ReadableStreamDefaultReader: - 'webstreams.html#class-readablestreamdefaultreader', - - RecordableHistogram: - 'perf_hooks.html#class-recordablehistogram-extends-histogram', - - RsaHashedImportParams: 'webcrypto.html#class-rsahashedimportparams', - RsaHashedKeyGenParams: 'webcrypto.html#class-rsahashedkeygenparams', - RsaOaepParams: 'webcrypto.html#class-rsaoaepparams', - RsaPssParams: 'webcrypto.html#class-rsapssparams', - - ServerHttp2Session: 'http2.html#class-serverhttp2session', - ServerHttp2Stream: 'http2.html#class-serverhttp2stream', - - Sign: 'crypto.html#class-sign', - - StatementSync: 'sqlite.html#class-statementsync', - - Stream: 'stream.html#stream', - - SubtleCrypto: 'webcrypto.html#class-subtlecrypto', - - TestsStream: 'test.html#class-testsstream', - - TextDecoderStream: 'webstreams.html#class-textdecoderstream', - TextEncoderStream: 'webstreams.html#class-textencoderstream', - - Timeout: 'timers.html#class-timeout', - Timer: 'timers.html#timers', - - Tracing: 'tracing.html#tracing-object', - TracingChannel: 'diagnostics_channel.html#class-tracingchannel', - - Transform: 'stream.html#class-streamtransform', - TransformStream: 'webstreams.html#class-transformstream', - TransformStreamDefaultController: - 'webstreams.html#class-transformstreamdefaultcontroller', - - URL: 'url.html#the-whatwg-url-api', - URLSearchParams: 'url.html#class-urlsearchparams', - - Verify: 'crypto.html#class-verify', - - Writable: 'stream.html#class-streamwritable', - WritableStream: 'webstreams.html#class-writablestream', - WritableStreamDefaultController: - 'webstreams.html#class-writablestreamdefaultcontroller', - WritableStreamDefaultWriter: - 'webstreams.html#class-writablestreamdefaultwriter', - - Worker: 'worker_threads.html#class-worker', - - X509Certificate: 'crypto.html#class-x509certificate', - - 'brotli options': 'zlib.html#class-brotlioptions', - - 'cluster.Worker': 'cluster.html#class-worker', - - 'crypto.constants': 'crypto.html#cryptoconstants', - - 'dgram.Socket': 'dgram.html#class-dgramsocket', - - 'errors.Error': 'errors.html#class-error', - - 'fs.Dir': 'fs.html#class-fsdir', - 'fs.Dirent': 'fs.html#class-fsdirent', - 'fs.FSWatcher': 'fs.html#class-fsfswatcher', - 'fs.ReadStream': 'fs.html#class-fsreadstream', - 'fs.StatFs': 'fs.html#class-fsstatfs', - 'fs.Stats': 'fs.html#class-fsstats', - 'fs.StatWatcher': 'fs.html#class-fsstatwatcher', - 'fs.WriteStream': 'fs.html#class-fswritestream', - - 'http.Agent': 'http.html#class-httpagent', - 'http.ClientRequest': 'http.html#class-httpclientrequest', - 'http.IncomingMessage': 'http.html#class-httpincomingmessage', - 'http.OutgoingMessage': 'http.html#class-httpoutgoingmessage', - 'http.Server': 'http.html#class-httpserver', - 'http.ServerResponse': 'http.html#class-httpserverresponse', - - 'http2.Http2ServerRequest': 'http2.html#class-http2http2serverrequest', - 'http2.Http2ServerResponse': 'http2.html#class-http2http2serverresponse', - - 'import.meta': 'esm.html#importmeta', - - 'module.SourceMap': 'module.html#class-modulesourcemap', - - 'net.BlockList': 'net.html#class-netblocklist', - 'net.Server': 'net.html#class-netserver', - 'net.Socket': 'net.html#class-netsocket', - 'net.SocketAddress': 'net.html#class-netsocketaddress', - - 'os.constants.dlopen': 'os.html#dlopen-constants', - - 'readline.Interface': 'readline.html#class-readlineinterface', - 'readline.InterfaceConstructor': 'readline.html#class-interfaceconstructor', - 'readlinePromises.Interface': 'readline.html#class-readlinepromisesinterface', - - 'repl.REPLServer': 'repl.html#class-replserver', - - require: 'modules.html#requireid', - - 'stream.Duplex': 'stream.html#class-streamduplex', - 'stream.Readable': 'stream.html#class-streamreadable', - 'stream.Transform': 'stream.html#class-streamtransform', - 'stream.Writable': 'stream.html#class-streamwritable', - - 'tls.SecureContext': 'tls.html#tlscreatesecurecontextoptions', - 'tls.Server': 'tls.html#class-tlsserver', - 'tls.TLSSocket': 'tls.html#class-tlstlssocket', - - 'tty.ReadStream': 'tty.html#class-ttyreadstream', - 'tty.WriteStream': 'tty.html#class-ttywritestream', - - 'vm.Module': 'vm.html#class-vmmodule', - 'vm.Script': 'vm.html#class-vmscript', - 'vm.SourceTextModule': 'vm.html#class-vmsourcetextmodule', - 'vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER': - 'vm.html#vmconstantsuse_main_context_default_loader', - - 'zlib options': 'zlib.html#class-options', -}; - -// This is a mapping for miscellaneous types within the Markdown content and their respective -// external reference on appropriate 3rd-party vendors/documentation sites. -export const DOC_TYPES_MAPPING_OTHER = { - any: `${DOC_MDN_BASE_URL_JS_PRIMITIVES}#Data_types`, - this: `${DOC_MDN_BASE_URL_JS}Reference/Operators/this`, - - ArrayBufferView: - 'https://developer.mozilla.org/en-US/docs/Web/API/ArrayBufferView', - - AsyncIterator: 'https://tc39.github.io/ecma262/#sec-asynciterator-interface', - AsyncIterable: 'https://tc39.github.io/ecma262/#sec-asynciterable-interface', - AsyncFunction: 'https://tc39.es/ecma262/#sec-async-function-constructor', - - 'Module Namespace Object': - 'https://tc39.github.io/ecma262/#sec-module-namespace-exotic-objects', - - AsyncGeneratorFunction: - 'https://tc39.es/proposal-async-iteration/#sec-asyncgeneratorfunction-constructor', - - Iterable: `${DOC_MDN_BASE_URL_JS}Reference/Iteration_protocols#The_iterable_protocol`, - Iterator: `${DOC_MDN_BASE_URL_JS}Reference/Iteration_protocols#The_iterator_protocol`, - - FormData: `${DOC_MDN_BASE_URL}API/FormData`, - Headers: `${DOC_MDN_BASE_URL}/API/Headers`, - Response: `${DOC_MDN_BASE_URL}/API/Response`, - Request: `${DOC_MDN_BASE_URL}/API/Request`, -}; - -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}}', -}; diff --git a/src/linter/constants.mjs b/src/linter/constants.mjs index b70ba65..95d29b5 100644 --- a/src/linter/constants.mjs +++ b/src/linter/constants.mjs @@ -4,4 +4,5 @@ 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}}', + duplicateStabilityNode: 'Duplicate stability node', }; diff --git a/src/linter/rules/duplicate-stability-nodes.mjs b/src/linter/rules/duplicate-stability-nodes.mjs index bd4d163..57899f2 100644 --- a/src/linter/rules/duplicate-stability-nodes.mjs +++ b/src/linter/rules/duplicate-stability-nodes.mjs @@ -1,4 +1,4 @@ -import { LINT_MESSAGES } from '../../constants.mjs'; +import { LINT_MESSAGES } from '../constants.mjs'; /** * Checks if there are multiple stability nodes within a chain. diff --git a/src/linter/tests/rules/duplicate-stability-nodes.test.mjs b/src/linter/tests/rules/duplicate-stability-nodes.test.mjs index 828ffa1..bc8d9d5 100644 --- a/src/linter/tests/rules/duplicate-stability-nodes.test.mjs +++ b/src/linter/tests/rules/duplicate-stability-nodes.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import { deepStrictEqual } from 'assert'; import { duplicateStabilityNodes } from '../../rules/duplicate-stability-nodes.mjs'; -import { LINT_MESSAGES } from '../../../constants.mjs'; +import { LINT_MESSAGES } from '../../constants.mjs'; // Mock data structure for creating test entries const createEntry = ( From 3e5e4968f69e75d7bf1d2bdf4506745a6b7e1d9e Mon Sep 17 00:00:00 2001 From: avivkeller Date: Mon, 24 Mar 2025 18:36:08 -0400 Subject: [PATCH 7/8] use stability position --- src/linter/rules/duplicate-stability-nodes.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/linter/rules/duplicate-stability-nodes.mjs b/src/linter/rules/duplicate-stability-nodes.mjs index 57899f2..0f7a164 100644 --- a/src/linter/rules/duplicate-stability-nodes.mjs +++ b/src/linter/rules/duplicate-stability-nodes.mjs @@ -25,7 +25,7 @@ export const duplicateStabilityNodes = entries => { message: LINT_MESSAGES.duplicateStabilityNode, location: { path: entry.api_doc_source, - position: entry.yaml_position, + position: entry.stability.children[0].children[0].position, }, }); } else { From c6343885d7e154e6d867fad6b8f9f3a2015d27d9 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Mon, 24 Mar 2025 18:38:56 -0400 Subject: [PATCH 8/8] update tests --- src/linter/tests/rules/duplicate-stability-nodes.test.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/linter/tests/rules/duplicate-stability-nodes.test.mjs b/src/linter/tests/rules/duplicate-stability-nodes.test.mjs index bc8d9d5..7c3c700 100644 --- a/src/linter/tests/rules/duplicate-stability-nodes.test.mjs +++ b/src/linter/tests/rules/duplicate-stability-nodes.test.mjs @@ -11,9 +11,10 @@ const createEntry = ( position = { line: 1, column: 1 } ) => ({ heading: { data: { depth } }, - stability: { children: [{ data: { index: stabilityIndex } }] }, + stability: { + children: [{ data: { index: stabilityIndex }, children: [{ position }] }], + }, api_doc_source: source, - yaml_position: position, }); describe('duplicateStabilityNodes', () => {