diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8863cd8..55bd5c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,9 @@ jobs: - name: Lint run: npm run lint + - name: Check package schema + run: npm run check-package-schema + - name: Check benchmark schema run: npm run check-benchmark-schema diff --git a/package.json b/package.json index 10dcebf..0010e93 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "lint": "eslint src benchmarks --ext .ts --max-warnings=0", "check-benchmark-schema": "node scripts/check-benchmark-schema.js", "check-bundle-size": "bun run scripts/check-bundle-size.js", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "check-package-schema": "node scripts/validate-package-json.cjs" }, "repository": { "type": "git", diff --git a/scripts/validate-package-json.cjs b/scripts/validate-package-json.cjs new file mode 100644 index 0000000..c0a93ac --- /dev/null +++ b/scripts/validate-package-json.cjs @@ -0,0 +1,340 @@ +const fs = require("fs"); +const path = require("path"); + +const packageSchema = { + strings: [ + ["name"], + ["version"], + ["description"], + ["main"], + ["module"], + ["types"], + ["author"], + ["license"], + ["type"], + ["homepage"], + ["repository", "type"], + ["repository", "url"], + ["funding", "type"], + ["funding", "url"], + ["bugs", "url"], + ["scripts", "build"], + ["scripts", "dev"], + ["scripts", "test"], + ["scripts", "lint"], + ["scripts", "check-benchmark-schema"], + ["scripts", "check-bundle-size"], + ["scripts", "prepublishOnly"], + ["scripts", "check-package-schema"], + ], + + arrays: [ + ["keywords"], + ["files"], + ], + + booleans: [ + ["sideEffects"], + ], + + exports: [ + ".", + "./logger", + "./core/*", + "./transports/*", + ], +}; + +/** + * Validate one path inside an exports entry. + */ +function validateExportPath( + value, + condition, + extension, + label, + errors +) { + const fieldLabel = `${label}.${condition}`; + + if (typeof value !== "string") { + errors.push(`${fieldLabel} must be a string`); + return; + } + + if (!value.startsWith("./")) { + errors.push(`${fieldLabel} must start with "./"`); + } + + if (!value.endsWith(extension)) { + errors.push( + `${fieldLabel} must end with "${extension}"` + ); + } +} + +/** + * Validate one complete entry inside package.json exports. + */ +function validateExportEntry( + exportName, + entry, + errors +) { + const label = `exports["${exportName}"]`; + + if ( + entry === null || + typeof entry !== "object" || + Array.isArray(entry) + ) { + errors.push(`${label} must be an object`); + return; + } + + validateExportPath( + entry.types, + "types", + ".d.ts", + label, + errors + ); + + validateExportPath( + entry.import, + "import", + ".js", + label, + errors + ); + + validateExportPath( + entry.require, + "require", + ".cjs", + label, + errors + ); + + if (exportName.includes("*")) { + for (const condition of [ + "types", + "import", + "require", + ]) { + const value = entry[condition]; + + if ( + typeof value === "string" && + !value.includes("*") + ) { + errors.push( + `${label}.${condition} must contain "*" for a wildcard export` + ); + } + } + } +} + +/** + * Retrieve a nested value using an array of field names. + * + * Example: + * getNestedValue(data, ["repository", "url"]) + */ +function getNestedValue(data, fieldPath) { + let current = data; + + for (const key of fieldPath) { + if ( + current === null || + typeof current !== "object" || + !(key in current) + ) { + return undefined; + } + + current = current[key]; + } + + return current; +} + +/** + * Validate a package.json object against the schema. + * + * Returns an array of error messages. + * An empty array means the object is valid. + */ +function validate(data, schema) { + const errors = []; + + if ( + data === null || + typeof data !== "object" || + Array.isArray(data) + ) { + return [ + "package.json must contain a JSON object", + ]; + } + + // Validate string fields. + for (const fieldPath of schema.strings) { + const value = getNestedValue( + data, + fieldPath + ); + + const label = fieldPath.join("."); + + if (value === undefined) { + errors.push( + `Missing required field: ${label}` + ); + } else if (typeof value !== "string") { + errors.push( + `${label} must be a string` + ); + } + } + + // Validate array fields. + for (const fieldPath of schema.arrays) { + const value = getNestedValue( + data, + fieldPath + ); + + const label = fieldPath.join("."); + + if (value === undefined) { + errors.push( + `Missing required field: ${label}` + ); + } else if (!Array.isArray(value)) { + errors.push( + `${label} must be an array` + ); + } + } + + // Validate boolean fields. + for (const fieldPath of schema.booleans) { + const value = getNestedValue( + data, + fieldPath + ); + + const label = fieldPath.join("."); + + if (value === undefined) { + errors.push( + `Missing required field: ${label}` + ); + } else if (typeof value !== "boolean") { + errors.push( + `${label} must be a boolean` + ); + } + } + + // Validate exports. + if ( + data.exports === null || + typeof data.exports !== "object" || + Array.isArray(data.exports) + ) { + errors.push( + "exports must be an object" + ); + } else { + for (const exportName of schema.exports) { + validateExportEntry( + exportName, + data.exports[exportName], + errors + ); + } + } + + return errors; +} + +/** + * Read and validate package.json. + * + * Returns true when valid and false when invalid. + */ +function checkFile(filePath, schema) { + try { + const contents = fs.readFileSync( + filePath, + "utf8" + ); + + const data = JSON.parse(contents); + const errors = validate(data, schema); + + if (errors.length > 0) { + console.error( + "Invalid file structure: package.json" + ); + + for (const error of errors) { + console.error(`- ${error}`); + } + + return false; + } + + console.log( + "Valid file structure: package.json" + ); + + return true; + } catch (error) { + const message = + error instanceof Error + ? error.message + : String(error); + + console.error( + `Could not validate package.json: ${message}` + ); + + return false; + } +} + +/** + * Export functions so Jest can import and test them. + */ +module.exports = { + packageSchema, + validateExportPath, + validateExportEntry, + getNestedValue, + validate, + checkFile, +}; + +/** + * Only validate the real package.json when this script + * is executed directly. + * + * Importing it from Jest will not run this section. + */ +if (require.main === module) { + const packagePath = path.resolve( + __dirname, + "../package.json" + ); + + const valid = checkFile( + packagePath, + packageSchema + ); + + process.exitCode = valid ? 0 : 1; +} \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index dd21158..7268e4e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -40,6 +40,10 @@ tests/ ├── Logger.test.ts # Core Logger class tests ├── LogLevel.test.ts # Log level functionality tests ├── Formatter.test.ts # Output formatting tests +│ +│ +├── scripts #General script testing +│ ├── ValidatePackageJson.test.ts #Package.json check tests ├── transports/ # Transport implementation tests │ ├── Transport.test.ts # Base transport interface │ ├── ConsoleTransport.test.ts # Console output tests diff --git a/tests/scripts/ValidatePackageJson.test.ts b/tests/scripts/ValidatePackageJson.test.ts new file mode 100644 index 0000000..1f37493 --- /dev/null +++ b/tests/scripts/ValidatePackageJson.test.ts @@ -0,0 +1,580 @@ +import { + getNestedValue, + packageSchema, + validate, + validateExportEntry, + validateExportPath, +} from "../../scripts/validate-package-json.cjs"; + +function createValidPackage() { + return { + name: "zario", + version: "0.8.0", + description: + "Fast and simple logging library for TypeScript.", + main: "./dist/index.cjs", + module: "./dist/index.js", + types: "./dist/index.d.ts", + author: "Dev-Dami", + license: "MIT", + type: "module", + homepage: + "https://github.com/Dev-Dami/zario#readme", + + repository: { + type: "git", + url: "git+https://github.com/Dev-Dami/zario.git", + }, + + funding: { + type: "github", + url: "https://github.com/sponsors/Dev-Dami", + }, + + bugs: { + url: "https://github.com/Dev-Dami/zario/issues", + }, + + scripts: { + build: "tsup", + dev: "tsup --watch", + test: "jest", + lint: "eslint src benchmarks --ext .ts --max-warnings=0", + "check-benchmark-schema": + "node scripts/check-benchmark-schema.js", + "check-bundle-size": + "bun run scripts/check-bundle-size.js", + prepublishOnly: "npm run build", + "check-package-schema": + "node scripts/validate-package-json.cjs", + }, + + keywords: [ + "logging", + "logger", + "typescript", + ], + + files: [ + "dist/**/*", + "README.md", + ], + + sideEffects: false, + + exports: { + ".": { + types: "./dist/index.d.ts", + import: "./dist/index.js", + require: "./dist/index.cjs", + }, + + "./logger": { + types: "./dist/logger.d.ts", + import: "./dist/logger.js", + require: "./dist/logger.cjs", + }, + + "./core/*": { + types: "./dist/core/*.d.ts", + import: "./dist/core/*.js", + require: "./dist/core/*.cjs", + }, + + "./transports/*": { + types: "./dist/transports/*.d.ts", + import: "./dist/transports/*.js", + require: "./dist/transports/*.cjs", + }, + }, + }; +} + +describe("getNestedValue", () => { + test("returns a top-level value", () => { + const data = { + name: "zario", + }; + + expect( + getNestedValue(data, ["name"]) + ).toBe("zario"); + }); + + test("returns a nested value", () => { + const data = { + repository: { + type: "git", + }, + }; + + expect( + getNestedValue( + data, + ["repository", "type"] + ) + ).toBe("git"); + }); + + test("returns undefined when a field is missing", () => { + const data = { + repository: {}, + }; + + expect( + getNestedValue( + data, + ["repository", "url"] + ) + ).toBeUndefined(); + }); + + test("returns undefined when an intermediate value is null", () => { + const data = { + repository: null, + }; + + expect( + getNestedValue( + data, + ["repository", "url"] + ) + ).toBeUndefined(); + }); + + test("returns undefined when an intermediate value is not an object", () => { + const data = { + repository: "git", + }; + + expect( + getNestedValue( + data, + ["repository", "url"] + ) + ).toBeUndefined(); + }); +}); + +describe("validateExportPath", () => { + test("accepts a valid export path", () => { + const errors: string[] = []; + + validateExportPath( + "./dist/index.js", + "import", + ".js", + 'exports["."]', + errors + ); + + expect(errors).toEqual([]); + }); + + test("rejects a non-string export path", () => { + const errors: string[] = []; + + validateExportPath( + null, + "import", + ".js", + 'exports["."]', + errors + ); + + expect(errors).toEqual([ + 'exports["."].import must be a string', + ]); + }); + + test('requires a path to start with "./"', () => { + const errors: string[] = []; + + validateExportPath( + "dist/index.js", + "import", + ".js", + 'exports["."]', + errors + ); + + expect(errors).toContain( + 'exports["."].import must start with "./"' + ); + }); + + test("requires the correct extension", () => { + const errors: string[] = []; + + validateExportPath( + "./dist/index.cjs", + "import", + ".js", + 'exports["."]', + errors + ); + + expect(errors).toContain( + 'exports["."].import must end with ".js"' + ); + }); + + test("reports multiple problems for one path", () => { + const errors: string[] = []; + + validateExportPath( + "dist/index.cjs", + "import", + ".js", + 'exports["."]', + errors + ); + + expect(errors).toEqual([ + 'exports["."].import must start with "./"', + 'exports["."].import must end with ".js"', + ]); + }); +}); + +describe("validateExportEntry", () => { + test("accepts a valid export entry", () => { + const errors: string[] = []; + + validateExportEntry( + ".", + { + types: "./dist/index.d.ts", + import: "./dist/index.js", + require: "./dist/index.cjs", + }, + errors + ); + + expect(errors).toEqual([]); + }); + + test("rejects a missing export entry", () => { + const errors: string[] = []; + + validateExportEntry( + ".", + undefined, + errors + ); + + expect(errors).toEqual([ + 'exports["."] must be an object', + ]); + }); + + test("rejects null as an export entry", () => { + const errors: string[] = []; + + validateExportEntry( + ".", + null, + errors + ); + + expect(errors).toEqual([ + 'exports["."] must be an object', + ]); + }); + + test("rejects an array as an export entry", () => { + const errors: string[] = []; + + validateExportEntry( + ".", + [], + errors + ); + + expect(errors).toEqual([ + 'exports["."] must be an object', + ]); + }); + + test("requires types, import, and require", () => { + const errors: string[] = []; + + validateExportEntry( + ".", + { + types: "./dist/index.d.ts", + }, + errors + ); + + expect(errors).toContain( + 'exports["."].import must be a string' + ); + + expect(errors).toContain( + 'exports["."].require must be a string' + ); + }); + + test("accepts a valid wildcard export", () => { + const errors: string[] = []; + + validateExportEntry( + "./core/*", + { + types: "./dist/core/*.d.ts", + import: "./dist/core/*.js", + require: "./dist/core/*.cjs", + }, + errors + ); + + expect(errors).toEqual([]); + }); + + test("requires wildcard export paths to contain an asterisk", () => { + const errors: string[] = []; + + validateExportEntry( + "./core/*", + { + types: "./dist/core/index.d.ts", + import: "./dist/core/index.js", + require: "./dist/core/index.cjs", + }, + errors + ); + + expect(errors).toEqual([ + 'exports["./core/*"].types must contain "*" for a wildcard export', + 'exports["./core/*"].import must contain "*" for a wildcard export', + 'exports["./core/*"].require must contain "*" for a wildcard export', + ]); + }); +}); + +describe("validate", () => { + test("accepts a valid package", () => { + const data = createValidPackage(); + + expect( + validate(data, packageSchema) + ).toEqual([]); + }); + + test("rejects null package data", () => { + expect( + validate(null, packageSchema) + ).toEqual([ + "package.json must contain a JSON object", + ]); + }); + + test("rejects array package data", () => { + expect( + validate([], packageSchema) + ).toEqual([ + "package.json must contain a JSON object", + ]); + }); + + test("reports a missing top-level string", () => { + const data = createValidPackage(); + + delete data.name; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toContain( + "Missing required field: name" + ); + }); + + test("reports a missing nested string", () => { + const data = createValidPackage(); + + delete data.repository.url; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toContain( + "Missing required field: repository.url" + ); + }); + + test("reports a string with the wrong type", () => { + const data = createValidPackage(); + + data.version = 8; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toContain( + "version must be a string" + ); + }); + + test("reports a missing array", () => { + const data = createValidPackage(); + + delete data.files; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toContain( + "Missing required field: files" + ); + }); + + test("reports an array with the wrong type", () => { + const data = createValidPackage(); + + data.keywords = "logging"; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toContain( + "keywords must be an array" + ); + }); + + test("reports a missing boolean", () => { + const data = createValidPackage(); + + delete data.sideEffects; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toContain( + "Missing required field: sideEffects" + ); + }); + + test("reports a boolean with the wrong type", () => { + const data = createValidPackage(); + + data.sideEffects = "false"; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toContain( + "sideEffects must be a boolean" + ); + }); + + test.each([ + null, + [], + "exports", + false, + ])( + "rejects invalid exports value: %p", + (exportsValue) => { + const data = createValidPackage(); + + data.exports = exportsValue; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toContain( + "exports must be an object" + ); + } + ); + + test("reports a missing required export", () => { + const data = createValidPackage(); + + delete data.exports["./logger"]; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toContain( + 'exports["./logger"] must be an object' + ); + }); + + test("reports incorrect export extensions", () => { + const data = createValidPackage(); + + data.exports["."].types = + "./dist/index.js"; + + data.exports["."].import = + "./dist/index.cjs"; + + data.exports["."].require = + "./dist/index.js"; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toContain( + 'exports["."].types must end with ".d.ts"' + ); + + expect(errors).toContain( + 'exports["."].import must end with ".js"' + ); + + expect(errors).toContain( + 'exports["."].require must end with ".cjs"' + ); + }); + + test("reports multiple independent errors", () => { + const data = createValidPackage(); + + delete data.name; + delete data.repository.url; + + data.keywords = "logging"; + data.sideEffects = "false"; + + delete data.exports["./logger"]; + + const errors = validate( + data, + packageSchema + ); + + expect(errors).toEqual( + expect.arrayContaining([ + "Missing required field: name", + "Missing required field: repository.url", + "keywords must be an array", + "sideEffects must be a boolean", + 'exports["./logger"] must be an object', + ]) + ); + }); +}); \ No newline at end of file