Skip to content
Closed
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,25 @@ Controls whether dependencies should use the `catalog:` protocol:
- **`optional`**: CAN use catalog protocol. No errors or warnings.
- **`restrict`**: MUST NOT use catalog protocol. Throws errors if `catalog:` protocol is used.

##### `allow_protocols`

When using `strict` or `warn`, you may need to exempt certain protocols (e.g., `portal:`, `file:`, `link:`) that can't be replaced by `catalog:`. Use the object form of `catalog_protocol_usage` with `allow_protocols`:

```yaml
validation:
- workspaces: ["*"]
rules:
catalog_protocol_usage:
level: strict
allow_protocols:
- "portal:"
- "file:"
```

Dependencies using an allowed protocol will be exempt from the `strict`/`warn` check. The trailing colon is optional — `"portal"` and `"portal:"` are both accepted.

`allow_protocols` is only valid with `strict` or `warn` level. Using it with `optional` or `restrict` will result in an invalid configuration error.

#### Workspace-Based Validation

You can apply different validation rules to different workspaces using glob patterns. The first matching rule wins:
Expand Down
160 changes: 80 additions & 80 deletions bundles/@yarnpkg/plugin-catalogs.js

Large diffs are not rendered by default.

235 changes: 235 additions & 0 deletions sources/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
type TestWorkspace,
createTestWorkspace,
createTestProtocolPlugin,
hasDependency,
} from "./utils";

Expand Down Expand Up @@ -586,6 +587,240 @@ describe("validation", () => {
});
});

describe("allow_protocols", () => {
it("should pass when using an allowed protocol in strict mode", async () => {
workspace = await createTestWorkspace();
await createTestProtocolPlugin(workspace, "test");

await workspace.writeCatalogsYml({
options: {
default: ["stable"],
},
validation: [
{
workspaces: ["*"],
rules: {
catalog_protocol_usage: {
level: "strict",
allow_protocols: ["test:"],
},
},
},
],
list: {
stable: { react: "npm:18.0.0" },
},
});

await workspace.yarn.catalogs.apply();

await workspace.writeJson("package.json", {
name: "test-package",
version: "1.0.0",
private: true,
dependencies: { react: "test:18.0.0" },
});

const { stderr } = await workspace.yarn.install();
expect(stderr).toBe("");
});

it("should error when using a non-allowed protocol in strict mode", async () => {
workspace = await createTestWorkspace();
await createTestProtocolPlugin(workspace, "test");
await createTestProtocolPlugin(workspace, "other");

await workspace.writeCatalogsYml({
options: {
default: ["stable"],
},
validation: [
{
workspaces: ["*"],
rules: {
catalog_protocol_usage: {
level: "strict",
allow_protocols: ["test:"],
},
},
},
],
list: {
stable: { react: "npm:18.0.0" },
},
});

await workspace.yarn.catalogs.apply();

await workspace.writeJson("package.json", {
name: "test-package",
version: "1.0.0",
private: true,
dependencies: { react: "other:18.0.0" },
});

await expect(workspace.yarn.install()).rejects.toThrow();
});

it("should not warn when using an allowed protocol in warn mode", async () => {
workspace = await createTestWorkspace();
await createTestProtocolPlugin(workspace, "test");

await workspace.writeCatalogsYml({
options: {
default: ["stable"],
},
validation: [
{
workspaces: ["*"],
rules: {
catalog_protocol_usage: {
level: "warn",
allow_protocols: ["test:"],
},
},
},
],
list: {
stable: { react: "npm:18.0.0" },
},
});

await workspace.yarn.catalogs.apply();

await workspace.writeJson("package.json", {
name: "test-package",
version: "1.0.0",
private: true,
dependencies: { react: "test:18.0.0" },
});

const { stdout } = await workspace.yarn.install();
expect(stdout).not.toContain(validationMessage("react"));
});

it("should normalize protocol strings without trailing colon", async () => {
workspace = await createTestWorkspace();
await createTestProtocolPlugin(workspace, "test");

await workspace.writeCatalogsYml({
options: {
default: ["stable"],
},
validation: [
{
workspaces: ["*"],
rules: {
catalog_protocol_usage: {
level: "strict",
allow_protocols: ["test"],
},
},
},
],
list: {
stable: { react: "npm:18.0.0" },
},
});

await workspace.yarn.catalogs.apply();

await workspace.writeJson("package.json", {
name: "test-package",
version: "1.0.0",
private: true,
dependencies: { react: "test:18.0.0" },
});

const { stderr } = await workspace.yarn.install();
expect(stderr).toBe("");
});

it("should reject allow_protocols with optional level", async () => {
workspace = await createTestWorkspace();

await expect(
workspace.writeCatalogsYml({
options: {
default: ["stable"],
},
validation: [
{
workspaces: ["*"],
rules: {
catalog_protocol_usage: {
level: "optional",
allow_protocols: ["test:"],
},
},
},
],
list: {
stable: { react: "npm:18.0.0" },
},
}).then(() => workspace.yarn.catalogs.apply()),
).rejects.toThrow();
});

it("should reject allow_protocols with restrict level", async () => {
workspace = await createTestWorkspace();

await expect(
workspace.writeCatalogsYml({
options: {
default: ["stable"],
},
validation: [
{
workspaces: ["*"],
rules: {
catalog_protocol_usage: {
level: "restrict",
allow_protocols: ["test:"],
},
},
},
],
list: {
stable: { react: "npm:18.0.0" },
},
}).then(() => workspace.yarn.catalogs.apply()),
).rejects.toThrow();
});

it("should work with object form without allow_protocols", async () => {
workspace = await createTestWorkspace();

await workspace.writeCatalogsYml({
options: {
default: ["stable"],
},
validation: [
{
workspaces: ["*"],
rules: {
catalog_protocol_usage: { level: "strict" },
},
},
],
list: {
stable: { react: "npm:18.0.0" },
},
});

await workspace.yarn.catalogs.apply();

await workspace.writeJson("package.json", {
name: "test-package",
version: "1.0.0",
private: true,
dependencies: { react: "17.0.0" },
});

await expect(workspace.yarn.install()).rejects.toThrow();
});
});

describe("workspace pattern matching", () => {
it("should skip validation when no pattern matches", async () => {
workspace = await createTestWorkspace();
Expand Down
33 changes: 28 additions & 5 deletions sources/configuration/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ function isValidValidationConfig(
return false;
}

const validRuleValues = ["strict", "warn", "optional", "restrict"];
const validLevelValues = ["strict", "warn", "optional", "restrict"];
const allowProtocolsLevels = ["strict", "warn"];

for (const rule of validation) {
if (!rule || typeof rule !== "object") {
Expand All @@ -35,10 +36,32 @@ function isValidValidationConfig(

const rulesObj = rules as Record<string, unknown>;
if (rulesObj.catalog_protocol_usage !== undefined) {
if (
typeof rulesObj.catalog_protocol_usage !== "string" ||
!validRuleValues.includes(rulesObj.catalog_protocol_usage)
) {
const usage = rulesObj.catalog_protocol_usage;

if (typeof usage === "string") {
if (!validLevelValues.includes(usage)) {
return false;
}
} else if (typeof usage === "object" && usage !== null) {
const usageObj = usage as Record<string, unknown>;

if (typeof usageObj.level !== "string" || !validLevelValues.includes(usageObj.level)) {
return false;
}

if (usageObj.allow_protocols !== undefined) {
if (!allowProtocolsLevels.includes(usageObj.level)) {
return false;
}

if (
!Array.isArray(usageObj.allow_protocols) ||
!usageObj.allow_protocols.every((p) => typeof p === "string")
) {
return false;
}
}
} else {
return false;
}
}
Expand Down
32 changes: 29 additions & 3 deletions sources/configuration/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,35 @@
"additionalProperties": false,
"properties": {
"catalog_protocol_usage": {
"type": "string",
"description": "Controls catalog: protocol usage validation. 'strict' = MUST use catalog protocol, 'warn' = SHOULD use catalog protocol (warn if not), 'optional' = CAN use catalog protocol (no errors/warnings), 'restrict' = MUST NOT use catalog protocol",
"enum": ["strict", "warn", "optional", "restrict"]
"description": "Controls catalog: protocol usage validation. Can be a level string or an object with level and allow_protocols.",
"oneOf": [
{
"type": "string",
"enum": ["strict", "warn", "optional", "restrict"]
},
{
"type": "object",
"required": ["level"],
"additionalProperties": false,
"properties": {
"level": {
"type": "string",
"enum": ["strict", "warn", "optional", "restrict"]
},
"allow_protocols": {
"type": "array",
"description": "Protocols to exempt from catalog protocol usage validation (e.g., 'portal:', 'file:'). Only valid with 'strict' or 'warn' level.",
"items": { "type": "string" }
}
},
"if": {
"properties": { "level": { "enum": ["optional", "restrict"] } }
},
"then": {
"not": { "required": ["allow_protocols"] }
}
}
]
}
}
}
Expand Down
13 changes: 11 additions & 2 deletions sources/configuration/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
/**
* Rule for catalog protocol usage validation
* Rule levels for catalog protocol usage validation
* - 'strict': MUST use catalog protocol.
* - 'warn': SHOULD use catalog protocol. Print warnings if not.
* - 'optional': CAN use catalog protocol. No errors/warnings.
* - 'restrict': MUST NOT use catalog protocol.
*/
export type CatalogProtocolUsageRule = "strict" | "warn" | "optional" | "restrict";
type CatalogProtocolUsageLevel = "strict" | "warn" | "optional" | "restrict";

/**
* Rule for catalog protocol usage validation.
* Can be a simple string level or an object with level and allow_protocols.
*/
export type CatalogProtocolUsageRule =
| CatalogProtocolUsageLevel
| { level: Extract<CatalogProtocolUsageLevel, "strict" | "warn">; allow_protocols?: string[] }
| { level: Exclude<CatalogProtocolUsageLevel, "strict" | "warn"> };

export interface ValidationRules {
catalog_protocol_usage?: CatalogProtocolUsageRule;
Expand Down
Loading
Loading