Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1367a54
simplify findDependency logic with resolveInheritedRange
suhdonghwi Nov 6, 2025
4b02aea
add prettierrc & format files
suhdonghwi Nov 6, 2025
d2f79b5
better return types for findDependency
suhdonghwi Nov 6, 2025
0c0360b
findAllAccessibleGroups removal, replace with findDependency
suhdonghwi Nov 6, 2025
4f7372f
values<string>
suhdonghwi Nov 6, 2025
94bc8f8
remove console.log for debugging
suhdonghwi Nov 6, 2025
da22044
getValidationInfoForNonCatalogDependency add
suhdonghwi Nov 6, 2025
a0c131d
separate files
suhdonghwi Nov 6, 2025
e032632
configuration directory separation
suhdonghwi Nov 6, 2025
f1d82c4
ValidationLevel type separation
suhdonghwi Nov 6, 2025
96e37e2
add typecheck script
suhdonghwi Nov 6, 2025
50234d1
use omit
suhdonghwi Nov 6, 2025
a73b669
validateCatalogUsability
suhdonghwi Nov 6, 2025
00201bb
getCatalogProtocolUsability
suhdonghwi Nov 6, 2025
e22397b
getUnusedCatalogDependencies
suhdonghwi Nov 6, 2025
5b6e27e
return descriptors
suhdonghwi Nov 6, 2025
a585773
build plugin
suhdonghwi Nov 6, 2025
c63da0f
use biome instead of prettier
Nov 7, 2025
f780e89
move configuration reading related methods to top
Nov 7, 2025
8ca2058
move methods (2)
Nov 7, 2025
685753f
clearCache better input
Nov 7, 2025
6415e63
move getInheritanceChain into utils.ts
Nov 7, 2025
ff95562
file separation
Nov 7, 2025
85d3f08
move files into utils/
Nov 7, 2025
3b4b45a
utils/functions
Nov 7, 2025
1e4e43c
rename functions
Nov 7, 2025
3085872
make private functions non exported
Nov 7, 2025
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
6 changes: 6 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": false
}
6 changes: 3 additions & 3 deletions bundles/@yarnpkg/plugin-catalogs.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"build:dev": "builder build plugin --no-minify",
"clean": "rimraf bundles",
"test": "yarn build && vitest run",
"test:watch": "yarn build && vitest watch"
"test:watch": "yarn build && vitest watch",
"typecheck": "tsc --noEmit"
},
"packageManager": "[email protected]"
}
4 changes: 2 additions & 2 deletions sources/__tests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export async function createTestWorkspace(): Promise<TestWorkspace> {
*/
export async function createTestProtocolPlugin(
workspace: TestWorkspace,
protocolName: string
protocolName: string,
): Promise<string> {
const pluginCode = `
module.exports = {
Expand Down Expand Up @@ -122,7 +122,7 @@ export function extractDependencies(log: string): string[] {
.filter((str) => str != null && str.length > 0)
.map(
(depsString) =>
JSON.parse(depsString) as { value: string; children: object }
JSON.parse(depsString) as { value: string; children: object },
)
.reduce((result, item) => [...result, item.value], [] as string[]);
}
13 changes: 13 additions & 0 deletions sources/configuration/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Error thrown when .yarnrc.yml#catalogs is invalid or missing
*/
export class CatalogConfigurationError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
this.name = "CatalogConfigurationError";
}

static FILE_NOT_FOUND = "FILE_NOT_FOUND";
static INVALID_FORMAT = "INVALID_FORMAT";
static INVALID_ALIAS = "INVALID_ALIAS";
}
7 changes: 7 additions & 0 deletions sources/configuration/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export * from "./types";
export * from "./errors";
export * from "./reader";

// Create a singleton instance of our configuration reader
import { CatalogConfigurationReader } from "./reader";
export const configReader = new CatalogConfigurationReader();
147 changes: 18 additions & 129 deletions sources/configuration.ts → sources/configuration/reader.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,9 @@
import { Descriptor, Project, structUtils, Workspace } from "@yarnpkg/core";
import { isMatch } from "picomatch";

export const ROOT_ALIAS_GROUP = "root";

export const CATALOG_PROTOCOL = "catalog:";

declare module "@yarnpkg/core" {
interface ConfigurationValueMap {
catalogs?: CatalogsConfiguration;
}
}

type ValidationLevel = "warn" | "strict" | "off";
type ValidationConfig =
| ValidationLevel
| { [groupName: string]: ValidationLevel };

/**
* Configuration structure for .yarnrc.yml#catalogs
*/
export interface CatalogsConfiguration {
options?: {
/**
* The default alias group to be used when no group is specified when adding a dependency
* - if list of alias groups, it will be used in order
* - if 'max', the most frequently used alias group will be used
*/
default?: string[] | "max";
/**
* List of workspaces to ignore
*/
ignoredWorkspaces?: string[];
/**
* Validation level for catalog usage
* - 'warn': Show warnings when catalog versions are not used (default)
* - 'strict': Throw errors when catalog versions are not used
* - 'off': Disable validation
* Can also be an object with group-specific settings: { [groupName]: 'warn' | 'strict' | 'off' }
*/
validation?: ValidationConfig;
};
list?: {
[alias: string]:
| {
[packageName: string]: string;
}
| string;
};
}

/**
* Error thrown when .yarnrc.yml#catalogs is invalid or missing
*/
export class CatalogConfigurationError extends Error {
constructor(
message: string,
public readonly code: string,
) {
super(message);
this.name = "CatalogConfigurationError";
}

static FILE_NOT_FOUND = "FILE_NOT_FOUND";
static INVALID_FORMAT = "INVALID_FORMAT";
static INVALID_ALIAS = "INVALID_ALIAS";
}
import { CatalogsConfiguration } from "./types";
import { CatalogConfigurationError } from "./errors";
import { ROOT_ALIAS_GROUP, CATALOG_PROTOCOL } from "../constants";
import { ValidationLevel } from "../types";

/**
* Handles reading and parsing of .yarnrc.yml#catalogs configuration
Expand Down Expand Up @@ -247,7 +186,9 @@ export class CatalogConfigurationReader {
if (inheritedVersion) {
// If version doesn't have a protocol prefix (e.g., "npm:"), add "npm:" as default
if (!/^[^:]+:/.test(inheritedVersion)) {
return `${project.configuration.get("defaultProtocol")}${inheritedVersion}`;
return `${project.configuration.get(
"defaultProtocol",
)}${inheritedVersion}`;
}
return inheritedVersion;
}
Expand Down Expand Up @@ -330,30 +271,6 @@ export class CatalogConfigurationReader {
return [];
}

/**
* Find all groups that can access a specific package (including inheritance)
*/
async findAllAccessibleGroups(
project: Project,
packageName: string,
): Promise<string[]> {
const config = await this.readConfiguration(project);
const accessibleGroups: string[] = [];

for (const groupName of Object.keys(config.list || {})) {
const resolvedRange = this.resolveInheritedRange(
config,
groupName,
packageName,
);
if (resolvedRange) {
accessibleGroups.push(groupName);
}
}

return accessibleGroups;
}

/**
* Get validation level for a specific group (considering inheritance)
*/
Expand Down Expand Up @@ -386,12 +303,11 @@ export class CatalogConfigurationReader {
*/
async getValidationLevelForPackage(
workspace: Workspace,
packageName: string,
descriptor: Descriptor,
): Promise<ValidationLevel> {
const accessibleGroups = await this.findAllAccessibleGroups(
workspace.project,
packageName,
);
const accessibleGroups = (
await this.findDependency(workspace.project, descriptor)
).map(({ groupName }) => groupName);

if (accessibleGroups.length === 0) {
return "off";
Expand All @@ -417,48 +333,21 @@ export class CatalogConfigurationReader {
async findDependency(
project: Project,
dependency: Descriptor,
): Promise<[string, string][]> {
): Promise<Array<{ groupName: string; version: string }>> {
const dependencyString = structUtils.stringifyIdent(dependency);
const config = await this.readConfiguration(project);
const results: [string, string][] = [];

// Direct lookup (existing behavior)
const aliasGroups = Object.entries(config.list || {}).filter(
([_, value]) => {
if (typeof value === "string") {
return dependencyString === value;
} else {
return Object.keys(value).includes(dependencyString);
}
},
);
const results: Array<{ groupName: string; version: string }> = [];

results.push(
...aliasGroups.map(([alias, aliasConfig]) => {
const version =
typeof aliasConfig === "string"
? aliasConfig
: aliasConfig[dependencyString];
return [alias, version] as [string, string];
}),
);

// Check for inheritance-based matches
for (const [groupName] of Object.entries(config.list || {})) {
// Skip if already found in direct lookup
if (results.some(([alias]) => alias === groupName)) {
continue;
}

// Check if dependency can be resolved through inheritance
const inheritedVersion = this.resolveInheritedRange(
// Use resolveInheritedRange for all groups to handle both direct and inherited matches
for (const groupName of Object.keys(config.list || {})) {
const resolvedVersion = this.resolveInheritedRange(
config,
groupName,
dependencyString,
);

if (inheritedVersion) {
results.push([groupName, inheritedVersion]);
if (resolvedVersion) {
results.push({ groupName, version: resolvedVersion });
}
}

Expand Down
44 changes: 44 additions & 0 deletions sources/configuration/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ValidationLevel } from "../types";

declare module "@yarnpkg/core" {
interface ConfigurationValueMap {
catalogs?: CatalogsConfiguration;
}
}

export type ValidationConfig =
| ValidationLevel
| { [groupName: string]: ValidationLevel };

/**
* Configuration structure for .yarnrc.yml#catalogs
*/
export interface CatalogsConfiguration {
options?: {
/**
* The default alias group to be used when no group is specified when adding a dependency
* - if list of alias groups, it will be used in order
* - if 'max', the most frequently used alias group will be used
*/
default?: string[] | "max";
/**
* List of workspaces to ignore
*/
ignoredWorkspaces?: string[];
/**
* Validation level for catalog usage
* - 'warn': Show warnings when catalog versions are not used (default)
* - 'strict': Throw errors when catalog versions are not used
* - 'off': Disable validation
* Can also be an object with group-specific settings: { [groupName]: 'warn' | 'strict' | 'off' }
*/
validation?: ValidationConfig;
};
list?: {
[alias: string]:
| {
[packageName: string]: string;
}
| string;
};
}
3 changes: 3 additions & 0 deletions sources/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const ROOT_ALIAS_GROUP = "root";

export const CATALOG_PROTOCOL = "catalog:";
61 changes: 61 additions & 0 deletions sources/fallback-default-alias-group.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about organizing the files by key feature rather than by individual functions? We could split them into four modules:

  • configuration: Reading and parsing yarnrc file, resolving catalog descriptors
  • inheritance: Resolving inheritance chains, validating inheritance structures
  • default: fallbackDefaultAliasGroup, getDefaultAliasGroups, ...
  • validation: getGroupValidationLevel, getValidationLevel, ...

We could place these at the same level (it looks like yarn core typically uses a utils directory for this) and have index.ts reference them. This structure might make the code easier to maintain going forward.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great idea. The only issue is that some functions (e.g. resolveInheritedRange, getValidationLevel) are methods of CatalogConfigurationReader. I'll consider separating those functions so that the Reader class focuses solely on configuration reading.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have made changes:

  • Now CatalogConfigurationReader only includes config reading, caching, config object validation feature
  • Created utils/ directory with default.ts, validation.ts, resolution.ts, functions.ts
    • default.ts: Fallback to default alias group
    • validation.ts: Validate if catalog dependencies are using catalog protocols (if possible)
    • resolution.ts: Resolve catalog dependency & Find all alias groups that contain specific dependency
    • funtions.ts: General utility functions

Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Descriptor, Workspace } from "@yarnpkg/core";
import chalk from "chalk";
import { configReader } from "./configuration";
import { getCatalogProtocolUsability } from "./get-catalog-protocol-usability";
import { CATALOG_PROTOCOL, ROOT_ALIAS_GROUP } from "./constants";

export async function fallbackDefaultAliasGroup(
workspace: Workspace,
dependency: Descriptor,
) {
if (dependency.range.startsWith(CATALOG_PROTOCOL)) {
if (await configReader.shouldIgnoreWorkspace(workspace)) {
throw new Error(
chalk.red(
`The workspace is ignored from the catalogs, but the dependency to add is using the catalog protocol. Consider removing the protocol.`,
),
);
}
return;
}

const validationInfo = await getCatalogProtocolUsability(
workspace,
dependency,
);

// If no applicable groups found, return early
if (!validationInfo) return;

const { validationLevel, applicableGroups } = validationInfo;

// If there's a default alias group, fallback to it
const defaultAliasGroups = await configReader.getDefaultAliasGroups(
workspace,
);
if (defaultAliasGroups.length > 0) {
for (const aliasGroup of defaultAliasGroups) {
if (applicableGroups.includes(aliasGroup)) {
dependency.range = `${CATALOG_PROTOCOL}${aliasGroup}`;
return;
}
}
}

// If no default alias group is specified, show warning message
const aliasGroups = applicableGroups.map((groupName) =>
groupName === ROOT_ALIAS_GROUP ? "" : groupName,
);

const aliasGroupsText =
aliasGroups.filter((aliasGroup) => aliasGroup !== "").length > 0
? ` (${aliasGroups.join(", ")})`
: "";

const message = `➤ ${dependency.name} is listed in the catalogs config${aliasGroupsText}, but it seems you're adding it without the catalog protocol. Consider running 'yarn add ${dependency.name}@${CATALOG_PROTOCOL}${aliasGroups[0]}' instead.`;
if (validationLevel === "strict") {
throw new Error(chalk.red(message));
} else if (validationLevel === "warn") {
console.warn(chalk.yellow(message));
}
}
Loading
Loading