Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
340 changes: 340 additions & 0 deletions scripts/validate-package-json.cjs
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading