Skip to content

Commit

Permalink
fix: handle unpublished packages/versions
Browse files Browse the repository at this point in the history
  • Loading branch information
ext committed Feb 28, 2024
1 parent 894a758 commit e9578e6
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 37 deletions.
4 changes: 2 additions & 2 deletions src/package-json.ts
Expand Up @@ -104,8 +104,8 @@ export async function verifyPackageJson(
{
messages,
filePath,
errorCount: messages.length,
warningCount: 0,
errorCount: messages.filter((it) => it.severity === 2).length,
warningCount: messages.filter((it) => it.severity === 1).length,
fixableErrorCount: 0,
fixableWarningCount: 0,
},
Expand Down
65 changes: 51 additions & 14 deletions src/rules/verify-engine-constraint.ts
@@ -1,12 +1,15 @@
import semver from "semver";
import semver, { type SemVer } from "semver";
import { type Message } from "../message";
import { type PackageJson } from "../types";
import { npmInfo } from "../utils";
import { isNpmInfoError, npmInfo } from "../utils";

const ruleId = "invalid-engine-constraint";

async function* getDeepDependencies(pkg: PackageJson, dependency?: string): AsyncGenerator<string> {
const pkgData = dependency ? await npmInfo(dependency) : pkg;
const pkgData = dependency ? await npmInfo(dependency, { ignoreUnpublished: true }) : pkg;
if (!pkgData) {
return;
}
for (const [key, value] of Object.entries(pkgData.dependencies ?? {})) {
/* ignore this as this package is sometimes is present as version "*" which
* just yields way to many versions to handle causing MaxBuffer errors and
Expand All @@ -22,6 +25,30 @@ async function* getDeepDependencies(pkg: PackageJson, dependency?: string): Asyn
}
}

async function verifyDependency(
dependency: string,
minDeclared: SemVer,
declaredConstraint: string
): Promise<Message | null> {
const pkgData = await npmInfo(dependency);
const constraint = pkgData.engines?.node;
if (!constraint) {
return null;
}

if (!semver.satisfies(minDeclared, constraint)) {
return {
ruleId,
severity: 2,
message: `the transitive dependency "${dependency}" (node ${constraint}) does not satisfy the declared node engine "${declaredConstraint}"`,
line: 1,
column: 1,
};
}

return null;
}

export async function verifyEngineConstraint(pkg: PackageJson): Promise<Message[]> {
const declaredConstraint = pkg.engines?.node;
if (!declaredConstraint) {
Expand All @@ -34,22 +61,32 @@ export async function verifyEngineConstraint(pkg: PackageJson): Promise<Message[
}

const messages: Message[] = [];
const visited = new Set<string>();

for await (const dependency of getDeepDependencies(pkg)) {
const pkgData = await npmInfo(dependency);
const constraint = pkgData.engines?.node;
if (!constraint) {
if (visited.has(dependency)) {
continue;
}

if (!semver.satisfies(minDeclared, constraint)) {
messages.push({
ruleId,
severity: 2,
message: `the transitive dependency "${dependency}" (node ${constraint}) does not satisfy the declared node engine "${declaredConstraint}"`,
line: 1,
column: 1,
});
visited.add(dependency);

try {
const message = await verifyDependency(dependency, minDeclared, declaredConstraint);
if (message) {
messages.push(message);
}
} catch (err: unknown) {
if (isNpmInfoError(err) && err.code === "E404") {
messages.push({
ruleId,
severity: 1,
message: `the transitive dependency "${dependency}" is not published to the NPM registry`,
line: 1,
column: 1,
});
continue;
}
throw err;
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/tarball.ts
Expand Up @@ -208,8 +208,8 @@ export async function verifyTarball(pkg: PackageJson, tarball: TarballMeta): Pro
{
messages,
filePath: tarball.reportPath ?? tarball.filePath,
errorCount: messages.length,
warningCount: 0,
errorCount: messages.filter((it) => it.severity === 2).length,
warningCount: messages.filter((it) => it.severity === 1).length,
fixableErrorCount: 0,
fixableWarningCount: 0,
},
Expand Down
2 changes: 1 addition & 1 deletion src/utils/index.ts
@@ -1 +1 @@
export { npmInfo } from "./npm-info";
export { type NpmInfoError, isNpmInfoError, npmInfo } from "./npm-info";
89 changes: 79 additions & 10 deletions src/utils/npm-info.spec.ts
@@ -1,23 +1,49 @@
import { execa } from "execa";
import { npmInfo } from "./npm-info";
import { type ExecaError, type NpmInfoError, npmInfo, isNpmInfoError } from "./npm-info";

jest.mock("execa");
jest.mock("./persistent-cache");

const mockExeca = execa as unknown as jest.Mock;

function createExecaError(message: string, stdout: string | Record<string, unknown>): Error {
const error = new Error(message) as Error & ExecaError;
error.stdout = typeof stdout === "string" ? stdout : JSON.stringify(stdout);
return error;
}

mockExeca.mockImplementation((_, args: string[]) => {
const [name, version] = args.at(-1)!.split("@");

switch (name) {
case "exception":
throw new Error("Unrelated exception");

case "unpublished":
throw createExecaError("mock error", {
error: {
code: "E404",
summary: "Mock summary",
detail: "Mock details",
},
});

case "unpublished-garbage":
throw createExecaError("mock error", "garbage non-json response");

default:
return {
stdout: JSON.stringify({ name, version }),
};
}
});

beforeEach(() => {
jest.clearAllMocks();
});

it("should fetch package info", async () => {
expect.assertions(2);
mockExeca.mockImplementation(() => ({
stdout: JSON.stringify({
name: "my-package",
version: "1.2.3",
}),
}));
const pkgData = await npmInfo("my-package@1.2.3");
expect(mockExeca).toHaveBeenCalledWith("npm", ["info", "--json", "my-package@1.2.3"]);
expect(pkgData).toEqual({
Expand All @@ -26,13 +52,56 @@ it("should fetch package info", async () => {
});
});

it("should return null when using ignoreUnpublished", async () => {
expect.assertions(1);
const pkgData = await npmInfo("unpublished@1.2.3", { ignoreUnpublished: true });
expect(pkgData).toBeNull();
});

it("should cache results", async () => {
expect.assertions(1);
mockExeca.mockImplementation(() => ({
stdout: "{}",
}));
await npmInfo("my-package@1");
await npmInfo("my-package@2");
await npmInfo("my-package@1");
expect(mockExeca).toHaveBeenCalledTimes(2);
});

it("should cache ignoreUnpublished", async () => {
expect.assertions(1);
await npmInfo("unpublished@1", { ignoreUnpublished: true });
await npmInfo("unpublished@2", { ignoreUnpublished: true });
await npmInfo("unpublished@1", { ignoreUnpublished: true });
expect(mockExeca).toHaveBeenCalledTimes(2);
});

it("should throw custom error if an error is returned by npm info", async () => {
expect.assertions(1);
await expect(() => npmInfo("unpublished@1.2.3")).rejects.toThrow("Mock summary");
});

it("should handle garbade data from registry", async () => {
expect.assertions(1);
await expect(() => npmInfo("unpublished-garbage@1.2.3")).rejects.toThrow("mock error");
});

it("should throw original error for other exceptions", async () => {
expect.assertions(1);
await expect(() => npmInfo("exception")).rejects.toThrow("Unrelated exception");
});

describe("isNpmInfoError()", () => {
it("should return true if error contains npm info error data", () => {
expect.assertions(1);
const error = new Error("mock error") as Error & NpmInfoError;
error.code = "E404";
error.summary = "summary";
error.detail = "detail";
expect(isNpmInfoError(error)).toBeTruthy();
});

it("should return false for errors without npm info error data", () => {
expect.assertions(1);
const error = new Error("mock error");
expect(isNpmInfoError(error)).toBeFalsy();
});
});
80 changes: 72 additions & 8 deletions src/utils/npm-info.ts
Expand Up @@ -2,11 +2,50 @@ import { execa } from "execa";
import { type PackageJson } from "../types";
import { persistentCacheGet, persistentCacheSet } from "./persistent-cache";

const cache = new Map<string, PackageJson>();
export interface NpmInfoError {
code: string;
summary: string;
detail: string;
}

export interface ExecaError extends Error {
stdout: string;
}

const cache = new Map<string, PackageJson | null>();

function isExecaError(error: unknown): error is ExecaError {
return Boolean(error && error instanceof Error && "stdout" in error);
}

export function isNpmInfoError(error: unknown): error is NpmInfoError {
return Boolean(error && error instanceof Error && "summary" in error);
}

export async function npmInfo(pkg: string): Promise<PackageJson> {
function tryParse(maybeJson: string): { error: NpmInfoError } | null {
try {
return JSON.parse(maybeJson) as { error: NpmInfoError };
} catch {
return null;
}
}

export async function npmInfo(pkg: string): Promise<PackageJson>;
export async function npmInfo(
pkg: string,
options: { ignoreUnpublished: true }
): Promise<PackageJson | null>;
export async function npmInfo(
pkg: string,
options: { ignoreUnpublished: boolean } = { ignoreUnpublished: false }
): Promise<PackageJson | null> {
const { ignoreUnpublished } = options;
const cached = cache.get(pkg);
if (cached) {
if (cached === null) {
if (ignoreUnpublished) {
return null;
}
} else if (cached) {
return cached;
}

Expand All @@ -15,9 +54,34 @@ export async function npmInfo(pkg: string): Promise<PackageJson> {
return persistent as PackageJson;
}

const result = await execa("npm", ["info", "--json", pkg]);
const pkgData = JSON.parse(result.stdout) as PackageJson;
cache.set(pkg, pkgData);
await persistentCacheSet(pkg, pkgData);
return pkgData;
try {
const result = await execa("npm", ["info", "--json", pkg]);
const pkgData = JSON.parse(result.stdout) as PackageJson;
cache.set(pkg, pkgData);
await persistentCacheSet(pkg, pkgData);
return pkgData;
} catch (err: unknown) {
if (!isExecaError(err)) {
throw err;
}
const parsed = tryParse(err.stdout);
if (!parsed) {
throw err;
}
const { code, summary, detail } = parsed.error;

/* cache for this session but don't store in persistent cache as this
* error might be temporary */
cache.set(pkg, null);

if (ignoreUnpublished && code === "E404") {
return null;
}

const wrappedError = new Error(summary) as Error & NpmInfoError;
wrappedError.code = code;
wrappedError.summary = summary;
wrappedError.detail = detail;
throw wrappedError;
}
}

0 comments on commit e9578e6

Please sign in to comment.