Skip to content

Commit d3c23c3

Browse files
authored
fix(secrets): address third-round Rust PR review feedback on #624
- `resolve_definitions_via_discovery`: normalize the user-supplied `--source` value through `normalize_source_path` before comparing against `marker.source`. The marker stores the canonical form (`agents/foo.md`), so without normalization a user typing `--source ./agents/foo.md` or `--source agents\foo.md` (Windows) silently matched nothing. Re-export `normalize_source_path` from `crate::compile` so callers outside the compile module tree can reach it cleanly. New test asserts the three common variants (canonical, leading-`./`, backslash) all produce the same normalized string. - `classify_definition` / `DiscoveryStatus::NotFound`: 404 from the Preview endpoint almost certainly means the definition was deleted between `list_definitions` and `preview_pipeline` (TOCTOU race). Previously routed through `PreviewFailed`, which inflated `skipped_failed` counts and confused operators. New `DiscoveryStatus::NotFound` variant is excluded from skip-warning counters in `resolve_definitions_via_discovery` and dropped by `discovered_to_matched` like the other non-actionable statuses. Debug-logged with the definition id+name so `--debug` users can still see what happened. - `DefinitionSummary::revision`: doc comment claimed Preview-driven discovery uses it as a cache key, but no caching is implemented. Rewrote to say it's deserialised for a future cache and there is *no* caching yet, with a clear "see the discovery module for current behaviour" pointer. - `DiscoveryScope::Explicit`: clarified the docstring to call out that no production callsite constructs this variant — `--definition-ids` uses the legacy `resolve_definitions` path before discovery ever runs. Variant is kept (not removed and not `#[cfg(test)]`-gated) because direct API consumers may want to feed pre-filtered IDs into discovery; the existing unit-test construction stays. - `secrets::resolve_for_command`: bail early with a targeted error when `--source` is used without `--all-repos` outside a recognised ADO repo. The previous behaviour was a generic "No ado-aw pipelines found via Preview-driven discovery" message that gave no hint that the empty result was caused by the missing git remote. New error spells out the cause and suggests `--all-repos`. All 1746 tests pass; clippy clean on touched files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent c762d9a commit d3c23c3

4 files changed

Lines changed: 135 additions & 6 deletions

File tree

src/ado/discovery.rs

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,15 @@ pub enum DiscoveryScope {
6060
/// Every definition in the project, regardless of repository.
6161
AllRepos,
6262
/// A pre-resolved list of definition IDs; bypasses listing and
63-
/// scope filtering entirely. Used by `--definition-ids` callers.
63+
/// scope filtering entirely.
64+
///
65+
/// **Reserved for future use.** No production callsite constructs
66+
/// this variant today — the `--definition-ids` CLI flag is handled
67+
/// by `crate::ado::resolve_definitions` (the legacy lexical
68+
/// matcher), which short-circuits before discovery is invoked. The
69+
/// variant exists so callers that want to feed an explicit ID list
70+
/// into the discovery pipeline (e.g. for future automation that
71+
/// has pre-filtered definitions) don't need a parallel API.
6472
Explicit(Vec<u64>),
6573
}
6674

@@ -83,6 +91,12 @@ pub enum DiscoveryStatus {
8391
/// Preview returned 403 — the calling identity lacks read access
8492
/// on the definition or one of its referenced repos.
8593
UnknownForbidden,
94+
/// Preview returned 404 — the definition disappeared between
95+
/// `list_definitions` and `preview_pipeline` (race with a
96+
/// concurrent delete). Tracked as a distinct status so it can be
97+
/// filtered out of the skip-warning counts: there's no operator
98+
/// action to take for a definition that no longer exists.
99+
NotFound,
86100
/// Preview returned some other error (5xx, network failure, etc.).
87101
PreviewFailed(String),
88102
/// Preview succeeded but no ado-aw marker was found in the
@@ -380,6 +394,25 @@ async fn classify_definition(
380394
markers: vec![],
381395
status: DiscoveryStatus::UnknownForbidden,
382396
},
397+
Err(PreviewError::NotFound) => {
398+
// Definition was deleted between `list_definitions` and the
399+
// Preview call (TOCTOU race with a concurrent delete).
400+
// Track as a distinct status so it's excluded from the
401+
// "Preview Failed" warning — there's no operator action to
402+
// take for a definition that no longer exists.
403+
debug!(
404+
"Definition {} ({}) disappeared between list and preview (404); skipping",
405+
def.id, def.name
406+
);
407+
DiscoveredPipeline {
408+
definition_id: def.id,
409+
definition_name: def.name,
410+
repository_url,
411+
queue_status: def.queue_status,
412+
markers: vec![],
413+
status: DiscoveryStatus::NotFound,
414+
}
415+
}
383416
Err(e) => DiscoveredPipeline {
384417
definition_id: def.id,
385418
definition_name: def.name,
@@ -479,6 +512,7 @@ pub fn discovered_to_matched(d: &DiscoveredPipeline) -> Option<MatchedDefinition
479512
match d.status {
480513
DiscoveryStatus::Direct | DiscoveryStatus::Consumer => {}
481514
DiscoveryStatus::NotAdoAw
515+
| DiscoveryStatus::NotFound
482516
| DiscoveryStatus::UnknownForbidden
483517
| DiscoveryStatus::UnknownRequiredParams
484518
| DiscoveryStatus::PreviewFailed(_) => return None,
@@ -538,10 +572,18 @@ pub async fn resolve_definitions_via_discovery(
538572
let mut skipped_failed = 0usize;
539573
let mut uninspectable = 0usize;
540574

575+
// Normalize the user-supplied `--source` value through the same
576+
// canonical form the compiler uses for the marker JSON's `source`
577+
// field. Without this, `--source ./agents/foo.md` or
578+
// `--source agents\foo.md` (Windows) silently matches nothing
579+
// because the marker stores `agents/foo.md`.
580+
let normalized_filter: Option<String> = source_filter
581+
.map(|s| crate::compile::normalize_source_path(Path::new(s)));
582+
541583
let kept: Vec<_> = discovered
542584
.into_iter()
543585
.filter(|d| {
544-
let matches_filter = match source_filter {
586+
let matches_filter = match normalized_filter.as_deref() {
545587
Some(src) => d.markers.iter().any(|m| m.source == src),
546588
None => true,
547589
};
@@ -552,7 +594,7 @@ pub async fn resolve_definitions_via_discovery(
552594
// on every ado-aw pipeline in scope);
553595
// - filtered: we can't attribute uninspectable definitions
554596
// to a specific source, so use a single combined counter.
555-
if source_filter.is_none() {
597+
if normalized_filter.is_none() {
556598
match &d.status {
557599
DiscoveryStatus::UnknownRequiredParams => skipped_required_params += 1,
558600
DiscoveryStatus::UnknownForbidden => skipped_forbidden += 1,
@@ -592,7 +634,7 @@ pub async fn resolve_definitions_via_discovery(
592634
);
593635
}
594636
if uninspectable > 0
595-
&& let Some(src) = source_filter
637+
&& let Some(src) = normalized_filter.as_deref()
596638
{
597639
warn!(
598640
"Discovery could not inspect {uninspectable} definition(s) (Preview failure, \
@@ -804,4 +846,73 @@ mod tests {
804846
assert!(PreviewError::Forbidden.to_string().contains("403"));
805847
assert!(PreviewError::NotFound.to_string().contains("404"));
806848
}
849+
850+
// ── source_filter normalization ──────────────────────────────────
851+
852+
#[test]
853+
fn source_filter_normalization_matches_marker_form() {
854+
// The marker stores normalized form (`agents/foo.md`). Verify
855+
// that the same normalization applied to user input produces
856+
// matchable strings for the common variants.
857+
use crate::compile::normalize_source_path;
858+
use std::path::Path;
859+
860+
let canonical = normalize_source_path(Path::new("agents/foo.md"));
861+
assert_eq!(canonical, "agents/foo.md");
862+
863+
// Leading `./` is stripped.
864+
assert_eq!(
865+
normalize_source_path(Path::new("./agents/foo.md")),
866+
canonical
867+
);
868+
869+
// Backslashes are normalized to forward slashes.
870+
assert_eq!(
871+
normalize_source_path(Path::new(r"agents\foo.md")),
872+
canonical
873+
);
874+
}
875+
876+
// ── discovered_to_matched ────────────────────────────────────────
877+
878+
fn discovered(status: DiscoveryStatus) -> DiscoveredPipeline {
879+
DiscoveredPipeline {
880+
definition_id: 42,
881+
definition_name: "test".to_string(),
882+
repository_url: None,
883+
queue_status: None,
884+
markers: vec![],
885+
status,
886+
}
887+
}
888+
889+
#[test]
890+
fn discovered_to_matched_drops_not_found() {
891+
// 404 from Preview (definition deleted in flight) must not
892+
// surface as a matched definition that a write command would
893+
// act on — there's nothing to act on.
894+
assert!(discovered_to_matched(&discovered(DiscoveryStatus::NotFound)).is_none());
895+
}
896+
897+
#[test]
898+
fn discovered_to_matched_drops_unactionable_statuses() {
899+
for status in [
900+
DiscoveryStatus::NotAdoAw,
901+
DiscoveryStatus::NotFound,
902+
DiscoveryStatus::UnknownForbidden,
903+
DiscoveryStatus::UnknownRequiredParams,
904+
DiscoveryStatus::PreviewFailed("boom".to_string()),
905+
] {
906+
assert!(
907+
discovered_to_matched(&discovered(status.clone())).is_none(),
908+
"expected {status:?} to map to None"
909+
);
910+
}
911+
}
912+
913+
#[test]
914+
fn discovered_to_matched_keeps_direct_and_consumer() {
915+
assert!(discovered_to_matched(&discovered(DiscoveryStatus::Direct)).is_some());
916+
assert!(discovered_to_matched(&discovered(DiscoveryStatus::Consumer)).is_some());
917+
}
807918
}

src/ado/mod.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,11 @@ pub struct DefinitionSummary {
218218
#[serde(default)]
219219
pub repository: Option<Repository>,
220220
/// Monotonic revision counter ADO bumps on every definition edit.
221-
/// Used as a cache key by Preview-driven discovery so an unchanged
222-
/// definition is only previewed once per session.
221+
/// Deserialised here so a future Preview-driven discovery cache
222+
/// can key on `(definition_id, revision)`. **No caching is
223+
/// implemented yet** — see the discovery module for the current
224+
/// in-process behaviour. Track in a follow-up before depending on
225+
/// this for staleness checks.
223226
#[serde(default)]
224227
pub revision: Option<u64>,
225228
}

src/compile/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub use common::parse_markdown;
3131
#[allow(unused_imports)]
3232
pub use common::{atomic_write, parse_markdown_detailed, reconstruct_source, ParsedSource};
3333
pub use common::HEADER_MARKER;
34+
pub use common::normalize_source_path;
3435
pub use common::ADO_MCP_ENTRYPOINT;
3536
pub use common::ADO_MCP_IMAGE;
3637
pub use common::ADO_MCP_PACKAGE;

src/secrets.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,20 @@ async fn resolve_for_command(
200200
// Discovery code path: activated by --all-repos or --source.
201201
// Explicit definition_ids always takes precedence (escape hatch).
202202
if definition_ids.is_none() && (all_repos || source_filter.is_some()) {
203+
// CurrentRepo scope without a resolvable git remote is a
204+
// user-friendly footgun: discovery would silently return zero
205+
// results. Surface a targeted hint *before* spending an HTTP
206+
// round-trip on a doomed listing.
207+
if !all_repos && ado_ctx.repo_name.is_empty() {
208+
anyhow::bail!(
209+
"--source filters by the current repository, but no Azure DevOps git remote \
210+
was detected in `{}`.\n\
211+
Either run from a checkout of an ADO repo, or pass --all-repos to search \
212+
the entire project.",
213+
repo_path.display()
214+
);
215+
}
216+
203217
let scope = if all_repos {
204218
DiscoveryScope::AllRepos
205219
} else {

0 commit comments

Comments
 (0)