@lamentis/naome 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+ use std::fs;
3
+ use std::path::Path;
4
+
5
+ use naome_core::TaskProofStatus;
6
+ use serde_json::{json, Value};
7
+
8
+ use crate::task_commands::check_run;
9
+
10
+ use super::impact::impact_kind;
11
+
12
+ #[derive(Debug, Clone, PartialEq, Eq)]
13
+ pub(super) struct VerificationCheck {
14
+ id: String,
15
+ command: String,
16
+ cwd: String,
17
+ }
18
+
19
+ pub(in crate::task_commands) fn changed_paths(root: &Path) -> Vec<String> {
20
+ check_run::changed_paths(root).unwrap_or_default()
21
+ }
22
+
23
+ fn read_verification_checks(root: &Path) -> Vec<VerificationCheck> {
24
+ let Ok(content) = fs::read_to_string(root.join(".naome/verification.json")) else {
25
+ return Vec::new();
26
+ };
27
+ let Ok(value) = serde_json::from_str::<Value>(&content) else {
28
+ return Vec::new();
29
+ };
30
+ let mut checks = value
31
+ .get("checks")
32
+ .and_then(Value::as_array)
33
+ .into_iter()
34
+ .flatten()
35
+ .filter_map(|check| {
36
+ Some(VerificationCheck {
37
+ id: check.get("id")?.as_str()?.to_string(),
38
+ command: check.get("command")?.as_str()?.to_string(),
39
+ cwd: check.get("cwd")?.as_str()?.to_string(),
40
+ })
41
+ })
42
+ .collect::<Vec<_>>();
43
+ checks.sort_by(|left, right| left.id.cmp(&right.id));
44
+ checks
45
+ }
46
+
47
+ pub(in crate::task_commands) fn planned_commands(
48
+ root: &Path,
49
+ paths: &[String],
50
+ proof: Option<&TaskProofStatus>,
51
+ ) -> Vec<Value> {
52
+ let mut selected = BTreeMap::<String, (VerificationCheck, BTreeSet<String>, String)>::new();
53
+ for check in read_verification_checks(root) {
54
+ let reasons = check_reasons(&check, paths, proof);
55
+ if reasons.is_empty() {
56
+ continue;
57
+ }
58
+ let proof_reason = proof_reason(&reasons).to_string();
59
+ selected.insert(check.id.clone(), (check, reasons, proof_reason));
60
+ }
61
+ selected
62
+ .into_values()
63
+ .map(|(check, reasons, proof_reason)| command_value(check, reasons, proof_reason, paths))
64
+ .collect()
65
+ }
66
+
67
+ fn check_reasons(
68
+ check: &VerificationCheck,
69
+ paths: &[String],
70
+ proof: Option<&TaskProofStatus>,
71
+ ) -> BTreeSet<String> {
72
+ let mut reasons = BTreeSet::new();
73
+ if let Some(proof) = proof {
74
+ if proof.missing_checks.contains(&check.id) {
75
+ reasons.insert("missing-proof".to_string());
76
+ }
77
+ if proof.stale_checks.contains(&check.id) {
78
+ reasons.insert("stale-proof".to_string());
79
+ }
80
+ }
81
+ for path in paths {
82
+ if check_is_impacted(check, path) {
83
+ reasons.insert(impact_kind(path).to_string());
84
+ }
85
+ }
86
+ reasons
87
+ }
88
+
89
+ fn command_value(
90
+ check: VerificationCheck,
91
+ reasons: BTreeSet<String>,
92
+ proof_reason: String,
93
+ paths: &[String],
94
+ ) -> Value {
95
+ let selection_reason = reasons.into_iter().collect::<Vec<_>>().join(",");
96
+ json!({
97
+ "checkId": check.id,
98
+ "command": check.command,
99
+ "cwd": check.cwd,
100
+ "reason": selection_reason,
101
+ "selectionReason": selection_reason,
102
+ "impactedPaths": paths,
103
+ "proofReason": proof_reason,
104
+ "safeToExecute": safe_to_execute(&check, paths)
105
+ })
106
+ }
107
+
108
+ fn proof_reason(reasons: &BTreeSet<String>) -> &'static str {
109
+ if reasons.contains("missing-proof") {
110
+ "missing"
111
+ } else if reasons.contains("stale-proof") {
112
+ "stale"
113
+ } else if reasons.contains("final_only") || reasons.contains("release_only") {
114
+ "final"
115
+ } else {
116
+ "impacted"
117
+ }
118
+ }
119
+
120
+ fn check_is_impacted(check: &VerificationCheck, path: &str) -> bool {
121
+ let command = check.command.as_str();
122
+ match impact_kind(path) {
123
+ "changed_task_state" => {
124
+ command.contains("check-task-state") || command.contains("test:task-state")
125
+ }
126
+ "changed_architecture_config" => command.contains("arch validate"),
127
+ "changed_package_metadata" => {
128
+ command.contains("pack:dry-run") || command.contains("test:decision-engine")
129
+ }
130
+ "changed_native_binary" | "changed_templates" => {
131
+ command.contains("check-harness-health") || command.contains("pack:dry-run")
132
+ }
133
+ "changed_docs" => command.contains("quality check") || command.contains("semantic check"),
134
+ "changed_tests" => {
135
+ command.contains("quality check")
136
+ || command.contains("semantic check")
137
+ || command.contains("test:")
138
+ }
139
+ _ => {
140
+ command == "git diff --check"
141
+ || command.contains("quality check")
142
+ || command.contains("semantic check")
143
+ || command.contains("arch validate")
144
+ }
145
+ }
146
+ }
147
+
148
+ fn safe_to_execute(check: &VerificationCheck, paths: &[String]) -> bool {
149
+ if check.cwd != "." {
150
+ return false;
151
+ }
152
+ match check.command.as_str() {
153
+ "git diff --check"
154
+ | "node .naome/bin/check-harness-health.js"
155
+ | "node .naome/bin/check-task-state.js"
156
+ | "node .naome/bin/naome.js quality check --changed"
157
+ | "node .naome/bin/naome.js semantic check --changed"
158
+ | "node .naome/bin/naome.js arch validate --changed-only" => true,
159
+ "npm run check:task-state" | "npm run test:task-state" | "npm run test:decision-engine" => {
160
+ !paths
161
+ .iter()
162
+ .any(|path| path == "package.json" || path == "packages/naome/package.json")
163
+ }
164
+ _ => false,
165
+ }
166
+ }
@@ -0,0 +1,35 @@
1
+ pub(in crate::task_commands) fn impact_kind(path: &str) -> &'static str {
2
+ if path == ".naome/task-state.json" {
3
+ "changed_task_state"
4
+ } else if path == "naome.arch.yaml" || path.ends_with("/naome.arch.yaml") {
5
+ "changed_architecture_config"
6
+ } else if path == "package.json"
7
+ || path.ends_with("/package.json")
8
+ || path.ends_with("Cargo.toml")
9
+ || path.ends_with("Cargo.lock")
10
+ {
11
+ "changed_package_metadata"
12
+ } else if path.contains("/native/") || path.ends_with(".node") || path.ends_with(".so") {
13
+ "changed_native_binary"
14
+ } else if path.contains("/templates/") || path.starts_with("templates/") {
15
+ "changed_templates"
16
+ } else if path.ends_with(".md") || path.starts_with("docs/") {
17
+ "changed_docs"
18
+ } else if path.contains("test") || path.contains("spec") {
19
+ "changed_tests"
20
+ } else {
21
+ "changed_source"
22
+ }
23
+ }
24
+
25
+ pub(in crate::task_commands) fn path_risk(path: &str) -> &'static str {
26
+ match impact_kind(path) {
27
+ "changed_package_metadata"
28
+ | "changed_native_binary"
29
+ | "changed_templates"
30
+ | "changed_task_state"
31
+ | "changed_architecture_config" => "high",
32
+ "changed_source" | "changed_tests" => "medium",
33
+ _ => "low",
34
+ }
35
+ }
@@ -0,0 +1,24 @@
1
+ use serde_json::Value;
2
+
3
+ mod checks;
4
+ mod impact;
5
+
6
+ pub(super) use checks::{changed_paths, planned_commands};
7
+ pub(super) use impact::path_risk;
8
+
9
+ pub(super) fn split_safe_commands(commands: &[Value]) -> (Vec<Value>, Vec<Value>) {
10
+ let mut safe = Vec::new();
11
+ let mut deferred = Vec::new();
12
+ for command in commands {
13
+ if command
14
+ .get("safeToExecute")
15
+ .and_then(Value::as_bool)
16
+ .unwrap_or(false)
17
+ {
18
+ safe.push(command.clone());
19
+ } else {
20
+ deferred.push(command.clone());
21
+ }
22
+ }
23
+ (safe, deferred)
24
+ }
@@ -0,0 +1,208 @@
1
+ use std::path::Path;
2
+
3
+ use naome_core::{path_matches_any, task_status_report, TaskStatusReportV1};
4
+ use serde_json::{json, Value};
5
+
6
+ use super::common::{agent_session, print_json_with_session, repeated_values};
7
+ use super::path_policy::{is_control_path, is_ignored, normalize_requested_path};
8
+ use super::planner;
9
+
10
+ pub(super) fn preflight(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
11
+ let session = agent_session(args)?;
12
+ let paths = requested_paths(root, args);
13
+ let status = task_status_report(root)?;
14
+ let mut findings = target_findings(selector_supplied(args));
15
+ let mut must_not_edit = Vec::new();
16
+ let mut normalized_paths = Vec::new();
17
+ let path_reports = path_reports(
18
+ root,
19
+ &paths,
20
+ &status,
21
+ &mut findings,
22
+ &mut must_not_edit,
23
+ &mut normalized_paths,
24
+ );
25
+ normalized_paths.sort();
26
+ normalized_paths.dedup();
27
+ must_not_edit.sort();
28
+ must_not_edit.dedup();
29
+ let recommended = planner::planned_commands(root, &normalized_paths, Some(&status.proof));
30
+ let (safe, _deferred) = planner::split_safe_commands(&recommended);
31
+ let blocked = !findings.is_empty() || !must_not_edit.is_empty();
32
+ print_json_with_session(
33
+ json!({
34
+ "schema": "naome.task.preflight.v1",
35
+ "paths": path_reports,
36
+ "recommendedCommands": recommended,
37
+ "safeCommands": safe,
38
+ "mustNotEdit": must_not_edit,
39
+ "expectedProofPathSet": normalized_paths,
40
+ "findings": findings,
41
+ "nextAction": {
42
+ "type": if blocked { "blocked" } else { "edit" },
43
+ "reason": if blocked { "Preflight is blocked until every requested path is explicit and editable." } else { "All requested paths are editable." },
44
+ "paths": must_not_edit,
45
+ "commands": [],
46
+ "checkIds": [],
47
+ "safeToExecute": !blocked,
48
+ "requiresUserApproval": blocked
49
+ }
50
+ }),
51
+ session.as_deref(),
52
+ )
53
+ }
54
+
55
+ fn requested_paths(root: &Path, args: &[String]) -> Vec<String> {
56
+ let mut paths = if args.iter().any(|arg| arg == "--from-changed") {
57
+ planner::changed_paths(root)
58
+ } else {
59
+ repeated_values(args, "--path")
60
+ };
61
+ paths.sort();
62
+ paths.dedup();
63
+ paths
64
+ }
65
+
66
+ fn selector_supplied(args: &[String]) -> bool {
67
+ args.iter()
68
+ .any(|arg| arg == "--from-changed" || arg == "--path")
69
+ }
70
+
71
+ fn target_findings(selector_supplied: bool) -> Vec<Value> {
72
+ if !selector_supplied {
73
+ vec![finding(
74
+ "task.preflight.missing_target_paths",
75
+ "Preflight requires --from-changed or at least one --path.",
76
+ "",
77
+ )]
78
+ } else {
79
+ Vec::new()
80
+ }
81
+ }
82
+
83
+ fn path_reports(
84
+ root: &Path,
85
+ paths: &[String],
86
+ status: &TaskStatusReportV1,
87
+ findings: &mut Vec<Value>,
88
+ must_not_edit: &mut Vec<String>,
89
+ normalized_paths: &mut Vec<String>,
90
+ ) -> Vec<Value> {
91
+ paths
92
+ .iter()
93
+ .map(|raw_path| {
94
+ path_report(
95
+ root,
96
+ raw_path,
97
+ status,
98
+ findings,
99
+ must_not_edit,
100
+ normalized_paths,
101
+ )
102
+ })
103
+ .collect()
104
+ }
105
+
106
+ fn path_report(
107
+ root: &Path,
108
+ raw_path: &str,
109
+ status: &TaskStatusReportV1,
110
+ findings: &mut Vec<Value>,
111
+ must_not_edit: &mut Vec<String>,
112
+ normalized_paths: &mut Vec<String>,
113
+ ) -> Value {
114
+ let (path, path_error) = normalize_requested_path(raw_path);
115
+ let ignored = path_error.is_none() && is_ignored(root, &path);
116
+ let control = path_error.is_none() && is_control_path(&path);
117
+ let in_scope = path_error.is_none() && path_matches_any(&path, &status.scope.allowed_paths);
118
+ let editable = path_editable(status, path_error.is_none(), ignored, control, in_scope);
119
+ track_path_report(&path, editable, must_not_edit, normalized_paths);
120
+ add_path_finding(&path, path_error, ignored, control, in_scope, findings);
121
+ let commands =
122
+ planner::planned_commands(root, std::slice::from_ref(&path), Some(&status.proof));
123
+ json!({
124
+ "path": path,
125
+ "editable": editable,
126
+ "reason": if editable { "Path is editable for the active task." } else { "Path is not editable without scope or state repair." },
127
+ "ignored": ignored,
128
+ "controlPath": control,
129
+ "inScope": in_scope,
130
+ "impactedChecks": commands.iter().filter_map(|command| command.get("checkId").and_then(Value::as_str).map(ToString::to_string)).collect::<Vec<_>>(),
131
+ "requiredBeforeCommit": commands,
132
+ "risk": planner::path_risk(raw_path),
133
+ "agentInstruction": if editable { "Agent may edit this path, then rerun task agent-snapshot or task loop." } else { "Do not edit this path in the current task." }
134
+ })
135
+ }
136
+
137
+ fn path_editable(
138
+ status: &TaskStatusReportV1,
139
+ valid_path: bool,
140
+ ignored: bool,
141
+ control: bool,
142
+ in_scope: bool,
143
+ ) -> bool {
144
+ valid_path
145
+ && !ignored
146
+ && !control
147
+ && in_scope
148
+ && status.task_id.is_some()
149
+ && !matches!(
150
+ status.state.as_str(),
151
+ "idle" | "missing" | "complete" | "blocked" | "needs_human_review"
152
+ )
153
+ }
154
+
155
+ fn track_path_report(
156
+ path: &str,
157
+ editable: bool,
158
+ must_not_edit: &mut Vec<String>,
159
+ normalized_paths: &mut Vec<String>,
160
+ ) {
161
+ if editable {
162
+ normalized_paths.push(path.to_string());
163
+ } else {
164
+ must_not_edit.push(path.to_string());
165
+ }
166
+ }
167
+
168
+ fn add_path_finding(
169
+ path: &str,
170
+ path_error: Option<String>,
171
+ ignored: bool,
172
+ control: bool,
173
+ in_scope: bool,
174
+ findings: &mut Vec<Value>,
175
+ ) {
176
+ if let Some(message) = path_error {
177
+ findings.push(finding("task.preflight.unsafe_path", &message, path));
178
+ } else if ignored {
179
+ findings.push(finding(
180
+ "task.preflight.ignored_path",
181
+ "Path is ignored by .naomeignore.",
182
+ path,
183
+ ));
184
+ } else if control {
185
+ findings.push(finding(
186
+ "task.preflight.control_path",
187
+ "Path is a NAOME control path.",
188
+ path,
189
+ ));
190
+ } else if !in_scope {
191
+ findings.push(finding(
192
+ "task.preflight.out_of_scope",
193
+ "Path is outside active task scope.",
194
+ path,
195
+ ));
196
+ }
197
+ }
198
+
199
+ fn finding(id: &str, message: &str, path: &str) -> Value {
200
+ json!({
201
+ "id": id,
202
+ "severity": "error",
203
+ "message": message,
204
+ "path": path,
205
+ "suggestedFix": "Use an in-scope non-control path or request explicit scope revision.",
206
+ "agentInstruction": "Do not edit this path unless a human-approved task scope revision allows it."
207
+ })
208
+ }
@@ -158,9 +158,6 @@ fn proof_batch(
158
158
  "exitCode": receipt.exit_code,
159
159
  "checkedAt": receipt.checked_at,
160
160
  "evidenceFingerprint": receipt.evidence_fingerprint,
161
- "stdoutSummary": receipt.stdout_summary,
162
- "stderrSummary": receipt.stderr_summary,
163
- "durationMs": receipt.duration_ms,
164
161
  "agentSession": receipt.agent_session
165
162
  })
166
163
  }).collect::<Vec<_>>()
@@ -174,42 +171,82 @@ fn recordable_receipts(
174
171
  ) -> Result<(bool, Vec<Value>, Vec<CheckRunReceipt>), Box<dyn std::error::Error>> {
175
172
  let receipts = successful_receipts(root)?;
176
173
  let current_fingerprint = evidence_fingerprint(root, paths)?;
174
+ let task_id = read_task_state(root)?
175
+ .get("activeTask")
176
+ .and_then(|task| task.get("id"))
177
+ .and_then(Value::as_str)
178
+ .map(ToString::to_string);
177
179
  let mut selected = Vec::new();
178
180
  let mut findings = Vec::new();
179
181
  for check_id in check_ids {
180
182
  let expected = super::check_run::read_verification_check(root, check_id)?;
181
- let receipt = receipts
183
+ let matching_check = receipts
182
184
  .iter()
183
185
  .rev()
184
- .find(|receipt| {
185
- receipt.check_id == *check_id
186
- && expected.as_ref().is_some_and(|check| {
187
- receipt.command == check.command && receipt.cwd == check.cwd
188
- })
189
- && paths.iter().all(|path| {
190
- receipt
191
- .evidence_paths
192
- .iter()
193
- .any(|evidence| evidence == path)
194
- })
195
- && receipt.evidence_fingerprint == current_fingerprint
196
- })
197
- .cloned();
198
- match receipt {
199
- Some(receipt) => selected.push(receipt),
200
- None => findings.push(json!({
201
- "id": "task.proof.no_recent_success",
186
+ .find(|receipt| receipt.check_id == *check_id);
187
+ let Some(receipt) = matching_check else {
188
+ findings.push(no_recent_success(check_id));
189
+ continue;
190
+ };
191
+ if receipt.task_id != task_id {
192
+ findings.push(json!({
193
+ "id": "task.proof.receipt_task_mismatch",
194
+ "severity": "error",
195
+ "message": format!("Recent receipt for {check_id} was produced for a different task."),
196
+ "path": null,
197
+ "suggestedFix": "Rerun the check for the active task before recording proof.",
198
+ "agentInstruction": "Do not record proof from another task."
199
+ }));
200
+ continue;
201
+ }
202
+ if !expected
203
+ .as_ref()
204
+ .is_some_and(|check| receipt.command == check.command && receipt.cwd == check.cwd)
205
+ {
206
+ findings.push(no_recent_success(check_id));
207
+ continue;
208
+ }
209
+ if paths != receipt.evidence_paths {
210
+ findings.push(json!({
211
+ "id": "task.proof.receipt_path_mismatch",
202
212
  "severity": "error",
203
- "message": format!("No recent successful safe check evidence covers current task paths for {check_id}."),
213
+ "message": format!("Recent receipt for {check_id} covers a different path set."),
204
214
  "path": null,
205
- "suggestedFix": "Run naome task run-check --check <id> --record-proof --json or naome task loop --execute-safe --json.",
206
- "agentInstruction": "Do not record proof until NAOME has just executed the check successfully."
207
- })),
215
+ "suggestedFix": "Rerun the check against the current task-owned changed paths.",
216
+ "agentInstruction": "Do not record proof when receipt paths differ from the current task diff."
217
+ }));
218
+ continue;
219
+ }
220
+ if receipt.evidence_fingerprint != current_fingerprint {
221
+ findings.push(json!({
222
+ "id": "task.proof.stale_receipt_content",
223
+ "severity": "error",
224
+ "message": format!("Recent receipt for {check_id} does not match current file contents."),
225
+ "path": null,
226
+ "suggestedFix": "Rerun the check after the latest edits.",
227
+ "agentInstruction": "Do not record proof from stale check output."
228
+ }));
229
+ continue;
230
+ }
231
+ match matching_check.cloned() {
232
+ Some(receipt) => selected.push(receipt),
233
+ None => findings.push(no_recent_success(check_id)),
208
234
  }
209
235
  }
210
236
  Ok((findings.is_empty(), findings, selected))
211
237
  }
212
238
 
239
+ fn no_recent_success(check_id: &str) -> Value {
240
+ json!({
241
+ "id": "task.proof.no_recent_success",
242
+ "severity": "error",
243
+ "message": format!("No recent successful safe check evidence covers current task paths for {check_id}."),
244
+ "path": null,
245
+ "suggestedFix": "Run naome task run-check --check <id> --record-proof --json or naome task loop --execute-safe --json.",
246
+ "agentInstruction": "Do not record proof until NAOME has just executed the check successfully."
247
+ })
248
+ }
249
+
213
250
  fn unique_path_set_id(path_sets: &serde_json::Map<String, Value>, base: &str) -> String {
214
251
  unique_id(base, |candidate| path_sets.contains_key(candidate))
215
252
  }
@@ -0,0 +1,109 @@
1
+ use std::path::Path;
2
+ use std::process::Command;
3
+
4
+ use serde_json::json;
5
+
6
+ use super::common::{agent_session, print_json_with_session};
7
+ use super::path_policy::is_ignored;
8
+ use super::planner;
9
+
10
+ pub(super) fn scope_suggestions(
11
+ root: &Path,
12
+ args: &[String],
13
+ ) -> Result<(), Box<dyn std::error::Error>> {
14
+ let session = agent_session(args)?;
15
+ if !args.iter().any(|arg| arg == "--from-changed") {
16
+ return Err("naome task scope-suggestions requires --from-changed".into());
17
+ }
18
+ let mut suggestions = rename_suggestions(root)
19
+ .into_iter()
20
+ .chain(loader_suggestions(root))
21
+ .filter(|suggestion| {
22
+ !suggestion
23
+ .get("path")
24
+ .and_then(serde_json::Value::as_str)
25
+ .is_some_and(|path| is_ignored(root, path))
26
+ })
27
+ .collect::<Vec<_>>();
28
+ suggestions.sort_by(|left, right| {
29
+ left.get("path")
30
+ .and_then(serde_json::Value::as_str)
31
+ .unwrap_or("")
32
+ .cmp(
33
+ right
34
+ .get("path")
35
+ .and_then(serde_json::Value::as_str)
36
+ .unwrap_or(""),
37
+ )
38
+ });
39
+ print_json_with_session(
40
+ json!({
41
+ "schema": "naome.task.scope-suggestions.v1",
42
+ "suggestions": suggestions,
43
+ "agentInstruction": "Suggestions are read-only hints; task scope mutation still requires explicit approval."
44
+ }),
45
+ session.as_deref(),
46
+ )
47
+ }
48
+
49
+ fn rename_suggestions(root: &Path) -> Vec<serde_json::Value> {
50
+ let Ok(output) = Command::new("git")
51
+ .args(["status", "--porcelain=v1", "-z"])
52
+ .current_dir(root)
53
+ .output()
54
+ else {
55
+ return Vec::new();
56
+ };
57
+ if !output.status.success() {
58
+ return Vec::new();
59
+ }
60
+ let mut suggestions = Vec::new();
61
+ let entries = output.stdout.split(|byte| *byte == 0).collect::<Vec<_>>();
62
+ let mut index = 0;
63
+ while index < entries.len() {
64
+ let entry = entries[index];
65
+ index += 1;
66
+ if entry.len() < 4 {
67
+ continue;
68
+ }
69
+ let status = String::from_utf8_lossy(&entry[..2]);
70
+ if status.contains('R') && index < entries.len() {
71
+ let old = String::from_utf8_lossy(entries[index]).replace('\\', "/");
72
+ index += 1;
73
+ let new = String::from_utf8_lossy(&entry[3..]).replace('\\', "/");
74
+ for path in [old, new] {
75
+ suggestions.push(json!({
76
+ "path": path,
77
+ "reason": "paired path for renamed file",
78
+ "confidence": 0.95,
79
+ "requiresUserApproval": true
80
+ }));
81
+ }
82
+ }
83
+ }
84
+ suggestions
85
+ }
86
+
87
+ fn loader_suggestions(root: &Path) -> Vec<serde_json::Value> {
88
+ planner::changed_paths(root)
89
+ .into_iter()
90
+ .filter(|path| path.ends_with(".test.js") || path.ends_with("_test.rs"))
91
+ .filter_map(|path| {
92
+ let loader = if path.starts_with("scripts/") {
93
+ Some("scripts/naome-installer-support.js".to_string())
94
+ } else if path.contains("/tests/") {
95
+ path.split("/tests/")
96
+ .next()
97
+ .map(|prefix| format!("{prefix}/tests/support/mod.rs"))
98
+ } else {
99
+ None
100
+ }?;
101
+ Some(json!({
102
+ "path": loader,
103
+ "reason": "fixture support helper for changed test",
104
+ "confidence": 0.7,
105
+ "requiresUserApproval": true
106
+ }))
107
+ })
108
+ .collect()
109
+ }