@@ -79,20 +79,66 @@ export function resolveExtends(
7979 // Start with empty definition
8080 let mergedDef : JobDefinitionNormalized = { }
8181
82+ // Collect all extends that should be kept (not merged)
83+ const keptExtends : string [ ] = [ ]
84+
85+ // Recursively collect non-template extends from merged templates
86+ const collectNonTemplateExtends = ( extendName : string , visited = new Set < string > ( ) ) : void => {
87+ if ( visited . has ( extendName ) ) return
88+ visited . add ( extendName )
89+
90+ const targetName = graph . has ( extendName ) ? extendName : `.${ extendName } `
91+ const targetNode = graph . get ( targetName )
92+
93+ if ( ! targetNode ?. extends ) return
94+
95+ for ( const nestedExtend of targetNode . extends ) {
96+ const nestedTargetName = graph . has ( nestedExtend ) ? nestedExtend : `.${ nestedExtend } `
97+ const nestedNode = graph . get ( nestedTargetName )
98+
99+ if ( ! nestedNode ) {
100+ // Unknown extend, keep it
101+ if ( ! keptExtends . includes ( nestedExtend ) ) {
102+ keptExtends . push ( nestedExtend )
103+ }
104+ } else if ( nestedNode . isRemote ) {
105+ // Remote extend, keep it
106+ if ( ! keptExtends . includes ( nestedExtend ) ) {
107+ keptExtends . push ( nestedExtend )
108+ }
109+ } else if ( ! nestedTargetName . startsWith ( "." ) ) {
110+ // Normal job (not template), keep it
111+ if ( ! keptExtends . includes ( nestedExtend ) ) {
112+ keptExtends . push ( nestedExtend )
113+ }
114+ } else {
115+ // It's a template, recurse into it
116+ collectNonTemplateExtends ( nestedExtend , visited )
117+ }
118+ }
119+ }
120+
82121 // Merge extends chain (ALWAYS resolve for merging, regardless of mergeExtends)
83122 if ( node . extends . length > 0 ) {
84123 for ( const extendName of node . extends ) {
85124 // Try with and without dot prefix
86125 const targetName = graph . has ( extendName ) ? extendName : `.${ extendName } `
87126 const targetNode = graph . get ( targetName )
88127
89- if ( ! targetNode ) continue
128+ if ( ! targetNode ) {
129+ // Unknown target, keep in extends
130+ keptExtends . push ( extendName )
131+ continue
132+ }
90133
91134 // Check if we should merge this extend
92135 const shouldMerge = resolveTemplatesOnly ? targetName . startsWith ( "." ) : true
93136
94- // Skip remote extends
95- if ( targetNode . isRemote ) continue
137+ // Skip remote extends from merging
138+ if ( targetNode . isRemote ) {
139+ keptExtends . push ( extendName )
140+ continue
141+ }
96142
97143 if ( shouldMerge ) {
98144 // Use fully resolved definition (without extends field) for merging
@@ -102,6 +148,14 @@ export function resolveExtends(
102148 } else {
103149 mergedDef = mergeJobDefinitions ( mergedDef , targetNode . definition )
104150 }
151+
152+ // If resolveTemplatesOnly and this is a template, collect non-template extends from it
153+ if ( resolveTemplatesOnly && targetName . startsWith ( "." ) ) {
154+ collectNonTemplateExtends ( extendName )
155+ }
156+ } else {
157+ // Don't merge, but keep in extends (normal job when resolveTemplatesOnly: true)
158+ keptExtends . push ( extendName )
105159 }
106160 }
107161 }
@@ -119,10 +173,21 @@ export function resolveExtends(
119173 continue
120174 }
121175
122- // Clean up extends field
123- const cleanedDef = cleanExtendsField ( finalDef , node , graph , globalOptions , jobOpts )
176+ // Add kept extends to final definition (or remove extends entirely if none kept)
177+ let outputDef : JobDefinitionOutput
178+ if ( keptExtends . length > 0 ) {
179+ if ( keptExtends . length === 1 ) {
180+ outputDef = { ...finalDef , extends : keptExtends [ 0 ] }
181+ } else {
182+ outputDef = { ...finalDef , extends : keptExtends }
183+ }
184+ } else {
185+ // Remove extends field entirely if nothing to keep
186+ const { extends : _extends , ...rest } = finalDef
187+ outputDef = rest as JobDefinitionOutput
188+ }
124189
125- resolved . set ( name , cleanedDef )
190+ resolved . set ( name , outputDef )
126191 }
127192
128193 // Convert Map back to Record
@@ -138,61 +203,3 @@ export function resolveExtends(
138203 skippedChecks : context . skippedChecks ,
139204 }
140205}
141-
142- /**
143- * Clean extends field after resolution
144- * Remove local extends, keep only remote/external ones
145- */
146- function cleanExtendsField (
147- definition : JobDefinitionNormalized ,
148- node : { extends : string [ ] ; isRemote : boolean } ,
149- graph : Map < string , { isRemote : boolean } > ,
150- globalOptions : GlobalOptions ,
151- jobOpts ?: JobOptions ,
152- ) : JobDefinitionOutput {
153- const mergeExtends = jobOpts ?. mergeExtends ?? globalOptions . mergeExtends
154-
155- if ( mergeExtends === false ) {
156- // Keep extends as-is, just optimize to string if single entry
157- if ( definition . extends ?. length === 1 ) {
158- return {
159- ...definition ,
160- extends : definition . extends [ 0 ] ,
161- } as JobDefinitionOutput
162- }
163- return definition as JobDefinitionOutput
164- }
165-
166- // Filter extends to keep only remote/external ones
167- if ( definition . extends && definition . extends . length > 0 ) {
168- const filtered = definition . extends . filter ( ( extendName ) => {
169- const targetName = graph . has ( extendName ) ? extendName : `.${ extendName } `
170- const targetNode = graph . get ( targetName )
171-
172- // Keep if remote or not found in local graph
173- return ! targetNode || targetNode . isRemote
174- } )
175-
176- if ( filtered . length === 0 ) {
177- // Remove extends field entirely
178- const { extends : _extends , ...rest } = definition
179- return rest as JobDefinitionOutput
180- }
181-
182- if ( filtered . length === 1 ) {
183- // Optimize to string
184- return {
185- ...definition ,
186- extends : filtered [ 0 ] ,
187- } as JobDefinitionOutput
188- }
189-
190- // Keep as array
191- return {
192- ...definition ,
193- extends : filtered ,
194- } as JobDefinitionOutput
195- }
196-
197- return definition as JobDefinitionOutput
198- }
0 commit comments