UNPKG

@nx/devkit

Version:

The Nx Devkit is used to customize Nx for different technologies and use cases. It contains many utility functions for reading and writing files, updating configuration, working with Abstract Syntax Trees(ASTs), and more. Learn more about [extending Nx by

419 lines (418 loc) 18.2 kB
"use strict"; 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); }