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