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
144 changes: 144 additions & 0 deletions eslint-rules/pinned-deps.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"use strict";

/**
* Enforce pinned deps in package.json:
* - allow protocols (workspace:, file:, link:, etc.) for internal packages
* - require exact x.y.z for everything else
* - no autofix
*/

const DEFAULT_DEP_FIELDS = [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
];

function isObjectExpression(node) {
return node && (node.type === "ObjectExpression" || node.type === "JSONObjectExpression");
}

function getPropKeyString(prop) {
const k = prop.key;
if (!k) return null;
// JS parser: Literal
if (k.type === "Literal" && typeof k.value === "string") return k.value;
// jsonc-eslint-parser: JSONLiteral
if (k.type === "JSONLiteral" && typeof k.value === "string") return k.value;
return null;
}

function getLiteralString(node) {
if (!node) return null;
if (node.type === "Literal" && typeof node.value === "string") return node.value;
if (node.type === "JSONLiteral" && typeof node.value === "string") return node.value;
return null;
}

function getObjectPropertyValue(objExpr, keyName) {
if (!isObjectExpression(objExpr)) return null;
for (const p of objExpr.properties || []) {
if (!p || (p.type !== "Property" && p.type !== "JSONProperty")) continue;
const k = getPropKeyString(p);
if (k === keyName) return p.value;
}
return null;
}

module.exports = {
meta: {
type: "problem",
docs: { description: "Require pinned dependency versions in package.json" },
schema: [
{
type: "object",
additionalProperties: false,
properties: {
depFields: { type: "array", items: { type: "string" } },
excludeList: { type: "array", items: { type: "string" } },
internalScopes: { type: "array", items: { type: "string" } },
allowProtocols: { type: "array", items: { type: "string" } },
allowProtocolsOnlyForInternal: { type: "boolean" },
allowExactPrerelease: { type: "boolean" },
},
},
],
},

create(context) {
const opt = context.options[0] || {};
const depFields = opt.depFields || DEFAULT_DEP_FIELDS;
const excludeList = opt.excludeList || [];
const internalScopes = opt.internalScopes || [];
const allowProtocols = opt.allowProtocols || ["workspace:", "file:", "link:"];
const allowProtocolsOnlyForInternal = opt.allowProtocolsOnlyForInternal !== false; // default true
const allowExactPrerelease = !!opt.allowExactPrerelease;

const exact = allowExactPrerelease
? /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/
: /^[0-9]+\.[0-9]+\.[0-9]+$/;

function shouldExclude(name) {
return excludeList.some((p) => name.startsWith(p));
}

function isInternal(name) {
return internalScopes.some((p) => name.startsWith(p));
}

function isAllowedProtocol(version) {
return allowProtocols.some((p) => version.startsWith(p));
}

function report(node, depName, field, version) {
context.report({
node,
message:
`Dependency "${depName}" in "${field}" must be pinned to an exact version (x.y.z). ` +
`Got "${version}".` +
(internalScopes.length ? ` Internal scopes: ${internalScopes.join(", ")}.` : "") +
(allowProtocols.length
? ` Allowed protocols: ${allowProtocols.join(", ")}.` +
(allowProtocolsOnlyForInternal ? " (internal only)" : "")
: ""),
});
}

return {
"Program:exit"(program) {
const expr = program.body?.[0]?.expression;
if (!isObjectExpression(expr)) return;

for (const field of depFields) {
const depObj = getObjectPropertyValue(expr, field);
if (!isObjectExpression(depObj)) continue;

for (const p of depObj.properties || []) {
if (!p || (p.type !== "Property" && p.type !== "JSONProperty")) continue;

const depName = getPropKeyString(p);
const version = getLiteralString(p.value);
if (!depName || version == null) continue;

if (shouldExclude(depName)) {
continue;
}

// allow protocols
if (isAllowedProtocol(version)) {
if (allowProtocolsOnlyForInternal && !isInternal(depName)) {
report(p.value, depName, field, version);
}
continue;
}

// require exact semver
if (!exact.test(version)) {
report(p.value, depName, field, version);
}
}
}
},
};
},
};
103 changes: 56 additions & 47 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ const tseslint = require("@typescript-eslint/eslint-plugin");
const tsParser = require("@typescript-eslint/parser");
const pluginJsonc = require("eslint-plugin-jsonc");
const jsonParser = require("jsonc-eslint-parser");
const jsonDependencies = require("eslint-plugin-package-json-dependencies");

module.exports = [
{
Expand All @@ -17,57 +16,67 @@ module.exports = [
"jsonc/no-dupe-keys": "error",
},
},
// {
// files: ["**/*.ts", "**/*.tsx"],
// languageOptions: {
// parser: tsParser,
// parserOptions: {
// project: ["./tsconfig.eslint.json"],
// ecmaVersion: 2019,
// sourceType: "module",
// },
// },
// ignores: [],
// plugins: {
// "@typescript-eslint": tseslint,
// },
// rules: {
// ...tseslint.configs["recommended"].rules,
// "@typescript-eslint/no-non-null-assertion": "off",
// "@typescript-eslint/no-empty-interface": "off",
// "@typescript-eslint/no-unused-vars": "off",
// "@typescript-eslint/no-explicit-any": "off",
// "@typescript-eslint/ban-ts-comment": "off",
// "@typescript-eslint/no-non-null-asserted-optional-chain": "off",
// "@typescript-eslint/switch-exhaustiveness-check": "off",
// "quotes": [
// "error",
// "double",
// {
// avoidEscape: true,
// allowTemplateLiterals: true,
// },
// ],
// "no-console": "error",
// "no-self-compare": "error",
// },
// },
// {
// files: ["**/*.spec.ts"],
// rules: {
// "no-console": "off",
// },
// },
{
files: ["**/package.json"],
files: ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"],
languageOptions: {
parser: tsParser,
parserOptions: {
project: ["./tsconfig.eslint.json"],
ecmaVersion: 2019,
sourceType: "module",
},
},
ignores: [],
plugins: {
"package-json-deps": jsonDependencies,
"@typescript-eslint": tseslint,
},
languageOptions: {
parser: jsonParser,
rules: {
...tseslint.configs["recommended"].rules,
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-non-null-asserted-optional-chain": "off",
"@typescript-eslint/switch-exhaustiveness-check": "off",
"quotes": [
"error",
"double",
{
avoidEscape: true,
allowTemplateLiterals: true,
},
],
"no-console": "error",
"no-self-compare": "error",
},
},
{
files: ["**/package.json"],
languageOptions: { parser: jsonParser },
plugins: {
"unevenlabs-policy": {
rules: {
"pinned-deps": require("./eslint-rules/pinned-deps.cjs"),
},
},
},
rules: {
"package-json-deps/controlled-versions": ["error", { granularity: "patch" }],
"unevenlabs-policy/pinned-deps": [
"error",
{
excludeList: [
"@berachain-foundation/berancer-sdk",
"@nktkas/hyperliquid",
"@solana-developers/helpers",
"@solana/spl-token",
],
internalScopes: ["@relay-vaults/"],
allowProtocols: ["workspace:", "^workspace:"],
allowProtocolsOnlyForInternal: true,
allowExactPrerelease: true,
},
],
},
},
];
9 changes: 1 addition & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,7 @@
"strip-ansi": "5.x"
},
"lint-staged": {
"package.json": [
"eslint --max-warnings 0"
],
"tsconfig*.json": [
"eslint --max-warnings 0 --fix"
],
"*.{js,ts}": [
"*.{js,ts,json}": [
"eslint --max-warnings 0 --fix"
]
},
Expand Down Expand Up @@ -68,7 +62,6 @@
"dotenv": "16.6.1",
"eslint": "9.39.2",
"eslint-plugin-jsonc": "2.21.0",
"eslint-plugin-package-json-dependencies": "1.0.20",
"husky": "9.1.7",
"jest": "29.7.0",
"jsonc-eslint-parser": "2.4.2",
Expand Down
2 changes: 1 addition & 1 deletion src/common/db.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import PgPromise from "pg-promise";

import { config } from "../config";
import { getIamToken } from './aws';
import { getIamToken } from "./aws";

export const pgp = PgPromise();

Expand Down
8 changes: 4 additions & 4 deletions src/scripts/run-migrations.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// wrapper for node-pg-migrate to inject the database password
import { spawnSync } from 'node:child_process';
import { getDatabaseUrlWithPassword } from '../common/db'
import { spawnSync } from "node:child_process";
import { getDatabaseUrlWithPassword } from "../common/db"

(async () => {
process.env.POSTGRES_URL = await getDatabaseUrlWithPassword(String(process.env.POSTGRES_URL));

spawnSync(
'node-pg-migrate',
"node-pg-migrate",
process.argv.slice(2),
{ stdio: 'inherit' }
{ stdio: "inherit" }
);
})();
2 changes: 1 addition & 1 deletion tsconfig.eslint.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
Loading