From e2115b5f00faad829b59a75faedbc8dec9b59283 Mon Sep 17 00:00:00 2001 From: Paul Farault Date: Tue, 12 May 2026 19:30:21 +0200 Subject: [PATCH] feat: add validation --- .github/workflows/validate.yml | 31 ++++ .gitignore | 2 + README.md | 17 ++ package-lock.json | 310 ++++++++++++++++++++++++++++++++ package.json | 23 +++ scripts/validate-frontmatter.js | 226 +++++++++++++++++++++++ 6 files changed, 609 insertions(+) create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/validate-frontmatter.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..8224518 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,31 @@ +name: Validate Meeting Notes + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Validate frontmatter + run: npm run validate + + - name: Success message + if: success() + run: echo "✅ All meeting notes validated successfully!" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c0a4a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +npm-debug.log* diff --git a/README.md b/README.md index d007289..aee38e7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,23 @@ Its goal is to provide a transparent and accessible record of: There notes are public and intended for contributors, stakeholders, and anyone interested in the project's evolution. +## Validation + +Meeting notes are automatically validated for consistency. Each file must include: + +- **Frontmatter** with: + - `date` (YYYY-MM-DD) + - `type` (`users` or `contributors`) +- **Filename** containing the date in YYYYMMDD format (e.g., `20240117.md`) +- **Directory** structure matching `{type}/{YYYY}/` + +Run validation locally: + +```bash +npm install +npm run validate +``` + ## Related - Main repository: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6f47530 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,310 @@ +{ + "name": "okdp-meeting-notes", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "okdp-meeting-notes", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "glob": "^13.0.6", + "gray-matter": "^4.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..854b921 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "okdp-meeting-notes", + "version": "1.0.0", + "description": "OKDP Meeting Notes", + "private": true, + "scripts": { + "validate": "node scripts/validate-frontmatter.js" + }, + "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "glob": "^13.0.6", + "gray-matter": "^4.0.3" + }, + "engines": { + "node": ">=16" + }, + "repository": { + "type": "git", + "url": "https://github.com/OKDP/meeting-notes" + }, + "license": "Apache-2.0" +} diff --git a/scripts/validate-frontmatter.js b/scripts/validate-frontmatter.js new file mode 100644 index 0000000..955df58 --- /dev/null +++ b/scripts/validate-frontmatter.js @@ -0,0 +1,226 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const matter = require('gray-matter'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); +const glob = require('glob'); + +// Define the schema for frontmatter validation +const schema = { + type: 'object', + required: ['date', 'type'], + properties: { + date: { + type: 'string', + format: 'date', + description: 'Meeting date in YYYY-MM-DD format' + }, + type: { + type: 'string', + enum: ['users', 'contributors'], + description: 'Type of meeting' + }, + attendees: { + type: 'array', + minItems: 1, + items: { + type: 'string', + minLength: 1 + }, + description: 'List of meeting attendees (optional)' + } + }, + additionalProperties: false +}; + +// Initialize validator +const ajv = new Ajv({ allErrors: true }); +addFormats(ajv); +const validate = ajv.compile(schema); + +/** + * Convert a date to YYYY-MM-DD format string + * @param {Date|string} date - The date to format (Date object or string) + * @returns {string} The date formatted as YYYY-MM-DD + */ +function formatDate(date) { + if (date instanceof Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + return String(date); +} + +/** + * Validate that the filename contains the date in frontmatter + * Expected format: filename must contain YYYYMMDD (e.g., YYYYMMDD.md or YYYYMMDD-meeting.md) + * @param {string} filePath - The full path to the file + * @param {Date|string} date - The date from frontmatter + * @returns {{valid: boolean, message?: string}} Validation result + */ +function validateFilename(filePath, date) { + const filename = path.basename(filePath, '.md'); + + // Convert date to YYYY-MM-DD format if it's a Date object + const dateStr = formatDate(date); + + // Convert date YYYY-MM-DD to YYYYMMDD + const expectedDate = dateStr.replace(/-/g, ''); + + // Check if filename contains the expected date + if (!filename.includes(expectedDate)) { + return { + valid: false, + message: `Filename "${filename}.md" does not contain date "${expectedDate}" (from frontmatter date "${dateStr}")` + }; + } + + return { valid: true }; +} + +/** + * Validate that the file is in the correct directory based on type + * Expected: users/YYYY/YYYYMMDD.md for type "users" + * @param {string} filePath - The full path to the file + * @param {string} type - The meeting type from frontmatter + * @param {Date|string} date - The date from frontmatter + * @returns {{valid: boolean, message?: string}} Validation result + */ +function validateDirectoryStructure(filePath, type, date) { + const normalizedPath = filePath.replace(/\\/g, '/'); + const parts = normalizedPath.split('/'); + + // Find the index of the type directory (e.g., "users") + const typeIndex = parts.findIndex(p => p === type); + + if (typeIndex === -1) { + return { + valid: false, + message: `File should be in "${type}/" directory but found in "${parts.slice(-3, -1).join('/')}"` + }; + } + + // Convert date to YYYY-MM-DD format if it's a Date object + const dateStr = formatDate(date); + + // Check if the year directory matches the date + const year = dateStr.substring(0, 4); + const yearDirectoryIndex = typeIndex + 1; + + if (yearDirectoryIndex >= parts.length - 1) { + return { + valid: false, + message: `File should be in "${type}/${year}/" directory structure` + }; + } + + const yearDirectory = parts[yearDirectoryIndex]; + + if (yearDirectory !== year) { + return { + valid: false, + message: `File should be in "${type}/${year}/" directory but found in "${type}/${yearDirectory}/"` + }; + } + + return { valid: true }; +} + +// Find all markdown files (excluding README.md) +const files = glob.sync('{users,contributors}/**/*.md', { + cwd: path.resolve(__dirname, '..'), + absolute: true +}); + +let hasErrors = false; +const errors = []; + +console.log(`Validating ${files.length} files...\n`); + +files.forEach(filePath => { + const relativePath = path.relative(process.cwd(), filePath); + const fileErrors = []; + + try { + const content = fs.readFileSync(filePath, 'utf8'); + // Parse frontmatter without automatic date conversion + const { data: frontmatter, isEmpty } = matter(content); + + // Convert Date objects to strings for validation + if (frontmatter.date instanceof Date) { + frontmatter.date = formatDate(frontmatter.date); + } + + // Check if frontmatter exists + if (isEmpty || Object.keys(frontmatter).length === 0) { + fileErrors.push('No frontmatter found'); + errors.push({ + file: relativePath, + errors: fileErrors + }); + hasErrors = true; + return; + } + + // Validate against schema + const valid = validate(frontmatter); + + if (!valid) { + validate.errors.forEach(err => { + fileErrors.push(`${err.instancePath || '/'}: ${err.message}`); + }); + } + + // Validate filename matches date + if (frontmatter.date) { + const filenameValidation = validateFilename(filePath, frontmatter.date); + if (!filenameValidation.valid) { + fileErrors.push(filenameValidation.message); + } + + // Validate directory structure + if (frontmatter.type) { + const directoryValidation = validateDirectoryStructure(filePath, frontmatter.type, frontmatter.date); + if (!directoryValidation.valid) { + fileErrors.push(directoryValidation.message); + } + } + } + + if (fileErrors.length > 0) { + errors.push({ + file: relativePath, + errors: fileErrors + }); + hasErrors = true; + } + + } catch (error) { + fileErrors.push(`Failed to parse file: ${error.message}`); + errors.push({ + file: relativePath, + errors: fileErrors + }); + hasErrors = true; + } +}); + +// Print results +if (hasErrors) { + console.error('Validation failed!\n'); + errors.forEach(({ file, errors }) => { + console.error(`File: ${file}`); + errors.forEach(err => { + console.error(` ✗ ${err}`); + }); + console.error(''); + }); + process.exit(1); +} else { + console.log('All files validated successfully!'); + process.exit(0); +}