Skip to main content

agentstatedeveloper_cli/commands/
annotate_commit.rs

1//! `asd annotate-commit [sha] [--write]` — derive ledger annotations from a commit.
2//!
3//! Reads a git commit's changed files and commit message, resolves the symbols
4//! in those files, and derives candidate ledger entries (decisions, invariants,
5//! hazards, proofs, validation scenarios).
6//!
7//! By default this is a dry-run: it prints the suggested entries as JSON.
8//! Pass `--write` to actually append them to the ASD ledger.
9//!
10//! Annotation syntax recognised in commit message body:
11//!   invariant: <text>          → LedgerKind::Invariant
12//!   hazard: <text>             → LedgerKind::Hazard
13//!   proof: <text>              → LedgerKind::Proof
14//!   validation_scenario: <text> → LedgerKind::ValidationScenario
15//!   known_bug: <text>          → LedgerKind::KnownBug
16//!   decision: <text>           → LedgerKind::Decision   (explicit)
17//!   (any other body line)      → LedgerKind::Decision   (implicit, from subject)
18//!
19//! The subject line always produces a Decision entry for every touched symbol
20//! that doesn't already have that exact summary recorded.
21//!
22//! Integrates with CTX task closure via `--task-description`:
23//!   asd annotate-commit --write --task-description "$(ctx task show t-042)"
24
25use std::collections::{HashMap, HashSet};
26use std::path::Path;
27use std::process::Command;
28
29use anyhow::{Context, Result};
30use clap::Args;
31use serde_json::{Value, json};
32
33use agentstatedeveloper_core::{
34    AsgIndexStore, AsgLedgerStore, Engine, LedgerKind, LedgerStore, Symbol, kind_str,
35    schema::{Author, AuthorKind, LedgerEntry},
36};
37
38/// Per-file change detail for a commit, used to split symbols into
39/// "directly edited" (their line span overlaps a changed hunk) vs
40/// "nearby/touched" (in a renamed/moved file, or a changed file but outside
41/// the edited lines) — so ledger annotations don't treat a file move the same
42/// as a real edit.
43#[derive(Default)]
44struct FileChange {
45    /// True when the file was renamed/moved in this commit.
46    renamed: bool,
47    /// 1-based inclusive line ranges on the NEW side that actually changed.
48    /// Empty for a pure rename (100% similarity, no content delta).
49    hunks: Vec<(u32, u32)>,
50}
51
52impl FileChange {
53    /// Does `[start, end]` (1-based inclusive) overlap any changed hunk?
54    fn overlaps(&self, start: u32, end: u32) -> bool {
55        self.hunks.iter().any(|(a, b)| start <= *b && end >= *a)
56    }
57}
58
59/// Parse `git diff-tree -p -M --unified=0` for a commit into new-path →
60/// FileChange. Tracks renames (`rename to`) and new-side hunk ranges (`@@ …
61/// +c,d @@`).
62fn commit_file_changes(commit: &str) -> std::collections::HashMap<String, FileChange> {
63    use std::collections::HashMap;
64    let mut map: HashMap<String, FileChange> = HashMap::new();
65    let out = Command::new("git")
66        .args([
67            "diff-tree",
68            "--no-commit-id",
69            "-r",
70            "-M",
71            "-p",
72            "--unified=0",
73            commit,
74        ])
75        .output();
76    let text = match out {
77        Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
78        Err(_) => return map,
79    };
80    let mut cur: Option<String> = None;
81    let mut renamed = false;
82    for line in text.lines() {
83        if line.starts_with("diff --git ") {
84            // New file section; resolve the path from later +++/rename lines.
85            cur = None;
86            renamed = false;
87        } else if let Some(p) = line.strip_prefix("rename to ") {
88            renamed = true;
89            let path = p.trim().to_string();
90            map.entry(path.clone()).or_default().renamed = true;
91            cur = Some(path);
92        } else if let Some(p) = line.strip_prefix("+++ b/") {
93            if p != "/dev/null" {
94                let path = p.trim().to_string();
95                let e = map.entry(path.clone()).or_default();
96                e.renamed = e.renamed || renamed;
97                cur = Some(path);
98            }
99        } else if line.starts_with("@@ ") {
100            // @@ -a,b +c,d @@ ; new range is [c, c+max(d,1)-1]; d==0 → deletion at c.
101            if let Some(rest) = line.split(" +").nth(1)
102                && let Some(spec) = rest.split(' ').next()
103            {
104                let mut it = spec.split(',');
105                let c: u32 = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
106                let d: u32 = it.next().and_then(|s| s.parse().ok()).unwrap_or(1);
107                let start = c.max(1);
108                let end = if d == 0 { start } else { start + d - 1 };
109                if let Some(ref path) = cur {
110                    map.entry(path.clone())
111                        .or_default()
112                        .hunks
113                        .push((start, end));
114                }
115            }
116        }
117    }
118    map
119}
120
121fn is_doc_file(path: &str) -> bool {
122    let lower = path.to_lowercase();
123    let p = Path::new(&lower);
124    let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
125    let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
126    matches!(
127        ext,
128        "md" | "txt" | "rst" | "adoc" | "asciidoc" | "tex" | "org"
129    ) || name.starts_with("readme")
130        || name.starts_with("changelog")
131        || name.starts_with("license")
132        || name.starts_with("authors")
133        || name.starts_with("contributing")
134        || lower.contains("/docs/")
135        || lower.starts_with("docs/")
136        || lower.contains("/doc/")
137        || lower.starts_with("doc/")
138}
139
140use crate::config::Config;
141
142#[derive(Debug, Args)]
143pub struct AnnotateCommitArgs {
144    /// Git commit SHA to annotate (default: HEAD).
145    pub sha: Option<String>,
146
147    /// Write derived entries to the ASD ledger.
148    /// Without this flag the command is a dry-run and only prints suggestions.
149    #[arg(long, default_value_t = false)]
150    pub write: bool,
151
152    /// Author id written into ledger entries (default: git user.name).
153    #[arg(long)]
154    pub author: Option<String>,
155
156    /// Additional context from the active CTX task (its title or description).
157    /// Appended to the commit message body when deriving annotations.
158    #[arg(long)]
159    pub task_description: Option<String>,
160
161    /// CTX task ID — written as a `ctx:task:<id>` provenance tag on every entry.
162    /// Also accepts CTXONE_TASK env var.
163    #[arg(long)]
164    pub ctx_task: Option<String>,
165
166    /// CTX plan ID — written as a `ctx:plan:<id>` provenance tag.
167    /// Also accepts CTXONE_PLAN env var.
168    #[arg(long)]
169    pub ctx_plan: Option<String>,
170
171    /// Suppress informational stderr output.
172    #[arg(long)]
173    pub quiet: bool,
174
175    /// Print per-doc cluster selection reasoning: terms extracted, top-5 candidates
176    /// with kind bonus and match score, and why the winner was selected.
177    /// Only active for docs-only commits.
178    #[arg(long)]
179    pub debug_clusters: bool,
180}
181
182pub fn run(cfg: &Config, args: AnnotateCommitArgs) -> Result<()> {
183    let sha = args.sha.clone().unwrap_or_else(|| "HEAD".to_string());
184
185    // ---- Commit metadata ------------------------------------------------
186    let log_out = Command::new("git")
187        .args(["log", "-1", "--format=%H%n%s%n%b", &sha])
188        .output()
189        .context("git log failed")?;
190    let log_str = String::from_utf8_lossy(&log_out.stdout);
191    let mut log_lines = log_str.lines();
192    let commit_hash = log_lines.next().unwrap_or("").trim().to_string();
193    let subject = log_lines.next().unwrap_or("").trim().to_string();
194    let body: String = log_lines.collect::<Vec<_>>().join("\n");
195
196    if commit_hash.is_empty() {
197        anyhow::bail!("could not resolve commit: {}", sha);
198    }
199
200    // Append optional task description to body for annotation extraction.
201    let full_body = if let Some(ref td) = args.task_description {
202        format!("{body}\n{td}")
203    } else {
204        body
205    };
206
207    // ---- Changed files ---------------------------------------------------
208    let diff_out = Command::new("git")
209        .args([
210            "diff-tree",
211            "--no-commit-id",
212            "-r",
213            "--name-only",
214            &commit_hash,
215        ])
216        .output()
217        .context("git diff-tree failed")?;
218    let changed_files: Vec<String> = String::from_utf8_lossy(&diff_out.stdout)
219        .lines()
220        .map(|l| l.trim().to_string())
221        .filter(|l| !l.is_empty())
222        .collect();
223
224    if changed_files.is_empty() {
225        println!(
226            "{}",
227            json!({ "commit": commit_hash, "subject": subject, "note": "no changed files detected", "suggested_entries": [] })
228        );
229        return Ok(());
230    }
231
232    // ---- Resolve symbols for changed files --------------------------------
233    let engine = Engine::open_sqlite(&cfg.db_path)?;
234    let index_store = AsgIndexStore::from_engine(&engine);
235    let ledger_store = AsgLedgerStore::from_engine(&engine);
236
237    // Load all indexed symbols via the SQLite cache (avoids a full git tree walk).
238    let all_syms: Vec<Symbol> = index_store.build_id_map(&engine).into_values().collect();
239
240    let mut touched_symbols: Vec<Symbol> = Vec::new();
241    let mut seen_ids: HashSet<String> = HashSet::new();
242    // Borrow (not move) so all_syms stays available for the docs-only candidate scan below.
243    for sym in &all_syms {
244        if changed_files
245            .iter()
246            .any(|f| sym.file.ends_with(f.as_str()) || sym.file == *f)
247        {
248            if seen_ids.insert(sym.symbol_id.clone()) {
249                touched_symbols.push(sym.clone());
250            }
251        }
252    }
253
254    // Limit to 20 most relevant symbols to keep output manageable.
255    touched_symbols.truncate(20);
256
257    // cluster_meta: symbol_id → (per-cluster concept summary, confidence 0-1).
258    // Populated by the docs-only path; used in the emit loop to attach
259    // file-specific concept annotations only to their matched symbol.
260    let mut cluster_meta: HashMap<String, (String, f64)> = HashMap::new();
261    // cluster_debug: per-doc selection trace (populated when --debug-clusters is set).
262    let mut cluster_debug: Vec<Value> = Vec::new();
263
264    // ---- Docs-only path: find nearest domain symbol when all changed files
265    //      are documentation (*.md, docs/ dirs, etc.) and no symbols were found.
266    let all_docs = !changed_files.is_empty() && changed_files.iter().all(|f| is_doc_file(f));
267    if all_docs && touched_symbols.is_empty() {
268        let extract_terms = |path: &str| -> Vec<String> {
269            let stem = Path::new(path)
270                .file_stem()
271                .and_then(|s| s.to_str())
272                .unwrap_or("")
273                .to_lowercase();
274            stem.replace(['-', '_', '.'], " ")
275                .split_whitespace()
276                .filter(|w| {
277                    w.len() >= 3
278                        && !matches!(
279                            *w,
280                            "the"
281                                | "and"
282                                | "for"
283                                | "doc"
284                                | "docs"
285                                | "readme"
286                                | "design"
287                                | "notes"
288                                | "plan"
289                                | "spec"
290                                | "guide"
291                                | "api"
292                                | "todo"
293                                | "fixme"
294                                | "draft"
295                                | "wip"
296                                | "change"
297                                | "changes"
298                        )
299                })
300                .map(|w| w.to_string())
301                .collect()
302        };
303
304        // Filter non-doc candidate symbols from the already-loaded all_syms
305        // (no second git tree walk needed).
306        let candidate_syms: Vec<&Symbol> =
307            all_syms.iter().filter(|s| !is_doc_file(&s.file)).collect();
308
309        // Per-file pass: find the best-matching symbol for each changed doc file
310        // independently. This produces one concept cluster per doc domain rather
311        // than collapsing all files into a single nearest-neighbour lookup.
312        // Prefers type/module/class/struct symbols over methods to anchor concepts
313        // at the right granularity.
314        let mut any_term_match = false;
315        for doc_file in &changed_files {
316            let file_terms = extract_terms(doc_file);
317            if file_terms.is_empty() {
318                continue;
319            }
320            // combined_score is isize so test-file symbols can receive a negative bonus.
321            let mut ranked: Vec<(isize, usize, &Symbol)> = candidate_syms
322                .iter()
323                .copied()
324                .map(|s| {
325                    let haystack = format!("{} {}", s.qname.to_lowercase(), s.file.to_lowercase());
326                    let term_score = file_terms
327                        .iter()
328                        .filter(|t| haystack.contains(t.as_str()))
329                        .count();
330                    let fl = s.file.to_lowercase();
331                    let is_test_file = fl.contains("/test")
332                        || fl.contains("/tests/")
333                        || fl.contains("/spec")
334                        || fl.ends_with("tests.swift");
335                    let kind_bonus: isize = if is_test_file {
336                        -2 // loses to any non-test class with equal term match
337                    } else {
338                        match kind_str(&s.kind) {
339                            "module" | "class" | "struct" | "enum" | "trait" | "type"
340                            | "interface" | "protocol" | "namespace" => 2,
341                            _ => 0,
342                        }
343                    };
344                    // dir_seg_bonus: +1 per distinct doc-term that appears in a *parent-
345                    // directory* component of the symbol's file path (not the filename).
346                    // Rewards symbols in a dedicated namespace dir (SongPlayers/SIDPlayer/)
347                    // over symbols that merely match a term in their class name.
348                    let dir_seg_bonus: isize = {
349                        let p = Path::new(&s.file);
350                        let dirs: Vec<String> = p
351                            .parent()
352                            .map(|parent| {
353                                parent
354                                    .components()
355                                    .filter_map(|c| c.as_os_str().to_str())
356                                    .map(|seg| seg.to_lowercase())
357                                    .collect()
358                            })
359                            .unwrap_or_default();
360                        file_terms
361                            .iter()
362                            .filter(|t| dirs.iter().any(|d| d.contains(t.as_str())))
363                            .count() as isize
364                    };
365                    (
366                        (term_score as isize) * 2 + kind_bonus + dir_seg_bonus,
367                        term_score,
368                        s,
369                    )
370                })
371                .filter(|(_, ts, _)| *ts >= 1)
372                .collect();
373            ranked.sort_by(|a, b| b.0.cmp(&a.0));
374            let best = ranked.first().copied();
375            if args.debug_clusters {
376                let top5: Vec<Value> = ranked
377                    .iter()
378                    .take(5)
379                    .map(|(combined, ts, s)| {
380                        let k = kind_str(&s.kind);
381                        let fl = s.file.to_lowercase();
382                        let is_test = fl.contains("/test")
383                            || fl.contains("/tests/")
384                            || fl.contains("/spec")
385                            || fl.ends_with("tests.swift");
386                        let kb: isize = if is_test {
387                            -2
388                        } else {
389                            match k {
390                                "module" | "class" | "struct" | "enum" | "trait" | "type"
391                                | "interface" | "protocol" | "namespace" => 2,
392                                _ => 0,
393                            }
394                        };
395                        let dsb: isize = {
396                            let p = Path::new(&s.file);
397                            let dirs: Vec<String> = p
398                                .parent()
399                                .map(|parent| {
400                                    parent
401                                        .components()
402                                        .filter_map(|c| c.as_os_str().to_str())
403                                        .map(|seg| seg.to_lowercase())
404                                        .collect()
405                                })
406                                .unwrap_or_default();
407                            file_terms
408                                .iter()
409                                .filter(|t| dirs.iter().any(|d| d.contains(t.as_str())))
410                                .count() as isize
411                        };
412                        json!({
413                            "qname": s.qname,
414                            "file": s.file,
415                            "kind": k,
416                            "kind_bonus": kb,
417                            "dir_seg_bonus": dsb,
418                            "term_score": ts,
419                            "combined_score": combined,
420                        })
421                    })
422                    .collect();
423                let winner_info: Value = if let Some((_, ts, sym)) = best {
424                    let confidence = ts as f64 / file_terms.len().max(1) as f64;
425                    json!({
426                        "qname": sym.qname,
427                        "term_score": ts,
428                        "confidence": confidence,
429                        "reason": format!(
430                            "highest combined score (term_score×2 + kind_bonus + dir_seg_bonus); matched {}/{} terms",
431                            ts, file_terms.len()
432                        ),
433                    })
434                } else {
435                    json!({"reason": "no candidates matched any term"})
436                };
437                cluster_debug.push(json!({
438                    "doc_file": doc_file,
439                    "terms_extracted": file_terms,
440                    "top_5_candidates": top5,
441                    "winner_selected": winner_info,
442                }));
443            }
444            if let Some((_, term_score, sym)) = best {
445                any_term_match = true;
446                let confidence = term_score as f64 / file_terms.len().max(1) as f64;
447                let doc_name = Path::new(doc_file)
448                    .file_stem()
449                    .and_then(|s| s.to_str())
450                    .unwrap_or(doc_file);
451                let concept = format!("documentation: {}", doc_name.replace(['-', '_'], " "));
452                cluster_meta.insert(sym.symbol_id.clone(), (concept, confidence));
453                if seen_ids.insert(sym.symbol_id.clone()) {
454                    touched_symbols.push(sym.clone());
455                }
456            }
457        }
458
459        // Fallback: no term matches found — use directory heuristics across all docs combined.
460        if !any_term_match {
461            let doc_dirs: Vec<&str> = changed_files
462                .iter()
463                .filter_map(|f| Path::new(f).parent()?.to_str())
464                .collect();
465            let mut dir_scored: Vec<(usize, &Symbol)> = candidate_syms
466                .iter()
467                .copied()
468                .map(|s| {
469                    let score = doc_dirs
470                        .iter()
471                        .filter(|d| !d.is_empty() && **d != "." && s.file.contains(**d))
472                        .count();
473                    (score, s)
474                })
475                .collect();
476            dir_scored.sort_by(|a, b| b.0.cmp(&a.0));
477            for (_, sym) in dir_scored.into_iter().take(5) {
478                if seen_ids.insert(sym.symbol_id.clone()) {
479                    touched_symbols.push((*sym).clone());
480                }
481            }
482        }
483
484        if !args.quiet {
485            eprintln!(
486                "asd: docs-only commit — {} domain cluster(s) identified across {} doc file(s)",
487                touched_symbols.len(),
488                changed_files.len()
489            );
490        }
491    }
492
493    // ---- Parse commit message for structured annotations -----------------
494    #[derive(Debug)]
495    struct Annotation {
496        kind: LedgerKind,
497        summary: String,
498    }
499
500    let mut annotations: Vec<Annotation> = Vec::new();
501
502    // Docs-only commits: subject becomes a Concept entry (documentation, not code decision).
503    // Code commits: subject becomes a Decision.
504    if !subject.is_empty() {
505        let subject_kind = if all_docs {
506            LedgerKind::Concept
507        } else {
508            LedgerKind::Decision
509        };
510        annotations.push(Annotation {
511            kind: subject_kind,
512            summary: subject.clone(),
513        });
514    }
515    // Note: for docs-only commits, per-cluster concept annotations are stored in
516    // `cluster_meta` (built in the docs-only path above) rather than the global
517    // annotations list, so they attach only to their matched cluster symbol.
518    // No global file-stem concepts are injected here.
519
520    // Parse body lines for structured annotations.
521    for line in full_body.lines() {
522        let line = line.trim();
523        if line.is_empty() {
524            continue;
525        }
526
527        let (kind, text) = if let Some(rest) = line.strip_prefix("invariant:") {
528            (LedgerKind::Invariant, rest.trim())
529        } else if let Some(rest) = line.strip_prefix("hazard:") {
530            (LedgerKind::Hazard, rest.trim())
531        } else if let Some(rest) = line.strip_prefix("proof:") {
532            (LedgerKind::Proof, rest.trim())
533        } else if let Some(rest) = line.strip_prefix("validation_scenario:") {
534            (LedgerKind::ValidationScenario, rest.trim())
535        } else if let Some(rest) = line.strip_prefix("known_bug:") {
536            (LedgerKind::KnownBug, rest.trim())
537        } else if let Some(rest) = line.strip_prefix("concept:") {
538            (LedgerKind::Concept, rest.trim())
539        } else if let Some(rest) = line.strip_prefix("decision:") {
540            (LedgerKind::Decision, rest.trim())
541        } else {
542            continue; // unstructured body lines are skipped
543        };
544
545        if !text.is_empty() {
546            annotations.push(Annotation {
547                kind,
548                summary: text.to_string(),
549            });
550        }
551    }
552
553    // ---- CTX provenance tags (t-001) -------------------------------------
554    let ctx_plan = args
555        .ctx_plan
556        .clone()
557        .or_else(|| std::env::var("CTXONE_PLAN").ok())
558        .unwrap_or_default();
559    let ctx_task = args
560        .ctx_task
561        .clone()
562        .or_else(|| std::env::var("CTXONE_TASK").ok())
563        .unwrap_or_default();
564    let mut ctx_tags: Vec<String> = Vec::new();
565    if !ctx_plan.is_empty() {
566        ctx_tags.push(format!("ctx:plan:{}", ctx_plan));
567    }
568    if !ctx_task.is_empty() {
569        ctx_tags.push(format!("ctx:task:{}", ctx_task));
570    }
571    ctx_tags.push(format!(
572        "commit:{}",
573        &commit_hash[..8.min(commit_hash.len())]
574    ));
575
576    // ---- Determine author ------------------------------------------------
577    let author_id = args.author.clone().unwrap_or_else(|| {
578        let out = Command::new("git")
579            .args(["config", "user.name"])
580            .output()
581            .ok();
582        out.and_then(|o| String::from_utf8(o.stdout).ok())
583            .unwrap_or_default()
584            .trim()
585            .to_string()
586    });
587    let author = Author {
588        kind: AuthorKind::Human,
589        id: author_id,
590    };
591
592    // ---- Build suggested entries -----------------------------------------
593    let mut suggested: Vec<Value> = Vec::new();
594    let mut written: Vec<Value> = Vec::new();
595
596    for sym in &touched_symbols {
597        // For docs-only commits, inject the per-cluster concept annotation
598        // before the global annotations so the concept is recorded first.
599        let cluster_anns: Vec<Annotation> = if all_docs {
600            cluster_meta
601                .get(&sym.symbol_id)
602                .map(|(concept, _)| {
603                    vec![Annotation {
604                        kind: LedgerKind::Concept,
605                        summary: concept.clone(),
606                    }]
607                })
608                .unwrap_or_default()
609        } else {
610            vec![]
611        };
612        // When this symbol has a cluster-specific concept, suppress any generic
613        // Concept entries from the global annotations list (the subject line) so
614        // we don't emit both "documentation: X" and "Define X scope" for the same symbol.
615        let has_cluster = !cluster_anns.is_empty();
616        let all_anns: Vec<&Annotation> = cluster_anns
617            .iter()
618            .chain(
619                annotations
620                    .iter()
621                    .filter(|a| !(has_cluster && a.kind == LedgerKind::Concept)),
622            )
623            .collect();
624        let cluster_confidence = cluster_meta.get(&sym.symbol_id).map(|(_, c)| *c);
625
626        for ann in all_anns {
627            // Check for duplicate: skip if same summary + kind already recorded.
628            let existing = ledger_store
629                .list_entries(&engine.ref_name, &sym.symbol_id)
630                .unwrap_or_default();
631            let already_exists = existing.iter().any(|e| {
632                e.kind == ann.kind && e.summary.to_lowercase() == ann.summary.to_lowercase()
633            });
634            if already_exists {
635                continue;
636            }
637
638            let entry_val = json!({
639                "symbol": sym.qname,
640                "file": sym.file,
641                "kind": format!("{:?}", ann.kind).to_lowercase(),
642                "summary": ann.summary,
643                "cluster_confidence": cluster_confidence,
644            });
645
646            if args.write {
647                let mut entry = LedgerEntry::new(
648                    sym.symbol_id.clone(),
649                    ann.kind,
650                    ann.summary.clone(),
651                    author.clone(),
652                );
653                entry.tags.extend(ctx_tags.iter().cloned());
654                match ledger_store.append_entry(&engine.ref_name, &entry, &author.id) {
655                    Ok(()) => written.push(entry_val),
656                    Err(e) => eprintln!("warn: could not write entry for {}: {e}", sym.qname),
657                }
658            } else {
659                suggested.push(entry_val);
660            }
661        }
662    }
663
664    // Split touched symbols into those whose own line span was actually edited
665    // vs those merely carried along by a file move / an unrelated same-file
666    // change — computed here, after all mutations to `touched_symbols`.
667    let file_changes = commit_file_changes(&commit_hash);
668    let symbol_directly_edited = |sym: &Symbol| -> bool {
669        file_changes
670            .iter()
671            .find(|(p, _)| sym.file.ends_with(p.as_str()) || &sym.file == *p)
672            .map(|(_, c)| c.overlaps(sym.start.line, sym.end.line))
673            .unwrap_or(false)
674    };
675    let directly_edited: Vec<String> = touched_symbols
676        .iter()
677        .filter(|s| symbol_directly_edited(s))
678        .map(|s| s.qname.clone())
679        .collect();
680    let nearby_touched: Vec<String> = touched_symbols
681        .iter()
682        .filter(|s| !symbol_directly_edited(s))
683        .map(|s| s.qname.clone())
684        .collect();
685
686    let out = if args.write {
687        json!({
688            "commit": commit_hash,
689            "subject": subject,
690            "changed_files": changed_files,
691            "touched_symbols": touched_symbols.len(),
692            "directly_edited": directly_edited,
693            "nearby_touched": nearby_touched,
694            "written_entries": written,
695        })
696    } else {
697        json!({
698            "commit": commit_hash,
699            "subject": subject,
700            "changed_files": changed_files,
701            "touched_symbols": touched_symbols.iter().map(|s| &s.qname).collect::<Vec<_>>(),
702            // The signal split: symbols whose own lines changed vs those merely
703            // carried along by a move / unrelated same-file edit.
704            "directly_edited": directly_edited,
705            "nearby_touched": nearby_touched,
706            "suggested_entries": suggested,
707            "note": "dry-run — pass --write to record these entries",
708            "cluster_debug": if args.debug_clusters { json!(cluster_debug) } else { Value::Null },
709        })
710    };
711
712    println!("{}", serde_json::to_string_pretty(&out)?);
713    Ok(())
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    #[test]
721    fn overlaps_detects_span_hunk_intersection() {
722        let fc = FileChange {
723            renamed: false,
724            hunks: vec![(10, 12), (40, 40)],
725        };
726        // Symbol [8,15] straddles hunk (10,12) → directly edited.
727        assert!(fc.overlaps(8, 15));
728        // Symbol exactly on a single-line hunk.
729        assert!(fc.overlaps(40, 45));
730        // Symbol [20,30] between hunks → not edited (nearby/touched).
731        assert!(!fc.overlaps(20, 30));
732        // Symbol entirely before the first hunk.
733        assert!(!fc.overlaps(1, 9));
734    }
735
736    #[test]
737    fn pure_rename_has_no_hunks_so_never_directly_edited() {
738        let fc = FileChange {
739            renamed: true,
740            hunks: vec![],
741        };
742        assert!(!fc.overlaps(1, 10_000));
743    }
744}