Skip to content

Commit 2795274

Browse files
jamesadevineCopilot
andcommitted
fix(compile): address fourth-round PR review on codemod refactor
Two findings + three suggestions from the latest Rust PR Reviewer pass: 1. Stage-3 typed-deserialize errors after codemods reused the same "Failed to parse YAML front matter" context as the Stage-1 raw parse, so a codemod that produced an invalid shape was indistinguishable from a user typo. Switch to with_context that surfaces the applied codemod ids when codemods ran: "Failed to parse YAML front matter after applying codemods (id1, id2); a codemod likely produced an invalid shape". 2. rename_key with ConflictPolicy::PreferNew returns Ok(true) when it drops a stale `old` key even though `new` is unchanged. That triggers a source-rewrite warning saying "we migrated" when really we "cleaned up a remnant". The behavior is correct (the mapping did mutate); the doc was misleading. Rewrote the rename_key doc comment to enumerate exactly when Ok(true) fires and call out the PreferNew/cleanup case. 3. Added `// TODO(codemods): remove when the first real codemod is registered.` markers next to every #[allow(dead_code)] / #[allow(unused_imports)] in src/compile/codemods/{mod,helpers}.rs so search-based cleanup is trivial when the registry stops being empty. 4. tests/codemod_tests.rs leaked temp dirs on assertion failure because `let _ = fs::remove_dir_all(&dir)` only ran on the happy path. Refactored to return tempfile::TempDir from the helpers and rely on RAII cleanup, matching the pattern already used by src/compile/codemod_integration_test.rs. Skipped: the empty-mapping concern in reconstruct_source (a mapping reduced to empty wouldn't deserialize as FrontMatter anyway because of required name/description fields, so the existing typed-deserialize error path catches it; #1 makes that error informative). All 1349 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5b6e265 commit 2795274

4 files changed

Lines changed: 60 additions & 52 deletions

File tree

src/compile/codemods/helpers.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use serde_yaml::{Mapping, Value};
99

1010
/// Conflict policy used by [`rename_key`] when the destination key is
1111
/// already present.
12+
// TODO(codemods): remove when the first real codemod is registered.
1213
#[allow(dead_code)]
1314
#[derive(Debug, Clone, Copy)]
1415
pub enum ConflictPolicy {
@@ -21,6 +22,7 @@ pub enum ConflictPolicy {
2122
}
2223

2324
/// Remove and return the value at `key`, or `None` if absent.
25+
// TODO(codemods): remove when the first real codemod is registered.
2426
#[allow(dead_code)]
2527
pub fn take_key(m: &mut Mapping, key: &str) -> Option<Value> {
2628
m.remove(Value::String(key.to_string()))
@@ -30,6 +32,7 @@ pub fn take_key(m: &mut Mapping, key: &str) -> Option<Value> {
3032
///
3133
/// Prefer this over `Mapping::insert` in migrations: silent overwrite is
3234
/// almost always a bug when transforming user data.
35+
// TODO(codemods): remove when the first real codemod is registered.
3336
#[allow(dead_code)]
3437
pub fn insert_no_overwrite(
3538
m: &mut Mapping,
@@ -48,13 +51,24 @@ pub fn insert_no_overwrite(
4851

4952
/// Rename `old` → `new` according to `policy`.
5053
///
51-
/// Returns `Ok(true)` when the rename happened (regardless of policy
52-
/// branch), `Ok(false)` when `old` was absent (no-op).
54+
/// Returns `Ok(true)` when the mapping was mutated in any way:
55+
///
56+
/// - the `old` key was moved to `new` (the typical rename), OR
57+
/// - both keys were present with [`ConflictPolicy::PreferOld`] and
58+
/// `new` was overwritten, OR
59+
/// - both keys were present with [`ConflictPolicy::PreferNew`] and
60+
/// the stale `old` key was dropped (note: the `new` value did not
61+
/// change, but the mapping still mutated — codemod authors using
62+
/// `PreferNew` should be aware that `Ok(true)` here means
63+
/// "cleaned up a remnant," not "migrated semantic content").
64+
///
65+
/// Returns `Ok(false)` when `old` was absent (no-op).
5366
///
5467
/// The mapping is left **unchanged** on the error path. Callers can
5568
/// rely on this invariant when chaining migrations: a failed rename
5669
/// won't leave the mapping in a half-mutated state for the next call
5770
/// to inspect.
71+
// TODO(codemods): remove when the first real codemod is registered.
5872
#[allow(dead_code)]
5973
pub fn rename_key(
6074
m: &mut Mapping,

src/compile/codemods/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ mod helpers;
4040
// and uses one of these helpers, both the re-export attribute and
4141
// the per-item `dead_code` allows on `helpers.rs` should be
4242
// removed.
43+
// TODO(codemods): remove when the first real codemod is registered.
4344
#[allow(unused_imports)]
4445
pub use helpers::{insert_no_overwrite, rename_key, take_key, ConflictPolicy};
4546

@@ -73,6 +74,7 @@ pub struct Codemod {
7374
pub summary: &'static str,
7475
/// Compiler version that introduced this codemod (e.g. "0.27.0").
7576
/// Provenance only; not consumed by the runner.
77+
// TODO(codemods): remove when the first real codemod is registered.
7678
#[allow(dead_code)]
7779
pub introduced_in: &'static str,
7880
/// The transformation. Returns `Ok(true)` when the codemod
@@ -107,6 +109,7 @@ pub struct AppliedCodemod {
107109

108110
impl CodemodReport {
109111
/// An empty report (no codemod fired).
112+
// TODO(codemods): remove when the first real codemod is registered.
110113
#[allow(dead_code)]
111114
pub fn empty() -> Self {
112115
Self {
@@ -120,6 +123,7 @@ impl CodemodReport {
120123
}
121124

122125
/// IDs of codemods that ran, in order. Helpful for tests.
126+
// TODO(codemods): remove when the first real codemod is registered.
123127
#[allow(dead_code)]
124128
pub fn applied_ids(&self) -> Vec<&'static str> {
125129
self.applied.iter().map(|a| a.id).collect()
@@ -130,6 +134,7 @@ impl CodemodReport {
130134
///
131135
/// Equivalent to [`apply_codemods_with`] called with the global
132136
/// [`CODEMODS`] registry.
137+
// TODO(codemods): remove when the first real codemod is registered.
133138
#[allow(dead_code)]
134139
pub fn apply_codemods(fm: &mut Mapping) -> Result<CodemodReport> {
135140
apply_codemods_with(fm, CODEMODS)

src/compile/common.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,23 @@ pub(crate) fn parse_markdown_detailed_with_registry(
182182

183183
// Stage 3: deserialize the (possibly modified) mapping into the
184184
// typed FrontMatter. Errors here mean either the user wrote an
185-
// unsupported shape or a codemod produced invalid output.
186-
let front_matter: FrontMatter =
187-
serde_yaml::from_value(serde_yaml::Value::Mapping(mapping.clone()))
188-
.context("Failed to parse YAML front matter")?;
185+
// unsupported shape or a codemod produced invalid output. The
186+
// error context differs by case so the user can tell which.
187+
let front_matter: FrontMatter = serde_yaml::from_value(
188+
serde_yaml::Value::Mapping(mapping.clone()),
189+
)
190+
.with_context(|| {
191+
if report.changed() {
192+
let ids = report.applied_ids().join(", ");
193+
format!(
194+
"Failed to parse YAML front matter after applying codemods ({}); \
195+
a codemod likely produced an invalid shape",
196+
ids
197+
)
198+
} else {
199+
"Failed to parse YAML front matter".to_string()
200+
}
201+
})?;
189202

190203
Ok(ParsedSource {
191204
front_matter,

tests/codemod_tests.rs

Lines changed: 22 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -17,69 +17,51 @@
1717
//! - The full `compile` -> `check` round-trip succeeds.
1818
1919
use std::fs;
20-
use std::path::PathBuf;
20+
use std::path::{Path, PathBuf};
2121
use std::process::Command;
22-
23-
/// Set up a unique temp directory for each test run.
24-
fn fresh_temp_dir(label: &str) -> PathBuf {
25-
let dir = std::env::temp_dir().join(format!(
26-
"ado-aw-codemod-tests-{}-{}-{}",
27-
label,
28-
std::process::id(),
29-
rand_suffix(),
30-
));
31-
fs::create_dir_all(&dir).expect("create temp dir");
32-
dir
22+
use tempfile::TempDir;
23+
24+
/// Set up a unique temp directory for each test run. Returned as a
25+
/// `TempDir` so RAII cleans the directory up even if a test panics.
26+
fn fresh_temp_dir() -> TempDir {
27+
tempfile::Builder::new()
28+
.prefix("ado-aw-codemod-tests-")
29+
.tempdir()
30+
.expect("create temp dir")
3331
}
3432

3533
/// Same as [`fresh_temp_dir`] but also creates an empty `.git/`
3634
/// directory at the root so `ado-aw check` (which walks up to the
3735
/// repo root) can resolve a source path from the compiled lock
3836
/// file's `@ado-aw` header.
39-
fn fresh_git_temp_dir(label: &str) -> PathBuf {
40-
let dir = fresh_temp_dir(label);
41-
fs::create_dir(dir.join(".git")).expect("create .git dir");
37+
fn fresh_git_temp_dir() -> TempDir {
38+
let dir = fresh_temp_dir();
39+
fs::create_dir(dir.path().join(".git")).expect("create .git dir");
4240
dir
4341
}
4442

45-
/// Suffix that's unique within the process for the lifetime of a
46-
/// single test binary run. Uses a wall-clock nanosecond timestamp
47-
/// combined with a monotonic atomic counter so two parallel tests
48-
/// scheduled in the same nanosecond still get distinct directories.
49-
fn rand_suffix() -> String {
50-
use std::sync::atomic::{AtomicU64, Ordering};
51-
use std::time::{SystemTime, UNIX_EPOCH};
52-
static SEQ: AtomicU64 = AtomicU64::new(0);
53-
let nanos = SystemTime::now()
54-
.duration_since(UNIX_EPOCH)
55-
.map(|d| d.as_nanos())
56-
.unwrap_or(0);
57-
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
58-
format!("{:x}-{:x}", nanos, seq)
59-
}
60-
6143
fn ado_aw_binary() -> PathBuf {
6244
PathBuf::from(env!("CARGO_BIN_EXE_ado-aw"))
6345
}
6446

6547
/// Run `ado-aw compile <source>`, returning the captured output.
66-
fn run_compile(source: &PathBuf) -> std::process::Output {
48+
fn run_compile(source: &Path) -> std::process::Output {
6749
Command::new(ado_aw_binary())
6850
.args(["compile", source.to_str().unwrap()])
6951
.output()
7052
.expect("Failed to run ado-aw compile")
7153
}
7254

7355
/// Run `ado-aw check <pipeline>`, returning the captured output.
74-
fn run_check(pipeline: &PathBuf) -> std::process::Output {
56+
fn run_check(pipeline: &Path) -> std::process::Output {
7557
Command::new(ado_aw_binary())
7658
.args(["check", pipeline.to_str().unwrap()])
7759
.output()
7860
.expect("Failed to run ado-aw check")
7961
}
8062

8163
/// Write a source file to `dir/agent.md` and return its path.
82-
fn write_source(dir: &PathBuf, content: &str) -> PathBuf {
64+
fn write_source(dir: &Path, content: &str) -> PathBuf {
8365
let path = dir.join("agent.md");
8466
fs::write(&path, content).expect("write source");
8567
path
@@ -89,9 +71,9 @@ fn write_source(dir: &PathBuf, content: &str) -> PathBuf {
8971

9072
#[test]
9173
fn compile_succeeds_on_current_source() {
92-
let dir = fresh_temp_dir("current-source");
74+
let dir = fresh_temp_dir();
9375
let original = "---\nname: smoketest\ndescription: smoketest description\n---\n## Body\n\nHello.\n";
94-
let source = write_source(&dir, original);
76+
let source = write_source(dir.path(), original);
9577

9678
let output = run_compile(&source);
9779

@@ -125,15 +107,13 @@ fn compile_succeeds_on_current_source() {
125107
"no codemod warning expected, got stderr: {}",
126108
stderr
127109
);
128-
129-
let _ = fs::remove_dir_all(&dir);
130110
}
131111

132112
#[test]
133113
fn compile_then_check_round_trip_passes() {
134-
let dir = fresh_git_temp_dir("round-trip");
114+
let dir = fresh_git_temp_dir();
135115
let source = write_source(
136-
&dir,
116+
dir.path(),
137117
"---\nname: round-trip-agent\ndescription: round-trip\n---\n## Body\n",
138118
);
139119

@@ -154,16 +134,14 @@ fn compile_then_check_round_trip_passes() {
154134
String::from_utf8_lossy(&check_output.stdout),
155135
String::from_utf8_lossy(&check_output.stderr)
156136
);
157-
158-
let _ = fs::remove_dir_all(&dir);
159137
}
160138

161139
// ─── Non-mapping front matter ──────────────────────────────────────────────
162140

163141
#[test]
164142
fn compile_rejects_non_mapping_top_level_yaml() {
165-
let dir = fresh_temp_dir("non-mapping");
166-
let source = write_source(&dir, "---\n- a\n- b\n---\nbody\n");
143+
let dir = fresh_temp_dir();
144+
let source = write_source(dir.path(), "---\n- a\n- b\n---\nbody\n");
167145

168146
let output = run_compile(&source);
169147

@@ -177,6 +155,4 @@ fn compile_rejects_non_mapping_top_level_yaml() {
177155
"stderr should report non-mapping error, got: {}",
178156
stderr
179157
);
180-
181-
let _ = fs::remove_dir_all(&dir);
182158
}

0 commit comments

Comments
 (0)