@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,217 @@
|
|
|
1
|
+
use std::collections::HashSet;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
|
|
5
|
+
use serde_json::Value;
|
|
6
|
+
|
|
7
|
+
use crate::models::NaomeError;
|
|
8
|
+
|
|
9
|
+
pub fn seed_builtin_verification_checks(root: &Path) -> Result<bool, NaomeError> {
|
|
10
|
+
let verification_path = root.join(".naome").join("verification.json");
|
|
11
|
+
let original_content = fs::read_to_string(&verification_path)?;
|
|
12
|
+
let mut verification: Value = serde_json::from_str(&original_content)?;
|
|
13
|
+
|
|
14
|
+
let Some(verification_object) = verification.as_object_mut() else {
|
|
15
|
+
return Err(NaomeError::new(
|
|
16
|
+
".naome/verification.json must be a JSON object.",
|
|
17
|
+
));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if !verification_object
|
|
21
|
+
.get("checks")
|
|
22
|
+
.is_some_and(serde_json::Value::is_array)
|
|
23
|
+
{
|
|
24
|
+
verification_object.insert("checks".to_string(), Value::Array(Vec::new()));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let mut existing_ids = verification_object
|
|
28
|
+
.get("checks")
|
|
29
|
+
.and_then(serde_json::Value::as_array)
|
|
30
|
+
.expect("checks must be an array after insertion")
|
|
31
|
+
.iter()
|
|
32
|
+
.filter_map(|check| check.get("id").and_then(serde_json::Value::as_str))
|
|
33
|
+
.map(ToString::to_string)
|
|
34
|
+
.collect::<HashSet<_>>();
|
|
35
|
+
|
|
36
|
+
let mut missing_checks = Vec::new();
|
|
37
|
+
for check in builtin_checks() {
|
|
38
|
+
if !existing_ids.contains(check.id) {
|
|
39
|
+
existing_ids.insert(check.id.to_string());
|
|
40
|
+
missing_checks.push(check);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if missing_checks.is_empty() {
|
|
45
|
+
return Ok(false);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let next_content = match append_checks_preserving_layout(&original_content, &missing_checks) {
|
|
49
|
+
Some(content) => content,
|
|
50
|
+
None => {
|
|
51
|
+
let checks = verification_object
|
|
52
|
+
.get_mut("checks")
|
|
53
|
+
.and_then(serde_json::Value::as_array_mut)
|
|
54
|
+
.expect("checks must be an array after insertion");
|
|
55
|
+
for check in &missing_checks {
|
|
56
|
+
checks.push(check.value());
|
|
57
|
+
}
|
|
58
|
+
serde_json::to_string_pretty(&verification).unwrap_or_else(|_| original_content.clone())
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
fs::write(verification_path, format!("{next_content}\n"))?;
|
|
63
|
+
Ok(true)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
struct BuiltinCheck {
|
|
67
|
+
id: &'static str,
|
|
68
|
+
content: &'static str,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
impl BuiltinCheck {
|
|
72
|
+
fn value(&self) -> Value {
|
|
73
|
+
serde_json::from_str(self.content).expect("built-in verification check must be valid JSON")
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn builtin_checks() -> Vec<BuiltinCheck> {
|
|
78
|
+
vec![
|
|
79
|
+
BuiltinCheck {
|
|
80
|
+
id: "diff-check",
|
|
81
|
+
content: r#"{
|
|
82
|
+
"id": "diff-check",
|
|
83
|
+
"command": "git diff --check",
|
|
84
|
+
"cwd": ".",
|
|
85
|
+
"purpose": "Reject whitespace errors in the current diff.",
|
|
86
|
+
"cost": "fast",
|
|
87
|
+
"source": "NAOME built-in",
|
|
88
|
+
"evidence": [],
|
|
89
|
+
"lastVerified": null
|
|
90
|
+
}"#,
|
|
91
|
+
},
|
|
92
|
+
BuiltinCheck {
|
|
93
|
+
id: "naome-harness-health",
|
|
94
|
+
content: r#"{
|
|
95
|
+
"id": "naome-harness-health",
|
|
96
|
+
"command": "node .naome/bin/check-harness-health.js",
|
|
97
|
+
"cwd": ".",
|
|
98
|
+
"purpose": "Validate the installed NAOME harness before feature work or task completion.",
|
|
99
|
+
"cost": "fast",
|
|
100
|
+
"source": "NAOME built-in",
|
|
101
|
+
"evidence": [
|
|
102
|
+
".naome/bin/check-harness-health.js"
|
|
103
|
+
],
|
|
104
|
+
"lastVerified": null
|
|
105
|
+
}"#,
|
|
106
|
+
},
|
|
107
|
+
BuiltinCheck {
|
|
108
|
+
id: "naome-task-state",
|
|
109
|
+
content: r#"{
|
|
110
|
+
"id": "naome-task-state",
|
|
111
|
+
"command": "node .naome/bin/check-task-state.js",
|
|
112
|
+
"cwd": ".",
|
|
113
|
+
"purpose": "Validate the NAOME task-state contract for the current repository.",
|
|
114
|
+
"cost": "fast",
|
|
115
|
+
"source": "NAOME built-in",
|
|
116
|
+
"evidence": [
|
|
117
|
+
".naome/bin/check-task-state.js",
|
|
118
|
+
".naome/task-contract.schema.json"
|
|
119
|
+
],
|
|
120
|
+
"lastVerified": null
|
|
121
|
+
}"#,
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fn append_checks_preserving_layout(
|
|
127
|
+
content: &str,
|
|
128
|
+
missing_checks: &[BuiltinCheck],
|
|
129
|
+
) -> Option<String> {
|
|
130
|
+
let (array_start, array_end) = find_checks_array_bounds(content)?;
|
|
131
|
+
let closing_indent = line_indent_before(content, array_end);
|
|
132
|
+
let item_indent = format!("{closing_indent} ");
|
|
133
|
+
let existing_body = content.get(array_start + 1..array_end)?.trim();
|
|
134
|
+
let mut insertion = String::new();
|
|
135
|
+
|
|
136
|
+
if existing_body.is_empty() {
|
|
137
|
+
insertion.push('\n');
|
|
138
|
+
} else {
|
|
139
|
+
insertion.push_str(",\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (index, check) in missing_checks.iter().enumerate() {
|
|
143
|
+
if index > 0 {
|
|
144
|
+
insertion.push_str(",\n");
|
|
145
|
+
}
|
|
146
|
+
insertion.push_str(&indent_block(check.content, &item_indent));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
insertion.push('\n');
|
|
150
|
+
insertion.push_str(&closing_indent);
|
|
151
|
+
|
|
152
|
+
let mut next = String::with_capacity(content.len() + insertion.len());
|
|
153
|
+
next.push_str(&content[..array_end]);
|
|
154
|
+
next.push_str(&insertion);
|
|
155
|
+
next.push_str(content[array_end..].trim_end_matches('\n'));
|
|
156
|
+
Some(next)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fn find_checks_array_bounds(content: &str) -> Option<(usize, usize)> {
|
|
160
|
+
let key_start = content.find("\"checks\"")?;
|
|
161
|
+
let colon = content[key_start..].find(':')? + key_start;
|
|
162
|
+
let array_start = content[colon..].find('[')? + colon;
|
|
163
|
+
let array_end = find_matching_array_end(content, array_start)?;
|
|
164
|
+
Some((array_start, array_end))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fn find_matching_array_end(content: &str, array_start: usize) -> Option<usize> {
|
|
168
|
+
let mut depth = 0usize;
|
|
169
|
+
let mut in_string = false;
|
|
170
|
+
let mut escaped = false;
|
|
171
|
+
|
|
172
|
+
for (offset, character) in content[array_start..].char_indices() {
|
|
173
|
+
if in_string {
|
|
174
|
+
if escaped {
|
|
175
|
+
escaped = false;
|
|
176
|
+
} else if character == '\\' {
|
|
177
|
+
escaped = true;
|
|
178
|
+
} else if character == '"' {
|
|
179
|
+
in_string = false;
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
match character {
|
|
185
|
+
'"' => in_string = true,
|
|
186
|
+
'[' => depth += 1,
|
|
187
|
+
']' => {
|
|
188
|
+
depth = depth.checked_sub(1)?;
|
|
189
|
+
if depth == 0 {
|
|
190
|
+
return Some(array_start + offset);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
_ => {}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
None
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fn line_indent_before(content: &str, index: usize) -> String {
|
|
201
|
+
let line_start = content[..index]
|
|
202
|
+
.rfind('\n')
|
|
203
|
+
.map(|position| position + 1)
|
|
204
|
+
.unwrap_or(0);
|
|
205
|
+
content[line_start..index]
|
|
206
|
+
.chars()
|
|
207
|
+
.take_while(|character| *character == ' ' || *character == '\t')
|
|
208
|
+
.collect()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
fn indent_block(content: &str, indent: &str) -> String {
|
|
212
|
+
content
|
|
213
|
+
.lines()
|
|
214
|
+
.map(|line| format!("{indent}{line}"))
|
|
215
|
+
.collect::<Vec<_>>()
|
|
216
|
+
.join("\n")
|
|
217
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
use std::collections::HashSet;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
|
|
5
|
+
use serde_json::Value;
|
|
6
|
+
|
|
7
|
+
use crate::models::NaomeError;
|
|
8
|
+
|
|
9
|
+
const REQUIRED_TESTING_HEADINGS: &[&str] = &[
|
|
10
|
+
"# Testing And Verification",
|
|
11
|
+
"## Verification Map",
|
|
12
|
+
"## Known Checks",
|
|
13
|
+
"## Change Type Rules",
|
|
14
|
+
"## Release Gates",
|
|
15
|
+
"## Evidence",
|
|
16
|
+
];
|
|
17
|
+
const ALLOWED_STATUS: &[&str] = &["uninitialized", "partial", "ready"];
|
|
18
|
+
const ALLOWED_COST: &[&str] = &["fast", "medium", "slow", "expensive", "ci-only", "unknown"];
|
|
19
|
+
const ALLOWED_TOP_LEVEL_KEYS: &[&str] = &[
|
|
20
|
+
"schema",
|
|
21
|
+
"version",
|
|
22
|
+
"status",
|
|
23
|
+
"lastUpdated",
|
|
24
|
+
"checks",
|
|
25
|
+
"changeTypes",
|
|
26
|
+
"releaseGates",
|
|
27
|
+
];
|
|
28
|
+
const MAX_CHECKS: usize = 20;
|
|
29
|
+
const MAX_CHANGE_TYPES: usize = 12;
|
|
30
|
+
const MAX_RELEASE_GATES: usize = 10;
|
|
31
|
+
|
|
32
|
+
pub fn validate_verification_contract(root: &Path) -> Result<Vec<String>, NaomeError> {
|
|
33
|
+
let mut errors = Vec::new();
|
|
34
|
+
validate_testing_markdown(root, &mut errors);
|
|
35
|
+
|
|
36
|
+
let verification_path = root.join(".naome").join("verification.json");
|
|
37
|
+
let verification: Value = match fs::read_to_string(&verification_path) {
|
|
38
|
+
Ok(content) => match serde_json::from_str(&content) {
|
|
39
|
+
Ok(value) => value,
|
|
40
|
+
Err(error) => {
|
|
41
|
+
errors.push(format!(
|
|
42
|
+
".naome/verification.json is not valid JSON: {error}"
|
|
43
|
+
));
|
|
44
|
+
return Ok(errors);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
Err(_) => {
|
|
48
|
+
errors.push(".naome/verification.json is missing.".to_string());
|
|
49
|
+
return Ok(errors);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
validate_contract_shape(&verification, &mut errors);
|
|
54
|
+
Ok(errors)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fn validate_testing_markdown(root: &Path, errors: &mut Vec<String>) {
|
|
58
|
+
let testing_path = root.join("docs").join("naome").join("testing.md");
|
|
59
|
+
let Ok(content) = fs::read_to_string(testing_path) else {
|
|
60
|
+
errors.push("docs/naome/testing.md is missing.".to_string());
|
|
61
|
+
return;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for heading in REQUIRED_TESTING_HEADINGS {
|
|
65
|
+
if !content.lines().any(|line| line.trim() == *heading) {
|
|
66
|
+
errors.push(format!("docs/naome/testing.md missing heading: {heading}"));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn validate_contract_shape(contract: &Value, errors: &mut Vec<String>) {
|
|
72
|
+
let Some(object) = contract.as_object() else {
|
|
73
|
+
errors.push(".naome/verification.json must be a JSON object.".to_string());
|
|
74
|
+
return;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
for key in object.keys() {
|
|
78
|
+
if !ALLOWED_TOP_LEVEL_KEYS.contains(&key.as_str()) {
|
|
79
|
+
errors.push(format!(
|
|
80
|
+
".naome/verification.json unknown top-level key: {key}"
|
|
81
|
+
));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if object.get("schema").and_then(Value::as_str) != Some("naome.verification.v1") {
|
|
86
|
+
errors.push(".naome/verification.json schema must be naome.verification.v1.".to_string());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if object.get("version").and_then(Value::as_i64) != Some(1) {
|
|
90
|
+
errors.push(".naome/verification.json version must be 1.".to_string());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let status = object.get("status").and_then(Value::as_str);
|
|
94
|
+
if !status.is_some_and(|value| ALLOWED_STATUS.contains(&value)) {
|
|
95
|
+
errors.push(format!(
|
|
96
|
+
".naome/verification.json status must be one of: {}.",
|
|
97
|
+
ALLOWED_STATUS.join(", ")
|
|
98
|
+
));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if let Some(last_updated) = object.get("lastUpdated") {
|
|
102
|
+
if !last_updated.is_null()
|
|
103
|
+
&& !last_updated
|
|
104
|
+
.as_str()
|
|
105
|
+
.is_some_and(|value| is_iso_date(value))
|
|
106
|
+
{
|
|
107
|
+
errors.push(
|
|
108
|
+
".naome/verification.json lastUpdated must be YYYY-MM-DD or null.".to_string(),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let Some(checks) = validate_array(object, "checks", errors) else {
|
|
114
|
+
return;
|
|
115
|
+
};
|
|
116
|
+
let Some(change_types) = validate_array(object, "changeTypes", errors) else {
|
|
117
|
+
return;
|
|
118
|
+
};
|
|
119
|
+
let Some(release_gates) = validate_array(object, "releaseGates", errors) else {
|
|
120
|
+
return;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
validate_array_limit(checks, "checks", MAX_CHECKS, errors);
|
|
124
|
+
validate_array_limit(change_types, "changeTypes", MAX_CHANGE_TYPES, errors);
|
|
125
|
+
validate_array_limit(release_gates, "releaseGates", MAX_RELEASE_GATES, errors);
|
|
126
|
+
|
|
127
|
+
let check_ids = validate_checks(checks, errors);
|
|
128
|
+
validate_change_types(change_types, &check_ids, errors);
|
|
129
|
+
validate_release_gates(release_gates, &check_ids, errors);
|
|
130
|
+
|
|
131
|
+
if status == Some("ready") && contains_example_placeholders(contract) {
|
|
132
|
+
errors.push(
|
|
133
|
+
".naome/verification.json must not contain example placeholders when status is ready."
|
|
134
|
+
.to_string(),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if status == Some("ready") && checks.is_empty() {
|
|
139
|
+
errors.push(
|
|
140
|
+
".naome/verification.json must contain at least one real check when status is ready."
|
|
141
|
+
.to_string(),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fn validate_array<'a>(
|
|
147
|
+
object: &'a serde_json::Map<String, Value>,
|
|
148
|
+
name: &str,
|
|
149
|
+
errors: &mut Vec<String>,
|
|
150
|
+
) -> Option<&'a Vec<Value>> {
|
|
151
|
+
match object.get(name).and_then(Value::as_array) {
|
|
152
|
+
Some(values) => Some(values),
|
|
153
|
+
None => {
|
|
154
|
+
errors.push(format!("{name} must be an array."));
|
|
155
|
+
None
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fn validate_array_limit(values: &[Value], name: &str, max: usize, errors: &mut Vec<String>) {
|
|
161
|
+
if values.len() > max {
|
|
162
|
+
errors.push(format!("{name} must contain {max} entries or fewer."));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fn validate_checks(checks: &[Value], errors: &mut Vec<String>) -> HashSet<String> {
|
|
167
|
+
let mut check_ids = HashSet::new();
|
|
168
|
+
|
|
169
|
+
for (index, check) in checks.iter().enumerate() {
|
|
170
|
+
let prefix = format!("checks[{index}]");
|
|
171
|
+
let Some(object) = check.as_object() else {
|
|
172
|
+
errors.push(format!("{prefix} must be an object."));
|
|
173
|
+
continue;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
match object.get("id").and_then(Value::as_str) {
|
|
177
|
+
Some(id) if is_id(id) && !check_ids.contains(id) => {
|
|
178
|
+
check_ids.insert(id.to_string());
|
|
179
|
+
}
|
|
180
|
+
Some(id) if is_id(id) => {
|
|
181
|
+
errors.push(format!("{prefix}.id duplicates check id: {id}"));
|
|
182
|
+
}
|
|
183
|
+
_ => errors.push(format!("{prefix}.id must be kebab-case lowercase.")),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
require_string(object, "command", &prefix, errors);
|
|
187
|
+
require_string(object, "cwd", &prefix, errors);
|
|
188
|
+
require_string(object, "purpose", &prefix, errors);
|
|
189
|
+
require_string(object, "source", &prefix, errors);
|
|
190
|
+
require_string_array_allow_empty(object, "evidence", &prefix, errors);
|
|
191
|
+
|
|
192
|
+
if !object
|
|
193
|
+
.get("cost")
|
|
194
|
+
.and_then(Value::as_str)
|
|
195
|
+
.is_some_and(|cost| ALLOWED_COST.contains(&cost))
|
|
196
|
+
{
|
|
197
|
+
errors.push(format!(
|
|
198
|
+
"{prefix}.cost must be one of: {}.",
|
|
199
|
+
ALLOWED_COST.join(", ")
|
|
200
|
+
));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
match object.get("lastVerified") {
|
|
204
|
+
Some(last_verified)
|
|
205
|
+
if last_verified.is_null()
|
|
206
|
+
|| last_verified
|
|
207
|
+
.as_str()
|
|
208
|
+
.is_some_and(|value| is_iso_date(value)) => {}
|
|
209
|
+
_ => errors.push(format!("{prefix}.lastVerified must be YYYY-MM-DD or null.")),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
check_ids
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
fn validate_change_types(
|
|
217
|
+
change_types: &[Value],
|
|
218
|
+
check_ids: &HashSet<String>,
|
|
219
|
+
errors: &mut Vec<String>,
|
|
220
|
+
) {
|
|
221
|
+
for (index, change_type) in change_types.iter().enumerate() {
|
|
222
|
+
let prefix = format!("changeTypes[{index}]");
|
|
223
|
+
let Some(object) = change_type.as_object() else {
|
|
224
|
+
errors.push(format!("{prefix} must be an object."));
|
|
225
|
+
continue;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if !object.get("id").and_then(Value::as_str).is_some_and(is_id) {
|
|
229
|
+
errors.push(format!("{prefix}.id must be kebab-case lowercase."));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
require_string(object, "description", &prefix, errors);
|
|
233
|
+
require_string_array(object, "paths", &prefix, errors);
|
|
234
|
+
validate_check_reference_array(object, "requiredChecks", &prefix, check_ids, errors, true);
|
|
235
|
+
validate_check_reference_array(
|
|
236
|
+
object,
|
|
237
|
+
"recommendedChecks",
|
|
238
|
+
&prefix,
|
|
239
|
+
check_ids,
|
|
240
|
+
errors,
|
|
241
|
+
false,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
if !object.get("humanReview").is_some_and(Value::is_boolean) {
|
|
245
|
+
errors.push(format!("{prefix}.humanReview must be boolean."));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn validate_release_gates(
|
|
251
|
+
release_gates: &[Value],
|
|
252
|
+
check_ids: &HashSet<String>,
|
|
253
|
+
errors: &mut Vec<String>,
|
|
254
|
+
) {
|
|
255
|
+
for (index, release_gate) in release_gates.iter().enumerate() {
|
|
256
|
+
let prefix = format!("releaseGates[{index}]");
|
|
257
|
+
let Some(object) = release_gate.as_object() else {
|
|
258
|
+
errors.push(format!(
|
|
259
|
+
"{prefix} must be an object with checkId and requiredWhen."
|
|
260
|
+
));
|
|
261
|
+
continue;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
require_string(object, "checkId", &prefix, errors);
|
|
265
|
+
require_string(object, "requiredWhen", &prefix, errors);
|
|
266
|
+
|
|
267
|
+
if let Some(check_id) = object.get("checkId").and_then(Value::as_str) {
|
|
268
|
+
if !check_ids.contains(check_id) {
|
|
269
|
+
errors.push(format!("{prefix}.checkId unknown check id: {check_id}"));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
fn validate_check_reference_array(
|
|
276
|
+
object: &serde_json::Map<String, Value>,
|
|
277
|
+
field: &str,
|
|
278
|
+
prefix: &str,
|
|
279
|
+
check_ids: &HashSet<String>,
|
|
280
|
+
errors: &mut Vec<String>,
|
|
281
|
+
require_non_empty: bool,
|
|
282
|
+
) {
|
|
283
|
+
let Some(values) = object.get(field).and_then(Value::as_array) else {
|
|
284
|
+
let qualifier = if require_non_empty { "non-empty " } else { "" };
|
|
285
|
+
errors.push(format!(
|
|
286
|
+
"{prefix}.{field} must be a {qualifier}string array."
|
|
287
|
+
));
|
|
288
|
+
return;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
if require_non_empty && values.is_empty() {
|
|
292
|
+
errors.push(format!(
|
|
293
|
+
"{prefix}.{field} must be a non-empty string array."
|
|
294
|
+
));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for value in values {
|
|
298
|
+
let Some(check_id) = value.as_str().filter(|value| !value.trim().is_empty()) else {
|
|
299
|
+
errors.push(format!("{prefix}.{field} must be a string array."));
|
|
300
|
+
continue;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
if !check_ids.contains(check_id) {
|
|
304
|
+
errors.push(format!("{prefix}.{field} unknown check id: {check_id}"));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
fn require_string(
|
|
310
|
+
object: &serde_json::Map<String, Value>,
|
|
311
|
+
field: &str,
|
|
312
|
+
prefix: &str,
|
|
313
|
+
errors: &mut Vec<String>,
|
|
314
|
+
) {
|
|
315
|
+
if !object
|
|
316
|
+
.get(field)
|
|
317
|
+
.and_then(Value::as_str)
|
|
318
|
+
.is_some_and(|value| !value.trim().is_empty())
|
|
319
|
+
{
|
|
320
|
+
errors.push(format!("{prefix}.{field} must be a non-empty string."));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
fn require_string_array(
|
|
325
|
+
object: &serde_json::Map<String, Value>,
|
|
326
|
+
field: &str,
|
|
327
|
+
prefix: &str,
|
|
328
|
+
errors: &mut Vec<String>,
|
|
329
|
+
) {
|
|
330
|
+
let Some(values) = object.get(field).and_then(Value::as_array) else {
|
|
331
|
+
errors.push(format!(
|
|
332
|
+
"{prefix}.{field} must be a non-empty string array."
|
|
333
|
+
));
|
|
334
|
+
return;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if values.is_empty()
|
|
338
|
+
|| values
|
|
339
|
+
.iter()
|
|
340
|
+
.any(|value| !value.as_str().is_some_and(|entry| !entry.trim().is_empty()))
|
|
341
|
+
{
|
|
342
|
+
errors.push(format!(
|
|
343
|
+
"{prefix}.{field} must be a non-empty string array."
|
|
344
|
+
));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
fn require_string_array_allow_empty(
|
|
349
|
+
object: &serde_json::Map<String, Value>,
|
|
350
|
+
field: &str,
|
|
351
|
+
prefix: &str,
|
|
352
|
+
errors: &mut Vec<String>,
|
|
353
|
+
) {
|
|
354
|
+
let Some(values) = object.get(field).and_then(Value::as_array) else {
|
|
355
|
+
errors.push(format!("{prefix}.{field} must be a string array."));
|
|
356
|
+
return;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
if values
|
|
360
|
+
.iter()
|
|
361
|
+
.any(|value| !value.as_str().is_some_and(|entry| !entry.trim().is_empty()))
|
|
362
|
+
{
|
|
363
|
+
errors.push(format!("{prefix}.{field} must be a string array."));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
fn is_id(value: &str) -> bool {
|
|
368
|
+
let mut chars = value.chars();
|
|
369
|
+
let Some(first) = chars.next() else {
|
|
370
|
+
return false;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
value
|
|
378
|
+
.chars()
|
|
379
|
+
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
fn is_iso_date(value: &str) -> bool {
|
|
383
|
+
let bytes = value.as_bytes();
|
|
384
|
+
bytes.len() == 10
|
|
385
|
+
&& bytes[4] == b'-'
|
|
386
|
+
&& bytes[7] == b'-'
|
|
387
|
+
&& bytes
|
|
388
|
+
.iter()
|
|
389
|
+
.enumerate()
|
|
390
|
+
.filter(|(index, _)| *index != 4 && *index != 7)
|
|
391
|
+
.all(|(_, byte)| byte.is_ascii_digit())
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
fn contains_example_placeholders(value: &Value) -> bool {
|
|
395
|
+
match value {
|
|
396
|
+
Value::String(text) => {
|
|
397
|
+
let normalized = text.to_lowercase();
|
|
398
|
+
normalized.contains("example-")
|
|
399
|
+
|| normalized.contains("replace with")
|
|
400
|
+
|| normalized.contains("replace/**")
|
|
401
|
+
}
|
|
402
|
+
Value::Array(values) => values.iter().any(contains_example_placeholders),
|
|
403
|
+
Value::Object(values) => values.values().any(contains_example_placeholders),
|
|
404
|
+
_ => false,
|
|
405
|
+
}
|
|
406
|
+
}
|