feat(graph): show partial project graph & errors in graph app (#22838)

This commit is contained in:
MaxKless 2024-04-30 17:35:07 +02:00 committed by GitHub
parent 0ceea2f7da
commit c8d44b0355
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 617 additions and 78 deletions

View File

@ -219,7 +219,7 @@ function SubProjectList({
</span> </span>
</div> </div>
) : null} ) : null}
<ul className="mt-2 -ml-3"> <ul className="-ml-3 mt-2">
{sortedProjects.map((project) => { {sortedProjects.map((project) => {
return ( return (
<ProjectListItem <ProjectListItem

View File

@ -1,12 +1,16 @@
import { redirect, RouteObject } from 'react-router-dom'; import { redirect, RouteObject, json } from 'react-router-dom';
import { ProjectsSidebar } from './feature-projects/projects-sidebar'; import { ProjectsSidebar } from './feature-projects/projects-sidebar';
import { TasksSidebar } from './feature-tasks/tasks-sidebar'; import { TasksSidebar } from './feature-tasks/tasks-sidebar';
import { Shell } from './shell'; import { Shell } from './shell';
/* eslint-disable @nx/enforce-module-boundaries */ /* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line // nx-ignore-next-line
import { ProjectGraphClientResponse } from 'nx/src/command-line/graph/graph'; import type {
GraphError,
ProjectGraphClientResponse,
} from 'nx/src/command-line/graph/graph';
// nx-ignore-next-line // nx-ignore-next-line
import type { ProjectGraphProjectNode } from 'nx/src/config/project-graph'; import type { ProjectGraphProjectNode } from 'nx/src/config/project-graph';
/* eslint-enable @nx/enforce-module-boundaries */
import { import {
getEnvironmentConfig, getEnvironmentConfig,
getProjectGraphDataService, getProjectGraphDataService,
@ -78,6 +82,7 @@ const projectDetailsLoader = async (
hash: string; hash: string;
project: ProjectGraphProjectNode; project: ProjectGraphProjectNode;
sourceMap: Record<string, string[]>; sourceMap: Record<string, string[]>;
errors?: GraphError[];
}> => { }> => {
const workspaceData = await workspaceDataLoader(selectedWorkspaceId); const workspaceData = await workspaceDataLoader(selectedWorkspaceId);
const sourceMaps = await sourceMapsLoader(selectedWorkspaceId); const sourceMaps = await sourceMapsLoader(selectedWorkspaceId);
@ -85,10 +90,18 @@ const projectDetailsLoader = async (
const project = workspaceData.projects.find( const project = workspaceData.projects.find(
(project) => project.name === projectName (project) => project.name === projectName
); );
if (!project) {
throw json({
id: 'project-not-found',
projectName,
errors: workspaceData.errors,
});
}
return { return {
hash: workspaceData.hash, hash: workspaceData.hash,
project, project,
sourceMap: sourceMaps[project.data.root], sourceMap: sourceMaps[project.data.root],
errors: workspaceData.errors,
}; };
}; };

View File

@ -1,30 +1,48 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import {
GraphError,
ProjectGraphClientResponse,
} from 'nx/src/command-line/graph/graph';
/* eslint-enable @nx/enforce-module-boundaries */
import { import {
ArrowDownTrayIcon, ArrowDownTrayIcon,
ArrowLeftCircleIcon, ArrowLeftCircleIcon,
InformationCircleIcon, InformationCircleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import {
ErrorToast,
fetchProjectGraph,
getProjectGraphDataService,
useEnvironmentConfig,
useIntervalWhen,
} from '@nx/graph/shared';
import { Dropdown, Spinner } from '@nx/graph/ui-components';
import { getSystemTheme, Theme, ThemePanel } from '@nx/graph/ui-theme';
import { Tooltip } from '@nx/graph/ui-tooltips';
import classNames from 'classnames'; import classNames from 'classnames';
import { DebuggerPanel } from './ui-components/debugger-panel'; import { useLayoutEffect, useState } from 'react';
import { getGraphService } from './machines/graph.service';
import { import {
Outlet, Outlet,
useNavigate, useNavigate,
useNavigation, useNavigation,
useParams, useParams,
useRouteLoaderData,
} from 'react-router-dom'; } from 'react-router-dom';
import { getSystemTheme, Theme, ThemePanel } from '@nx/graph/ui-theme';
import { Dropdown, Spinner } from '@nx/graph/ui-components';
import { useCurrentPath } from './hooks/use-current-path';
import { ExperimentalFeature } from './ui-components/experimental-feature';
import { RankdirPanel } from './feature-projects/panels/rankdir-panel';
import { getProjectGraphService } from './machines/get-services';
import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { useSyncExternalStore } from 'use-sync-external-store/shim';
import { Tooltip } from '@nx/graph/ui-tooltips'; import { RankdirPanel } from './feature-projects/panels/rankdir-panel';
import { useCurrentPath } from './hooks/use-current-path';
import { getProjectGraphService } from './machines/get-services';
import { getGraphService } from './machines/graph.service';
import { DebuggerPanel } from './ui-components/debugger-panel';
import { ExperimentalFeature } from './ui-components/experimental-feature';
import { TooltipDisplay } from './ui-tooltips/graph-tooltip-display'; import { TooltipDisplay } from './ui-tooltips/graph-tooltip-display';
import { useEnvironmentConfig } from '@nx/graph/shared';
export function Shell(): JSX.Element { export function Shell(): JSX.Element {
const projectGraphService = getProjectGraphService(); const projectGraphService = getProjectGraphService();
const projectGraphDataService = getProjectGraphDataService();
const graphService = getGraphService(); const graphService = getGraphService();
const lastPerfReport = useSyncExternalStore( const lastPerfReport = useSyncExternalStore(
@ -43,9 +61,30 @@ export function Shell(): JSX.Element {
const navigate = useNavigate(); const navigate = useNavigate();
const { state: navigationState } = useNavigation(); const { state: navigationState } = useNavigation();
const currentPath = useCurrentPath(); const currentPath = useCurrentPath();
const { selectedWorkspaceId } = useParams(); const params = useParams();
const currentRoute = currentPath.currentPath; const currentRoute = currentPath.currentPath;
const [errors, setErrors] = useState<GraphError[] | undefined>(undefined);
const { errors: routerErrors } = useRouteLoaderData('selectedWorkspace') as {
errors: GraphError[];
};
useLayoutEffect(() => {
setErrors(routerErrors);
}, [routerErrors]);
useIntervalWhen(
() => {
fetchProjectGraph(
projectGraphDataService,
params,
environmentConfig.appConfig
).then((response: ProjectGraphClientResponse) => {
setErrors(response.errors);
});
},
1000,
environmentConfig.watch
);
const topLevelRoute = currentRoute.startsWith('/tasks') const topLevelRoute = currentRoute.startsWith('/tasks')
? '/tasks' ? '/tasks'
: '/projects'; : '/projects';
@ -84,7 +123,7 @@ export function Shell(): JSX.Element {
<div <div
className={`${ className={`${
environmentConfig.environment === 'nx-console' environmentConfig.environment === 'nx-console'
? 'absolute top-5 left-5 z-50 bg-white' ? 'absolute left-5 top-5 z-50 bg-white'
: 'relative flex h-full overflow-y-scroll' : 'relative flex h-full overflow-y-scroll'
} w-72 flex-col pb-10 shadow-lg ring-1 ring-slate-900/10 ring-opacity-10 transition-all dark:ring-slate-300/10`} } w-72 flex-col pb-10 shadow-lg ring-1 ring-slate-900/10 ring-opacity-10 transition-all dark:ring-slate-300/10`}
id="sidebar" id="sidebar"
@ -165,7 +204,7 @@ export function Shell(): JSX.Element {
{environment.appConfig.showDebugger ? ( {environment.appConfig.showDebugger ? (
<DebuggerPanel <DebuggerPanel
projects={environment.appConfig.workspaces} projects={environment.appConfig.workspaces}
selectedProject={selectedWorkspaceId} selectedProject={params.selectedWorkspaceId}
lastPerfReport={lastPerfReport} lastPerfReport={lastPerfReport}
selectedProjectChange={projectChange} selectedProjectChange={projectChange}
></DebuggerPanel> ></DebuggerPanel>
@ -212,11 +251,12 @@ export function Shell(): JSX.Element {
data-cy="downloadImageButton" data-cy="downloadImageButton"
onClick={downloadImage} onClick={downloadImage}
> >
<ArrowDownTrayIcon className="absolute top-1/2 left-1/2 -mt-3 -ml-3 h-6 w-6" /> <ArrowDownTrayIcon className="absolute left-1/2 top-1/2 -ml-3 -mt-3 h-6 w-6" />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<ErrorToast errors={errors} />
</div> </div>
); );
} }

View File

@ -1,27 +1,100 @@
import { useEnvironmentConfig } from '@nx/graph/shared'; import { ProjectDetailsHeader } from '@nx/graph/project-details';
import { ProjectDetailsHeader } from 'graph/project-details/src/lib/project-details-header'; import {
import { useRouteError } from 'react-router-dom'; fetchProjectGraph,
getProjectGraphDataService,
useEnvironmentConfig,
useIntervalWhen,
} from '@nx/graph/shared';
import { ErrorRenderer } from '@nx/graph/ui-components';
import {
isRouteErrorResponse,
useParams,
useRouteError,
} from 'react-router-dom';
export function ErrorBoundary() { export function ErrorBoundary() {
let error = useRouteError(); let error = useRouteError();
console.error(error); console.error(error);
const environment = useEnvironmentConfig()?.environment;
let message = 'Disconnected from graph server. '; const { environment, appConfig, watch } = useEnvironmentConfig();
const projectGraphDataService = getProjectGraphDataService();
const params = useParams();
const hasErrorData =
isRouteErrorResponse(error) && error.data.errors?.length > 0;
useIntervalWhen(
async () => {
fetchProjectGraph(projectGraphDataService, params, appConfig).then(
(data) => {
if (
isRouteErrorResponse(error) &&
error.data.id === 'project-not-found' &&
data.projects.find((p) => p.name === error.data.projectName)
) {
window.location.reload();
}
return;
}
);
},
1000,
watch
);
let message: string | JSX.Element;
let stack: string;
if (isRouteErrorResponse(error) && error.data.id === 'project-not-found') {
message = (
<p>
Project <code>{error.data.projectName}</code> not found.
</p>
);
} else {
message = 'Disconnected from graph server. ';
if (environment === 'nx-console') { if (environment === 'nx-console') {
message += 'Please refresh the page.'; message += 'Please refresh the page.';
} else { } else {
message += 'Please rerun your command and refresh the page.'; message += 'Please rerun your command and refresh the page.';
} }
stack = error.toString();
}
return ( return (
<div className="flex h-screen w-full flex-col items-center"> <div className="flex h-screen w-full flex-col items-center">
<ProjectDetailsHeader /> {environment !== 'nx-console' && <ProjectDetailsHeader />}
<div className="mx-auto mb-8 w-full max-w-6xl flex-grow px-8">
<h1 className="mb-4 text-4xl dark:text-slate-100">Error</h1> <h1 className="mb-4 text-4xl dark:text-slate-100">Error</h1>
<div> <div>
<p className="mb-4 text-lg dark:text-slate-200">{message}</p> <ErrorWithStack message={message} stack={stack} />
<p className="text-sm">Error message: {error?.toString()}</p> </div>
{hasErrorData && (
<div>
<p className="text-md mb-4 dark:text-slate-200">
Nx encountered the following issues while processing the project
graph:{' '}
</p>
<div>
<ErrorRenderer errors={error.data.errors} />
</div>
</div>
)}
</div> </div>
</div> </div>
); );
} }
function ErrorWithStack({
message,
stack,
}: {
message: string | JSX.Element;
stack?: string;
}) {
return (
<div>
<p className="mb-4 text-lg dark:text-slate-100">{message}</p>
{stack && <p className="text-sm">Error message: {stack}</p>}
</div>
);
}

View File

@ -1,11 +1,11 @@
/* eslint-disable @nx/enforce-module-boundaries */ /* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line // nx-ignore-next-line
import { useFloating } from '@floating-ui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { ProjectDetailsWrapper } from '@nx/graph/project-details';
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import { ProjectGraphClientResponse } from 'nx/src/command-line/graph/graph'; import { ProjectGraphClientResponse } from 'nx/src/command-line/graph/graph';
// nx-ignore-next-line
import { ProjectDetailsWrapper } from '@nx/graph/project-details';
/* eslint-enable @nx/enforce-module-boundaries */
import { useFloating } from '@floating-ui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouteLoaderData, useSearchParams } from 'react-router-dom'; import { useRouteLoaderData, useSearchParams } from 'react-router-dom';
@ -50,15 +50,15 @@ export function ProjectDetailsModal() {
return ( return (
isOpen && ( isOpen && (
<div <div
className="top-24 z-20 right-4 opacity-100 bg-white dark:bg-slate-800 fixed h-max w-1/3" className="fixed right-4 top-24 z-20 h-max w-1/3 bg-white opacity-100 dark:bg-slate-800"
style={{ style={{
height: 'calc(100vh - 6rem - 2rem)', height: 'calc(100vh - 6rem - 2rem)',
}} }}
ref={refs.setFloating} ref={refs.setFloating}
> >
<div className="rounded-md h-full border border-slate-500"> <div className="h-full rounded-md border border-slate-500">
<ProjectDetailsWrapper project={project} sourceMap={sourceMap} /> <ProjectDetailsWrapper project={project} sourceMap={sourceMap} />
<div className="top-2 right-2 absolute" onClick={onClose}> <div className="absolute right-2 top-2" onClick={onClose}>
<XMarkIcon className="h-4 w-4" /> <XMarkIcon className="h-4 w-4" />
</div> </div>
</div> </div>

View File

@ -1,2 +1,3 @@
export * from './lib/project-details-wrapper'; export * from './lib/project-details-wrapper';
export * from './lib/project-details-page'; export * from './lib/project-details-page';
export * from './lib/project-details-header';

View File

@ -1,6 +1,10 @@
/* eslint-disable @nx/enforce-module-boundaries */ /* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line // nx-ignore-next-line
import type { ProjectGraphProjectNode } from '@nx/devkit'; import { ProjectGraphProjectNode } from '@nx/devkit';
// nx-ignore-next-line
import { GraphError } from 'nx/src/command-line/graph/graph';
/* eslint-enable @nx/enforce-module-boundaries */
import { import {
ScrollRestoration, ScrollRestoration,
useParams, useParams,
@ -16,12 +20,13 @@ import {
import { ProjectDetailsHeader } from './project-details-header'; import { ProjectDetailsHeader } from './project-details-header';
export function ProjectDetailsPage() { export function ProjectDetailsPage() {
const { project, sourceMap, hash } = useRouteLoaderData( const { project, sourceMap, hash, errors } = useRouteLoaderData(
'selectedProjectDetails' 'selectedProjectDetails'
) as { ) as {
hash: string; hash: string;
project: ProjectGraphProjectNode; project: ProjectGraphProjectNode;
sourceMap: Record<string, string[]>; sourceMap: Record<string, string[]>;
errors?: GraphError[];
}; };
const { environment, watch, appConfig } = useEnvironmentConfig(); const { environment, watch, appConfig } = useEnvironmentConfig();
@ -56,6 +61,7 @@ export function ProjectDetailsPage() {
<ProjectDetailsWrapper <ProjectDetailsWrapper
project={project} project={project}
sourceMap={sourceMap} sourceMap={sourceMap}
errors={errors}
></ProjectDetailsWrapper> ></ProjectDetailsWrapper>
</div> </div>
</div> </div>

View File

@ -1,9 +1,13 @@
/* eslint-disable @nx/enforce-module-boundaries */ /* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line // nx-ignore-next-line
import type { ProjectGraphProjectNode } from '@nx/devkit'; import type { ProjectGraphProjectNode } from '@nx/devkit';
// nx-ignore-next-line
import { GraphError } from 'nx/src/command-line/graph/graph';
/* eslint-enable @nx/enforce-module-boundaries */
import { useNavigate, useNavigation, useSearchParams } from 'react-router-dom'; import { useNavigate, useNavigation, useSearchParams } from 'react-router-dom';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import {
ErrorToast,
getExternalApiService, getExternalApiService,
useEnvironmentConfig, useEnvironmentConfig,
useRouteConstructor, useRouteConstructor,
@ -23,6 +27,7 @@ type ProjectDetailsProps = mapStateToPropsType &
mapDispatchToPropsType & { mapDispatchToPropsType & {
project: ProjectGraphProjectNode; project: ProjectGraphProjectNode;
sourceMap: Record<string, string[]>; sourceMap: Record<string, string[]>;
errors?: GraphError[];
}; };
export function ProjectDetailsWrapperComponent({ export function ProjectDetailsWrapperComponent({
@ -31,6 +36,7 @@ export function ProjectDetailsWrapperComponent({
setExpandTargets, setExpandTargets,
expandTargets, expandTargets,
collapseAllTargets, collapseAllTargets,
errors,
}: ProjectDetailsProps) { }: ProjectDetailsProps) {
const environment = useEnvironmentConfig()?.environment; const environment = useEnvironmentConfig()?.environment;
const externalApiService = getExternalApiService(); const externalApiService = getExternalApiService();
@ -158,6 +164,7 @@ export function ProjectDetailsWrapperComponent({
} }
return ( return (
<>
<ProjectDetails <ProjectDetails
project={project} project={project}
sourceMap={sourceMap} sourceMap={sourceMap}
@ -165,6 +172,8 @@ export function ProjectDetailsWrapperComponent({
onViewInTaskGraph={handleViewInTaskGraph} onViewInTaskGraph={handleViewInTaskGraph}
onRunTarget={environment === 'nx-console' ? handleRunTarget : undefined} onRunTarget={environment === 'nx-console' ? handleRunTarget : undefined}
/> />
<ErrorToast errors={errors} />
</>
); );
} }

View File

@ -6,3 +6,4 @@ export * from './lib/use-route-constructor';
export * from './lib/use-interval-when'; export * from './lib/use-interval-when';
export * from './lib/project-graph-data-service/get-project-graph-data-service'; export * from './lib/project-graph-data-service/get-project-graph-data-service';
export * from './lib/fetch-project-graph'; export * from './lib/fetch-project-graph';
export * from './lib/error-toast';

View File

@ -0,0 +1,132 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import { GraphError } from 'nx/src/command-line/graph/graph';
/* eslint-enable @nx/enforce-module-boundaries */
import {
createRef,
ForwardedRef,
forwardRef,
useCallback,
useImperativeHandle,
useLayoutEffect,
} from 'react';
import { Transition } from '@headlessui/react';
import { ExclamationCircleIcon } from '@heroicons/react/24/outline';
import { SetURLSearchParams, useSearchParams } from 'react-router-dom';
import { ErrorRenderer, Modal, ModalHandle } from '@nx/graph/ui-components';
export interface ErrorToastImperativeHandle {
closeModal: () => void;
openModal: () => void;
}
interface ErrorToastProps {
errors?: GraphError[] | undefined;
}
export const ErrorToast = forwardRef(
(
{ errors }: ErrorToastProps,
ref: ForwardedRef<ErrorToastImperativeHandle>
) => {
const inputsModalRef = createRef<ModalHandle>();
const [searchParams, setSearchParams] = useSearchParams();
useImperativeHandle(ref, () => ({
openModal: () => {
inputsModalRef?.current?.openModal();
},
closeModal: () => {
inputsModalRef?.current?.closeModal();
},
}));
const handleModalOpen = useCallback(() => {
if (searchParams.get('show-error') === 'true') return;
setSearchParams(
(currentSearchParams) => {
currentSearchParams.set('show-error', 'true');
return currentSearchParams;
},
{ replace: true, preventScrollReset: true }
);
}, [setSearchParams, searchParams]);
const handleModalClose = useCallback(() => {
if (!searchParams.get('show-error')) return;
setSearchParams(
(currentSearchParams) => {
currentSearchParams.delete('show-error');
return currentSearchParams;
},
{ replace: true, preventScrollReset: true }
);
}, [setSearchParams, searchParams]);
useLayoutEffect(() => {
if (searchParams.get('show-error') === 'true') {
if (errors && errors.length > 0) {
inputsModalRef.current?.openModal();
} else {
setSearchParams(
(currentSearchParams) => {
currentSearchParams.delete('show-error');
return currentSearchParams;
},
{ replace: true, preventScrollReset: true }
);
}
}
}, [searchParams, inputsModalRef, errors, setSearchParams]);
return (
<Transition
show={!!errors && errors.length > 0}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-x-0 bottom-0 px-4 py-2 text-center">
<div
onClick={() => inputsModalRef.current?.openModal()}
className="z-50 mx-auto flex w-fit max-w-[75%] cursor-pointer items-center rounded-md bg-red-600 p-4 text-slate-200 shadow-lg"
>
<ExclamationCircleIcon className="mr-2 inline-block h-6 w-6" />
Some project information might be missing. Click to see errors.
</div>
{errors?.length > 0 && (
<Modal
title={`Project Graph Errors`}
ref={inputsModalRef}
onOpen={handleModalOpen}
onClose={handleModalClose}
>
<ErrorRenderer errors={errors ?? []} />
</Modal>
)}
</div>
</Transition>
);
}
);
export const useRouterHandleModalOpen = (
searchParams: URLSearchParams,
setSearchParams: SetURLSearchParams
) =>
useCallback(() => {
if (searchParams.get('show-error') === 'true') return;
setSearchParams(
(currentSearchParams) => {
currentSearchParams.set('show-error', 'true');
return currentSearchParams;
},
{ replace: true, preventScrollReset: true }
);
}, [setSearchParams, searchParams]);

View File

@ -61,6 +61,7 @@ export class MockProjectGraphService implements ProjectGraphService {
focus: null, focus: null,
exclude: [], exclude: [],
groupByFolder: false, groupByFolder: false,
isPartial: false,
}; };
private taskGraphsResponse: TaskGraphClientResponse = { private taskGraphsResponse: TaskGraphClientResponse = {

View File

@ -2,3 +2,5 @@ export * from './lib/debounced-text-input';
export * from './lib/tag'; export * from './lib/tag';
export * from './lib/dropdown'; export * from './lib/dropdown';
export * from './lib/spinner'; export * from './lib/spinner';
export * from './lib/error-renderer';
export * from './lib/modal';

View File

@ -0,0 +1,63 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import { GraphError } from 'nx/src/command-line/graph/graph';
/* eslint-enable @nx/enforce-module-boundaries */
export function ErrorRenderer({ errors }: { errors: GraphError[] }) {
return (
<div>
{errors.map((error, index) => {
const errorHeading =
error.pluginName && error.name
? `${error.name} - ${error.pluginName}`
: error.name ?? error.message;
const fileSpecifier =
isCauseWithLocation(error.cause) && error.cause.errors.length === 1
? `${error.fileName}:${error.cause.errors[0].location.line}:${error.cause.errors[0].location.column}`
: error.fileName;
return (
<div className="overflow-hidden pb-4">
<span className="inline-flex max-w-full flex-col break-words font-bold font-normal text-gray-900 md:inline dark:text-slate-200">
<span>{errorHeading}</span>
<span className="hidden px-1 md:inline">-</span>
<span>{fileSpecifier}</span>
</span>
<pre className="overflow-x-scroll pl-4 pt-3">
{isCauseWithErrors(error.cause) &&
error.cause.errors.length === 1 ? (
<div>
{error.message} <br />
{error.cause.errors[0].text}{' '}
</div>
) : (
<div>{error.stack}</div>
)}
</pre>
</div>
);
})}
</div>
);
}
function isCauseWithLocation(cause: unknown): cause is {
errors: {
location: {
column: number;
line: number;
};
text: string;
}[];
} {
return (
isCauseWithErrors(cause) &&
(cause as any).errors[0].location &&
(cause as any).errors[0].location.column &&
(cause as any).errors[0].location.line
);
}
function isCauseWithErrors(
cause: unknown
): cause is { errors: { text: string }[] } {
return cause && (cause as any).errors && (cause as any).errors[0].text;
}

View File

@ -0,0 +1,108 @@
// component from https://tailwindui.com/components/application-ui/overlays/dialogs
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import {
ForwardedRef,
Fragment,
ReactNode,
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react';
export interface ModalProps {
children: ReactNode;
title: string;
onOpen?: () => void;
onClose?: () => void;
}
export interface ModalHandle {
openModal: () => void;
closeModal: () => void;
}
export const Modal = forwardRef(
(
{ children, title, onOpen, onClose }: ModalProps,
ref: ForwardedRef<ModalHandle>
) => {
const [open, setOpen] = useState(false);
useEffect(() => {
if (open) {
onOpen?.();
} else {
onClose?.();
}
}, [open, onOpen, onClose]);
useImperativeHandle(ref, () => ({
closeModal: () => {
setOpen(false);
},
openModal: () => {
setOpen(true);
},
}));
return (
<Transition.Root show={open} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={setOpen}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-end items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
className={`relative mx-4 my-8 w-full transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all md:max-w-5xl xl:max-w-7xl dark:bg-slate-900
`}
>
<div className="flex items-center justify-between rounded-t border-b bg-white p-2 pb-1 md:p-4 md:pb-2 dark:border-gray-600 dark:bg-slate-900">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900 dark:text-slate-200"
>
{title}
</Dialog.Title>
<button
type="button"
className="ms-auto inline-flex h-8 w-8 items-center justify-center rounded-lg bg-transparent text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white"
data-modal-hide="default-modal"
onClick={() => setOpen(false)}
>
<XMarkIcon />
<span className="sr-only">Close modal</span>
</button>
</div>
<div className="max-h-[90vh] overflow-y-auto bg-white p-2 md:p-4 dark:bg-slate-900">
{children}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}
);

View File

@ -3,6 +3,9 @@
/* eslint-disable @nx/enforce-module-boundaries */ /* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line // nx-ignore-next-line
import type { ProjectGraphProjectNode } from '@nx/devkit'; import type { ProjectGraphProjectNode } from '@nx/devkit';
// nx-ignore-next-line
import { GraphError } from 'nx/src/command-line/graph/graph';
/* eslint-enable @nx/enforce-module-boundaries */
import { EyeIcon } from '@heroicons/react/24/outline'; import { EyeIcon } from '@heroicons/react/24/outline';
import { PropertyInfoTooltip, Tooltip } from '@nx/graph/ui-tooltips'; import { PropertyInfoTooltip, Tooltip } from '@nx/graph/ui-tooltips';
@ -15,6 +18,7 @@ import { TargetTechnologies } from '../target-technologies/target-technologies';
export interface ProjectDetailsProps { export interface ProjectDetailsProps {
project: ProjectGraphProjectNode; project: ProjectGraphProjectNode;
sourceMap: Record<string, string[]>; sourceMap: Record<string, string[]>;
errors?: GraphError[];
variant?: 'default' | 'compact'; variant?: 'default' | 'compact';
onViewInProjectGraph?: (data: { projectName: string }) => void; onViewInProjectGraph?: (data: { projectName: string }) => void;
onViewInTaskGraph?: (data: { onViewInTaskGraph?: (data: {
@ -82,7 +86,7 @@ export const ProjectDetails = ({
<span> <span>
{onViewInProjectGraph ? ( {onViewInProjectGraph ? (
<button <button
className="inline-flex cursor-pointer items-center gap-2 rounded-md py-1 px-2 text-base text-slate-600 ring-2 ring-inset ring-slate-400/40 hover:bg-slate-50 dark:text-slate-300 dark:ring-slate-400/30 dark:hover:bg-slate-800/60" className="inline-flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-base text-slate-600 ring-2 ring-inset ring-slate-400/40 hover:bg-slate-50 dark:text-slate-300 dark:ring-slate-400/30 dark:hover:bg-slate-800/60"
onClick={() => onClick={() =>
onViewInProjectGraph({ projectName: project.name }) onViewInProjectGraph({ projectName: project.name })
} }

View File

@ -86,7 +86,7 @@ export function TargetConfigurationGroupList({
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed top-0 left-0 right-0 z-10 mb-8 border-b-2 border-slate-900/10 bg-slate-50 dark:border-slate-300/10 dark:bg-slate-800 dark:text-slate-300"> <div className="fixed left-0 right-0 top-0 z-10 mb-8 border-b-2 border-slate-900/10 bg-slate-50 dark:border-slate-300/10 dark:bg-slate-800 dark:text-slate-300">
<div className="mx-auto max-w-6xl px-8 pt-2"> <div className="mx-auto max-w-6xl px-8 pt-2">
<TargetConfigurationGroupHeader <TargetConfigurationGroupHeader
targetGroupName={stickyHeaderContent} targetGroupName={stickyHeaderContent}

View File

@ -64,7 +64,7 @@ export const TargetConfigurationDetailsHeader = ({
collapsable ? 'cursor-pointer' : '', collapsable ? 'cursor-pointer' : '',
isCompact ? 'px-2 py-1' : 'p-2', isCompact ? 'px-2 py-1' : 'p-2',
!isCollasped || !collapsable !isCollasped || !collapsable
? 'border-b bg-slate-50 dark:border-slate-700/60 dark:border-slate-300/10 dark:bg-slate-800 ' ? 'border-b bg-slate-50 dark:border-slate-300/10 dark:border-slate-700/60 dark:bg-slate-800 '
: '' : ''
)} )}
onClick={collapsable ? toggleCollapse : undefined} onClick={collapsable ? toggleCollapse : undefined}
@ -148,7 +148,7 @@ export const TargetConfigurationDetailsHeader = ({
</div> </div>
</div> </div>
{!isCollasped && ( {!isCollasped && (
<div className="mt-2 ml-5 text-sm"> <div className="ml-5 mt-2 text-sm">
<SourceInfo <SourceInfo
data={sourceMap[`targets.${targetName}`]} data={sourceMap[`targets.${targetName}`]}
propertyKey={`targets.${targetName}`} propertyKey={`targets.${targetName}`}

View File

@ -141,7 +141,7 @@ export const TargetConfigurationDetailsComponent = ({
{singleCommand ? ( {singleCommand ? (
<span className="font-medium"> <span className="font-medium">
Command Command
<span className="ml-2 mb-1 hidden group-hover:inline"> <span className="mb-1 ml-2 hidden group-hover:inline">
<CopyToClipboard <CopyToClipboard
onCopy={() => onCopy={() =>
handleCopyClick(`"command": "${singleCommand}"`) handleCopyClick(`"command": "${singleCommand}"`)
@ -191,7 +191,7 @@ export const TargetConfigurationDetailsComponent = ({
<TooltipTriggerText>Inputs</TooltipTriggerText> <TooltipTriggerText>Inputs</TooltipTriggerText>
</span> </span>
</Tooltip> </Tooltip>
<span className="ml-2 mb-1 hidden group-hover:inline"> <span className="mb-1 ml-2 hidden group-hover:inline">
<CopyToClipboard <CopyToClipboard
onCopy={() => onCopy={() =>
handleCopyClick( handleCopyClick(
@ -241,7 +241,7 @@ export const TargetConfigurationDetailsComponent = ({
<TooltipTriggerText>Outputs</TooltipTriggerText> <TooltipTriggerText>Outputs</TooltipTriggerText>
</span> </span>
</Tooltip> </Tooltip>
<span className="ml-2 mb-1 hidden group-hover:inline"> <span className="mb-1 ml-2 hidden group-hover:inline">
<CopyToClipboard <CopyToClipboard
onCopy={() => onCopy={() =>
handleCopyClick( handleCopyClick(

View File

@ -453,7 +453,7 @@ export default function NewYear(): JSX.Element {
className="py-18 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8" className="py-18 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"
> >
<article id="nx-new-year-tips-intro" className="relative"> <article id="nx-new-year-tips-intro" className="relative">
<h1 className="my-8 text-3xl font-semibold dark:text-white sm:text-5xl"> <h1 className="my-8 text-3xl font-semibold sm:text-5xl dark:text-white">
Nx New Year Tips Nx New Year Tips
</h1> </h1>
<p> <p>
@ -477,7 +477,7 @@ export default function NewYear(): JSX.Element {
className="py-18 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8" className="py-18 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"
> >
<article id="nx-new-year-tips" className="relative"> <article id="nx-new-year-tips" className="relative">
<div className="mx-auto grid grid-cols-1 items-stretch gap-8 py-12 dark:text-slate-100 sm:grid-cols-2 md:grid-cols-3 lg:py-16"> <div className="mx-auto grid grid-cols-1 items-stretch gap-8 py-12 sm:grid-cols-2 md:grid-cols-3 lg:py-16 dark:text-slate-100">
{shownTips.map((tip) => ( {shownTips.map((tip) => (
<FlipCard <FlipCard
key={tip.day} key={tip.day}

View File

@ -13,7 +13,7 @@ export function CallToAction({
icon?: string; icon?: string;
}): JSX.Element { }): JSX.Element {
return ( return (
<div className="not-prose group relative my-12 mx-auto flex w-full max-w-md items-center gap-3 overflow-hidden rounded-lg bg-slate-50 shadow-md transition hover:text-white dark:bg-slate-800/60"> <div className="not-prose group relative mx-auto my-12 flex w-full max-w-md items-center gap-3 overflow-hidden rounded-lg bg-slate-50 shadow-md transition hover:text-white dark:bg-slate-800/60">
<div className="absolute inset-0 z-0 w-2 bg-blue-500 transition-all duration-150 group-hover:w-full dark:bg-sky-500"></div> <div className="absolute inset-0 z-0 w-2 bg-blue-500 transition-all duration-150 group-hover:w-full dark:bg-sky-500"></div>
<div className="w-2 bg-blue-500 dark:bg-sky-500"></div> <div className="w-2 bg-blue-500 dark:bg-sky-500"></div>

View File

@ -198,7 +198,7 @@ export function Card({
) : null} ) : null}
{/*HOVER ICON*/} {/*HOVER ICON*/}
<span className="absolute right-2 top-1/2 -translate-y-2.5 -translate-x-2 opacity-0 transition-all group-hover:translate-x-0 group-hover:opacity-100"> <span className="absolute right-2 top-1/2 -translate-x-2 -translate-y-2.5 opacity-0 transition-all group-hover:translate-x-0 group-hover:opacity-100">
<ArrowRightCircleIcon className="h-5 w-5" /> <ArrowRightCircleIcon className="h-5 w-5" />
</span> </span>
</div> </div>

View File

@ -36,6 +36,7 @@ import { pruneExternalNodes } from '../../project-graph/operators';
import { import {
createProjectGraphAndSourceMapsAsync, createProjectGraphAndSourceMapsAsync,
createProjectGraphAsync, createProjectGraphAsync,
handleProjectGraphError,
} from '../../project-graph/project-graph'; } from '../../project-graph/project-graph';
import { import {
createTaskGraph, createTaskGraph,
@ -48,21 +49,35 @@ import { HashPlanner, transferProjectGraph } from '../../native';
import { transformProjectGraphForRust } from '../../native/transform-objects'; import { transformProjectGraphForRust } from '../../native/transform-objects';
import { getAffectedGraphNodes } from '../affected/affected'; import { getAffectedGraphNodes } from '../affected/affected';
import { readFileMapCache } from '../../project-graph/nx-deps-cache'; import { readFileMapCache } from '../../project-graph/nx-deps-cache';
import { Hash, getNamedInputs } from '../../hasher/task-hasher';
import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration-utils'; import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration-utils';
import { filterUsingGlobPatterns } from '../../hasher/task-hasher';
import { createTaskHasher } from '../../hasher/create-task-hasher'; import { createTaskHasher } from '../../hasher/create-task-hasher';
import { filterUsingGlobPatterns } from '../../hasher/task-hasher';
import { ProjectGraphError } from '../../project-graph/error-types';
export interface GraphError {
message: string;
stack: string;
cause: unknown;
name: string;
pluginName: string;
fileName?: string;
}
export interface ProjectGraphClientResponse { export interface ProjectGraphClientResponse {
hash: string; hash: string;
projects: ProjectGraphProjectNode[]; projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>; dependencies: Record<string, ProjectGraphDependency[]>;
fileMap: ProjectFileMap; fileMap?: ProjectFileMap;
layout: { appsDir: string; libsDir: string }; layout: { appsDir: string; libsDir: string };
affected: string[]; affected: string[];
focus: string; focus: string;
groupByFolder: boolean; groupByFolder: boolean;
exclude: string[]; exclude: string[];
isPartial: boolean;
errors?: GraphError[];
} }
export interface TaskGraphClientResponse { export interface TaskGraphClientResponse {
@ -273,10 +288,30 @@ export async function generateGraph(
? args.targets[0] ? args.targets[0]
: args.targets; : args.targets;
const { projectGraph: rawGraph, sourceMaps } = let rawGraph: ProjectGraph;
let sourceMaps: ConfigurationSourceMaps;
let isPartial = false;
try {
const projectGraphAndSourceMaps =
await createProjectGraphAndSourceMapsAsync({ await createProjectGraphAndSourceMapsAsync({
exitOnError: true, exitOnError: false,
}); });
rawGraph = projectGraphAndSourceMaps.projectGraph;
sourceMaps = projectGraphAndSourceMaps.sourceMaps;
} catch (e) {
if (e instanceof ProjectGraphError) {
output.warn({
title: 'Failed to process project graph. Showing partial graph.',
});
rawGraph = e.getPartialProjectGraph();
sourceMaps = e.getPartialSourcemaps();
isPartial = true;
}
if (!rawGraph) {
handleProjectGraphError({ exitOnError: true }, e);
}
}
let prunedGraph = pruneExternalNodes(rawGraph); let prunedGraph = pruneExternalNodes(rawGraph);
const projects = Object.values( const projects = Object.values(
@ -632,6 +667,8 @@ let currentProjectGraphClientResponse: ProjectGraphClientResponse = {
focus: null, focus: null,
groupByFolder: false, groupByFolder: false,
exclude: [], exclude: [],
isPartial: false,
errors: [],
}; };
let currentSourceMapsClientResponse: ConfigurationSourceMaps = {}; let currentSourceMapsClientResponse: ConfigurationSourceMaps = {};
@ -649,7 +686,11 @@ function debounce(fn: (...args) => void, time: number) {
function createFileWatcher() { function createFileWatcher() {
return daemonClient.registerFileWatcher( return daemonClient.registerFileWatcher(
{ watchProjects: 'all', includeGlobalWorkspaceFiles: true }, {
watchProjects: 'all',
includeGlobalWorkspaceFiles: true,
allowPartialGraph: true,
},
debounce(async (error, changes) => { debounce(async (error, changes) => {
if (error === 'closed') { if (error === 'closed') {
output.error({ title: `Watch error: Daemon closed the connection` }); output.error({ title: `Watch error: Daemon closed the connection` });
@ -687,11 +728,39 @@ async function createProjectGraphAndSourceMapClientResponse(
}> { }> {
performance.mark('project graph watch calculation:start'); performance.mark('project graph watch calculation:start');
const { projectGraph, sourceMaps } = let projectGraph: ProjectGraph;
await createProjectGraphAndSourceMapsAsync({ exitOnError: true }); let sourceMaps: ConfigurationSourceMaps;
let isPartial = false;
let errors: GraphError[] | undefined;
try {
const projectGraphAndSourceMaps =
await createProjectGraphAndSourceMapsAsync({ exitOnError: false });
projectGraph = projectGraphAndSourceMaps.projectGraph;
sourceMaps = projectGraphAndSourceMaps.sourceMaps;
} catch (e) {
if (e instanceof ProjectGraphError) {
projectGraph = e.getPartialProjectGraph();
sourceMaps = e.getPartialSourcemaps();
errors = e.getErrors().map((e) => ({
message: e.message,
stack: e.stack,
cause: e.cause,
name: e.name,
pluginName: (e as any).pluginName,
fileName:
(e as any).file ?? (e.cause as any)?.errors?.[0]?.location?.file,
}));
isPartial = true;
}
if (!projectGraph) {
handleProjectGraphError({ exitOnError: true }, e);
}
}
let graph = pruneExternalNodes(projectGraph); let graph = pruneExternalNodes(projectGraph);
let fileMap = readFileMapCache().fileMap.projectFileMap; let fileMap: ProjectFileMap | undefined =
readFileMapCache()?.fileMap.projectFileMap;
performance.mark('project graph watch calculation:end'); performance.mark('project graph watch calculation:end');
performance.mark('project graph response generation:start'); performance.mark('project graph response generation:start');
@ -700,7 +769,9 @@ async function createProjectGraphAndSourceMapClientResponse(
const dependencies = graph.dependencies; const dependencies = graph.dependencies;
const hasher = createHash('sha256'); const hasher = createHash('sha256');
hasher.update(JSON.stringify({ layout, projects, dependencies, sourceMaps })); hasher.update(
JSON.stringify({ layout, projects, dependencies, sourceMaps, errors })
);
const hash = hasher.digest('hex'); const hash = hasher.digest('hex');
@ -727,6 +798,8 @@ async function createProjectGraphAndSourceMapClientResponse(
dependencies, dependencies,
affected, affected,
fileMap, fileMap,
isPartial,
errors,
}, },
sourceMapResponse: sourceMaps, sourceMapResponse: sourceMaps,
}; };
@ -736,12 +809,15 @@ async function createTaskGraphClientResponse(
pruneExternal: boolean = false pruneExternal: boolean = false
): Promise<TaskGraphClientResponse> { ): Promise<TaskGraphClientResponse> {
let graph: ProjectGraph; let graph: ProjectGraph;
try {
graph = await createProjectGraphAsync({ exitOnError: false });
} catch (e) {
if (e instanceof ProjectGraphError) {
graph = e.getPartialProjectGraph();
}
}
if (pruneExternal) { if (pruneExternal) {
graph = pruneExternalNodes( graph = pruneExternalNodes(graph);
await createProjectGraphAsync({ exitOnError: true })
);
} else {
graph = await createProjectGraphAsync({ exitOnError: true });
} }
const nxJson = readNxJson(); const nxJson = readNxJson();

View File

@ -179,6 +179,7 @@ export class DaemonClient {
watchProjects: string[] | 'all'; watchProjects: string[] | 'all';
includeGlobalWorkspaceFiles?: boolean; includeGlobalWorkspaceFiles?: boolean;
includeDependentProjects?: boolean; includeDependentProjects?: boolean;
allowPartialGraph?: boolean;
}, },
callback: ( callback: (
error: Error | null | 'closed', error: Error | null | 'closed',
@ -188,7 +189,15 @@ export class DaemonClient {
} | null } | null
) => void ) => void
): Promise<UnregisterCallback> { ): Promise<UnregisterCallback> {
try {
await this.getProjectGraphAndSourceMaps(); await this.getProjectGraphAndSourceMaps();
} catch (e) {
if (config.allowPartialGraph && e instanceof ProjectGraphError) {
// we are fine with partial graph
} else {
throw e;
}
}
let messenger: DaemonSocketMessenger | undefined; let messenger: DaemonSocketMessenger | undefined;
await this.queue.sendToQueue(() => { await this.queue.sendToQueue(() => {

View File

@ -1,6 +1,5 @@
import { Socket } from 'net'; import { Socket } from 'net';
import { findMatchingProjects } from '../../../utils/find-matching-projects'; import { findMatchingProjects } from '../../../utils/find-matching-projects';
import { ProjectGraph } from '../../../config/project-graph';
import { findAllProjectNodeDependencies } from '../../../utils/project-graph-utils'; import { findAllProjectNodeDependencies } from '../../../utils/project-graph-utils';
import { PromisedBasedQueue } from '../../../utils/promised-based-queue'; import { PromisedBasedQueue } from '../../../utils/promised-based-queue';
import { currentProjectGraph } from '../project-graph-incremental-recomputation'; import { currentProjectGraph } from '../project-graph-incremental-recomputation';

View File

@ -261,7 +261,7 @@ async function processFilesAndCreateAndSerializeProjectGraph(
const errors = [...(projectConfigurationsError?.errors ?? [])]; const errors = [...(projectConfigurationsError?.errors ?? [])];
if (g.error) { if (g.error) {
if (isAggregateProjectGraphError(g.error)) { if (isAggregateProjectGraphError(g.error) && g.error.errors?.length) {
errors.push(...g.error.errors); errors.push(...g.error.errors);
} else { } else {
return { return {

View File

@ -4,6 +4,7 @@ import { ProjectConfiguration } from '../../../config/workspace-json-project-jso
import { toProjectName } from '../../../config/workspaces'; import { toProjectName } from '../../../config/workspaces';
import { readJsonFile } from '../../../utils/fileutils'; import { readJsonFile } from '../../../utils/fileutils';
import { NxPluginV2 } from '../../../project-graph/plugins'; import { NxPluginV2 } from '../../../project-graph/plugins';
import { CreateNodesError } from '../../../project-graph/error-types';
export const ProjectJsonProjectsPlugin: NxPluginV2 = { export const ProjectJsonProjectsPlugin: NxPluginV2 = {
name: 'nx/core/project-json', name: 'nx/core/project-json',
@ -13,6 +14,7 @@ export const ProjectJsonProjectsPlugin: NxPluginV2 = {
const json = readJsonFile<ProjectConfiguration>( const json = readJsonFile<ProjectConfiguration>(
join(workspaceRoot, file) join(workspaceRoot, file)
); );
const project = buildProjectFromProjectJson(json, file); const project = buildProjectFromProjectJson(json, file);
return { return {
projects: { projects: {

View File

@ -171,7 +171,7 @@ export async function buildProjectGraphAndSourceMapsWithoutDaemon() {
} }
} }
function handleProjectGraphError(opts: { exitOnError: boolean }, e) { export function handleProjectGraphError(opts: { exitOnError: boolean }, e) {
if (opts.exitOnError) { if (opts.exitOnError) {
const isVerbose = process.env.NX_VERBOSE_LOGGING === 'true'; const isVerbose = process.env.NX_VERBOSE_LOGGING === 'true';
if (e instanceof ProjectGraphError) { if (e instanceof ProjectGraphError) {