Skip to main content

agentstatedeveloper_core/
recipes.rs

1//! Plan C t-004: change-intent recipes.
2//!
3//! Recipes are structured outputs that replace raw symbol lists for known
4//! task families. Where a query like "migrate stale tests" today returns
5//! a flat list of candidate symbols that the LLM has to classify itself,
6//! a recipe returns a per-file action plan (move / delete / run / gate /
7//! keep-as-covered) with a one-line reason for each action.
8//!
9//! The schema is intentionally minimal so additional recipes (Plan C+)
10//! can land without re-litigating the shape.
11
12use serde::Serialize;
13
14use crate::engine::Engine;
15use crate::index::{AsgIndexStore, IndexStore};
16use crate::ledger::{AsgLedgerStore, LedgerStore};
17use crate::schema::{LedgerKind, RoleTag};
18use crate::search_fts::symbol_tier;
19
20#[derive(Debug, Clone, Serialize)]
21pub struct Recipe {
22    pub intent: String,
23    pub query: String,
24    pub actions: Vec<RecipeAction>,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct RecipeAction {
29    pub kind: ActionKind,
30    pub file: String,
31    pub qname: String,
32    pub reason: String,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub command: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub role: Option<String>,
37}
38
39/// Action kinds emitted by recipes. Each value corresponds to a branch
40/// in some recipe's `pick_action`-style decision tree. Variants are added
41/// only when at least one decision tree actually emits them.
42///
43/// Plan F t-002: `Move` re-added for the `migrate_stale_tests` recipe,
44/// which reads `move_to` out of a Mapping entry's body JSON to specify
45/// the destination path. Emitted only by that recipe, not by
46/// `classify_test_migration`.
47#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
48#[serde(rename_all = "snake_case")]
49pub enum ActionKind {
50    Move,
51    Delete,
52    Run,
53    Gate,
54    KeepAsCovered,
55    Review,
56}
57
58/// First concrete recipe: classify the test-tier symbols matching a
59/// query by their migration story. Walks test files in scope, reads the
60/// ledger for each symbol, and picks an `ActionKind` per the role tags
61/// + Mapping entries already in the ledger.
62///
63/// Decision tree (first match wins, per symbol):
64/// 1. `Mapping` entry → `KeepAsCovered`
65/// 2. `Constraint`/`Decision` with role=stale-api → `Delete`
66/// 3. `Ownership`/`Concept` with role=diagnostic-test → `Gate`
67/// 4. `Ownership`/`Concept` with role=fast-test + command → `Run`
68/// 5. Default → `Review`
69pub fn classify_test_migration(
70    engine: &Engine,
71    index_store: &AsgIndexStore,
72    candidate_qnames: &[String],
73    query: &str,
74) -> Recipe {
75    let ledger_store = AsgLedgerStore::from_engine(engine);
76    let mut actions: Vec<RecipeAction> = Vec::new();
77
78    for qname in candidate_qnames {
79        let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
80            Ok(Some(s)) => s,
81            _ => continue,
82        };
83        if symbol_tier(&sym.file) != 2 {
84            continue;
85        }
86        let entries = ledger_store
87            .list_entries(&engine.ref_name, &sym.symbol_id)
88            .unwrap_or_default();
89
90        actions.push(pick_action(&entries, sym.file.clone(), sym.qname.clone()));
91    }
92
93    actions.sort_by_key(|a| action_sort_key(a.kind));
94
95    Recipe {
96        intent: "classify-test-migration".to_string(),
97        query: query.to_string(),
98        actions,
99    }
100}
101
102fn pick_action(
103    entries: &[crate::schema::LedgerEntry],
104    file: String,
105    qname: String,
106) -> RecipeAction {
107    for entry in entries {
108        if matches!(entry.kind, LedgerKind::Mapping) {
109            return RecipeAction {
110                kind: ActionKind::KeepAsCovered,
111                file,
112                qname,
113                reason: format!("covered by mapping: {}", entry.summary),
114                command: entry.command.clone(),
115                role: entry.role.clone(),
116            };
117        }
118    }
119    for entry in entries {
120        if !matches!(entry.kind, LedgerKind::Constraint | LedgerKind::Decision) {
121            continue;
122        }
123        if entry.role.as_deref().and_then(RoleTag::from_str) == Some(RoleTag::StaleApi) {
124            return RecipeAction {
125                kind: ActionKind::Delete,
126                file,
127                qname,
128                reason: format!("stale-api per ledger: {}", entry.summary),
129                command: entry.command.clone(),
130                role: entry.role.clone(),
131            };
132        }
133    }
134    for entry in entries {
135        if !matches!(entry.kind, LedgerKind::Ownership | LedgerKind::Concept) {
136            continue;
137        }
138        if entry.role.as_deref().and_then(RoleTag::from_str) == Some(RoleTag::DiagnosticTest) {
139            return RecipeAction {
140                kind: ActionKind::Gate,
141                file,
142                qname,
143                reason: format!("diagnostic-only per classification: {}", entry.summary),
144                command: entry.command.clone(),
145                role: entry.role.clone(),
146            };
147        }
148    }
149    for entry in entries {
150        if !matches!(entry.kind, LedgerKind::Ownership | LedgerKind::Concept) {
151            continue;
152        }
153        if entry.role.as_deref().and_then(RoleTag::from_str) == Some(RoleTag::FastTest)
154            && entry.command.is_some()
155        {
156            return RecipeAction {
157                kind: ActionKind::Run,
158                file,
159                qname,
160                reason: "fast-test with reproduction command".to_string(),
161                command: entry.command.clone(),
162                role: entry.role.clone(),
163            };
164        }
165    }
166    RecipeAction {
167        kind: ActionKind::Review,
168        file,
169        qname,
170        reason: "no role-tagged ledger entry; needs human classification".to_string(),
171        command: None,
172        role: None,
173    }
174}
175
176/// Plan F t-002 — `migrate-stale-tests` recipe. Extends
177/// `classify_test_migration` with a Move action: when a Mapping entry's
178/// body carries `move_to: "<destination/path>"`, the test file is
179/// emitted as Move with that path in `command`. Otherwise falls back to
180/// the same decision tree as classify_test_migration so callers get one
181/// unified plan.
182///
183/// Body schema for Mapping entries this recipe consumes:
184/// ```json
185/// {"from_qname": "pkg.legacy_test", "to_qname": "pkg.modern_test",
186///  "move_to": "tests/modern/foo_test.py"}
187/// ```
188/// Plain `to_qname` (no `move_to`) keeps the t-004 KeepAsCovered behavior.
189pub fn migrate_stale_tests(
190    engine: &Engine,
191    index_store: &AsgIndexStore,
192    candidate_qnames: &[String],
193    query: &str,
194) -> Recipe {
195    let ledger_store = AsgLedgerStore::from_engine(engine);
196    let mut actions: Vec<RecipeAction> = Vec::new();
197
198    for qname in candidate_qnames {
199        let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
200            Ok(Some(s)) => s,
201            _ => continue,
202        };
203        if symbol_tier(&sym.file) != 2 {
204            continue;
205        }
206        let entries = ledger_store
207            .list_entries(&engine.ref_name, &sym.symbol_id)
208            .unwrap_or_default();
209
210        // First match wins. Move takes precedence over KeepAsCovered
211        // when a Mapping entry has both — the move_to is the operational
212        // instruction; the to_qname is just provenance.
213        if let Some(move_to) = entries.iter().find_map(|e| {
214            if !matches!(e.kind, LedgerKind::Mapping) {
215                return None;
216            }
217            mapping_move_to(e.body.as_deref())
218        }) {
219            actions.push(RecipeAction {
220                kind: ActionKind::Move,
221                file: sym.file.clone(),
222                qname: sym.qname.clone(),
223                reason: format!("mapping says move to {move_to}"),
224                command: Some(move_to),
225                role: None,
226            });
227            continue;
228        }
229        // Otherwise reuse the classify_test_migration decision tree —
230        // single source of truth for the remaining branches.
231        actions.push(pick_action(&entries, sym.file.clone(), sym.qname.clone()));
232    }
233
234    actions.sort_by_key(|a| action_sort_key(a.kind));
235
236    Recipe {
237        intent: "migrate-stale-tests".to_string(),
238        query: query.to_string(),
239        actions,
240    }
241}
242
243/// Parse `move_to` out of a Mapping entry's body JSON. Returns None on
244/// missing body, non-JSON, or missing move_to field — caller falls back
245/// to the KeepAsCovered branch.
246fn mapping_move_to(body: Option<&str>) -> Option<String> {
247    let raw = body?;
248    let v: serde_json::Value = serde_json::from_str(raw).ok()?;
249    v.get("move_to")?.as_str().map(|s| s.to_string())
250}
251
252fn action_sort_key(kind: ActionKind) -> u8 {
253    match kind {
254        ActionKind::Delete => 0,
255        ActionKind::Move => 1,
256        ActionKind::Gate => 2,
257        ActionKind::Run => 3,
258        ActionKind::KeepAsCovered => 4,
259        ActionKind::Review => 5,
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::schema::{Author, AuthorKind, LedgerEntry, Position, Symbol, SymbolKind};
267
268    fn engine_with_symbol(qname: &str, file: &str) -> (Engine, String) {
269        let engine = Engine::open_in_memory().unwrap();
270        let index = AsgIndexStore::from_engine(&engine);
271        let sym = Symbol {
272            symbol_id: format!("sym_{}", qname.replace('.', "_")),
273            symbol_fp: "fp".into(),
274            qname: qname.to_string(),
275            language: "python".into(),
276            kind: SymbolKind::Function,
277            file: file.to_string(),
278            start: Position { line: 1, col: 0 },
279            end: Position { line: 5, col: 0 },
280            signature: None,
281            doc: None,
282        };
283        index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
284        (engine, sym.symbol_id)
285    }
286
287    fn append(
288        engine: &Engine,
289        sym_id: &str,
290        kind: LedgerKind,
291        role: Option<&str>,
292        command: Option<&str>,
293    ) {
294        let ledger = AsgLedgerStore::from_engine(engine);
295        let mut entry = LedgerEntry::new(
296            sym_id,
297            kind,
298            "test entry",
299            Author {
300                kind: AuthorKind::Agent,
301                id: "test".into(),
302            },
303        );
304        entry.role = role.map(str::to_string);
305        entry.command = command.map(str::to_string);
306        ledger
307            .append_entry(&engine.ref_name, &entry, "test")
308            .unwrap();
309    }
310
311    #[test]
312    fn stale_api_constraint_yields_delete() {
313        let (engine, sid) = engine_with_symbol("pkg.tests.legacy_test", "tests/legacy_test.py");
314        append(
315            &engine,
316            &sid,
317            LedgerKind::Constraint,
318            Some("stale-api"),
319            None,
320        );
321        let index = AsgIndexStore::from_engine(&engine);
322        let recipe = classify_test_migration(
323            &engine,
324            &index,
325            &["pkg.tests.legacy_test".to_string()],
326            "migrate stale tests",
327        );
328        assert_eq!(recipe.actions.len(), 1);
329        assert_eq!(recipe.actions[0].kind, ActionKind::Delete);
330    }
331
332    #[test]
333    fn mapping_yields_keep_as_covered() {
334        let (engine, sid) = engine_with_symbol("pkg.tests.covered_test", "tests/covered_test.py");
335        append(&engine, &sid, LedgerKind::Mapping, None, None);
336        let index = AsgIndexStore::from_engine(&engine);
337        let recipe = classify_test_migration(
338            &engine,
339            &index,
340            &["pkg.tests.covered_test".to_string()],
341            "migrate tests",
342        );
343        assert_eq!(recipe.actions[0].kind, ActionKind::KeepAsCovered);
344    }
345
346    #[test]
347    fn diagnostic_classification_yields_gate() {
348        let (engine, sid) =
349            engine_with_symbol("pkg.tests.real_file_test", "tests/real_file_test.py");
350        append(
351            &engine,
352            &sid,
353            LedgerKind::Ownership,
354            Some("diagnostic-test"),
355            None,
356        );
357        let index = AsgIndexStore::from_engine(&engine);
358        let recipe = classify_test_migration(
359            &engine,
360            &index,
361            &["pkg.tests.real_file_test".to_string()],
362            "migrate tests",
363        );
364        assert_eq!(recipe.actions[0].kind, ActionKind::Gate);
365    }
366
367    #[test]
368    fn fast_test_classification_with_command_yields_run() {
369        let (engine, sid) = engine_with_symbol("pkg.tests.fast_test", "tests/fast_test.py");
370        append(
371            &engine,
372            &sid,
373            LedgerKind::Ownership,
374            Some("fast-test"),
375            Some("swift test --filter FastTest"),
376        );
377        let index = AsgIndexStore::from_engine(&engine);
378        let recipe = classify_test_migration(
379            &engine,
380            &index,
381            &["pkg.tests.fast_test".to_string()],
382            "migrate tests",
383        );
384        assert_eq!(recipe.actions[0].kind, ActionKind::Run);
385        assert_eq!(
386            recipe.actions[0].command.as_deref(),
387            Some("swift test --filter FastTest")
388        );
389    }
390
391    #[test]
392    fn no_ledger_evidence_falls_to_review() {
393        let (engine, _) = engine_with_symbol("pkg.tests.bare_test", "tests/bare_test.py");
394        let index = AsgIndexStore::from_engine(&engine);
395        let recipe = classify_test_migration(
396            &engine,
397            &index,
398            &["pkg.tests.bare_test".to_string()],
399            "migrate tests",
400        );
401        assert_eq!(recipe.actions[0].kind, ActionKind::Review);
402    }
403
404    #[test]
405    fn non_test_tier_symbols_are_skipped() {
406        let (engine, sid) = engine_with_symbol("pkg.module.production_fn", "src/pkg/module.py");
407        append(&engine, &sid, LedgerKind::Mapping, None, None);
408        let index = AsgIndexStore::from_engine(&engine);
409        let recipe = classify_test_migration(
410            &engine,
411            &index,
412            &["pkg.module.production_fn".to_string()],
413            "migrate tests",
414        );
415        assert_eq!(
416            recipe.actions.len(),
417            0,
418            "production-tier symbols must not appear in a test-migration recipe"
419        );
420    }
421
422    // -- Plan F t-002: migrate_stale_tests recipe ---------------------------
423
424    fn append_mapping_with_body(engine: &Engine, sym_id: &str, body: &str) {
425        let ledger = AsgLedgerStore::from_engine(engine);
426        let mut entry = LedgerEntry::new(
427            sym_id,
428            LedgerKind::Mapping,
429            "mapping with body",
430            Author {
431                kind: AuthorKind::Agent,
432                id: "test".into(),
433            },
434        );
435        entry.body = Some(body.to_string());
436        ledger
437            .append_entry(&engine.ref_name, &entry, "test")
438            .unwrap();
439    }
440
441    #[test]
442    fn mapping_move_to_parses_body_field() {
443        assert_eq!(
444            super::mapping_move_to(Some(r#"{"move_to":"tests/modern/foo.py"}"#)),
445            Some("tests/modern/foo.py".to_string())
446        );
447    }
448
449    #[test]
450    fn mapping_move_to_returns_none_when_missing() {
451        assert_eq!(super::mapping_move_to(None), None);
452        assert_eq!(super::mapping_move_to(Some("plain text")), None);
453        assert_eq!(super::mapping_move_to(Some(r#"{"to_qname":"x"}"#)), None);
454    }
455
456    #[test]
457    fn migrate_stale_tests_emits_move_when_mapping_has_move_to() {
458        let (engine, sid) = engine_with_symbol("pkg.tests.legacy_test", "tests/legacy_test.py");
459        append_mapping_with_body(
460            &engine,
461            &sid,
462            r#"{"from_qname":"pkg.tests.legacy_test","to_qname":"pkg.tests.modern_test","move_to":"tests/modern/foo_test.py"}"#,
463        );
464        let index = AsgIndexStore::from_engine(&engine);
465        let recipe = super::migrate_stale_tests(
466            &engine,
467            &index,
468            &["pkg.tests.legacy_test".to_string()],
469            "migrate stale tests",
470        );
471        assert_eq!(recipe.intent, "migrate-stale-tests");
472        assert_eq!(recipe.actions.len(), 1);
473        assert_eq!(recipe.actions[0].kind, ActionKind::Move);
474        assert_eq!(
475            recipe.actions[0].command.as_deref(),
476            Some("tests/modern/foo_test.py")
477        );
478    }
479
480    #[test]
481    fn migrate_stale_tests_falls_back_to_classify_decision_tree() {
482        // A Mapping entry without move_to should fall back to
483        // KeepAsCovered, matching classify_test_migration.
484        let (engine, sid) = engine_with_symbol("pkg.tests.covered_test", "tests/covered_test.py");
485        append_mapping_with_body(
486            &engine,
487            &sid,
488            r#"{"from_qname":"pkg.tests.covered_test","to_qname":"pkg.tests.new"}"#,
489        );
490        let index = AsgIndexStore::from_engine(&engine);
491        let recipe = super::migrate_stale_tests(
492            &engine,
493            &index,
494            &["pkg.tests.covered_test".to_string()],
495            "migrate stale tests",
496        );
497        assert_eq!(recipe.actions[0].kind, ActionKind::KeepAsCovered);
498    }
499
500    #[test]
501    fn migrate_stale_tests_routes_stale_api_constraint_to_delete() {
502        // Inherits the Delete branch from pick_action.
503        let (engine, sid) = engine_with_symbol("pkg.tests.legacy", "tests/legacy_test.py");
504        append(
505            &engine,
506            &sid,
507            LedgerKind::Constraint,
508            Some("stale-api"),
509            None,
510        );
511        let index = AsgIndexStore::from_engine(&engine);
512        let recipe = super::migrate_stale_tests(
513            &engine,
514            &index,
515            &["pkg.tests.legacy".to_string()],
516            "migrate stale tests",
517        );
518        assert_eq!(recipe.actions[0].kind, ActionKind::Delete);
519    }
520
521    #[test]
522    fn migrate_stale_tests_skips_non_test_tier() {
523        let (engine, sid) = engine_with_symbol("pkg.module.production_fn", "src/pkg/module.py");
524        append_mapping_with_body(&engine, &sid, r#"{"move_to":"src/new.py"}"#);
525        let index = AsgIndexStore::from_engine(&engine);
526        let recipe = super::migrate_stale_tests(
527            &engine,
528            &index,
529            &["pkg.module.production_fn".to_string()],
530            "migrate",
531        );
532        assert_eq!(recipe.actions.len(), 0);
533    }
534}