@nx/devkit
Version:
419 lines (418 loc) • 18.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.upsertTargetDefault = upsertTargetDefault;
exports.findTargetDefault = findTargetDefault;
exports.updateTargetDefault = updateTargetDefault;
exports.readTargetDefaultsForTarget = readTargetDefaultsForTarget;
exports.addBuildTargetDefaults = addBuildTargetDefaults;
exports.addE2eCiTargetDefaults = addE2eCiTargetDefaults;
const devkit_exports_1 = require("nx/src/devkit-exports");
const devkit_internals_1 = require("nx/src/devkit-internals");
const minimatch_1 = require("minimatch");
/**
* Upsert a `targetDefaults` entry on the provided `nxJson`. Mutates
* `nxJson` in place and returns it so the caller can chain or batch
* other edits before persisting via `updateNxJson` exactly once.
*
* The input is the logical {@link TargetDefaultEntry} shape
* (`{ target?, executor?, projects?, plugin?, ...config }`). The `projects` /
* `plugin` filter is *context* describing what the caller is configuring, used
* to pick which existing entry to merge into — it is never authored as a new
* filtered entry. For the key (`target` ?? `executor`):
*
* - No value yet → create the generic (plain object) value.
* - Only a generic value → merge into it.
* - A filtered entry matching the context filter exists → merge into that one
* (so a caller updating a project the user already scoped a default for edits
* that scoped entry rather than clobbering the workspace baseline).
* - Otherwise → merge into the generic, creating one if needed.
*
* A lone unfiltered entry is always stored as a plain object, never a
* single-element array. The entry must set at least one of `target` /
* `executor`.
*/
function upsertTargetDefault(tree, nxJson, options) {
const { target, executor, projects, plugin, ...config } = options;
const key = target ?? executor;
if (key === undefined) {
throw new Error('upsertTargetDefault requires at least one of `target` or `executor` to be set.');
}
// `executor` is the map key itself when no `target` is given; when a `target`
// is present it is a configuration field (a default executor) written onto
// the value.
const valueConfig = target !== undefined && executor !== undefined
? { executor, ...config }
: config;
const targetDefaults = (nxJson.targetDefaults ??= {});
targetDefaults[key] = collapse(upsertValue(targetDefaults[key], valueConfig, {
tree,
projects,
plugin,
executor,
}));
return nxJson;
}
/**
* Merge `config` into the right entry of a key's value, per the algorithm
* documented on {@link upsertTargetDefault}. Returns the new value (always an
* array here; {@link collapse} downgrades a lone unfiltered one to an object).
*/
function upsertValue(existing, config, context) {
// No value yet → create the generic.
if (existing === undefined) {
return { ...config };
}
const entries = Array.isArray(existing)
? [...existing]
: [existing];
// A filtered entry that covers what's being configured → merge into it,
// preserving its filter.
const matchIdx = entries.findIndex((e) => e.filter !== undefined && filterCoversContext(e.filter, context));
if (matchIdx >= 0) {
entries[matchIdx] = { ...entries[matchIdx], ...config };
return entries;
}
// Otherwise merge into the generic (catch-all) entry, creating one if absent.
const genericIdx = entries.findIndex((e) => e.filter === undefined);
if (genericIdx >= 0) {
const { filter: _filter, ...rest } = entries[genericIdx];
entries[genericIdx] = { ...rest, ...config };
}
else {
entries.push({ ...config });
}
return entries;
}
/**
* Whether an entry's `filter` applies to what the caller is configuring:
* `plugin` / `executor` must match exactly, and `projects` is evaluated as
* *coverage* — every configured project must match the filter's patterns
* (names, globs, `tag:`, directories, negation) via `findMatchingProjects`,
* resolved against each project's tags/root in the tree. A `projects` filter
* can't be confirmed when no project context is supplied (or the project
* isn't in the tree), so it falls through to the generic.
*/
function filterCoversContext(filter, context) {
if (filter.plugin !== undefined && filter.plugin !== context.plugin) {
return false;
}
if (filter.executor !== undefined && filter.executor !== context.executor) {
return false;
}
if (filter.projects !== undefined) {
const configured = context.projects ?? [];
const nodes = contextProjectNodes(context.tree, configured);
if (!projectsFilterCovers(filter.projects, nodes, configured)) {
return false;
}
}
return true;
}
/**
* Whether a `filter.projects` pattern set covers *every* required project name:
* each must be returned by `findMatchingProjects` against `nodes`. An empty
* `requiredNames` can never be "covered", so the entry falls through to the
* generic baseline. Shared by the upsert and update paths so the coverage
* semantics live in one place.
*/
function projectsFilterCovers(patterns, nodes, requiredNames) {
if (requiredNames.length === 0) {
return false;
}
const matched = (0, devkit_internals_1.findMatchingProjects)([...patterns], nodes);
return requiredNames.every((name) => matched.includes(name));
}
function contextProjectNodes(tree, projectNames) {
const projects = (0, devkit_exports_1.getProjects)(tree);
const nodes = {};
for (const name of projectNames) {
const config = projects.get(name);
if (config) {
nodes[name] = { data: { root: config.root, tags: config.tags } };
}
}
return nodes;
}
/**
* Collapse a key's value back to the plain object form when it has degenerated
* to a single entry with no filter — at that point the array wrapper carries no
* information the object form doesn't, and the object form is the tidier,
* record-shape-compatible representation.
*/
function collapse(value) {
if (Array.isArray(value) &&
value.length === 1 &&
value[0].filter === undefined) {
// Drop the (already-undefined) `filter` key so the result matches the
// object value form, which forbids `filter`.
const { filter: _filter, ...config } = value[0];
return config;
}
return value;
}
/**
* Find a `targetDefaults` entry by its logical locator tuple
* `(target, executor, projects, plugin)`. Locator keys default to
* `undefined`, matching only entries that also leave them unset — same
* semantics as `upsertTargetDefault`.
*
* Throws when called with an empty locator (no `target`, `executor`,
* `projects`, or `plugin`). An empty locator is almost always a bug — the
* caller intended to find a specific entry but forgot to populate the lookup.
*/
function findTargetDefault(targetDefaults, locator) {
if (locator.target === undefined &&
locator.executor === undefined &&
locator.projects === undefined &&
locator.plugin === undefined) {
throw new Error('findTargetDefault requires at least one of `target`, `executor`, `projects`, or `plugin` on the locator.');
}
for (const entry of logicalTargetDefaultEntries(targetDefaults)) {
if (entry.target === locator.target &&
entry.executor === locator.executor &&
projectsEqual(entry.projects, locator.projects) &&
entry.plugin === locator.plugin) {
return entry;
}
}
return undefined;
}
/**
* Expand the nested `targetDefaults` map into its logical {@link
* TargetDefaultEntry} view, one entry at a time. The key becomes `target` (or
* `executor` for executor-shaped keys) and any `filter` is un-nested into the
* flat `projects`/`plugin`/`executor` siblings. Lazy so callers that find a
* match early stop iterating. Internal to this module — the flat shape is a
* lookup convenience, never a `targetDefaults` value form.
*/
function* logicalTargetDefaultEntries(targetDefaults) {
for (const key of Object.keys(targetDefaults ?? {})) {
const locator = isExecutorLikeKey(key)
? { executor: key }
: { target: key };
const value = targetDefaults[key];
const entries = Array.isArray(value)
? value
: [value ?? {}];
for (const entry of entries) {
const { filter, ...config } = entry;
yield {
...locator,
...(filter?.projects !== undefined
? { projects: filter.projects }
: {}),
...(filter?.plugin !== undefined ? { plugin: filter.plugin } : {}),
...(filter?.executor !== undefined
? { executor: filter.executor }
: {}),
...config,
};
}
}
}
// Mirrors `isGlobPattern` from `nx/src/utils/globs.ts`, which isn't on the
// devkit-exports surface guaranteed across the supported nx version range.
//
// Known limitation: a `:` is also legal in a target name, so a target literally
// named e.g. `e2e:ci` is classified as an executor key here. This matches how
// the rest of the target-defaults stack disambiguates keys (executor-shaped
// keys win), and such target names are vanishingly rare — accepted rather than
// worked around.
const GLOB_CHARACTERS = new Set(['*', '|', '{', '}', '(', ')', '[']);
function isExecutorLikeKey(key) {
if (!key.includes(':'))
return false;
for (const c of key)
if (GLOB_CHARACTERS.has(c))
return false;
return true;
}
/**
* Walk the `targetDefaults` entries matching `context` and run `callback`
* against each, mutating `nxJson` in place and returning it. This is the
* read-modify-write counterpart to {@link upsertTargetDefault}, meant for
* generators and migrations that need to edit *existing* defaults across both
* value forms (plain object and filtered array) without re-implementing the
* normalize → edit → collapse dance by hand.
*
* Matching, per the `context`:
* - `executor` selects entries that reference it as the map key, an `executor`
* config field, or `filter.executor`.
* - `projects` narrows to entries whose `filter.projects` covers the scoped
* projects (unfiltered entries always match). With no `projects` context,
* project filters are ignored and every executor-matching entry is visited.
*
* Removal and collapsing follow the storage-form rules:
* - Array entry whose callback returns `null` → dropped from the array.
* - An array that collapses to a single unfiltered entry → stored as the plain
* object form; an emptied array → the whole key is deleted.
* - Object-form value whose callback returns `null` → the whole key is deleted.
* - An emptied `targetDefaults` map → removed from `nxJson` entirely.
*
* Throws when neither `executor` nor `projects` is provided — an empty context
* would match everything and is almost always a bug.
*/
function updateTargetDefault(nxJson, context, callback) {
const { executor, projects } = context;
if (executor === undefined && projects === undefined) {
throw new Error('updateTargetDefault requires at least one of `executor` or `projects` on the context to locate entries to update.');
}
const targetDefaults = nxJson.targetDefaults;
if (!targetDefaults) {
return nxJson;
}
const projectNames = projects ? Object.keys(projects) : [];
for (const key of Object.keys(targetDefaults)) {
const value = targetDefaults[key];
const entries = Array.isArray(value)
? value
: [value];
const kept = [];
for (const entry of entries) {
if (!entryMatchesContext(key, entry, executor, projects, projectNames)) {
kept.push(entry);
continue;
}
const { filter, ...config } = entry;
const result = callback(config, {
key,
target: isExecutorLikeKey(key) ? undefined : key,
executor: isExecutorLikeKey(key)
? key
: (config.executor ?? filter?.executor),
filter,
});
// `null` drops the entry; anything else keeps the (mutated or replaced)
// config, re-attaching the filter so a filtered entry stays filtered.
if (result === null) {
continue;
}
const nextConfig = result ?? config;
kept.push(filter !== undefined ? { filter, ...nextConfig } : nextConfig);
}
if (kept.length === 0) {
delete targetDefaults[key];
}
else {
targetDefaults[key] = collapse(kept);
}
}
if (Object.keys(targetDefaults).length === 0) {
delete nxJson.targetDefaults;
}
return nxJson;
}
/**
* Whether `callback` should run against this entry: it must reference the
* context's `executor` (when given), and — when the context is scoped to
* `projects` — any `filter.projects` it carries must cover every scoped
* project. Unfiltered entries always pass the project check.
*/
function entryMatchesContext(key, entry, executor, projects, projectNames) {
if (executor !== undefined) {
const referencesExecutor = key === executor ||
entry.executor === executor ||
entry.filter?.executor === executor;
if (!referencesExecutor) {
return false;
}
}
if (projects !== undefined && entry.filter?.projects !== undefined) {
const patterns = Array.isArray(entry.filter.projects)
? entry.filter.projects
: [entry.filter.projects];
if (!projectsFilterCovers(patterns, projects, projectNames)) {
return false;
}
}
return true;
}
function readTargetDefaultsForTarget(targetName, targetDefaults, executor, opts) {
// `targetDefaults` is the nested map; the nx reader resolves either the
// object or array value form for `targetName` natively.
return (0, devkit_internals_1.readTargetDefaultsForTarget)(targetName, targetDefaults, executor, opts);
}
// Order-insensitive equality so re-upserts with reordered patterns merge into
// the same entry rather than appending a duplicate. Comparing sorted copies
// keeps duplicates significant (`['a','a']` ≠ `['a']`) without special-casing.
function projectsEqual(a, b) {
if (a === b)
return true;
if (!a || !b)
return false;
if (a.length !== b.length)
return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((value, i) => value === sortedB[i]);
}
function addBuildTargetDefaults(tree, executorName, buildTargetName = 'build', extraInputs = []) {
const nxJson = (0, devkit_exports_1.readNxJson)(tree) ?? {};
// Only skip when an *unfiltered* entry already covers this executor.
// A filtered entry (`projects:` or `plugin:`) only applies to a subset
// of targets, so it doesn't satisfy the workspace-wide default this
// helper writes — we still need to add the unfiltered baseline.
if (findTargetDefault(nxJson.targetDefaults, { executor: executorName })) {
return;
}
upsertTargetDefault(tree, nxJson, {
executor: executorName,
cache: true,
dependsOn: [`^${buildTargetName}`],
inputs: [
...(nxJson.namedInputs && 'production' in nxJson.namedInputs
? ['production', '^production']
: ['default', '^default']),
...extraInputs,
],
});
(0, devkit_exports_1.updateNxJson)(tree, nxJson);
}
async function addE2eCiTargetDefaults(tree, e2ePlugin, buildTarget, pathToE2EConfigFile) {
const nxJson = (0, devkit_exports_1.readNxJson)(tree);
if (!nxJson?.plugins) {
return;
}
const e2ePluginRegistrations = nxJson.plugins.filter((p) => typeof p === 'string' ? p === e2ePlugin : p.plugin === e2ePlugin);
if (!e2ePluginRegistrations.length) {
return;
}
const resolvedE2ePlugin = await import(e2ePlugin);
const e2ePluginGlob = resolvedE2ePlugin.createNodes?.[0] ?? resolvedE2ePlugin.createNodesV2?.[0];
// The e2e config file must be one this plugin actually processes (its path
// matches the plugin's createNodes glob) before the registration's
// include/exclude filters are applied.
const e2eConfigMatchesPluginGlob = !e2ePluginGlob ||
(0, minimatch_1.minimatch)(pathToE2EConfigFile, e2ePluginGlob, { dot: true });
let foundPluginForApplication;
for (let i = 0; i < e2ePluginRegistrations.length; i++) {
let candidatePluginForApplication = e2ePluginRegistrations[i];
if (typeof candidatePluginForApplication === 'string') {
foundPluginForApplication = candidatePluginForApplication;
break;
}
const matchingConfigFiles = e2eConfigMatchesPluginGlob
? (0, devkit_internals_1.findMatchingConfigFiles)([pathToE2EConfigFile], candidatePluginForApplication.include, candidatePluginForApplication.exclude)
: [];
if (matchingConfigFiles.length) {
foundPluginForApplication = candidatePluginForApplication;
break;
}
}
if (!foundPluginForApplication) {
return;
}
const ciTargetName = typeof foundPluginForApplication === 'string'
? 'e2e-ci'
: (foundPluginForApplication.options?.ciTargetName ?? 'e2e-ci');
const ciTargetNameGlob = `${ciTargetName}--**/**`;
const existing = findTargetDefault(nxJson.targetDefaults, {
target: ciTargetNameGlob,
});
const dependsOn = [...(existing?.dependsOn ?? [])];
if (!dependsOn.includes(buildTarget)) {
dependsOn.push(buildTarget);
}
upsertTargetDefault(tree, nxJson, { target: ciTargetNameGlob, dependsOn });
(0, devkit_exports_1.updateNxJson)(tree, nxJson);
}