From ec57d36c26bf5a2635abde30ca6361b5439bacf7 Mon Sep 17 00:00:00 2001 From: Ricky White Date: Tue, 21 Jul 2026 19:21:55 -0400 Subject: [PATCH 1/2] chore: add automated snippet validation for CI - add scripts/validate-snippets.js for JSON, required-field, namespace, and duplicate-prefix checks - add npm test script to run snippet validation - update PR CI workflow to use npm test - refresh contributor/internal docs to reference npm test-based validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 8 +-- .github/workflows/ci.yml | 2 +- README.md | 2 +- package.json | 3 + scripts/validate-snippets.js | 111 ++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 scripts/validate-snippets.js 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..320c548 --- /dev/null +++ b/scripts/validate-snippets.js @@ -0,0 +1,111 @@ +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) { + 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).`); From b2ea4860b9a2d69e380178a5596b76aeced11104 Mon Sep 17 00:00:00 2001 From: Ricky White Date: Tue, 21 Jul 2026 19:28:24 -0400 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/validate-snippets.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/validate-snippets.js b/scripts/validate-snippets.js index 320c548..a2ae8fd 100644 --- a/scripts/validate-snippets.js +++ b/scripts/validate-snippets.js @@ -44,7 +44,11 @@ for (const catalog of catalogs) { const rawCatalog = fs.readFileSync(absolutePath, "utf8"); parsedCatalog = JSON.parse(rawCatalog); } catch (error) { - addError(`${catalog.path}: invalid JSON (${error.message})`); + if (error && error.code) { + addError(`${catalog.path}: failed to read file (${error.message})`); + } else { + addError(`${catalog.path}: invalid JSON (${error.message})`); + } continue; }