From 5a3f486c2dbf606d44e271788883daba9b6edaf3 Mon Sep 17 00:00:00 2001 From: David Gasquez Date: Wed, 30 Sep 2026 16:25:14 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Check=20README=20order=20within?= =?UTF-8?q?=20licensing=20subgroups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/check-readme-order.mjs | 12 ++- .github/scripts/check-readme-order.test.mjs | 103 ++++++++++++++++++++ .github/workflows/linkchecker.yml | 7 +- CONTRIBUTING.md | 2 +- 4 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/check-readme-order.test.mjs diff --git a/.github/scripts/check-readme-order.mjs b/.github/scripts/check-readme-order.mjs index 6a5b0e6..14742fa 100644 --- a/.github/scripts/check-readme-order.mjs +++ b/.github/scripts/check-readme-order.mjs @@ -8,7 +8,7 @@ if (!basePath || !currentPath) { process.exit(2); } -const SORTED_SECTION = "Community Extensions"; +const SORTED_SECTION = "Extensions / Community Extensions"; const IGNORED_SECTIONS = new Set(["Contents"]); function headingName(markdown) { @@ -18,12 +18,18 @@ function headingName(markdown) { function parseSections(path) { const sections = new Map(); + const headings = []; let section = null; for (const line of readFileSync(path, "utf8").split("\n")) { - const heading = line.match(/^#{2,3}\s+(.+?)\s*$/); + const heading = line.match(/^(#{2,6})\s+(.+?)\s*$/); if (heading) { - section = headingName(heading[1]); + const level = heading[1].length; + while (headings.length > 0 && headings.at(-1).level >= level) { + headings.pop(); + } + headings.push({ level, name: headingName(heading[2]) }); + section = headings.map(({ name }) => name).join(" / "); if (!sections.has(section)) { sections.set(section, []); } diff --git a/.github/scripts/check-readme-order.test.mjs b/.github/scripts/check-readme-order.test.mjs new file mode 100644 index 0000000..88b4a7e --- /dev/null +++ b/.github/scripts/check-readme-order.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +const entry = (name) => `- [${name}](https://example.com/${name}) - Description.`; +const communityHeading = "## Extensions\n### [Community Extensions](https://duckdb.org/community_extensions/)"; +const subgroupBase = [ + "## Tools Powered by DuckDB", + "### Business Intelligence & Dashboards", + "#### Free & Open Source", + entry("Pondview"), + "#### Free (Proprietary / Source-Available)", + entry("Proprietary"), + "### Fun & Demos", + "#### Free & Open Source", + entry("Demo"), +].join("\n"); + +const cases = [ + { + name: "append to a flat section", + base: `## Resources\n${entry("Existing")}`, + current: `## Resources\n${entry("Existing")}\n${entry("New")}`, + valid: true, + }, + { + name: "reject insertion before an existing flat entry", + base: `## Resources\n${entry("Existing")}`, + current: `## Resources\n${entry("New")}\n${entry("Existing")}`, + valid: false, + }, + { + name: "append to a licensing subgroup before another subgroup", + base: subgroupBase, + current: subgroupBase.replace(entry("Pondview"), `${entry("Pondview")}\n${entry("DUX")}`), + valid: true, + }, + { + name: "reject insertion within a subgroup despite matching entries in another category", + base: subgroupBase, + current: subgroupBase.replace(entry("Pondview"), `${entry("Demo")}\n${entry("Pondview")}`), + valid: false, + }, + { + name: "move an entry to the bottom of its destination subgroup", + base: `${subgroupBase}\n${entry("DUX")}`, + current: subgroupBase.replace(entry("Pondview"), `${entry("Pondview")}\n${entry("DUX")}`), + valid: true, + }, + { + name: "reset subgroup context at a top-level section", + base: `${subgroupBase}\n## Resources\n${entry("Existing")}`, + current: `${subgroupBase}\n${entry("NewDemo")}\n## Resources\n${entry("Existing")}\n${entry("NewResource")}`, + valid: true, + }, + { + name: "allow updating an existing description in place", + base: `## Resources\n${entry("Existing")}\n${entry("Last")}`, + current: `## Resources\n${entry("Existing").replace("Description.", "Updated description.")}\n${entry("Last")}`, + valid: true, + }, + { + name: "keep community extensions case-insensitively sorted", + base: `${communityHeading}\n${entry("`beta`")}`, + current: `${communityHeading}\n${entry("`Alpha`")}\n${entry("`beta`")}`, + valid: true, + }, + { + name: "reject unsorted community extensions", + base: `${communityHeading}\n${entry("`beta`")}`, + current: `${communityHeading}\n${entry("`beta`")}\n${entry("`Alpha`")}`, + valid: false, + }, + { + name: "ignore contents ordering", + base: `## Contents\n${entry("Existing")}`, + current: `## Contents\n${entry("New")}\n${entry("Existing")}`, + valid: true, + }, +]; + +for (const { name, base, current, valid } of cases) { + test(name, () => { + const directory = mkdtempSync(join(tmpdir(), "readme-order-")); + try { + const basePath = join(directory, "base.md"); + const currentPath = join(directory, "current.md"); + writeFileSync(basePath, base); + writeFileSync(currentPath, current); + const result = spawnSync(process.execPath, [ + new URL("./check-readme-order.mjs", import.meta.url).pathname, + basePath, + currentPath, + ], { encoding: "utf8" }); + assert.equal(result.status, valid ? 0 : 1, result.stderr || result.stdout); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +} diff --git a/.github/workflows/linkchecker.yml b/.github/workflows/linkchecker.yml index b028c1b..a222a6f 100644 --- a/.github/workflows/linkchecker.yml +++ b/.github/workflows/linkchecker.yml @@ -6,13 +6,13 @@ on: paths: - README.md - .github/lychee.toml - - .github/scripts/check-readme-order.mjs + - .github/scripts/check-readme-order*.mjs - .github/workflows/linkchecker.yml pull_request: paths: - README.md - .github/lychee.toml - - .github/scripts/check-readme-order.mjs + - .github/scripts/check-readme-order*.mjs - .github/workflows/linkchecker.yml workflow_dispatch: @@ -30,6 +30,9 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Test entry order checker + run: node --test .github/scripts/check-readme-order.test.mjs + - name: Check entry order if: github.event_name == 'pull_request' env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a05032..4aa11f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ By participating in this project, you agree to abide by its [Contributor Code of ### Entry order -Most sections preserve insertion order. **Append new entries to the bottom of the relevant list**, even when another entry seems similar. Do not alphabetize or otherwise reposition existing entries. +Most sections preserve insertion order. **Append new entries to the bottom of the relevant list**, even when another entry seems similar. In **Tools Powered by DuckDB**, append to the bottom of the relevant licensing subgroup within the most specific category, not the bottom of the whole category. Do not alphabetize or otherwise reposition existing entries. The **Community Extensions** section is the exception: insert extensions in case-insensitive lexicographic order by extension name. Community extension names use backticks, for example: ``- [`extension_name`](https://example.com) - A concise description.``