@@ -47,6 +47,7 @@ interface PomData {
4747 developers ?: { developer ?: unknown }
4848 contributors ?: { contributor ?: unknown }
4949 parent ?: { groupId ?: unknown ; artifactId ?: unknown ; version ?: unknown }
50+ properties ?: unknown
5051}
5152
5253interface PomPerson {
@@ -262,6 +263,9 @@ interface ResolvedFields {
262263 developers : PomMaintainer [ ]
263264 contributors : PomMaintainer [ ]
264265 hops : number
266+ // Merged <properties> across the resolved parent chain (child overrides parent),
267+ // used to interpolate ${...} placeholders in the SCM URL.
268+ properties : Record < string , string >
265269}
266270
267271// prettier-ignore
@@ -273,9 +277,12 @@ async function resolveWithInheritance(groupId: string, artifactId: string, versi
273277 const scmUrl = extractStr ( pom . scm ?. url ?? pom . scm ?. connection )
274278 const developers = extractPersons ( pom . developers ?. developer , 'author' )
275279 const contributors = extractPersons ( pom . contributors ?. contributor , 'maintainer' )
280+ const properties = extractProperties ( pom )
276281
277282 const missingLicense = licenses . length === 0
278- const missingScm = ! scmUrl
283+ // An unresolved ${...} placeholder counts as missing: the property that defines it
284+ // may live in a parent POM, so we still need to walk the chain to collect it.
285+ const missingScm = ! scmUrl || scmUrl . includes ( '${' )
279286 const missingDevelopers = developers . length === 0 || contributors . length === 0
280287 const parent = extractParent ( pom )
281288
@@ -306,6 +313,8 @@ async function resolveWithInheritance(groupId: string, artifactId: string, versi
306313 developers : developers . length > 0 ? developers : parentFields . developers ,
307314 contributors : contributors . length > 0 ? contributors : parentFields . contributors ,
308315 hops : parentFields . hops ,
316+ // Child properties override the parent's.
317+ properties : { ...parentFields . properties , ...properties } ,
309318 }
310319 }
311320 }
@@ -319,6 +328,7 @@ async function resolveWithInheritance(groupId: string, artifactId: string, versi
319328 developers,
320329 contributors,
321330 hops : depth ,
331+ properties,
322332 }
323333}
324334
@@ -355,7 +365,12 @@ export async function extractArtifactDirect(groupId: string, artifactId: string,
355365 }
356366
357367 const licenses = extractLicenses ( pom )
358- const scmUrl = extractStr ( pom . scm ?. url ?? pom . scm ?. connection )
368+ const rawScmUrl = extractStr ( pom . scm ?. url ?? pom . scm ?. connection )
369+ const props = {
370+ ...extractProperties ( pom ) ,
371+ ...builtinProjectProperties ( groupId , artifactId , version ) ,
372+ }
373+ const scmUrl = rawScmUrl ? interpolateProperties ( rawScmUrl , props ) : null
359374 const developers = extractPersons ( pom . developers ?. developer , 'author' )
360375 const contributors = extractPersons ( pom . contributors ?. contributor , 'maintainer' )
361376
@@ -414,6 +429,14 @@ export async function extractArtifact(groupId: string, artifactId: string, versi
414429 new Set ( ) ,
415430 baseUrl ,
416431 )
432+ // Resolve ${...} placeholders in the SCM URL using the merged chain properties
433+ // plus the leaf's built-in project.* values. Best-effort: unresolved placeholders
434+ // stay literal and are rejected downstream by normalizeScmUrl.
435+ const props = {
436+ ...resolved . properties ,
437+ ...builtinProjectProperties ( groupId , artifactId , version ) ,
438+ }
439+ const scmUrl = resolved . scmUrl ? interpolateProperties ( resolved . scmUrl , props ) : null
417440 return {
418441 groupId,
419442 artifactId,
@@ -422,7 +445,7 @@ export async function extractArtifact(groupId: string, artifactId: string, versi
422445 description : resolved . description ,
423446 licenses : resolved . licenses ,
424447 licensesRaw : resolved . licensesRaw ,
425- scmUrl : resolved . scmUrl ,
448+ scmUrl,
426449 homepageUrl : resolved . homepageUrl ,
427450 developers : resolved . developers ,
428451 contributors : resolved . contributors ,
@@ -524,6 +547,12 @@ export function normalizeScmUrl(raw: string | null): string | null {
524547 let s = raw . trim ( )
525548 if ( ! s ) return null
526549
550+ // A leftover ${...} means best-effort interpolation upstream could not resolve it.
551+ // Reject outright: a placeholder embedded in the path (e.g. github.com/owner/${x})
552+ // otherwise survives URL parsing (percent-encoded to %7B…%7D) and would yield a junk
553+ // repository_url instead of null.
554+ if ( s . includes ( '${' ) ) return null
555+
527556 // Strip Maven scm:git: / scm: prefix
528557 s = s . replace ( / ^ s c m : g i t : / i, '' ) . replace ( / ^ s c m : / i, '' )
529558
@@ -612,6 +641,59 @@ function extractPersons(raw: unknown, role: 'author' | 'maintainer'): PomMaintai
612641 } ) )
613642}
614643
644+ /** Flattens a POM's <properties> block into a string→string map (non-string values skipped). */
645+ function extractProperties ( pom : PomData ) : Record < string , string > {
646+ const raw = pom . properties
647+ if ( ! raw || typeof raw !== 'object' ) return { }
648+ const out : Record < string , string > = { }
649+ for ( const [ key , value ] of Object . entries ( raw as Record < string , unknown > ) ) {
650+ if ( typeof value === 'string' ) out [ key ] = value
651+ else if ( typeof value === 'number' ) out [ key ] = String ( value )
652+ }
653+ return out
654+ }
655+
656+ /** Maven built-in project.* / pom.* properties for the leaf coordinates. */
657+ function builtinProjectProperties (
658+ groupId : string ,
659+ artifactId : string ,
660+ version : string ,
661+ ) : Record < string , string > {
662+ return {
663+ 'project.groupId' : groupId ,
664+ 'project.artifactId' : artifactId ,
665+ 'project.version' : version ,
666+ 'pom.groupId' : groupId ,
667+ 'pom.artifactId' : artifactId ,
668+ 'pom.version' : version ,
669+ groupId,
670+ artifactId,
671+ version,
672+ }
673+ }
674+
675+ const MAX_INTERPOLATION_DEPTH = 10
676+
677+ /**
678+ * Best-effort Maven property interpolation of ${...} placeholders. Resolves
679+ * recursively (a property value may itself reference another) up to a depth cap
680+ * to guard against cycles. Placeholders with no matching property (e.g. defined
681+ * in a profile/settings, or method calls like ${x.substring(8)}) are left as-is
682+ * so the SCM normaliser rejects them.
683+ */
684+ export function interpolateProperties ( value : string , props : Record < string , string > ) : string {
685+ let current = value
686+ for ( let i = 0 ; i < MAX_INTERPOLATION_DEPTH && current . includes ( '${' ) ; i ++ ) {
687+ const next = current . replace ( / \$ \{ ( [ ^ { } ] + ) \} / g, ( match , key ) => {
688+ const resolved = props [ ( key as string ) . trim ( ) ]
689+ return resolved !== undefined ? resolved : match
690+ } )
691+ if ( next === current ) break
692+ current = next
693+ }
694+ return current
695+ }
696+
615697function extractParent (
616698 pom : PomData ,
617699) : { groupId : string ; artifactId : string ; version : string } | null {
@@ -634,5 +716,6 @@ function emptyFields(hops: number): ResolvedFields {
634716 developers : [ ] ,
635717 contributors : [ ] ,
636718 hops,
719+ properties : { } ,
637720 }
638721}
0 commit comments