diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bd1522f..a7add3a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,14 +20,14 @@ The extension has no activation code. Changes to a snippet catalog take effect t ## Commands -There are no npm scripts, automated tests, or lint configuration. The PR workflow mirrors these local validation commands: +There is no lint configuration. The PR workflow mirrors these local validation commands: ```bash # Install the locked packaging dependency npm ci -# Validate all snippet catalogs parse as JSON -node -e "for (const f of ['snippets/snippets.json','snippets/jinja-snippets.json','snippets/django-snippets.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))" +# Validate snippet catalogs and snippet metadata rules +npm test # Build a VSIX package (also validates the extension manifest) npx vsce package @@ -37,7 +37,7 @@ npx vsce package ## Pull request checks -PRs should keep the snippet catalogs parseable and the package manifest valid. The CI workflow runs `npm ci`, JSON parsing for all three snippet catalogs, and `npx vsce package` on every pull request. +PRs should keep snippet catalogs and metadata valid, and the package manifest valid. The CI workflow runs `npm ci`, `npm test`, and `npx vsce package` on every pull request. ## Releases diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90f8b20..23b0812 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: run: npm ci - name: Validate snippet catalogs - run: node -e "for (const f of ['snippets/snippets.json','snippets/jinja-snippets.json','snippets/django-snippets.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))" + run: npm test - name: Package extension run: npx vsce package diff --git a/README.md b/README.md index 3d39230..d878e46 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ Found a bug? Want a new snippet? Contributions are welcome! Every pull request runs a small validation suite on Node.js 20: 1. `npm ci` -2. Parse all snippet catalogs as JSON +2. `npm test` (JSON + snippet metadata validation) 3. `npx vsce package` If you want to mirror CI locally before opening a PR, run the same commands from the repository root. diff --git a/package.json b/package.json index 5f6b4c3..e91413f 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,9 @@ "url": "https://github.com/EndlessTrax/python-template-snippets" }, "homepage": "https://github.com/EndlessTrax/python-template-snippets/blob/master/README.md", + "scripts": { + "test": "node scripts/validate-snippets.js" + }, "devDependencies": { "@vscode/vsce": "^3.9.2" } diff --git a/scripts/validate-snippets.js b/scripts/validate-snippets.js new file mode 100644 index 0000000..a2ae8fd --- /dev/null +++ b/scripts/validate-snippets.js @@ -0,0 +1,115 @@ +const fs = require("fs"); +const path = require("path"); + +const catalogs = [ + { + path: "snippets/snippets.json", + label: "shared", + expectedPrefix: "pt* (excluding ptj-*/ptd-*)", + prefixValidator: (prefix) => + prefix.startsWith("pt") && + !prefix.startsWith("ptj-") && + !prefix.startsWith("ptd-"), + }, + { + path: "snippets/jinja-snippets.json", + label: "jinja", + expectedPrefix: "ptj-*", + prefixValidator: (prefix) => prefix.startsWith("ptj-"), + }, + { + path: "snippets/django-snippets.json", + label: "django", + expectedPrefix: "ptd-*", + prefixValidator: (prefix) => prefix.startsWith("ptd-"), + }, +]; + +const errors = []; +const seenPrefixes = new Map(); + +function addError(message) { + errors.push(message); +} + +function validateRequiredString(snippet, fieldName) { + return typeof snippet[fieldName] === "string" && snippet[fieldName].trim().length > 0; +} + +for (const catalog of catalogs) { + const absolutePath = path.resolve(catalog.path); + let parsedCatalog; + + try { + const rawCatalog = fs.readFileSync(absolutePath, "utf8"); + parsedCatalog = JSON.parse(rawCatalog); + } catch (error) { + if (error && error.code) { + addError(`${catalog.path}: failed to read file (${error.message})`); + } else { + addError(`${catalog.path}: invalid JSON (${error.message})`); + } + continue; + } + + if ( + parsedCatalog === null || + typeof parsedCatalog !== "object" || + Array.isArray(parsedCatalog) + ) { + addError(`${catalog.path}: root must be a JSON object keyed by snippet name`); + continue; + } + + for (const [snippetName, snippet] of Object.entries(parsedCatalog)) { + if (snippet === null || typeof snippet !== "object" || Array.isArray(snippet)) { + addError(`${catalog.path} > "${snippetName}": snippet definition must be an object`); + continue; + } + + if (!validateRequiredString(snippet, "prefix")) { + addError(`${catalog.path} > "${snippetName}": missing or invalid "prefix"`); + continue; + } + + const prefix = snippet.prefix; + + if (!Array.isArray(snippet.body) || snippet.body.length === 0) { + addError(`${catalog.path} > "${snippetName}": missing or invalid "body" (must be a non-empty array)`); + } else if (!snippet.body.every((line) => typeof line === "string")) { + addError(`${catalog.path} > "${snippetName}": invalid "body" (all entries must be strings)`); + } + + if (!validateRequiredString(snippet, "description")) { + addError(`${catalog.path} > "${snippetName}": missing or invalid "description"`); + } + + if (!catalog.prefixValidator(prefix)) { + addError( + `${catalog.path} > "${snippetName}": invalid prefix "${prefix}" for ${catalog.label} catalog (expected ${catalog.expectedPrefix})` + ); + } + + const existing = seenPrefixes.get(prefix); + if (existing) { + addError( + `duplicate prefix "${prefix}" found in ${catalog.path} > "${snippetName}" and ${existing.catalogPath} > "${existing.snippetName}"` + ); + } else { + seenPrefixes.set(prefix, { + catalogPath: catalog.path, + snippetName, + }); + } + } +} + +if (errors.length > 0) { + console.error("Snippet validation failed:\n"); + errors.forEach((error, index) => { + console.error(`${index + 1}. ${error}`); + }); + process.exit(1); +} + +console.log(`Snippet validation passed (${catalogs.length} catalogs, ${seenPrefixes.size} prefixes checked).`);