From f61c80e2472b830e8a736f524a65d7b01fc068c4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 14:02:07 +0100 Subject: [PATCH 01/10] Add test-first shared Markdown file validation --- .github/linters/.markdown-lint.yml | 29 + .github/scripts/validate-markdown.mjs | 45 + .github/tests/validate-markdown.test.mjs | 99 ++ .gitignore | 3 + .node-version | 1 + README.md | 24 + package-lock.json | 1419 ++++++++++++++++++++++ package.json | 16 + 8 files changed, 1636 insertions(+) create mode 100644 .github/linters/.markdown-lint.yml create mode 100644 .github/scripts/validate-markdown.mjs create mode 100644 .github/tests/validate-markdown.test.mjs create mode 100644 .node-version create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml new file mode 100644 index 0000000..4c0f98c --- /dev/null +++ b/.github/linters/.markdown-lint.yml @@ -0,0 +1,29 @@ +########################### +## Markdown Linter rules ## +########################### + +# Linter rules doc: +# - https://github.com/DavidAnson/markdownlint + +############### +# Rules by id # +############### +MD004: false # Unordered list style +MD007: + indent: 2 # Unordered list indentation +MD013: + line_length: 3000 # Line length +MD025: + front_matter_title: "" # Allow a body H1 alongside the front matter title +MD026: + punctuation: ".,;:!。,;:" # List of not allowed +MD029: false # Ordered list item prefix +MD033: false # Allow inline HTML +MD036: false # Emphasis used instead of a heading +MD041: false # First line in file should be a top level heading, PULL_REQUEST_TEMPLATE.md is an exception +MD060: false # Table column style — content mixes compact and aligned tables + +################# +# Rules by tags # +################# +blank_lines: false # Error on blank lines diff --git a/.github/scripts/validate-markdown.mjs b/.github/scripts/validate-markdown.mjs new file mode 100644 index 0000000..77f900d --- /dev/null +++ b/.github/scripts/validate-markdown.mjs @@ -0,0 +1,45 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { extname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { lint, readConfig } from 'markdownlint-cli2/markdownlint/promise'; +import yamlParse from 'markdownlint-cli2/parsers/yaml'; + +/** Find authored Markdown without traversing dependency or Git metadata trees. */ +function findMarkdownFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return ['.git', 'node_modules'].includes(entry.name) ? [] : findMarkdownFiles(path); + } + return entry.isFile() && extname(entry.name).toLowerCase() === '.md' ? [path] : []; + }); +} + +// Optional first argument selects an isolated root; the shared rules stay fixed. +const directory = resolve(process.argv[2] ?? process.cwd()); +const configurationPath = fileURLToPath(new URL('../linters/.markdown-lint.yml', import.meta.url)); +const configuration = await readConfig(configurationPath, [yamlParse]); +const files = findMarkdownFiles(directory); +if (files.length === 0) { + throw new Error(`No Markdown files were checked in ${directory}. Check the validation root.`); +} +for (const file of files) { + if (readFileSync(file, 'utf8').trim().length === 0) { + throw new Error(`Empty Markdown file: ${file}. Restore its content before validation.`); + } +} + +const results = await lint({ files, config: configuration }); +const checkedFiles = Object.keys(results); +if (checkedFiles.length !== files.length) { + throw new Error(`Markdown lint checked ${checkedFiles.length} of ${files.length} selected files.`); +} +for (const file of checkedFiles) { + console.log(JSON.stringify(relative(directory, file))); +} +const hasErrors = Object.values(results).some((errors) => errors.length > 0); +if (hasErrors) { + console.error(JSON.stringify(results, null, 2)); +} +console.log(`Checked ${checkedFiles.length} Markdown files; result: ${hasErrors ? 'failed' : 'passed'}.`); +process.exitCode = hasErrors ? 1 : 0; diff --git a/.github/tests/validate-markdown.test.mjs b/.github/tests/validate-markdown.test.mjs new file mode 100644 index 0000000..71a6acf --- /dev/null +++ b/.github/tests/validate-markdown.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const script = fileURLToPath(new URL('../scripts/validate-markdown.mjs', import.meta.url)); + +/** Run the real validator against disposable, anonymous Markdown files. */ +function validateFixture(context, files) { + const directory = mkdtempSync(join(tmpdir(), 'community-markdown-')); + context.after(() => rmSync(directory, { recursive: true, force: true })); + for (const [name, content] of Object.entries(files)) { + const path = join(directory, name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + } + return spawnSync(process.execPath, [script, directory], { encoding: 'utf8' }); +} + +test('checks both inherited community paths and the repository README', (context) => { + const result = validateFixture(context, { + 'README.md': '# Community defaults\n', + '.github/CONTRIBUTING.md': '# Contributing\n\nFollow the shared workflow.\n', + '.github/pull_request_template.md': + '\n\n---\n\n
\nTechnical details\n\nImplementation evidence.\n\n
\n', + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Checked 3 Markdown files; result: passed/); + assert.match(result.stdout, /\.github[/\\]+CONTRIBUTING\.md/); + assert.match(result.stdout, /\.github[/\\]+pull_request_template\.md/); +}); + +for (const name of ['.github/pull_request_template.md', '.github/CONTRIBUTING.md']) { + test(`rejects malformed Markdown in ${name}`, (context) => { + const result = validateFixture(context, { + 'README.md': '# Community defaults\n', + [name]: '# Community file\n\n### Skipped heading level\n', + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /MD001/); + assert.match(result.stderr, /heading-increment/); + }); +} + +test('rejects duplicate document titles', (context) => { + const result = validateFixture(context, { + '.github/CONTRIBUTING.md': '# Contributing\n\n# Another title\n', + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /MD025/); +}); + +test('accepts source-shaped announcement and result-scoped blocks as Markdown', (context) => { + const sections = ['Breaking', 'Removed', 'New', 'Changed', 'Fixed'].map((classifier) => + `## ${classifier}: Example result\n\nReaders inspect the affected setting and verify the documented result. No additional action applies to unaffected uses.\n\n
\nTechnical details\n\nAnonymous validation evidence.\n\n
\n\n
\nRelated references\n\nOptional supporting context.\n\n
\n`); + const result = validateFixture(context, { + '.github/pull_request_template.md': + `Readers receive complete adoption instructions for the affected uses.\n\n\n\n${sections.join('\n')}`, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Checked 1 Markdown files; result: passed/); +}); + +test('rejects a scan with no Markdown files', (context) => { + const result = validateFixture(context, { 'unrelated.txt': 'Not Markdown.\n' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /No Markdown files were checked/); +}); + +test('rejects blank inherited files even when other files are valid', (context) => { + const result = validateFixture(context, { + 'README.md': '# Community defaults\n', + '.github/CONTRIBUTING.md': ' \n\t\n', + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Empty Markdown file/); + assert.match(result.stderr, /CONTRIBUTING\.md/); +}); + +test('ignores dependency and Git metadata trees, not hidden community files', (context) => { + const result = validateFixture(context, { + '.github/CONTRIBUTING.md': '# Contributing\n', + 'node_modules/dependency/README.md': '# Dependency\n\n### Invalid heading\n', + '.git/README.md': '# Git metadata\n\n### Invalid heading\n', + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Checked 1 Markdown files; result: passed/); +}); + +test('rejects a nonexistent root instead of reporting a successful scan', (context) => { + const directory = mkdtempSync(join(tmpdir(), 'community-markdown-')); + context.after(() => rmSync(directory, { recursive: true, force: true })); + const result = spawnSync(process.execPath, [script, join(directory, 'missing')], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /ENOENT|not a directory/); +}); diff --git a/.gitignore b/.gitignore index 0d5ccaf..4aeb3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ Thumbs.db *.code-workspace *.swp *~ + +# Local validation dependencies +node_modules/ diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..1dda03d --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +26.13.0 diff --git a/README.md b/README.md index 0e884f9..c246a81 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,27 @@ `PSModule/.github` is GitHub's special public organization-defaults repository. GitHub uses its community files when a PSModule repository does not define its own corresponding file. + +## Validate changes + +Use the Node.js LTS version in `.node-version`, install the locked tooling, and +run the same validation command used by the PR check: + +```powershell +npm ci --ignore-scripts +npm run validate +``` + +`npm test` runs isolated valid and malformed fixtures. `npm run lint:markdown` +checks every authored Markdown file, including hidden community directories, +with MSX's [shared Markdown rules][shared-markdown-rules]. Empty scans, blank +files, lint findings, and tool errors fail. Dependency and Git metadata trees +are excluded. + +The rule file is an unchanged snapshot of the linked merged revision. Refresh +it from its owning source rather than adding repository-specific rule overrides. +The check measures Markdown syntax/style, not PR-authoring completeness, source +correspondence, release decisions, or fallback propagation. Repositories with +local community files keep their own files. + +[shared-markdown-rules]: https://github.com/MSXOrg/docs/blob/36da9154aa55d2052345fb2f28b812b29daf8bc9/.github/linters/.markdown-lint.yml diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7503695 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1419 @@ +{ + "name": "psmodule-community-validation", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "psmodule-community-validation", + "devDependencies": { + "markdownlint-cli2": "0.24.0" + }, + "engines": { + "node": ">=26.13.0 <27" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.4.tgz", + "integrity": "sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "micromatch": "^4.0.8", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.3.tgz", + "integrity": "sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, + "node_modules/markdown-it": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz", + "integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.3", + "entities": "^8.1.0", + "linkify-it": "^6.1.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz", + "integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, + "node_modules/markdownlint": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.42.0.tgz", + "integrity": "sha512-kzsCV/rPWRFn3jUhcB919YF4Qgeh0g9tcogoXFadGSo6Zxj6662YOqWXv0jDn8nv5KCCxDfMKJZv6N4HkNkvTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "4.0.3", + "micromark-core-commonmark": "2.0.4", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.2", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.3", + "re2js": "2.8.6", + "string-width": "8.3.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.24.0.tgz", + "integrity": "sha512-c5hU2MDoTlS230MLzdNlkIyxImXq5acxGKz4nYz4aDhH/2BgFtpsA7iK9/OoTHMgZ4FuR0wedTQA31K6Lp9I/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "16.2.4", + "js-yaml": "5.4.3", + "jsonc-parser": "3.3.1", + "jsonpointer": "5.0.1", + "markdown-it": "15.0.3", + "markdownlint": "0.42.0", + "markdownlint-cli2-formatter-default": "0.0.6", + "micromatch": "4.0.8", + "smol-toml": "1.9.1" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2-bin.mjs" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.6.tgz", + "integrity": "sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + }, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/re2js": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", + "integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/smol-toml": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", + "integrity": "sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1eb2858 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "psmodule-community-validation", + "private": true, + "type": "module", + "engines": { + "node": ">=26.13.0 <27" + }, + "scripts": { + "test": "node --test .github/tests/validate-markdown.test.mjs", + "lint:markdown": "node .github/scripts/validate-markdown.mjs", + "validate": "npm test && npm run lint:markdown" + }, + "devDependencies": { + "markdownlint-cli2": "0.24.0" + } +} From 0b749ae3edf9d780b1dc4d095dbc71733a0885a7 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 14:12:38 +0100 Subject: [PATCH 02/10] Run read-only community validation on pull request heads --- .github/dependabot.yml | 12 +++++ .../workflows/Validate-Community-Files.yml | 53 +++++++++++++++++++ .pre-commit-config.yaml | 10 ++++ README.md | 11 ++++ 4 files changed, 86 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/Validate-Community-Files.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bd9de83 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +--- +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + + - package-ecosystem: npm + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/Validate-Community-Files.yml b/.github/workflows/Validate-Community-Files.yml new file mode 100644 index 0000000..c30448f --- /dev/null +++ b/.github/workflows/Validate-Community-Files.yml @@ -0,0 +1,53 @@ +--- +name: Community file validation + +'on': + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - edited + - ready_for_review + +permissions: {} + +concurrency: + group: community-file-validation-${{ github.event.pull_request.number }} + cancel-in-progress: true + +defaults: + run: + shell: pwsh + +jobs: + validate: + name: Community file validation + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Check out the actual PR head + # yamllint disable-line rule:line-length + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Set up the declared Node.js LTS + # yamllint disable-line rule:line-length + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + + - name: Record the checked-out candidate + run: git rev-parse HEAD + + - name: Install locked validation tooling + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Validate fixtures and community Markdown + run: npm run validate diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7f54b7b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +--- +repos: + - repo: local + hooks: + - id: community-file-validation + name: Community file validation + entry: npm run validate + language: system + pass_filenames: false + always_run: true diff --git a/README.md b/README.md index c246a81..45cd4de 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ npm ci --ignore-scripts npm run validate ``` +The local pre-commit hook invokes `npm run validate` too. After installing +the pre-commit runner, use `pre-commit install` to enable it in your checkout +and `pre-commit run --all-files` to check all files before committing. + `npm test` runs isolated valid and malformed fixtures. `npm run lint:markdown` checks every authored Markdown file, including hidden community directories, with MSX's [shared Markdown rules][shared-markdown-rules]. Empty scans, blank @@ -26,4 +30,11 @@ The check measures Markdown syntax/style, not PR-authoring completeness, source correspondence, release decisions, or fallback propagation. Repositories with local community files keep their own files. +Every PR targeting `main`, including drafts, runs the named +`Community file validation` job against its actual head. There is no path or +draft filter, manual-dispatch substitute, write token, or publisher. Making +this result mandatory requires separately authorized branch governance to +require that exact check name and its GitHub Actions integration. Do not rename +the check independently of its protection rule. + [shared-markdown-rules]: https://github.com/MSXOrg/docs/blob/36da9154aa55d2052345fb2f28b812b29daf8bc9/.github/linters/.markdown-lint.yml From 8b501094f5797beb8603b7b519f36e437d3fc272 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 14:20:35 +0100 Subject: [PATCH 03/10] Escape validation paths in failure diagnostics --- .github/scripts/validate-markdown.mjs | 4 ++-- .github/tests/validate-markdown.test.mjs | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/scripts/validate-markdown.mjs b/.github/scripts/validate-markdown.mjs index 77f900d..b36d07e 100644 --- a/.github/scripts/validate-markdown.mjs +++ b/.github/scripts/validate-markdown.mjs @@ -21,11 +21,11 @@ const configurationPath = fileURLToPath(new URL('../linters/.markdown-lint.yml', const configuration = await readConfig(configurationPath, [yamlParse]); const files = findMarkdownFiles(directory); if (files.length === 0) { - throw new Error(`No Markdown files were checked in ${directory}. Check the validation root.`); + throw new Error(`No Markdown files were checked in ${JSON.stringify(directory)}. Check the validation root.`); } for (const file of files) { if (readFileSync(file, 'utf8').trim().length === 0) { - throw new Error(`Empty Markdown file: ${file}. Restore its content before validation.`); + throw new Error(`Empty Markdown file: ${JSON.stringify(file)}. Restore its content before validation.`); } } diff --git a/.github/tests/validate-markdown.test.mjs b/.github/tests/validate-markdown.test.mjs index 71a6acf..51a7680 100644 --- a/.github/tests/validate-markdown.test.mjs +++ b/.github/tests/validate-markdown.test.mjs @@ -68,6 +68,9 @@ test('rejects a scan with no Markdown files', (context) => { const result = validateFixture(context, { 'unrelated.txt': 'Not Markdown.\n' }); assert.notEqual(result.status, 0); assert.match(result.stderr, /No Markdown files were checked/); + const path = result.stderr.match(/No Markdown files were checked in (".*")\. Check the validation root/); + assert.ok(path, result.stderr); + assert.equal(typeof JSON.parse(path[1]), 'string'); }); test('rejects blank inherited files even when other files are valid', (context) => { @@ -78,6 +81,9 @@ test('rejects blank inherited files even when other files are valid', (context) assert.notEqual(result.status, 0); assert.match(result.stderr, /Empty Markdown file/); assert.match(result.stderr, /CONTRIBUTING\.md/); + const path = result.stderr.match(/Empty Markdown file: (".*")\. Restore its content/); + assert.ok(path, result.stderr); + assert.ok(JSON.parse(path[1]).endsWith('CONTRIBUTING.md')); }); test('ignores dependency and Git metadata trees, not hidden community files', (context) => { From 0ac62ba9ed02c40d92d5ac5ad68d04fd2ab2fbb0 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 14:48:00 +0100 Subject: [PATCH 04/10] Validate complete Git-aware community Markdown inputs --- .github/scripts/validate-markdown.mjs | 60 +++++++++-- .github/tests/validate-markdown.test.mjs | 125 +++++++++++++++++++++-- README.md | 33 ++++-- 3 files changed, 190 insertions(+), 28 deletions(-) diff --git a/.github/scripts/validate-markdown.mjs b/.github/scripts/validate-markdown.mjs index b36d07e..32dadaf 100644 --- a/.github/scripts/validate-markdown.mjs +++ b/.github/scripts/validate-markdown.mjs @@ -1,29 +1,67 @@ -import { readFileSync, readdirSync } from 'node:fs'; -import { extname, join, relative, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { lstatSync, readFileSync, realpathSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { lint, readConfig } from 'markdownlint-cli2/markdownlint/promise'; import yamlParse from 'markdownlint-cli2/parsers/yaml'; -/** Find authored Markdown without traversing dependency or Git metadata trees. */ +/** Read repository file metadata without invoking a shell. */ +function readGitOutput(directory, args) { + const result = spawnSync('git', ['-C', directory, ...args], { encoding: 'utf8' }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`Git file discovery failed in ${JSON.stringify(directory)}: ${JSON.stringify(result.stderr.trim())}`); + } + return result.stdout; +} + +/** Select tracked and nonignored Markdown, retaining Git symlink metadata. */ function findMarkdownFiles(directory) { - return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const path = join(directory, entry.name); - if (entry.isDirectory()) { - return ['.git', 'node_modules'].includes(entry.name) ? [] : findMarkdownFiles(path); - } - return entry.isFile() && extname(entry.name).toLowerCase() === '.md' ? [path] : []; - }); + const files = readGitOutput(directory, [ + 'ls-files', '--cached', '--others', '--exclude-standard', '--deduplicate', '-z', + ]).split('\0') + .filter((name) => /\.(?:md|markdown)$/i.test(name) && !name.split('/').includes('node_modules')) + .map((name) => join(directory, ...name.split('/'))) + .sort(); + // Git symlinks can be ordinary path-text files in Windows checkouts. + const symlinks = new Set(readGitOutput(directory, ['ls-files', '--stage', '-z']) + .split('\0') + .filter((record) => record.startsWith('120000 ')) + .map((record) => join(directory, ...record.slice(record.indexOf('\t') + 1).split('/')))); + return { files, symlinks }; } // Optional first argument selects an isolated root; the shared rules stay fixed. const directory = resolve(process.argv[2] ?? process.cwd()); +if (!lstatSync(directory).isDirectory()) { + throw new Error(`Validation root is not a directory: ${JSON.stringify(directory)}`); +} +const repositoryRoot = readGitOutput(directory, ['rev-parse', '--show-toplevel']).replace(/\r?\n$/, ''); +if (realpathSync(directory) !== realpathSync(repositoryRoot)) { + throw new Error(`Validation must run from the repository root: ${JSON.stringify(repositoryRoot)}`); +} const configurationPath = fileURLToPath(new URL('../linters/.markdown-lint.yml', import.meta.url)); const configuration = await readConfig(configurationPath, [yamlParse]); -const files = findMarkdownFiles(directory); +const { files, symlinks } = findMarkdownFiles(directory); if (files.length === 0) { throw new Error(`No Markdown files were checked in ${JSON.stringify(directory)}. Check the validation root.`); } +for (const name of ['pull_request_template.md', 'CONTRIBUTING.md']) { + const file = join(directory, '.github', name); + if (!files.includes(file)) { + throw new Error(`Required community file is missing from the validation input: ${JSON.stringify(file)}. Restore the file or explicitly change the contract.`); + } +} for (const file of files) { + const metadata = lstatSync(file); + if (metadata.isSymbolicLink() || symlinks.has(file)) { + throw new Error(`Markdown symlinks are not allowed: ${JSON.stringify(file)}. Use a regular file instead.`); + } + if (!metadata.isFile()) { + throw new Error(`Markdown input must be a regular file: ${JSON.stringify(file)}`); + } if (readFileSync(file, 'utf8').trim().length === 0) { throw new Error(`Empty Markdown file: ${JSON.stringify(file)}. Restore its content before validation.`); } diff --git a/.github/tests/validate-markdown.test.mjs b/.github/tests/validate-markdown.test.mjs index 51a7680..d3f1be6 100644 --- a/.github/tests/validate-markdown.test.mjs +++ b/.github/tests/validate-markdown.test.mjs @@ -1,27 +1,55 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; const script = fileURLToPath(new URL('../scripts/validate-markdown.mjs', import.meta.url)); +const gitignore = readFileSync(new URL('../../.gitignore', import.meta.url), 'utf8'); +const communityFiles = { + '.github/CONTRIBUTING.md': '# Contributing\n', + '.github/pull_request_template.md': 'Describe the community outcome.\n', +}; -/** Run the real validator against disposable, anonymous Markdown files. */ -function validateFixture(context, files) { +/** Keep anonymous fixture Git operations isolated from personal hooks. */ +function fixtureGit(directory, args, input) { + const result = spawnSync('git', [ + '-c', `core.hooksPath=${join(directory, '.git', 'fixture-hooks')}`, + '-C', directory, ...args, + ], { encoding: 'utf8', input }); + assert.equal(result.status, 0, result.stderr); + return result.stdout; +} + +/** Create a disposable Git root with the same authored-input contract. */ +function createFixture(context, files) { const directory = mkdtempSync(join(tmpdir(), 'community-markdown-')); context.after(() => rmSync(directory, { recursive: true, force: true })); - for (const [name, content] of Object.entries(files)) { + fixtureGit(directory, ['init', '--quiet']); + fixtureGit(directory, ['config', 'core.excludesfile', join(directory, '.git', 'fixture-excludes')]); + for (const [name, content] of Object.entries({ + '.gitignore': gitignore, + '.git/fixture-excludes': '', + ...files, + })) { const path = join(directory, name); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); } + return directory; +} + +/** Run the real validator against disposable, anonymous Markdown files. */ +function validateFixture(context, files) { + const directory = createFixture(context, files); return spawnSync(process.execPath, [script, directory], { encoding: 'utf8' }); } test('checks both inherited community paths and the repository README', (context) => { const result = validateFixture(context, { + ...communityFiles, 'README.md': '# Community defaults\n', '.github/CONTRIBUTING.md': '# Contributing\n\nFollow the shared workflow.\n', '.github/pull_request_template.md': @@ -36,6 +64,7 @@ test('checks both inherited community paths and the repository README', (context for (const name of ['.github/pull_request_template.md', '.github/CONTRIBUTING.md']) { test(`rejects malformed Markdown in ${name}`, (context) => { const result = validateFixture(context, { + ...communityFiles, 'README.md': '# Community defaults\n', [name]: '# Community file\n\n### Skipped heading level\n', }); @@ -47,6 +76,7 @@ for (const name of ['.github/pull_request_template.md', '.github/CONTRIBUTING.md test('rejects duplicate document titles', (context) => { const result = validateFixture(context, { + ...communityFiles, '.github/CONTRIBUTING.md': '# Contributing\n\n# Another title\n', }); assert.equal(result.status, 1, result.stderr); @@ -57,11 +87,12 @@ test('accepts source-shaped announcement and result-scoped blocks as Markdown', const sections = ['Breaking', 'Removed', 'New', 'Changed', 'Fixed'].map((classifier) => `## ${classifier}: Example result\n\nReaders inspect the affected setting and verify the documented result. No additional action applies to unaffected uses.\n\n
\nTechnical details\n\nAnonymous validation evidence.\n\n
\n\n
\nRelated references\n\nOptional supporting context.\n\n
\n`); const result = validateFixture(context, { + ...communityFiles, '.github/pull_request_template.md': `Readers receive complete adoption instructions for the affected uses.\n\n\n\n${sections.join('\n')}`, }); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Checked 1 Markdown files; result: passed/); + assert.match(result.stdout, /Checked 2 Markdown files; result: passed/); }); test('rejects a scan with no Markdown files', (context) => { @@ -75,6 +106,7 @@ test('rejects a scan with no Markdown files', (context) => { test('rejects blank inherited files even when other files are valid', (context) => { const result = validateFixture(context, { + ...communityFiles, 'README.md': '# Community defaults\n', '.github/CONTRIBUTING.md': ' \n\t\n', }); @@ -88,12 +120,93 @@ test('rejects blank inherited files even when other files are valid', (context) test('ignores dependency and Git metadata trees, not hidden community files', (context) => { const result = validateFixture(context, { + ...communityFiles, '.github/CONTRIBUTING.md': '# Contributing\n', 'node_modules/dependency/README.md': '# Dependency\n\n### Invalid heading\n', '.git/README.md': '# Git metadata\n\n### Invalid heading\n', }); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Checked 1 Markdown files; result: passed/); + assert.match(result.stdout, /Checked 2 Markdown files; result: passed/); +}); + +test('ignores untracked editor history using the repository ignore rules', (context) => { + const result = validateFixture(context, { + ...communityFiles, + '.history/README_old.md': '# Old snapshot\n\n### Invalid heading\n', + '.idea/notes.md': '# Editor notes\n\n### Invalid heading\n', + '.vscode/notes.md': '# Editor notes\n\n### Invalid heading\n', + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Checked 2 Markdown files; result: passed/); +}); + +test('still validates deliberately tracked Markdown under an ignored directory', (context) => { + const directory = createFixture(context, { + ...communityFiles, + '.history/tracked.md': '# Authored Markdown\n\n### Invalid heading\n', + }); + fixtureGit(directory, ['add', '--force', '--', '.history/tracked.md']); + const result = spawnSync(process.execPath, [script, directory], { encoding: 'utf8' }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /MD001/); +}); + +test('validates the .markdown extension instead of silently skipping it', (context) => { + const result = validateFixture(context, { + ...communityFiles, + 'README.markdown': '# Markdown document\n\n### Invalid heading\n', + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /MD001/); +}); + +for (const name of Object.keys(communityFiles)) { + test(`rejects missing required ${name} even when other Markdown remains`, (context) => { + const files = { ...communityFiles, 'README.md': '# Community defaults\n' }; + delete files[name]; + const result = validateFixture(context, files); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Required community file/); + assert.ok(result.stderr.includes(name.split('/').at(-1))); + }); +} + +test('rejects Git symlink entries even when Windows materializes them as regular path text', (context) => { + const directory = createFixture(context, { + ...communityFiles, + 'guide.md': 'target.txt\n', + 'target.txt': '# Invalid target\n\n### Skipped heading level\n', + }); + const object = fixtureGit(directory, ['hash-object', '-w', '--stdin'], 'target.txt\n').trim(); + fixtureGit(directory, ['update-index', '--add', '--cacheinfo', `120000,${object},guide.md`]); + const result = spawnSync(process.execPath, [script, directory], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /symlink|symbolic link/i); + assert.match(result.stderr, /guide\.md/); +}); + +test('rejects a non-Git root rather than silently changing the input contract', (context) => { + const directory = mkdtempSync(join(tmpdir(), 'community-markdown-')); + context.after(() => rmSync(directory, { recursive: true, force: true })); + for (const [name, content] of Object.entries(communityFiles)) { + const path = join(directory, name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + } + const result = spawnSync(process.execPath, [script, directory], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Git file discovery failed/); +}); + +test('rejects a repository subdirectory instead of validating only a partial checkout', (context) => { + const directory = createFixture(context, { + ...communityFiles, + 'nested/.github/CONTRIBUTING.md': '# Nested contribution fixture\n', + 'nested/.github/pull_request_template.md': 'Nested template fixture.\n', + }); + const result = spawnSync(process.execPath, [script, join(directory, 'nested')], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /repository root/); }); test('rejects a nonexistent root instead of reporting a successful scan', (context) => { diff --git a/README.md b/README.md index 45cd4de..38b59d8 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ own corresponding file. ## Validate changes -Use the Node.js LTS version in `.node-version`, install the locked tooling, and -run the same validation command used by the PR check: +Use Git and the Node.js LTS version in `.node-version`, install the locked +tooling, and run the same validation command used by the PR check: ```powershell npm ci --ignore-scripts @@ -18,11 +18,20 @@ The local pre-commit hook invokes `npm run validate` too. After installing the pre-commit runner, use `pre-commit install` to enable it in your checkout and `pre-commit run --all-files` to check all files before committing. -`npm test` runs isolated valid and malformed fixtures. `npm run lint:markdown` -checks every authored Markdown file, including hidden community directories, -with MSX's [shared Markdown rules][shared-markdown-rules]. Empty scans, blank -files, lint findings, and tool errors fail. Dependency and Git metadata trees -are excluded. +`npm test` runs isolated valid and malformed Git fixtures. `npm run lint:markdown` +checks tracked `.md` and `.markdown` files and untracked files not ignored by Git, +including hidden community directories, with MSX's [shared Markdown rules][shared-markdown-rules]. +Ignored editor history, dependency trees, and Git metadata do not affect the +scan. Deliberately tracked Markdown under an ignored directory is still checked; +`node_modules` stays excluded. + +Both `.github/pull_request_template.md` and `.github/CONTRIBUTING.md` must be +present as regular, nonempty validation inputs. Missing or renamed required +files, Markdown symlinks, empty scans, blank files, lint findings, and tool +errors fail. Symlinks are rejected rather than followed, including Git symlinks +materialized as path-text files on Windows. An intentional deletion or rename +requires an explicit change to this contract, not a skipped check. An isolated +root passed to the helper must be a Git repository root with the same two-file contract. The rule file is an unchanged snapshot of the linked merged revision. Refresh it from its owning source rather than adding repository-specific rule overrides. @@ -30,10 +39,12 @@ The check measures Markdown syntax/style, not PR-authoring completeness, source correspondence, release decisions, or fallback propagation. Repositories with local community files keep their own files. -Every PR targeting `main`, including drafts, runs the named -`Community file validation` job against its actual head. There is no path or -draft filter, manual-dispatch substitute, write token, or publisher. Making -this result mandatory requires separately authorized branch governance to +Normal PR events targeting `main`, including drafts, run the named +`Community file validation` job against the actual head. GitHub does not start +`pull_request` workflows for PRs with merge conflicts; resolve the conflicts +and obtain a fresh current-head result before readiness. There is no path or +draft filter, manual-dispatch substitute, elevated trigger, write token, or +publisher. Making this result mandatory requires separately authorized branch governance to require that exact check name and its GitHub Actions integration. Do not rename the check independently of its protection rule. From 772423f977ea332061311b3ff62656d73188de9b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 15:04:10 +0100 Subject: [PATCH 05/10] Require an exact checked-out PR head before validation --- .github/tests/validate-markdown.test.mjs | 20 +++++++++++++++++++ .../workflows/Validate-Community-Files.yml | 12 +++++++++-- README.md | 8 +++++--- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.github/tests/validate-markdown.test.mjs b/.github/tests/validate-markdown.test.mjs index d3f1be6..e8e3d65 100644 --- a/.github/tests/validate-markdown.test.mjs +++ b/.github/tests/validate-markdown.test.mjs @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import yamlParse from 'markdownlint-cli2/parsers/yaml'; const script = fileURLToPath(new URL('../scripts/validate-markdown.mjs', import.meta.url)); const gitignore = readFileSync(new URL('../../.gitignore', import.meta.url), 'utf8'); @@ -216,3 +217,22 @@ test('rejects a nonexistent root instead of reporting a successful scan', (conte assert.notEqual(result.status, 0); assert.match(result.stderr, /ENOENT|not a directory/); }); + +for (const expectedHead of ['not-a-pr-head', '']) { + test(`candidate identity rejects ${expectedHead ? 'mismatched' : 'absent'} PR head metadata`, () => { + const workflow = yamlParse(readFileSync( + new URL('../workflows/Validate-Community-Files.yml', import.meta.url), 'utf8')); + const step = workflow.jobs.validate.steps.find((candidate) => + typeof candidate.run === 'string' && candidate.run.includes('git rev-parse HEAD')); + assert.ok(step, 'The workflow must verify the checked-out candidate.'); + const result = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', step.run], { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + encoding: 'utf8', + env: { ...process.env, PR_HEAD_SHA: expectedHead }, + }); + assert.ifError(result.error); + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /Checked-out commit does not match the PR head/); + assert.equal(step.env?.PR_HEAD_SHA, '${{ github.event.pull_request.head.sha }}'); + }); +} diff --git a/.github/workflows/Validate-Community-Files.yml b/.github/workflows/Validate-Community-Files.yml index c30448f..510eca0 100644 --- a/.github/workflows/Validate-Community-Files.yml +++ b/.github/workflows/Validate-Community-Files.yml @@ -43,8 +43,16 @@ jobs: with: node-version-file: .node-version - - name: Record the checked-out candidate - run: git rev-parse HEAD + - name: Verify the checked-out PR head + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + $ErrorActionPreference = 'Stop' + $actualHead = git rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $actualHead -ne $env:PR_HEAD_SHA) { + throw 'Checked-out commit does not match the PR head.' + } + Write-Output -InputObject $actualHead - name: Install locked validation tooling run: npm ci --ignore-scripts --no-audit --no-fund diff --git a/README.md b/README.md index 38b59d8..4993ffa 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ own corresponding file. ## Validate changes -Use Git and the Node.js LTS version in `.node-version`, install the locked -tooling, and run the same validation command used by the PR check: +Use Git, PowerShell 7 (`pwsh`), and the Node.js LTS version in `.node-version`. +Install the locked tooling and run the same validation command used by the PR check: ```powershell npm ci --ignore-scripts @@ -40,7 +40,9 @@ correspondence, release decisions, or fallback propagation. Repositories with local community files keep their own files. Normal PR events targeting `main`, including drafts, run the named -`Community file validation` job against the actual head. GitHub does not start +`Community file validation` job against the actual head. The checkout must match +the PR-head metadata; absent or mismatched identity fails instead of accepting +a default checkout. GitHub does not start `pull_request` workflows for PRs with merge conflicts; resolve the conflicts and obtain a fresh current-head result before readiness. There is no path or draft filter, manual-dispatch substitute, elevated trigger, write token, or From 66cd708ebf9270f264dd86f33b2bcee65d10c9fd Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 15:27:37 +0100 Subject: [PATCH 06/10] Isolate fixture Git environment and disable template copying --- .github/tests/validate-markdown.test.mjs | 97 +++++++++++++++++++++--- README.md | 5 ++ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/.github/tests/validate-markdown.test.mjs b/.github/tests/validate-markdown.test.mjs index e8e3d65..4b4657b 100644 --- a/.github/tests/validate-markdown.test.mjs +++ b/.github/tests/validate-markdown.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import test from 'node:test'; @@ -14,12 +14,21 @@ const communityFiles = { '.github/pull_request_template.md': 'Describe the community outcome.\n', }; +/** Isolate fixture Git state without changing the real repository environment. */ +function fixtureEnvironment(directory) { + return { + ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^GIT_/i.test(name))), + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: join(directory, '.git', 'fixture-global-config'), + }; +} + /** Keep anonymous fixture Git operations isolated from personal hooks. */ function fixtureGit(directory, args, input) { const result = spawnSync('git', [ '-c', `core.hooksPath=${join(directory, '.git', 'fixture-hooks')}`, '-C', directory, ...args, - ], { encoding: 'utf8', input }); + ], { encoding: 'utf8', input, env: fixtureEnvironment(directory) }); assert.equal(result.status, 0, result.stderr); return result.stdout; } @@ -28,7 +37,9 @@ function fixtureGit(directory, args, input) { function createFixture(context, files) { const directory = mkdtempSync(join(tmpdir(), 'community-markdown-')); context.after(() => rmSync(directory, { recursive: true, force: true })); - fixtureGit(directory, ['init', '--quiet']); + mkdirSync(join(directory, '.git')); + writeFileSync(join(directory, '.git', 'fixture-global-config'), ''); + fixtureGit(directory, ['init', '--quiet', '--template=']); fixtureGit(directory, ['config', 'core.excludesfile', join(directory, '.git', 'fixture-excludes')]); for (const [name, content] of Object.entries({ '.gitignore': gitignore, @@ -43,9 +54,17 @@ function createFixture(context, files) { } /** Run the real validator against disposable, anonymous Markdown files. */ +function runValidator(directory) { + return spawnSync(process.execPath, [script, directory], { + encoding: 'utf8', + env: fixtureEnvironment(directory), + }); +} + +/** Validate a disposable root using the same contract as repository CI. */ function validateFixture(context, files) { const directory = createFixture(context, files); - return spawnSync(process.execPath, [script, directory], { encoding: 'utf8' }); + return runValidator(directory); } test('checks both inherited community paths and the repository README', (context) => { @@ -147,7 +166,7 @@ test('still validates deliberately tracked Markdown under an ignored directory', '.history/tracked.md': '# Authored Markdown\n\n### Invalid heading\n', }); fixtureGit(directory, ['add', '--force', '--', '.history/tracked.md']); - const result = spawnSync(process.execPath, [script, directory], { encoding: 'utf8' }); + const result = runValidator(directory); assert.equal(result.status, 1, result.stderr); assert.match(result.stderr, /MD001/); }); @@ -180,7 +199,7 @@ test('rejects Git symlink entries even when Windows materializes them as regular }); const object = fixtureGit(directory, ['hash-object', '-w', '--stdin'], 'target.txt\n').trim(); fixtureGit(directory, ['update-index', '--add', '--cacheinfo', `120000,${object},guide.md`]); - const result = spawnSync(process.execPath, [script, directory], { encoding: 'utf8' }); + const result = runValidator(directory); assert.notEqual(result.status, 0); assert.match(result.stderr, /symlink|symbolic link/i); assert.match(result.stderr, /guide\.md/); @@ -194,7 +213,7 @@ test('rejects a non-Git root rather than silently changing the input contract', mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); } - const result = spawnSync(process.execPath, [script, directory], { encoding: 'utf8' }); + const result = runValidator(directory); assert.notEqual(result.status, 0); assert.match(result.stderr, /Git file discovery failed/); }); @@ -205,15 +224,75 @@ test('rejects a repository subdirectory instead of validating only a partial che 'nested/.github/CONTRIBUTING.md': '# Nested contribution fixture\n', 'nested/.github/pull_request_template.md': 'Nested template fixture.\n', }); - const result = spawnSync(process.execPath, [script, join(directory, 'nested')], { encoding: 'utf8' }); + const result = runValidator(join(directory, 'nested')); assert.notEqual(result.status, 0); assert.match(result.stderr, /repository root/); }); +test('fixture Git and validator subprocesses cannot use the caller index', (context) => { + const caller = mkdtempSync(join(tmpdir(), 'anonymous-caller-')); + context.after(() => rmSync(caller, { recursive: true, force: true })); + const environment = { + ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^GIT_/i.test(name))), + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: join(caller, 'empty-global-config'), + }; + writeFileSync(environment.GIT_CONFIG_GLOBAL, ''); + for (const args of [['init', '--quiet', '--template='], ['add', '--', 'sentinel.txt']]) { + writeFileSync(join(caller, 'sentinel.txt'), 'Anonymous caller data.\n'); + const result = spawnSync('git', [ + '-c', `core.hooksPath=${join(caller, '.git', 'fixture-hooks')}`, + '-C', caller, ...args, + ], { encoding: 'utf8', env: environment }); + assert.equal(result.status, 0, result.stderr); + } + const index = join(caller, '.git', 'index'); + const originalIndex = readFileSync(index); + const previousIndex = process.env.GIT_INDEX_FILE; + try { + process.env.GIT_INDEX_FILE = index; + const directory = createFixture(context, { ...communityFiles, 'guide.md': 'target.txt\n' }); + const object = fixtureGit(directory, ['hash-object', '-w', '--stdin'], 'target.txt\n').trim(); + fixtureGit(directory, ['update-index', '--add', '--cacheinfo', `120000,${object},guide.md`]); + const result = runValidator(directory); + assert.deepEqual(readFileSync(index), originalIndex, 'Disposable fixtures must not change the caller index.'); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /symlink/i); + } finally { + if (previousIndex === undefined) { + delete process.env.GIT_INDEX_FILE; + } else { + process.env.GIT_INDEX_FILE = previousIndex; + } + } +}); + +test('disposable Git initialization copies no user or default templates', (context) => { + const template = mkdtempSync(join(tmpdir(), 'anonymous-git-template-')); + context.after(() => rmSync(template, { recursive: true, force: true })); + mkdirSync(join(template, 'info')); + writeFileSync(join(template, 'info', 'exclude'), '*.md\n'); + const previousTemplate = process.env.GIT_TEMPLATE_DIR; + try { + process.env.GIT_TEMPLATE_DIR = template; + const directory = createFixture(context, communityFiles); + const result = runValidator(directory); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(join(directory, '.git', 'info', 'exclude')), false); + assert.match(result.stdout, /Checked 2 Markdown files; result: passed/); + } finally { + if (previousTemplate === undefined) { + delete process.env.GIT_TEMPLATE_DIR; + } else { + process.env.GIT_TEMPLATE_DIR = previousTemplate; + } + } +}); + test('rejects a nonexistent root instead of reporting a successful scan', (context) => { const directory = mkdtempSync(join(tmpdir(), 'community-markdown-')); context.after(() => rmSync(directory, { recursive: true, force: true })); - const result = spawnSync(process.execPath, [script, join(directory, 'missing')], { encoding: 'utf8' }); + const result = runValidator(join(directory, 'missing')); assert.notEqual(result.status, 0); assert.match(result.stderr, /ENOENT|not a directory/); }); diff --git a/README.md b/README.md index 4993ffa..af13abc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ Ignored editor history, dependency trees, and Git metadata do not affect the scan. Deliberately tracked Markdown under an ignored directory is still checked; `node_modules` stays excluded. +Fixture subprocesses discard inherited Git repository variables and use empty +global configuration with template copying disabled. This keeps a pre-commit +hook's real index and contributor configuration outside the disposable roots; +the subsequent real-repository lint retains its caller environment. + Both `.github/pull_request_template.md` and `.github/CONTRIBUTING.md` must be present as regular, nonempty validation inputs. Missing or renamed required files, Markdown symlinks, empty scans, blank files, lint findings, and tool From a009b6216d7eed833383698035c6a1f467fc494e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 16:17:29 +0100 Subject: [PATCH 07/10] Clarify validation applies only to central community sources --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index af13abc..21accc0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,14 @@ GitHub uses its community files when a PSModule repository does not define its own corresponding file. -## Validate changes +Ordinary PSModule repositories inherit these defaults. Keep a local community +file only when repository-specific content is needed; inheritance does not +require module-local template or `CONTRIBUTING.md` copies. + +## Validate central sources + +This validator protects the current source files in `PSModule/.github` only. +Its two required paths are not an ordinary-repository baseline. Use Git, PowerShell 7 (`pwsh`), and the Node.js LTS version in `.node-version`. Install the locked tooling and run the same validation command used by the PR check: @@ -30,13 +37,15 @@ global configuration with template copying disabled. This keeps a pre-commit hook's real index and contributor configuration outside the disposable roots; the subsequent real-repository lint retains its caller environment. -Both `.github/pull_request_template.md` and `.github/CONTRIBUTING.md` must be -present as regular, nonempty validation inputs. Missing or renamed required +In this special defaults repository, both `.github/pull_request_template.md` +and `.github/CONTRIBUTING.md` must be regular, nonempty validation inputs. +Missing or renamed required files, Markdown symlinks, empty scans, blank files, lint findings, and tool errors fail. Symlinks are rejected rather than followed, including Git symlinks materialized as path-text files on Windows. An intentional deletion or rename requires an explicit change to this contract, not a skipped check. An isolated -root passed to the helper must be a Git repository root with the same two-file contract. +test root passed to the helper models these central sources in a Git repository +root with the same two-file contract. The rule file is an unchanged snapshot of the linked merged revision. Refresh it from its owning source rather than adding repository-specific rule overrides. From 6609aa54978b84680b0166ffa3cd5cc8e5994abc Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 16:32:30 +0100 Subject: [PATCH 08/10] Keep resolved Markdown inputs inside the central repository --- .github/scripts/validate-markdown.mjs | 9 ++++++-- .github/tests/validate-markdown.test.mjs | 28 +++++++++++++++++++++++- README.md | 3 +++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/scripts/validate-markdown.mjs b/.github/scripts/validate-markdown.mjs index 32dadaf..bc30bbc 100644 --- a/.github/scripts/validate-markdown.mjs +++ b/.github/scripts/validate-markdown.mjs @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process'; import { lstatSync, readFileSync, realpathSync } from 'node:fs'; -import { join, relative, resolve } from 'node:path'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { lint, readConfig } from 'markdownlint-cli2/markdownlint/promise'; import yamlParse from 'markdownlint-cli2/parsers/yaml'; @@ -39,7 +39,8 @@ if (!lstatSync(directory).isDirectory()) { throw new Error(`Validation root is not a directory: ${JSON.stringify(directory)}`); } const repositoryRoot = readGitOutput(directory, ['rev-parse', '--show-toplevel']).replace(/\r?\n$/, ''); -if (realpathSync(directory) !== realpathSync(repositoryRoot)) { +const resolvedRepositoryRoot = realpathSync(directory); +if (resolvedRepositoryRoot !== realpathSync(repositoryRoot)) { throw new Error(`Validation must run from the repository root: ${JSON.stringify(repositoryRoot)}`); } const configurationPath = fileURLToPath(new URL('../linters/.markdown-lint.yml', import.meta.url)); @@ -62,6 +63,10 @@ for (const file of files) { if (!metadata.isFile()) { throw new Error(`Markdown input must be a regular file: ${JSON.stringify(file)}`); } + const resolvedLocation = relative(resolvedRepositoryRoot, realpathSync(file)); + if (resolvedLocation === '..' || resolvedLocation.startsWith(`..${sep}`) || isAbsolute(resolvedLocation)) { + throw new Error(`Markdown input resolves outside the repository root: ${JSON.stringify(file)}. Use a repository-local source.`); + } if (readFileSync(file, 'utf8').trim().length === 0) { throw new Error(`Empty Markdown file: ${JSON.stringify(file)}. Restore its content before validation.`); } diff --git a/.github/tests/validate-markdown.test.mjs b/.github/tests/validate-markdown.test.mjs index 4b4657b..d3aeed4 100644 --- a/.github/tests/validate-markdown.test.mjs +++ b/.github/tests/validate-markdown.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import test from 'node:test'; @@ -229,6 +229,32 @@ test('rejects a repository subdirectory instead of validating only a partial che assert.match(result.stderr, /repository root/); }); +test('rejects Markdown reached through a parent link outside the repository', (context) => { + const directory = createFixture(context, communityFiles); + fixtureGit(directory, ['add', '--', '.github']); + const outside = mkdtempSync(join(tmpdir(), 'anonymous-outside-sources-')); + context.after(() => rmSync(outside, { recursive: true, force: true })); + for (const [name, content] of Object.entries(communityFiles)) { + writeFileSync(join(outside, name.split('/').at(-1)), content); + } + renameSync(join(directory, '.github'), join(directory, 'original-sources')); + symlinkSync(outside, join(directory, '.github'), process.platform === 'win32' ? 'junction' : 'dir'); + const result = runValidator(directory); + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /outside the repository root/); +}); + +test('preserves Markdown parent aliases whose targets stay inside the repository', (context) => { + const directory = createFixture(context, communityFiles); + fixtureGit(directory, ['add', '--', '.github']); + const target = join(directory, 'central-sources'); + renameSync(join(directory, '.github'), target); + symlinkSync(target, join(directory, '.github'), process.platform === 'win32' ? 'junction' : 'dir'); + const result = runValidator(directory); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Checked 4 Markdown files; result: passed/); +}); + test('fixture Git and validator subprocesses cannot use the caller index', (context) => { const caller = mkdtempSync(join(tmpdir(), 'anonymous-caller-')); context.after(() => rmSync(caller, { recursive: true, force: true })); diff --git a/README.md b/README.md index 21accc0..b3485c9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ requires an explicit change to this contract, not a skipped check. An isolated test root passed to the helper models these central sources in a Git repository root with the same two-file contract. +Resolved Markdown inputs must stay inside this central repository. Parent +directory aliases to outside targets fail before their content is read. + The rule file is an unchanged snapshot of the linked merged revision. Refresh it from its owning source rather than adding repository-specific rule overrides. The check measures Markdown syntax/style, not PR-authoring completeness, source From 1d4538550a31d71d45a35ae9a0ae1a621f0fe00e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 22:19:21 +0100 Subject: [PATCH 09/10] Allow intentional PR-template absence in community validation --- .github/scripts/validate-markdown.mjs | 8 ++---- .github/tests/validate-markdown.test.mjs | 36 +++++++++++------------- README.md | 21 +++++++------- 3 files changed, 29 insertions(+), 36 deletions(-) diff --git a/.github/scripts/validate-markdown.mjs b/.github/scripts/validate-markdown.mjs index bc30bbc..fcbc0cf 100644 --- a/.github/scripts/validate-markdown.mjs +++ b/.github/scripts/validate-markdown.mjs @@ -49,11 +49,9 @@ const { files, symlinks } = findMarkdownFiles(directory); if (files.length === 0) { throw new Error(`No Markdown files were checked in ${JSON.stringify(directory)}. Check the validation root.`); } -for (const name of ['pull_request_template.md', 'CONTRIBUTING.md']) { - const file = join(directory, '.github', name); - if (!files.includes(file)) { - throw new Error(`Required community file is missing from the validation input: ${JSON.stringify(file)}. Restore the file or explicitly change the contract.`); - } +const contributionSource = join(directory, '.github', 'CONTRIBUTING.md'); +if (!files.includes(contributionSource)) { + throw new Error(`Required community file is missing from the validation input: ${JSON.stringify(contributionSource)}. Restore the file or explicitly change the contract.`); } for (const file of files) { const metadata = lstatSync(file); diff --git a/.github/tests/validate-markdown.test.mjs b/.github/tests/validate-markdown.test.mjs index d3aeed4..dfc24cb 100644 --- a/.github/tests/validate-markdown.test.mjs +++ b/.github/tests/validate-markdown.test.mjs @@ -11,7 +11,6 @@ const script = fileURLToPath(new URL('../scripts/validate-markdown.mjs', import. const gitignore = readFileSync(new URL('../../.gitignore', import.meta.url), 'utf8'); const communityFiles = { '.github/CONTRIBUTING.md': '# Contributing\n', - '.github/pull_request_template.md': 'Describe the community outcome.\n', }; /** Isolate fixture Git state without changing the real repository environment. */ @@ -67,21 +66,21 @@ function validateFixture(context, files) { return runValidator(directory); } -test('checks both inherited community paths and the repository README', (context) => { - const result = validateFixture(context, { +test('validates central sources without any PR-template file', (context) => { + const directory = createFixture(context, { ...communityFiles, 'README.md': '# Community defaults\n', '.github/CONTRIBUTING.md': '# Contributing\n\nFollow the shared workflow.\n', - '.github/pull_request_template.md': - '\n\n---\n\n
\nTechnical details\n\nImplementation evidence.\n\n
\n', }); + assert.equal(existsSync(join(directory, '.github', 'pull_request_template.md')), false); + const result = runValidator(directory); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Checked 3 Markdown files; result: passed/); + assert.match(result.stdout, /Checked 2 Markdown files; result: passed/); assert.match(result.stdout, /\.github[/\\]+CONTRIBUTING\.md/); - assert.match(result.stdout, /\.github[/\\]+pull_request_template\.md/); + assert.match(result.stdout, /README\.md/); }); -for (const name of ['.github/pull_request_template.md', '.github/CONTRIBUTING.md']) { +for (const name of ['guide.md', '.github/CONTRIBUTING.md']) { test(`rejects malformed Markdown in ${name}`, (context) => { const result = validateFixture(context, { ...communityFiles, @@ -103,13 +102,11 @@ test('rejects duplicate document titles', (context) => { assert.match(result.stderr, /MD025/); }); -test('accepts source-shaped announcement and result-scoped blocks as Markdown', (context) => { - const sections = ['Breaking', 'Removed', 'New', 'Changed', 'Fixed'].map((classifier) => - `## ${classifier}: Example result\n\nReaders inspect the affected setting and verify the documented result. No additional action applies to unaffected uses.\n\n
\nTechnical details\n\nAnonymous validation evidence.\n\n
\n\n
\nRelated references\n\nOptional supporting context.\n\n
\n`); +test('validates ordinary authored Markdown with supporting HTML blocks', (context) => { const result = validateFixture(context, { ...communityFiles, - '.github/pull_request_template.md': - `Readers receive complete adoption instructions for the affected uses.\n\n\n\n${sections.join('\n')}`, + 'guide.md': + '# Usage guide\n\nReaders follow the documented instructions.\n\n
\nSupporting details\n\nAdditional context.\n\n
\n', }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Checked 2 Markdown files; result: passed/); @@ -124,7 +121,7 @@ test('rejects a scan with no Markdown files', (context) => { assert.equal(typeof JSON.parse(path[1]), 'string'); }); -test('rejects blank inherited files even when other files are valid', (context) => { +test('rejects the blank central contribution source even when other files are valid', (context) => { const result = validateFixture(context, { ...communityFiles, 'README.md': '# Community defaults\n', @@ -146,7 +143,7 @@ test('ignores dependency and Git metadata trees, not hidden community files', (c '.git/README.md': '# Git metadata\n\n### Invalid heading\n', }); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Checked 2 Markdown files; result: passed/); + assert.match(result.stdout, /Checked 1 Markdown files; result: passed/); }); test('ignores untracked editor history using the repository ignore rules', (context) => { @@ -157,7 +154,7 @@ test('ignores untracked editor history using the repository ignore rules', (cont '.vscode/notes.md': '# Editor notes\n\n### Invalid heading\n', }); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Checked 2 Markdown files; result: passed/); + assert.match(result.stdout, /Checked 1 Markdown files; result: passed/); }); test('still validates deliberately tracked Markdown under an ignored directory', (context) => { @@ -222,7 +219,6 @@ test('rejects a repository subdirectory instead of validating only a partial che const directory = createFixture(context, { ...communityFiles, 'nested/.github/CONTRIBUTING.md': '# Nested contribution fixture\n', - 'nested/.github/pull_request_template.md': 'Nested template fixture.\n', }); const result = runValidator(join(directory, 'nested')); assert.notEqual(result.status, 0); @@ -252,7 +248,7 @@ test('preserves Markdown parent aliases whose targets stay inside the repository symlinkSync(target, join(directory, '.github'), process.platform === 'win32' ? 'junction' : 'dir'); const result = runValidator(directory); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Checked 4 Markdown files; result: passed/); + assert.match(result.stdout, /Checked 2 Markdown files; result: passed/); }); test('fixture Git and validator subprocesses cannot use the caller index', (context) => { @@ -293,7 +289,7 @@ test('fixture Git and validator subprocesses cannot use the caller index', (cont } }); -test('disposable Git initialization copies no user or default templates', (context) => { +test('disposable Git initialization copies no user or default Git templates', (context) => { const template = mkdtempSync(join(tmpdir(), 'anonymous-git-template-')); context.after(() => rmSync(template, { recursive: true, force: true })); mkdirSync(join(template, 'info')); @@ -305,7 +301,7 @@ test('disposable Git initialization copies no user or default templates', (conte const result = runValidator(directory); assert.equal(result.status, 0, result.stderr); assert.equal(existsSync(join(directory, '.git', 'info', 'exclude')), false); - assert.match(result.stdout, /Checked 2 Markdown files; result: passed/); + assert.match(result.stdout, /Checked 1 Markdown files; result: passed/); } finally { if (previousTemplate === undefined) { delete process.env.GIT_TEMPLATE_DIR; diff --git a/README.md b/README.md index b3485c9..a41fe7e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ require module-local template or `CONTRIBUTING.md` copies. ## Validate central sources This validator protects the current source files in `PSModule/.github` only. -Its two required paths are not an ordinary-repository baseline. +Its remaining required contribution source is not an ordinary-repository baseline. +PR-template files are not required, generated, or distributed by validation; +their intentional absence is valid. Use Git, PowerShell 7 (`pwsh`), and the Node.js LTS version in `.node-version`. Install the locked tooling and run the same validation command used by the PR check: @@ -37,15 +39,12 @@ global configuration with template copying disabled. This keeps a pre-commit hook's real index and contributor configuration outside the disposable roots; the subsequent real-repository lint retains its caller environment. -In this special defaults repository, both `.github/pull_request_template.md` -and `.github/CONTRIBUTING.md` must be regular, nonempty validation inputs. -Missing or renamed required -files, Markdown symlinks, empty scans, blank files, lint findings, and tool -errors fail. Symlinks are rejected rather than followed, including Git symlinks -materialized as path-text files on Windows. An intentional deletion or rename -requires an explicit change to this contract, not a skipped check. An isolated -test root passed to the helper models these central sources in a Git repository -root with the same two-file contract. +In this special defaults repository, `.github/CONTRIBUTING.md` remains a +required regular, nonempty source. Missing or renamed contribution sources, +Markdown symlinks, empty scans, blank files, lint findings, and tool errors fail. +Symlinks are rejected rather than followed, including Git links materialized as +path-text files on Windows. Isolated test roots contain ordinary authored +Markdown and the contribution source without copying a PR template. Resolved Markdown inputs must stay inside this central repository. Parent directory aliases to outside targets fail before their content is read. @@ -53,7 +52,7 @@ directory aliases to outside targets fail before their content is read. The rule file is an unchanged snapshot of the linked merged revision. Refresh it from its owning source rather than adding repository-specific rule overrides. The check measures Markdown syntax/style, not PR-authoring completeness, source -correspondence, release decisions, or fallback propagation. Repositories with +correspondence, release decisions, or template distribution. Repositories with local community files keep their own files. Normal PR events targeting `main`, including drafts, run the named From 26463d1ea1dac50773156abe9c490a574c1985c6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 22:29:09 +0100 Subject: [PATCH 10/10] Exclude intentionally deleted optional Markdown from scan inputs --- .github/scripts/validate-markdown.mjs | 4 ++- .github/tests/validate-markdown.test.mjs | 37 ++++++++++++++++++++++++ README.md | 2 ++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/scripts/validate-markdown.mjs b/.github/scripts/validate-markdown.mjs index fcbc0cf..e90904b 100644 --- a/.github/scripts/validate-markdown.mjs +++ b/.github/scripts/validate-markdown.mjs @@ -17,11 +17,13 @@ function readGitOutput(directory, args) { return result.stdout; } -/** Select tracked and nonignored Markdown, retaining Git symlink metadata. */ +/** Select existing tracked and nonignored Markdown, retaining Git symlink metadata. */ function findMarkdownFiles(directory) { + const deleted = new Set(readGitOutput(directory, ['ls-files', '--deleted', '-z']).split('\0')); const files = readGitOutput(directory, [ 'ls-files', '--cached', '--others', '--exclude-standard', '--deduplicate', '-z', ]).split('\0') + .filter((name) => !deleted.has(name)) .filter((name) => /\.(?:md|markdown)$/i.test(name) && !name.split('/').includes('node_modules')) .map((name) => join(directory, ...name.split('/'))) .sort(); diff --git a/.github/tests/validate-markdown.test.mjs b/.github/tests/validate-markdown.test.mjs index dfc24cb..75239ea 100644 --- a/.github/tests/validate-markdown.test.mjs +++ b/.github/tests/validate-markdown.test.mjs @@ -80,6 +80,43 @@ test('validates central sources without any PR-template file', (context) => { assert.match(result.stdout, /README\.md/); }); +test('accepts an unstaged deletion of optional tracked Markdown', (context) => { + const directory = createFixture(context, { + ...communityFiles, + 'guide.md': '# Optional guide\n', + }); + fixtureGit(directory, ['add', '--', '.github/CONTRIBUTING.md', 'guide.md']); + rmSync(join(directory, 'guide.md')); + const result = runValidator(directory); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Checked 1 Markdown files; result: passed/); +}); + +test('still rejects an unstaged deletion of the required contribution source', (context) => { + const directory = createFixture(context, { + ...communityFiles, + 'README.md': '# Community defaults\n', + }); + fixtureGit(directory, ['add', '--', '.github/CONTRIBUTING.md', 'README.md']); + rmSync(join(directory, '.github', 'CONTRIBUTING.md')); + const result = runValidator(directory); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Required community file/); +}); + +test('rejects an empty scan when all tracked Markdown was deleted', (context) => { + const directory = createFixture(context, { + ...communityFiles, + 'guide.md': '# Optional guide\n', + }); + fixtureGit(directory, ['add', '--', '.github/CONTRIBUTING.md', 'guide.md']); + rmSync(join(directory, '.github', 'CONTRIBUTING.md')); + rmSync(join(directory, 'guide.md')); + const result = runValidator(directory); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /No Markdown files were checked/); +}); + for (const name of ['guide.md', '.github/CONTRIBUTING.md']) { test(`rejects malformed Markdown in ${name}`, (context) => { const result = validateFixture(context, { diff --git a/README.md b/README.md index a41fe7e..68725e6 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ including hidden community directories, with MSX's [shared Markdown rules][share Ignored editor history, dependency trees, and Git metadata do not affect the scan. Deliberately tracked Markdown under an ignored directory is still checked; `node_modules` stays excluded. +Git-reported deletions of optional Markdown are excluded even before staging. +Deleting the required contribution source or every Markdown input still fails. Fixture subprocesses discard inherited Git repository variables and use empty global configuration with template copying disabled. This keeps a pre-commit