@lamentis/naome 1.1.2 → 1.2.0
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.
- package/Cargo.lock +2 -2
- package/Cargo.toml +1 -1
- package/LICENSE +180 -21
- package/README.md +49 -6
- package/bin/naome.js +54 -16
- package/crates/naome-cli/Cargo.toml +1 -1
- package/crates/naome-cli/src/check_commands.rs +135 -0
- package/crates/naome-cli/src/cli_args.rs +5 -0
- package/crates/naome-cli/src/dispatcher.rs +36 -0
- package/crates/naome-cli/src/install_bridge.rs +83 -0
- package/crates/naome-cli/src/main.rs +57 -341
- package/crates/naome-cli/src/prompt_commands.rs +68 -0
- package/crates/naome-cli/src/quality_commands.rs +141 -0
- package/crates/naome-cli/src/simple_commands.rs +53 -0
- package/crates/naome-cli/src/workflow_commands.rs +153 -0
- package/crates/naome-core/Cargo.toml +1 -1
- package/crates/naome-core/src/harness_health/integrity.rs +96 -0
- package/crates/naome-core/src/harness_health.rs +14 -126
- package/crates/naome-core/src/install_plan.rs +3 -0
- package/crates/naome-core/src/intent/classifier.rs +171 -0
- package/crates/naome-core/src/intent/envelope.rs +108 -0
- package/crates/naome-core/src/intent/legacy.rs +138 -0
- package/crates/naome-core/src/intent/legacy_response.rs +76 -0
- package/crates/naome-core/src/intent/model.rs +71 -0
- package/crates/naome-core/src/intent/patterns.rs +170 -0
- package/crates/naome-core/src/intent/resolver.rs +162 -0
- package/crates/naome-core/src/intent/resolver_active.rs +17 -0
- package/crates/naome-core/src/intent/resolver_baseline.rs +55 -0
- package/crates/naome-core/src/intent/resolver_catalog.rs +167 -0
- package/crates/naome-core/src/intent/resolver_policy.rs +72 -0
- package/crates/naome-core/src/intent/resolver_shared.rs +55 -0
- package/crates/naome-core/src/intent/risk.rs +40 -0
- package/crates/naome-core/src/intent/segment.rs +170 -0
- package/crates/naome-core/src/intent.rs +64 -879
- package/crates/naome-core/src/journal.rs +9 -20
- package/crates/naome-core/src/lib.rs +13 -0
- package/crates/naome-core/src/quality/adapters.rs +178 -0
- package/crates/naome-core/src/quality/baseline.rs +75 -0
- package/crates/naome-core/src/quality/checks/duplicate_blocks.rs +175 -0
- package/crates/naome-core/src/quality/checks/near_duplicates.rs +130 -0
- package/crates/naome-core/src/quality/checks.rs +228 -0
- package/crates/naome-core/src/quality/cleanup.rs +72 -0
- package/crates/naome-core/src/quality/config.rs +109 -0
- package/crates/naome-core/src/quality/mod.rs +90 -0
- package/crates/naome-core/src/quality/scanner/repo_paths.rs +103 -0
- package/crates/naome-core/src/quality/scanner.rs +367 -0
- package/crates/naome-core/src/quality/types.rs +289 -0
- package/crates/naome-core/src/route.rs +62 -0
- package/crates/naome-core/src/task_state/admission.rs +63 -0
- package/crates/naome-core/src/task_state/admission_proof.rs +72 -0
- package/crates/naome-core/src/task_state/api.rs +130 -0
- package/crates/naome-core/src/task_state/commit_gate.rs +138 -0
- package/crates/naome-core/src/task_state/compact_proof.rs +160 -0
- package/crates/naome-core/src/task_state/completed_refresh.rs +89 -0
- package/crates/naome-core/src/task_state/completion.rs +72 -0
- package/crates/naome-core/src/task_state/deleted_paths.rs +47 -0
- package/crates/naome-core/src/task_state/diff.rs +95 -0
- package/crates/naome-core/src/task_state/evidence.rs +154 -0
- package/crates/naome-core/src/task_state/git_io.rs +86 -0
- package/crates/naome-core/src/task_state/git_parse.rs +86 -0
- package/crates/naome-core/src/task_state/git_refs.rs +37 -0
- package/crates/naome-core/src/task_state/human_review_state.rs +31 -0
- package/crates/naome-core/src/task_state/mod.rs +38 -0
- package/crates/naome-core/src/task_state/process_guard.rs +40 -0
- package/crates/naome-core/src/task_state/progress.rs +123 -0
- package/crates/naome-core/src/task_state/proof.rs +139 -0
- package/crates/naome-core/src/task_state/proof_entry.rs +66 -0
- package/crates/naome-core/src/task_state/proof_model.rs +70 -0
- package/crates/naome-core/src/task_state/proof_sources.rs +76 -0
- package/crates/naome-core/src/task_state/push_gate.rs +49 -0
- package/crates/naome-core/src/task_state/reconcile.rs +7 -0
- package/crates/naome-core/src/task_state/repair.rs +168 -0
- package/crates/naome-core/src/task_state/shape.rs +117 -0
- package/crates/naome-core/src/task_state/task_diff_api.rs +170 -0
- package/crates/naome-core/src/task_state/task_records.rs +131 -0
- package/crates/naome-core/src/task_state/task_references.rs +126 -0
- package/crates/naome-core/src/task_state/types.rs +87 -0
- package/crates/naome-core/src/task_state/util.rs +137 -0
- package/crates/naome-core/src/verification/render.rs +122 -0
- package/crates/naome-core/src/verification.rs +176 -58
- package/crates/naome-core/src/verification_contract.rs +49 -21
- package/crates/naome-core/src/workflow/integrity.rs +123 -0
- package/crates/naome-core/src/workflow/integrity_normalize.rs +7 -0
- package/crates/naome-core/src/workflow/integrity_support.rs +110 -0
- package/crates/naome-core/src/workflow/mod.rs +18 -0
- package/crates/naome-core/src/workflow/mutation.rs +68 -0
- package/crates/naome-core/src/workflow/output.rs +111 -0
- package/crates/naome-core/src/workflow/phase_inference.rs +73 -0
- package/crates/naome-core/src/workflow/phases.rs +169 -0
- package/crates/naome-core/src/workflow/policy.rs +156 -0
- package/crates/naome-core/src/workflow/processes.rs +91 -0
- package/crates/naome-core/src/workflow/types.rs +42 -0
- package/crates/naome-core/tests/harness_health.rs +3 -0
- package/crates/naome-core/tests/intent.rs +97 -792
- package/crates/naome-core/tests/intent_support/mod.rs +133 -0
- package/crates/naome-core/tests/intent_v2.rs +90 -0
- package/crates/naome-core/tests/quality.rs +425 -0
- package/crates/naome-core/tests/route.rs +88 -188
- package/crates/naome-core/tests/task_state.rs +3 -0
- package/crates/naome-core/tests/task_state_compact.rs +110 -0
- package/crates/naome-core/tests/task_state_compact_support/mod.rs +5 -0
- package/crates/naome-core/tests/task_state_compact_support/repo.rs +130 -0
- package/crates/naome-core/tests/task_state_compact_support/states.rs +151 -0
- package/crates/naome-core/tests/workflow_integrity.rs +85 -0
- package/crates/naome-core/tests/workflow_policy.rs +139 -0
- package/crates/naome-core/tests/workflow_support/mod.rs +194 -0
- package/native/darwin-arm64/naome +0 -0
- package/native/linux-x64/naome +0 -0
- package/package.json +2 -2
- package/templates/naome-root/.naome/bin/check-harness-health.js +66 -85
- package/templates/naome-root/.naome/bin/check-task-state.js +9 -10
- package/templates/naome-root/.naome/bin/naome.js +34 -63
- package/templates/naome-root/.naome/manifest.json +20 -18
- package/templates/naome-root/.naome/repository-quality-baseline.json +5 -0
- package/templates/naome-root/.naome/repository-quality.json +24 -0
- package/templates/naome-root/.naome/task-contract.schema.json +93 -11
- package/templates/naome-root/.naome/upgrade-state.json +1 -1
- package/templates/naome-root/.naome/verification.json +37 -0
- package/templates/naome-root/AGENTS.md +3 -0
- package/templates/naome-root/docs/naome/agent-workflow.md +25 -12
- package/templates/naome-root/docs/naome/execution.md +25 -21
- package/templates/naome-root/docs/naome/index.md +4 -3
- package/templates/naome-root/docs/naome/repository-quality.md +43 -0
- package/templates/naome-root/docs/naome/testing.md +12 -0
- package/crates/naome-core/src/task_state.rs +0 -2210
|
@@ -1,2210 +0,0 @@
|
|
|
1
|
-
use std::collections::{HashMap, HashSet};
|
|
2
|
-
use std::fs;
|
|
3
|
-
use std::path::{Path, PathBuf};
|
|
4
|
-
use std::process::Command;
|
|
5
|
-
|
|
6
|
-
use serde_json::Value;
|
|
7
|
-
|
|
8
|
-
use crate::harness_health::{validate_harness_health, HarnessHealthOptions};
|
|
9
|
-
use crate::install_plan::MACHINE_OWNED_PATHS;
|
|
10
|
-
use crate::models::NaomeError;
|
|
11
|
-
|
|
12
|
-
const CONTROL_STATE_PATH: &str = ".naome/task-state.json";
|
|
13
|
-
const ALLOWED_STATUS: &[&str] = &[
|
|
14
|
-
"idle",
|
|
15
|
-
"planning",
|
|
16
|
-
"implementing",
|
|
17
|
-
"revising",
|
|
18
|
-
"verifying",
|
|
19
|
-
"needs_human_review",
|
|
20
|
-
"blocked",
|
|
21
|
-
"complete",
|
|
22
|
-
];
|
|
23
|
-
const BLOCKING_STATUS: &[&str] = &[
|
|
24
|
-
"planning",
|
|
25
|
-
"implementing",
|
|
26
|
-
"revising",
|
|
27
|
-
"verifying",
|
|
28
|
-
"needs_human_review",
|
|
29
|
-
"blocked",
|
|
30
|
-
];
|
|
31
|
-
const ALLOWED_EVIDENCE_STATUS: &[&str] = &["added", "modified", "deleted", "renamed"];
|
|
32
|
-
|
|
33
|
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
34
|
-
pub enum TaskStateMode {
|
|
35
|
-
State,
|
|
36
|
-
Admission,
|
|
37
|
-
Progress,
|
|
38
|
-
CommitGate,
|
|
39
|
-
PushGate,
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
#[derive(Debug, Clone)]
|
|
43
|
-
pub struct TaskStateOptions {
|
|
44
|
-
pub mode: TaskStateMode,
|
|
45
|
-
pub harness_health: Option<HarnessHealthOptions>,
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
impl Default for TaskStateOptions {
|
|
49
|
-
fn default() -> Self {
|
|
50
|
-
Self {
|
|
51
|
-
mode: TaskStateMode::State,
|
|
52
|
-
harness_health: None,
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
58
|
-
pub struct TaskStateReport {
|
|
59
|
-
pub errors: Vec<String>,
|
|
60
|
-
pub notices: Vec<String>,
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
#[derive(Debug, Clone)]
|
|
64
|
-
struct ChangedEntry {
|
|
65
|
-
path: String,
|
|
66
|
-
status: String,
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
70
|
-
pub struct CompletedTaskHarnessRefreshDiff {
|
|
71
|
-
pub harness_paths: Vec<String>,
|
|
72
|
-
pub task_paths: Vec<String>,
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
76
|
-
pub struct HarnessRefreshWithUnrelatedDiff {
|
|
77
|
-
pub harness_paths: Vec<String>,
|
|
78
|
-
pub unrelated_paths: Vec<String>,
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
82
|
-
pub struct HarnessRefreshDiff {
|
|
83
|
-
pub harness_paths: Vec<String>,
|
|
84
|
-
pub unrelated_paths: Vec<String>,
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
88
|
-
pub struct CompletedTaskCommitDiff {
|
|
89
|
-
pub task_paths: Vec<String>,
|
|
90
|
-
pub unrelated_paths: Vec<String>,
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
pub fn validate_task_state(
|
|
94
|
-
root: &Path,
|
|
95
|
-
options: TaskStateOptions,
|
|
96
|
-
) -> Result<TaskStateReport, NaomeError> {
|
|
97
|
-
let mut report = TaskStateReport {
|
|
98
|
-
errors: Vec::new(),
|
|
99
|
-
notices: Vec::new(),
|
|
100
|
-
};
|
|
101
|
-
let Some(task_state) = read_json(root, ".naome/task-state.json", &mut report.errors)? else {
|
|
102
|
-
return Ok(report);
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
validate_task_state_shape(&task_state, &mut report.errors);
|
|
106
|
-
let status = task_state
|
|
107
|
-
.get("status")
|
|
108
|
-
.and_then(Value::as_str)
|
|
109
|
-
.unwrap_or("invalid");
|
|
110
|
-
if !ALLOWED_STATUS.contains(&status) {
|
|
111
|
-
return Ok(report);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
validate_harness_health_gate(root, &options, &mut report.errors)?;
|
|
115
|
-
if !report.errors.is_empty() {
|
|
116
|
-
return Ok(report);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
match options.mode {
|
|
120
|
-
TaskStateMode::Admission => {
|
|
121
|
-
validate_admission(&task_state, root, &mut report.errors)?;
|
|
122
|
-
return Ok(report);
|
|
123
|
-
}
|
|
124
|
-
TaskStateMode::Progress => {
|
|
125
|
-
validate_progress(&task_state, root, &mut report.errors)?;
|
|
126
|
-
return Ok(report);
|
|
127
|
-
}
|
|
128
|
-
TaskStateMode::CommitGate => {
|
|
129
|
-
validate_commit_gate(&task_state, root, &mut report.errors, &mut report.notices)?;
|
|
130
|
-
return Ok(report);
|
|
131
|
-
}
|
|
132
|
-
TaskStateMode::PushGate => {
|
|
133
|
-
validate_push_gate(&task_state, &mut report.errors);
|
|
134
|
-
return Ok(report);
|
|
135
|
-
}
|
|
136
|
-
TaskStateMode::State => {}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if status == "idle" {
|
|
140
|
-
validate_idle_state(&task_state, &mut report.errors);
|
|
141
|
-
return Ok(report);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
let active_error_start = report.errors.len();
|
|
145
|
-
validate_active_task(task_state.get("activeTask"), &mut report.errors);
|
|
146
|
-
validate_pending_upgrade(&task_state, root, &mut report.errors)?;
|
|
147
|
-
validate_active_task_references(
|
|
148
|
-
task_state.get("activeTask"),
|
|
149
|
-
root,
|
|
150
|
-
&mut report.errors,
|
|
151
|
-
Some(status),
|
|
152
|
-
)?;
|
|
153
|
-
|
|
154
|
-
if status == "needs_human_review" {
|
|
155
|
-
validate_blocker(task_state.get("blocker"), &mut report.errors);
|
|
156
|
-
validate_human_review_blocker_paths(
|
|
157
|
-
task_state.get("activeTask"),
|
|
158
|
-
task_state.get("blocker"),
|
|
159
|
-
root,
|
|
160
|
-
&mut report.errors,
|
|
161
|
-
)?;
|
|
162
|
-
validate_proof_evidence_covers_changed_paths(
|
|
163
|
-
task_state.get("activeTask"),
|
|
164
|
-
root,
|
|
165
|
-
&mut report.errors,
|
|
166
|
-
)?;
|
|
167
|
-
if report.errors.len() > active_error_start {
|
|
168
|
-
report.errors.push("needs_human_review task state is invalid; fix blocker paths and proof evidence before asking for a human decision.".to_string());
|
|
169
|
-
} else {
|
|
170
|
-
report.errors.push(format_blocker(
|
|
171
|
-
"Human review required",
|
|
172
|
-
task_state.get("blocker"),
|
|
173
|
-
));
|
|
174
|
-
}
|
|
175
|
-
return Ok(report);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if status == "blocked" {
|
|
179
|
-
validate_blocker(task_state.get("blocker"), &mut report.errors);
|
|
180
|
-
report
|
|
181
|
-
.errors
|
|
182
|
-
.push(format_blocker("Task is blocked", task_state.get("blocker")));
|
|
183
|
-
return Ok(report);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if BLOCKING_STATUS.contains(&status) {
|
|
187
|
-
report.errors.push(format!(
|
|
188
|
-
"Task is still {status}; new work must wait until the active task is complete or resolved."
|
|
189
|
-
));
|
|
190
|
-
return Ok(report);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
validate_complete_task(
|
|
194
|
-
task_state.get("activeTask"),
|
|
195
|
-
task_state.get("blocker"),
|
|
196
|
-
root,
|
|
197
|
-
&mut report.errors,
|
|
198
|
-
&mut report.notices,
|
|
199
|
-
)?;
|
|
200
|
-
Ok(report)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
pub fn completed_task_commit_paths(root: &Path) -> Result<Vec<String>, NaomeError> {
|
|
204
|
-
Ok(completed_task_commit_diff(root)?
|
|
205
|
-
.map(|diff| diff.task_paths)
|
|
206
|
-
.unwrap_or_default())
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
pub fn completed_task_commit_diff(
|
|
210
|
-
root: &Path,
|
|
211
|
-
) -> Result<Option<CompletedTaskCommitDiff>, NaomeError> {
|
|
212
|
-
let mut read_errors = Vec::new();
|
|
213
|
-
let Some(task_state) = read_json(root, ".naome/task-state.json", &mut read_errors)? else {
|
|
214
|
-
return Ok(None);
|
|
215
|
-
};
|
|
216
|
-
if !read_errors.is_empty()
|
|
217
|
-
|| task_state.get("status").and_then(Value::as_str) != Some("complete")
|
|
218
|
-
{
|
|
219
|
-
return Ok(None);
|
|
220
|
-
}
|
|
221
|
-
let Some(active_task) = task_state.get("activeTask") else {
|
|
222
|
-
return Ok(None);
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
let allowed_paths = string_array(active_task.get("allowedPaths")).unwrap_or_default();
|
|
226
|
-
let mut task_entries = Vec::new();
|
|
227
|
-
let mut unrelated_paths = Vec::new();
|
|
228
|
-
for entry in read_git_changed_entries(root)? {
|
|
229
|
-
if entry.path == CONTROL_STATE_PATH || matches_any_pattern(&entry.path, &allowed_paths) {
|
|
230
|
-
task_entries.push(entry);
|
|
231
|
-
} else {
|
|
232
|
-
unrelated_paths.push(entry.path);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if task_entries.is_empty() {
|
|
237
|
-
return Ok(None);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
let mut errors = Vec::new();
|
|
241
|
-
validate_task_state_shape(&task_state, &mut errors);
|
|
242
|
-
validate_active_task(Some(active_task), &mut errors);
|
|
243
|
-
validate_pending_upgrade(&task_state, root, &mut errors)?;
|
|
244
|
-
validate_active_task_references(Some(active_task), root, &mut errors, Some("complete"))?;
|
|
245
|
-
if !task_state.get("blocker").is_some_and(Value::is_null) {
|
|
246
|
-
errors.push("complete task state must have blocker set to null.".to_string());
|
|
247
|
-
}
|
|
248
|
-
let check_ids = read_verification_check_ids(root, &mut errors)?;
|
|
249
|
-
validate_required_check_ids(active_task, &check_ids, &mut errors);
|
|
250
|
-
validate_complete_task_against_entries(
|
|
251
|
-
active_task,
|
|
252
|
-
root,
|
|
253
|
-
&check_ids,
|
|
254
|
-
&task_entries,
|
|
255
|
-
&mut errors,
|
|
256
|
-
)?;
|
|
257
|
-
if !errors.is_empty() {
|
|
258
|
-
return Ok(None);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
let mut task_paths: Vec<String> = task_entries
|
|
262
|
-
.into_iter()
|
|
263
|
-
.map(|entry| entry.path)
|
|
264
|
-
.collect::<HashSet<_>>()
|
|
265
|
-
.into_iter()
|
|
266
|
-
.collect();
|
|
267
|
-
task_paths.sort();
|
|
268
|
-
unrelated_paths.sort();
|
|
269
|
-
|
|
270
|
-
Ok(Some(CompletedTaskCommitDiff {
|
|
271
|
-
task_paths,
|
|
272
|
-
unrelated_paths,
|
|
273
|
-
}))
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
pub fn harness_refresh_diff(root: &Path) -> Result<Option<HarnessRefreshDiff>, NaomeError> {
|
|
277
|
-
let changed_paths = read_git_changed_paths(root)?;
|
|
278
|
-
let has_repair_signal = changed_paths
|
|
279
|
-
.iter()
|
|
280
|
-
.any(|path| is_packaged_machine_owned_path(path) || is_repair_archive_path(path));
|
|
281
|
-
if !has_repair_signal {
|
|
282
|
-
return Ok(None);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
let mut harness_paths = Vec::new();
|
|
286
|
-
let mut unrelated_paths = Vec::new();
|
|
287
|
-
|
|
288
|
-
for path in changed_paths {
|
|
289
|
-
if is_safe_harness_refresh_path(&path) {
|
|
290
|
-
harness_paths.push(path);
|
|
291
|
-
} else {
|
|
292
|
-
unrelated_paths.push(path);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if harness_paths.is_empty() {
|
|
297
|
-
return Ok(None);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
harness_paths.sort();
|
|
301
|
-
unrelated_paths.sort();
|
|
302
|
-
|
|
303
|
-
Ok(Some(HarnessRefreshDiff {
|
|
304
|
-
harness_paths,
|
|
305
|
-
unrelated_paths,
|
|
306
|
-
}))
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
pub fn harness_refresh_with_unrelated_diff(
|
|
310
|
-
root: &Path,
|
|
311
|
-
) -> Result<Option<HarnessRefreshWithUnrelatedDiff>, NaomeError> {
|
|
312
|
-
let Some(diff) = harness_refresh_diff(root)? else {
|
|
313
|
-
return Ok(None);
|
|
314
|
-
};
|
|
315
|
-
if diff.unrelated_paths.is_empty() {
|
|
316
|
-
return Ok(None);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
Ok(Some(HarnessRefreshWithUnrelatedDiff {
|
|
320
|
-
harness_paths: diff.harness_paths,
|
|
321
|
-
unrelated_paths: diff.unrelated_paths,
|
|
322
|
-
}))
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
fn validate_harness_health_gate(
|
|
326
|
-
root: &Path,
|
|
327
|
-
options: &TaskStateOptions,
|
|
328
|
-
errors: &mut Vec<String>,
|
|
329
|
-
) -> Result<(), NaomeError> {
|
|
330
|
-
let Some(health_options) = options.harness_health.clone() else {
|
|
331
|
-
return Ok(());
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
let health_errors = validate_harness_health(root, health_options)?;
|
|
335
|
-
if health_errors.is_empty() {
|
|
336
|
-
return Ok(());
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
errors.push(
|
|
340
|
-
"Harness health failed; normal NAOME task work is repair-only until machine-owned harness files are healthy. Human options: repair_harness, review_harness_health."
|
|
341
|
-
.to_string(),
|
|
342
|
-
);
|
|
343
|
-
errors.extend(
|
|
344
|
-
health_errors
|
|
345
|
-
.into_iter()
|
|
346
|
-
.map(|error| format!("Harness health: {error}")),
|
|
347
|
-
);
|
|
348
|
-
Ok(())
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
fn validate_task_state_shape(task_state: &Value, errors: &mut Vec<String>) {
|
|
352
|
-
let Some(object) = task_state.as_object() else {
|
|
353
|
-
errors.push(".naome/task-state.json must be a JSON object.".to_string());
|
|
354
|
-
return;
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
if object.get("schema").and_then(Value::as_str) != Some("naome.task-state.v1") {
|
|
358
|
-
errors.push(".naome/task-state.json schema must be naome.task-state.v1.".to_string());
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if object.get("version").and_then(Value::as_i64) != Some(1) {
|
|
362
|
-
errors.push(".naome/task-state.json version must be 1.".to_string());
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
let status = object.get("status").and_then(Value::as_str);
|
|
366
|
-
if !status.is_some_and(|value| ALLOWED_STATUS.contains(&value)) {
|
|
367
|
-
errors.push(format!(
|
|
368
|
-
".naome/task-state.json status must be one of: {}.",
|
|
369
|
-
ALLOWED_STATUS.join(", ")
|
|
370
|
-
));
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
if let Some(updated_at) = object.get("updatedAt") {
|
|
374
|
-
if !updated_at.is_null() && !updated_at.as_str().is_some_and(is_iso_datetime) {
|
|
375
|
-
errors.push(
|
|
376
|
-
".naome/task-state.json updatedAt must be an ISO timestamp or null.".to_string(),
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
fn validate_idle_state(task_state: &Value, errors: &mut Vec<String>) {
|
|
383
|
-
if !task_state.get("activeTask").is_some_and(Value::is_null) {
|
|
384
|
-
errors.push("idle task state must have activeTask set to null.".to_string());
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if !task_state.get("blocker").is_some_and(Value::is_null) {
|
|
388
|
-
errors.push("idle task state must have blocker set to null.".to_string());
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
fn validate_active_task(active_task: Option<&Value>, errors: &mut Vec<String>) {
|
|
393
|
-
let Some(active_task) = active_task.and_then(Value::as_object) else {
|
|
394
|
-
errors.push("activeTask must be an object for active task states.".to_string());
|
|
395
|
-
return;
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
if !active_task
|
|
399
|
-
.get("id")
|
|
400
|
-
.and_then(Value::as_str)
|
|
401
|
-
.is_some_and(is_id)
|
|
402
|
-
{
|
|
403
|
-
errors.push("activeTask.id must be kebab-case lowercase.".to_string());
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
require_string(active_task.get("request"), "activeTask.request", errors);
|
|
407
|
-
validate_prompt_record(
|
|
408
|
-
active_task.get("userPrompt"),
|
|
409
|
-
"activeTask.userPrompt",
|
|
410
|
-
errors,
|
|
411
|
-
);
|
|
412
|
-
require_string_array(
|
|
413
|
-
active_task.get("allowedPaths"),
|
|
414
|
-
"activeTask.allowedPaths",
|
|
415
|
-
errors,
|
|
416
|
-
);
|
|
417
|
-
require_string_array(
|
|
418
|
-
active_task.get("declaredChangeTypes"),
|
|
419
|
-
"activeTask.declaredChangeTypes",
|
|
420
|
-
errors,
|
|
421
|
-
);
|
|
422
|
-
require_string_array_allow_empty(
|
|
423
|
-
active_task.get("requiredCheckIds"),
|
|
424
|
-
"activeTask.requiredCheckIds",
|
|
425
|
-
errors,
|
|
426
|
-
);
|
|
427
|
-
validate_control_state_patterns(
|
|
428
|
-
active_task.get("allowedPaths"),
|
|
429
|
-
"activeTask.allowedPaths",
|
|
430
|
-
errors,
|
|
431
|
-
);
|
|
432
|
-
|
|
433
|
-
if !active_task.get("proofResults").is_some_and(Value::is_array) {
|
|
434
|
-
errors.push("activeTask.proofResults must be an array.".to_string());
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
validate_revisions(active_task.get("revisions"), errors);
|
|
438
|
-
validate_human_review(active_task.get("humanReview"), errors);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
fn validate_revisions(revisions: Option<&Value>, errors: &mut Vec<String>) {
|
|
442
|
-
let Some(revisions) = revisions else {
|
|
443
|
-
return;
|
|
444
|
-
};
|
|
445
|
-
let Some(revisions) = revisions.as_array() else {
|
|
446
|
-
errors.push("activeTask.revisions must be an array when present.".to_string());
|
|
447
|
-
return;
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
for (index, revision) in revisions.iter().enumerate() {
|
|
451
|
-
let prefix = format!("activeTask.revisions[{index}]");
|
|
452
|
-
let Some(object) = revision.as_object() else {
|
|
453
|
-
errors.push(format!("{prefix} must be an object."));
|
|
454
|
-
continue;
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
require_string(object.get("request"), &format!("{prefix}.request"), errors);
|
|
458
|
-
validate_prompt_record(
|
|
459
|
-
object.get("userPrompt"),
|
|
460
|
-
&format!("{prefix}.userPrompt"),
|
|
461
|
-
errors,
|
|
462
|
-
);
|
|
463
|
-
|
|
464
|
-
if !object
|
|
465
|
-
.get("requestedAt")
|
|
466
|
-
.and_then(Value::as_str)
|
|
467
|
-
.is_some_and(is_iso_datetime)
|
|
468
|
-
{
|
|
469
|
-
errors.push(format!("{prefix}.requestedAt must be an ISO timestamp."));
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
if let Some(proof_stale) = object.get("proofStale") {
|
|
473
|
-
if !proof_stale.is_boolean() {
|
|
474
|
-
errors.push(format!("{prefix}.proofStale must be boolean when present."));
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
fn validate_prompt_record(
|
|
481
|
-
prompt_record: Option<&Value>,
|
|
482
|
-
field_name: &str,
|
|
483
|
-
errors: &mut Vec<String>,
|
|
484
|
-
) {
|
|
485
|
-
let Some(object) = prompt_record.and_then(Value::as_object) else {
|
|
486
|
-
errors.push(format!(
|
|
487
|
-
"{field_name} must be an object with receivedAt and text."
|
|
488
|
-
));
|
|
489
|
-
return;
|
|
490
|
-
};
|
|
491
|
-
|
|
492
|
-
if !object
|
|
493
|
-
.get("receivedAt")
|
|
494
|
-
.and_then(Value::as_str)
|
|
495
|
-
.is_some_and(is_iso_datetime)
|
|
496
|
-
{
|
|
497
|
-
errors.push(format!("{field_name}.receivedAt must be an ISO timestamp."));
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
require_string(object.get("text"), &format!("{field_name}.text"), errors);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
fn validate_human_review(human_review: Option<&Value>, errors: &mut Vec<String>) {
|
|
504
|
-
let Some(object) = human_review.and_then(Value::as_object) else {
|
|
505
|
-
errors.push("activeTask.humanReview must be an object.".to_string());
|
|
506
|
-
return;
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
if !object.get("required").is_some_and(Value::is_boolean) {
|
|
510
|
-
errors.push("activeTask.humanReview.required must be boolean.".to_string());
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if !object.get("approved").is_some_and(Value::is_boolean) {
|
|
514
|
-
errors.push("activeTask.humanReview.approved must be boolean.".to_string());
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
if let Some(reason) = object.get("reason") {
|
|
518
|
-
if !reason.is_null() && !reason.as_str().is_some_and(is_non_empty_string) {
|
|
519
|
-
errors.push("activeTask.humanReview.reason must be a string or null.".to_string());
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
fn validate_blocker(blocker: Option<&Value>, errors: &mut Vec<String>) {
|
|
525
|
-
let Some(object) = blocker.and_then(Value::as_object) else {
|
|
526
|
-
errors.push(
|
|
527
|
-
"blocker must be an object when task state is blocked or needs human review."
|
|
528
|
-
.to_string(),
|
|
529
|
-
);
|
|
530
|
-
return;
|
|
531
|
-
};
|
|
532
|
-
|
|
533
|
-
require_string(object.get("type"), "blocker.type", errors);
|
|
534
|
-
require_string(object.get("message"), "blocker.message", errors);
|
|
535
|
-
require_string_array_allow_empty(object.get("paths"), "blocker.paths", errors);
|
|
536
|
-
require_string_array(object.get("humanOptions"), "blocker.humanOptions", errors);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
fn format_blocker(prefix: &str, blocker: Option<&Value>) -> String {
|
|
540
|
-
let Some(object) = blocker.and_then(Value::as_object) else {
|
|
541
|
-
return format!("{prefix}.");
|
|
542
|
-
};
|
|
543
|
-
|
|
544
|
-
let mut parts = vec![format!(
|
|
545
|
-
"{prefix}: {}",
|
|
546
|
-
object
|
|
547
|
-
.get("message")
|
|
548
|
-
.and_then(Value::as_str)
|
|
549
|
-
.unwrap_or("No message recorded.")
|
|
550
|
-
)];
|
|
551
|
-
|
|
552
|
-
if let Some(paths) = string_array(object.get("paths")) {
|
|
553
|
-
if !paths.is_empty() {
|
|
554
|
-
parts.push(format!("Paths: {}", paths.join(", ")));
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
if let Some(options) = string_array(object.get("humanOptions")) {
|
|
559
|
-
if !options.is_empty() {
|
|
560
|
-
parts.push(format!("Human options: {}", options.join(", ")));
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
parts.join(" ")
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
fn validate_pending_upgrade(
|
|
568
|
-
_task_state: &Value,
|
|
569
|
-
root: &Path,
|
|
570
|
-
errors: &mut Vec<String>,
|
|
571
|
-
) -> Result<(), NaomeError> {
|
|
572
|
-
if !root.join(".naome/upgrade-state.json").exists() {
|
|
573
|
-
return Ok(());
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
let Some(upgrade_state) = read_json(root, ".naome/upgrade-state.json", errors)? else {
|
|
577
|
-
return Ok(());
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
if upgrade_state.get("status").and_then(Value::as_str) == Some("needs_agent_upgrade") {
|
|
581
|
-
let pending = upgrade_state
|
|
582
|
-
.get("pending")
|
|
583
|
-
.and_then(Value::as_array)
|
|
584
|
-
.map(|values| {
|
|
585
|
-
values
|
|
586
|
-
.iter()
|
|
587
|
-
.filter_map(Value::as_str)
|
|
588
|
-
.collect::<Vec<_>>()
|
|
589
|
-
.join(", ")
|
|
590
|
-
})
|
|
591
|
-
.unwrap_or_else(|| "unknown".to_string());
|
|
592
|
-
errors.push(format!(
|
|
593
|
-
"NAOME upgrade is pending. Finish docs/naome/upgrade.md before feature work. Pending: {pending}"
|
|
594
|
-
));
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
Ok(())
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
fn validate_active_task_references(
|
|
601
|
-
active_task: Option<&Value>,
|
|
602
|
-
root: &Path,
|
|
603
|
-
errors: &mut Vec<String>,
|
|
604
|
-
status: Option<&str>,
|
|
605
|
-
) -> Result<(), NaomeError> {
|
|
606
|
-
let Some(active_task) = active_task else {
|
|
607
|
-
return Ok(());
|
|
608
|
-
};
|
|
609
|
-
|
|
610
|
-
validate_admission_proof(active_task.get("admission"), root, errors)?;
|
|
611
|
-
validate_external_git_reconciliation(active_task, status, root, errors)?;
|
|
612
|
-
let check_ids = read_verification_check_ids(root, errors)?;
|
|
613
|
-
validate_required_check_ids(active_task, &check_ids, errors);
|
|
614
|
-
validate_proof_result_entries(active_task, &check_ids, root, errors)?;
|
|
615
|
-
Ok(())
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
fn validate_admission_proof(
|
|
619
|
-
admission: Option<&Value>,
|
|
620
|
-
root: &Path,
|
|
621
|
-
errors: &mut Vec<String>,
|
|
622
|
-
) -> Result<(), NaomeError> {
|
|
623
|
-
let Some(object) = admission.and_then(Value::as_object) else {
|
|
624
|
-
errors.push(
|
|
625
|
-
"activeTask.admission must be an object recorded from a passed admission check."
|
|
626
|
-
.to_string(),
|
|
627
|
-
);
|
|
628
|
-
return Ok(());
|
|
629
|
-
};
|
|
630
|
-
let prefix = "activeTask.admission";
|
|
631
|
-
|
|
632
|
-
require_string(object.get("command"), &format!("{prefix}.command"), errors);
|
|
633
|
-
require_string(object.get("cwd"), &format!("{prefix}.cwd"), errors);
|
|
634
|
-
require_string_array_allow_empty(
|
|
635
|
-
object.get("changedPaths"),
|
|
636
|
-
&format!("{prefix}.changedPaths"),
|
|
637
|
-
errors,
|
|
638
|
-
);
|
|
639
|
-
require_string(object.get("gitHead"), &format!("{prefix}.gitHead"), errors);
|
|
640
|
-
|
|
641
|
-
if object.get("command").and_then(Value::as_str)
|
|
642
|
-
!= Some("node .naome/bin/check-task-state.js --admission")
|
|
643
|
-
{
|
|
644
|
-
errors.push(format!(
|
|
645
|
-
"{prefix}.command must be node .naome/bin/check-task-state.js --admission."
|
|
646
|
-
));
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
if object.get("cwd").and_then(Value::as_str) != Some(".") {
|
|
650
|
-
errors.push(format!("{prefix}.cwd must be \".\"."));
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
match object.get("exitCode").and_then(Value::as_i64) {
|
|
654
|
-
Some(0) => {}
|
|
655
|
-
Some(_) => errors.push(format!("{prefix}.exitCode must be 0.")),
|
|
656
|
-
None => errors.push(format!("{prefix}.exitCode must be an integer.")),
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
if !object
|
|
660
|
-
.get("checkedAt")
|
|
661
|
-
.and_then(Value::as_str)
|
|
662
|
-
.is_some_and(is_iso_datetime)
|
|
663
|
-
{
|
|
664
|
-
errors.push(format!("{prefix}.checkedAt must be an ISO timestamp."));
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
if let Some(changed_paths) = object.get("changedPaths").and_then(Value::as_array) {
|
|
668
|
-
if !changed_paths.is_empty() {
|
|
669
|
-
errors.push(format!("{prefix}.changedPaths must be empty because task admission requires a clean git diff."));
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
if let Some(git_head) = object.get("gitHead").and_then(Value::as_str) {
|
|
674
|
-
if !git_head.trim().is_empty() && !git_commit_exists(root, git_head)? {
|
|
675
|
-
errors.push(format!("{prefix}.gitHead must be an existing git commit."));
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
Ok(())
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
fn validate_external_git_reconciliation(
|
|
683
|
-
active_task: &Value,
|
|
684
|
-
status: Option<&str>,
|
|
685
|
-
root: &Path,
|
|
686
|
-
errors: &mut Vec<String>,
|
|
687
|
-
) -> Result<(), NaomeError> {
|
|
688
|
-
if status == Some("complete") {
|
|
689
|
-
return Ok(());
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
let Some(admission_head) = active_task
|
|
693
|
-
.get("admission")
|
|
694
|
-
.and_then(|admission| admission.get("gitHead"))
|
|
695
|
-
.and_then(Value::as_str)
|
|
696
|
-
.filter(|head| !head.trim().is_empty())
|
|
697
|
-
else {
|
|
698
|
-
return Ok(());
|
|
699
|
-
};
|
|
700
|
-
|
|
701
|
-
let Some(current_head) = read_git_head(root)? else {
|
|
702
|
-
return Ok(());
|
|
703
|
-
};
|
|
704
|
-
|
|
705
|
-
if current_head != admission_head {
|
|
706
|
-
errors.push(format!("Task git HEAD changed after admission from {admission_head} to {current_head}. Reconcile external git work before continuing. Human options: mark_task_complete_from_git, reopen_task_revision, recover_current_diff, cancel_task_state."));
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
Ok(())
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
fn read_verification_check_ids(
|
|
713
|
-
root: &Path,
|
|
714
|
-
errors: &mut Vec<String>,
|
|
715
|
-
) -> Result<HashSet<String>, NaomeError> {
|
|
716
|
-
let mut check_ids = HashSet::new();
|
|
717
|
-
let Some(verification) = read_json(root, ".naome/verification.json", errors)? else {
|
|
718
|
-
return Ok(check_ids);
|
|
719
|
-
};
|
|
720
|
-
|
|
721
|
-
if let Some(checks) = verification.get("checks").and_then(Value::as_array) {
|
|
722
|
-
for check in checks {
|
|
723
|
-
if let Some(id) = check.get("id").and_then(Value::as_str) {
|
|
724
|
-
if !id.trim().is_empty() {
|
|
725
|
-
check_ids.insert(id.to_string());
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
Ok(check_ids)
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
fn validate_required_check_ids(
|
|
735
|
-
active_task: &Value,
|
|
736
|
-
check_ids: &HashSet<String>,
|
|
737
|
-
errors: &mut Vec<String>,
|
|
738
|
-
) {
|
|
739
|
-
let Some(required_check_ids) = active_task
|
|
740
|
-
.get("requiredCheckIds")
|
|
741
|
-
.and_then(Value::as_array)
|
|
742
|
-
else {
|
|
743
|
-
return;
|
|
744
|
-
};
|
|
745
|
-
|
|
746
|
-
for check_id in required_check_ids {
|
|
747
|
-
if let Some(check_id) = check_id.as_str().filter(|value| !value.trim().is_empty()) {
|
|
748
|
-
if !check_ids.contains(check_id) {
|
|
749
|
-
errors.push(format!(
|
|
750
|
-
"activeTask.requiredCheckIds unknown check id: {check_id}"
|
|
751
|
-
));
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
fn validate_proof_result_entries(
|
|
758
|
-
active_task: &Value,
|
|
759
|
-
check_ids: &HashSet<String>,
|
|
760
|
-
root: &Path,
|
|
761
|
-
errors: &mut Vec<String>,
|
|
762
|
-
) -> Result<(), NaomeError> {
|
|
763
|
-
let Some(proofs) = active_task.get("proofResults").and_then(Value::as_array) else {
|
|
764
|
-
return Ok(());
|
|
765
|
-
};
|
|
766
|
-
|
|
767
|
-
for (index, proof) in proofs.iter().enumerate() {
|
|
768
|
-
validate_proof_result(proof, index, check_ids, root, errors, active_task)?;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
Ok(())
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
fn validate_proof_results(
|
|
775
|
-
active_task: &Value,
|
|
776
|
-
check_ids: &HashSet<String>,
|
|
777
|
-
root: &Path,
|
|
778
|
-
errors: &mut Vec<String>,
|
|
779
|
-
) -> Result<(), NaomeError> {
|
|
780
|
-
let Some(required_check_ids) = active_task
|
|
781
|
-
.get("requiredCheckIds")
|
|
782
|
-
.and_then(Value::as_array)
|
|
783
|
-
else {
|
|
784
|
-
return Ok(());
|
|
785
|
-
};
|
|
786
|
-
let Some(proofs) = active_task.get("proofResults").and_then(Value::as_array) else {
|
|
787
|
-
return Ok(());
|
|
788
|
-
};
|
|
789
|
-
|
|
790
|
-
for check_id in required_check_ids {
|
|
791
|
-
let Some(check_id) = check_id.as_str().filter(|value| !value.trim().is_empty()) else {
|
|
792
|
-
continue;
|
|
793
|
-
};
|
|
794
|
-
|
|
795
|
-
match proofs
|
|
796
|
-
.iter()
|
|
797
|
-
.find(|proof| proof.get("checkId").and_then(Value::as_str) == Some(check_id))
|
|
798
|
-
{
|
|
799
|
-
Some(proof) if proof.get("exitCode").and_then(Value::as_i64) == Some(0) => {}
|
|
800
|
-
Some(_) => errors.push(format!(
|
|
801
|
-
"activeTask.proofResults failed proof result: {check_id}"
|
|
802
|
-
)),
|
|
803
|
-
None => errors.push(format!(
|
|
804
|
-
"activeTask.proofResults missing proof result: {check_id}"
|
|
805
|
-
)),
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
validate_proof_result_entries(active_task, check_ids, root, errors)
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
fn validate_proof_evidence_covers_changed_paths(
|
|
813
|
-
active_task: Option<&Value>,
|
|
814
|
-
root: &Path,
|
|
815
|
-
errors: &mut Vec<String>,
|
|
816
|
-
) -> Result<(), NaomeError> {
|
|
817
|
-
let Some(active_task) = active_task else {
|
|
818
|
-
return Ok(());
|
|
819
|
-
};
|
|
820
|
-
let Some(proofs) = active_task.get("proofResults").and_then(Value::as_array) else {
|
|
821
|
-
return Ok(());
|
|
822
|
-
};
|
|
823
|
-
if proofs.is_empty() {
|
|
824
|
-
return Ok(());
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
let entries = read_git_changed_entries(root)?;
|
|
828
|
-
validate_proof_evidence_covers_changed_entries(active_task, &entries, errors)
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
fn validate_proof_evidence_covers_changed_entries(
|
|
832
|
-
active_task: &Value,
|
|
833
|
-
entries: &[ChangedEntry],
|
|
834
|
-
errors: &mut Vec<String>,
|
|
835
|
-
) -> Result<(), NaomeError> {
|
|
836
|
-
let Some(proofs) = active_task.get("proofResults").and_then(Value::as_array) else {
|
|
837
|
-
return Ok(());
|
|
838
|
-
};
|
|
839
|
-
if proofs.is_empty() {
|
|
840
|
-
return Ok(());
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
let changed_paths = task_diff_from_entries(active_task, entries);
|
|
844
|
-
let evidence_paths: HashSet<String> = proofs
|
|
845
|
-
.iter()
|
|
846
|
-
.flat_map(|proof| {
|
|
847
|
-
proof
|
|
848
|
-
.get("evidence")
|
|
849
|
-
.and_then(Value::as_array)
|
|
850
|
-
.cloned()
|
|
851
|
-
.unwrap_or_default()
|
|
852
|
-
})
|
|
853
|
-
.filter_map(|entry| evidence_entry_path(&entry).map(normalize_path))
|
|
854
|
-
.collect();
|
|
855
|
-
|
|
856
|
-
let allowed_paths = string_array(active_task.get("allowedPaths")).unwrap_or_default();
|
|
857
|
-
let changed_allowed_paths: Vec<String> = changed_paths
|
|
858
|
-
.diff_paths
|
|
859
|
-
.into_iter()
|
|
860
|
-
.filter(|path| matches_any_pattern(path, &allowed_paths))
|
|
861
|
-
.collect();
|
|
862
|
-
let missing_paths: Vec<String> = changed_allowed_paths
|
|
863
|
-
.into_iter()
|
|
864
|
-
.filter(|path| !evidence_paths.contains(path))
|
|
865
|
-
.collect();
|
|
866
|
-
|
|
867
|
-
if !missing_paths.is_empty() {
|
|
868
|
-
errors.push(format!(
|
|
869
|
-
"activeTask.proofResults evidence missing changed allowed paths: {}",
|
|
870
|
-
missing_paths.join(", ")
|
|
871
|
-
));
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
Ok(())
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
fn validate_proof_result(
|
|
878
|
-
proof: &Value,
|
|
879
|
-
index: usize,
|
|
880
|
-
check_ids: &HashSet<String>,
|
|
881
|
-
root: &Path,
|
|
882
|
-
errors: &mut Vec<String>,
|
|
883
|
-
active_task: &Value,
|
|
884
|
-
) -> Result<(), NaomeError> {
|
|
885
|
-
let prefix = format!("activeTask.proofResults[{index}]");
|
|
886
|
-
let Some(object) = proof.as_object() else {
|
|
887
|
-
errors.push(format!("{prefix} must be an object."));
|
|
888
|
-
return Ok(());
|
|
889
|
-
};
|
|
890
|
-
|
|
891
|
-
require_string(object.get("checkId"), &format!("{prefix}.checkId"), errors);
|
|
892
|
-
require_string(object.get("command"), &format!("{prefix}.command"), errors);
|
|
893
|
-
require_string(object.get("cwd"), &format!("{prefix}.cwd"), errors);
|
|
894
|
-
validate_evidence_array(
|
|
895
|
-
object.get("evidence"),
|
|
896
|
-
&format!("{prefix}.evidence"),
|
|
897
|
-
errors,
|
|
898
|
-
);
|
|
899
|
-
validate_control_state_paths(
|
|
900
|
-
object.get("evidence"),
|
|
901
|
-
&format!("{prefix}.evidence"),
|
|
902
|
-
errors,
|
|
903
|
-
);
|
|
904
|
-
|
|
905
|
-
if object.get("exitCode").and_then(Value::as_i64).is_none() {
|
|
906
|
-
errors.push(format!("{prefix}.exitCode must be an integer."));
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
if !object
|
|
910
|
-
.get("checkedAt")
|
|
911
|
-
.and_then(Value::as_str)
|
|
912
|
-
.is_some_and(is_iso_datetime)
|
|
913
|
-
{
|
|
914
|
-
errors.push(format!("{prefix}.checkedAt must be an ISO timestamp."));
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
if let Some(check_id) = object.get("checkId").and_then(Value::as_str) {
|
|
918
|
-
if !check_id.trim().is_empty() && !check_ids.contains(check_id) {
|
|
919
|
-
errors.push(format!("{prefix}.checkId unknown check id: {check_id}"));
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
validate_evidence_paths(
|
|
924
|
-
object.get("evidence"),
|
|
925
|
-
&format!("{prefix}.evidence"),
|
|
926
|
-
root,
|
|
927
|
-
errors,
|
|
928
|
-
active_task,
|
|
929
|
-
)
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
fn validate_evidence_array(evidence: Option<&Value>, field_name: &str, errors: &mut Vec<String>) {
|
|
933
|
-
let Some(evidence) = evidence.and_then(Value::as_array) else {
|
|
934
|
-
errors.push(format!("{field_name} must be an evidence array."));
|
|
935
|
-
return;
|
|
936
|
-
};
|
|
937
|
-
|
|
938
|
-
for (index, entry) in evidence.iter().enumerate() {
|
|
939
|
-
let prefix = format!("{field_name}[{index}]");
|
|
940
|
-
if entry.as_str().is_some_and(is_non_empty_string) {
|
|
941
|
-
continue;
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
let Some(object) = entry.as_object() else {
|
|
945
|
-
errors.push(format!(
|
|
946
|
-
"{prefix} must be a non-empty string path or an evidence object."
|
|
947
|
-
));
|
|
948
|
-
continue;
|
|
949
|
-
};
|
|
950
|
-
|
|
951
|
-
require_string(object.get("path"), &format!("{prefix}.path"), errors);
|
|
952
|
-
|
|
953
|
-
if let Some(status) = object.get("status").and_then(Value::as_str) {
|
|
954
|
-
if !ALLOWED_EVIDENCE_STATUS.contains(&status) {
|
|
955
|
-
errors.push(format!(
|
|
956
|
-
"{prefix}.status must be one of: {}.",
|
|
957
|
-
ALLOWED_EVIDENCE_STATUS.join(", ")
|
|
958
|
-
));
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
if object.contains_key("fromPath")
|
|
963
|
-
&& !object
|
|
964
|
-
.get("fromPath")
|
|
965
|
-
.and_then(Value::as_str)
|
|
966
|
-
.is_some_and(is_non_empty_string)
|
|
967
|
-
{
|
|
968
|
-
errors.push(format!(
|
|
969
|
-
"{prefix}.fromPath must be a non-empty string when present."
|
|
970
|
-
));
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
fn validate_control_state_patterns(
|
|
976
|
-
patterns: Option<&Value>,
|
|
977
|
-
field_name: &str,
|
|
978
|
-
errors: &mut Vec<String>,
|
|
979
|
-
) {
|
|
980
|
-
let Some(patterns) = string_array(patterns) else {
|
|
981
|
-
return;
|
|
982
|
-
};
|
|
983
|
-
|
|
984
|
-
for pattern in patterns {
|
|
985
|
-
if matches_path_pattern(CONTROL_STATE_PATH, &pattern) {
|
|
986
|
-
errors.push(format!(
|
|
987
|
-
"{field_name} cannot include NAOME control state: {pattern}"
|
|
988
|
-
));
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
fn validate_control_state_paths(paths: Option<&Value>, field_name: &str, errors: &mut Vec<String>) {
|
|
994
|
-
let Some(paths) = paths.and_then(Value::as_array) else {
|
|
995
|
-
return;
|
|
996
|
-
};
|
|
997
|
-
|
|
998
|
-
for entry in paths {
|
|
999
|
-
let Some(path) = evidence_entry_path(entry) else {
|
|
1000
|
-
continue;
|
|
1001
|
-
};
|
|
1002
|
-
if normalize_path(&path) == CONTROL_STATE_PATH {
|
|
1003
|
-
errors.push(format!(
|
|
1004
|
-
"{field_name} cannot include NAOME control state: {path}"
|
|
1005
|
-
));
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
fn validate_evidence_paths(
|
|
1011
|
-
evidence: Option<&Value>,
|
|
1012
|
-
field_name: &str,
|
|
1013
|
-
root: &Path,
|
|
1014
|
-
errors: &mut Vec<String>,
|
|
1015
|
-
active_task: &Value,
|
|
1016
|
-
) -> Result<(), NaomeError> {
|
|
1017
|
-
let Some(evidence) = evidence.and_then(Value::as_array) else {
|
|
1018
|
-
return Ok(());
|
|
1019
|
-
};
|
|
1020
|
-
|
|
1021
|
-
let mut deleted_paths: HashSet<String> = read_git_changed_entries(root)?
|
|
1022
|
-
.into_iter()
|
|
1023
|
-
.filter(|entry| entry.status == "deleted")
|
|
1024
|
-
.map(|entry| entry.path)
|
|
1025
|
-
.collect();
|
|
1026
|
-
for path in read_historical_deleted_paths(active_task, root)? {
|
|
1027
|
-
deleted_paths.insert(path);
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
for entry in evidence {
|
|
1031
|
-
let Some(evidence_path) = evidence_entry_path(entry) else {
|
|
1032
|
-
continue;
|
|
1033
|
-
};
|
|
1034
|
-
let normalized_path = normalize_path(&evidence_path);
|
|
1035
|
-
if Path::new(&evidence_path).is_absolute()
|
|
1036
|
-
|| normalized_path.split('/').any(|part| part == "..")
|
|
1037
|
-
{
|
|
1038
|
-
errors.push(format!("{field_name} unsafe path: {evidence_path}"));
|
|
1039
|
-
continue;
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
if !root.join(&normalized_path).exists() && !deleted_paths.contains(&normalized_path) {
|
|
1043
|
-
errors.push(format!(
|
|
1044
|
-
"{field_name} path does not exist or is not deleted in git diff: {evidence_path}"
|
|
1045
|
-
));
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
Ok(())
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
fn read_historical_deleted_paths(
|
|
1053
|
-
active_task: &Value,
|
|
1054
|
-
root: &Path,
|
|
1055
|
-
) -> Result<Vec<String>, NaomeError> {
|
|
1056
|
-
let Some(admission_head) = active_task
|
|
1057
|
-
.get("admission")
|
|
1058
|
-
.and_then(|admission| admission.get("gitHead"))
|
|
1059
|
-
.and_then(Value::as_str)
|
|
1060
|
-
.filter(|head| !head.trim().is_empty())
|
|
1061
|
-
else {
|
|
1062
|
-
return Ok(Vec::new());
|
|
1063
|
-
};
|
|
1064
|
-
|
|
1065
|
-
if !git_commit_exists(root, admission_head)? {
|
|
1066
|
-
return Ok(Vec::new());
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
let Some(current_head) = read_git_head(root)? else {
|
|
1070
|
-
return Ok(Vec::new());
|
|
1071
|
-
};
|
|
1072
|
-
|
|
1073
|
-
if current_head == admission_head {
|
|
1074
|
-
return Ok(Vec::new());
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
let output = run_git(
|
|
1078
|
-
root,
|
|
1079
|
-
["diff", "--name-status", "-z", admission_head, ¤t_head],
|
|
1080
|
-
)?;
|
|
1081
|
-
if !output.status.success() {
|
|
1082
|
-
return Ok(Vec::new());
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
Ok(parse_name_status_output(&output.stdout)
|
|
1086
|
-
.into_iter()
|
|
1087
|
-
.filter(|entry| entry.status == "deleted")
|
|
1088
|
-
.map(|entry| entry.path)
|
|
1089
|
-
.collect())
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
fn evidence_entry_path(entry: &Value) -> Option<String> {
|
|
1093
|
-
entry
|
|
1094
|
-
.as_str()
|
|
1095
|
-
.filter(|value| is_non_empty_string(value))
|
|
1096
|
-
.map(ToString::to_string)
|
|
1097
|
-
.or_else(|| {
|
|
1098
|
-
entry
|
|
1099
|
-
.get("path")
|
|
1100
|
-
.and_then(Value::as_str)
|
|
1101
|
-
.filter(|value| is_non_empty_string(value))
|
|
1102
|
-
.map(ToString::to_string)
|
|
1103
|
-
})
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
fn validate_changed_paths(
|
|
1107
|
-
active_task: &Value,
|
|
1108
|
-
root: &Path,
|
|
1109
|
-
errors: &mut Vec<String>,
|
|
1110
|
-
) -> Result<(), NaomeError> {
|
|
1111
|
-
let entries = read_git_changed_entries(root)?;
|
|
1112
|
-
validate_changed_entries(active_task, &entries, errors)
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
fn validate_changed_entries(
|
|
1116
|
-
active_task: &Value,
|
|
1117
|
-
entries: &[ChangedEntry],
|
|
1118
|
-
errors: &mut Vec<String>,
|
|
1119
|
-
) -> Result<(), NaomeError> {
|
|
1120
|
-
let diff = task_diff_from_entries(active_task, entries);
|
|
1121
|
-
if !diff.outside_paths.is_empty() {
|
|
1122
|
-
errors.push(format!(
|
|
1123
|
-
"Changed files outside allowedPaths: {}",
|
|
1124
|
-
diff.outside_paths.join(", ")
|
|
1125
|
-
));
|
|
1126
|
-
}
|
|
1127
|
-
Ok(())
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
fn validate_human_review_blocker_paths(
|
|
1131
|
-
active_task: Option<&Value>,
|
|
1132
|
-
blocker: Option<&Value>,
|
|
1133
|
-
root: &Path,
|
|
1134
|
-
errors: &mut Vec<String>,
|
|
1135
|
-
) -> Result<(), NaomeError> {
|
|
1136
|
-
let (Some(active_task), Some(blocker)) = (active_task, blocker) else {
|
|
1137
|
-
return Ok(());
|
|
1138
|
-
};
|
|
1139
|
-
let diff = read_task_diff(active_task, root)?;
|
|
1140
|
-
if diff.outside_paths.is_empty() {
|
|
1141
|
-
return Ok(());
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
let blocker_paths: HashSet<String> = blocker
|
|
1145
|
-
.get("paths")
|
|
1146
|
-
.and_then(Value::as_array)
|
|
1147
|
-
.into_iter()
|
|
1148
|
-
.flatten()
|
|
1149
|
-
.filter_map(Value::as_str)
|
|
1150
|
-
.map(normalize_path)
|
|
1151
|
-
.collect();
|
|
1152
|
-
let missing_paths: Vec<String> = diff
|
|
1153
|
-
.outside_paths
|
|
1154
|
-
.into_iter()
|
|
1155
|
-
.filter(|path| !blocker_paths.contains(path))
|
|
1156
|
-
.collect();
|
|
1157
|
-
|
|
1158
|
-
if !missing_paths.is_empty() {
|
|
1159
|
-
errors.push(format!(
|
|
1160
|
-
"blocker.paths missing actual scope violations: {}",
|
|
1161
|
-
missing_paths.join(", ")
|
|
1162
|
-
));
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
Ok(())
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
struct TaskDiff {
|
|
1169
|
-
diff_paths: Vec<String>,
|
|
1170
|
-
outside_paths: Vec<String>,
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
fn read_task_diff(active_task: &Value, root: &Path) -> Result<TaskDiff, NaomeError> {
|
|
1174
|
-
let entries = read_git_changed_entries(root)?;
|
|
1175
|
-
Ok(task_diff_from_entries(active_task, &entries))
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
fn task_diff_from_entries(active_task: &Value, entries: &[ChangedEntry]) -> TaskDiff {
|
|
1179
|
-
let allowed_paths = string_array(active_task.get("allowedPaths")).unwrap_or_default();
|
|
1180
|
-
let diff_paths: Vec<String> = entries
|
|
1181
|
-
.iter()
|
|
1182
|
-
.map(|entry| entry.path.clone())
|
|
1183
|
-
.filter(|path| path != CONTROL_STATE_PATH)
|
|
1184
|
-
.collect();
|
|
1185
|
-
let outside_paths = diff_paths
|
|
1186
|
-
.iter()
|
|
1187
|
-
.filter(|path| !matches_any_pattern(path, &allowed_paths))
|
|
1188
|
-
.cloned()
|
|
1189
|
-
.collect();
|
|
1190
|
-
|
|
1191
|
-
TaskDiff {
|
|
1192
|
-
diff_paths,
|
|
1193
|
-
outside_paths,
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
fn validate_complete_task(
|
|
1198
|
-
active_task: Option<&Value>,
|
|
1199
|
-
blocker: Option<&Value>,
|
|
1200
|
-
root: &Path,
|
|
1201
|
-
errors: &mut Vec<String>,
|
|
1202
|
-
notices: &mut Vec<String>,
|
|
1203
|
-
) -> Result<(), NaomeError> {
|
|
1204
|
-
let error_start = errors.len();
|
|
1205
|
-
|
|
1206
|
-
if !blocker.is_some_and(Value::is_null) {
|
|
1207
|
-
errors.push("complete task state must have blocker set to null.".to_string());
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
let Some(active_task) = active_task else {
|
|
1211
|
-
return Ok(());
|
|
1212
|
-
};
|
|
1213
|
-
|
|
1214
|
-
if active_task
|
|
1215
|
-
.get("humanReview")
|
|
1216
|
-
.and_then(|review| review.get("required"))
|
|
1217
|
-
.and_then(Value::as_bool)
|
|
1218
|
-
== Some(true)
|
|
1219
|
-
&& active_task
|
|
1220
|
-
.get("humanReview")
|
|
1221
|
-
.and_then(|review| review.get("approved"))
|
|
1222
|
-
.and_then(Value::as_bool)
|
|
1223
|
-
!= Some(true)
|
|
1224
|
-
{
|
|
1225
|
-
errors.push("complete task requires human review approval before completion.".to_string());
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
let check_ids = read_verification_check_ids(root, errors)?;
|
|
1229
|
-
validate_required_check_ids(active_task, &check_ids, errors);
|
|
1230
|
-
let entries = read_git_changed_entries(root)?;
|
|
1231
|
-
validate_complete_task_against_entries(active_task, root, &check_ids, &entries, errors)?;
|
|
1232
|
-
|
|
1233
|
-
if errors.len() == error_start {
|
|
1234
|
-
add_completed_task_diff_notice(root, notices)?;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
Ok(())
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
fn validate_complete_task_against_entries(
|
|
1241
|
-
active_task: &Value,
|
|
1242
|
-
root: &Path,
|
|
1243
|
-
check_ids: &HashSet<String>,
|
|
1244
|
-
entries: &[ChangedEntry],
|
|
1245
|
-
errors: &mut Vec<String>,
|
|
1246
|
-
) -> Result<(), NaomeError> {
|
|
1247
|
-
validate_proof_results(active_task, check_ids, root, errors)?;
|
|
1248
|
-
validate_changed_entries(active_task, entries, errors)?;
|
|
1249
|
-
validate_proof_evidence_covers_changed_entries(active_task, entries, errors)?;
|
|
1250
|
-
Ok(())
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
fn validate_progress(
|
|
1254
|
-
task_state: &Value,
|
|
1255
|
-
root: &Path,
|
|
1256
|
-
errors: &mut Vec<String>,
|
|
1257
|
-
) -> Result<(), NaomeError> {
|
|
1258
|
-
validate_init_complete(root, errors)?;
|
|
1259
|
-
validate_upgrade_complete(root, errors)?;
|
|
1260
|
-
|
|
1261
|
-
let status = task_state
|
|
1262
|
-
.get("status")
|
|
1263
|
-
.and_then(Value::as_str)
|
|
1264
|
-
.unwrap_or("invalid");
|
|
1265
|
-
|
|
1266
|
-
match status {
|
|
1267
|
-
"planning" | "implementing" | "revising" | "verifying" => {
|
|
1268
|
-
validate_active_task(task_state.get("activeTask"), errors);
|
|
1269
|
-
validate_pending_upgrade(task_state, root, errors)?;
|
|
1270
|
-
validate_active_task_references(
|
|
1271
|
-
task_state.get("activeTask"),
|
|
1272
|
-
root,
|
|
1273
|
-
errors,
|
|
1274
|
-
Some(status),
|
|
1275
|
-
)?;
|
|
1276
|
-
if let Some(active_task) = task_state.get("activeTask") {
|
|
1277
|
-
validate_changed_paths(active_task, root, errors)?;
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
"needs_human_review" => {
|
|
1281
|
-
validate_active_task(task_state.get("activeTask"), errors);
|
|
1282
|
-
validate_active_task_references(
|
|
1283
|
-
task_state.get("activeTask"),
|
|
1284
|
-
root,
|
|
1285
|
-
errors,
|
|
1286
|
-
Some(status),
|
|
1287
|
-
)?;
|
|
1288
|
-
validate_blocker(task_state.get("blocker"), errors);
|
|
1289
|
-
validate_human_review_blocker_paths(
|
|
1290
|
-
task_state.get("activeTask"),
|
|
1291
|
-
task_state.get("blocker"),
|
|
1292
|
-
root,
|
|
1293
|
-
errors,
|
|
1294
|
-
)?;
|
|
1295
|
-
validate_proof_evidence_covers_changed_paths(
|
|
1296
|
-
task_state.get("activeTask"),
|
|
1297
|
-
root,
|
|
1298
|
-
errors,
|
|
1299
|
-
)?;
|
|
1300
|
-
errors.push(format_blocker(
|
|
1301
|
-
"Human review required",
|
|
1302
|
-
task_state.get("blocker"),
|
|
1303
|
-
));
|
|
1304
|
-
}
|
|
1305
|
-
"blocked" => {
|
|
1306
|
-
validate_active_task(task_state.get("activeTask"), errors);
|
|
1307
|
-
validate_blocker(task_state.get("blocker"), errors);
|
|
1308
|
-
errors.push(format_blocker("Task is blocked", task_state.get("blocker")));
|
|
1309
|
-
}
|
|
1310
|
-
"complete" => errors.push(
|
|
1311
|
-
"Task is complete; use node .naome/bin/check-task-state.js for completion validation."
|
|
1312
|
-
.to_string(),
|
|
1313
|
-
),
|
|
1314
|
-
"idle" => errors.push(
|
|
1315
|
-
"No active task is in progress; use --admission before starting feature work."
|
|
1316
|
-
.to_string(),
|
|
1317
|
-
),
|
|
1318
|
-
_ => {}
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
Ok(())
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
fn validate_admission(
|
|
1325
|
-
task_state: &Value,
|
|
1326
|
-
root: &Path,
|
|
1327
|
-
errors: &mut Vec<String>,
|
|
1328
|
-
) -> Result<(), NaomeError> {
|
|
1329
|
-
validate_init_complete(root, errors)?;
|
|
1330
|
-
validate_upgrade_complete(root, errors)?;
|
|
1331
|
-
|
|
1332
|
-
let status = task_state
|
|
1333
|
-
.get("status")
|
|
1334
|
-
.and_then(Value::as_str)
|
|
1335
|
-
.unwrap_or("invalid");
|
|
1336
|
-
match status {
|
|
1337
|
-
"idle" => validate_idle_state(task_state, errors),
|
|
1338
|
-
"complete" => {
|
|
1339
|
-
validate_active_task(task_state.get("activeTask"), errors);
|
|
1340
|
-
validate_active_task_references(
|
|
1341
|
-
task_state.get("activeTask"),
|
|
1342
|
-
root,
|
|
1343
|
-
errors,
|
|
1344
|
-
Some(status),
|
|
1345
|
-
)?;
|
|
1346
|
-
validate_complete_task(
|
|
1347
|
-
task_state.get("activeTask"),
|
|
1348
|
-
task_state.get("blocker"),
|
|
1349
|
-
root,
|
|
1350
|
-
errors,
|
|
1351
|
-
&mut Vec::new(),
|
|
1352
|
-
)?;
|
|
1353
|
-
}
|
|
1354
|
-
"needs_human_review" => {
|
|
1355
|
-
let start = errors.len();
|
|
1356
|
-
validate_active_task(task_state.get("activeTask"), errors);
|
|
1357
|
-
validate_active_task_references(
|
|
1358
|
-
task_state.get("activeTask"),
|
|
1359
|
-
root,
|
|
1360
|
-
errors,
|
|
1361
|
-
Some(status),
|
|
1362
|
-
)?;
|
|
1363
|
-
validate_blocker(task_state.get("blocker"), errors);
|
|
1364
|
-
validate_human_review_blocker_paths(
|
|
1365
|
-
task_state.get("activeTask"),
|
|
1366
|
-
task_state.get("blocker"),
|
|
1367
|
-
root,
|
|
1368
|
-
errors,
|
|
1369
|
-
)?;
|
|
1370
|
-
validate_proof_evidence_covers_changed_paths(
|
|
1371
|
-
task_state.get("activeTask"),
|
|
1372
|
-
root,
|
|
1373
|
-
errors,
|
|
1374
|
-
)?;
|
|
1375
|
-
if errors.len() > start {
|
|
1376
|
-
errors.push("Task admission is blocked because needs_human_review state is invalid; fix blocker paths and proof evidence before asking for a human decision.".to_string());
|
|
1377
|
-
} else {
|
|
1378
|
-
errors.push(format_blocker(
|
|
1379
|
-
"Task admission is blocked",
|
|
1380
|
-
task_state.get("blocker"),
|
|
1381
|
-
));
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
"blocked" => {
|
|
1385
|
-
validate_blocker(task_state.get("blocker"), errors);
|
|
1386
|
-
errors.push(format_blocker(
|
|
1387
|
-
"Task admission is blocked",
|
|
1388
|
-
task_state.get("blocker"),
|
|
1389
|
-
));
|
|
1390
|
-
}
|
|
1391
|
-
other => errors.push(format!(
|
|
1392
|
-
"Task admission is blocked because task state is {other}."
|
|
1393
|
-
)),
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
validate_clean_git_diff(task_state, root, errors)
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
fn validate_upgrade_complete(root: &Path, errors: &mut Vec<String>) -> Result<(), NaomeError> {
|
|
1400
|
-
let Some(upgrade_state) = read_json(root, ".naome/upgrade-state.json", errors)? else {
|
|
1401
|
-
return Ok(());
|
|
1402
|
-
};
|
|
1403
|
-
|
|
1404
|
-
if upgrade_state.get("status").and_then(Value::as_str) != Some("complete") {
|
|
1405
|
-
let pending = upgrade_state
|
|
1406
|
-
.get("pending")
|
|
1407
|
-
.and_then(Value::as_array)
|
|
1408
|
-
.map(|values| {
|
|
1409
|
-
values
|
|
1410
|
-
.iter()
|
|
1411
|
-
.filter_map(Value::as_str)
|
|
1412
|
-
.collect::<Vec<_>>()
|
|
1413
|
-
.join(", ")
|
|
1414
|
-
})
|
|
1415
|
-
.unwrap_or_else(|| "unknown".to_string());
|
|
1416
|
-
errors.push(format!("NAOME upgrade is not complete. Finish docs/naome/upgrade.md before feature work. Pending: {pending}"));
|
|
1417
|
-
}
|
|
1418
|
-
Ok(())
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
fn validate_init_complete(root: &Path, errors: &mut Vec<String>) -> Result<(), NaomeError> {
|
|
1422
|
-
let Some(init_state) = read_json(root, ".naome/init-state.json", errors)? else {
|
|
1423
|
-
return Ok(());
|
|
1424
|
-
};
|
|
1425
|
-
|
|
1426
|
-
if init_state.get("initialized").and_then(Value::as_bool) != Some(true)
|
|
1427
|
-
|| init_state.get("intakeStatus").and_then(Value::as_str) != Some("complete")
|
|
1428
|
-
{
|
|
1429
|
-
errors.push(
|
|
1430
|
-
"NAOME intake is not complete. Finish docs/naome/first-run.md before feature work."
|
|
1431
|
-
.to_string(),
|
|
1432
|
-
);
|
|
1433
|
-
}
|
|
1434
|
-
Ok(())
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
fn validate_clean_git_diff(
|
|
1438
|
-
task_state: &Value,
|
|
1439
|
-
root: &Path,
|
|
1440
|
-
errors: &mut Vec<String>,
|
|
1441
|
-
) -> Result<(), NaomeError> {
|
|
1442
|
-
let changed_paths = read_git_changed_paths(root)?;
|
|
1443
|
-
if !changed_paths.is_empty() {
|
|
1444
|
-
errors.push(format_dirty_diff_admission_blocker(
|
|
1445
|
-
task_state,
|
|
1446
|
-
root,
|
|
1447
|
-
&changed_paths,
|
|
1448
|
-
)?);
|
|
1449
|
-
}
|
|
1450
|
-
Ok(())
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
fn validate_commit_gate(
|
|
1454
|
-
task_state: &Value,
|
|
1455
|
-
root: &Path,
|
|
1456
|
-
errors: &mut Vec<String>,
|
|
1457
|
-
notices: &mut Vec<String>,
|
|
1458
|
-
) -> Result<(), NaomeError> {
|
|
1459
|
-
let staged_entries = read_git_staged_changed_entries(root)?;
|
|
1460
|
-
let changed_paths: Vec<String> = staged_entries
|
|
1461
|
-
.iter()
|
|
1462
|
-
.map(|entry| entry.path.clone())
|
|
1463
|
-
.collect();
|
|
1464
|
-
if changed_paths.is_empty() {
|
|
1465
|
-
return Ok(());
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
let status = task_state
|
|
1469
|
-
.get("status")
|
|
1470
|
-
.and_then(Value::as_str)
|
|
1471
|
-
.unwrap_or("invalid");
|
|
1472
|
-
if status == "complete" && is_deterministic_harness_refresh_diff(&changed_paths) {
|
|
1473
|
-
validate_pending_upgrade(task_state, root, errors)?;
|
|
1474
|
-
validate_completed_task_for_harness_refresh(task_state, root, &staged_entries, errors)?;
|
|
1475
|
-
return Ok(());
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
if status == "complete" {
|
|
1479
|
-
validate_active_task(task_state.get("activeTask"), errors);
|
|
1480
|
-
validate_active_task_references(task_state.get("activeTask"), root, errors, Some(status))?;
|
|
1481
|
-
if !task_state.get("blocker").is_some_and(Value::is_null) {
|
|
1482
|
-
errors.push("complete task state must have blocker set to null.".to_string());
|
|
1483
|
-
}
|
|
1484
|
-
if let Some(active_task) = task_state.get("activeTask") {
|
|
1485
|
-
let check_ids = read_verification_check_ids(root, errors)?;
|
|
1486
|
-
validate_required_check_ids(active_task, &check_ids, errors);
|
|
1487
|
-
validate_complete_task_against_entries(
|
|
1488
|
-
active_task,
|
|
1489
|
-
root,
|
|
1490
|
-
&check_ids,
|
|
1491
|
-
&staged_entries,
|
|
1492
|
-
errors,
|
|
1493
|
-
)?;
|
|
1494
|
-
if errors.is_empty() {
|
|
1495
|
-
notices.push(format!(
|
|
1496
|
-
"Commit gate accepted task-owned staged paths: {}.",
|
|
1497
|
-
changed_paths.join(", ")
|
|
1498
|
-
));
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
return Ok(());
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
if status == "idle" && is_install_or_upgrade_baseline_diff(root, &changed_paths)? {
|
|
1505
|
-
return Ok(());
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
if status == "idle" && is_harness_repair_diff(root, &changed_paths)? {
|
|
1509
|
-
return Ok(());
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
validate_init_complete(root, errors)?;
|
|
1513
|
-
validate_upgrade_complete(root, errors)?;
|
|
1514
|
-
|
|
1515
|
-
if status == "idle" {
|
|
1516
|
-
errors.push(format!("NAOME commit gate blocked: changed paths are not owned by a completed task state. Changed paths: {}. Finish a NAOME task and use naome commit, or reconcile the diff before committing.", changed_paths.join(", ")));
|
|
1517
|
-
return Ok(());
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
if status == "blocked" || status == "needs_human_review" {
|
|
1521
|
-
validate_blocker(task_state.get("blocker"), errors);
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
errors.push(format!("NAOME commit gate blocked because task state is {status}. Finish or revise the active task, set it to complete with fresh proof, then use naome commit. Human options: continue_current_task, request_task_changes, mark_task_blocked, cancel_task_state."));
|
|
1525
|
-
Ok(())
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
fn validate_completed_task_for_harness_refresh(
|
|
1529
|
-
task_state: &Value,
|
|
1530
|
-
root: &Path,
|
|
1531
|
-
staged_entries: &[ChangedEntry],
|
|
1532
|
-
errors: &mut Vec<String>,
|
|
1533
|
-
) -> Result<(), NaomeError> {
|
|
1534
|
-
validate_active_task(task_state.get("activeTask"), errors);
|
|
1535
|
-
validate_active_task_references(task_state.get("activeTask"), root, errors, Some("complete"))?;
|
|
1536
|
-
if !task_state.get("blocker").is_some_and(Value::is_null) {
|
|
1537
|
-
errors.push("complete task state must have blocker set to null.".to_string());
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
let Some(active_task) = task_state.get("activeTask") else {
|
|
1541
|
-
return Ok(());
|
|
1542
|
-
};
|
|
1543
|
-
|
|
1544
|
-
let check_ids = read_verification_check_ids(root, errors)?;
|
|
1545
|
-
validate_required_check_ids(active_task, &check_ids, errors);
|
|
1546
|
-
|
|
1547
|
-
let mut validation_errors = Vec::new();
|
|
1548
|
-
validate_complete_task_against_entries(
|
|
1549
|
-
active_task,
|
|
1550
|
-
root,
|
|
1551
|
-
&check_ids,
|
|
1552
|
-
staged_entries,
|
|
1553
|
-
&mut validation_errors,
|
|
1554
|
-
)?;
|
|
1555
|
-
|
|
1556
|
-
let staged_harness_paths = task_diff_from_entries(active_task, staged_entries).outside_paths;
|
|
1557
|
-
let allowed_scope_error = format!(
|
|
1558
|
-
"Changed files outside allowedPaths: {}",
|
|
1559
|
-
staged_harness_paths.join(", ")
|
|
1560
|
-
);
|
|
1561
|
-
|
|
1562
|
-
errors.extend(
|
|
1563
|
-
validation_errors
|
|
1564
|
-
.into_iter()
|
|
1565
|
-
.filter(|error| error != &allowed_scope_error),
|
|
1566
|
-
);
|
|
1567
|
-
|
|
1568
|
-
Ok(())
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
fn validate_push_gate(task_state: &Value, errors: &mut Vec<String>) {
|
|
1572
|
-
let status = task_state
|
|
1573
|
-
.get("status")
|
|
1574
|
-
.and_then(Value::as_str)
|
|
1575
|
-
.unwrap_or("invalid");
|
|
1576
|
-
if !BLOCKING_STATUS.contains(&status) {
|
|
1577
|
-
return;
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
if status == "blocked" || status == "needs_human_review" {
|
|
1581
|
-
validate_blocker(task_state.get("blocker"), errors);
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
errors.push(format!("NAOME push gate blocked because task state is {status}. Resolve the active task before pushing. Human options: continue_current_task, request_task_changes, mark_task_blocked, cancel_task_state."));
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
fn format_dirty_diff_admission_blocker(
|
|
1588
|
-
task_state: &Value,
|
|
1589
|
-
root: &Path,
|
|
1590
|
-
changed_paths: &[String],
|
|
1591
|
-
) -> Result<String, NaomeError> {
|
|
1592
|
-
let prefix = format!(
|
|
1593
|
-
"Task admission requires a clean git diff. Changed paths: {}.",
|
|
1594
|
-
changed_paths.join(", ")
|
|
1595
|
-
);
|
|
1596
|
-
|
|
1597
|
-
if is_harness_repair_diff(root, changed_paths)? {
|
|
1598
|
-
return Ok(format!("{prefix} These look like completed Harness Repair changes. Run NAOME intent for the next natural-language request before deciding whether to baseline, review, or cancel the repair diff."));
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
if is_completed_task_diff(task_state, changed_paths) {
|
|
1602
|
-
return Ok(format!("{prefix} These look like completed task changes. Run NAOME intent for the next natural-language request; deterministic policy can baseline a valid completed task before creating the next task."));
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
if is_naome_baseline_diff(changed_paths) {
|
|
1606
|
-
return Ok(format!("{prefix} These look like completed NAOME install or upgrade changes. Run NAOME intent for the next natural-language request; deterministic policy can baseline setup before creating the next task."));
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
Ok(format!("{prefix} Ask the user to choose exactly one: review_task_diff, request_task_changes, cancel_task_changes. Do not start new feature work or commit without explicit user selection."))
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
fn is_harness_repair_diff(root: &Path, changed_paths: &[String]) -> Result<bool, NaomeError> {
|
|
1613
|
-
let machine_owned_paths = read_machine_owned_paths(root)?;
|
|
1614
|
-
if machine_owned_paths.is_empty() {
|
|
1615
|
-
return Ok(false);
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
let has_repair_signal = changed_paths
|
|
1619
|
-
.iter()
|
|
1620
|
-
.any(|path| machine_owned_paths.contains(path) || is_repair_archive_path(path));
|
|
1621
|
-
if !has_repair_signal {
|
|
1622
|
-
return Ok(false);
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
Ok(changed_paths
|
|
1626
|
-
.iter()
|
|
1627
|
-
.all(|path| machine_owned_paths.contains(path) || is_repair_support_path(path)))
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
fn is_repair_support_path(path: &str) -> bool {
|
|
1631
|
-
path == ".naome/manifest.json"
|
|
1632
|
-
|| path == ".naome/upgrade-state.json"
|
|
1633
|
-
|| is_repair_archive_path(path)
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
fn is_packaged_machine_owned_path(path: &str) -> bool {
|
|
1637
|
-
MACHINE_OWNED_PATHS.iter().any(|owned| *owned == path)
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
fn is_safe_harness_refresh_path(path: &str) -> bool {
|
|
1641
|
-
is_packaged_machine_owned_path(path) || is_repair_support_path(path)
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1644
|
-
fn is_deterministic_harness_refresh_diff(changed_paths: &[String]) -> bool {
|
|
1645
|
-
!changed_paths.is_empty()
|
|
1646
|
-
&& changed_paths
|
|
1647
|
-
.iter()
|
|
1648
|
-
.any(|path| is_packaged_machine_owned_path(path) || is_repair_archive_path(path))
|
|
1649
|
-
&& changed_paths
|
|
1650
|
-
.iter()
|
|
1651
|
-
.all(|path| is_safe_harness_refresh_path(path))
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
fn is_repair_archive_path(path: &str) -> bool {
|
|
1655
|
-
path.starts_with(".naome/archive/repair-")
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
fn is_completed_task_diff(task_state: &Value, changed_paths: &[String]) -> bool {
|
|
1659
|
-
if task_state.get("status").and_then(Value::as_str) != Some("complete") {
|
|
1660
|
-
return false;
|
|
1661
|
-
}
|
|
1662
|
-
let Some(active_task) = task_state.get("activeTask") else {
|
|
1663
|
-
return false;
|
|
1664
|
-
};
|
|
1665
|
-
let allowed_paths = string_array(active_task.get("allowedPaths")).unwrap_or_default();
|
|
1666
|
-
let task_paths: Vec<&String> = changed_paths
|
|
1667
|
-
.iter()
|
|
1668
|
-
.filter(|path| path.as_str() != CONTROL_STATE_PATH)
|
|
1669
|
-
.collect();
|
|
1670
|
-
!task_paths.is_empty()
|
|
1671
|
-
&& task_paths
|
|
1672
|
-
.iter()
|
|
1673
|
-
.all(|path| matches_any_pattern(path, &allowed_paths))
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
fn is_naome_baseline_diff(changed_paths: &[String]) -> bool {
|
|
1677
|
-
changed_paths.iter().all(|path| {
|
|
1678
|
-
path == "AGENTS.md"
|
|
1679
|
-
|| path == ".gitignore"
|
|
1680
|
-
|| path == ".naomeignore"
|
|
1681
|
-
|| path.starts_with(".naome/")
|
|
1682
|
-
|| path.starts_with("docs/naome/")
|
|
1683
|
-
})
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
fn is_install_or_upgrade_baseline_diff(
|
|
1687
|
-
root: &Path,
|
|
1688
|
-
changed_paths: &[String],
|
|
1689
|
-
) -> Result<bool, NaomeError> {
|
|
1690
|
-
if !is_naome_baseline_diff(changed_paths) {
|
|
1691
|
-
return Ok(false);
|
|
1692
|
-
}
|
|
1693
|
-
|
|
1694
|
-
let has_setup_signal = changed_paths.iter().any(|path| {
|
|
1695
|
-
matches!(
|
|
1696
|
-
path.as_str(),
|
|
1697
|
-
"AGENTS.md"
|
|
1698
|
-
| ".gitignore"
|
|
1699
|
-
| ".naomeignore"
|
|
1700
|
-
| ".naome/init-state.json"
|
|
1701
|
-
| ".naome/manifest.json"
|
|
1702
|
-
| ".naome/package.json"
|
|
1703
|
-
| ".naome/task-contract.schema.json"
|
|
1704
|
-
| ".naome/upgrade-state.json"
|
|
1705
|
-
)
|
|
1706
|
-
});
|
|
1707
|
-
if !has_setup_signal {
|
|
1708
|
-
return Ok(false);
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
if read_init_incomplete(root)? || read_upgrade_baseline_signal(root)? {
|
|
1712
|
-
return Ok(true);
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
Ok(changed_paths.iter().any(|path| {
|
|
1716
|
-
matches!(
|
|
1717
|
-
path.as_str(),
|
|
1718
|
-
".naome/init-state.json" | ".naome/manifest.json" | ".naome/upgrade-state.json"
|
|
1719
|
-
)
|
|
1720
|
-
}))
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
fn read_init_incomplete(root: &Path) -> Result<bool, NaomeError> {
|
|
1724
|
-
let Some(init_state) = read_json(root, ".naome/init-state.json", &mut Vec::new())? else {
|
|
1725
|
-
return Ok(false);
|
|
1726
|
-
};
|
|
1727
|
-
|
|
1728
|
-
Ok(
|
|
1729
|
-
init_state.get("initialized").and_then(Value::as_bool) != Some(true)
|
|
1730
|
-
|| init_state.get("intakeStatus").and_then(Value::as_str) != Some("complete"),
|
|
1731
|
-
)
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
fn read_upgrade_baseline_signal(root: &Path) -> Result<bool, NaomeError> {
|
|
1735
|
-
let Some(upgrade_state) = read_json(root, ".naome/upgrade-state.json", &mut Vec::new())? else {
|
|
1736
|
-
return Ok(false);
|
|
1737
|
-
};
|
|
1738
|
-
|
|
1739
|
-
Ok(upgrade_state.get("fromVersion").is_some()
|
|
1740
|
-
|| upgrade_state
|
|
1741
|
-
.get("pending")
|
|
1742
|
-
.and_then(Value::as_array)
|
|
1743
|
-
.is_some_and(|pending| !pending.is_empty())
|
|
1744
|
-
|| upgrade_state
|
|
1745
|
-
.get("completed")
|
|
1746
|
-
.and_then(Value::as_array)
|
|
1747
|
-
.is_some_and(|completed| !completed.is_empty()))
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
fn read_machine_owned_paths(root: &Path) -> Result<HashSet<String>, NaomeError> {
|
|
1751
|
-
let Some(manifest) = read_json(root, ".naome/manifest.json", &mut Vec::new())? else {
|
|
1752
|
-
return Ok(HashSet::new());
|
|
1753
|
-
};
|
|
1754
|
-
|
|
1755
|
-
Ok(manifest
|
|
1756
|
-
.get("machineOwned")
|
|
1757
|
-
.and_then(Value::as_array)
|
|
1758
|
-
.into_iter()
|
|
1759
|
-
.flatten()
|
|
1760
|
-
.filter_map(Value::as_str)
|
|
1761
|
-
.filter(|value| is_non_empty_string(value))
|
|
1762
|
-
.map(normalize_path)
|
|
1763
|
-
.collect())
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
fn add_completed_task_diff_notice(
|
|
1767
|
-
root: &Path,
|
|
1768
|
-
notices: &mut Vec<String>,
|
|
1769
|
-
) -> Result<(), NaomeError> {
|
|
1770
|
-
let changed_paths = read_git_changed_paths(root)?;
|
|
1771
|
-
if changed_paths.is_empty() {
|
|
1772
|
-
return Ok(());
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
notices.push(format!("Task is complete and verified. Changed paths: {}. NAOME intent can baseline it automatically before the next distinct task; only surface human choices when intent blocks or the user explicitly asks to review, revise, cancel, or commit.", changed_paths.join(", ")));
|
|
1776
|
-
Ok(())
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
pub fn completed_task_harness_refresh_diff(
|
|
1780
|
-
root: &Path,
|
|
1781
|
-
) -> Result<Option<CompletedTaskHarnessRefreshDiff>, NaomeError> {
|
|
1782
|
-
let mut read_errors = Vec::new();
|
|
1783
|
-
let Some(task_state) = read_json(root, ".naome/task-state.json", &mut read_errors)? else {
|
|
1784
|
-
return Ok(None);
|
|
1785
|
-
};
|
|
1786
|
-
if !read_errors.is_empty() {
|
|
1787
|
-
return Ok(None);
|
|
1788
|
-
}
|
|
1789
|
-
if task_state.get("status").and_then(Value::as_str) != Some("complete") {
|
|
1790
|
-
return Ok(None);
|
|
1791
|
-
}
|
|
1792
|
-
let Some(active_task) = task_state.get("activeTask") else {
|
|
1793
|
-
return Ok(None);
|
|
1794
|
-
};
|
|
1795
|
-
|
|
1796
|
-
let allowed_paths = string_array(active_task.get("allowedPaths")).unwrap_or_default();
|
|
1797
|
-
let mut harness_paths = Vec::new();
|
|
1798
|
-
let mut task_paths = Vec::new();
|
|
1799
|
-
let mut other_paths = Vec::new();
|
|
1800
|
-
|
|
1801
|
-
for path in read_git_changed_paths(root)? {
|
|
1802
|
-
if path == CONTROL_STATE_PATH {
|
|
1803
|
-
continue;
|
|
1804
|
-
}
|
|
1805
|
-
if matches_any_pattern(&path, &allowed_paths) {
|
|
1806
|
-
task_paths.push(path);
|
|
1807
|
-
} else if is_safe_harness_refresh_path(&path) {
|
|
1808
|
-
harness_paths.push(path);
|
|
1809
|
-
} else {
|
|
1810
|
-
other_paths.push(path);
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
if task_paths.is_empty() || harness_paths.is_empty() || !other_paths.is_empty() {
|
|
1815
|
-
return Ok(None);
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
let report = validate_task_state(
|
|
1819
|
-
root,
|
|
1820
|
-
TaskStateOptions {
|
|
1821
|
-
mode: TaskStateMode::State,
|
|
1822
|
-
harness_health: None,
|
|
1823
|
-
},
|
|
1824
|
-
)?;
|
|
1825
|
-
let allowed_scope_error = format!(
|
|
1826
|
-
"Changed files outside allowedPaths: {}",
|
|
1827
|
-
harness_paths.join(", ")
|
|
1828
|
-
);
|
|
1829
|
-
if report
|
|
1830
|
-
.errors
|
|
1831
|
-
.iter()
|
|
1832
|
-
.all(|error| error == &allowed_scope_error)
|
|
1833
|
-
{
|
|
1834
|
-
Ok(Some(CompletedTaskHarnessRefreshDiff {
|
|
1835
|
-
harness_paths,
|
|
1836
|
-
task_paths,
|
|
1837
|
-
}))
|
|
1838
|
-
} else {
|
|
1839
|
-
Ok(None)
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
fn read_git_changed_paths(root: &Path) -> Result<Vec<String>, NaomeError> {
|
|
1844
|
-
Ok(read_git_changed_entries(root)?
|
|
1845
|
-
.into_iter()
|
|
1846
|
-
.map(|entry| entry.path)
|
|
1847
|
-
.collect())
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
fn read_git_staged_changed_entries(root: &Path) -> Result<Vec<ChangedEntry>, NaomeError> {
|
|
1851
|
-
let output = Command::new("git")
|
|
1852
|
-
.args(["diff", "--name-status", "--cached", "-z"])
|
|
1853
|
-
.current_dir(root)
|
|
1854
|
-
.output()?;
|
|
1855
|
-
if !output.status.success() {
|
|
1856
|
-
return Err(NaomeError::new(format!(
|
|
1857
|
-
"git diff --name-status --cached -z failed: {}",
|
|
1858
|
-
command_output(&output)
|
|
1859
|
-
)));
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
Ok(parse_name_status_output(&output.stdout))
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
fn read_git_changed_entries(root: &Path) -> Result<Vec<ChangedEntry>, NaomeError> {
|
|
1866
|
-
let git_check = run_git(root, ["rev-parse", "--is-inside-work-tree"])?;
|
|
1867
|
-
if !git_check.status.success() {
|
|
1868
|
-
return Err(NaomeError::new(
|
|
1869
|
-
"complete task validation requires a git work tree.",
|
|
1870
|
-
));
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
let mut entries: HashMap<String, ChangedEntry> = HashMap::new();
|
|
1874
|
-
for args in [
|
|
1875
|
-
vec!["diff", "--name-status", "-z"],
|
|
1876
|
-
vec!["diff", "--name-status", "--cached", "-z"],
|
|
1877
|
-
] {
|
|
1878
|
-
let output = Command::new("git").args(&args).current_dir(root).output()?;
|
|
1879
|
-
if !output.status.success() {
|
|
1880
|
-
return Err(NaomeError::new(format!(
|
|
1881
|
-
"git {} failed: {}",
|
|
1882
|
-
args.join(" "),
|
|
1883
|
-
command_output(&output)
|
|
1884
|
-
)));
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
for entry in parse_name_status_output(&output.stdout) {
|
|
1888
|
-
upsert_changed_entry(&mut entries, entry);
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1892
|
-
let untracked = run_git(root, ["ls-files", "--others", "--exclude-standard", "-z"])?;
|
|
1893
|
-
if !untracked.status.success() {
|
|
1894
|
-
return Err(NaomeError::new(format!(
|
|
1895
|
-
"git ls-files --others --exclude-standard -z failed: {}",
|
|
1896
|
-
command_output(&untracked)
|
|
1897
|
-
)));
|
|
1898
|
-
}
|
|
1899
|
-
|
|
1900
|
-
for token in split_nul(&untracked.stdout) {
|
|
1901
|
-
let path = normalize_path(token.trim());
|
|
1902
|
-
if !path.is_empty() {
|
|
1903
|
-
upsert_changed_entry(
|
|
1904
|
-
&mut entries,
|
|
1905
|
-
ChangedEntry {
|
|
1906
|
-
path,
|
|
1907
|
-
status: "added".to_string(),
|
|
1908
|
-
},
|
|
1909
|
-
);
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
|
-
let mut entries: Vec<ChangedEntry> = entries.into_values().collect();
|
|
1914
|
-
entries.sort_by(|left, right| left.path.cmp(&right.path));
|
|
1915
|
-
Ok(entries)
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
fn parse_name_status_output(output: &[u8]) -> Vec<ChangedEntry> {
|
|
1919
|
-
let tokens = split_nul(output);
|
|
1920
|
-
let mut entries = Vec::new();
|
|
1921
|
-
let mut index = 0;
|
|
1922
|
-
|
|
1923
|
-
while index < tokens.len() {
|
|
1924
|
-
let raw_status = &tokens[index];
|
|
1925
|
-
index += 1;
|
|
1926
|
-
let status_code = raw_status.chars().next().unwrap_or('M');
|
|
1927
|
-
|
|
1928
|
-
if status_code == 'R' || status_code == 'C' {
|
|
1929
|
-
let from_path = normalize_path(tokens.get(index).map(String::as_str).unwrap_or(""));
|
|
1930
|
-
index += 1;
|
|
1931
|
-
let to_path = normalize_path(tokens.get(index).map(String::as_str).unwrap_or(""));
|
|
1932
|
-
index += 1;
|
|
1933
|
-
if !from_path.is_empty() {
|
|
1934
|
-
entries.push(ChangedEntry {
|
|
1935
|
-
path: from_path,
|
|
1936
|
-
status: "deleted".to_string(),
|
|
1937
|
-
});
|
|
1938
|
-
}
|
|
1939
|
-
if !to_path.is_empty() {
|
|
1940
|
-
entries.push(ChangedEntry {
|
|
1941
|
-
path: to_path,
|
|
1942
|
-
status: "renamed".to_string(),
|
|
1943
|
-
});
|
|
1944
|
-
}
|
|
1945
|
-
continue;
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
let path = normalize_path(tokens.get(index).map(String::as_str).unwrap_or(""));
|
|
1949
|
-
index += 1;
|
|
1950
|
-
if path.is_empty() {
|
|
1951
|
-
continue;
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
entries.push(ChangedEntry {
|
|
1955
|
-
path,
|
|
1956
|
-
status: git_status_code_to_evidence_status(status_code).to_string(),
|
|
1957
|
-
});
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
entries
|
|
1961
|
-
}
|
|
1962
|
-
|
|
1963
|
-
fn split_nul(output: &[u8]) -> Vec<String> {
|
|
1964
|
-
output
|
|
1965
|
-
.split(|byte| *byte == 0)
|
|
1966
|
-
.filter(|token| !token.is_empty())
|
|
1967
|
-
.map(|token| String::from_utf8_lossy(token).to_string())
|
|
1968
|
-
.collect()
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
fn git_status_code_to_evidence_status(status_code: char) -> &'static str {
|
|
1972
|
-
match status_code {
|
|
1973
|
-
'A' => "added",
|
|
1974
|
-
'D' => "deleted",
|
|
1975
|
-
_ => "modified",
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
fn upsert_changed_entry(entries: &mut HashMap<String, ChangedEntry>, entry: ChangedEntry) {
|
|
1980
|
-
let should_replace = entries
|
|
1981
|
-
.get(&entry.path)
|
|
1982
|
-
.map(|existing| status_rank(&entry.status) > status_rank(&existing.status))
|
|
1983
|
-
.unwrap_or(true);
|
|
1984
|
-
if should_replace {
|
|
1985
|
-
entries.insert(entry.path.clone(), entry);
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
fn status_rank(status: &str) -> u8 {
|
|
1990
|
-
match status {
|
|
1991
|
-
"deleted" => 4,
|
|
1992
|
-
"renamed" => 3,
|
|
1993
|
-
"added" => 2,
|
|
1994
|
-
_ => 1,
|
|
1995
|
-
}
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
fn read_git_head(root: &Path) -> Result<Option<String>, NaomeError> {
|
|
1999
|
-
let output = run_git(root, ["rev-parse", "HEAD"])?;
|
|
2000
|
-
if !output.status.success() {
|
|
2001
|
-
return Ok(None);
|
|
2002
|
-
}
|
|
2003
|
-
Ok(Some(
|
|
2004
|
-
String::from_utf8_lossy(&output.stdout).trim().to_string(),
|
|
2005
|
-
))
|
|
2006
|
-
}
|
|
2007
|
-
|
|
2008
|
-
fn git_commit_exists(root: &Path, commit: &str) -> Result<bool, NaomeError> {
|
|
2009
|
-
Ok(
|
|
2010
|
-
run_git(root, ["cat-file", "-e", &format!("{commit}^{{commit}}")])?
|
|
2011
|
-
.status
|
|
2012
|
-
.success(),
|
|
2013
|
-
)
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
fn run_git<const N: usize>(
|
|
2017
|
-
root: &Path,
|
|
2018
|
-
args: [&str; N],
|
|
2019
|
-
) -> Result<std::process::Output, NaomeError> {
|
|
2020
|
-
Ok(Command::new("git").args(args).current_dir(root).output()?)
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
fn command_output(output: &std::process::Output) -> String {
|
|
2024
|
-
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
2025
|
-
if !stderr.is_empty() {
|
|
2026
|
-
return stderr;
|
|
2027
|
-
}
|
|
2028
|
-
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
|
2029
|
-
}
|
|
2030
|
-
|
|
2031
|
-
fn matches_any_pattern(path: &str, patterns: &[String]) -> bool {
|
|
2032
|
-
patterns
|
|
2033
|
-
.iter()
|
|
2034
|
-
.any(|pattern| matches_path_pattern(path, pattern))
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
fn matches_path_pattern(path: &str, pattern: &str) -> bool {
|
|
2038
|
-
let normalized_path = normalize_path(path);
|
|
2039
|
-
let normalized_pattern = normalize_path(pattern);
|
|
2040
|
-
|
|
2041
|
-
if normalized_path == normalized_pattern {
|
|
2042
|
-
return true;
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
if let Some(prefix) = normalized_pattern.strip_suffix("/**") {
|
|
2046
|
-
return normalized_path == prefix || normalized_path.starts_with(&format!("{prefix}/"));
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
if !normalized_pattern.contains('*') {
|
|
2050
|
-
return false;
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
let path_to_match = if normalized_pattern.contains('/') {
|
|
2054
|
-
normalized_path
|
|
2055
|
-
} else {
|
|
2056
|
-
PathBuf::from(&normalized_path)
|
|
2057
|
-
.file_name()
|
|
2058
|
-
.map(|name| name.to_string_lossy().to_string())
|
|
2059
|
-
.unwrap_or(normalized_path)
|
|
2060
|
-
};
|
|
2061
|
-
wildcard_match(path_to_match.as_bytes(), normalized_pattern.as_bytes())
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
fn wildcard_match(value: &[u8], pattern: &[u8]) -> bool {
|
|
2065
|
-
let (mut value_index, mut pattern_index) = (0, 0);
|
|
2066
|
-
let mut star_index = None;
|
|
2067
|
-
let mut match_index = 0;
|
|
2068
|
-
|
|
2069
|
-
while value_index < value.len() {
|
|
2070
|
-
if pattern_index < pattern.len()
|
|
2071
|
-
&& pattern[pattern_index] != b'*'
|
|
2072
|
-
&& pattern[pattern_index] == value[value_index]
|
|
2073
|
-
{
|
|
2074
|
-
value_index += 1;
|
|
2075
|
-
pattern_index += 1;
|
|
2076
|
-
} else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
|
|
2077
|
-
star_index = Some(pattern_index);
|
|
2078
|
-
match_index = value_index;
|
|
2079
|
-
pattern_index += 1;
|
|
2080
|
-
} else if let Some(star) = star_index {
|
|
2081
|
-
pattern_index = star + 1;
|
|
2082
|
-
match_index += 1;
|
|
2083
|
-
value_index = match_index;
|
|
2084
|
-
} else {
|
|
2085
|
-
return false;
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
|
-
while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
|
|
2090
|
-
pattern_index += 1;
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
pattern_index == pattern.len()
|
|
2094
|
-
}
|
|
2095
|
-
|
|
2096
|
-
fn read_json(
|
|
2097
|
-
root: &Path,
|
|
2098
|
-
relative_path: &str,
|
|
2099
|
-
errors: &mut Vec<String>,
|
|
2100
|
-
) -> Result<Option<Value>, NaomeError> {
|
|
2101
|
-
let path = root.join(relative_path);
|
|
2102
|
-
if !path.exists() {
|
|
2103
|
-
errors.push(format!("{relative_path} is missing."));
|
|
2104
|
-
return Ok(None);
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2107
|
-
match serde_json::from_str(&fs::read_to_string(path)?) {
|
|
2108
|
-
Ok(value) => Ok(Some(value)),
|
|
2109
|
-
Err(error) => {
|
|
2110
|
-
errors.push(format!("{relative_path} is not valid JSON: {error}"));
|
|
2111
|
-
Ok(None)
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
fn require_string(value: Option<&Value>, field_name: &str, errors: &mut Vec<String>) {
|
|
2117
|
-
if !value
|
|
2118
|
-
.and_then(Value::as_str)
|
|
2119
|
-
.is_some_and(is_non_empty_string)
|
|
2120
|
-
{
|
|
2121
|
-
errors.push(format!("{field_name} must be a non-empty string."));
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
fn require_string_array(value: Option<&Value>, field_name: &str, errors: &mut Vec<String>) {
|
|
2126
|
-
let Some(values) = value.and_then(Value::as_array) else {
|
|
2127
|
-
errors.push(format!("{field_name} must be a non-empty string array."));
|
|
2128
|
-
return;
|
|
2129
|
-
};
|
|
2130
|
-
|
|
2131
|
-
if values.is_empty()
|
|
2132
|
-
|| values
|
|
2133
|
-
.iter()
|
|
2134
|
-
.any(|entry| !entry.as_str().is_some_and(is_non_empty_string))
|
|
2135
|
-
{
|
|
2136
|
-
errors.push(format!("{field_name} must be a non-empty string array."));
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
fn require_string_array_allow_empty(
|
|
2141
|
-
value: Option<&Value>,
|
|
2142
|
-
field_name: &str,
|
|
2143
|
-
errors: &mut Vec<String>,
|
|
2144
|
-
) {
|
|
2145
|
-
let Some(values) = value.and_then(Value::as_array) else {
|
|
2146
|
-
errors.push(format!("{field_name} must be a string array."));
|
|
2147
|
-
return;
|
|
2148
|
-
};
|
|
2149
|
-
|
|
2150
|
-
if values
|
|
2151
|
-
.iter()
|
|
2152
|
-
.any(|entry| !entry.as_str().is_some_and(is_non_empty_string))
|
|
2153
|
-
{
|
|
2154
|
-
errors.push(format!("{field_name} must be a string array."));
|
|
2155
|
-
}
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
fn string_array(value: Option<&Value>) -> Option<Vec<String>> {
|
|
2159
|
-
value.and_then(Value::as_array).and_then(|values| {
|
|
2160
|
-
values
|
|
2161
|
-
.iter()
|
|
2162
|
-
.map(|entry| {
|
|
2163
|
-
entry
|
|
2164
|
-
.as_str()
|
|
2165
|
-
.filter(|value| is_non_empty_string(value))
|
|
2166
|
-
.map(ToString::to_string)
|
|
2167
|
-
})
|
|
2168
|
-
.collect()
|
|
2169
|
-
})
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
fn is_non_empty_string(value: &str) -> bool {
|
|
2173
|
-
!value.trim().is_empty()
|
|
2174
|
-
}
|
|
2175
|
-
|
|
2176
|
-
fn is_id(value: &str) -> bool {
|
|
2177
|
-
let mut chars = value.chars();
|
|
2178
|
-
let Some(first) = chars.next() else {
|
|
2179
|
-
return false;
|
|
2180
|
-
};
|
|
2181
|
-
(first.is_ascii_lowercase() || first.is_ascii_digit())
|
|
2182
|
-
&& value
|
|
2183
|
-
.chars()
|
|
2184
|
-
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
|
2185
|
-
}
|
|
2186
|
-
|
|
2187
|
-
fn is_iso_datetime(value: &str) -> bool {
|
|
2188
|
-
let bytes = value.as_bytes();
|
|
2189
|
-
bytes.len() == 24
|
|
2190
|
-
&& bytes[4] == b'-'
|
|
2191
|
-
&& bytes[7] == b'-'
|
|
2192
|
-
&& bytes[10] == b'T'
|
|
2193
|
-
&& bytes[13] == b':'
|
|
2194
|
-
&& bytes[16] == b':'
|
|
2195
|
-
&& bytes[19] == b'.'
|
|
2196
|
-
&& bytes[23] == b'Z'
|
|
2197
|
-
&& bytes
|
|
2198
|
-
.iter()
|
|
2199
|
-
.enumerate()
|
|
2200
|
-
.filter(|(index, _)| ![4, 7, 10, 13, 16, 19, 23].contains(index))
|
|
2201
|
-
.all(|(_, byte)| byte.is_ascii_digit())
|
|
2202
|
-
}
|
|
2203
|
-
|
|
2204
|
-
fn normalize_path(value: impl AsRef<str>) -> String {
|
|
2205
|
-
value
|
|
2206
|
-
.as_ref()
|
|
2207
|
-
.replace('\\', "/")
|
|
2208
|
-
.trim_start_matches("./")
|
|
2209
|
-
.to_string()
|
|
2210
|
-
}
|