@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.
Files changed (49) hide show
  1. package/Cargo.lock +199 -0
  2. package/Cargo.toml +11 -0
  3. package/LICENSE +21 -0
  4. package/README.md +6 -0
  5. package/bin/naome-node.js +1424 -0
  6. package/bin/naome.js +129 -0
  7. package/crates/naome-cli/Cargo.toml +14 -0
  8. package/crates/naome-cli/src/main.rs +341 -0
  9. package/crates/naome-core/Cargo.toml +11 -0
  10. package/crates/naome-core/src/decision.rs +432 -0
  11. package/crates/naome-core/src/git.rs +70 -0
  12. package/crates/naome-core/src/harness_health.rs +557 -0
  13. package/crates/naome-core/src/install_plan.rs +82 -0
  14. package/crates/naome-core/src/lib.rs +17 -0
  15. package/crates/naome-core/src/models.rs +99 -0
  16. package/crates/naome-core/src/paths.rs +72 -0
  17. package/crates/naome-core/src/task_state.rs +1859 -0
  18. package/crates/naome-core/src/verification.rs +217 -0
  19. package/crates/naome-core/src/verification_contract.rs +406 -0
  20. package/crates/naome-core/tests/decision.rs +297 -0
  21. package/crates/naome-core/tests/harness_health.rs +232 -0
  22. package/crates/naome-core/tests/install_plan.rs +35 -0
  23. package/crates/naome-core/tests/task_state.rs +588 -0
  24. package/crates/naome-core/tests/verification.rs +165 -0
  25. package/crates/naome-core/tests/verification_contract.rs +181 -0
  26. package/native/darwin-arm64/naome +0 -0
  27. package/package.json +44 -0
  28. package/templates/naome-root/.naome/bin/check-harness-health.js +163 -0
  29. package/templates/naome-root/.naome/bin/check-task-state.js +180 -0
  30. package/templates/naome-root/.naome/bin/naome.js +306 -0
  31. package/templates/naome-root/.naome/init-state.json +13 -0
  32. package/templates/naome-root/.naome/manifest.json +45 -0
  33. package/templates/naome-root/.naome/package.json +3 -0
  34. package/templates/naome-root/.naome/task-contract.schema.json +174 -0
  35. package/templates/naome-root/.naome/task-state.json +8 -0
  36. package/templates/naome-root/.naome/upgrade-state.json +7 -0
  37. package/templates/naome-root/.naome/verification.json +45 -0
  38. package/templates/naome-root/.naomeignore +4 -0
  39. package/templates/naome-root/AGENTS.md +77 -0
  40. package/templates/naome-root/docs/naome/agent-workflow.md +82 -0
  41. package/templates/naome-root/docs/naome/architecture.md +37 -0
  42. package/templates/naome-root/docs/naome/decisions.md +18 -0
  43. package/templates/naome-root/docs/naome/execution.md +192 -0
  44. package/templates/naome-root/docs/naome/first-run.md +135 -0
  45. package/templates/naome-root/docs/naome/index.md +67 -0
  46. package/templates/naome-root/docs/naome/repo-profile.md +51 -0
  47. package/templates/naome-root/docs/naome/security.md +60 -0
  48. package/templates/naome-root/docs/naome/testing.md +51 -0
  49. 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
+ }