Skip to main content

newt_core/
scope_grounding.rs

1//! Shared def-site scope grounding (#812, #840): derive a subtask's file
2//! scope from the harness's OWN grounding — a `def_sites` grep closure the
3//! caller injects — rather than trusting a model's self-reported file list
4//! alone. `derive_subtask_scope`/[`ground_scope`] are the ONE implementation
5//! shared by crew mode's `plan::Subtask.context` (`newt-cli/src/crew.rs`) and
6//! team mode's per-subtask fence (`newt-scheduler/src/team.rs`), ported here
7//! (not forked) so the two paths cannot drift out of sync.
8//!
9//! Pure and git-free: `def_sites(symbol)` is injected as a closure, typically
10//! backed by `git grep` at the call site (see `newt-cli/src/crew.rs`'s
11//! `author_plan_to_plan`), so this module only reasons about the resulting
12//! `path:line:content` grep lines.
13
14/// Words that flag an adjacent unquoted token as a likely code symbol rather
15/// than plain prose (e.g. "the help_lines function", "struct ConfigLoader").
16const DEF_KEYWORDS: &[&str] = &[
17    "function", "fn", "struct", "trait", "enum", "method", "type", "module", "mod", "macro",
18];
19
20/// A code-like identifier: snake_case (`_`), CamelCase (mixed case), or contains a
21/// digit — so plain prose ("the", "parser") isn't mistaken for a symbol. (#696)
22fn looks_like_identifier(t: &str) -> bool {
23    let t = t.trim_matches('`');
24    !t.is_empty()
25        && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
26        && t.chars()
27            .next()
28            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
29        && (t.contains('_')
30            || t.contains(|c: char| c.is_ascii_digit())
31            || (t.chars().any(|c| c.is_ascii_uppercase())
32                && t.chars().any(|c| c.is_ascii_lowercase())))
33}
34
35/// High-signal code-ish terms in `text` to grep for: slash-commands (`/dgx`),
36/// backtick-quoted single tokens (`help_lines`), and code-like identifiers
37/// adjacent to a def keyword ("the help_lines function", "struct Foo").
38/// Distinctive enough to locate real code without the noise a bare word like
39/// "help" would drown it in. (#696)
40pub fn grep_terms(text: &str) -> Vec<String> {
41    let mut terms: Vec<String> = Vec::new();
42    // Backtick-quoted single tokens.
43    let mut rest = text;
44    while let Some(open) = rest.find('`') {
45        rest = &rest[open + 1..];
46        let Some(close) = rest.find('`') else { break };
47        let span = rest[..close].trim();
48        if (3..=40).contains(&span.chars().count())
49            && span.split_whitespace().count() == 1
50            && span.chars().any(|c| c.is_ascii_alphanumeric())
51        {
52            terms.push(span.to_string());
53        }
54        rest = &rest[close + 1..];
55    }
56    // Slash-commands: `/word`.
57    for tok in text.split(|c: char| c.is_whitespace() || "()[]{}<>,;:\"'".contains(c)) {
58        if let Some(after) = tok.strip_prefix('/') {
59            let cmd: String = after
60                .chars()
61                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
62                .collect();
63            if cmd.len() >= 2 {
64                terms.push(format!("/{cmd}"));
65            }
66        }
67    }
68    // Code-like identifiers adjacent to a def keyword (#696): "the help_lines
69    // function", "struct Foo", "refactor parse_config" — so the claim-check sees
70    // an unquoted symbol the model named. Guarded to code-like tokens so plain
71    // prose isn't grepped.
72    let words: Vec<&str> = text
73        .split(|c: char| c.is_whitespace() || "()[]{}<>,;:\"'`".contains(c))
74        .collect();
75    for (i, w) in words.iter().enumerate() {
76        if DEF_KEYWORDS.contains(&w.to_ascii_lowercase().as_str()) {
77            let prev = i.checked_sub(1).and_then(|j| words.get(j));
78            let next = words.get(i + 1);
79            for cand in [prev, next].into_iter().flatten() {
80                if looks_like_identifier(cand) {
81                    terms.push((*cand).to_string());
82                }
83            }
84        }
85    }
86    terms.sort();
87    terms.dedup();
88    terms.truncate(12);
89    terms
90}
91
92/// Escape ERE metacharacters so a task term matches literally inside the
93/// definition pattern (grep terms are usually bare symbols, but be safe).
94pub fn ere_escape(s: &str) -> String {
95    let mut out = String::with_capacity(s.len());
96    for c in s.chars() {
97        if "\\.^$*+?()[]{}|".contains(c) {
98            out.push('\\');
99        }
100        out.push(c);
101    }
102    out
103}
104
105/// A POSIX-ERE pattern (for `git grep -E`) matching a Rust DEFINITION of `term`
106/// — `fn`/`struct`/`trait`/`enum`/`type`/`const`/`static`/`union`/`mod`, with an
107/// optional `pub`/`async`/`unsafe`, at line start, the symbol word-bounded — and
108/// NOT a bare mention.
109pub fn definition_grep_pattern(term: &str) -> String {
110    format!(
111        "^[[:space:]]*(pub[[:space:]]+)?(async[[:space:]]+)?(unsafe[[:space:]]+)?\
112         (fn|struct|trait|enum|type|const|static|union|mod)[[:space:]]+{}([^A-Za-z0-9_]|$)",
113        ere_escape(term)
114    )
115}
116
117/// A term whose definitions are spread over more than this many files carries
118/// no aiming signal (`main` defines in dozens) — it is skipped rather than
119/// allowed to flood/evict the real target under the scope cap. (#812)
120pub const AMBIGUOUS_DEF_FILES: usize = 3;
121
122/// Cap so a derived+declared scope stays a lane, not the whole repo. (#812)
123pub const SCOPE_CAP: usize = 6;
124
125/// Derive one subtask's file scope from the harness's OWN grounding —
126/// `grep_terms(instruction)` → def-site grep → the defining files. PURE over
127/// the injected `def_sites` (`path:line:content` grep lines), so it is
128/// unit-testable with no fs. Order-preserving dedup; ambiguous terms (defs in
129/// more than [`AMBIGUOUS_DEF_FILES`] files) are skipped entirely — no signal,
130/// not weak signal; git-quoted (`"`-prefixed, core.quotePath) and empty
131/// paths are dropped as unusable fence entries. (#812)
132pub fn derive_subtask_scope(
133    instruction: &str,
134    def_sites: &impl Fn(&str) -> Vec<String>,
135) -> Vec<String> {
136    let mut paths: Vec<String> = Vec::new();
137    for term in grep_terms(instruction) {
138        let mut term_files: Vec<String> = Vec::new();
139        for hit in def_sites(&term) {
140            if let Some(p) = hit.split(':').next() {
141                if !p.is_empty() && !p.starts_with('"') && !term_files.iter().any(|q| q == p) {
142                    term_files.push(p.to_string());
143                }
144            }
145        }
146        if term_files.is_empty() || term_files.len() > AMBIGUOUS_DEF_FILES {
147            continue;
148        }
149        for p in term_files {
150            if !paths.iter().any(|q| q == &p) {
151                paths.push(p);
152            }
153        }
154    }
155    paths
156}
157
158/// Ground one subtask's scope: the def-site derivation LEADS (an under- or
159/// mis-declaring model cannot mis-aim the fence away from the real seam), and
160/// `declared` (the model's self-reported files) is APPENDED — augmentation,
161/// never replacement — so a companion edit target grounding cannot see (a new
162/// test file, docs, a brand-new module) stays in the lane. Capped so the lane
163/// stays a lane; empty result fences nothing (meet-only downstream: the scope
164/// can only narrow the writable set, never widen authority). Shared by crew
165/// mode's `Plan.context` stamping and team mode's per-subtask fence — the ONE
166/// grounding implementation both paths call. (#812, #840)
167pub fn ground_scope(
168    instruction: &str,
169    declared: &[String],
170    def_sites: &impl Fn(&str) -> Vec<String>,
171) -> Vec<String> {
172    let mut scope = derive_subtask_scope(instruction, def_sites);
173    for d in declared {
174        let d = d.trim();
175        if !d.is_empty() && !scope.iter().any(|q| q == d) {
176            scope.push(d.to_string());
177        }
178    }
179    scope.truncate(SCOPE_CAP);
180    scope
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn grep_terms_extracts_slash_commands_and_backtick_tokens_only() {
189        let terms =
190            grep_terms("roll up `/dgx` help; refactor `help_lines` and /models — but not help");
191        assert!(terms.contains(&"/dgx".to_string()), "{terms:?}");
192        assert!(terms.contains(&"/models".to_string()), "{terms:?}");
193        assert!(terms.contains(&"help_lines".to_string()), "{terms:?}");
194        // Bare common words (like "help") are NOT extracted — too noisy to grep.
195        assert!(!terms.iter().any(|t| t == "help"), "{terms:?}");
196    }
197
198    #[test]
199    fn grep_terms_extracts_unquoted_identifiers_next_to_def_keywords() {
200        // #696: the model often names a symbol unquoted — "the help_lines function".
201        let t1 = grep_terms("Refactor the help_lines function in newt-cli/src/crew.rs");
202        assert!(t1.contains(&"help_lines".to_string()), "{t1:?}");
203        let t2 = grep_terms("add a new struct ConfigLoader to hold settings");
204        assert!(t2.contains(&"ConfigLoader".to_string()), "{t2:?}");
205        // Plain prose adjacent to a keyword is NOT a symbol (precision guard).
206        let t3 = grep_terms("this function works well and the parser is fine");
207        assert!(
208            !t3.iter()
209                .any(|x| x == "works" || x == "parser" || x == "the"),
210            "{t3:?}"
211        );
212    }
213
214    #[test]
215    fn definition_grep_pattern_matches_defs_not_mentions() {
216        let re = regex::Regex::new(&definition_grep_pattern("help_lines")).unwrap();
217        assert!(re.is_match("fn help_lines() -> &'static [&'static str] {"));
218        assert!(re.is_match("    pub async fn help_lines() {"));
219        assert!(re.is_match("pub fn help_lines<T>(x: T) {"));
220        assert!(!re.is_match("    // help_lines is the seam"));
221        assert!(!re.is_match("    let v = self.help_lines();"));
222        assert!(!re.is_match("fn help_lines_other() {")); // word-bounded, not a prefix
223    }
224
225    #[test]
226    fn derive_subtask_scope_extracts_def_files_and_skips_ambiguous_terms() {
227        // #812: def_sites returns `path:line:content` grep lines; the scope is
228        // the DEFINING files, order-preserving, deduped. A term defined in
229        // more than AMBIGUOUS_DEF_FILES files carries no aiming signal (e.g.
230        // `main`) and is skipped so it cannot evict the real target; a
231        // git-quoted path (core.quotePath escaping) is dropped as unusable.
232        let def_sites = |sym: &str| -> Vec<String> {
233            match sym {
234                "humanize_duration" => vec![
235                    "src/util.rs:2:pub fn humanize_duration(...)".to_string(),
236                    "src/util.rs:9:fn humanize_duration_impl(...)".to_string(),
237                    "src/lib.rs:11:fn humanizes()".to_string(),
238                    "\"src/gr\\303\\266\\303\\237e.rs\":1:fn humanize_x()".to_string(),
239                ],
240                "main" => (0..14).map(|i| format!("file{i}.rs:1:fn main()")).collect(),
241                _ => Vec::new(),
242            }
243        };
244        let scope = derive_subtask_scope(
245            "fix `humanize_duration` in `main` so 90s renders as 1m 30s",
246            &def_sites,
247        );
248        assert_eq!(
249            scope,
250            vec!["src/util.rs".to_string(), "src/lib.rs".to_string()],
251            "defining files kept; ambiguous `main` skipped; quoted path dropped"
252        );
253        // No grounded symbols → empty scope (fence stays off; never invented).
254        assert!(derive_subtask_scope("tidy the docs", &def_sites).is_empty());
255    }
256
257    #[test]
258    fn ground_scope_unions_def_sites_with_declared_and_caps() {
259        // #812/#840 (§4b: augmentation, not replacement): the derived def-site
260        // LEADS the scope, and the declared files are APPENDED — a companion
261        // target grounding cannot see (a new test file) must stay in the
262        // lane, and a mis-declaring model still cannot aim the fence away
263        // from the real seam. Where derivation finds nothing, the
264        // declaration alone survives.
265        let def_sites = |sym: &str| -> Vec<String> {
266            if sym == "humanize_duration" {
267                vec!["src/util.rs:2:pub fn humanize_duration".to_string()]
268            } else {
269                Vec::new()
270            }
271        };
272        let scope = ground_scope(
273            "Fix `humanize_duration` in the util module",
274            &["tests/util_test.rs".to_string(), "src/util.rs".to_string()],
275            &def_sites,
276        );
277        assert_eq!(
278            scope,
279            vec!["src/util.rs".to_string(), "tests/util_test.rs".to_string()],
280            "derived seam leads; declared companion appended; dup deduped"
281        );
282        let scope2 = ground_scope(
283            "Create the brand-new reporting module",
284            &["src/report.rs".to_string()],
285            &def_sites,
286        );
287        assert_eq!(
288            scope2,
289            vec!["src/report.rs".to_string()],
290            "no def-site found → the declared file survives alone"
291        );
292    }
293}