Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,78 @@ describe('syncGenerator()', () => {
]);
});

describe('@nx/js/typescript plugin filters', () => {
it('should not reference a project the plugin excludes', async () => {
writeJson(tree, 'nx.json', {
plugins: [{ plugin: '@nx/js/typescript', exclude: ['examples/**/*'] }],
});
addProject('excluded-example', [], [], 'examples/basic');

await syncGenerator(tree);

expect(readJson(tree, 'tsconfig.json').references).toEqual([
{ path: './packages/a' },
{ path: './packages/b' },
]);
});

it('should reference a project claimed by a second registration', async () => {
// The first registration excludes e2e, the second includes it. The
// plugin runs for the union, so the reference must be kept.
writeJson(tree, 'nx.json', {
plugins: [
{ plugin: '@nx/js/typescript', exclude: ['e2e/**/*'] },
{ plugin: '@nx/js/typescript', include: ['e2e/**/*'] },
],
});
addProject('e2e-app', [], [], 'e2e/app');

await syncGenerator(tree);

expect(readJson(tree, 'tsconfig.json').references).toContainEqual({
path: './e2e/app',
});
});

it('should not filter anything for a bare string registration', async () => {
writeJson(tree, 'nx.json', { plugins: ['@nx/js/typescript'] });
addProject('anywhere', [], [], 'examples/basic');

await syncGenerator(tree);

expect(readJson(tree, 'tsconfig.json').references).toContainEqual({
path: './examples/basic',
});
});

it('should not filter anything when the plugin is not registered', async () => {
writeJson(tree, 'nx.json', { plugins: [] });
addProject('anywhere', [], [], 'examples/basic');

await syncGenerator(tree);

expect(readJson(tree, 'tsconfig.json').references).toContainEqual({
path: './examples/basic',
});
});

it('should not report a non-composite project as out of sync', async () => {
addProject('not-composite', [], [], 'packages/not-composite');
writeJson(tree, 'packages/not-composite/tsconfig.json', {
compilerOptions: {},
});

const result = await syncGenerator(tree);

// It is filtered out before writing, so it must never be named as the
// reason the workspace is out of sync.
expect(JSON.stringify(result ?? {})).not.toContain('not-composite');
expect(readJson(tree, 'tsconfig.json').references).not.toContainEqual({
path: './packages/not-composite',
});
});
});

describe('root tsconfig.json', () => {
it('should sync project references to the tsconfig.json', async () => {
expect(readJson(tree, 'tsconfig.json').references).toBeUndefined();
Expand Down
67 changes: 65 additions & 2 deletions packages/js/src/generators/typescript-sync/typescript-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import {
logger,
parseJson,
readNxJson,
type ExpandedPluginConfiguration,
type NxJsonConfiguration,
type ProjectGraph,
type ProjectGraphProjectNode,
type Tree,
} from '@nx/devkit';
import ignore from 'ignore';
import { applyEdits, modify } from 'jsonc-parser';
import { dirname, normalize, relative } from 'node:path/posix';
import { findMatchingConfigFiles } from 'nx/src/project-graph/utils/project-configuration-utils';
import {
SyncError,
type SyncGeneratorResult,
Expand Down Expand Up @@ -48,6 +51,50 @@ type GeneratorOptions = {
};

type NormalizedGeneratorOptions = Required<GeneratorOptions>;

const TS_PLUGIN_NAME = '@nx/js/typescript';

/**
* Builds a predicate for whether a tsconfig is managed by `@nx/js/typescript`,
* honoring the `include`/`exclude` filters on its `nx.json` registrations.
*
* A project the plugin was told to skip must not be pulled into the root
* tsconfig's references — nested standalone workspaces are excluded there
* precisely because they are not part of this workspace's TypeScript build.
*
* Fails open: when the plugin is registered without filters, or not registered
* at all, nothing is filtered out.
*/
function createTypeScriptPluginFilter(
nxJson: NxJsonConfiguration
): (tsconfigPath: string) => boolean {
const plugins = nxJson.plugins ?? [];

// A bare string registration carries no filters, so it claims everything.
if (plugins.some((p) => p === TS_PLUGIN_NAME)) {
return () => true;
}

const registrations = plugins
.filter(
(p): p is ExpandedPluginConfiguration =>
typeof p !== 'string' && p.plugin === TS_PLUGIN_NAME
)
.map((p) => ({ include: p.include ?? [], exclude: p.exclude ?? [] }));

if (registrations.length === 0) {
return () => true;
}

// Registrations are additive: one may exclude a directory that another
// includes, and the plugin runs for the union of what they claim.
return (tsconfigPath) =>
registrations.some(
({ include, exclude }) =>
findMatchingConfigFiles([tsconfigPath], include, exclude).length > 0
);
}

type TsconfigInfoCaches = {
composite: Map<string, boolean>;
content: Map<string, string>;
Expand Down Expand Up @@ -88,14 +135,18 @@ export async function syncGenerator(tree: Tree): Promise<SyncGeneratorResult> {
const projectGraph = await createProjectGraphAsync();
const projectRoots = new Set<string>();

const isManagedByTypeScriptPlugin = createTypeScriptPluginFilter(nxJson);
const tsconfigProjectNodeValues = Object.values(projectGraph.nodes).filter(
(node) => {
projectRoots.add(node.data.root);
const projectTsconfigPath = joinPathFragments(
node.data.root,
'tsconfig.json'
);
return tsconfigExists(tree, tsconfigInfoCaches, projectTsconfigPath);
return (
tsconfigExists(tree, tsconfigInfoCaches, projectTsconfigPath) &&
isManagedByTypeScriptPlugin(projectTsconfigPath)
);
}
);

Expand Down Expand Up @@ -170,6 +221,18 @@ export async function syncGenerator(tree: Tree): Promise<SyncGeneratorResult> {
const normalizedPath = normalizeReferencePath(node.data.root);
// Skip the root tsconfig itself
if (node.data.root !== '.' && !referencesSet.has(normalizedPath)) {
// Check composite here rather than only when writing, so a reference
// that would be filtered out below never reports the workspace as out
// of sync — it can never be satisfied.
if (
!hasCompositeEnabled(
tsSysFromTree,
tsconfigInfoCaches,
joinPathFragments(normalizedPath, 'tsconfig.json')
)
) {
continue;
}
referencesSet.add(normalizedPath);
addChangedFile(
changedFiles,
Expand All @@ -182,7 +245,7 @@ export async function syncGenerator(tree: Tree): Promise<SyncGeneratorResult> {

if (changedFiles.size > 0) {
const updatedReferences = Array.from(referencesSet)
// Check composite is true in the internal reference before proceeding
// Existing references are not re-checked above, so still filter here.
.filter((ref) =>
hasCompositeEnabled(
tsSysFromTree,
Expand Down
Loading