Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
84dc979
test(devkit): pin convert-to-inferred whole-workspace inference-pass …
meeroslav Jul 31, 2026
fad8ed5
refactor(devkit): extract Phase 0 collectMigrationScope (no output ch…
meeroslav Jul 31, 2026
8039ead
perf(devkit): single-inference convert-to-inferred engine (Phase 1 + …
meeroslav Jul 31, 2026
9b639b9
refactor(devkit): Phase 2 residualByProject with baselineFinal oracle
meeroslav Jul 31, 2026
7da2e5a
feat(devkit): Phase 3 strict-common hoist to targetDefaults
meeroslav Jul 31, 2026
7164eac
feat(devkit): Phase 4 verification pass + equivalence oracle + fallback
meeroslav Jul 31, 2026
55a363f
test(devkit): update convert-to-inferred plugin specs for the hoist s…
meeroslav Aug 3, 2026
05401e1
chore(devkit): format executor to plugin migrator
nx-cloud[bot] Aug 3, 2026
7c4d786
perf(devkit): keep convert-to-inferred benchmark scalable
meeroslav Aug 3, 2026
47baa8a
test(devkit): align inferred migrator target default specs
meeroslav Aug 5, 2026
7166801
fix(devkit): remove restricted imports
nx-cloud[bot] Aug 5, 2026
917eef8
fix(e2e): stabilize yarn and vitest plugin setup
meeroslav Aug 5, 2026
4e3cbf1
fix(devkit): scope hoisted convert-to-inferred defaults to the source…
meeroslav Aug 6, 2026
9ee316f
fix(devkit): make the convert-to-inferred fallback warning honest
meeroslav Aug 6, 2026
9990046
cleanup(devkit): remove quadratic shapes from convert-to-inferred inc…
meeroslav Aug 6, 2026
5d30618
cleanup(devkit): correct stale migrator comments and honest option-se…
meeroslav Aug 6, 2026
9cf249e
fix(devkit): isolate inferred migrator option sets
meeroslav Aug 7, 2026
0daa859
fix(devkit): isolate inferred migrator option sets [Self-Healing CI R…
nx-cloud[bot] Aug 7, 2026
cb27bb3
fix(devkit): do not centralize convert-to-inferred targets carrying e…
meeroslav Aug 10, 2026
1186bc5
fix(devkit): fail closed when convert-to-inferred verification hits a…
meeroslav Aug 10, 2026
414c399
cleanup(devkit): trim dead migrator scope state and fix stale comments
meeroslav Aug 10, 2026
87bdc42
chore(devkit): make convert-to-inferred include coverage linear in pr…
meeroslav Aug 10, 2026
b2d87e7
chore(devkit): assert convert-to-inferred equivalence through real Nx…
meeroslav Aug 10, 2026
0d3c6a0
chore(devkit): pin convert-to-inferred de-bloat with a ratio bound
meeroslav Aug 10, 2026
599e2c8
docs(nx-dev): document convert-to-inferred targetDefaults centralization
meeroslav Aug 10, 2026
064e3db
chore(devkit): satisfy lint for convert-to-inferred test helpers
meeroslav Aug 10, 2026
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
16 changes: 16 additions & 0 deletions e2e/utils/create-project-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ export function newProject({
} = {}): string {
const newProjectStart = performance.mark('new-project:start');
try {
if (packageManager === 'yarn') {
// E2E workspaces are mutated dynamically; Yarn Berry's CI default would
// otherwise reject lockfile updates before the workspace is cached.
process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = 'false';
}

const projScope = 'proj';

let createNxWorkspaceMeasure: PerformanceMeasure;
Expand Down Expand Up @@ -161,10 +167,20 @@ export function newProject({
} else {
console.info('No packages to install');
}
if (packageManager === 'yarn') {
execSync(getPackageManagerCommand({ packageManager }).install, {
cwd: `${e2eCwd}/proj`,
stdio: isVerbose() ? 'inherit' : 'pipe',
env: process.env,
windowsHide: true,
});
}
// stop the daemon
execSync(`${getPackageManagerCommand({ packageManager }).runNx} reset`, {
cwd: `${e2eCwd}/proj`,
stdio: isVerbose() ? 'inherit' : 'pipe',
env: process.env,
windowsHide: true,
});

moveSync(`${e2eCwd}/proj`, backupPath);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { addProjectConfiguration } from 'nx/src/generators/utils/project-configuration';
import type { TargetConfiguration } from 'nx/src/config/workspace-json-project-json';
import { migrateProjectExecutorsToPlugin } from './executor-to-plugin-migrator';
import {
createSyntheticPlugin,
setupFixture,
teardownFixture,
SYNTHETIC_CONFIG_FILE,
type FixtureContext,
} from './executor-to-plugin-migrator.test-utils';

/**
* Synthetic large-workspace benchmark mirroring the clickup-frontend profile:
* ~600 projects, each with a `lint` + `test` executor target (mostly uniform,
* a few deviating). It pins the two headline properties of the rewrite:
*
* 1. whole-workspace inference passes stay single-digit (O(distinct option
* sets) + 1 verify), NOT O(projects) — the previous engine ran
* ~(targets + 2*projects) passes, i.e. thousands here.
* 2. total emitted config (nx.json + every project.json) does not grow —
* shared config is centralized once instead of duplicated per project.
*/

const LINT_EXECUTOR = '@acme/tool:lint';
const TEST_EXECUTOR = '@acme/tool:test';
const LINT_PLUGIN_PATH = '@acme/eslint/plugin';
const TEST_PLUGIN_PATH = '@acme/jest/plugin';
const PROJECT_COUNT = 600;
// A handful of projects deviate from the uniform config.
const DEVIATING = new Set([7, 42, 123, 456, 599]);

function cleanTransformer(target: TargetConfiguration): TargetConfiguration {
if (target.options) {
delete (target.options as Record<string, unknown>).config;
if (Object.keys(target.options).length === 0) {
delete target.options;
}
}
return target;
}

function migrations(executor: string) {
return [
{
executors: [executor],
targetPluginOptionMapper: (targetName: string) => ({ targetName }),
postTargetTransformer: cleanTransformer,
},
];
}

function executorTarget(
executor: string,
deviating: boolean
): TargetConfiguration {
const options: Record<string, string> = {
config: SYNTHETIC_CONFIG_FILE,
mode: 'production',
};
if (deviating) {
options.shard = 'canary';
}

return {
executor,
cache: true,
outputs: ['{projectRoot}/dist'],
options,
};
}

function addBenchProject(ctx: FixtureContext, index: number): string {
const name = `app${index}`;
const root = `packages/${name}`;
const deviating = DEVIATING.has(index);
const project = {
name,
root,
projectType: 'application' as const,
targets: {
lint: executorTarget(LINT_EXECUTOR, deviating),
test: executorTarget(TEST_EXECUTOR, deviating),
},
};
addProjectConfiguration(ctx.tree, name, project);
ctx.projectGraph.nodes[name] = {
name,
type: 'app',
data: { root, targets: project.targets } as any,
};
ctx.fs.createFileSync(`${root}/${SYNTHETIC_CONFIG_FILE}`, '{}');
return root;
}

function totalConfigBytes(ctx: FixtureContext, roots: string[]): number {
let bytes = ctx.tree.read('nx.json', 'utf-8')?.length ?? 0;
for (const root of roots) {
const file = `${root}/project.json`;
if (ctx.tree.exists(file)) {
bytes += ctx.tree.read(file, 'utf-8').length;
}
}
return bytes;
}

describe('executor-to-plugin-migrator benchmark (synthetic ~600 projects)', () => {
let ctx: FixtureContext;

afterEach(() => {
if (ctx) {
teardownFixture(ctx.fs);
ctx = undefined;
}
});

it('runs single-digit inference passes and does not grow emitted config', async () => {
ctx = setupFixture('bench');
const lintPlugin = createSyntheticPlugin(undefined, LINT_PLUGIN_PATH);
const testPlugin = createSyntheticPlugin(undefined, TEST_PLUGIN_PATH);

const roots: string[] = [];
for (let i = 0; i < PROJECT_COUNT; i++) {
roots.push(addBenchProject(ctx, i));
}

const preBytes = totalConfigBytes(ctx, roots);

await migrateProjectExecutorsToPlugin(
ctx.tree,
ctx.projectGraph,
lintPlugin.pluginPath,
lintPlugin.createNodes,
{ targetName: 'lint' },
migrations(LINT_EXECUTOR)
);
await migrateProjectExecutorsToPlugin(
ctx.tree,
ctx.projectGraph,
testPlugin.pluginPath,
testPlugin.createNodes,
{ targetName: 'test' },
migrations(TEST_EXECUTOR)
);

// Every `createNodes` invocation is one whole-workspace inference pass
// (Phase 1 runs one per distinct option set; the Phase 4 verification runs
// one per registration group).
const inferencePasses =
lintPlugin.inferenceCount() + testPlugin.inferenceCount();
const postBytes = totalConfigBytes(ctx, roots);

// 2 plugins * (1 distinct option set + 1 verification pass) = 4.
// The old engine would have run roughly 2 * (1 + 2*600) = ~2402 passes.
expect(inferencePasses).toBeLessThan(10);
expect(inferencePasses).toBe(4);

// De-bloat: centralized config must not exceed the per-project executor
// config it replaced.
expect(postBytes).toBeLessThanOrEqual(preBytes);

// Shared config is centralized as a plugin-scoped entry; only the
// deviating projects keep an override.
const nxJson = JSON.parse(ctx.tree.read('nx.json', 'utf-8'));
expect(nxJson.targetDefaults.lint).toContainEqual({
filter: { plugin: LINT_PLUGIN_PATH },
options: { mode: 'production' },
});
expect(nxJson.targetDefaults.test).toContainEqual({
filter: { plugin: TEST_PLUGIN_PATH },
options: { mode: 'production' },
});

let projectsWithLintOverride = 0;
let projectsWithTestOverride = 0;
for (const root of roots) {
const pj = JSON.parse(ctx.tree.read(`${root}/project.json`, 'utf-8'));
if (pj.targets?.lint) {
projectsWithLintOverride++;
expect(pj.targets.lint.options).toEqual({ shard: 'canary' });
}
if (pj.targets?.test) {
projectsWithTestOverride++;
expect(pj.targets.test.options).toEqual({ shard: 'canary' });
}
}
expect(projectsWithLintOverride).toBe(DEVIATING.size);
expect(projectsWithTestOverride).toBe(DEVIATING.size);
}, 120_000);
});
Loading
Loading