From 56e0fb0c57d3289f9b518ca7d5b3615d526047a5 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 19 May 2026 16:45:33 -0700 Subject: [PATCH 01/17] Validate that GAP directories only contain allowed files Restrict files in GAP directories to *.md and metadata.yml to prevent files that could poison developer environments (e.g. .nvmrc) from being merged. Co-Authored-By: Claude Opus 4.7 --- scripts/validate-structure.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/validate-structure.js b/scripts/validate-structure.js index 6c0b634..85683f2 100755 --- a/scripts/validate-structure.js +++ b/scripts/validate-structure.js @@ -9,7 +9,7 @@ * find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} node scripts/validate-structure.js {} */ -import { existsSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { basename, join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; @@ -114,6 +114,26 @@ function validateMetadata(dirPath, gapName) { } } +function validateAllowedFiles(dirPath, gapName) { + const entries = readdirSync(dirPath); + for (const entry of entries) { + const fullPath = join(dirPath, entry); + if (statSync(fullPath).isDirectory()) { + error( + gapName, + `Unexpected directory "${entry}" found. GAP directories may only contain *.md files and metadata.yml. If you believe this is in error, please ping @graphql/gaps-editors.`, + ); + } + if (entry === "metadata.yml" || entry.endsWith(".md")) { + continue; + } + error( + gapName, + `Unexpected file "${entry}" found. GAP directories may only contain *.md files and metadata.yml. If you believe this is in error, please ping @graphql/gaps-editors.`, + ); + } +} + function main() { const { positionals } = parseArgs({ allowPositionals: true, strict: true }); @@ -137,6 +157,9 @@ function main() { // Validate directory naming const gapName = validateDirectoryNaming(dirPath); + // Validate only allowed files are present + validateAllowedFiles(dirPath, gapName); + // Validate README.md exists validateReadmeExists(dirPath, gapName); From ec8a520a5b7d23bc9c7acc072d5050844f4c6a8e Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 2 Jun 2026 11:22:29 -0500 Subject: [PATCH 02/17] Allow dotfiles and metadata.json in GAP directories Address review feedback: skip dotfiles (e.g. .gitkeep) from the allowlist check, and permit metadata.json alongside metadata.yml. Co-Authored-By: Claude Opus 4.8 --- scripts/validate-structure.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/validate-structure.js b/scripts/validate-structure.js index 85683f2..f9ce506 100755 --- a/scripts/validate-structure.js +++ b/scripts/validate-structure.js @@ -121,15 +121,20 @@ function validateAllowedFiles(dirPath, gapName) { if (statSync(fullPath).isDirectory()) { error( gapName, - `Unexpected directory "${entry}" found. GAP directories may only contain *.md files and metadata.yml. If you believe this is in error, please ping @graphql/gaps-editors.`, + `Unexpected directory "${entry}" found. GAP directories may only contain *.md files, metadata.yml, and metadata.json. If you believe this is in error, please ping @graphql/gaps-editors.`, ); } - if (entry === "metadata.yml" || entry.endsWith(".md")) { + if ( + entry === "metadata.yml" || + entry === "metadata.json" || + entry.endsWith(".md") || + entry.startsWith(".") + ) { continue; } error( gapName, - `Unexpected file "${entry}" found. GAP directories may only contain *.md files and metadata.yml. If you believe this is in error, please ping @graphql/gaps-editors.`, + `Unexpected file "${entry}" found. GAP directories may only contain *.md files, metadata.yml, and metadata.json. If you believe this is in error, please ping @graphql/gaps-editors.`, ); } } From bc63d3c24e30804372e2fc2ddf84519c8f8017d5 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 2 Jun 2026 11:27:14 -0500 Subject: [PATCH 03/17] Skip dotfiles before all checks, not inside the allowlist Co-Authored-By: Claude Opus 4.8 --- scripts/validate-structure.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/validate-structure.js b/scripts/validate-structure.js index f9ce506..9a6c322 100755 --- a/scripts/validate-structure.js +++ b/scripts/validate-structure.js @@ -117,6 +117,9 @@ function validateMetadata(dirPath, gapName) { function validateAllowedFiles(dirPath, gapName) { const entries = readdirSync(dirPath); for (const entry of entries) { + if (entry.startsWith(".")) { + continue; + } const fullPath = join(dirPath, entry); if (statSync(fullPath).isDirectory()) { error( @@ -127,8 +130,7 @@ function validateAllowedFiles(dirPath, gapName) { if ( entry === "metadata.yml" || entry === "metadata.json" || - entry.endsWith(".md") || - entry.startsWith(".") + entry.endsWith(".md") ) { continue; } From 1c0503521d34598908683738a62c9b51c2a05c63 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 2 Jun 2026 11:28:08 -0500 Subject: [PATCH 04/17] Reject dotfiles: use !entry.startsWith(".") in allowlist Co-Authored-By: Claude Opus 4.8 --- scripts/validate-structure.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/validate-structure.js b/scripts/validate-structure.js index 9a6c322..9754e29 100755 --- a/scripts/validate-structure.js +++ b/scripts/validate-structure.js @@ -117,9 +117,6 @@ function validateMetadata(dirPath, gapName) { function validateAllowedFiles(dirPath, gapName) { const entries = readdirSync(dirPath); for (const entry of entries) { - if (entry.startsWith(".")) { - continue; - } const fullPath = join(dirPath, entry); if (statSync(fullPath).isDirectory()) { error( @@ -130,7 +127,8 @@ function validateAllowedFiles(dirPath, gapName) { if ( entry === "metadata.yml" || entry === "metadata.json" || - entry.endsWith(".md") + entry.endsWith(".md") || + !entry.startsWith(".") ) { continue; } From 9512f7be6c8e19d2b414c982ea61a13616bb5656 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 2 Jun 2026 11:28:39 -0500 Subject: [PATCH 05/17] =?UTF-8?q?Remove=20dotfile=20exception=20=E2=80=94?= =?UTF-8?q?=20only=20allow=20*.md,=20metadata.yml,=20metadata.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dotfiles like .nvmrc should still be rejected. Co-Authored-By: Claude Opus 4.8 --- scripts/validate-structure.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/validate-structure.js b/scripts/validate-structure.js index 9754e29..0bdbb47 100755 --- a/scripts/validate-structure.js +++ b/scripts/validate-structure.js @@ -127,8 +127,7 @@ function validateAllowedFiles(dirPath, gapName) { if ( entry === "metadata.yml" || entry === "metadata.json" || - entry.endsWith(".md") || - !entry.startsWith(".") + entry.endsWith(".md") ) { continue; } From daa16fe23e5dbdef39e42a38be9b06d2ea1bf963 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 2 Jun 2026 11:29:59 -0500 Subject: [PATCH 06/17] Explicitly reject dotfiles before the allowlist check Catches cases like .foo.md that would otherwise pass the .md check. Co-Authored-By: Claude Opus 4.8 --- scripts/validate-structure.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/validate-structure.js b/scripts/validate-structure.js index 0bdbb47..aa8e546 100755 --- a/scripts/validate-structure.js +++ b/scripts/validate-structure.js @@ -117,6 +117,12 @@ function validateMetadata(dirPath, gapName) { function validateAllowedFiles(dirPath, gapName) { const entries = readdirSync(dirPath); for (const entry of entries) { + if (entry.startsWith(".")) { + error( + gapName, + `Dotfiles are not allowed: "${entry}". If you believe this is in error, please ping @graphql/gaps-editors.`, + ); + } const fullPath = join(dirPath, entry); if (statSync(fullPath).isDirectory()) { error( From 2a6978206b132ef9d2abe4f4ffd647a9ea22c804 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 2 Jun 2026 13:17:08 -0500 Subject: [PATCH 07/17] Refactor file validation and add versions/ directory support Two plain functions, no abstractions. Validates YYYY-MM.md and YYYY-MM.yml in versions/, rejects dotfiles at both levels. Co-Authored-By: Claude Opus 4.8 --- scripts/validate-structure.js | 52 ++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/scripts/validate-structure.js b/scripts/validate-structure.js index aa8e546..fa81059 100755 --- a/scripts/validate-structure.js +++ b/scripts/validate-structure.js @@ -115,32 +115,46 @@ function validateMetadata(dirPath, gapName) { } function validateAllowedFiles(dirPath, gapName) { - const entries = readdirSync(dirPath); - for (const entry of entries) { + for (const entry of readdirSync(dirPath)) { if (entry.startsWith(".")) { - error( - gapName, - `Dotfiles are not allowed: "${entry}". If you believe this is in error, please ping @graphql/gaps-editors.`, - ); + error(gapName, `Dotfiles are not allowed: "${entry}".`); + continue; } + const fullPath = join(dirPath, entry); + if (statSync(fullPath).isDirectory()) { - error( - gapName, - `Unexpected directory "${entry}" found. GAP directories may only contain *.md files, metadata.yml, and metadata.json. If you believe this is in error, please ping @graphql/gaps-editors.`, - ); + if (entry === "versions") { + validateVersionsDir(fullPath, gapName); + } else { + error(gapName, `Unexpected directory "${entry}".`); + } + continue; } - if ( - entry === "metadata.yml" || - entry === "metadata.json" || - entry.endsWith(".md") - ) { + + if (entry === "metadata.yml" || entry === "metadata.json" || entry.endsWith(".md")) { continue; } - error( - gapName, - `Unexpected file "${entry}" found. GAP directories may only contain *.md files, metadata.yml, and metadata.json. If you believe this is in error, please ping @graphql/gaps-editors.`, - ); + + error(gapName, `Unexpected file "${entry}".`); + } +} + +function validateVersionsDir(dirPath, gapName) { + for (const entry of readdirSync(dirPath)) { + if (entry.startsWith(".")) { + error(gapName, `Dotfiles are not allowed in versions/: "${entry}".`); + continue; + } + + if (statSync(join(dirPath, entry)).isDirectory()) { + error(gapName, `Unexpected directory in versions/: "${entry}".`); + continue; + } + + if (!/^\d{4}-\d{2}\.(md|yml)$/.test(entry)) { + error(gapName, `Unexpected file in versions/: "${entry}". Only YYYY-MM.md and YYYY-MM.yml are allowed.`); + } } } From 7e5fb11c62401af05e69e37bcf076774b3c72ba4 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 9 Jul 2026 15:32:50 +0100 Subject: [PATCH 08/17] Convert to TypeScript --- package-lock.json | 57 +++++++++++++++++++ package.json | 7 ++- ...ate-structure.js => validate-structure.ts} | 6 +- tsconfig.json | 9 +++ 4 files changed, 75 insertions(+), 4 deletions(-) rename scripts/{validate-structure.js => validate-structure.ts} (96%) create mode 100644 tsconfig.json diff --git a/package-lock.json b/package-lock.json index e69676e..a3ff5bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "name": "@graphql/gaps", "devDependencies": { "@mlarah/spec-md": "^3.1.0", + "@tsconfig/node-ts": "^23.6.4", + "@tsconfig/node24": "^24.0.4", + "@types/node": "^24.13.2", + "@types/validator": "^13.15.10", "ajv": "^8.17.1", "cspell": "5.9.1", "handlebars": "^4.7.9", @@ -15,6 +19,7 @@ "nodemon": "2.0.20", "p-limit": "^7.3.0", "prettier": "^3.8.1", + "typescript": "^6.0.3", "validator": "^13.12.0", "yaml": "^2.7.0" }, @@ -369,12 +374,43 @@ "node": ">=16" } }, + "node_modules/@tsconfig/node-ts": { + "version": "23.6.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node-ts/-/node-ts-23.6.4.tgz", + "integrity": "sha512-37BMJvNQZ+vTgd1xG2TGBkJ6ENeT4eO4Wh2CHrnn0IwH7ybLFCzh4Uc//kc7UIvqiRac4uGdIc1meKOjMSlKzw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node24": { + "version": "24.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node24/-/node24-24.0.4.tgz", + "integrity": "sha512-2A933l5P5oCbv6qSxHs7ckKwobs8BDAe9SJ/Xr2Hy+nDlwmLE1GhFh/g/vXGRZWgxBg9nX/5piDtHR9Dkw/XuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "dev": true, "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "dev": true, + "license": "MIT" + }, "node_modules/ajv": { "version": "8.17.1", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", @@ -1611,6 +1647,20 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "integrity": "sha1-gjFem7xvKyWIiFis0f/4RBA1t38=", @@ -1630,6 +1680,13 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/unique-string": { "version": "2.0.0", "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", diff --git a/package.json b/package.json index a2621eb..0fa69f8 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,15 @@ "suggest:format": "echo \"\nTo resolve this, run: $(tput bold)npm run format$(tput sgr0)\" && exit 1", "test:format": "prettier --check . || npm run suggest:format", "test:spelling": "cspell \"spec/**/*.md\" README.md LICENSE.md", - "test:structure": "find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} ./scripts/validate-structure.js {}", + "test:structure": "find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} ./scripts/validate-structure.ts {}", "sync:codeowners": "node scripts/sync-codeowners.js" }, "devDependencies": { "@mlarah/spec-md": "^3.1.0", + "@tsconfig/node-ts": "^23.6.4", + "@tsconfig/node24": "^24.0.4", + "@types/node": "^24.13.2", + "@types/validator": "^13.15.10", "ajv": "^8.17.1", "cspell": "5.9.1", "handlebars": "^4.7.9", @@ -25,6 +29,7 @@ "nodemon": "2.0.20", "p-limit": "^7.3.0", "prettier": "^3.8.1", + "typescript": "^6.0.3", "validator": "^13.12.0", "yaml": "^2.7.0" } diff --git a/scripts/validate-structure.js b/scripts/validate-structure.ts similarity index 96% rename from scripts/validate-structure.js rename to scripts/validate-structure.ts index fa81059..6d5be9f 100755 --- a/scripts/validate-structure.js +++ b/scripts/validate-structure.ts @@ -3,10 +3,10 @@ /** * Validates the structure of a GAP directory. * - * Usage: ./scripts/validate-structure.js + * Usage: ./scripts/validate-structure.ts * * Can be xarg'd over all GAP directories: - * find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} node scripts/validate-structure.js {} + * find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} node scripts/validate-structure.ts {} */ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; @@ -162,7 +162,7 @@ function main() { const { positionals } = parseArgs({ allowPositionals: true, strict: true }); if (positionals.length !== 1) { - console.error("Usage: ./scripts/validate-structure.js "); + console.error("Usage: ./scripts/validate-structure.ts "); process.exit(1); } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..637951c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": [ + "@tsconfig/node24/tsconfig.json", + "@tsconfig/node-ts/tsconfig.json" + ], + "compilerOptions": { + "types": ["node"] + } +} From f69c875de850fac2fb83aff8b03890ec86e1af0e Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 9 Jul 2026 15:38:07 +0100 Subject: [PATCH 09/17] Add types, fix dataflow --- scripts/validate-structure.ts | 37 +++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/scripts/validate-structure.ts b/scripts/validate-structure.ts index 6d5be9f..086be91 100755 --- a/scripts/validate-structure.ts +++ b/scripts/validate-structure.ts @@ -13,7 +13,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { basename, join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; -import Ajv from "ajv/dist/2020.js"; +import { Ajv2020 as Ajv } from "ajv/dist/2020.js"; import { parse as parseYaml } from "yaml"; import validator from "validator"; @@ -27,12 +27,12 @@ const metadataSchema = JSON.parse(readFileSync(schemaPath, "utf8")); const ajv = new Ajv({ allErrors: true }); const validateMetadataSchema = ajv.compile(metadataSchema); -function error(gapName, message) { +function error(gapName: string, message: string) { console.error(`${gapName}: ${message}`); process.exit(1); } -function validateDirectoryNaming(dirPath) { +function validateDirectoryNaming(dirPath: string) { const dirName = basename(dirPath); // Special case: GAP-0 is allowed @@ -51,14 +51,14 @@ function validateDirectoryNaming(dirPath) { return dirName; } -function validateReadmeExists(dirPath, gapName) { +function validateReadmeExists(dirPath: string, gapName: string) { const readmePath = join(dirPath, "README.md"); if (!existsSync(readmePath)) { error(gapName, "No README.md file found"); } } -function validateMetadata(dirPath, gapName) { +function validateMetadata(dirPath: string, gapName: string) { const metadataPath = join(dirPath, "metadata.yml"); if (!existsSync(metadataPath)) { @@ -69,30 +69,34 @@ function validateMetadata(dirPath, gapName) { try { content = readFileSync(metadataPath, "utf8"); } catch (err) { - error(gapName, `Failed to read metadata.yml: ${err.message}`); + error(gapName, `Failed to read metadata.yml: ${String(err)}`); + return; } let metadata; try { metadata = parseYaml(content); } catch (err) { - error(gapName, `Invalid YAML in metadata.yml: ${err.message}`); + error(gapName, `Invalid YAML in metadata.yml: ${String(err)}`); + return; } if (typeof metadata !== "object") { error(gapName, "metadata.yml must contain a valid YAML object"); + return; } // Validate against JSON Schema const valid = validateMetadataSchema(metadata); if (!valid) { - const errors = validateMetadataSchema.errors - .map((err) => { + const errors = validateMetadataSchema + .errors!.map((err) => { const prefix = err.instancePath ? `${err.instancePath}: ` : ""; return `${prefix}${err.message}`; }) .join("\n"); error(gapName, `metadata.yml validation failed:\n\n${errors}`); + return; } // Validate authors have valid email @@ -114,7 +118,7 @@ function validateMetadata(dirPath, gapName) { } } -function validateAllowedFiles(dirPath, gapName) { +function validateAllowedFiles(dirPath: string, gapName: string) { for (const entry of readdirSync(dirPath)) { if (entry.startsWith(".")) { error(gapName, `Dotfiles are not allowed: "${entry}".`); @@ -132,7 +136,11 @@ function validateAllowedFiles(dirPath, gapName) { continue; } - if (entry === "metadata.yml" || entry === "metadata.json" || entry.endsWith(".md")) { + if ( + entry === "metadata.yml" || + entry === "metadata.json" || + entry.endsWith(".md") + ) { continue; } @@ -140,7 +148,7 @@ function validateAllowedFiles(dirPath, gapName) { } } -function validateVersionsDir(dirPath, gapName) { +function validateVersionsDir(dirPath: string, gapName: string) { for (const entry of readdirSync(dirPath)) { if (entry.startsWith(".")) { error(gapName, `Dotfiles are not allowed in versions/: "${entry}".`); @@ -153,7 +161,10 @@ function validateVersionsDir(dirPath, gapName) { } if (!/^\d{4}-\d{2}\.(md|yml)$/.test(entry)) { - error(gapName, `Unexpected file in versions/: "${entry}". Only YYYY-MM.md and YYYY-MM.yml are allowed.`); + error( + gapName, + `Unexpected file in versions/: "${entry}". Only YYYY-MM.md and YYYY-MM.yml are allowed.`, + ); } } } From da5b5c6da71129c1dab8daaace7e0acc97dadbb3 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 9 Jul 2026 15:45:06 +0100 Subject: [PATCH 10/17] Use promises --- scripts/validate-structure.ts | 51 ++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/scripts/validate-structure.ts b/scripts/validate-structure.ts index 086be91..0678320 100755 --- a/scripts/validate-structure.ts +++ b/scripts/validate-structure.ts @@ -9,7 +9,7 @@ * find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} node scripts/validate-structure.ts {} */ -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { access, constants, readFile, readdir, stat } from "node:fs/promises"; import { basename, join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; @@ -17,11 +17,20 @@ import { Ajv2020 as Ajv } from "ajv/dist/2020.js"; import { parse as parseYaml } from "yaml"; import validator from "validator"; +async function exists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + const __dirname = dirname(fileURLToPath(import.meta.url)); // Load JSON Schema from root directory const schemaPath = join(__dirname, "..", "metadata.schema.json"); -const metadataSchema = JSON.parse(readFileSync(schemaPath, "utf8")); +const metadataSchema = JSON.parse(await readFile(schemaPath, "utf8")); // Set up ajv with JSON Schema const ajv = new Ajv({ allErrors: true }); @@ -51,23 +60,23 @@ function validateDirectoryNaming(dirPath: string) { return dirName; } -function validateReadmeExists(dirPath: string, gapName: string) { +async function validateReadmeExists(dirPath: string, gapName: string) { const readmePath = join(dirPath, "README.md"); - if (!existsSync(readmePath)) { + if (!(await exists(readmePath))) { error(gapName, "No README.md file found"); } } -function validateMetadata(dirPath: string, gapName: string) { +async function validateMetadata(dirPath: string, gapName: string) { const metadataPath = join(dirPath, "metadata.yml"); - if (!existsSync(metadataPath)) { + if (!(await exists(metadataPath))) { error(gapName, "No metadata.yml file found"); } let content; try { - content = readFileSync(metadataPath, "utf8"); + content = await readFile(metadataPath, "utf8"); } catch (err) { error(gapName, `Failed to read metadata.yml: ${String(err)}`); return; @@ -118,8 +127,8 @@ function validateMetadata(dirPath: string, gapName: string) { } } -function validateAllowedFiles(dirPath: string, gapName: string) { - for (const entry of readdirSync(dirPath)) { +async function validateAllowedFiles(dirPath: string, gapName: string) { + for (const entry of await readdir(dirPath)) { if (entry.startsWith(".")) { error(gapName, `Dotfiles are not allowed: "${entry}".`); continue; @@ -127,9 +136,9 @@ function validateAllowedFiles(dirPath: string, gapName: string) { const fullPath = join(dirPath, entry); - if (statSync(fullPath).isDirectory()) { + if ((await stat(fullPath)).isDirectory()) { if (entry === "versions") { - validateVersionsDir(fullPath, gapName); + await validateVersionsDir(fullPath, gapName); } else { error(gapName, `Unexpected directory "${entry}".`); } @@ -148,14 +157,14 @@ function validateAllowedFiles(dirPath: string, gapName: string) { } } -function validateVersionsDir(dirPath: string, gapName: string) { - for (const entry of readdirSync(dirPath)) { +async function validateVersionsDir(dirPath: string, gapName: string) { + for (const entry of await readdir(dirPath)) { if (entry.startsWith(".")) { error(gapName, `Dotfiles are not allowed in versions/: "${entry}".`); continue; } - if (statSync(join(dirPath, entry)).isDirectory()) { + if ((await stat(join(dirPath, entry))).isDirectory()) { error(gapName, `Unexpected directory in versions/: "${entry}".`); continue; } @@ -169,7 +178,7 @@ function validateVersionsDir(dirPath: string, gapName: string) { } } -function main() { +async function main() { const { positionals } = parseArgs({ allowPositionals: true, strict: true }); if (positionals.length !== 1) { @@ -179,12 +188,12 @@ function main() { const dirPath = positionals[0]; - if (!existsSync(dirPath)) { + if (!(await exists(dirPath))) { console.error(`Directory does not exist: ${dirPath}`); process.exit(1); } - if (!statSync(dirPath).isDirectory()) { + if (!(await stat(dirPath)).isDirectory()) { console.error(`Not a directory: ${dirPath}`); process.exit(1); } @@ -193,13 +202,13 @@ function main() { const gapName = validateDirectoryNaming(dirPath); // Validate only allowed files are present - validateAllowedFiles(dirPath, gapName); + await validateAllowedFiles(dirPath, gapName); // Validate README.md exists - validateReadmeExists(dirPath, gapName); + await validateReadmeExists(dirPath, gapName); // Validate metadata.yml - validateMetadata(dirPath, gapName); + await validateMetadata(dirPath, gapName); } -main(); +await main(); From 07b28ed664dfbfb006a4c61b71f7312f54bdc5ea Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 9 Jul 2026 15:47:41 +0100 Subject: [PATCH 11/17] Process in parallel --- scripts/validate-structure.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/scripts/validate-structure.ts b/scripts/validate-structure.ts index 0678320..068957a 100755 --- a/scripts/validate-structure.ts +++ b/scripts/validate-structure.ts @@ -128,10 +128,11 @@ async function validateMetadata(dirPath: string, gapName: string) { } async function validateAllowedFiles(dirPath: string, gapName: string) { - for (const entry of await readdir(dirPath)) { + const entries = await readdir(dirPath); + const promises = entries.map(async (entry) => { if (entry.startsWith(".")) { error(gapName, `Dotfiles are not allowed: "${entry}".`); - continue; + return; } const fullPath = join(dirPath, entry); @@ -142,7 +143,7 @@ async function validateAllowedFiles(dirPath: string, gapName: string) { } else { error(gapName, `Unexpected directory "${entry}".`); } - continue; + return; } if ( @@ -150,23 +151,25 @@ async function validateAllowedFiles(dirPath: string, gapName: string) { entry === "metadata.json" || entry.endsWith(".md") ) { - continue; + return; } error(gapName, `Unexpected file "${entry}".`); - } + }); + await Promise.all(promises); } async function validateVersionsDir(dirPath: string, gapName: string) { - for (const entry of await readdir(dirPath)) { + const entries = await readdir(dirPath); + const promises = entries.map(async (entry) => { if (entry.startsWith(".")) { error(gapName, `Dotfiles are not allowed in versions/: "${entry}".`); - continue; + return; } if ((await stat(join(dirPath, entry))).isDirectory()) { error(gapName, `Unexpected directory in versions/: "${entry}".`); - continue; + return; } if (!/^\d{4}-\d{2}\.(md|yml)$/.test(entry)) { @@ -175,7 +178,8 @@ async function validateVersionsDir(dirPath: string, gapName: string) { `Unexpected file in versions/: "${entry}". Only YYYY-MM.md and YYYY-MM.yml are allowed.`, ); } - } + }); + await Promise.all(promises); } async function main() { From e0c726cefe95b437eb5e1193c792bb00feb192ef Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 9 Jul 2026 15:48:28 +0100 Subject: [PATCH 12/17] Simplify --- scripts/validate-structure.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/validate-structure.ts b/scripts/validate-structure.ts index 068957a..932fe81 100755 --- a/scripts/validate-structure.ts +++ b/scripts/validate-structure.ts @@ -10,8 +10,7 @@ */ import { access, constants, readFile, readdir, stat } from "node:fs/promises"; -import { basename, join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { basename, join } from "node:path"; import { parseArgs } from "node:util"; import { Ajv2020 as Ajv } from "ajv/dist/2020.js"; import { parse as parseYaml } from "yaml"; @@ -26,7 +25,7 @@ async function exists(path: string): Promise { } } -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = import.meta.dirname; // Load JSON Schema from root directory const schemaPath = join(__dirname, "..", "metadata.schema.json"); From a85d6711c0f8fe0b3e5c75e137792cb065522091 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 9 Jul 2026 15:53:43 +0100 Subject: [PATCH 13/17] Don't give up after first error - let them have it --- scripts/validate-structure.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/validate-structure.ts b/scripts/validate-structure.ts index 932fe81..73d8091 100755 --- a/scripts/validate-structure.ts +++ b/scripts/validate-structure.ts @@ -35,9 +35,10 @@ const metadataSchema = JSON.parse(await readFile(schemaPath, "utf8")); const ajv = new Ajv({ allErrors: true }); const validateMetadataSchema = ajv.compile(metadataSchema); +const errors: { [gapName: string]: string[] } = Object.create(null); function error(gapName: string, message: string) { - console.error(`${gapName}: ${message}`); - process.exit(1); + errors[gapName] ??= []; + errors[gapName].push(message); } function validateDirectoryNaming(dirPath: string) { @@ -212,6 +213,20 @@ async function main() { // Validate metadata.yml await validateMetadata(dirPath, gapName); + + const badGaps = Object.keys(errors); + if (badGaps.length > 0) { + process.exitCode = 1; + badGaps.sort(); // This is lexicographic... Not ideal but I'm too lazy to parse it. + for (const gapName of badGaps) { + console.error(`# ${gapName}`); + console.error(); + for (const message of errors[gapName]) { + console.error(`- ${message}`); + } + console.error(); + } + } } await main(); From f7aad88fb77e59e81857cdbce603995ed574a300 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 9 Jul 2026 16:13:16 +0100 Subject: [PATCH 14/17] Overhaul validation script --- package.json | 2 +- scripts/validate-structure.ts | 101 ++++++++++++++++++++-------------- 2 files changed, 61 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index 0fa69f8..a52231f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "suggest:format": "echo \"\nTo resolve this, run: $(tput bold)npm run format$(tput sgr0)\" && exit 1", "test:format": "prettier --check . || npm run suggest:format", "test:spelling": "cspell \"spec/**/*.md\" README.md LICENSE.md", - "test:structure": "find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} ./scripts/validate-structure.ts {}", + "test:structure": "node scripts/validate-structure.ts", "sync:codeowners": "node scripts/sync-codeowners.js" }, "devDependencies": { diff --git a/scripts/validate-structure.ts b/scripts/validate-structure.ts index 73d8091..8c46b96 100755 --- a/scripts/validate-structure.ts +++ b/scripts/validate-structure.ts @@ -10,7 +10,7 @@ */ import { access, constants, readFile, readdir, stat } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { basename, join, resolve } from "node:path"; import { parseArgs } from "node:util"; import { Ajv2020 as Ajv } from "ajv/dist/2020.js"; import { parse as parseYaml } from "yaml"; @@ -25,10 +25,11 @@ async function exists(path: string): Promise { } } -const __dirname = import.meta.dirname; +const ROOT = resolve(import.meta.dirname, ".."); +const GAPS_DIR = join(ROOT, "gaps"); // Load JSON Schema from root directory -const schemaPath = join(__dirname, "..", "metadata.schema.json"); +const schemaPath = join(ROOT, "metadata.schema.json"); const metadataSchema = JSON.parse(await readFile(schemaPath, "utf8")); // Set up ajv with JSON Schema @@ -41,7 +42,7 @@ function error(gapName: string, message: string) { errors[gapName].push(message); } -function validateDirectoryNaming(dirPath: string) { +function validateDirectoryNaming(dirPath: string): string | null { const dirName = basename(dirPath); // Special case: GAP-0 is allowed @@ -55,6 +56,7 @@ function validateDirectoryNaming(dirPath: string) { dirName, `Invalid directory name format. Expected GAP-N (e.g. GAP-10, GAP-123)`, ); + return null; } return dirName; @@ -72,6 +74,7 @@ async function validateMetadata(dirPath: string, gapName: string) { if (!(await exists(metadataPath))) { error(gapName, "No metadata.yml file found"); + return; } let content; @@ -143,18 +146,15 @@ async function validateAllowedFiles(dirPath: string, gapName: string) { } else { error(gapName, `Unexpected directory "${entry}".`); } - return; - } - - if ( + } else if ( entry === "metadata.yml" || entry === "metadata.json" || entry.endsWith(".md") ) { - return; + // Allowed + } else { + error(gapName, `Unexpected file "${entry}".`); } - - error(gapName, `Unexpected file "${entry}".`); }); await Promise.all(promises); } @@ -164,19 +164,15 @@ async function validateVersionsDir(dirPath: string, gapName: string) { const promises = entries.map(async (entry) => { if (entry.startsWith(".")) { error(gapName, `Dotfiles are not allowed in versions/: "${entry}".`); - return; - } - - if ((await stat(join(dirPath, entry))).isDirectory()) { + } else if ((await stat(join(dirPath, entry))).isDirectory()) { error(gapName, `Unexpected directory in versions/: "${entry}".`); - return; - } - - if (!/^\d{4}-\d{2}\.(md|yml)$/.test(entry)) { + } else if (!/^\d{4}-\d{2}\.(md|yml)$/.test(entry)) { error( gapName, `Unexpected file in versions/: "${entry}". Only YYYY-MM.md and YYYY-MM.yml are allowed.`, ); + } else { + // Passes all the checks } }); await Promise.all(promises); @@ -185,38 +181,61 @@ async function validateVersionsDir(dirPath: string, gapName: string) { async function main() { const { positionals } = parseArgs({ allowPositionals: true, strict: true }); - if (positionals.length !== 1) { - console.error("Usage: ./scripts/validate-structure.ts "); - process.exit(1); - } - - const dirPath = positionals[0]; - - if (!(await exists(dirPath))) { - console.error(`Directory does not exist: ${dirPath}`); - process.exit(1); - } + const gapsToCheck: string[] = []; - if (!(await stat(dirPath)).isDirectory()) { - console.error(`Not a directory: ${dirPath}`); + if (positionals.length > 1) { + console.error("Usage: ./scripts/validate-structure.ts "); process.exit(1); + } else if (positionals.length === 1) { + gapsToCheck.push(positionals[0]); + } else { + const gaps = await readdir(GAPS_DIR); + await Promise.all( + gaps.map(async (filename) => { + if (filename.startsWith(".")) return; + const fullPath = join(GAPS_DIR, filename); + const stats = await stat(fullPath); + if (stats.isDirectory()) { + gapsToCheck.push(fullPath); + } + }), + ); } - // Validate directory naming - const gapName = validateDirectoryNaming(dirPath); + await Promise.all( + gapsToCheck.map(async (dirPath) => { + // Validate directory naming + const gapName = validateDirectoryNaming(dirPath); + if (gapName == null) return; + + let stats; + try { + stats = await stat(dirPath); + } catch (e) { + error(gapName, `Directory ${dirPath} does not exist? ${e}`); + return; + } - // Validate only allowed files are present - await validateAllowedFiles(dirPath, gapName); + if (!stats.isDirectory()) { + error(gapName, `Not a directory: ${dirPath}`); + } else { + await Promise.all([ + // Validate only allowed files are present + validateAllowedFiles(dirPath, gapName), - // Validate README.md exists - await validateReadmeExists(dirPath, gapName); + // Validate README.md exists + validateReadmeExists(dirPath, gapName), - // Validate metadata.yml - await validateMetadata(dirPath, gapName); + // Validate metadata.yml + validateMetadata(dirPath, gapName), + ]); + } + }), + ); const badGaps = Object.keys(errors); if (badGaps.length > 0) { - process.exitCode = 1; + process.exitCode = 2; badGaps.sort(); // This is lexicographic... Not ideal but I'm too lazy to parse it. for (const gapName of badGaps) { console.error(`# ${gapName}`); From 27709b670c33732e4c74388612a86386c7698571 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 9 Jul 2026 16:23:21 +0100 Subject: [PATCH 15/17] Fix comment --- scripts/validate-structure.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/validate-structure.ts b/scripts/validate-structure.ts index 8c46b96..6941aa8 100755 --- a/scripts/validate-structure.ts +++ b/scripts/validate-structure.ts @@ -3,10 +3,10 @@ /** * Validates the structure of a GAP directory. * - * Usage: ./scripts/validate-structure.ts + * Usage: ./scripts/validate-structure.ts [gap-directory] * - * Can be xarg'd over all GAP directories: - * find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} node scripts/validate-structure.ts {} + * If no GAP directory is specified, will scan all: + * node scripts/validate-structure.ts */ import { access, constants, readFile, readdir, stat } from "node:fs/promises"; From f2ad72ac23f0492b0ed9dd6730ca44ca5aad7aff Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 9 Jul 2026 16:27:35 +0100 Subject: [PATCH 16/17] Convert sync-codeowners to TS also --- package.json | 2 +- ...{sync-codeowners.js => sync-codeowners.ts} | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) rename scripts/{sync-codeowners.js => sync-codeowners.ts} (62%) diff --git a/package.json b/package.json index a52231f..fd2240e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test:format": "prettier --check . || npm run suggest:format", "test:spelling": "cspell \"spec/**/*.md\" README.md LICENSE.md", "test:structure": "node scripts/validate-structure.ts", - "sync:codeowners": "node scripts/sync-codeowners.js" + "sync:codeowners": "node scripts/sync-codeowners.ts" }, "devDependencies": { "@mlarah/spec-md": "^3.1.0", diff --git a/scripts/sync-codeowners.js b/scripts/sync-codeowners.ts similarity index 62% rename from scripts/sync-codeowners.js rename to scripts/sync-codeowners.ts index 35201ea..c3cc240 100755 --- a/scripts/sync-codeowners.js +++ b/scripts/sync-codeowners.ts @@ -1,28 +1,33 @@ #!/usr/bin/env node import { readdir, readFile, writeFile } from "node:fs/promises"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; import { parse as parseYaml } from "yaml"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const rootDir = join(__dirname, ".."); +const rootDir = join(import.meta.dirname, ".."); const gapsDir = join(rootDir, "gaps"); async function getGapDirs() { const entries = await readdir(gapsDir, { withFileTypes: true }); - return entries.filter((d) => d.isDirectory() && /^GAP-[1-9]\d*$/.test(d.name)); + return entries.filter( + (d) => d.isDirectory() && /^GAP-[1-9]\d*$/.test(d.name), + ); } async function main() { const dirs = await getGapDirs(); - dirs.sort((a, b) => parseInt(a.name.split("-")[1], 10) - parseInt(b.name.split("-")[1], 10)); + dirs.sort( + (a, b) => + parseInt(a.name.split("-")[1], 10) - parseInt(b.name.split("-")[1], 10), + ); const lines = await Promise.all( dirs.map(async (dir) => { const metadataPath = join(gapsDir, dir.name, "metadata.yml"); const metadata = parseYaml(await readFile(metadataPath, "utf8")); - const owners = metadata.authors.map((a) => a.githubUsername.replace(/^@/, "")); + const owners = metadata.authors.map((a) => + a.githubUsername.replace(/^@/, ""), + ); const ownerList = owners.map((o) => `@${o}`).join(" "); return `/gaps/${dir.name}/ ${ownerList}`; }), From 10979d0f06eafe28a8ff608fd6f6f20980534cae Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 9 Jul 2026 16:28:29 +0100 Subject: [PATCH 17/17] Consistency with existing script --- scripts/validate-structure.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/validate-structure.ts b/scripts/validate-structure.ts index 6941aa8..8c987c8 100755 --- a/scripts/validate-structure.ts +++ b/scripts/validate-structure.ts @@ -25,11 +25,11 @@ async function exists(path: string): Promise { } } -const ROOT = resolve(import.meta.dirname, ".."); -const GAPS_DIR = join(ROOT, "gaps"); +const rootDir = resolve(import.meta.dirname, ".."); +const gapsDir = join(rootDir, "gaps"); // Load JSON Schema from root directory -const schemaPath = join(ROOT, "metadata.schema.json"); +const schemaPath = join(rootDir, "metadata.schema.json"); const metadataSchema = JSON.parse(await readFile(schemaPath, "utf8")); // Set up ajv with JSON Schema @@ -189,11 +189,11 @@ async function main() { } else if (positionals.length === 1) { gapsToCheck.push(positionals[0]); } else { - const gaps = await readdir(GAPS_DIR); + const gaps = await readdir(gapsDir); await Promise.all( gaps.map(async (filename) => { if (filename.startsWith(".")) return; - const fullPath = join(GAPS_DIR, filename); + const fullPath = join(gapsDir, filename); const stats = await stat(fullPath); if (stats.isDirectory()) { gapsToCheck.push(fullPath);