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..fd2240e 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 {}", - "sync:codeowners": "node scripts/sync-codeowners.js" + "test:structure": "node scripts/validate-structure.ts", + "sync:codeowners": "node scripts/sync-codeowners.ts" }, "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/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}`; }), diff --git a/scripts/validate-structure.js b/scripts/validate-structure.js deleted file mode 100755 index 6c0b634..0000000 --- a/scripts/validate-structure.js +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env node - -/** - * Validates the structure of a GAP directory. - * - * Usage: ./scripts/validate-structure.js - * - * Can be xarg'd over all GAP directories: - * find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} node scripts/validate-structure.js {} - */ - -import { existsSync, readFileSync, 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 { parse as parseYaml } from "yaml"; -import validator from "validator"; - -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")); - -// Set up ajv with JSON Schema -const ajv = new Ajv({ allErrors: true }); -const validateMetadataSchema = ajv.compile(metadataSchema); - -function error(gapName, message) { - console.error(`${gapName}: ${message}`); - process.exit(1); -} - -function validateDirectoryNaming(dirPath) { - const dirName = basename(dirPath); - - // Special case: GAP-0 is allowed - if (dirName === "GAP-0") { - return dirName; - } - - // Must match GAP-N format (one or more digits, no zero pad) - if (!/^GAP-[1-9]\d*$/.test(dirName)) { - error( - dirName, - `Invalid directory name format. Expected GAP-N (e.g. GAP-10, GAP-123)`, - ); - } - - return dirName; -} - -function validateReadmeExists(dirPath, gapName) { - const readmePath = join(dirPath, "README.md"); - if (!existsSync(readmePath)) { - error(gapName, "No README.md file found"); - } -} - -function validateMetadata(dirPath, gapName) { - const metadataPath = join(dirPath, "metadata.yml"); - - if (!existsSync(metadataPath)) { - error(gapName, "No metadata.yml file found"); - } - - let content; - try { - content = readFileSync(metadataPath, "utf8"); - } catch (err) { - error(gapName, `Failed to read metadata.yml: ${err.message}`); - } - - let metadata; - try { - metadata = parseYaml(content); - } catch (err) { - error(gapName, `Invalid YAML in metadata.yml: ${err.message}`); - } - - if (typeof metadata !== "object") { - error(gapName, "metadata.yml must contain a valid YAML object"); - } - - // Validate against JSON Schema - const valid = validateMetadataSchema(metadata); - if (!valid) { - 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}`); - } - - // Validate authors have valid email - for (const author of metadata.authors) { - if (!validator.isEmail(author.email)) { - error( - gapName, - `metadata.yml invalid author email "${author.email}" for "${author.name}"`, - ); - } - } - - // Validate discussion is a valid URL - if (!validator.isURL(metadata.discussion)) { - error( - gapName, - `metadata.yml discussion must be a valid URL (got "${metadata.discussion}")`, - ); - } -} - -function main() { - const { positionals } = parseArgs({ allowPositionals: true, strict: true }); - - if (positionals.length !== 1) { - console.error("Usage: ./scripts/validate-structure.js "); - process.exit(1); - } - - const dirPath = positionals[0]; - - if (!existsSync(dirPath)) { - console.error(`Directory does not exist: ${dirPath}`); - process.exit(1); - } - - if (!statSync(dirPath).isDirectory()) { - console.error(`Not a directory: ${dirPath}`); - process.exit(1); - } - - // Validate directory naming - const gapName = validateDirectoryNaming(dirPath); - - // Validate README.md exists - validateReadmeExists(dirPath, gapName); - - // Validate metadata.yml - validateMetadata(dirPath, gapName); -} - -main(); diff --git a/scripts/validate-structure.ts b/scripts/validate-structure.ts new file mode 100755 index 0000000..8c987c8 --- /dev/null +++ b/scripts/validate-structure.ts @@ -0,0 +1,251 @@ +#!/usr/bin/env node + +/** + * Validates the structure of a GAP directory. + * + * Usage: ./scripts/validate-structure.ts [gap-directory] + * + * If no GAP directory is specified, will scan all: + * node scripts/validate-structure.ts + */ + +import { access, constants, readFile, readdir, stat } from "node:fs/promises"; +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"; +import validator from "validator"; + +async function exists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +const rootDir = resolve(import.meta.dirname, ".."); +const gapsDir = join(rootDir, "gaps"); + +// Load JSON Schema from root directory +const schemaPath = join(rootDir, "metadata.schema.json"); +const metadataSchema = JSON.parse(await readFile(schemaPath, "utf8")); + +// Set up ajv with JSON Schema +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) { + errors[gapName] ??= []; + errors[gapName].push(message); +} + +function validateDirectoryNaming(dirPath: string): string | null { + const dirName = basename(dirPath); + + // Special case: GAP-0 is allowed + if (dirName === "GAP-0") { + return dirName; + } + + // Must match GAP-N format (one or more digits, no zero pad) + if (!/^GAP-[1-9]\d*$/.test(dirName)) { + error( + dirName, + `Invalid directory name format. Expected GAP-N (e.g. GAP-10, GAP-123)`, + ); + return null; + } + + return dirName; +} + +async function validateReadmeExists(dirPath: string, gapName: string) { + const readmePath = join(dirPath, "README.md"); + if (!(await exists(readmePath))) { + error(gapName, "No README.md file found"); + } +} + +async function validateMetadata(dirPath: string, gapName: string) { + const metadataPath = join(dirPath, "metadata.yml"); + + if (!(await exists(metadataPath))) { + error(gapName, "No metadata.yml file found"); + return; + } + + let content; + try { + content = await readFile(metadataPath, "utf8"); + } catch (err) { + 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: ${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 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 + for (const author of metadata.authors) { + if (!validator.isEmail(author.email)) { + error( + gapName, + `metadata.yml invalid author email "${author.email}" for "${author.name}"`, + ); + } + } + + // Validate discussion is a valid URL + if (!validator.isURL(metadata.discussion)) { + error( + gapName, + `metadata.yml discussion must be a valid URL (got "${metadata.discussion}")`, + ); + } +} + +async function validateAllowedFiles(dirPath: string, gapName: string) { + const entries = await readdir(dirPath); + const promises = entries.map(async (entry) => { + if (entry.startsWith(".")) { + error(gapName, `Dotfiles are not allowed: "${entry}".`); + return; + } + + const fullPath = join(dirPath, entry); + + if ((await stat(fullPath)).isDirectory()) { + if (entry === "versions") { + await validateVersionsDir(fullPath, gapName); + } else { + error(gapName, `Unexpected directory "${entry}".`); + } + } else if ( + entry === "metadata.yml" || + entry === "metadata.json" || + entry.endsWith(".md") + ) { + // Allowed + } else { + error(gapName, `Unexpected file "${entry}".`); + } + }); + await Promise.all(promises); +} + +async function validateVersionsDir(dirPath: string, gapName: string) { + const entries = await readdir(dirPath); + const promises = entries.map(async (entry) => { + if (entry.startsWith(".")) { + error(gapName, `Dotfiles are not allowed in versions/: "${entry}".`); + } else if ((await stat(join(dirPath, entry))).isDirectory()) { + error(gapName, `Unexpected directory in versions/: "${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); +} + +async function main() { + const { positionals } = parseArgs({ allowPositionals: true, strict: true }); + + const gapsToCheck: string[] = []; + + 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(gapsDir); + await Promise.all( + gaps.map(async (filename) => { + if (filename.startsWith(".")) return; + const fullPath = join(gapsDir, filename); + const stats = await stat(fullPath); + if (stats.isDirectory()) { + gapsToCheck.push(fullPath); + } + }), + ); + } + + 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; + } + + 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 + validateReadmeExists(dirPath, gapName), + + // Validate metadata.yml + validateMetadata(dirPath, gapName), + ]); + } + }), + ); + + const badGaps = Object.keys(errors); + if (badGaps.length > 0) { + 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}`); + console.error(); + for (const message of errors[gapName]) { + console.error(`- ${message}`); + } + console.error(); + } + } +} + +await main(); 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"] + } +}