@@ -112,6 +112,18 @@ const listProjectsOutputSchema = {
112112 )
113113 . default ( [ ] )
114114 . describe ( 'A list of workspaces for which the framework version could not be determined.' ) ,
115+ validationErrors : z
116+ . array (
117+ z . object ( {
118+ filePath : z . string ( ) . describe ( 'The path to the workspace `angular.json` file.' ) ,
119+ projectName : z . string ( ) . describe ( 'The name of the project with invalid schema.' ) ,
120+ message : z . string ( ) . describe ( 'The reason why validation failed or fell back.' ) ,
121+ } ) ,
122+ )
123+ . default ( [ ] )
124+ . describe (
125+ 'A list of projects within workspaces that had invalid or malformed schema elements.' ,
126+ ) ,
115127} ;
116128
117129export const LIST_PROJECTS_TOOL = declareTool ( {
@@ -331,6 +343,7 @@ async function findAngularCoreVersion(
331343type WorkspaceData = z . infer < typeof listProjectsOutputSchema . workspaces > [ number ] ;
332344type ParsingError = z . infer < typeof listProjectsOutputSchema . parsingErrors > [ number ] ;
333345type VersioningError = z . infer < typeof listProjectsOutputSchema . versioningErrors > [ number ] ;
346+ type ValidationError = z . infer < typeof listProjectsOutputSchema . validationErrors > [ number ] ;
334347
335348/**
336349 * Determines the unit test framework for a project based on its 'test' target configuration.
@@ -455,36 +468,71 @@ async function getProjectStyleLanguage(
455468async function loadAndParseWorkspace (
456469 configFile : string ,
457470 seenPaths : Set < string > ,
458- ) : Promise < { workspace : WorkspaceData | null ; error : ParsingError | null } > {
471+ ) : Promise < {
472+ workspace : WorkspaceData | null ;
473+ error : ParsingError | null ;
474+ validationErrors : ValidationError [ ] ;
475+ } > {
459476 try {
460477 const resolvedPath = resolve ( configFile ) ;
461478 if ( seenPaths . has ( resolvedPath ) ) {
462- return { workspace : null , error : null } ; // Already processed, skip.
479+ return { workspace : null , error : null , validationErrors : [ ] } ; // Already processed, skip.
463480 }
464481 seenPaths . add ( resolvedPath ) ;
465482
466483 const ws = await AngularWorkspace . load ( configFile ) ;
467- const projects = [ ] ;
484+ const projects : WorkspaceData [ 'projects' ] = [ ] ;
485+ const validationErrors : ValidationError [ ] = [ ] ;
468486 const workspaceRoot = dirname ( configFile ) ;
469487 for ( const [ name , project ] of ws . projects . entries ( ) ) {
470488 const sourceRoot = posix . join ( project . root , project . sourceRoot ?? 'src' ) ;
471489 const fullSourceRoot = join ( workspaceRoot , sourceRoot ) ;
472490 const unitTestFramework = getUnitTestFramework ( project . targets . get ( 'test' ) ) ;
473491 const styleLanguage = await getProjectStyleLanguage ( project , ws , fullSourceRoot ) ;
474492
493+ const rawType = project . extensions [ 'projectType' ] ;
494+ const type = rawType === 'application' || rawType === 'library' ? rawType : undefined ;
495+ if ( rawType && ! type ) {
496+ validationErrors . push ( {
497+ filePath : configFile ,
498+ projectName : name ,
499+ message : `Invalid \`projectType\` '${ rawType } '. Expected 'application' or 'library'. Falling back to undefined.` ,
500+ } ) ;
501+ }
502+
503+ const rawPrefix = project . extensions [ 'prefix' ] ;
504+ const selectorPrefix = typeof rawPrefix === 'string' ? rawPrefix : undefined ;
505+ if ( rawPrefix !== undefined && selectorPrefix === undefined ) {
506+ validationErrors . push ( {
507+ filePath : configFile ,
508+ projectName : name ,
509+ message : `Invalid \`prefix\`. Expected a string. Falling back to undefined.` ,
510+ } ) ;
511+ }
512+
513+ const buildBuilder = project . targets . get ( 'build' ) ?. builder ;
514+ const builder = typeof buildBuilder === 'string' ? buildBuilder : undefined ;
515+ if ( buildBuilder !== undefined && builder === undefined ) {
516+ validationErrors . push ( {
517+ filePath : configFile ,
518+ projectName : name ,
519+ message : `Invalid or missing build builder. Falling back to undefined.` ,
520+ } ) ;
521+ }
522+
475523 projects . push ( {
476524 name,
477- type : project . extensions [ 'projectType' ] as 'application' | 'library' | undefined ,
478- builder : project . targets . get ( 'build' ) ?. builder ,
525+ type,
526+ builder,
479527 root : project . root ,
480528 sourceRoot,
481- selectorPrefix : project . extensions [ 'prefix' ] as string ,
529+ selectorPrefix,
482530 unitTestFramework,
483531 styleLanguage,
484532 } ) ;
485533 }
486534
487- return { workspace : { path : configFile , projects } , error : null } ;
535+ return { workspace : { path : configFile , projects } , error : null , validationErrors } ;
488536 } catch ( error ) {
489537 let message ;
490538 if ( error instanceof Error ) {
@@ -493,7 +541,7 @@ async function loadAndParseWorkspace(
493541 message = 'An unknown error occurred while parsing the file.' ;
494542 }
495543
496- return { workspace : null , error : { filePath : configFile , message } } ;
544+ return { workspace : null , error : { filePath : configFile , message } , validationErrors : [ ] } ;
497545 }
498546}
499547
@@ -514,14 +562,15 @@ async function processConfigFile(
514562 workspace ?: WorkspaceData ;
515563 parsingError ?: ParsingError ;
516564 versioningError ?: VersioningError ;
565+ validationErrors ?: ValidationError [ ] ;
517566} > {
518- const { workspace, error } = await loadAndParseWorkspace ( configFile , seenPaths ) ;
567+ const { workspace, error, validationErrors } = await loadAndParseWorkspace ( configFile , seenPaths ) ;
519568 if ( error ) {
520569 return { parsingError : error } ;
521570 }
522571
523572 if ( ! workspace ) {
524- return { } ; // Skipped as it was already seen.
573+ return { validationErrors } ; // If already seen, we still group validation errors if any (unlikely to be any if seen) .
525574 }
526575
527576 try {
@@ -532,10 +581,11 @@ async function processConfigFile(
532581 searchRoot ,
533582 ) ;
534583
535- return { workspace } ;
584+ return { workspace, validationErrors } ;
536585 } catch ( e ) {
537586 return {
538587 workspace,
588+ validationErrors,
539589 versioningError : {
540590 filePath : workspace . path ,
541591 message : e instanceof Error ? e . message : 'An unknown error occurred.' ,
@@ -544,11 +594,37 @@ async function processConfigFile(
544594 }
545595}
546596
597+ /**
598+ * Deduplicates overlapping search roots (e.g., if one is a child of another).
599+ * Sorting by length ensures parent directories are processed before children.
600+ * @param roots A list of normalized absolute paths used as search roots.
601+ * @returns A deduplicated list of search roots.
602+ */
603+ function deduplicateSearchRoots ( roots : string [ ] ) : string [ ] {
604+ const sortedRoots = [ ...roots ] . sort ( ( a , b ) => a . length - b . length ) ;
605+ const deduplicated : string [ ] = [ ] ;
606+
607+ for ( const root of sortedRoots ) {
608+ const isSubdirectory = deduplicated . some ( ( existing ) => {
609+ const rel = relative ( existing , root ) ;
610+
611+ return rel === '' || ( ! rel . startsWith ( '..' ) && ! isAbsolute ( rel ) ) ;
612+ } ) ;
613+
614+ if ( ! isSubdirectory ) {
615+ deduplicated . push ( root ) ;
616+ }
617+ }
618+
619+ return deduplicated ;
620+ }
621+
547622async function createListProjectsHandler ( { server } : McpToolContext ) {
548623 return async ( ) => {
549624 const workspaces : WorkspaceData [ ] = [ ] ;
550625 const parsingErrors : ParsingError [ ] = [ ] ;
551626 const versioningErrors : z . infer < typeof listProjectsOutputSchema . versioningErrors > = [ ] ;
627+ const validationErrors : ValidationError [ ] = [ ] ;
552628 const seenPaths = new Set < string > ( ) ;
553629 const versionCache = new Map < string , string | undefined > ( ) ;
554630
@@ -562,6 +638,8 @@ async function createListProjectsHandler({ server }: McpToolContext) {
562638 searchRoots = [ process . cwd ( ) ] ;
563639 }
564640
641+ searchRoots = deduplicateSearchRoots ( searchRoots ) ;
642+
565643 // Pre-resolve allowed roots to handle their own symlinks or normalizations.
566644 // We ignore failures here; if a root is broken, we simply won't match against it.
567645 const realAllowedRoots = searchRoots
@@ -576,12 +654,12 @@ async function createListProjectsHandler({ server }: McpToolContext) {
576654
577655 for ( const root of searchRoots ) {
578656 for await ( const configFile of findAngularJsonFiles ( root , realAllowedRoots ) ) {
579- const { workspace , parsingError , versioningError } = await processConfigFile (
580- configFile ,
581- root ,
582- seenPaths ,
583- versionCache ,
584- ) ;
657+ const {
658+ workspace ,
659+ parsingError ,
660+ versioningError ,
661+ validationErrors : currentValidationErrors ,
662+ } = await processConfigFile ( configFile , root , seenPaths , versionCache ) ;
585663
586664 if ( workspace ) {
587665 workspaces . push ( workspace ) ;
@@ -592,6 +670,9 @@ async function createListProjectsHandler({ server }: McpToolContext) {
592670 if ( versioningError ) {
593671 versioningErrors . push ( versioningError ) ;
594672 }
673+ if ( currentValidationErrors ) {
674+ validationErrors . push ( ...currentValidationErrors ) ;
675+ }
595676 }
596677 }
597678
@@ -619,10 +700,16 @@ async function createListProjectsHandler({ server }: McpToolContext) {
619700 text += `\n\nWarning: The framework version for the following ${ versioningErrors . length } workspace(s) could not be determined:\n` ;
620701 text += versioningErrors . map ( ( e ) => `- ${ e . filePath } : ${ e . message } ` ) . join ( '\n' ) ;
621702 }
703+ if ( validationErrors . length > 0 ) {
704+ text += `\n\nWarning: The following ${ validationErrors . length } project validation issue(s) were found (properties fell back to defaults):\n` ;
705+ text += validationErrors
706+ . map ( ( e ) => `- ${ e . filePath } [Project: ${ e . projectName } ]: ${ e . message } ` )
707+ . join ( '\n' ) ;
708+ }
622709
623710 return {
624711 content : [ { type : 'text' as const , text } ] ,
625- structuredContent : { workspaces, parsingErrors, versioningErrors } ,
712+ structuredContent : { workspaces, parsingErrors, versioningErrors, validationErrors } ,
626713 } ;
627714 } ;
628715}
0 commit comments