Skip to content

Commit 6f817cd

Browse files
jamesadevineCopilot
andcommitted
fix(secrets): address fifth-round Rust PR review feedback on #624
Three concrete issues from the latest review pass: 1. `normalize_source_path` double-escaped `"` in the JSON marker. The helper escaped `"` -> `\"` before the path reached `serde_json::json!`, which then escaped the backslash again. The marker stored `"source":"agents/foo\\\"bar.md"` instead of the canonical `"source":"agents/foo\"bar.md"` — every round-trip carried a spurious backslash. Move the `"` -> `\"` escape into `generate_header_comment` (the only YAML-comment surface that needs it) and leave `normalize_source_path` returning the canonical form for the JSON marker and the `--source` filter. 2. `is_direct_match` false-positive for same-stem in different directories. The previous `yaml_normalized.ends_with("/{stem}")` branch would label an unrelated pipeline `Direct` when its `yamlFilename` happened to share the same trailing `<stem>.lock.yml` (e.g. marker `agents/foo.md` matched `other/agents/foo.lock.yml`). Both sides are already normalised to repo-root-relative paths without a leading slash, so strict equality is the correct check. Updated test name and added a regression test for the same-stem-different-directory case. 3. Documented case-sensitivity of `--source` in CLI help and the discovery module's doc comment so Windows users don't get silent zero-result runs from a case mismatch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 19d9aa8 commit 6f817cd

4 files changed

Lines changed: 104 additions & 18 deletions

File tree

src/ado/discovery.rs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,16 @@ fn is_direct_match(def: &DefinitionSummary, markers: &[MarkerMetadata]) -> bool
491491
// the definition's root YAML. Convention: `<stem>.md` compiles to
492492
// `<stem>.lock.yml`.
493493
//
494+
// Equality is required — an earlier version also accepted
495+
// `yaml_normalized.ends_with("/{stem}")` for a defensive
496+
// tail-match, but that produced false-positives when an unrelated
497+
// pipeline happened to live under a same-named lock file in a
498+
// different directory (e.g. marker `agents/foo.md` + yamlFilename
499+
// `other/agents/foo.lock.yml` would mislabel a Consumer as Direct).
500+
// Both `marker.source` and the post-`normalize_ado_yaml_path`
501+
// form of `yaml_filename` are repo-root-relative without a leading
502+
// slash, so strict equality is the correct check.
503+
//
494504
// Non-`.md` sources are treated conservatively as `Consumer`: this
495505
// branch is unreachable today (the compiler always emits `.md`
496506
// source paths) but stays defensive against future extensions that
@@ -506,7 +516,7 @@ fn is_direct_match(def: &DefinitionSummary, markers: &[MarkerMetadata]) -> bool
506516
else {
507517
return false;
508518
};
509-
yaml_normalized == stem || yaml_normalized.ends_with(&format!("/{stem}"))
519+
yaml_normalized == stem
510520
}
511521

512522
async fn parse_local_lock(path: &Path) -> Option<MarkerMetadata> {
@@ -597,6 +607,12 @@ pub fn discovered_to_matched(d: &DiscoveredPipeline) -> Option<MatchedDefinition
597607
/// `source_filter` filters discovery results so only definitions whose
598608
/// markers reference that source path are kept. Match is by exact
599609
/// equality on the normalized source string in the marker JSON.
610+
/// Normalisation is applied to the user-supplied value too
611+
/// ([`crate::compile::normalize_source_path`] — forward-slash separators,
612+
/// CR/LF stripping, leading `./` collapsed) so the common variants
613+
/// (`./agents/foo.md`, `agents\foo.md` on Windows) match. Matching is
614+
/// **case-sensitive** even on Windows; pass the path in the same case
615+
/// it was compiled with.
600616
///
601617
/// Skip-summary warnings are emitted differently depending on whether
602618
/// `source_filter` is active:
@@ -833,10 +849,10 @@ mod tests {
833849
}
834850

835851
#[test]
836-
fn direct_when_yaml_filename_has_extra_path_prefix() {
837-
// ADO sometimes stores yamlFilename with a project-relative
838-
// leading slash + extra path components. The marker source is
839-
// just the markdown path the user passed at compile time.
852+
fn direct_when_yaml_filename_has_leading_slash() {
853+
// ADO sometimes returns yamlFilename with a leading slash. The
854+
// `normalize_ado_yaml_path` helper strips it, so equality with
855+
// the derived `<stem>.lock.yml` still holds.
840856
let def = def_with(1, "a", Some("/agents/foo.lock.yml"), None);
841857
let markers = vec![MarkerMetadata {
842858
schema: 1,
@@ -847,6 +863,24 @@ mod tests {
847863
assert!(is_direct_match(&def, &markers));
848864
}
849865

866+
#[test]
867+
fn consumer_when_same_stem_in_different_directory() {
868+
// Regression: previously `yaml_normalized.ends_with("/{stem}")`
869+
// would mislabel a Consumer pipeline as Direct whenever a
870+
// same-named lock file lived under any unrelated prefix
871+
// (e.g. marker `agents/foo.md` + yamlFilename
872+
// `other/agents/foo.lock.yml`). The fix requires strict
873+
// equality after normalisation.
874+
let def = def_with(1, "a", Some("other/agents/foo.lock.yml"), None);
875+
let markers = vec![MarkerMetadata {
876+
schema: 1,
877+
source: "agents/foo.md".to_string(),
878+
version: "0.30.0".to_string(),
879+
target: "standalone".to_string(),
880+
}];
881+
assert!(!is_direct_match(&def, &markers));
882+
}
883+
850884
#[test]
851885
fn consumer_when_yaml_filename_does_not_match_marker() {
852886
let def = def_with(1, "a", Some("/release-readiness.yml"), None);

src/compile/common.rs

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,19 +1219,26 @@ pub const HEADER_MARKER: &str = "# @ado-aw";
12191219
/// The source path is the input path as provided to the compiler (e.g., `agents/my-agent.md`,
12201220
/// `.azdo/pipelines/review.md`, or any other location the user chose). Path separators
12211221
/// are normalized to forward slashes for cross-platform consistency.
1222-
/// Normalise a source markdown path for embedding in generated YAML.
1222+
/// Normalise a source markdown path into the canonical form embedded
1223+
/// in the `# ado-aw-metadata:` JSON marker.
12231224
///
1224-
/// Applies the same transformations the `# @ado-aw` comment header uses
1225-
/// (forward-slash separators, newline/CR stripping, `"` escaping, leading
1226-
/// `./` collapsed). Shared between [`generate_header_comment`] and the
1227-
/// always-on `ado-aw-marker` compiler extension so both surfaces agree on
1228-
/// the canonical form of a source path.
1225+
/// Applies forward-slash separator normalisation, strips CR/LF, and
1226+
/// collapses any leading `./`. Does **not** escape `"` — JSON encoding
1227+
/// is the caller's job (`serde_json::json!` handles it for the marker;
1228+
/// [`generate_header_comment`] escapes for its YAML comment surface).
1229+
///
1230+
/// Shared between the always-on `ado-aw-marker` compiler extension and
1231+
/// the `--source` filter in Preview-driven discovery so both sides
1232+
/// agree on the canonical form of a source path. Previously this also
1233+
/// escaped `"`, but that produced a literal `\"` inside the marker
1234+
/// JSON (serde_json then escaped the leading backslash on top, storing
1235+
/// a spurious extra backslash on every round-trip). The header
1236+
/// comment now applies its own escape inline.
12291237
pub fn normalize_source_path(input_path: &std::path::Path) -> String {
12301238
let mut source_path = input_path
12311239
.to_string_lossy()
12321240
.replace('\\', "/")
1233-
.replace(['\n', '\r'], "")
1234-
.replace('"', "\\\"");
1241+
.replace(['\n', '\r'], "");
12351242

12361243
// Strip redundant leading "./" prefixes to prevent accumulation when
12371244
// compile_all_pipelines re-joins paths through Path::new(".").join(source).
@@ -1244,7 +1251,11 @@ pub fn normalize_source_path(input_path: &std::path::Path) -> String {
12441251

12451252
pub fn generate_header_comment(input_path: &std::path::Path) -> String {
12461253
let version = env!("CARGO_PKG_VERSION");
1247-
let source_path = normalize_source_path(input_path);
1254+
// The header comment embeds the source inside double quotes
1255+
// (`source="…"`); escape `"` so legacy `parse_header_line` consumers
1256+
// can still recover the original. The JSON marker does not need
1257+
// this — serde_json escapes JSON-meaningful chars on its own.
1258+
let source_path = normalize_source_path(input_path).replace('"', "\\\"");
12481259

12491260
format!(
12501261
"# This file is auto-generated by ado-aw. Do not edit manually.\n\

src/compile/extensions/ado_aw_marker.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,4 +270,39 @@ mod tests {
270270
"expected `##vso[` neutralised via canonical backtick-wrap in echo line: {echo_line}"
271271
);
272272
}
273-
}
273+
274+
#[test]
275+
fn json_marker_quote_in_source_round_trips_correctly() {
276+
// Regression: `normalize_source_path` previously escaped `"` to
277+
// `\"` before embedding the path. `serde_json::json!` then
278+
// double-encoded the backslash, so the marker JSON looked like
279+
// `"source":"agents/foo\\\"bar.md"` — and the path returned by
280+
// `parse_marker_step` carried a spurious `\` that did not exist
281+
// in the original filename. The fix is to feed the canonical
282+
// (unescaped) path into the JSON value and let serde_json do
283+
// the JSON-level escaping.
284+
let fm = parse_fm("name: t\ndescription: x\n");
285+
let input_path = Path::new(r#"agents/foo"bar.md"#);
286+
let ctx = CompileContext {
287+
agent_name: &fm.name,
288+
front_matter: &fm,
289+
ado_context: None,
290+
engine: crate::engine::Engine::Copilot,
291+
compile_dir: None,
292+
input_path: Some(input_path),
293+
};
294+
let steps = AdoAwMarkerExtension.setup_steps(&ctx).unwrap();
295+
assert_eq!(steps.len(), 1);
296+
297+
// Parse the marker step back via the canonical discovery parser
298+
// and confirm the source field reconstructs to the original
299+
// path (forward-slash-normalised, no spurious backslashes).
300+
let parsed = crate::detect::parse_marker_step(&steps[0]);
301+
assert_eq!(parsed.len(), 1, "expected exactly one marker in step");
302+
assert_eq!(
303+
parsed[0].source,
304+
r#"agents/foo"bar.md"#,
305+
"marker source should round-trip without spurious backslash"
306+
);
307+
}
308+
}

src/main.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ enum SecretsCmd {
7272
/// ado-aw template (e.g. `agents/security-scan.md`). Activates
7373
/// the discovery code path. **Without `--all-repos`, only
7474
/// definitions in the current repository are searched** — pair
75-
/// with `--all-repos` to search the full project.
75+
/// with `--all-repos` to search the full project. Path matching
76+
/// is case-sensitive and forward-slash-normalised; on Windows,
77+
/// pass the path in the same case it was compiled with.
7678
#[arg(long, conflicts_with = "definition_ids")]
7779
source: Option<String>,
7880
},
@@ -95,7 +97,9 @@ enum SecretsCmd {
9597
all_repos: bool,
9698
/// Filter discovered definitions to consumers of one specific
9799
/// ado-aw template. **Without `--all-repos`, only definitions
98-
/// in the current repository are searched.**
100+
/// in the current repository are searched.** Path matching is
101+
/// case-sensitive and forward-slash-normalised; on Windows,
102+
/// pass the path in the same case it was compiled with.
99103
#[arg(long, conflicts_with = "definition_ids")]
100104
source: Option<String>,
101105
},
@@ -119,7 +123,9 @@ enum SecretsCmd {
119123
all_repos: bool,
120124
/// Filter discovered definitions to consumers of one specific
121125
/// ado-aw template. **Without `--all-repos`, only definitions
122-
/// in the current repository are searched.**
126+
/// in the current repository are searched.** Path matching is
127+
/// case-sensitive and forward-slash-normalised; on Windows,
128+
/// pass the path in the same case it was compiled with.
123129
#[arg(long, conflicts_with = "definition_ids")]
124130
source: Option<String>,
125131
},

0 commit comments

Comments
 (0)