Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[rush] JSON Selector Expressions (with "json:" selector parser) #4271

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Add selector expressions to Rush CLI",
"type": "none"
}
],
"packageName": "@microsoft/rush"
}
15 changes: 15 additions & 0 deletions libraries/rush-lib/src/api/IRushProjectSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { RushConfigurationProject } from './RushConfigurationProject';
import { SelectorExpression } from './SelectorExpressions';

/**
* This interface allows a previously constructed RushProjectSelector to be passed around
* and used by other lower-level objects. (For example, the "json:" selector reads and
* parses an entire new selector expression, which might in turn load another selector
* expression and parse it.)
*/
export interface IRushProjectSelector {
selectExpression(expr: SelectorExpression, context: string): Promise<ReadonlySet<RushConfigurationProject>>;
}
213 changes: 213 additions & 0 deletions libraries/rush-lib/src/api/RushProjectSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { ITerminal } from '@rushstack/node-core-library';
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved
import { Selection } from '../logic/Selection';
import {
GitChangedProjectSelectorParser,
IGitSelectorParserOptions
} from '../logic/selectors/GitChangedProjectSelectorParser';
import type { ISelectorParser } from '../logic/selectors/ISelectorParser';
import { JsonFileSelectorParser } from '../logic/selectors/JsonFileSelectorParser';
import { NamedProjectSelectorParser } from '../logic/selectors/NamedProjectSelectorParser';
import { SelectorError } from '../logic/selectors/SelectorError';
import { TagProjectSelectorParser } from '../logic/selectors/TagProjectSelectorParser';
import { VersionPolicyProjectSelectorParser } from '../logic/selectors/VersionPolicyProjectSelectorParser';
import { RushConfiguration } from './RushConfiguration';
import { RushConfigurationProject } from './RushConfigurationProject';
import {
ExpressionParameter,
IExpressionDetailedSelector,
IExpressionOperatorIntersect,
IExpressionOperatorSubtract,
IExpressionOperatorUnion,
SelectorExpression,
isDetailedSelector,
isIntersect,
isParameter,
isSubtract,
isUnion
} from './SelectorExpressions';

/**
* When preparing to select projects in a Rush monorepo, some selector scopes
* require additional configuration in order to control their behavior. This
* options interface allows the caller to provide these properties.
*/
export interface IProjectSelectionOptions {
/**
* Options required for configuring the git selector scope.
*/
gitSelectorParserOptions: IGitSelectorParserOptions;
}

/**
* A central interface for selecting a subset of Rush projects from a given monorepo,
* using standardized selector expressions. Note that the types of selectors available
* in a monorepo may be influenced in the future by plugins, so project selection
* is always done in the context of a particular Rush configuration.
*/
export class RushProjectSelector {
private readonly _rushConfig: RushConfiguration;
private readonly _scopes: Map<string, ISelectorParser<RushConfigurationProject>>;
private readonly _options: IProjectSelectionOptions;

public constructor(rushConfig: RushConfiguration, options: IProjectSelectionOptions) {
this._rushConfig = rushConfig;
this._options = options;

const scopes: Map<string, ISelectorParser<RushConfigurationProject>> = new Map();
scopes.set('name', new NamedProjectSelectorParser(this._rushConfig));
scopes.set(
'git',
new GitChangedProjectSelectorParser(this._rushConfig, this._options.gitSelectorParserOptions)
);
scopes.set('tag', new TagProjectSelectorParser(this._rushConfig));
scopes.set('version-policy', new VersionPolicyProjectSelectorParser(this._rushConfig));
scopes.set('json', new JsonFileSelectorParser(this._rushConfig, this));
this._scopes = scopes;
}

/**
* Select a set of projects using the passed selector expression.
*
* The passed context string is used only when constructing error messages, in the event of
* an error in user input. The default string "expression" is used if no context is provided.
*/
public async selectExpression(
expr: SelectorExpression,
context: string = 'expression'
): Promise<ReadonlySet<RushConfigurationProject>> {
if (isUnion(expr)) {
return this._evaluateUnion(expr, context);
} else if (isIntersect(expr)) {
return this._evaluateIntersect(expr, context);
} else if (isSubtract(expr)) {
return this._evaluateSubtract(expr, context);
} else if (isParameter(expr)) {
return this._evaluateParameter(expr, context);
} else if (isDetailedSelector(expr)) {
return this._evaluateDetailedSelector(expr, context);
} else if (typeof expr === 'string') {
return this._evaluateSimpleSelector(expr, context);
} else {
// Fail-safe... in general, this shouldn't be possible, as user script type checking
// or JSON schema validation should catch it before this point.
throw new SelectorError(`Invalid object encountered in selector expression in ${context}.`);
}
}

private async _evaluateUnion(
expr: IExpressionOperatorUnion,
context: string
): Promise<Set<RushConfigurationProject>> {
const results: ReadonlySet<RushConfigurationProject>[] = [];
for (const operand of expr.union) {
results.push(await this.selectExpression(operand, context));
}

return Selection.union(results[0], ...results.slice(1));
}

private async _evaluateIntersect(
expr: IExpressionOperatorIntersect,
context: string
): Promise<Set<RushConfigurationProject>> {
const results: ReadonlySet<RushConfigurationProject>[] = [];
for (const operand of expr.intersect) {
results.push(await this.selectExpression(operand, context));
}

return Selection.intersection(results[0], ...results.slice(1));
}

private async _evaluateSubtract(
expr: IExpressionOperatorSubtract,
context: string
): Promise<Set<RushConfigurationProject>> {
const results: ReadonlySet<RushConfigurationProject>[] = [];
for (const operand of expr.subtract) {
results.push(await this.selectExpression(operand, context));
}

return Selection.subtraction(results[0], ...results.slice(1));
}

private async _evaluateParameter(
expr: ExpressionParameter,
context: string
): Promise<ReadonlySet<RushConfigurationProject>> {
const key: keyof ExpressionParameter = Object.keys(expr)[0] as keyof ExpressionParameter;
const value: SelectorExpression = expr[key];

// Existing parameters "--to-version-policy" and "--from-version-policy" are not supported
// in expressions (prefer `{ "--to": "version-policy:xyz" }`).
//
// Existing parameter "--changed-projects-only" is also not supported.

if (key === '--to') {
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved
const projects: ReadonlySet<RushConfigurationProject> = await this.selectExpression(value, context);
return Selection.expandAllDependencies(projects);
} else if (key === '--from') {
const projects: ReadonlySet<RushConfigurationProject> = await this.selectExpression(value, context);
return Selection.expandAllDependencies(Selection.expandAllConsumers(projects));
} else if (key === '--impacted-by') {
const projects: ReadonlySet<RushConfigurationProject> = await this.selectExpression(value, context);
return Selection.expandAllConsumers(projects);
} else if (key === '--to-except') {
const projects: ReadonlySet<RushConfigurationProject> = await this.selectExpression(value, context);
return Selection.subtraction(Selection.expandAllDependencies(projects), projects);
} else if (key === '--impacted-by-except') {
const projects: ReadonlySet<RushConfigurationProject> = await this.selectExpression(value, context);
return Selection.subtraction(Selection.expandAllConsumers(projects), projects);
} else if (key === '--only') {
// "only" is a no-op in a generic selector expression
const projects: ReadonlySet<RushConfigurationProject> = await this.selectExpression(value, context);
return projects;
} else {
throw new SelectorError(`Unknown parameter '${key}' encountered in selector expression in ${context}.`);
}
}

private async _evaluateDetailedSelector(
expr: IExpressionDetailedSelector,
context: string
): Promise<Set<RushConfigurationProject>> {
const parser: ISelectorParser<RushConfigurationProject> | undefined = this._scopes.get(expr.scope);
if (!parser) {
throw new SelectorError(
`Unknown selector scope '${expr.scope}' for value '${expr.value}' in ${context}.`
);
}

const result: Set<RushConfigurationProject> = new Set();
for (const project of await parser.evaluateSelectorAsync({
unscopedSelector: expr.value,
terminal: undefined as unknown as ITerminal,
context: context
})) {
result.add(project);
}

return result;
}

private async _evaluateSimpleSelector(
expr: string,
context: string
): Promise<Set<RushConfigurationProject>> {
const index: number = expr.indexOf(':');

if (index === -1) {
return this._evaluateDetailedSelector({ scope: 'name', value: expr }, context);
}

return this._evaluateDetailedSelector(
{
scope: expr.slice(0, index),
value: expr.slice(index + 1)
},
context
);
}
}
39 changes: 39 additions & 0 deletions libraries/rush-lib/src/api/SelectorExpressionJsonFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { JsonFile, JsonSchema, FileSystem } from '@rushstack/node-core-library';

import { SelectorExpression } from './SelectorExpressions';
import schemaJson from '../schemas/selector-expression.schema.json';

/**
* A utility class for saving and loading selector expression JSON files.
*/
export class SelectorExpressionJsonFile {
private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson);

public static async loadAsync(jsonFilePath: string): Promise<SelectorExpression> {
const expr: SelectorExpression = await JsonFile.loadAndValidateAsync(
jsonFilePath,
SelectorExpressionJsonFile._jsonSchema
);
return expr;
}

public static async tryLoadAsync(jsonFilePath: string): Promise<SelectorExpression | undefined> {
try {
return await this.loadAsync(jsonFilePath);
} catch (error) {
if (FileSystem.isNotExistError(error as Error)) {
return undefined;
}
throw error;
}
}

public static loadFromString(jsonString: string): SelectorExpression {
const expr: SelectorExpression = JsonFile.parseString(jsonString);
SelectorExpressionJsonFile._jsonSchema.validateObject(expr, 'stdin');
return expr;
}
}
61 changes: 61 additions & 0 deletions libraries/rush-lib/src/api/SelectorExpressions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

/**
* A "Selector Expression" is a JSON description of a complex selection of
* projects, using concepts familiar to users of the Rush CLI. This type represents
* the type-safe version of these JSON objects.
*/
export type SelectorExpression = ExpressionSelector | ExpressionParameter | ExpressionOperator;

export type ExpressionSelector = string | IExpressionDetailedSelector;

export interface IExpressionDetailedSelector {
scope: string;

Choose a reason for hiding this comment

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

What is scope? Give example strings

value: string;

Choose a reason for hiding this comment

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

What is value? Give example strings


// Reserved for future use
filters?: Record<string, string>;
}

export type ExpressionParameter = Record<`--${string}`, string>;

Choose a reason for hiding this comment

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

Seems like this should be allowed:

Screenshot 2023-09-14 at 1 06 08 AM


export type ExpressionOperator =
| IExpressionOperatorUnion
| IExpressionOperatorIntersect
| IExpressionOperatorSubtract;

export interface IExpressionOperatorUnion {
union: SelectorExpression[];
}

export interface IExpressionOperatorIntersect {
intersect: SelectorExpression[];
}

export interface IExpressionOperatorSubtract {
subtract: SelectorExpression[];

Choose a reason for hiding this comment

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

Is multi-subtraction overcomplicated?

For example, subtract({A,C}, {B,C}, {C}):

  • is that ({A,C} - {B,C}) - {C} = {A}?
  • Or is it {A,C} - ({B,C} - {C}) = {A,C}?

This order-of-operations concern does not arise with union or intersection. Multi-subtraction can easily be non-ambiguously implemented as subtract(A, union(B,C)).

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 guess the only pushback I have is where to enforce such a restriction.

I could attempt to use a FixedLengthArray type for this everywhere, and enforce a min/max length in the JSON schema, and then throw Error in the function if it's not exactly 2 elements, but it seems like a lot of extra work to prevent the user from providing multiple elements. Whereas if you allow it, and all your examples only show 2 elements, I think most people will typically just subtract two elements.

(For what it's worth, your first result seems like the clear answer, it is the 1st operand minus all the other operands.)

P.S. I definitely don't want to change to not be an array e.g. { minuend: , subtrahend: } - I think there's value in all operators taking an operands array of at least 2 elements.

}

// A collection of type guards useful for interacting with selector expressions.

export function isDetailedSelector(expr: SelectorExpression): expr is IExpressionDetailedSelector {
return !!(expr && (expr as IExpressionDetailedSelector).scope);
}

export function isParameter(expr: SelectorExpression): expr is ExpressionParameter {
const keys: string[] = Object.keys(expr);
return keys.length === 1 && keys[0].startsWith('--');
}

export function isUnion(expr: SelectorExpression): expr is IExpressionOperatorUnion {
return !!(expr && (expr as IExpressionOperatorUnion).union);
}

export function isIntersect(expr: SelectorExpression): expr is IExpressionOperatorIntersect {
return !!(expr && (expr as IExpressionOperatorIntersect).intersect);
}

export function isSubtract(expr: SelectorExpression): expr is IExpressionOperatorSubtract {
return !!(expr && (expr as IExpressionOperatorSubtract).subtract);
}