Repository: nodejs/node
Pull request: test: check stability block position in API markdown

The doc generator will only add a stability marker to the index if the blockquote element appears close to the relevant heading. There is an ordering convention (\<heading\> \[\<yaml\>\] \<stability\>) which is not currently enforced.

Adding a test for this identifies one element which is currently non-compliant, `buf.slice()` in buffer.md, which does not appear as deprecated in the index due to the malpositioned deprecation block.

Changed files:
filename: doc/api/buffer.md
patch: @@ -3534,13 +3534,13 @@ changes:
                  calculations with them.
 -->
 
+> Stability: 0 - Deprecated: Use [`buf.subarray`][] instead.
+
 * `start` {integer} Where the new `Buffer` will start. **Default:** `0`.
 * `end` {integer} Where the new `Buffer` will end (not inclusive).
   **Default:** [`buf.length`][].
 * Returns: {Buffer}
 
-> Stability: 0 - Deprecated: Use [`buf.subarray`][] instead.
-
 Returns a new `Buffer` that references the same memory as the original, but
 offset and cropped by the `start` and `end` indexes.
 

filename: doc/api/environment_variables.md
patch: @@ -18,10 +18,10 @@ For more details refer to the [`process.env` documentation][].
 
 ## DotEnv
 
-Set of utilities for dealing with additional environment variables defined in `.env` files.
-
 > Stability: 2 - Stable
 
+Set of utilities for dealing with additional environment variables defined in `.env` files.
+
 ### .env files
 
 `.env` files (also known as dotenv files) are files that define environment variables,

filename: test/doctool/test-stability-block-position.mjs
patch: @@ -0,0 +1,52 @@
+import '../common/index.mjs';
+
+import fs from 'fs';
+import assert from 'assert';
+
+import {
+  remarkParse,
+  unified,
+} from '../../tools/doc/deps.mjs';
+
+const ignore = ['deprecations.md', 'documentation.md'];
+
+const docURL = new URL('../../doc/api/', import.meta.url);
+const docList = fs.readdirSync(docURL).filter((filename) => !ignore.includes(filename));
+
+const re = /^Stability: \d/;
+
+for (const file of docList) {
+  const fileURL = new URL(file, docURL);
+  const tree = unified()
+    .use(remarkParse)
+    .parse(fs.readFileSync(fileURL));
+
+  // Traverse first-level nodes, ignoring comment blocks
+  const nodes = tree.children.filter((node) => node.type !== 'html');
+
+  for (let i = 0; i < nodes.length; i++) {
+    const { [i]: node, [i - 1]: previousNode } = nodes;
+    if (node.type !== 'blockquote' || !node.children.length) continue;
+
+    const paragraph = node.children[0];
+    if (paragraph.type !== 'paragraph' || !paragraph.children.length) continue;
+
+    const text = paragraph.children[0];
+    if (text.type !== 'text' || !re.exec(text.value)) continue;
+
+    // Check that previous node type (excluding comment blocks) is one of:
+    // * 'heading'
+    // * 'paragraph' with a leading 'strong' node (pseudo-heading, eg. assert.equal)
+    try {
+      assert(previousNode.type === 'heading' ||
+                 (previousNode.type === 'paragraph' && previousNode.children[0]?.type === 'strong'),
+             'Stability block must be the first content element under heading');
+    } catch (error) {
+      const { line, column } = node.position.start;
+      error.stack = error.stack.split('\n')
+                      .toSpliced(1, 0, `    at ${fileURL}:${line}:${column}`)
+                      .join('\n');
+      throw error;
+    }
+  }
+}