@@ -519,6 +519,27 @@ fn is_direct_match(def: &DefinitionSummary, markers: &[MarkerMetadata]) -> bool
519519 yaml_normalized == stem
520520}
521521
522+ /// Decide whether a marker's `(org, repo)` identifies the same
523+ /// repository as the discovery context. Empty marker fields (legacy
524+ /// markers produced before the org/repo embed landed, or markers from
525+ /// non-ADO compile environments) are treated as wildcards so existing
526+ /// deployments are not silently excluded. Once those lock files are
527+ /// recompiled, the match becomes strict.
528+ fn marker_origin_matches (
529+ marker : & MarkerMetadata ,
530+ current_org_lc : & str ,
531+ current_repo_lc : & str ,
532+ ) -> bool {
533+ if marker. org . is_empty ( ) && marker. repo . is_empty ( ) {
534+ return true ;
535+ }
536+ // Marker fields are already lower-cased at emit time. Be defensive
537+ // anyway — round-tripping through serde_json doesn't change case
538+ // but a hand-edited fixture or future producer might.
539+ marker. org . eq_ignore_ascii_case ( current_org_lc)
540+ && marker. repo . eq_ignore_ascii_case ( current_repo_lc)
541+ }
542+
522543async fn parse_local_lock ( path : & Path ) -> Option < MarkerMetadata > {
523544 let content = tokio:: fs:: read_to_string ( path) . await . ok ( ) ?;
524545 // Two surfaces, in order of preference:
@@ -535,6 +556,8 @@ async fn parse_local_lock(path: &Path) -> Option<MarkerMetadata> {
535556 return Some ( MarkerMetadata {
536557 schema : 0 ,
537558 source : h. source ,
559+ org : String :: new ( ) ,
560+ repo : String :: new ( ) ,
538561 version : h. version ,
539562 target : String :: new ( ) ,
540563 } ) ;
@@ -614,6 +637,17 @@ pub fn discovered_to_matched(d: &DiscoveredPipeline) -> Option<MatchedDefinition
614637/// **case-sensitive** even on Windows; pass the path in the same case
615638/// it was compiled with.
616639///
640+ /// Source-only matching is ambiguous when two repos in the same ADO
641+ /// project happen to define a file of the same name (e.g. both have
642+ /// `agents/foo.md`). To disambiguate, the marker carries the ADO
643+ /// `org` and `repo` of the compiling repository (lower-cased). When
644+ /// `source_filter` is active, the marker's `(org, repo)` must also
645+ /// equal `ctx`'s — i.e. the operator gets only consumers whose
646+ /// template originated in the **current repo**. Markers with empty
647+ /// `org` / `repo` (legacy or non-ADO compilers) match leniently so
648+ /// pre-existing deployments are not silently excluded; once everything
649+ /// is recompiled with this version, the match becomes strict.
650+ ///
617651/// Skip-summary warnings are emitted differently depending on whether
618652/// `source_filter` is active:
619653///
@@ -648,6 +682,20 @@ pub async fn resolve_definitions_via_discovery(
648682 let normalized_filter: Option < String > = source_filter
649683 . map ( |s| crate :: compile:: normalize_source_path ( Path :: new ( s) ) ) ;
650684
685+ // Origin scoping: when filtering by `--source`, also require the
686+ // marker's (org, repo) to identify the current repository. This
687+ // disambiguates the source field when two repos in the same
688+ // project define files of the same name. Lower-cased to align with
689+ // the marker's lower-casing at emit time (ADO identifiers are
690+ // case-insensitive). Markers with empty fields (legacy / non-ADO
691+ // compiles) match leniently so already-deployed pipelines remain
692+ // discoverable until they are recompiled.
693+ let current_org = ctx
694+ . org_name ( )
695+ . map ( |s| s. to_ascii_lowercase ( ) )
696+ . unwrap_or_default ( ) ;
697+ let current_repo = ctx. repo_name . to_ascii_lowercase ( ) ;
698+
651699 // Pass 1: classify each discovered definition into "keep / skip
652700 // silently / skip with reason". The previous shape stuffed all of
653701 // this into a side-effecting `.filter()` closure that mutated
@@ -662,7 +710,9 @@ pub async fn resolve_definitions_via_discovery(
662710
663711 for d in discovered {
664712 let matches_filter = match normalized_filter. as_deref ( ) {
665- Some ( src) => d. markers . iter ( ) . any ( |m| m. source == src) ,
713+ Some ( src) => d. markers . iter ( ) . any ( |m| {
714+ m. source == src && marker_origin_matches ( m, & current_org, & current_repo)
715+ } ) ,
666716 None => true ,
667717 } ;
668718
@@ -844,6 +894,7 @@ mod tests {
844894 source: "agents/foo.md" . to_string( ) ,
845895 version: "0.30.0" . to_string( ) ,
846896 target: "standalone" . to_string( ) ,
897+ ..Default :: default ( )
847898 } ] ;
848899 assert ! ( is_direct_match( & def, & markers) ) ;
849900 }
@@ -859,6 +910,7 @@ mod tests {
859910 source: "agents/foo.md" . to_string( ) ,
860911 version: "0.30.0" . to_string( ) ,
861912 target: "standalone" . to_string( ) ,
913+ ..Default :: default ( )
862914 } ] ;
863915 assert ! ( is_direct_match( & def, & markers) ) ;
864916 }
@@ -877,6 +929,7 @@ mod tests {
877929 source: "agents/foo.md" . to_string( ) ,
878930 version: "0.30.0" . to_string( ) ,
879931 target: "standalone" . to_string( ) ,
932+ ..Default :: default ( )
880933 } ] ;
881934 assert ! ( !is_direct_match( & def, & markers) ) ;
882935 }
@@ -889,6 +942,7 @@ mod tests {
889942 source: "agents/foo.md" . to_string( ) ,
890943 version: "0.30.0" . to_string( ) ,
891944 target: "stage" . to_string( ) ,
945+ ..Default :: default ( )
892946 } ] ;
893947 assert ! ( !is_direct_match( & def, & markers) ) ;
894948 }
@@ -902,12 +956,14 @@ mod tests {
902956 source: "agents/foo.md" . to_string( ) ,
903957 version: "0.30.0" . to_string( ) ,
904958 target: "stage" . to_string( ) ,
959+ ..Default :: default ( )
905960 } ,
906961 MarkerMetadata {
907962 schema: 1 ,
908963 source: "agents/bar.md" . to_string( ) ,
909964 version: "0.30.0" . to_string( ) ,
910965 target: "job" . to_string( ) ,
966+ ..Default :: default ( )
911967 } ,
912968 ] ;
913969 // Multiple markers = at least one template is being included
@@ -972,6 +1028,62 @@ mod tests {
9721028 ) ;
9731029 }
9741030
1031+ // ── marker_origin_matches ────────────────────────────────────────
1032+
1033+ #[ test]
1034+ fn origin_matches_strict_when_marker_has_org_and_repo ( ) {
1035+ let marker = MarkerMetadata {
1036+ org : "myorg" . to_string ( ) ,
1037+ repo : "templates-a" . to_string ( ) ,
1038+ source : "agents/foo.md" . to_string ( ) ,
1039+ ..Default :: default ( )
1040+ } ;
1041+ assert ! ( marker_origin_matches( & marker, "myorg" , "templates-a" ) ) ;
1042+ assert ! ( !marker_origin_matches( & marker, "myorg" , "templates-b" ) ) ;
1043+ assert ! ( !marker_origin_matches( & marker, "otherorg" , "templates-a" ) ) ;
1044+ }
1045+
1046+ #[ test]
1047+ fn origin_matches_case_insensitively ( ) {
1048+ // ADO identifiers are case-insensitive. Marker fields are
1049+ // lower-cased at emit time, but a fixture or hand-edited
1050+ // marker might carry uppercase — accept either.
1051+ let marker = MarkerMetadata {
1052+ org : "MyOrg" . to_string ( ) ,
1053+ repo : "Templates-A" . to_string ( ) ,
1054+ source : "agents/foo.md" . to_string ( ) ,
1055+ ..Default :: default ( )
1056+ } ;
1057+ assert ! ( marker_origin_matches( & marker, "myorg" , "templates-a" ) ) ;
1058+ }
1059+
1060+ #[ test]
1061+ fn origin_matches_leniently_when_marker_org_repo_empty ( ) {
1062+ // Legacy markers (pre-org/repo embed) and markers compiled
1063+ // outside an ADO checkout carry empty org/repo. Match anything
1064+ // so existing deployments keep working until recompiled.
1065+ let marker = MarkerMetadata {
1066+ source : "agents/foo.md" . to_string ( ) ,
1067+ ..Default :: default ( )
1068+ } ;
1069+ assert ! ( marker_origin_matches( & marker, "myorg" , "templates-a" ) ) ;
1070+ assert ! ( marker_origin_matches( & marker, "" , "" ) ) ;
1071+ }
1072+
1073+ #[ test]
1074+ fn origin_matches_strictly_when_only_one_field_empty ( ) {
1075+ // If only one half of (org, repo) is set, we treat the marker
1076+ // as non-legacy and require both to match. Pre-empts a
1077+ // malformed fixture passing through the lenient path.
1078+ let half_marker = MarkerMetadata {
1079+ org : "myorg" . to_string ( ) ,
1080+ repo : String :: new ( ) ,
1081+ source : "agents/foo.md" . to_string ( ) ,
1082+ ..Default :: default ( )
1083+ } ;
1084+ assert ! ( !marker_origin_matches( & half_marker, "myorg" , "templates-a" ) ) ;
1085+ }
1086+
9751087 // ── discovered_to_matched ────────────────────────────────────────
9761088
9771089 fn discovered ( status : DiscoveryStatus ) -> DiscoveredPipeline {
@@ -1027,12 +1139,14 @@ mod tests {
10271139 source: "agents/a.md" . to_string( ) ,
10281140 version: "1.0" . to_string( ) ,
10291141 target: "job" . to_string( ) ,
1142+ ..Default :: default ( )
10301143 } ,
10311144 MarkerMetadata {
10321145 schema: 1 ,
10331146 source: "agents/b.md" . to_string( ) ,
10341147 version: "1.0" . to_string( ) ,
10351148 target: "stage" . to_string( ) ,
1149+ ..Default :: default ( )
10361150 } ,
10371151 ] ;
10381152 let matched = discovered_to_matched ( & d) . expect ( "Consumer kept" ) ;
@@ -1063,6 +1177,7 @@ mod tests {
10631177 source: "agents/##vso[task.setvariable variable=X]value.md" . to_string( ) ,
10641178 version: "1.0" . to_string( ) ,
10651179 target: "job" . to_string( ) ,
1180+ ..Default :: default ( )
10661181 } ] ;
10671182 let matched = discovered_to_matched ( & d) . expect ( "Consumer kept" ) ;
10681183 assert ! (
0 commit comments