Skip to content

Commit 9b5a232

Browse files
jamesadevineCopilot
andcommitted
fix(secrets): final-round review fixes on Preview-driven discovery
1. `src/ado/discovery.rs::build_lock_path_map` — was hand-rolling the `\\` → `/` + leading-slash-strip normalisation while the lookup side used `super::normalize_ado_yaml_path`. Functionally equivalent today, but the two would silently diverge if `normalize_ado_yaml_path` ever gained case-folding or URL-decoding. Route both sides through the shared helper. 2. `src/ado/discovery.rs::resolve_definitions_via_discovery` — in `--all-repos` mode (no source filter), uninspectable definitions (`UnknownRequiredParams` / `UnknownForbidden` / `PreviewFailed`) and `NotAdoAw` / `NotFound` defs were ending up in the `selected` vec only to be discarded by `discovered_to_matched`. In a large project where most definitions are non-ado-aw, this could allocate a vec of hundreds of dead entries. Push only actionable statuses (`Direct` / `Consumer`) into `selected`. Counts are still tallied before the guard so the warning text is unchanged. 3. `src/compile/extensions/ado_aw_marker.rs` — added a trailing newline (was failing `cargo fmt --check`). 4. `src/ado/discovery.rs::is_direct_match` — expanded the `markers.len() > 1` comment to explain *why* multiple markers imply Consumer: a compiled ado-aw pipeline's expanded YAML carries exactly one marker (its own Setup-job step); more than one means the YAML was produced by expanding a consumer that `template:`-includes multiple ado-aw lock files. 5. `src/secrets.rs` — empty-match hints used to lump all three flag combinations into one of two messages. Factored an `empty_match_hint(all_repos, source)` helper that branches on the four `(bool, Option)` cases, so `--source agents/foo.md` on its own now points the user at `--all-repos` when nothing matches in the current repo. All three callers (`run_set` / `run_list` / `run_delete`) consolidated onto the helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bac9e6f commit 9b5a232

3 files changed

Lines changed: 138 additions & 86 deletions

File tree

src/ado/discovery.rs

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ use std::path::{Path, PathBuf};
3939
use std::sync::Arc;
4040
use tokio::sync::Semaphore;
4141

42-
use super::{AdoAuth, AdoContext, DefinitionSummary, MatchMethod, MatchedDefinition, list_definitions};
42+
use super::{
43+
AdoAuth, AdoContext, DefinitionSummary, MatchMethod, MatchedDefinition, list_definitions,
44+
};
4345
use crate::detect::{MarkerMetadata, parse_marker_step};
4446

4547
/// Default permits used to throttle concurrent Preview HTTP calls.
@@ -136,7 +138,10 @@ pub enum PreviewError {
136138
impl std::fmt::Display for PreviewError {
137139
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138140
match self {
139-
PreviewError::RequiredParams => write!(f, "preview returned 400 (required parameters without defaults)"),
141+
PreviewError::RequiredParams => write!(
142+
f,
143+
"preview returned 400 (required parameters without defaults)"
144+
),
140145
PreviewError::Forbidden => write!(f, "preview returned 403 (forbidden)"),
141146
PreviewError::NotFound => write!(f, "preview returned 404 (not found)"),
142147
PreviewError::Other(msg) => write!(f, "preview failed: {msg}"),
@@ -350,16 +355,20 @@ fn normalize_repo_url(url: &str) -> String {
350355

351356
/// Build a `(normalized yamlFilename → local lock path)` lookup table
352357
/// from `--source agents/foo.lock.yml` or similar inputs.
358+
///
359+
/// The key is produced by [`super::normalize_ado_yaml_path`], the same
360+
/// helper applied to ADO's returned `process.yamlFilename` during
361+
/// classification. Routing both sides through one function future-proofs
362+
/// the lookup: if `normalize_ado_yaml_path` ever gains case-folding or
363+
/// URL-decoding, the keys stay in sync without a second edit.
353364
fn build_lock_path_map(local_lock_paths: Option<&[PathBuf]>) -> HashMap<String, PathBuf> {
354365
let mut map = HashMap::new();
355366
let Some(paths) = local_lock_paths else {
356367
return map;
357368
};
358369
for path in paths {
359-
let normalized = path.to_string_lossy().replace('\\', "/");
360-
// Match the same trim that `normalize_ado_yaml_path` applies.
361-
let trimmed = normalized.trim_start_matches('/').to_string();
362-
map.insert(trimmed, path.clone());
370+
let key = super::normalize_ado_yaml_path(&path.to_string_lossy());
371+
map.insert(key, path.clone());
363372
}
364373
map
365374
}
@@ -473,16 +482,16 @@ fn is_direct_match(def: &DefinitionSummary, markers: &[MarkerMetadata]) -> bool
473482
return false;
474483
}
475484
if markers.len() > 1 {
476-
// Multiple markers means a consumer pulling in more than one
477-
// template; can't be a direct ado-aw pipeline.
485+
// A compiled ado-aw pipeline's expanded YAML carries exactly
486+
// one marker — its own Setup-job step. More than one means
487+
// the YAML was produced by expanding a consumer that
488+
// `template:`-includes multiple ado-aw lock files (each
489+
// contributing its own marker step). None of those templates
490+
// are the consumer's own root YAML, so it can't be Direct.
478491
return false;
479492
}
480493
let marker = &markers[0];
481-
let Some(yaml_filename) = def
482-
.process
483-
.as_ref()
484-
.and_then(|p| p.yaml_filename.as_ref())
485-
else {
494+
let Some(yaml_filename) = def.process.as_ref().and_then(|p| p.yaml_filename.as_ref()) else {
486495
return false;
487496
};
488497
let yaml_normalized = super::normalize_ado_yaml_path(yaml_filename);
@@ -679,8 +688,8 @@ pub async fn resolve_definitions_via_discovery(
679688
// field. Without this, `--source ./agents/foo.md` or
680689
// `--source agents\foo.md` (Windows) silently matches nothing
681690
// because the marker stores `agents/foo.md`.
682-
let normalized_filter: Option<String> = source_filter
683-
.map(|s| crate::compile::normalize_source_path(Path::new(s)));
691+
let normalized_filter: Option<String> =
692+
source_filter.map(|s| crate::compile::normalize_source_path(Path::new(s)));
684693

685694
// Origin scoping: when filtering by `--source`, also require the
686695
// marker's (org, repo) to identify the current repository. This
@@ -719,20 +728,35 @@ pub async fn resolve_definitions_via_discovery(
719728

720729
for d in discovered {
721730
let matches_filter = match normalized_filter.as_deref() {
722-
Some(src) => d.markers.iter().any(|m| {
723-
m.source == src && marker_origin_matches(m, &current_org, &current_repo)
724-
}),
731+
Some(src) => d
732+
.markers
733+
.iter()
734+
.any(|m| m.source == src && marker_origin_matches(m, &current_org, &current_repo)),
725735
None => true,
726736
};
727737

738+
// Tally uninspectable statuses *before* the actionability
739+
// guard below so the count is accurate regardless of whether
740+
// the definition makes it into `selected`.
728741
match d.status {
729742
DiscoveryStatus::UnknownRequiredParams => uninspectable_required_params += 1,
730743
DiscoveryStatus::UnknownForbidden => uninspectable_forbidden += 1,
731744
DiscoveryStatus::PreviewFailed(_) => uninspectable_failed += 1,
732745
_ => {}
733746
}
734747

735-
if matches_filter {
748+
// Drop non-actionable statuses up-front instead of letting
749+
// them ride along in `selected` only to be filtered out by
750+
// `discovered_to_matched` at the end. In an `--all-repos` run
751+
// against a large project where most definitions are
752+
// `NotAdoAw` or `PreviewFailed`, this keeps the intermediate
753+
// vec tight and makes the filter pass's intent obvious.
754+
let actionable = matches!(
755+
d.status,
756+
DiscoveryStatus::Direct | DiscoveryStatus::Consumer
757+
);
758+
759+
if matches_filter && actionable {
736760
selected.push(d);
737761
}
738762
}
@@ -865,7 +889,12 @@ mod tests {
865889

866890
#[test]
867891
fn scope_current_repo_with_no_remote_returns_empty() {
868-
let defs = vec![def_with(1, "a", None, Some("https://dev.azure.com/o/p/_git/x"))];
892+
let defs = vec![def_with(
893+
1,
894+
"a",
895+
None,
896+
Some("https://dev.azure.com/o/p/_git/x"),
897+
)];
869898
let kept = apply_scope_filter(defs, &DiscoveryScope::CurrentRepo, &None);
870899
assert!(kept.is_empty());
871900
}
@@ -1149,8 +1178,7 @@ mod tests {
11491178
];
11501179
let matched = discovered_to_matched(&d).expect("Consumer kept");
11511180
assert!(
1152-
matched.yaml_path.contains("agents/a.md")
1153-
&& matched.yaml_path.contains("agents/b.md"),
1181+
matched.yaml_path.contains("agents/a.md") && matched.yaml_path.contains("agents/b.md"),
11541182
"expected both marker sources in yaml_path, got: {}",
11551183
matched.yaml_path
11561184
);

src/compile/extensions/ado_aw_marker.rs

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,12 @@ impl CompilerExtension for AdoAwMarkerExtension {
115115
// `org` and `repo` are derived from ADO remote parsing, which
116116
// already restricts them to a safe character set, but we apply
117117
// the same defence-in-depth pattern for consistency.
118-
let echo_source = bash_single_quote_escape(
119-
&crate::sanitize::neutralize_pipeline_commands(&source),
120-
);
121-
let echo_org = bash_single_quote_escape(
122-
&crate::sanitize::neutralize_pipeline_commands(&org),
123-
);
124-
let echo_repo = bash_single_quote_escape(
125-
&crate::sanitize::neutralize_pipeline_commands(&repo),
126-
);
118+
let echo_source =
119+
bash_single_quote_escape(&crate::sanitize::neutralize_pipeline_commands(&source));
120+
let echo_org =
121+
bash_single_quote_escape(&crate::sanitize::neutralize_pipeline_commands(&org));
122+
let echo_repo =
123+
bash_single_quote_escape(&crate::sanitize::neutralize_pipeline_commands(&repo));
127124
let step = format!(
128125
"- bash: |\n \
129126
# ado-aw-metadata: {metadata}\n \
@@ -164,7 +161,10 @@ mod tests {
164161
let fm = parse_fm("name: t\ndescription: x\n");
165162
let ctx = CompileContext::for_test(&fm);
166163
let steps = AdoAwMarkerExtension.prepare_steps(&ctx);
167-
assert!(steps.is_empty(), "expected no marker when input_path is None");
164+
assert!(
165+
steps.is_empty(),
166+
"expected no marker when input_path is None"
167+
);
168168
}
169169

170170
#[test]
@@ -184,14 +184,35 @@ mod tests {
184184
let steps = AdoAwMarkerExtension.prepare_steps(&ctx);
185185
assert_eq!(steps.len(), 1);
186186
let step = &steps[0];
187-
assert!(step.contains("displayName: \"ado-aw\""), "step missing displayName:\n{step}");
188-
assert!(step.contains("# ado-aw-metadata:"), "step missing JSON marker line:\n{step}");
189-
assert!(step.contains("\"source\":\"agents/foo.md\""), "step missing source field:\n{step}");
190-
assert!(step.contains("\"target\":\"standalone\""), "step missing target field:\n{step}");
191-
assert!(step.contains("\"schema\":1"), "step missing schema field:\n{step}");
187+
assert!(
188+
step.contains("displayName: \"ado-aw\""),
189+
"step missing displayName:\n{step}"
190+
);
191+
assert!(
192+
step.contains("# ado-aw-metadata:"),
193+
"step missing JSON marker line:\n{step}"
194+
);
195+
assert!(
196+
step.contains("\"source\":\"agents/foo.md\""),
197+
"step missing source field:\n{step}"
198+
);
199+
assert!(
200+
step.contains("\"target\":\"standalone\""),
201+
"step missing target field:\n{step}"
202+
);
203+
assert!(
204+
step.contains("\"schema\":1"),
205+
"step missing schema field:\n{step}"
206+
);
192207
// No ado_context => org/repo emit as empty strings.
193-
assert!(step.contains("\"org\":\"\""), "step missing org field:\n{step}");
194-
assert!(step.contains("\"repo\":\"\""), "step missing repo field:\n{step}");
208+
assert!(
209+
step.contains("\"org\":\"\""),
210+
"step missing org field:\n{step}"
211+
);
212+
assert!(
213+
step.contains("\"repo\":\"\""),
214+
"step missing repo field:\n{step}"
215+
);
195216
}
196217

197218
#[test]
@@ -382,9 +403,8 @@ mod tests {
382403
let parsed = crate::detect::parse_marker_step(&steps[0]);
383404
assert_eq!(parsed.len(), 1, "expected exactly one marker in step");
384405
assert_eq!(
385-
parsed[0].source,
386-
r#"agents/foo"bar.md"#,
406+
parsed[0].source, r#"agents/foo"bar.md"#,
387407
"marker source should round-trip without spurious backslash"
388408
);
389409
}
390-
}
410+
}

0 commit comments

Comments
 (0)